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';

为什么这次有效?

  1. Apple' 里的单引号结束了最初的商品名称字符串。
  2. 接下来的 AND 才可能成为 SQL 语法。
  3. '1'='1 与代码自动补上的结尾单引号一起组成完整的比较表达式。
  4. 通过真和假的对照,可以测试输入是否影响数据库的 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 ...

这叫语法异常探测:通过让输入包含特殊字符,观察查询是否发生异常。

但有两层推理不能跳过:

  1. HTTP 500 并不一定来自 SQL。也可能是类型转换、后端业务异常或其他组件抛出的错误。
  2. 即使回显了 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。

真正的时间盲注需要:

  1. SQL 语法上下文允许触发延迟。
  2. 有一组延迟与不延迟的对照。
  3. 多次交错测量,排除链路抖动、连接复用、后端队列和数据库负载。
  4. 严格限制请求次数和影响,避免给生产系统制造不必要的负载。

单次请求慢了两秒,不足以判断 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”的承诺。 实际是否成立,受以下因素影响:

  1. 输入最终处于什么 SQL 语法位置。
  2. 原始 SQL 在输入前后自动添加了哪些字符。
  3. 数据库支持哪些 SQL 函数和语法。
  4. 驱动或 ORM 如何执行这条查询。
  5. 应用是否把数据库结果或错误反映到 HTTP 响应中。
  6. 是否存在防护、中间件、缓存或业务校验。

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,就意味着没有漏洞?

未必。几种可能解释:

  1. 参数根本没进入 SQL。
  2. SQL 使用了安全的参数化查询。
  3. 输入在进入数据库之前被归一化或过滤。
  4. 应用使用缓存,或回显与查询结果脱钩。
  5. 当前 Payload 不符合真实 SQL 语法上下文。
  6. 存在盲注,但当前选择的观测方式没有可见变化。
  7. 实际上不存在 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% 注入都是某某类型”这类未经证实的数字。

可以区分两个问题:

  1. 学习优先级:哪些场景覆盖基本原理、最有利于形成判断能力?
  2. 真实发生率:哪些场景在某一范围的系统中出现得更多?

本文给出的是学习优先级,不是漏洞流行病学统计。

阶段 重点上下文 重点技术 核心练习目标
第一阶段 数字型、字符串型 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 注入的能力。

posted @ 2026-10-25 09:53  0xMouise  阅读(7)  评论(0)    收藏  举报