SQL注入深度解析:从MyBatis的#{}与${}差异看安全编码实践
1. 项目概述:一次关于SQL注入的深度复盘
几年前,我在一次内部安全审计中,遇到了一个非常典型的案例,我习惯性地称之为“Twice SQL Injection”。这个名字听起来有点绕,但它精准地概括了这次漏洞的本质:一个看似简单的SQL注入点,因为开发人员在不同层级、以不同方式重复犯了两次几乎相同的错误,最终导致了一个高危漏洞的产生。这不是一个虚构的靶场练习,而是发生在2019年10月一个真实线上业务中的事情。今天,我就把这个案例从头到尾拆解一遍,不仅会还原漏洞的发现、利用和修复过程,更重要的是,我会深入分析其背后的代码逻辑、开发人员的思维误区,以及我们如何建立机制来避免这类“重复犯错”的问题。无论你是刚入门的安全工程师,还是有一定经验的开发者,相信这个案例都能给你带来一些关于代码安全和防御纵深建设的启发。
这个案例涉及一个用户查询功能,表面上看,它已经使用了参数化查询(Prepared Statement),这通常是防止SQL注入的“银弹”。但魔鬼藏在细节里,正是这种“以为已经安全了”的松懈,导致了漏洞的二次产生。我们将从漏洞现象入手,逐步深入到代码层、框架层,最后讨论修复方案与最佳实践。我会尽量用通俗的语言解释技术细节,并提供可直接参考的代码示例和排查思路。
2. 漏洞背景与功能场景解析
2.1 业务功能描述
存在漏洞的系统是一个内容管理平台的后台模块,其中一个核心功能是“用户行为日志查询”。管理员可以通过此功能,根据用户名、时间范围、操作类型等多个条件,筛选和查看用户的操作记录。前端是一个常见的表单查询页面,后端则是一个标准的Spring Boot + MyBatis技术栈的应用。
查询的核心逻辑是:前端提交表单数据(如username=admin&startTime=2019-10-01&actionType=LOGIN),后端控制器接收参数,然后调用服务层的方法,服务层再通过MyBatis的Mapper接口执行数据库查询。问题就出在服务层组装查询条件的过程中。
2.2 技术栈与初始安全认知
项目团队在当时已经具备了基本的安全意识,他们知道直接拼接SQL字符串是危险的。因此,在MyBatis的Mapper XML文件中,他们普遍使用了#{}语法进行参数绑定,例如:
<select id="selectLogs" resultType="Log"> SELECT * FROM user_operation_log WHERE username = #{username} <if test="startTime != null"> AND operation_time >= #{startTime} </if> </select>#{}在MyBatis中会被处理为预编译的参数占位符(即Prepared Statement),这能有效防止SQL注入。团队认为,只要在XML里写好了#{},整个查询就是安全的。这个认知本身没有错,但却是不完整的,它为后续的漏洞埋下了伏笔。
3. 第一次注入:服务层动态SQL拼接的陷阱
3.1 漏洞代码还原
让我们先看第一次出现问题的代码。在服务层的某个方法中,开发人员需要根据前端传入的多个可选查询条件,动态地构建WHERE子句。但是,他们犯了一个关键错误:没有完全依赖MyBatis的动态SQL标签(如<if>),而是在Java代码中进行了字符串拼接。
漏洞代码示例:
@Service public class OperationLogService { @Autowired private OperationLogMapper logMapper; public List<OperationLog> queryLogs(String username, String actionType, String customFilter) { StringBuilder whereClause = new StringBuilder("1=1"); // 常见的初始化技巧 if (username != null && !username.isEmpty()) { whereClause.append(" AND username = '").append(username).append("'"); } if (actionType != null && !actionType.isEmpty()) { whereClause.append(" AND action_type = '").append(actionType).append("'"); } // 危险操作:直接拼接用户输入的customFilter if (customFilter != null && !customFilter.isEmpty()) { whereClause.append(" AND ").append(customFilter); } // 将拼接好的字符串传给Mapper方法 return logMapper.selectLogsByDynamicWhere(whereClause.toString()); } }而在对应的Mapper接口和XML中:
// Mapper接口 List<OperationLog> selectLogsByDynamicWhere(@Param("whereClause") String whereClause);<!-- Mapper XML --> <select id="selectLogsByDynamicWhere" resultType="OperationLog"> SELECT * FROM user_operation_log WHERE ${whereClause} </select>3.2 漏洞原理与利用分析
这里出现了两个致命问题:
- 在Java层拼接字符串:
username和actionType虽然经过了判空,但直接使用append(“‘”).append(username).append(“‘”)的方式拼接,如果username中包含单引号,就会破坏SQL语法。 - MyBatis中
${}的使用:在Mapper XML中,他们使用了${whereClause}。在MyBatis中,${}是字符串替换,它会将传入的参数原封不动地拼接到SQL语句中,而不会进行预编译处理。这与#{}的参数化查询有本质区别。
攻击利用演示:
假设攻击者在前端传入以下参数:
username: admin' OR '1'='1customFilter: 1=1 UNION SELECT username, password FROM users --
经过服务层的拼接后,whereClause字符串变为:
1=1 AND username = 'admin' OR '1'='1' AND 1=1 UNION SELECT username, password FROM users --最终执行的SQL语句将是:
SELECT * FROM user_operation_log WHERE 1=1 AND username = 'admin' OR '1'='1' AND 1=1 UNION SELECT username, password FROM users --这条语句会先查询日志表,然后通过UNION操作窃取users表中的敏感信息(用户名和密码),--用于注释掉后续可能的SQL代码,保证语句正常执行。
注意:这里
username的注入利用了第一次拼接的漏洞,而customFilter的注入则更为直接和危险。实际上,customFilter参数的设计本身就是极大的安全隐患,它几乎等同于给了攻击者一个执行任意SQL片段的接口。
3.3 第一次修复及其局限性
在第一次安全扫描发现此问题后,开发团队迅速进行了修复。修复方案是:消除Java层的拼接,将所有条件判断下推到MyBatis的XML中,并使用#{}传参。
修复后的服务层代码:
public List<OperationLog> queryLogsFixed(String username, String actionType, String customFilter) { // 不再拼接,直接传递参数 return logMapper.selectLogsByCondition(username, actionType, customFilter); }修复后的Mapper XML:
<select id="selectLogsByCondition" resultType="OperationLog"> SELECT * FROM user_operation_log WHERE 1=1 <if test="username != null and username != ''"> AND username = #{username} </if> <if test="actionType != null and actionType != ''"> AND action_type = #{actionType} </if> <if test="customFilter != null and customFilter != ''"> <!-- 问题依旧:这里如何处理? --> AND ${customFilter} </if> </select>团队认为问题已经解决,因为username和actionType现在都用了#{}。但是,他们忽略了一个关键点:customFilter这个参数。这个参数的本意是让高级管理员可以输入一些额外的过滤条件(比如operation_time > ‘2019-10-10’)。在修复时,他们面临一个两难选择:
- 如果改成
AND #{customFilter},MyBatis会将其作为一个字符串值处理,最终SQL会变成AND ‘operation_time > ‘2019-10-10’’,语法错误。 - 如果保持
${customFilter},则注入漏洞依然存在。
当时的开发人员选择了一个看似聪明实则危险的折中方案:在服务层对customFilter参数进行简单的关键字过滤。他们写了一个方法,检查customFilter中是否包含SELECT、UNION、DROP、--等敏感词,如果包含则拒绝请求。
4. 第二次注入:绕过过滤与逻辑缺陷
4.1 “安全”过滤器的脆弱性
第一次修复后,团队增加了如下过滤函数:
private boolean isValidFilter(String filter) { if (filter == null) return true; String upperFilter = filter.toUpperCase(); String[] blacklist = {"SELECT", "UNION", "INSERT", "DELETE", "UPDATE", "DROP", "ALTER", "--", "#", "/*"}; for (String keyword : blacklist) { if (upperFilter.contains(keyword)) { return false; } } return true; }并在服务层调用:
if (customFilter != null && !customFilter.isEmpty()) { if (!isValidFilter(customFilter)) { throw new IllegalArgumentException("非法过滤条件"); } // 如果通过检查,则继续使用 ${customFilter} }这种基于黑名单的过滤方式存在经典的绕过问题:
- 大小写混合:黑名单检查前转成了大写,所以大小写混合无效。
- 编码与空白符:使用URL编码、十六进制编码、内联注释
/**/或制表符、换行符分割关键字。 - 等价替换与数据库特性:利用数据库特定语法和函数。
4.2 漏洞的再次触发与利用
攻击者这次没有直接使用UNION SELECT。他们发现,这个日志查询功能通常会关联用户表来获取用户昵称(假设通过user_id关联)。原始的、安全的查询可能是这样的:
SELECT l.*, u.nickname FROM user_operation_log l LEFT JOIN sys_user u ON l.user_id = u.id WHERE ...攻击者构造了如下的customFilter参数:
1=1) AND (EXTRACTVALUE(1, CONCAT(0x7e, (SELECT DATABASE()), 0x7e)) AND (1=1注入原理分析:
- 绕过过滤:这个字符串中没有
SELECT、UNION等被黑名单包含的完整单词。EXTRACTVALUE是一个MySQL的XML函数,常用于基于错误的盲注,它不在黑名单中。DATABASE()是函数,也不是关键字。 - 闭合SQL语句:攻击者利用
1=1)提前闭合了原本AND ${customFilter}前面的那个括号(如果存在),然后开始执行自己的恶意代码。 - 执行恶意函数:
EXTRACTVALUE函数会执行第二个参数产生的XPath表达式,而这里通过CONCAT拼接了当前数据库名。由于参数错误(第一个参数是数字1,不是XML文档),MySQL会抛出一个错误,但错误信息中会包含CONCAT执行的结果,即数据库名。 - 维持语法正确:最后的
AND (1=1是为了与后面可能存在的SQL代码保持平衡,避免语法错误。
当这个字符串被${}替换到SQL中后,形成的语句片段为:
... AND (1=1) AND (EXTRACTVALUE(1, CONCAT(0x7e, (SELECT DATABASE()), 0x7e)) AND (1=1) ...数据库执行时,会触发错误,并在错误信息中返回数据库名。攻击者通过捕获应用返回的数据库错误信息,就能一步步窃取数据。
实操心得:黑名单过滤在安全领域几乎被公认为“防君子不防小人”。它的维护成本极高(需要不断更新),且极易被绕过。攻击者的创造力总是比防御者的黑名单要丰富。这个案例中,开发人员误以为过滤了少数几个关键字就安全了,这是一种非常危险的“虚假安全感”。
4.3 问题的根本原因
第二次注入之所以发生,根本原因在于:
- 架构设计缺陷:允许前端直接传递SQL片段(
customFilter)给后端执行,这本身就是一个高危设计。这相当于给了用户部分“数据库解释器”的权限。 - 对
${}的危险性认识不足:团队没有深刻理解#{}和${}的天壤之别。${}应该仅用于动态指定诸如表名、列名等非用户输入的数据,且这些数据必须是服务端可枚举、可控的。 - 安全方案不彻底:第一次修复只解决了“显眼”的拼接问题,但对那个高危的
customFilter参数,采取了妥协且无效的过滤方案,没有从根源上重新设计功能。
5. 彻底修复方案与安全编程实践
5.1 短期紧急修复
针对这个特定漏洞,我们当时采取的紧急修复措施是:
- 完全移除
customFilter参数:与产品经理沟通,确认此功能的实际使用频率极低,且完全可以通过扩展其他固定参数(如增加endTime、operationModule等)来满足需求。因此,直接在前端和后端代码中删除了该参数。 - 全局搜索
${}的使用:在代码库中全局搜索所有使用${}的地方,逐一进行安全审计。确保${}后面跟的变量,其值来源于服务端枚举、配置或经过严格白名单校验,绝对不包含任何用户直接或间接输入。
5.2 长期架构与代码层面加固
紧急修复后,我们推行了一系列长期措施:
5.2.1 确立SQL注入防御第一准则:使用参数化查询
- 强制规范:在所有技术评审和代码审查中,明确要求所有数据库操作必须使用参数化查询(Prepared Statement)。在MyBatis中即意味着99%的情况使用
#{}。 - 例外情况白名单化:如需动态指定表名、列名(例如,做数据报表功能,选择不同的统计维度),必须建立白名单。例如:
private static final Set<String> ALLOWED_COLUMNS = Set.of("username", "operation_time", "action_type"); private static final Set<String> ALLOWED_TABLES = Set.of("user_operation_log", "sys_user"); public String safeColumn(String input) { if (!ALLOWED_COLUMNS.contains(input)) { throw new SecurityException("非法的列名: " + input); } return input; // 此时可以用 ${safeColumn} }5.2.2 引入安全的动态SQL构建器对于复杂的多条件查询,避免任何形式的字符串拼接。可以采用以下方案:
- 优先使用MyBatis动态SQL标签:
<if>,<choose>,<when>,<otherwise>,<foreach>等标签本身是安全的,它们与#{}结合是首选。 - 使用QueryDSL或JPA Criteria API:这些框架通过类型安全的Java API来构建查询,从根本上杜绝了SQL字符串拼接。
- 使用第三方安全SQL构建库:例如使用Apache Commons Lang的
StringEscapeUtils只能转义,不能防注入,不推荐。应使用专门设计用于安全构建SQL的库。
5.2.3 实施纵深防御
- Web应用防火墙(WAF):在应用前端部署WAF,可以拦截常见的SQL注入攻击payload,作为一道额外的防线。但不能依赖WAF作为唯一防线。
- 最小权限原则:连接数据库的应用程序账号,只授予其必要的最小权限(如只有SELECT、INSERT、UPDATE特定表,无DROP、ALTER、CREATE等权限)。这样即使发生注入,损害也能被限制。
- 输入验证与输出编码:虽然对防SQL注入主要靠参数化查询,但对所有输入进行严格的格式、类型、长度验证(如用户名只允许字母数字,时间必须是合法格式),是良好的安全习惯。同时,对输出到前端的数据进行HTML编码,防止XSS等二次攻击。
5.3 MyBatis中#{}与${}的再辨析
这是本案例的核心知识点,值得单独强调:
| 特性 | #{}(参数占位符) | ${}(字符串替换) |
|---|---|---|
| 处理方式 | 预编译(PreparedStatement) | 字符串直接拼接(Statement) |
| 安全性 | 高,可防止SQL注入 | 低,存在SQL注入风险 |
| 使用场景 | 传入值(WHERE条件值、INSERT值等) | 传入SQL片段(表名、列名、ORDER BY子句等) |
| 举例 | WHERE username = #{name}->WHERE username = ? | ORDER BY ${columnName}->ORDER BY create_time |
| 与输入关系 | 必须用于处理用户输入或外部变量 | 绝对不要直接用于处理用户输入 |
一个简单的记忆口诀:“井号传值,美元传名”。传“值”用#{},传“名”(标识符)用${},且传“名”时必须确保“名”是安全可控的。
6. 漏洞排查与自动化检测建议
6.1 人工代码审计要点
在审计代码时,应像侦探一样寻找以下“危险信号”:
- 搜索
${:这是最高效的起点。检查每一个${}的使用点,判断其变量来源。如果来源是HttpServletRequest.getParameter()、@RequestParam、用户输入对象等,立即标记为高危。 - 审查字符串拼接操作:查找代码中与SQL相关的
StringBuilder、StringBuffer、“+”拼接操作。特别是拼接后再传递给数据库执行的方法。 - 关注“动态查询”、“灵活查询”、“自定义过滤”等命名的功能模块或参数,这些往往是高风险点。
- 检查数据库操作框架的非常规用法:例如,直接使用
JdbcTemplate的query(String sql, ...)方法(接受SQL字符串),而不是query(String sql, Object[] args, ...)方法(接受参数化SQL)。
6.2 自动化工具集成
人工审计耗时耗力,必须借助自动化工具:
- 静态应用程序安全测试(SAST):集成SonarQube、Fortify、Checkmarx等工具到CI/CD流水线。这些工具可以扫描源代码,识别出潜在的SQL注入漏洞模式(如字符串拼接、不安全的
${}使用)。需要针对团队的技术栈(如MyBatis)配置相应的规则集。 - 依赖项扫描(SCA):使用OWASP Dependency-Check、Snyk等工具,检查项目依赖的第三方库是否存在已知的、包含SQL注入漏洞的版本。
- 动态应用程序安全测试(DAST):使用OWASP ZAP、Burp Suite等工具,对运行中的应用进行黑盒测试,自动发送大量测试payload,探测是否存在可注入的点。
6.3 常见问题排查速查表
| 现象/疑问 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
我的MyBatis查询用了#{},但日志显示SQL还是被注入了。 | 1. 可能在某些动态部分(如<if test>中的条件)误用了${}。2. 可能在其他非MyBatis的数据库操作中(如直接JDBC)存在拼接。 3. 可能 #{}中的参数在传入前已被恶意修改(如中间件漏洞)。 | 1. 检查Mapper XML中所有${}。2. 全局搜索 Statement、executeQuery(String sql)。3. 检查参数处理流程,确认是否有多处赋值或拦截器篡改。 |
我需要动态排序(ORDER BY),必须用${},怎么办? | 直接使用${column}风险极高。 | 实现白名单校验。前端传递枚举值(如“create_time_asc”),后端解析后映射为安全的列名“create_time ASC”。 |
| 使用了ORM(如JPA/Hibernate)就一定安全吗? | 不一定。如果使用原生SQL(createNativeQuery)并拼接字符串,同样存在注入风险。 | 检查所有@Query注解中nativeQuery = true的语句,以及EntityManager.createNativeQuery()的调用,确保它们使用了参数绑定(setParameter)。 |
| WAF已经拦截了,为什么还要修代码? | WAF可能被绕过(如0day攻击、编码绕过)。WAF是网络层防护,代码修正是应用层根本解决。 | 坚持“安全左移”,将安全能力内置到开发阶段。WAF作为纵深防御的补充,而非依赖。 |
7. 总结与个人体会
回顾这个“Twice SQL Injection”案例,它给我的最大教训是:安全不是一个可以“打补丁”的特性,而是一种必须贯穿于设计、编码、测试全流程的思维方式。
第一次注入是初级错误,源于对基础安全知识的缺失。第二次注入则更具欺骗性,它发生在一次“修复”之后,源于对漏洞原理的片面理解和对“银弹”的过度信任(认为参数化查询能解决一切,却忽略了${}这个特例)。这提醒我们,修复漏洞时必须追根溯源,理解漏洞产生的完整上下文和根本原因,而不是仅仅消除表面现象。
对于开发团队而言,建立并执行严格的安全编码规范、进行定期的安全培训、将SAST工具集成到开发流水线中,是避免此类问题重复发生的关键。对于安全工程师而言,在审计时不仅要看“怎么做”,更要问“为什么这么做”,理解业务逻辑往往能帮助发现更深层次的设计缺陷。
最后,分享一个我在代码审查时常用的小技巧:当看到一段数据库交互代码时,下意识地问自己:“用户输入的数据,在这里是作为‘数据’被处理,还是作为‘代码’被解释?”如果答案是“作为代码”,那么这里十有八九存在注入风险。坚持让用户输入永远只作为“数据”来处理,是抵御SQL注入最坚固的防线。
