1. 项目概述一次关于SQL注入的深度复盘几年前我在一次内部安全审计中遇到了一个非常典型的案例我习惯性地称之为“Twice SQL Injection”。这个名字听起来有点绕但它精准地概括了这次漏洞的本质一个看似简单的SQL注入点因为开发人员在不同层级、以不同方式重复犯了两次几乎相同的错误最终导致了一个高危漏洞的产生。这不是一个虚构的靶场练习而是发生在2019年10月一个真实线上业务中的事情。今天我就把这个案例从头到尾拆解一遍不仅会还原漏洞的发现、利用和修复过程更重要的是我会深入分析其背后的代码逻辑、开发人员的思维误区以及我们如何建立机制来避免这类“重复犯错”的问题。无论你是刚入门的安全工程师还是有一定经验的开发者相信这个案例都能给你带来一些关于代码安全和防御纵深建设的启发。这个案例涉及一个用户查询功能表面上看它已经使用了参数化查询Prepared Statement这通常是防止SQL注入的“银弹”。但魔鬼藏在细节里正是这种“以为已经安全了”的松懈导致了漏洞的二次产生。我们将从漏洞现象入手逐步深入到代码层、框架层最后讨论修复方案与最佳实践。我会尽量用通俗的语言解释技术细节并提供可直接参考的代码示例和排查思路。2. 漏洞背景与功能场景解析2.1 业务功能描述存在漏洞的系统是一个内容管理平台的后台模块其中一个核心功能是“用户行为日志查询”。管理员可以通过此功能根据用户名、时间范围、操作类型等多个条件筛选和查看用户的操作记录。前端是一个常见的表单查询页面后端则是一个标准的Spring Boot MyBatis技术栈的应用。查询的核心逻辑是前端提交表单数据如usernameadminstartTime2019-10-01actionTypeLOGIN后端控制器接收参数然后调用服务层的方法服务层再通过MyBatis的Mapper接口执行数据库查询。问题就出在服务层组装查询条件的过程中。2.2 技术栈与初始安全认知项目团队在当时已经具备了基本的安全意识他们知道直接拼接SQL字符串是危险的。因此在MyBatis的Mapper XML文件中他们普遍使用了#{}语法进行参数绑定例如select idselectLogs resultTypeLog SELECT * FROM user_operation_log WHERE username #{username} if teststartTime ! 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 ListOperationLog queryLogs(String username, String actionType, String customFilter) { StringBuilder whereClause new StringBuilder(11); // 常见的初始化技巧 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接口 ListOperationLog selectLogsByDynamicWhere(Param(whereClause) String whereClause);!-- Mapper XML -- select idselectLogsByDynamicWhere resultTypeOperationLog SELECT * FROM user_operation_log WHERE ${whereClause} /select3.2 漏洞原理与利用分析这里出现了两个致命问题在Java层拼接字符串username和actionType虽然经过了判空但直接使用append(“‘”).append(username).append(“‘”)的方式拼接如果username中包含单引号就会破坏SQL语法。MyBatis中${}的使用在Mapper XML中他们使用了${whereClause}。在MyBatis中${}是字符串替换它会将传入的参数原封不动地拼接到SQL语句中而不会进行预编译处理。这与#{}的参数化查询有本质区别。攻击利用演示假设攻击者在前端传入以下参数username: admin OR 11customFilter: 11 UNION SELECT username, password FROM users --经过服务层的拼接后whereClause字符串变为11 AND username admin OR 11 AND 11 UNION SELECT username, password FROM users --最终执行的SQL语句将是SELECT * FROM user_operation_log WHERE 11 AND username admin OR 11 AND 11 UNION SELECT username, password FROM users --这条语句会先查询日志表然后通过UNION操作窃取users表中的敏感信息用户名和密码--用于注释掉后续可能的SQL代码保证语句正常执行。注意这里username的注入利用了第一次拼接的漏洞而customFilter的注入则更为直接和危险。实际上customFilter参数的设计本身就是极大的安全隐患它几乎等同于给了攻击者一个执行任意SQL片段的接口。3.3 第一次修复及其局限性在第一次安全扫描发现此问题后开发团队迅速进行了修复。修复方案是消除Java层的拼接将所有条件判断下推到MyBatis的XML中并使用#{}传参。修复后的服务层代码public ListOperationLog queryLogsFixed(String username, String actionType, String customFilter) { // 不再拼接直接传递参数 return logMapper.selectLogsByCondition(username, actionType, customFilter); }修复后的Mapper XMLselect idselectLogsByCondition resultTypeOperationLog SELECT * FROM user_operation_log WHERE 11 if testusername ! null and username ! AND username #{username} /if if testactionType ! null and actionType ! AND action_type #{actionType} /if if testcustomFilter ! 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参数11) AND (EXTRACTVALUE(1, CONCAT(0x7e, (SELECT DATABASE()), 0x7e)) AND (11注入原理分析绕过过滤这个字符串中没有SELECT、UNION等被黑名单包含的完整单词。EXTRACTVALUE是一个MySQL的XML函数常用于基于错误的盲注它不在黑名单中。DATABASE()是函数也不是关键字。闭合SQL语句攻击者利用11)提前闭合了原本AND ${customFilter}前面的那个括号如果存在然后开始执行自己的恶意代码。执行恶意函数EXTRACTVALUE函数会执行第二个参数产生的XPath表达式而这里通过CONCAT拼接了当前数据库名。由于参数错误第一个参数是数字1不是XML文档MySQL会抛出一个错误但错误信息中会包含CONCAT执行的结果即数据库名。维持语法正确最后的AND (11是为了与后面可能存在的SQL代码保持平衡避免语法错误。当这个字符串被${}替换到SQL中后形成的语句片段为... AND (11) AND (EXTRACTVALUE(1, CONCAT(0x7e, (SELECT DATABASE()), 0x7e)) AND (11) ...数据库执行时会触发错误并在错误信息中返回数据库名。攻击者通过捕获应用返回的数据库错误信息就能一步步窃取数据。实操心得黑名单过滤在安全领域几乎被公认为“防君子不防小人”。它的维护成本极高需要不断更新且极易被绕过。攻击者的创造力总是比防御者的黑名单要丰富。这个案例中开发人员误以为过滤了少数几个关键字就安全了这是一种非常危险的“虚假安全感”。4.3 问题的根本原因第二次注入之所以发生根本原因在于架构设计缺陷允许前端直接传递SQL片段customFilter给后端执行这本身就是一个高危设计。这相当于给了用户部分“数据库解释器”的权限。对${}的危险性认识不足团队没有深刻理解#{}和${}的天壤之别。${}应该仅用于动态指定诸如表名、列名等非用户输入的数据且这些数据必须是服务端可枚举、可控的。安全方案不彻底第一次修复只解决了“显眼”的拼接问题但对那个高危的customFilter参数采取了妥协且无效的过滤方案没有从根源上重新设计功能。5. 彻底修复方案与安全编程实践5.1 短期紧急修复针对这个特定漏洞我们当时采取的紧急修复措施是完全移除customFilter参数与产品经理沟通确认此功能的实际使用频率极低且完全可以通过扩展其他固定参数如增加endTime、operationModule等来满足需求。因此直接在前端和后端代码中删除了该参数。全局搜索${}的使用在代码库中全局搜索所有使用${}的地方逐一进行安全审计。确保${}后面跟的变量其值来源于服务端枚举、配置或经过严格白名单校验绝对不包含任何用户直接或间接输入。5.2 长期架构与代码层面加固紧急修复后我们推行了一系列长期措施5.2.1 确立SQL注入防御第一准则使用参数化查询强制规范在所有技术评审和代码审查中明确要求所有数据库操作必须使用参数化查询Prepared Statement。在MyBatis中即意味着99%的情况使用#{}。例外情况白名单化如需动态指定表名、列名例如做数据报表功能选择不同的统计维度必须建立白名单。例如private static final SetString ALLOWED_COLUMNS Set.of(username, operation_time, action_type); private static final SetString 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就一定安全吗不一定。如果使用原生SQLcreateNativeQuery并拼接字符串同样存在注入风险。检查所有Query注解中nativeQuery true的语句以及EntityManager.createNativeQuery()的调用确保它们使用了参数绑定setParameter。WAF已经拦截了为什么还要修代码WAF可能被绕过如0day攻击、编码绕过。WAF是网络层防护代码修正是应用层根本解决。坚持“安全左移”将安全能力内置到开发阶段。WAF作为纵深防御的补充而非依赖。7. 总结与个人体会回顾这个“Twice SQL Injection”案例它给我的最大教训是安全不是一个可以“打补丁”的特性而是一种必须贯穿于设计、编码、测试全流程的思维方式。第一次注入是初级错误源于对基础安全知识的缺失。第二次注入则更具欺骗性它发生在一次“修复”之后源于对漏洞原理的片面理解和对“银弹”的过度信任认为参数化查询能解决一切却忽略了${}这个特例。这提醒我们修复漏洞时必须追根溯源理解漏洞产生的完整上下文和根本原因而不是仅仅消除表面现象。对于开发团队而言建立并执行严格的安全编码规范、进行定期的安全培训、将SAST工具集成到开发流水线中是避免此类问题重复发生的关键。对于安全工程师而言在审计时不仅要看“怎么做”更要问“为什么这么做”理解业务逻辑往往能帮助发现更深层次的设计缺陷。最后分享一个我在代码审查时常用的小技巧当看到一段数据库交互代码时下意识地问自己“用户输入的数据在这里是作为‘数据’被处理还是作为‘代码’被解释”如果答案是“作为代码”那么这里十有八九存在注入风险。坚持让用户输入永远只作为“数据”来处理是抵御SQL注入最坚固的防线。