SQL 注入长期位居 Web 安全风险的核心位置,并不是因为它“古老”,而是因为它直接触碰了应用安全中最根本的一条边界:数据与代码必须分离。

当用户输入被拼接进 SQL 语句时,输入不再只是数据,而有机会改变语句结构。安全产品、输入过滤和 WAF 可以增加攻击成本,但它们不能替代安全的数据访问方式。本文讨论 SQL 注入的主要类型、WAF 绕过的本质、常见检测误区,以及更可靠的工程化防御方案。

本文用于安全开发和防御体系建设。涉及真实系统的测试必须在明确授权、可恢复和可追踪的前提下进行。文章不提供针对真实目标的攻击步骤或可直接复用的绕过 payload。

一、SQL 注入的本质

应用程序通常会根据用户输入构造数据库查询。例如,概念上可能出现这样的逻辑:

查询用户名为输入值的管理员记录

如果输入始终被当作“用户名”这个数据值处理,那么无论用户输入什么内容,它都不应该改变查询结构。但如果应用采用字符串拼接,输入的语义就可能从“值”变成“语法”。

SQL 注入成立通常同时需要三个条件:

  1. 用户输入进入 SQL 语句。
  2. 输入能够影响 SQL 的语法结构。
  3. 应用或数据库会把执行结果、错误、状态或时间差异暴露给攻击者。

第三个条件经常被忽略。没有可观测差异,攻击者通常难以持续判断自己的假设是否正确。因此,防御不只是阻止某个字符,而是同时收紧语法边界、数据库权限、错误输出和日志可见性。

二、常见 SQL 注入类型

SQL 注入可以有多种分类方式。按照攻击者如何观察结果,通常可以分为以下几类。

1. 联合查询型注入

部分数据库支持 UNION,它可以把两条查询结果合并到同一结果集中。

联合查询型注入成立的前提通常包括:

  • 原查询结果能够返回给客户端。
  • 攻击者能够推断原查询的列数和兼容的数据类型。
  • 数据库用户的权限允许读取目标数据。

UNION 并不会让数据库“忽略权限”。如果数据库账号只能读取一张业务表,即便语句结构被改变,也不可能越过数据库本身的权限边界。

因此,防御不能只关注 UNION 这个关键词,还要确保:

  • 应用数据库账号使用最小权限。
  • 敏感表与业务读取账号隔离。
  • 查询接口不暴露不必要的字段。
  • 查询结果在服务端完成权限判断,而不是依赖客户端过滤。

2. 报错型注入

报错型注入依赖应用把数据库错误原样返回给客户端,或者错误信息中包含可观察的表达式结果。

攻击者真正利用的不是“报错”本身,而是:

  • 错误内容具有差异性。
  • 错误内容可以被稳定复现。
  • 错误内容与攻击者提交的数据存在对应关系。

从工程角度看,生产环境应做到:

  • 对用户只返回统一错误码和追踪 ID。
  • 将详细数据库错误写入受控日志。
  • 日志中避免记录密码、令牌和完整敏感数据。
  • 禁止将数据库异常直接拼接到 HTTP 响应。

即使错误信息不再回显,也不能认为漏洞已经修复。错误隐藏只是降低信息泄漏,不能阻止注入执行。

3. 布尔盲注

布尔盲注利用应用状态的差异来推断条件是否成立,例如:

  • 页面正常与页面异常。
  • 有记录与无记录。
  • 登录成功与登录失败。
  • 返回长度明显不同。

需要强调的是,任何稳定可区分的状态都可能成为推理信号,不一定只有“登录成功”和“登录失败”。

这也是为什么只隐藏错误页还不够。统一的响应内容、稳定的响应时间、原子化的认证逻辑和严格的接口输出,都能减少可利用的推理通道。

4. 时间盲注

时间盲注通过响应时间差异判断条件。它通常更容易受到网络抖动、缓存、数据库负载和代理超时影响,因此对攻击者来说并不总是稳定。

从防御角度看,时间差异可能需要结合以下机制观察:

  • 数据库慢查询日志。
  • 单 IP 或单用户的异常延迟分布。
  • 同一参数短期内的大量结构相似请求。
  • 应用日志中的异常查询模式。

单纯设置统一延时并不是合理的修复方式。它会影响正常用户,也无法解决最根本的拼接查询问题。

5. 认证绕过

所谓“万能密码”,本质上仍然是数据改变 SQL 逻辑。

认证系统中的风险往往比普通页面更高,因为一次成功绕过可能直接获得账户权限。可靠的认证实现需要同时满足:

  • 使用参数化查询读取账户记录。
  • 对用户名和认证标识执行精确匹配。
  • 使用成熟的密码哈希算法。
  • 统一失败信息,避免枚举账户。
  • 增加速率限制和异常登录检测。
  • 对高权限账户启用多因素认证。

三、注入点不只在 URL 参数中

“URL 中出现问号代表 GET 请求”是一个过度简化的说法。

现代应用的数据入口包括:

  • URL 查询参数。
  • REST 路径参数。
  • JSON 请求体。
  • 表单和 multipart 请求体。
  • Cookie。
  • 自定义请求头。
  • WebSocket 消息。
  • 消息队列、定时任务和内部服务调用。

SQL 注入最终关心的是:数据在哪里进入了数据库访问层,而不是数据最初来自 HTTP 的哪个位置。

安全设计应把入口分类和数据库访问分别治理:

  • 入口层负责格式、长度、编码和业务约束。
  • 数据库访问层负责参数化、权限和查询结构。
  • 日志层负责审计和异常聚合。
  • WAF 负责通用威胁拦截和虚拟补丁。

任何单一层都不应被当作唯一防线。

四、WAF 绕过的本质:解析器不一致

WAF 通常会先观察 HTTP 请求,再根据规则判断是否阻断;数据库则根据 SQL 语法解析真正执行的语句。

两者看到的输入未必相同:

  • WAF 可能只检查 URL,数据库实际接收的是请求体。
  • WAF 可能只解码一次,应用框架可能解码多次。
  • WAF 把某个片段视为普通文本,数据库可能把它视为注释或运算符。
  • WAF 采用正则匹配关键词,数据库采用语法树解析。
  • 应用框架会重写参数,代理会合并参数名。
  • 数据库版本之间对相同语法的处理也可能不同。

所谓“绕过”,很多时候并不是 WAF 完全失效,而是 WAF 的解析模型与最终执行链的解析模型不一致。

1. 注释和空白

SQL 注释在不同数据库和上下文中具有不同语义。请求进入数据库前,注释可能被忽略,但 WAF 仍能看到注释字符。

如果 WAF 只匹配“关键词必须连续出现”,它就可能把包含注释或空白变化的语句误判为正常请求。

正确的检测思路不是枚举某一种注释写法,而是:

  • 按应用实际支持的编码规则规范化输入。
  • 按目标 SQL 方言进行词法分析。
  • 在语法层识别危险的操作符、查询结构和数据源。
  • 对无法解析的输入采取保守策略。

2. 大小写与空白变化

SQL 关键词大小写通常不敏感,而正则表达式往往大小写敏感。

如果规则只写死一组小写关键词,攻击者可能通过大小写变化规避匹配。但修复方式不应只是添加更多大小写组合,而应是:

  • 在规范化后统一大小写。
  • 对 token 做语法识别。
  • 同时检查请求体、参数值和原始字节。
  • 使用解析器而不是无限扩展正则表达式。

3. 编码与类型转换

不同层可能使用不同的字符编码、URL 解码次数和 Unicode 规范化方式。同名参数、重复参数、数组参数和 multipart 边界也可能被代理、框架和数据库解释为不同内容。

这会带来“参数污染”或“解析差异”问题:

  • 代理检查了第一个参数,应用使用了最后一个参数。
  • WAF 解码了 URL,业务代码又解码了一次。
  • WAF 看到的是字符串,数据库最终接收到的是数字或二进制。

要减少这类风险,必须明确每一层的规范化顺序,并保证 WAF、应用框架和数据库对同一请求形成一致的语义视图。

4. 版本特性与数据库方言

不同数据库对注释、运算符、隐式类型转换、字符串拼接和版本兼容语法的支持并不完全一致。

WAF 供应商如果只针对某个数据库版本编写规则,就可能忽略其他方言。组织安全测试时,应确保:

  • 规则覆盖生产实际使用的数据库类型和版本。
  • 应用升级数据库时同步复核 WAF 规则。
  • 对多数据库架构分别测试。
  • 每个规则都配套误报和漏报样例。

五、为什么关键词黑名单不可靠

很多简单规则会采用“同时出现多个关键词就阻断”的策略。例如同时看到联合查询关键词和选择关键词,就认为请求危险。

这种做法存在几个问题。

1. 不理解语法

关键词出现在字符串、注释、列名或业务文本中时,不一定构成注入。

反过来,真正的注入也可能不依赖这些固定关键词,或者通过数据库语法变化后,在 WAF 看到的原始文本中不连续出现。

2. 不理解上下文

同一个词在不同的位置意义不同:

  • 作为普通文本。
  • 作为列名。
  • 作为表名。
  • 作为操作符。
  • 作为函数名。

只有语法上下文才能说明它是否危险。

3. 容易被编码和解析差异影响

安全产品看到一个版本,应用和数据库看到另一个版本。只对原始字符串做正则匹配,天然无法稳定处理这类差异。

4. 规则维护成本高

为了覆盖所有空格、大小写、注释和编码组合,规则会迅速膨胀。最终结果是:

  • 误报增加。
  • 性能下降。
  • 规则之间互相冲突。
  • 新数据库版本上线后无人维护。

更好的方式是:以参数化查询消除漏洞,以语法级检测发现异常,以速率限制和日志聚合应对自动化探测。

六、真正有效的工程防御

1. 参数化查询

参数化查询是最重要的修复手段。它让 SQL 结构先被数据库解析,用户输入随后作为参数绑定,不再参与语法构造。

不安全的思路:

query = "SELECT id, username FROM users WHERE username = '" + username + "'"
cursor.execute(query)

安全写法:

query = "SELECT id, username FROM users WHERE username = %s"
cursor.execute(query, (username,))

Java 中的参数化写法:

PreparedStatement statement =
    connection.prepareStatement(
        "SELECT id, username FROM users WHERE username = ?"
    );
statement.setString(1, username);

Node.js 中同样应使用绑定参数,而不是拼接字符串:

const [rows] = await pool.execute(
  "SELECT id, username FROM users WHERE username = ?",
  [username]
);

下面进一步用纯 SQL 展示危险拼接与安全参数化之间的区别。

6.1 示例表结构

为了让示例具有完整上下文,先定义一张博客文章表:

CREATE TABLE posts (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    author_id   BIGINT UNSIGNED NOT NULL,
    title       VARCHAR(200) NOT NULL,
    content     LONGTEXT NOT NULL,
    status      ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
    created_at  DATETIME NOT NULL,
    updated_at  DATETIME NOT NULL,
    PRIMARY KEY (id),
    INDEX idx_posts_author_created (author_id, created_at)
);

这张表本身没有问题,风险出现在应用如何把外部输入组合进 SQL。

6.2 危险写法

下面用尖括号表示一个会被用户输入替换的占位位置,它是概念示例,不应在应用中实现:

SELECT id, title, created_at
FROM posts
WHERE author_id = <user-input>;

如果应用只是把 <user-input> 替换成一段字符串,用户输入就可能改变查询结构。

更完整地看,危险点不是 SELECT、FROM 或 WHERE 这些关键词本身,而是输入从“数据位置”进入了“语法位置”。

6.3 安全参数化查询

使用绑定参数后,SQL 结构首先固定:

SELECT id, title, created_at
FROM posts
WHERE author_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT ?;

应用层绑定参数:

query = """
SELECT id, title, created_at
FROM posts
WHERE author_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT ?
"""

cursor.execute(query, (author_id, "published", page_size))

这里需要理解一个重点:参数值即使包含 SQL 关键词、引号或注释字符,也会作为普通数据参与比较,而不会成为新的 SQL 语法。

6.4 多条件查询

搜索页面经常组合多个条件,安全做法仍然是保持 SQL 固定,只绑定值:

SELECT id, title, created_at
FROM posts
WHERE status = ?
  AND author_id = ?
  AND created_at >= ?
ORDER BY created_at DESC
LIMIT ?;

参数顺序必须与应用绑定顺序一致。不要根据用户输入拼接字段名或操作符。

6.5 动态排序

表名、列名和排序方向通常不能作为普通参数直接绑定。正确方式是允许列表。

安全 SQL 可以写成:

SELECT id, title, created_at, updated_at
FROM posts
WHERE author_id = ?
  AND status = ?
ORDER BY
    CASE
        WHEN ? = 'updated_at' THEN updated_at
        ELSE created_at
    END DESC
LIMIT ?;

应用层只允许以下排序字段:

allowed_sort_fields = {
    "created": "created_at",
    "updated": "updated_at",
}

if requested_sort not in allowed_sort_fields:
    requested_sort = "created"

不要把用户输入直接拼接到 ORDER BY、表名或列名后面,因为参数绑定只适用于数据值。

6.6 精确获取单条记录

按 ID 查询时,应先校验类型,然后使用参数:

SELECT id, title, content, created_at, updated_at
FROM posts
WHERE id = ?
  AND status = ?;
post_id = int(requested_post_id)

cursor.execute(
    """
    SELECT id, title, content, created_at, updated_at
    FROM posts
    WHERE id = ?
      AND status = ?
    """,
    (post_id, "published"),
)

类型转换不能代替参数化。它只是输入格式约束,数据库访问仍应使用绑定参数。

6.7 更新操作

更新文章时应同时限制文章 ID 和当前用户:

UPDATE posts
SET title = ?,
    content = ?,
    updated_at = ?
WHERE id = ?
  AND author_id = ?
  AND status IN ('draft', 'published');

这里的关键不只是防注入,还包括越权控制。即使文章 ID 真实存在,也只能修改属于当前作者的文章。

6.8 事务

涉及多个相互依赖的写入时,应使用事务:

START TRANSACTION;

UPDATE posts
SET status = 'published',
    updated_at = ?
WHERE id = ?
  AND author_id = ?;

INSERT INTO audit_logs (
    actor_id,
    action,
    target_type,
    target_id,
    created_at
) VALUES (?, ?, ?, ?, ?);

COMMIT;

如果事务中的任一步失败,应立即回滚:

ROLLBACK;

参数化查询解决注入问题,但不会自动解决事务冲突、并发更新和业务权限问题,因此还需要同时设计锁、版本号和权限检查。

6.9 数据库最小权限

应用账号应只获得必要权限。下面是概念示例,实际权限应按业务调整:

GRANT SELECT, INSERT, UPDATE
ON app_database.posts
TO 'blog_app'@'10.0.%';

不需要删除权限时,不应授予:

-- 不要授予超出业务需要的权限
-- GRANT ALL PRIVILEGES ON *.* TO 'blog_app'@'10.0.%';

生产环境还应结合密钥管理系统保存数据库凭据,并限制来源网段。

6.10 安全审计查询

当安全事件发生时,可以从结构化日志中聚合异常请求:

SELECT
    request_id,
    route,
    param_name,
    risk_score,
    source_ip,
    created_at
FROM security_events
WHERE risk_score >= 70
  AND created_at >= ?
ORDER BY created_at DESC
LIMIT 100;

聚合登录失败:

SELECT
    source_ip,
    COUNT(*) AS failure_count,
    MIN(created_at) AS first_seen,
    MAX(created_at) AS last_seen
FROM authentication_events
WHERE success = 0
  AND created_at >= ?
GROUP BY source_ip
HAVING COUNT(*) >= ?
ORDER BY failure_count DESC;

这些查询只使用绑定参数,并且只从安全日志表读取数据,用于发现和响应异常行为。

6.11 数据库慢查询

慢查询可能包含正常业务,也可能与时间型攻击有关。应结合请求日志分析,而不是仅凭单次慢查询判断攻击:

SELECT
    event_time,
    user_host,
    query_time,
    rows_examined,
    rows_sent
FROM mysql.slow_log
WHERE start_time >= ?
ORDER BY query_time DESC
LIMIT 100;

数据库慢查询表在不同数据库中的名称和开放方式不同,实际采集建议使用数据库官方审计或可观测性工具。

需要注意,参数化查询并不自动解决所有问题:

  • 表名、列名和排序方向不能像普通值一样直接绑定。
  • 动态 SQL 仍需要白名单。
  • ORM 的原始 SQL 接口、排序参数和表达式接口仍可能引入注入。
  • 存储过程内部如果继续拼接参数,同样可能产生漏洞。

2. 允许列表而不是黑名单

对于不能参数化的结构部分,例如排序字段、查询模式和报表维度,应使用允许列表。

例如:

允许排序字段:created_at、updated_at、title
允许排序方向:asc、desc
其他值:拒绝

不要在用户输入中直接寻找“坏字符”,因为总有遗漏。应明确列出系统允许什么。

3. 最小权限数据库账号

应用使用的数据库账号不应拥有超出业务所需的权限:

  • 只允许访问必要数据库和表。
  • 只开放必要的查询、写入和更新权限。
  • 禁止业务账号访问系统表,除非业务确实需要。
  • 管理操作使用独立账号。
  • 不同服务使用不同账号。
  • 定期审计账号权限和连接来源。

即使应用出现注入,最小权限也能限制影响范围。

4. 统一错误处理

生产环境不应把数据库异常原样返回给用户。推荐做法:

  • 对用户返回固定错误编号和追踪 ID。
  • 在服务端日志保存完整异常。
  • 日志中脱敏敏感字段。
  • 对异常错误率设置告警。
  • 不在响应中暴露数据库类型、版本、表名或语句结构。

5. 网络与访问控制

数据库不应直接暴露到公网。应用和数据库之间应通过私有网络或安全组访问,并限制来源地址。

还需考虑:

  • 数据库只监听必要的网络接口。
  • 管理端口不对外网开放。
  • 严格限制跳板机和运维访问。
  • 定期轮换凭据。
  • 使用密钥管理服务保存敏感配置。

6. 日志、审计与异常检测

安全日志应回答四个问题:

  1. 谁在什么时候访问了系统?
  2. 请求了什么资源?
  3. 查询是否偏离正常业务模式?
  4. 是否存在敏感数据读取或权限提升迹象?

建议记录:

  • 用户、会话、来源 IP 和请求 ID。
  • 路由、参数名和参数类型,不记录密码等敏感值。
  • 数据库账号、目标表和执行耗时。
  • WAF、应用和数据库之间的关联 ID。
  • 查询频率、错误率和响应长度异常。

日志本身也需要保护,避免被攻击者删除或篡改。

七、WAF 规则应该怎么设计

WAF 是防御体系的一部分,但不应成为唯一防线。

1. 先统一请求解析

WAF 和业务系统必须明确一致的请求解析规则:

  • 支持哪些 Content-Type。
  • URL 解码几次。
  • Unicode 如何规范化。
  • 重复参数如何处理。
  • JSON 嵌套深度和数组如何处理。
  • multipart 文件名和字段名如何解析。
  • 请求体大小上限是多少。

如果 WAF 与框架解析不同,规则本身再复杂也会留下语义缺口。

2. 从关键词匹配升级到语法检测

更可靠的方向包括:

  • 对 SQL 片段进行词法分析。
  • 识别危险语法结构,而不是写死完整 payload。
  • 识别查询拼接附近的异常操作符组合。
  • 对数据库元数据访问、联合查询结构和异常函数调用建立风险评分。
  • 结合参数位置、参数类型和业务上下文进行判断。

规则应输出可解释的风险原因,而不是只有一个“匹配成功”。

3. 虚拟补丁与真实修复并行

WAF 可以用于临时缓解:

  • 漏洞披露后、代码修复前。
  • 旧系统无法立即升级时。
  • 大范围自动化探测发生时。

但虚拟补丁不是永久修复。必须建立跟踪机制:

  • 记录 WAF 规则对应的漏洞。
  • 明确代码修复负责人和完成时间。
  • 修复上线后验证规则是否仍必要。
  • 定期清理过期规则。

4. 阈值与速率限制

速率限制可以减少自动化探测,但阈值不能代替漏洞修复。

合理的限制包括:

  • 单 IP 请求速率。
  • 单账户登录失败次数。
  • 单接口异常查询比例。
  • 单会话参数变化次数。
  • 数据库慢查询和错误查询速率。

阈值设置应基于正常业务基线,避免影响搜索引擎、API 客户端和移动网络用户。

5. 误报治理

WAF 上线前必须测试正常业务流量:

  • 登录、搜索、筛选和排序。
  • 包含 SQL 关键词的文章和评论。
  • JSON、表单和 multipart 上传。
  • 多语言和特殊字符。
  • 移动端与第三方 API 调用。

如果规则误报率过高,业务团队会倾向于关闭规则,最终使安全控制失效。

八、如何测试与验证

SQL 注入测试必须在授权范围内进行。公开发表文章时,也不要附带真实目标地址、真实账号、数据库结果或可复现的攻击步骤。

适合学习和演示的公开环境包括:

  • OWASP Juice Shop。
  • OWASP WebGoat。
  • DVWA。
  • 本地搭建的独立数据库练习环境。

测试过程中应该遵守:

  1. 明确授权范围、目标域名和时间窗口。
  2. 不在生产环境执行破坏性操作。
  3. 限制请求频率,避免影响业务。
  4. 不读取与漏洞证明无关的敏感数据。
  5. 保存必要证据后及时清理测试数据。
  6. 测试结束后提交可复现但已脱敏的报告。

高质量的漏洞报告不应只写“可以注入”,而应说明:

  • 受影响接口和参数。
  • 权限边界和影响范围。
  • 可稳定复现的前置条件。
  • 是否能够读取、修改或删除数据。
  • 建议的代码层修复方案。
  • WAF 临时规则和长期修复计划。

九、事件响应

如果怀疑 SQL 注入已经发生,应优先控制影响,而不是只删除日志或重启服务。

建议流程:

  1. 保留 WAF、应用、数据库和身份系统日志。
  2. 确认受影响接口、数据库账号和时间范围。
  3. 隔离高风险入口或启用临时规则。
  4. 轮换数据库、应用和第三方凭据。
  5. 修复拼接查询和权限问题。
  6. 检查是否发生数据读取、写入、导出或账户创建。
  7. 评估通知和合规义务。
  8. 复盘检测缺口,补充测试和监控。

十、防御检查清单

数据库访问

  • 所有不可信输入都通过参数绑定。
  • 动态表名、列名和排序字段使用允许列表。
  • ORM 原始 SQL 接口经过安全审查。
  • 存储过程内部不存在字符串拼接。
  • 查询失败不会向用户暴露数据库细节。

权限

  • 应用账号最小权限。
  • 不同服务使用独立账号。
  • 数据库不直接暴露到公网。
  • 敏感表和高权限操作有额外审计。

WAF

  • WAF 与业务框架的解析规则一致。
  • 同时检查 URL、请求体、JSON、Cookie 和必要请求头。
  • 规则基于语法和风险评分,而不是少量固定关键词。
  • 阈值、误报和性能有持续基线。
  • 虚拟补丁有明确的代码修复期限。

监控

  • 登录失败、错误率和慢查询有告警。
  • WAF、应用和数据库日志可以关联。
  • 敏感数据导出和权限变更被审计。
  • 定期进行授权渗透测试和回归验证。

发布

  • 文章不包含真实站点地址。
  • 不包含真实账号和数据库内容。
  • 不提供可直接复用攻击真实系统的 payload。
  • 示例明确说明用于本地授权练习。

结语

SQL 注入不是“某个字符”或“某个关键词”的问题,而是数据与代码边界失效的问题。

WAF 绕过也不是简单的规则绕过,而是 HTTP、应用框架、WAF、数据库和业务逻辑之间的解析差异。只依靠关键词黑名单,会不断追逐新的编码、注释和语法变化;只依靠 WAF,也无法修复应用本身的不安全查询。

真正可靠的方案是:

  • 在代码层使用参数化查询。
  • 在权限层实施最小权限。
  • 在入口层做格式约束。
  • 在检测层进行语法分析和异常聚合。
  • 在响应层隐藏数据库错误。
  • 在运营层持续测试、监控和复盘。

只有当这些层次共同工作时,SQL 注入防护才从“拦截请求”变成“控制风险”。

参考资料

  • OWASP Top 10: Injection。
  • OWASP SQL Injection Prevention Cheat Sheet。
  • OWASP Query Parameterization Cheat Sheet。
  • 各数据库官方文档中的参数绑定、权限和错误处理章节。