动态SQL注入的核心问题不在于“有没有参数化”,而在于拼接发生的时机和位置。很多系统在静态查询上防护做得不错,一旦涉及排序字段、表名、条件拼接、白名单之外的动态片段,就容易被绕过。攻击者不会只盯着?id=1这种位置,更多时候会测试order、limit、group by、in列表、like模糊匹配以及报表筛选条件。识别动态SQL注入,首先要区分“值拼接”和“结构拼接”。值拼接可以用参数化解决,结构拼接必须用白名单和映射表控制,两者不能混为一谈。

在实际检测中,动态SQL注入有几个明显特征。第一,用户输入直接进入了SQL语句的字符串拼接过程,而不是作为绑定变量传入。第二,输入内容影响了SQL语法结构,比如改变了where条件、排序方式、表名或字段名。第三,报错信息中出现了数据库内部结构、表名、字段名或SQL片段。第四,使用时间盲注时,同一请求在加入不同条件后响应时间出现规律性差异。第五,布尔盲注下,输入永真和永假条件时页面返回内容明显不同。识别时不能只依赖WAF规则,要结合代码审计和运行时监控。

动态SQL最常见的危险场景

排序字段动态拼接是重灾区。很多列表接口允许用户指定排序字段和排序方向,后端代码经常写成“select * from orders order by $sortField $sortOrder”。如果sortField没有经过白名单校验,攻击者可以直接传入“id desc,(select case when 1=1 then 1 else 0 end)”这样的内容,把查询变成盲注工具。正确做法是维护一个允许排序的字段映射表,前端传的是key,后端映射成真实字段名,排序方向只允许ASC和DESC两个枚举值。

IN列表拼接同样危险。当筛选条件需要传入多个ID时,代码如果写成“where id in ($ids)”,而$ids来自逗号分隔的字符串拼接,就存在注入风险。攻击者可以构造“1) or 1=1 --”这样的内容闭合括号并注入额外条件。处理IN列表必须逐个参数化,或者将传入的ID列表拆分成数组后使用占位符逐个绑定。

表名和字段名动态拼接通常出现在报表系统、自定义查询或数据导出功能中。这类场景无法使用参数化,只能通过严格的白名单校验。白名单不能只校验格式,必须校验实际值是否在允许的集合内。比如只允许从“users,orders,products”这几个表名中选择,任何不在集合内的输入直接拒绝。

识别动态SQL注入的代码审计方法

代码审计时,重点关注所有执行SQL的方法调用。搜索字符串拼接操作符,比如Java中的加号连接、PHP中的点号连接、Python中的f-string或format方法。检查这些拼接变量是否来自用户输入。如果拼接变量经过过滤或转义,还要确认过滤函数是否覆盖了当前数据库的语法特性。MySQL的注释符、字符串连接符、报错函数和Oracle、SQL Server都有差异,过滤逻辑必须与数据库类型匹配。

运行时识别可以借助数据库审计日志。开启数据库的SQL日志记录后,分析执行过的SQL语句中是否存在用户输入片段。如果日志中出现大量结构相似但条件不断变化的查询,尤其是包含sleep、benchmark、case when、union select等关键字,就需要人工复核。还可以在应用层记录SQL执行前后的参数值,对比绑定变量和直接拼接的比例。

动态SQL注入的拦截不能只依赖WAF。WAF规则基于特征匹配,对编码绕过、注释混淆、等价函数替换的检测能力有限。真正有效的拦截是纵深防御:应用层做白名单和参数化,数据库层做权限最小化,网络层做异常流量检测。三层配合才能把风险降到最低。

参数化查询的正确用法

参数化查询是防注入的基础,但很多人用错了。参数化只能用于值的位置,不能用于表名、字段名、SQL关键字。比如“select * from ? where id = ?”中第一个问号是无效的,数据库会把表名当作字符串处理,导致语法错误。参数化查询的占位符只能替换字面量,不能替换标识符。

不同语言的参数化写法有差异。Java的PreparedStatement使用问号占位符,PHP的PDO可以使用命名参数或问号,Python的cursor.execute也支持参数元组。关键是确保用户输入永远不直接进入SQL字符串。下面是一个Java的示例:

String sql = "select * from users where username = ? and status = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, username);
ps.setInt(2, status);
ResultSet rs = ps.executeQuery();

这段代码中,username和status都作为参数传入,数据库驱动会负责转义和类型处理,用户输入不会改变SQL结构。即使username中包含单引号或SQL关键字,也只会被当作普通字符串处理。

白名单校验的设计原则

对于无法参数化的动态结构,白名单是唯一可靠的方案。白名单设计要遵循几个原则。第一,白名单必须定义在服务端,不能依赖前端传值。第二,白名单的匹配要精确,不能使用模糊匹配或正则替换。第三,白名单要覆盖所有可能的合法值,不在名单内的输入一律拒绝。第四,白名单的维护要有版本控制,新增合法值需要经过代码审核。

以排序字段为例,可以定义一个映射表:

Map<String, String> sortMap = new HashMap<>();
sortMap.put("createTime", "create_time");
sortMap.put("userName", "user_name");
sortMap.put("orderId", "order_id");

String sortKey = request.getParameter("sort");
String sortField = sortMap.get(sortKey);
if (sortField == null) {
    sortField = "create_time"; // 默认值
}
String sortOrder = "desc".equalsIgnoreCase(request.getParameter("order")) ? "desc" : "asc";
String sql = "select * from orders order by " + sortField + " " + sortOrder;

这段代码中,sortField只能从映射表中取值,用户传入的sort参数只是key,不会直接拼接到SQL中。sortOrder也只允许desc和asc两个值,其他输入会被强制设为asc。这样即使攻击者传入恶意内容,也无法改变SQL结构。

存储过程的防注入误区

很多人认为使用存储过程就能防止SQL注入,这是一个危险的误解。存储过程内部如果使用动态拼接,同样存在注入风险。比如存储过程内部使用EXEC或sp_executesql执行拼接后的SQL字符串,攻击者传入的内容依然可以改变SQL结构。存储过程的安全性与是否使用参数化有关,与是否使用存储过程本身无关。

在SQL Server中,sp_executesql支持参数化,但需要正确使用。如果存储过程内部这样写:

CREATE PROCEDURE GetUser
    @username NVARCHAR(50)
AS
BEGIN
    DECLARE @sql NVARCHAR(MAX)
    SET @sql = 'SELECT * FROM users WHERE username = ''' + @username + ''''
    EXEC(@sql)
END

这段代码存在注入风险。正确做法是使用sp_executesql的参数化能力:

CREATE PROCEDURE GetUser
    @username NVARCHAR(50)
AS
BEGIN
    DECLARE @sql NVARCHAR(MAX)
    SET @sql = 'SELECT * FROM users WHERE username = @p_username'
    EXEC sp_executesql @sql, N'@p_username NVARCHAR(50)', @p_username = @username
END

这样@username的值会作为参数传递,不会直接拼接到SQL字符串中。

ORM框架中的动态查询风险

ORM框架降低了SQL注入的风险,但动态查询接口仍然是漏洞高发区。很多ORM提供了原生SQL执行方法,比如JPA的createNativeQuery、MyBatis的${}占位符、Hibernate的createSQLQuery。这些方法如果直接拼接用户输入,就会绕过ORM的参数化保护。

MyBatis中,#{}和${}的区别至关重要。#{}会生成参数化占位符,${}是直接字符串替换。动态排序、动态表名等场景经常使用${},这正是需要白名单控制的地方。如果代码中大量使用${},就需要逐一审查是否存在注入风险。

ORM的Criteria API或QueryDSL等类型安全查询方式,可以在编译期检查字段名和类型,减少动态拼接的风险。对于复杂的动态查询,优先使用这些类型安全的方式,而不是手写SQL字符串。

拦截层的技术选型

应用层拦截可以在框架层面统一处理。比如使用过滤器或拦截器,在请求进入业务逻辑前检查参数中是否包含SQL关键字和特殊字符。这种拦截方式对简单注入有效,但容易误伤正常输入。比如用户昵称中包含“select”或“union”等单词,就会被误拦截。因此应用层拦截只能作为辅助手段,不能作为主要防线。

数据库防火墙是更专业的拦截方案。数据库防火墙部署在应用和数据库之间,解析SQL语句的语法结构,识别异常的查询模式。与传统WAF不同,数据库防火墙理解SQL语法,能够检测出编码绕过和注释混淆后的注入。数据库防火墙还可以配置白名单规则,只允许预定义的SQL模板执行,任何结构不一致的SQL都会被拦截。

数据库权限最小化是最后的防线。应用连接数据库的账号只授予必要的权限,比如只允许SELECT、INSERT、UPDATE、DELETE,不允许DDL操作。对于敏感表,可以进一步限制只能访问特定字段。这样即使发生注入,攻击者也无法执行高危操作,比如删除表、读取系统表、执行xp_cmdshell等。

日志监控与告警机制

拦截之外,日志监控能帮助发现绕过防护的攻击。记录所有SQL执行日志,包括SQL语句、参数值、执行时间、来源IP、用户标识。定期分析日志中的异常模式,比如短时间内大量相似的SQL语句、包含sleep或benchmark的查询、报错信息中包含数据库内部结构等。

告警规则要针对动态SQL注入的特征设计。比如同一IP在短时间内请求了多个不同的排序字段,且字段值不在白名单内;或者请求参数中出现了SQL注释符、联合查询关键字、时间盲注函数。告警触发后,安全团队可以人工复核,确认是否发生真实攻击。

日志记录要注意脱敏,避免将用户密码、身份证号等敏感信息写入日志。同时日志存储要有访问控制,防止攻击者通过注入读取日志文件获取更多信息。

动态SQL注入的绕过技术

了解绕过技术有助于更好地防御。攻击者常用编码绕过WAF,比如URL编码、双重编码、十六进制编码、Unicode编码。MySQL中,0x61646d696e可以表示“admin”,绕过基于关键字的检测。注释混淆也很常见,比如“sel//ect”可以绕过简单的关键字匹配。等价函数替换,比如用benchmark代替sleep,用load_file代替union select,也能绕过特征规则。

针对这些绕过技术,防御方需要做规范化处理。在检测前对输入进行标准化,解码URL编码、统一字符集、去除注释。但规范化本身也可能引入新的绕过,比如双重编码在解码一次后仍然包含编码内容。因此最可靠的方案仍然是白名单和参数化,从源头消除注入可能,而不是依赖检测和拦截。

动态SQL注入的防护没有银弹,需要结合代码规范、框架能力、数据库配置和监控告警。核心原则是:能参数化的绝不拼接,不能参数化的必须白名单,白名单之外的一律拒绝。把这三条落实到每一个SQL执行点,动态SQL注入的风险就能降到可控范围。