SQL 注入的关键不是背 Payload:从一个单引号,到真正看懂 SQL 注入
SQL 注入的关键不是背 Payload:从一个单引号,到真正看懂 SQL 注入
写给刚接触 Web 安全、会用 Burp Suite,但对 SQL 注入分类仍感到混乱的读者
本文不是 Payload 大全,而是一套能迁移到真实问题的判断方法:弄清输入处于什么 SQL 上下文、可用什么技术验证、观察到的现象究竟能证明什么。
写在前面:为什么学过 SQL 注入,还是不会判断注入点?
假设你在浏览器里看到一个接口:
GET /api/product?id=5 HTTP/1.1
Host: lab.example
你想判断 id 是否有 SQL 注入,于是先尝试:
5'
5 AND 1=1
5 AND 1=2
有时网页报错,有时返回空数据,有时所有请求都一模一样。换一个目标,又会发现此前有效的输入完全没效果。
很多初学者把这种结果解释成:“可能得换一个更高级的 Payload。”
但另一个解释往往更重要:我们并不知道这个参数在 SQL 里究竟长什么样。
它可能是:
-- 情况 A:数字表达式
SELECT * FROM products WHERE id = 5;
-- 情况 B:字符串表达式
SELECT * FROM products WHERE id = '5';
-- 情况 C:数据已经被安全地参数化
SELECT * FROM products WHERE id = ?;
-- 情况 D:这个参数根本没有进入 SQL
-- 程序用它查询缓存,或在进入数据库前把它转换成整数
同一个 HTTP 参数,不代表同一种 SQL 上下文。
这就是理解 SQL 注入时的第一个转折点。第二个转折点更容易被忽略:
“数字型、字符型”与“报错型、布尔盲注”,根本不在同一个分类维度上。
前者关心输入进入 SQL 后处在什么语法位置;后者关心我们通过什么方法制造和观察可验证的执行差异。
本文会反复用具体参数、完整的 SQL 拼接结果、假设的 HTTP 响应来拆开这些概念。看懂每一步,比记住几十条字符串有用得多。
阅读边界:文中测试仅用于自己搭建的环境或获得明确授权的靶场。对于生产系统,尤其是写入接口、时间延迟、堆叠查询和带外交互,不能把教学示例直接当作无风险测试方案。
第一部分:先把底层逻辑讲明白
01. SQL 注入不是“数据库识别了恶意字符”
数据库正常的工作是执行应用发来的 SQL。问题发生在应用构造查询时,把原本应该作为数据的用户输入,错误地当成了 SQL 语法的一部分。
想象这样一段 Python 伪代码:
user_input = request.args["id"]
sql = "SELECT * FROM products WHERE id = " + user_input
用户输入 5,最终语句是:
SELECT * FROM products WHERE id = 5;
用户输入 5 AND 1=2,最终语句可能变成:
SELECT * FROM products WHERE id = 5 AND 1=2;
AND 1=2 已经不再是普通商品编号,而变成了 SQL 条件。因为 1=2 恒为假,这条查询没有匹配的商品记录。
但要特别留意:“可能变成”不等于“我们已经知道它确实变成了”。 黑盒测试者只能看到 HTTP 请求和响应,不能凭空断定后端代码是字符串拼接。
1.1 参数化查询为什么能防止这类问题?
安全构造方式应该把 SQL 模板和数据值分开交给数据库驱动:
# 用 SQLite 风格占位符作说明;不同驱动的占位符写法不同
cursor.execute(
"SELECT * FROM products WHERE id = ?",
(user_input,)
)
即使 user_input 是字符串 "5 AND 1=2",它仍然是一个参数值,不会被当作 AND 运算符拼进查询结构。可能出现类型不匹配、无匹配记录或参数校验错误,但那不是 SQL 条件被篡改。
因此,请先记住一句话:
SQL 注入的本质不是有没有特殊字符,而是“不可信数据能否越界成为 SQL 语法”。
这种问题并不只发生在 MySQL。MySQL、PostgreSQL、SQL Server、Oracle、SQLite 等支持 SQL 的数据库,都可能被应用的不安全拼接方式暴露出注入风险。MongoDB 等非 SQL 数据库也可能存在查询注入,但通常归入 NoSQL 注入,而非传统 SQL 注入。
02. 最容易混淆的两件事:输入上下文与注入技术
把 SQL 注入想象成一道需要两个坐标才能定位的题。
坐标一:输入上下文(Context)——我的输入在哪里?
例如:
- 数字表达式:
WHERE id = 5 - 字符串表达式:
WHERE name = 'Apple' - 模糊匹配:
WHERE name LIKE '%Apple%' - 排序表达式:
ORDER BY price - 分页参数:
LIMIT 10 OFFSET 0 - SQL 标识符:动态指定列名、表名
上下文决定你输入的内容能否组成合法的 SQL 结构。
坐标二:注入技术(Technique)——怎么证明我影响了 SQL?
例如:
- 布尔盲注:让条件为真和为假,观察结果差异。
- 报错型注入:利用数据库错误作为证据或信息通道。
- UNION 注入:把额外查询结果合并到原结果集中。
- 时间盲注:利用条件引起的可重复延迟观察执行情况。
- 带外注入:通过应用响应以外的通道观察数据库行为。
技术决定你用什么方式获得执行证据。
还有第三个角度不可缺少:
坐标三:观测证据(Evidence)——服务器究竟发生了什么?
可能是 HTTP 内容、状态码、商品排列顺序、SQL 错误、响应时间,或者受控环境里的数据库日志。
这三个维度彼此相关,但不是同一件事。
2.1 一张让新手不再混淆的对照表
| 常听到的说法 | 实际回答的问题 | 它属于什么 |
|---|---|---|
| 数字型注入 | 输入可能处于什么表达式? | 输入上下文 |
| 字符型注入 | 输入是否位于引号包围的字符串中? | 输入上下文 |
| ORDER BY 注入 | 输入在 SQL 的哪一部分? | 语句位置/结构上下文 |
| 布尔盲注 | 如何用真假条件观察差异? | 验证技术 |
| 报错型注入 | 如何利用数据库错误观察执行? | 验证技术 |
| UNION 注入 | 能否将额外查询结果合并回显? | 验证技术 |
| 堆叠查询 | 能否在原语句之外执行额外语句? | 多语句执行能力 |
| 二次注入 | 输入是否先被保存、后续才触发? | 数据流和触发阶段 |
因此,下面这些组合完全成立:
- 数字型 WHERE 上下文 + 布尔盲注
- 字符串型 WHERE 上下文 + 布尔盲注
- 数字型 WHERE 上下文 + 报错型注入
- 字符串型 WHERE 上下文 + UNION 注入
- ORDER BY 上下文 + 条件排序差异验证
错误记忆法:“数字型是一种技术,布尔型是另一种技术。”
正确记忆法:“数字型描述语法边界,布尔型描述验证方式。”
第二部分:用同一类接口,看懂数字型与字符型
03. 数字型上下文:为什么 AND 1=1 有时有效?
假设有一个故意使用字符串拼接的测试靶场:
GET /api/product?id=5 HTTP/1.1
Host: lab.example
后端构造的 SQL:
sql = "SELECT id, name FROM products WHERE id = " + user_input
3.1 正常请求
| 位置 | 内容 |
|---|---|
| URL 参数 | id=5 |
| 用户输入部分 | 5 |
| 最终 SQL | SELECT id, name FROM products WHERE id = 5; |
| 假设响应 | 返回 ID 为 5 的商品 |
3.2 试一个恒真条件
GET /api/product?id=5%20AND%201%3D1 HTTP/1.1
Host: lab.example
URL 解码后的 id 值是:
5 AND 1=1
后端若直接拼接,则变成:
SELECT id, name FROM products WHERE id = 5 AND 1=1;
1=1 恒为真,最终仍等价于查找 ID 为 5 的商品。
3.3 再试一个恒假条件
5 AND 1=2
最终 SQL:
SELECT id, name FROM products WHERE id = 5 AND 1=2;
1=2 恒为假,因此通常返回空记录。
这里的关键并不是“看见 AND 就叫布尔注入”,而是:输入位于可解析的数字表达式中,AND 才有机会成为数据库执行的逻辑运算符。
3.4 这一组实验能证明多少?
| 请求 | 假设响应 | 你能得到的结论 |
|---|---|---|
id=5 |
返回商品 | 正常基线 |
id=999999 |
空数据 | 不存在 ID 的负向对照 |
id=5 AND 1=1 |
返回商品 | 符合恒真条件预期 |
id=5 AND 1=2 |
空数据 | 符合恒假条件预期 |
这说明 SQL 注入嫌疑较高,但仍需要交错重复请求,并排除应用侧特意处理异常参数、缓存与 WAF 等其他原因。黑盒观察得到的是行为证据,不是数据库真实 SQL 的录像。
04. 字符串上下文:为什么同样的 AND 1=1 可能根本没有被执行?
换一个接口:
GET /api/product?name=Apple HTTP/1.1
Host: lab.example
后端代码:
sql = "SELECT * FROM products WHERE name = '" + user_input + "'"
正常 SQL:
SELECT * FROM products WHERE name = 'Apple';
注意:这里的用户输入在两个单引号之间。
4.1 新手常犯的错误
直接输入:
Apple AND 1=1
实际拼接为:
SELECT * FROM products WHERE name = 'Apple AND 1=1';
这里的 AND 1=1 只是商品名称的一部分。 数据库不会把字符串内部的文字当作新的逻辑条件。
如果没返回数据,原因可能只是根本没有商品叫 Apple AND 1=1,而不是“恒真条件失效”。
4.2 正确理解“字符串边界”
要让布尔条件参与 SQL 解析,必须先考虑原本的单引号边界。在前述特定拼接模板下,可用于教学的恒真与恒假对照是:
恒真:Apple' AND '1'='1
恒假:Apple' AND '1'='2
看清楚拼接后的完整 SQL:
-- 恒真
SELECT * FROM products WHERE name = 'Apple' AND '1'='1';
-- 恒假
SELECT * FROM products WHERE name = 'Apple' AND '1'='2';
为什么这次有效?
Apple'里的单引号结束了最初的商品名称字符串。- 接下来的
AND才可能成为 SQL 语法。 '1'='1与代码自动补上的结尾单引号一起组成完整的比较表达式。- 通过真和假的对照,可以测试输入是否影响数据库的 WHERE 判断。
但这段写法只适用于我们假设的 SQL 结构。如果真实查询多了一层括号、LIKE 通配符或其他条件,照搬可能报错或产生不同语义。
4.3 数字型与字符型,终于可以并排看了
| 比较点 | 数字表达式上下文 | 字符串表达式上下文 |
|---|---|---|
| 后端 SQL 模板 | WHERE id = <输入> |
WHERE name = '<输入>' |
| 正常输入 | 5 |
Apple |
直接加 AND 1=1 |
有机会成为 SQL 条件 | 通常仍在字符串内 |
| 示例恒真输入 | 5 AND 1=1 |
Apple' AND '1'='1 |
| 示例恒假输入 | 5 AND 1=2 |
Apple' AND '1'='2 |
| 使用的验证技术 | 布尔条件探测 | 同样是布尔条件探测 |
| 真正的差别 | 不需要先结束字符串 | 必须考虑引号和语法边界 |
到这里应该能看出:两边的技术是同一类,变的是注入上下文。
4.4 一个比 Payload 更重要的反例
下面这个请求:
GET /api/product?id=5
看上去明明是数字 5,但后端也可能这样写:
SELECT * FROM products WHERE id = '5';
所以HTTP 参数是不是数字,不足以判定是否属于“数字型 SQL 注入”。 你必须推断输入在最终 SQL 里的上下文。
第三部分:SQL 注入上下文远不止数字和字符串
05. 如何理解“其他 SQL 上下文”?
数字型、字符型不是一份完整且互斥的分类表。 它们通常描述输入所在的表达式或字面量形式;而 WHERE、ORDER BY、LIMIT 描述 SQL 语句中的位置。两个概念还能互相叠加。
例如:
SELECT * FROM products
WHERE category = 'Phone' -- WHERE 中的字符串表达式
ORDER BY price -- ORDER BY 中的排序表达式
LIMIT 10; -- 分页中的数字表达式
把这些全部叫“字符型”或“数字型”,会丢掉重要的 SQL 结构信息。初学阶段可以记住下面这张地图:
| SQL 位置或结构 | 常见 HTTP 参数 | SQL 模板(故意简化) | 首先关注什么? |
|---|---|---|---|
WHERE 数值条件 |
id=5 |
WHERE id = 5 |
数字表达式与真假条件 |
WHERE 字符串条件 |
name=Apple |
WHERE name = 'Apple' |
引号边界与真假条件 |
LIKE |
keyword=Apple |
WHERE name LIKE '%Apple%' |
通配符、引号、匹配范围 |
IN (...) |
ids=1,2,3 |
WHERE id IN (1,2,3) |
列表、逗号、括号 |
ORDER BY |
sort=price |
ORDER BY price |
排序表达式及结果顺序 |
LIMIT/OFFSET |
limit=10&page=2 |
LIMIT 10 OFFSET 10 |
分页语法、结果数量 |
GROUP BY / HAVING |
group=category |
GROUP BY category |
分组键、聚合条件 |
SELECT 表达式 |
field=price |
SELECT price FROM ... |
返回列和表达式 |
| 表名、列名等标识符 | table=orders |
FROM orders |
动态结构与白名单 |
INSERT / UPDATE / DELETE |
note=... |
UPDATE ... SET note='...' |
副作用、数据完整性 |
不要把上表理解成“凡是有这些参数就一定有注入”。这些只是帮助你推测语句结构的业务线索。真实后端可能根本没有按参数拼接 SQL。
5.1 LIKE:它仍是字符串,但语法边界又变了
搜索框经常使用模糊匹配:
GET /api/products?keyword=Apple
假设后端构造:
sql = "SELECT * FROM products WHERE name LIKE '%" + keyword + "%'"
正常 SQL 是:
SELECT * FROM products WHERE name LIKE '%Apple%';
此时用户输入前后各多了一个 % 通配符。如果照搬上一章的:
Apple' AND '1'='1
拼接结果会是:
SELECT * FROM products WHERE name LIKE '%Apple' AND '1'='1%';
注意最后成了 '1'='1%',并不是你想象的恒真比较。
这正好说明:在黑盒场景中,单凭“这是字符串参数”,不足以保证 Payload 可以直接复用;还要判断周围是否有通配符、括号、函数和其他自动追加的 SQL 片段。
另外,keyword=App 与 keyword=Apple 返回不同商品,是模糊搜索的正常行为,本身不是 SQL 注入证据。
5.2 IN 与括号:逗号和括号也属于上下文
例如后台批量查看商品:
GET /api/products?ids=1,2,3
如果不安全地直接拼接:
SELECT * FROM products WHERE id IN (1,2,3);
这是一个列表表达式。你需要留意:
- 输入是一个值,还是多个值?
- 列表由后端安全展开参数,还是把整个字符串塞进
IN (...)? - 已经存在多少层括号?
- 这个参数输入的是数字 ID,还是被引号包围的字符串 ID?
同样,URL 中出现逗号,并不能证明它直达 SQL;后端完全可以先按逗号拆分、逐项转换成整数,再参数化查询。
5.3 ORDER BY:为什么排序注入不能照搬 WHERE 的思路?
考虑一个商品列表:
GET /api/products?sort=price
如果后端直接拼接:
sql = "SELECT id, name, price FROM products ORDER BY " + sort
sort=price 对应:
SELECT id, name, price FROM products ORDER BY price;
此时你的输入控制的是排序表达式,不是 WHERE 中的过滤条件。
在 WHERE 场景中,我们通常观察“商品是否返回”。而在 ORDER BY 场景中,更有价值的观察可能是“返回的是相同商品,但顺序是否改变”。
假设靶场数据库是:
| id | 商品 | price |
|---|---|---|
| 1 | A | 80 |
| 2 | B | 10 |
| 3 | C | 30 |
按 id 排序:A、B、C。按 price 排序:B、C、A。
在允许条件排序表达式的数据库、且类型兼容的前提下,可以研究:
SELECT id, name, price FROM products
ORDER BY CASE WHEN 1=1 THEN price ELSE id END;
SELECT id, name, price FROM products
ORDER BY CASE WHEN 1=2 THEN price ELSE id END;
第一条按 price 排序,第二条按 id 排序。两条结果可能包含完全相同的行,但顺序不同。
这揭示了一个重要道理:布尔条件本身并不限定只能观察“有数据/没数据”,还可能映射为排序差异等其他可观察现象。
不过,一个正常设计的系统本来就允许 sort=price、sort=id,不能因为排序变化就断定 SQL 注入。必须证明的是:用户可以越过预设排序选项,让输入作为未授权的 SQL 表达式参与执行。
5.4 LIMIT / OFFSET:控制的是“返回多少行”,而不是“是否满足 WHERE”
例如:
GET /api/products?limit=10&offset=20
MySQL 和 PostgreSQL 常见语句:
SELECT * FROM products LIMIT 10 OFFSET 20;
SQL Server 等系统有不同的分页语法和要求。对于这种位置,简单附加 AND 1=1 往往不是正确的语法思路。
首先应该理解:程序是否把 limit、offset 严格转换为数字,是否限制最大值,以及数据库最终使用何种分页语法。分页结果有差异是正常功能,不是自动等于 SQL 注入。
5.5 SELECT 表达式、列名和表名:参数化不是万能的
一些报表允许用户自选列:
GET /api/report?field=price
不安全的动态 SQL 可能是:
sql = "SELECT " + field + " FROM products"
这里的 field 控制的是 SQL 结构。动态列名和表名通常不能像普通值一样直接用 WHERE id = ? 的参数占位方式替代,而应通过严格允许列表等机制选择安全的、预定义的标识符。
例如:
allowed_fields = {"name": "name", "price": "price", "stock": "stock"}
column = allowed_fields.get(field)
if column is None:
raise ValueError("Unsupported field")
sql = f"SELECT {column} FROM products"
这个例子不是说任何 f-string 都不安全,而是说进入 f-string 的 SQL 结构只能来自受信任的允许列表映射,不能直接采用未经约束的外部输入。
5.6 GROUP BY / HAVING:从“行”切换到“组”
SELECT category, COUNT(*)
FROM products
GROUP BY category
HAVING COUNT(*) > 1;
这里 GROUP BY 决定如何聚合数据;HAVING 对聚合后的组筛选。即便都存在布尔条件,其结果可能表现为“某一类商品的统计行消失”,而不是单个商品是否返回。
5.7 INSERT / UPDATE / DELETE:上下文存在,但测试要格外谨慎
UPDATE users SET nickname = 'Tom' WHERE id = 5;
这里既有字符串值,也有数字条件,但执行的是写操作。即使你只是尝试一个看来无害的特殊字符,某些 SQL 结构也可能改变写入范围或触发事务副作用。
在生产系统里不要直接照搬只读查询的真假测试。更适合的方法是代码审查、数据库审计日志、隔离环境中的测试数据,或者经过明确审批的低影响验证。
5.8 小结:从“参数名”到“SQL 上下文”,中间隔着很多未知
id 不一定是数字上下文;sort 不一定真的进入 ORDER BY;keyword 也不一定就是 SQL 的 LIKE。业务功能只帮你形成假设,后续还需要设计实验验证。
第四部分:注入技术有哪些?它们到底在观察什么?
06. 第一件事:别把“单引号报错”直接当作报错型注入已经成立
在靶场中,最常见的初步输入是:
5'
可能出现:
HTTP/1.1 500 Internal Server Error
SQL syntax error ...
这叫语法异常探测:通过让输入包含特殊字符,观察查询是否发生异常。
但有两层推理不能跳过:
- HTTP 500 并不一定来自 SQL。也可能是类型转换、后端业务异常或其他组件抛出的错误。
- 即使回显了 SQL 错误,也还需要确认输入是否真的能改变 SQL 结构,而不仅仅是某个非法值触发了数据库拒绝处理。
严格来说,经典的 Error-based SQL Injection(报错型注入) 更强调利用数据库错误信息作为可控的信息通道,而不只是“我加个单引号看见报错”。
可以把它们理解成:
| 阶段 | 目标 | 典型证据 |
|---|---|---|
| 语法异常探测 | 找到 SQL 相关异常线索 | SQL 语法错误、转换错误 |
| 报错型验证 | 证明可控输入改变数据库执行并影响错误表现 | 可重复、可解释的数据库错误差异 |
单引号是线索,不是判决书。
07. 布尔盲注:用真和假设计可复现的对照实验
所谓 Boolean-based Blind SQL Injection,“盲”的意思通常是:数据库并不把需要观察的内部信息直接显示给你,你通过条件真假引起的间接变化推断执行情况。
最常见的教学对照是:
5 AND 1=1
5 AND 1=2
注意,布尔盲注的关键不在于必须包含 AND,而在于设计一对预期结果不同、且能排除业务干扰的条件。
在不同上下文中,真假的表现也不同:
| 上下文 | 可能的真假观察结果 |
|---|---|
WHERE 过滤 |
返回商品 / 返回空数据 |
| 登录验证 | 登录成功 / 登录失败(还须排除认证业务) |
ORDER BY 条件排序 |
商品排列顺序 A / 排列顺序 B |
HAVING 聚合过滤 |
统计分组存在 / 分组消失 |
| 通用业务逻辑 | 响应体、状态、长度等可重复差异 |
有时候状态码一直是 200,布尔对照仍然有意义,因为 200 只是 HTTP 请求被应用成功处理,不意味着 SQL 条件为真。
08. UNION 注入:关注的是“额外结果能不能进入原查询”
例如在本地测试库中:
SELECT name, price FROM products WHERE id = 5
UNION
SELECT 'probe', 1;
如果原查询会把结果返回给客户端,而且两侧 SELECT 列数相等、数据类型兼容,额外一行 probe / 1 就可能进入结果集。
它和布尔盲注的区别非常直观:
- 布尔盲注:我不一定看到数据库内部值,但能观察真假条件造成的变化。
- UNION 注入:我希望额外查询结果本身进入响应。
不过,UNION 不是万能的:
- 当前注入位置要能构成合法的
UNION查询结构。 - 原查询的 SELECT 列数与类型必须兼容。
- 应用必须展示可观察的结果,或以其他方式处理这些行。
- Oracle、SQL Server 等数据库在部分具体语法和类型规则上有差异。
因此,ORDER BY、LIMIT 等位置不是简单追加一段 UNION 就一定能成功。
09. 时间盲注:响应没有区别,就只能看时间吗?
不一定。“没有响应差异”首先可能意味着你猜错了上下文、参数没进入 SQL、数据被缓存、被过滤,或根本不存在 SQL 注入。
只有当你已经有合理线索认为 SQL 可以执行某种条件操作,且实验授权允许时,才进一步研究时间型验证。
例如数据库本身可能支持不同的延迟机制:
-- PostgreSQL:原生延迟函数示意
SELECT pg_sleep(1);
-- SQL Server:原生延迟语句示意
WAITFOR DELAY '00:00:01';
这两段只是数据库功能示例,并不是能直接放到任意 HTTP 参数里的通用 SQL 注入 Payload。
真正的时间盲注需要:
- SQL 语法上下文允许触发延迟。
- 有一组延迟与不延迟的对照。
- 多次交错测量,排除链路抖动、连接复用、后端队列和数据库负载。
- 严格限制请求次数和影响,避免给生产系统制造不必要的负载。
单次请求慢了两秒,不足以判断 SQL 注入。
10. 带外注入(OOB):结果不一定出现在 HTTP 响应里
某些数据库功能、配置或权限条件下,数据库执行结果可能通过 DNS、HTTP 等应用响应之外的渠道被观察。
它叫 Out-of-band,重点在于观测通道发生了变化。实际能否使用,严重依赖数据库、网络出口、系统权限和运维配置,并不普遍。
这一类测试通常不适合初学者直接拿生产目标练习。先理解它为什么存在,等掌握 SQL 上下文、黑盒对照和数据库差异后再进入受控实验。
11. 堆叠查询:它不是“比布尔注入更高级的固定 Payload”
正常单条查询:
SELECT * FROM products WHERE id = 5;
在允许多语句执行的受控环境中,可能存在:
SELECT * FROM products WHERE id = 5;
SELECT 1;
这里的核心不是报错、布尔或 UNION,而是一次执行通道是否允许执行第二条独立 SQL 语句。
是否可行受以下因素共同约束:
- 注入位置能否合法结束前一条 SQL。
- 数据库和客户端驱动是否允许相应的多语句执行。
- 是否使用了禁止多语句的预编译接口或协议模式。
- 数据库账号拥有哪些权限。
- 第二条语句的行为能否在授权测试中安全观察。
数据库“认识分号”不等于 Web API “允许堆叠查询”。 特别是 MySQL,客户端多语句能力受到 API 和配置限制;PostgreSQL、SQL Server 也要结合具体执行接口判断;Oracle 的普通 SQL 上下文通常不能直接当作多语句脚本执行。
由于堆叠查询可能具有写入或破坏性的风险,它不应是未知生产接口的初始探测手段。
12. 二次注入:它不是布尔、报错、UNION 的平级替代品
二次 SQL 注入(Second-order SQL Injection)描述一种跨请求或跨功能的数据流:
用户提交输入
↓
系统先把内容存进数据库
↓
某个后台功能在以后读取这条内容
↓
将读出的文本不安全地拼接进另一条 SQL
↓
注入在第二个阶段触发
例如用户填写合法昵称:
{"nickname": "O'Reilly"}
第一次保存时,程序可能使用参数化语句:
cursor.execute(
"UPDATE users SET nickname = ? WHERE id = ?",
(nickname, user_id)
)
此时没有发生 SQL 注入。
但另一个后台报表功能将存储的昵称重新拼接进 SQL:
saved_nickname = load_nickname_from_db()
sql = "SELECT * FROM users WHERE nickname = '" + saved_nickname + "'"
就可能生成:
SELECT * FROM users WHERE nickname = 'O'Reilly';
这条示例可能导致语法错误,仅说明第二阶段存在危险的 SQL 构造方式,并不自动构成完整的漏洞利用证明。如果用户能控制被保存的数据,并能在后续阶段改变 SQL 语法与行为,那么才有二次注入的可验证风险。
第一次接口永远返回 {"success":true},不代表安全;有问题的地方可能是后续的搜索、报表、导出、任务调度或后台管理功能。
这类漏洞的主要难点不是 Payload 写得多复杂,而是要跨多个业务步骤追踪数据从哪里来、保存在哪里、最后怎样被用于 SQL。
12.1 堆叠查询和二次注入,究竟有什么关系?
| 维度 | 堆叠查询 | 二次 SQL 注入 |
|---|---|---|
| 核心问题 | 能否执行多条语句? | 何时、在哪里触发不安全查询? |
| 关注点 | SQL 执行能力 | 跨阶段的数据流 |
| 一定需要存储用户输入吗 | 不需要 | 通常涉及先存储、后使用 |
| 一定需要多语句支持吗 | 是其核心条件 | 不需要 |
| 可以同时出现吗 | 可以 | 可以 |
| 适合初学者的首轮探测吗 | 不推荐 | 先学习数据流分析,再做受控验证 |
一个二次注入点既可能使用布尔盲注方式被验证,也可能有报错表现;在特殊执行环境中,也可能涉及堆叠查询。它们不是互相排斥的漏洞“门派”。
第五部分:上下文与技术,究竟怎样对应?
13. 为什么不存在一张“数字型 = 布尔,字符型 = 报错”的标准配对表?
因为上下文负责限制 SQL 语法,技术负责设计执行与观测方式。两者是“可以组合,但有条件”的关系。
下表使用三种标记:
- 常用:此上下文通常比较容易设计该类验证,但仍要满足具体查询结构。
- 有条件:语法、数据库方言、驱动、权限或回显机制满足要求时才可能成立。
- 不宜直接套用:不能简单将经典 WHERE 语句后附加 Payload。
| 输入上下文 | 语法异常 / 报错 | 布尔条件差异 | UNION | 时间盲注 | 堆叠查询 |
|---|---|---|---|---|---|
数字型 WHERE |
常用 | 常用 | 有条件 | 有条件 | 有条件 |
字符串型 WHERE |
常用 | 常用 | 有条件 | 有条件 | 有条件 |
LIKE |
常用 | 常用,但需正确处理 % 和引号 |
有条件 | 有条件 | 有条件 |
IN / 括号 |
常用 | 常用,但需匹配括号层级 | 有条件 | 有条件 | 有条件 |
ORDER BY |
常用 | 可观察条件排序 | 不宜直接套用 | 有条件 | 有条件 |
LIMIT / OFFSET |
常用 | 不宜直接套用 WHERE 布尔式 | 位置相关 | 方言相关 | 有条件 |
| 动态列名 / 表名 | 可能 | 表达式能力相关 | 结构相关 | 有条件 | 有条件 |
GROUP BY / HAVING |
可能 | 可观察分组差异 | 有条件 | 有条件 | 有条件 |
INSERT / UPDATE / DELETE |
可能 | 可观察状态受限,需控制副作用 | 不应直接照搬 SELECT UNION | 有条件 | 有条件 |
这张表不是“每一格都有一个通用 Payload”的承诺。 实际是否成立,受以下因素影响:
- 输入最终处于什么 SQL 语法位置。
- 原始 SQL 在输入前后自动添加了哪些字符。
- 数据库支持哪些 SQL 函数和语法。
- 驱动或 ORM 如何执行这条查询。
- 应用是否把数据库结果或错误反映到 HTTP 响应中。
- 是否存在防护、中间件、缓存或业务校验。
13.1 三个相互对照的“案例卡”
案例 A:数字型 WHERE + 布尔探测
请求参数:id=5 AND 1=2
假设 SQL:SELECT * FROM products WHERE id = 5 AND 1=2;
期望观察:相比基线,匹配记录消失。
案例 B:字符型 WHERE + 布尔探测
请求参数:name=Apple' AND '1'='2
假设 SQL:SELECT * FROM products WHERE name = 'Apple' AND '1'='2';
期望观察:相比基线,匹配记录消失。
两个案例都用了布尔验证;A 和 B 的区别是上下文的引号边界。
案例 C:ORDER BY + 条件排序观察
请求参数:sort=(可控排序表达式)
假设 SQL:SELECT id, price FROM products ORDER BY <输入>;
期望观察:相同记录按不同条件呈现不同顺序。
C 改变的不仅是 Payload,还改变了证据的形态。这个迁移能力是初学者真正需要获得的东西。
第六部分:不知道数据库是什么,黑盒测试究竟从哪里开始?
14. 新手的第二个误区:不先认出 MySQL,就无法验证 SQL 注入?
并非如此。
在主流 SQL 数据库中,很多基础逻辑语法是共通的:AND、OR、=、CASE WHEN、基本的 SELECT 和 WHERE。所以你通常可以先尝试符合上下文的基础条件对照,而不必立即确定数据库品牌。
但 SQL 方言也确实存在差异:
| 数据库 | 常见辨识线索 | 示例原生版本信息表达式 |
|---|---|---|
| MySQL / MariaDB | You have an error in your SQL syntax |
VERSION() |
| PostgreSQL | syntax error at or near ... |
version() |
| SQL Server | Unclosed quotation mark 等 |
@@VERSION |
| SQLite | near "...": syntax error、SQLITE_ERROR |
sqlite_version() |
| Oracle | ORA- 编号错误 |
v$version 视图(权限及查询语法相关) |
不要把这个表当成“错误文本出现一次就精确锁定数据库”的规则。框架、兼容数据库、代理层、统一错误页都可能隐藏或改变这些线索。
更不要把任意一个数据库版本函数直接塞进任意参数:函数本身是否存在,与表达式能否在当前 SQL 位置合法执行,是两个问题。
14.1 推荐顺序
第一步:弄清业务功能和参数含义
↓
第二步:建立正常基线与负向对照
↓
第三步:推测 SQL 上下文(WHERE?LIKE?ORDER BY?)
↓
第四步:使用适合上下文的低影响异常探测与真假对照
↓
第五步:重复验证,排除缓存、WAF、业务规则等原因
↓
第六步:如果需要,结合错误、代码、日志识别数据库方言
↓
第七步:使用数据库专用手法做授权范围内的进一步验证
先确定自己在问什么,再决定用什么语法。 这是比“先跑一大串指纹 Payload”更稳定的思路。
15. Burp Suite Repeater 完整案例:把每一个现象转换为证据
下面用一个教学靶场的只读商品详情接口做演示。响应全部是为了说明测试方法而假设的数据,不是某个真实网站的扫描结果。
第一步:捕获原始请求
在 Burp 的 Proxy → HTTP history 找到目标请求,右键 Send to Repeater。
GET /api/product?id=5 HTTP/1.1
Host: lab.example
Accept: application/json
点击 Send,先记录:
HTTP/1.1 200 OK
Content-Type: application/json
{"id":5,"name":"Apple","price":80}
这是基线 A,还没有任何关于 SQL 注入的结论。
第二步:设计负向对照
负向对照是很多初学者会跳过的一步。
请求 B:不存在的 ID
GET /api/product?id=999999 HTTP/1.1
Host: lab.example
假设返回:
{"data":null}
它告诉你:正常业务在找不到商品时也会返回空数据。后面恒假请求的空数据,必须与它比较。
请求 C:非法类型
GET /api/product?id=abc HTTP/1.1
Host: lab.example
假设返回:
HTTP/1.1 400 Bad Request
{"error":"id must be integer"}
这提示后端存在参数类型校验的可能,但不表示已经知道 SQL 语句怎样构造。
第三步:特殊字符探测
请求 D:
GET /api/product?id=5%27 HTTP/1.1
Host: lab.example
这里 %27 是 URL 编码的单引号 '。假设响应:
HTTP/1.1 500 Internal Server Error
{"error":"SQL syntax error near ..."}
这提供了SQL 相关异常线索,仍然不能独立确认你能控制 SQL 逻辑。
第四步:真假条件对照
请求 E(恒真):
GET /api/product?id=5%20AND%201%3D1 HTTP/1.1
Host: lab.example
假设返回:
{"id":5,"name":"Apple","price":80}
请求 F(恒假):
GET /api/product?id=5%20AND%201%3D2 HTTP/1.1
Host: lab.example
假设返回:
{"data":null}
现在整理六条记录:
| 编号 | 输入值 | 状态码 | 关键业务结果 | 用途 |
|---|---|---|---|---|
| A | 5 |
200 | 商品 Apple | 正常基线 |
| B | 999999 |
200 | 空数据 | 不存在值对照 |
| C | abc |
400 | 参数类型错误 | 输入校验线索 |
| D | 5' |
500 | SQL syntax error | 异常线索 |
| E | 5 AND 1=1 |
200 | 商品 Apple | 恒真条件 |
| F | 5 AND 1=2 |
200 | 空数据 | 恒假条件 |
这一组观察强烈符合输入影响 SQL 条件的模型,但还需要进一步证明 A、B、E、F 的关系不是偶然,也不是后端自定义的参数处理规则。
第五步:用 Comparer 和交错重复排除干扰
Repeater 可以将 Response 发送到 Comparer。重点对比:
- A 和 E:核心业务数据是否一致?
- B 和 F:空数据结构、业务码是否一致?
- D 的错误是否来自数据库,而不是泛用服务器错误页?
不要只看 Response Length:动态时间戳、随机 ID 和响应字段顺序都会干扰比较。
推荐交错重复,例如:
A → E → F → A → F → E
观察结果是否稳定。还应保持相同的登录态、请求方法、Content-Type、参数编码和其他业务条件。
如果拥有后端代码或数据库审计日志,就可以进一步确认是否真正发生了 SQL 拼接;如果没有,也要努力建立多个独立、可重复的黑盒证据,而非凭一次响应直接下结论。
第六步:谨慎描述证据等级
| 观察 | 更恰当的结论 |
|---|---|
| 单引号导致 HTTP 500 | 异常,尚不确认 SQL 注入 |
| 单引号导致 SQL syntax error | SQL 相关高价值线索 |
| 恒真与恒假第一次出现差异 | 符合注入模型,但需排除误报 |
| 交错重复、多组条件均符合数据库逻辑 | 高可信的黑盒注入证据 |
| 代码或查询日志确认不可信输入改变 SQL 语法 | 直接确认根因 |
并不是必须拿到源码才能确认漏洞;充分、独立、可重复的黑盒证据也可以确认 SQL 注入。这里强调的只是:每一步结论必须与证据强度匹配。
16. 三种最容易误判的结果
16.1 两个 Payload 都返回 403,能说明什么?
id=5 → 200,商品数据
id=5 AND 1=1 → 403,Access Denied
id=5 AND 1=2 → 403,Access Denied
不能说明不存在 SQL 注入,也不能证明存在 SQL 注入。 请求可能被 WAF 或应用级策略提前拦截,没有足够证据表明数据库执行了这些条件。
正确的记录方式是:“测试流量被拒绝,SQL 执行路径未确认。”
16.2 全部返回 200,能说明什么?
id=5 → 200
id=5 AND 1=1 → 200
id=5 AND 1=2 → 200
如果仅知道 HTTP 状态码,几乎没有回答 SQL 注入问题。需要观察响应中的商品列表、业务错误码、重定向、排序、字段内容等。
16.3 全部返回同一份 JSON,就意味着没有漏洞?
未必。几种可能解释:
- 参数根本没进入 SQL。
- SQL 使用了安全的参数化查询。
- 输入在进入数据库之前被归一化或过滤。
- 应用使用缓存,或回显与查询结果脱钩。
- 当前 Payload 不符合真实 SQL 语法上下文。
- 存在盲注,但当前选择的观测方式没有可见变化。
- 实际上不存在 SQL 注入。
因此,测试失败不等于安全证明;同样,不能因为存在这些可能性,就无依据地声称漏洞存在。
17. HTTP 参数本身也有“上下文”:GET、POST、JSON 不一定一样
初学者往往专注 Payload,忽略了请求传输与应用解析方式。
GET 查询参数
GET /api/product?id=5%20AND%201%3D1
HTTP URL 中的空格和特殊字符需要正确编码。
表单 POST
POST /api/search HTTP/1.1
Host: lab.example
Content-Type: application/x-www-form-urlencoded
keyword=Apple
表单编码、重复参数、字符集等会影响服务端拿到的实际值。
JSON 请求体
POST /api/product/query HTTP/1.1
Host: lab.example
Content-Type: application/json
{"id":5}
这里的 5 是 JSON 数字。如果你改成:
{"id":"5 AND 1=1"}
JSON 类型已经从数字变成字符串。某些后端会在 JSON 解析或类型校验阶段就拒绝它,不能因此直接推断 SQL 是否安全。
传输格式、程序参数类型与 SQL 输入上下文是三个不同层面。 这是很多自动化扫描和手工尝试失败的隐藏原因。
第七部分:从“记住知识点”到“有能力自己判断”
18. SQL 注入实战中到底什么最值得优先学?
谈“常见程度”时要谨慎:不同语言框架、行业、年代、数据库驱动、网站安全成熟度的分布差异很大。公开资料难以给出覆盖真实互联网 Web 应用的可靠比例,因此不要轻信“90% 注入都是某某类型”这类未经证实的数字。
可以区分两个问题:
- 学习优先级:哪些场景覆盖基本原理、最有利于形成判断能力?
- 真实发生率:哪些场景在某一范围的系统中出现得更多?
本文给出的是学习优先级,不是漏洞流行病学统计。
| 阶段 | 重点上下文 | 重点技术 | 核心练习目标 |
|---|---|---|---|
| 第一阶段 | 数字型、字符串型 WHERE |
异常探测、布尔对照 | 看懂拼接结果,避免把上下文和技术混淆 |
| 第二阶段 | LIKE、IN、括号 |
布尔差异、错误分析 | 处理引号、通配符、括号和列表边界 |
| 第三阶段 | ORDER BY、分页 |
排序差异、结构验证 | 认识“改变返回顺序”也是可观察证据 |
| 第四阶段 | 结果回显的 SELECT |
UNION、报错型 | 理解回显列、结果集与数据库差异 |
| 第五阶段 | 无明显响应差异的查询 | 时间盲注原理、带外原理 | 区分信号与网络噪声,控制测试影响 |
| 第六阶段 | 跨请求/跨功能、受限 SQL 执行 | 二次注入、堆叠查询机制 | 从单点 Payload 走向数据流和执行接口分析 |
特别推荐你优先练熟前三个阶段。它们会打破“只会在 id 后面拼单引号”的习惯。
19. 堆叠查询和二次注入,真实系统里常有吗?
答案是:确实存在、值得研究,但不应虚构出现比例,也不应默认任何注入点都能堆叠执行。
19.1 堆叠查询为什么不是通用能力?
举例来说:
- SQL Server 允许执行批量 SQL 语句,但仍要看应用通过哪种 API 提交。
- PostgreSQL 的多语句执行与所使用的查询协议、预编译模式有关。
- MySQL 能否多语句执行,很大程度上依赖客户端 API 和连接配置。
- Oracle 的常规 SQL 语句执行接口不等于可直接发送任意多语句脚本。
因此,判断“数据库可能支持某种语法”与“当前 Web 注入点可执行这种语法”必须分开。
在实战学习中,先确认基本注入和数据库执行模型,再研究堆叠能力,通常更有针对性。
19.2 二次注入为什么容易漏掉?
直接注入的观察路径常常是:
提交可控输入 → 后端立即执行 SQL → 当前请求出现行为差异
二次注入却可能是:
提交并保存昵称 → 数小时后后台报表读取昵称 → 另一条 SQL 才发生异常
如果你只在提交昵称的接口上点 Repeater、观察当前响应,无论发送多少请求,都未必能发现第二阶段的问题。
二次注入更考验业务流程理解,而不仅是 SQL 语法知识。
值得留意的链路包括:用户资料到后台管理、商品标签到统计报表、工单备注到导出、文件名到索引任务,以及不同系统间的数据导入。它们是值得检查的路径,不意味着这些业务天然有漏洞。
20. 从黑盒验证走向真正的漏洞根因:防御方法也必须看懂
学习 SQL 注入不能只停留在“怎样发请求”。理解正确修复,反过来能让你更快判断哪些输入可能会产生漏洞。
20.1 对数据值使用参数化查询
反例(不安全):
sql = "SELECT * FROM users WHERE name = '" + name + "'"
正确方向:
cursor.execute(
"SELECT * FROM users WHERE name = ?",
(name,)
)
参数化的主要作用是:让用户输入成为数据,而不是 SQL 代码。
注意:上例使用 SQLite / 部分 Python DB-API 驱动的
?风格。MySQL、PostgreSQL 等驱动可能使用%s、$1或其他占位符。不能把不同驱动的占位符随意混用。
20.2 对动态 SQL 标识符使用允许列表
ORDER BY、列名、表名、排序方向,不能仅靠普通数据值占位符完成所有安全设计。
allowed_sort = {
"price_asc": "price ASC",
"price_desc": "price DESC",
"newest": "created_at DESC",
}
sort_clause = allowed_sort.get(sort_key)
if sort_clause is None:
raise ValueError("Unsupported sort option")
sql = f"SELECT * FROM products ORDER BY {sort_clause}"
最终进入 SQL 结构的是程序内定义的可信表达式,而不是攻击者提供的任意字符串。
20.3 对二次注入,不能“保存时安全”就放松警惕
第一次存储时使用参数化查询是对的,但第二次从数据库读取内容后,如果重新拼接到 SQL,仍可能注入。
数据被存储过,不代表它已变成可信的 SQL 结构。 防护必须覆盖每一次 SQL 构造,而不是只覆盖用户提交的入口。
20.4 其他防御不是替代品
输入类型校验、最小权限、减少数据库错误信息泄露、合理的 WAF 规则都可能降低风险或影响,但不能代替正确的 SQL 参数化与动态结构白名单。
即便使用 ORM,也不能保证绝对没有 SQL 注入;如果某个调用绕过 ORM 安全机制执行拼接的原生 SQL,依然可能出问题。
结语:真正的提升,是从“我该试什么”变成“为什么要这样试”
刚开始学习 SQL 注入时,我们容易把它理解成一种字符串技巧:输入一个单引号,附加一个 AND 1=1,再换一种数据库特有的函数。
但一旦认真对比数字型与字符串型、WHERE 与 ORDER BY、布尔盲注与报错型、堆叠查询与二次注入,就会发现这些词根本不在同一层级。
SQL 注入测试真正需要建立的是一条推理链:
我能控制什么输入?
↓
它可能进入什么 SQL 上下文?
↓
什么表达式在这个上下文里是合法的?
↓
可以通过哪种技术制造可观察差异?
↓
眼前的响应究竟支持什么结论?
↓
我是否排除了其他解释?
↓
漏洞是否成立,根因是什么,如何修复?
记住某一条 Payload,你只学会了一种尝试;掌握这条推理链,你才开始具备独立分析 SQL 注入的能力。

浙公网安备 33010602011771号