Mybatis SQL注入审计:从#{}与${}原理到实战代码审计
1. 项目概述为什么Mybatis的SQL注入审计是门必修课如果你是一名Java开发者或者正在向安全方向转型那么“Mybatis框架SQL注入审计”这个主题绝对是你绕不开的核心技能点。我见过太多项目前端做得花里胡哨微服务架构也搭得有模有样但一翻后台的Mybatis Mapper文件${xxx}满天飞潜在的SQL注入漏洞就像埋了一地的雷。这不仅仅是新手容易犯的错在一些历史包袱重、迭代匆忙的项目里老手也可能因为一时疏忽或者对Mybatis机制理解不透彻而踩坑。今天我们就抛开那些泛泛而谈的安全原则深入到Mybatis的源码层面把“怎么判断SQL注入”这件事掰开了、揉碎了讲清楚。这不是一篇简单的工具使用指南而是一次从原理到实战的深度代码审计思维训练。无论你是想加固自己项目的安全防线还是准备投身于专业的代码审计工作理解Mybatis如何解析SQL、处理参数都是你构建完整Web安全知识体系的关键一环。2. Mybatis SQL执行的核心原理与参数处理机制要判断SQL注入首先你得明白Mybatis是怎么干活儿的。很多人用了很久Mybatis却只停留在“写XML、调接口”的层面这对于审计来说是远远不够的。我们必须深入到它处理SQL语句的“心脏”部位。2.1 SQL语句的构建与解析流程Mybatis执行一条SQL并不是简单地把我们写在XML里的字符串扔给数据库。它会经历一个复杂的解析和构建过程。核心类SqlSource负责代表一条SQL语句的内容而BoundSql则包含了最终要提交给JDBC的、已经完成参数替换的SQL字符串以及参数映射信息。当你定义一个Mapper方法时Mybatis会根据你的XML配置或注解创建一个MappedStatement对象。这个对象是执行操作的蓝图。其中关键的一步就是由LanguageDriver默认是XMLLanguageDriver来解析你的SQL文本创建SqlSource。解析过程中Mybatis会识别SQL文本中的动态标签如if,where,foreach和参数占位符#{}和${}。对于#{}它会被解析成?占位符并记录对应的参数映射关系对于${}它的内容会被直接拼接到SQL字符串中。这里有一个至关重要的细节这个“拼接”发生在哪个阶段它发生在SqlSource被解析成BoundSql的时候。也就是说在Mybatis框架内部SQL语句的“骨架”就已经确定了${}的内容在此时已经成为了SQL字符串的一部分。这和我们后面要讲的#{}的预编译机制有本质区别。2.2#{}与${}的本质区别预编译 vs. 字符串拼接这是Mybatis审计中最经典、也最核心的一个知识点。但很多人只记住了结论“#{}安全${}不安全”却不清楚背后的“为什么”。我们从JDBC的层面来理解。#{}安全推荐 当你在XML中写select * from user where id #{userId}Mybatis在解析时会将其转换为select * from user where id ?。这个?是一个JDBC的预编译语句PreparedStatement的参数占位符。随后当你传入参数比如userId1或userId1 or 11这个参数值会被传递给PreparedStatement的setXxx()方法例如setInt()或setString()。数据库驱动会负责对传入的参数值进行正确的类型处理和转义确保它永远被当作一个数据值来处理而不是SQL代码的一部分。即使用户输入了恶意的SQL片段如1 or 11到了数据库那里它就是一个普通的字符串值查询会变成where id 1\ or \1\\1假设是字符串类型这个字符串整体作为id去匹配自然匹配不到任何结果从而避免了注入。${}危险需谨慎 当你在XML中写select * from user where id ${userId}Mybatis在解析时会进行简单的字符串替换。假设userId传入的值是字符串1那么生成的SQL就是select * from user where id 1。注意这里没有引号如果userId传入的是1 or 11那么生成的SQL就变成了select * from user where id 1 or 11。这条SQL被直接发送给数据库执行。由于没有预编译机制的保护or 11这部分被数据库解析为有效的SQL逻辑导致了注入。关键陷阱很多人误以为${}在数字型字段下是安全的因为不需要引号。这大错特错如果攻击者传入1 or 11生成的SQL是id 1 or 11同样会造成注入。安全与否不取决于字段类型而取决于参数是否受控且可信。${}应该只用于拼接SQL语句中非用户输入、完全可控的部分例如动态表名、列名order by ${columnName}且这些值必须来自白名单绝不能直接来自前端请求参数。2.3 Mybatis动态SQL标签的潜在风险Mybatis提供了一系列强大的动态SQL标签如if,choose,when,otherwise,where,set,foreach,bind等。它们本身是为了灵活构建SQL而设计的但使用不当就会成为注入的帮凶。最常见的风险模式是在动态标签内部使用了${}进行拼接。例如select idfindUser parameterTypemap resultTypeUser SELECT * FROM user WHERE 11 if testname ! null AND name ${name} /if if testorderBy ! null ORDER BY ${orderBy} /if /select这里的${name}和${orderBy}都是高危点。${name}直接将用户输入的姓名拼接到引号内存在注入风险。${orderBy}常用于排序如果直接使用用户传入的orderBy参数如name; drop table user --后果不堪设想。正确的做法对于WHERE条件中的值无条件使用#{}。AND name #{name}对于ORDER BY、表名、列名等SQL关键字或标识符必须使用白名单机制。绝对不要直接拼接用户输入。// 在Java代码层进行校验 private static final SetString ALLOWED_ORDER_COLUMNS Set.of(id, name, create_time); public String safeFindUser(MapString, Object params) { String orderBy (String) params.get(orderBy); if (orderBy ! null !ALLOWED_ORDER_COLUMNS.contains(orderBy)) { orderBy id; // 默认值 } params.put(safeOrderBy, orderBy); // 然后Mapper XML中使用 ${safeOrderBy} }XML中对应使用经过校验的参数ORDER BY ${safeOrderBy}虽然这里还是用了${}但safeOrderBy的值已经过白名单过滤是安全的。foreach标签通常用于IN查询如id in (1,2,3)。Mybatis在处理foreach集合时如果使用#{}它会自动展开为多个?占位符id in (?, ?, ?)这是安全的。但如果错误地尝试用${}来拼接整个IN列表的字符串就会立刻引入注入漏洞。理解这些底层原理是我们进行有效代码审计的基础。接下来我们就进入实战环节看看如何系统地发现这些风险点。3. 代码审计实战定位与判断SQL注入漏洞知道了原理我们就要像侦探一样在代码中寻找线索。审计Mybatis的SQL注入核心就是找${}但不仅仅是找还要判断它是否危险。这是一个系统性的过程。3.1 审计入口与核心文件定位审计从哪里开始对于Spring Boot Mybatis的项目我通常遵循以下路径定位Mapper接口首先找到Mapper注解的接口或MapperScan扫描的包。这些接口定义了数据库操作方法。定位XML映射文件根据Mybatis的配置mybatis.mapper-locations找到对应的*Mapper.xml文件。这是审计的重中之重。通常它们位于resources/mapper/或resources/mybatis/目录下。定位SQL构建的Java代码除了XMLMybatis也支持注解方式Select,Update等和Provider类SelectProvider。这些也需要检查。特别是Provider类中通过字符串拼接SQL的情况风险极高。一个高效的技巧是使用IDE的全局搜索功能如IntelliJ IDEA的CtrlShiftF或VS Code的全局搜索直接搜索${这个字符串。这会快速定位所有可能的风险点。3.2 高风险模式识别与案例分析找到${}之后我们需要进行风险评估。不是所有${}都意味着漏洞但以下模式风险极高模式一直接拼接用户输入到WHERE条件值select idlogin parameterTypeString resultTypeUser SELECT * FROM users WHERE username ${username} AND password ${password} /select这是最典型的注入漏洞。攻击者可以在username中输入admin --来注释掉密码检查。审计时看到这种直接将请求参数尤其是来自HttpServletRequest.getParameter、RequestParam的参数用${}拼接到条件值中的基本可以判定为高危漏洞。模式二动态ORDER BY、GROUP BY、表名、列名拼接select idfindList resultTypeMap SELECT * FROM ${tableName} ORDER BY ${sortField} ${sortOrder} /select如前所述这里的${tableName},${sortField},${sortOrder}如果直接来自用户输入攻击者可以注入任意SQL片段。例如sortField传入id; (SELECT SLEEP(10)) --可能导致时间盲注。审计时需要追踪这些参数的来源看是否有白名单校验。模式三在if、choose等动态标签内使用${}if testsearchKey ! null and searchKey ! AND (title LIKE %${searchKey}% OR content LIKE %${searchKey}%) /ifLIKE模糊查询本身需要用引号包裹这里用${}拼接攻击者输入% OR 11即可构成注入。正确的做法是使用#{}并在Java代码或XML中使用bind标签或CONCAT函数处理LIKE模式bind namepattern value% searchKey %/ AND (title LIKE #{pattern} OR content LIKE #{pattern})或者AND (title LIKE CONCAT(%, #{searchKey}, %))模式四MyBatis注解中的SQL拼接Select(SELECT * FROM user WHERE id ${id}) User findById(Param(id) String id);在Select、Update等注解中直接使用字符串拼接SQL同样危险。审计时不要忽略注解方式。模式五SqlProvider类中的字符串拼接public String findUserByCondition(MapString, Object params) { String sql SELECT * FROM user WHERE 11; if (params.get(name) ! null) { sql AND name params.get(name) ; // 高危直接字符串拼接 } return sql; }在SelectProvider、InsertProvider等方法中如果通过Java字符串拼接来构建SQL其风险与在XML中使用${}等同甚至更隐蔽。审计时需要仔细检查这些Provider方法的实现。3.3 参数溯源与数据流分析判断一个${}是否构成漏洞关键在于参数溯源。我们需要追踪这个参数值从哪里来经过了哪些处理。从Mapper接口方法开始找到使用该XML语句的Mapper方法查看其参数列表。追踪调用链向上追踪看这个Mapper方法被哪个Service方法调用Service方法又被哪个Controller调用。分析参数来源在Controller中查看参数是如何获取的。是来自RequestParam、RequestBody、HttpServletRequest还是从Session或数据库中获取的检查过滤与校验在参数传递的路径上是否有进行安全校验例如类型转换如果Mapper方法参数是Integer但Controller接收的是String框架或代码是否做了安全的转换攻击者传入非数字字符串可能导致异常但不一定是SQL注入。数据清洗是否有调用StringEscapeUtils.escapeSql注意这个方法是过时且不安全的它只为JDBC转义并非针对所有数据库且不能防注入只能防部分语法错误切勿依赖。白名单校验对于ORDER BY等场景是否有将参数与一个固定的允许列表进行比较业务逻辑过滤参数是否经过复杂的业务逻辑处理最终值是否完全由系统生成与用户输入无关例如根据用户角色从配置表查出一个固定的排序字段。如果经过追踪发现${}中的值最终直接或间接来源于不可信的用户输入如HTTP请求参数并且没有经过有效的白名单校验或安全的映射那么就可以判定存在SQL注入漏洞的风险。实操心得在审计大型项目时手动追踪每个参数非常耗时。可以结合静态代码分析工具如Fortify、Checkmarx、SonarQube进行初步扫描这些工具能识别出常见的危险模式。但工具会有误报和漏报最终仍需人工进行数据流分析和逻辑确认。将工具扫描结果作为切入点能极大提高审计效率。4. 深入源码Mybatis如何解析#{}和${}为了更深刻地理解两者的区别我们不妨深入到Mybatis的源码里看一眼。这能让你在面试或讨论时更有底气。我们聚焦于org.apache.ibatis.scripting.xmltags这个包。4.1TextSqlNode与DynamicContextMybatis在解析XML时会将SQL文本分解为多个SqlNode。对于包含${}的普通文本会创建TextSqlNode对象。TextSqlNode的apply方法是关键// 简化后的逻辑 public boolean apply(DynamicContext context) { // 使用GenericTokenParser解析文本中的${}占位符 GenericTokenParser parser new GenericTokenParser(${, }, handler - { // 通过OGNL表达式从参数对象中获取值 Object value OgnlCache.getValue(handler, context.getBindings()); // 将获取到的值**直接拼接**到SQL字符串中 String s value null ? : String.valueOf(value); // 这里注意如果value本身包含SQL特殊字符它们会被原样拼接进去 context.appendSql(s); return ; }); // 解析并拼接 context.appendSql(parser.parse(text)); return true; }看${}的内容value被直接String.valueOf()后就appendSql了。没有任何转义或预编译处理。这就是字符串替换的本质。4.2ParameterMappingTokenHandler与PreparedStatement而对于#{}解析过程完全不同。Mybatis会创建ParameterMappingTokenHandler来处理// 简化逻辑 public String handleToken(String content) { // 1. 为每个#{}创建一个ParameterMapping对象记录参数名、类型处理器等元数据 parameterMappings.add(buildParameterMapping(content)); // 2. 返回一个?占位符 return ?; }最终在DefaultParameterHandler中Mybatis会遍历parameterMappings通过TypeHandler调用PreparedStatement.setXxx()方法来为每个?设置参数值。// 简化逻辑 for (ParameterMapping parameterMapping : parameterMappings) { Object value; // ... 从参数对象中解析出值 ... TypeHandler typeHandler parameterMapping.getTypeHandler(); // 关键步骤调用JDBC PreparedStatement的set方法 typeHandler.setParameter(ps, i 1, value, parameterMapping.getJdbcType()); }TypeHandler的setParameter方法最终会调用类似ps.setString(parameterIndex, value)的代码。JDBC驱动会负责对这个value进行正确的编码使其成为单纯的“数据”而不会破坏SQL语句结构。4.3 OGNL表达式注入的潜在风险注意到在解析${}时Mybatis使用了OGNLObject-Graph Navigation Language表达式来从参数对象中取值OgnlCache.getValue。这本身是一个强大的功能允许你使用${user.name}这样的表达式。但如果用户能够控制OGNL表达式的内容就可能造成更严重的“OGNL表达式注入”漏洞。考虑一个极端不安全的写法现实中应绝对避免SELECT * FROM news WHERE id ${id}如果攻击者传入的id参数值是1} and ${java.lang.RuntimegetRuntime().exec(calc)并且系统在某些旧版本或特定配置下可能造成OGNL表达式执行。这比SQL注入更可怕因为它可能导致远程代码执行RCE。虽然现代Mybatis版本默认有安全限制但在审计时看到${}中使用了复杂的点号路径如${object.method()}也需要保持警惕评估参数是否完全可控。理解源码层面的差异让我们对“为什么#{}安全”有了铁证般的认识。这也提醒我们在代码审计时不仅要看表面还要思考数据在框架内部的流转过程。5. 进阶审计场景与疑难问题排查在实际审计中尤其是面对历史项目或复杂业务逻辑时会遇到一些更隐蔽或需要深入判断的场景。5.1IN语句与foreach标签的正确用法IN查询是SQL注入的重灾区。错误的写法SELECT * FROM user WHERE id IN (${ids})如果ids是用户输入的1,2,3看似没问题。但如果用户输入1) OR 11 --SQL就变成了id IN (1) OR 11 --)造成注入。正确的安全写法是使用foreach配合#{}SELECT * FROM user WHERE id IN foreach collectionidList itemid open( separator, close) #{id} /foreach这里idList是一个Java集合如ListInteger。Mybatis会将其展开为(?, ?, ?)并为每个位置使用预编译。即使idList中的数据来自用户也是安全的因为每个值都是通过setXxx方法传入的。常见问题当IN列表很大时担心性能问题。有些开发者会想用${}拼接一个长字符串。这绝对不可取。性能问题应该通过数据库优化如临时表、分批次查询或应用层缓存来解决绝不能牺牲安全。5.2LIKE模糊查询的陷阱与安全方案如前所述LIKE %${keyword}%是危险的。安全方案有几种使用#{}与CONCAT函数推荐数据库通用AND name LIKE CONCAT(%, #{keyword}, %)使用bind标签Mybatis特性bind namepattern value% keyword % / AND name LIKE #{pattern}注意bind标签中的value是一个OGNL表达式这里进行的字符串拼接发生在Java代码层面生成的结果字符串pattern会作为一个整体通过#{}预编译传入SQL。因此是安全的。在Java代码中拼接好模式串String pattern % keyword %; paramMap.put(pattern, pattern);XML中直接使用LIKE #{pattern}。5.3 动态表名/列名与白名单机制的最佳实践业务中确实存在需要动态指定表名或列名的场景比如分表user_2024、动态报表列。使用${}是必要的但必须结合白名单。错误示范String tableName request.getParameter(table); // 直接来自用户 MapString, Object params new HashMap(); params.put(tableName, tableName); userMapper.selectFromTable(params);SELECT * FROM ${tableName}安全实践建立严格的白名单映射public class TableNameValidator { private static final MapString, String TABLE_WHITELIST new HashMap(); static { TABLE_WHITELIST.put(user, t_user_info); TABLE_WHITELIST.put(order, t_order_main); // ... 其他映射 } public static String getSafeTableName(String input) { // 白名单校验 String safeName TABLE_WHITELIST.get(input); if (safeName null) { throw new IllegalArgumentException(Invalid table name: input); } return safeName; } }在Service层进行校验和转换Service public class UserService { public ListUser getData(String tableKey) { String safeTableName TableNameValidator.getSafeTableName(tableKey); return userMapper.selectFromTable(safeTableName); // 传入安全的名字 } }Mapper XML中使用经过校验的参数SELECT * FROM ${safeTableName}此时${safeTableName}的值完全在应用控制之下是安全的。5.4 MyBatis-Plus等增强框架的审计要点MyBatis-PlusMP等工具在Mybatis基础上提供了更多便利但审计时需要注意其特有的API。QueryWrapper/LambdaQueryWrapper MP的Wrapper构建查询条件非常方便。大部分情况下使用eq、like等方法MP会自动使用预编译是安全的。例如new QueryWrapperUser().eq(name, nameParam) // 安全但是apply方法和last方法非常危险wrapper.apply(date_format(create_time,%Y-%m-%d) {0}, dateParam); // 如果{0}是#{}预编译则安全但需确认 wrapper.last(limit 1); // 直接拼接SQL片段如果参数可控则高危apply方法中如果SQL片段包含用户可控部分且未正确使用#{}占位符则存在注入。last方法是直接拼接绝对禁止传入用户可控数据。自定义SQLSelect注解或XML在MP项目中同样可能存在审计规则与原生Mybatis完全一致。SqlParser注解与多租户关注多租户场景下动态表名或条件拼接的逻辑检查是否有绕过租户隔离的注入风险。审计MP项目时要重点关注那些绕过MP条件构造器、直接进行SQL字符串拼接的代码。6. 自动化审计工具辅助与人工验证完全依赖人工审计海量代码是不现实的。我们需要借助工具但更要理解工具的局限性。6.1 常用静态代码分析工具SAST扫描与结果解读Fortify, Checkmarx, SonarQube这些商业或开源的SAST工具都有检测SQL注入的规则。它们能识别出${}的使用、字符串拼接等模式。Semgrep对于Java和Mybatis可以编写自定义规则来检测危险模式非常灵活。例如一个简单的规则可以查找XML文件中包含${的文本。IDEA插件有些代码审计插件能辅助高亮显示潜在的危险代码。工具局限性误报False Positive工具可能将安全的${}使用如经过严格白名单校验的也报告为漏洞。例如工具看到ORDER BY ${sortField}就会报警即使sortField在代码上层被限制为id或name。漏报False Negative工具可能无法识别复杂的、间接的数据流。例如参数从HTTP请求中获取经过多个方法传递和转换最终用在${}里如果数据流分析不深入可能会漏掉。无法理解业务逻辑工具不知道某个参数是否来自可信的配置源或经过业务逻辑强校验。因此工具报告只是一个“线索清单”必须由审计人员进行人工验证。6.2 人工验证漏洞的三步法拿到工具报告或自己找到可疑点后按以下步骤验证确认数据源这个${}中的变量它的值最初来自哪里是否是用户直接输入HTTP参数、Cookie、Header还是来自数据库、配置文件、Session如果是后者需要继续追溯这些源头是否可能被用户间接影响。追踪数据流从数据源到最终使用点数据经过了哪些方法有没有进行过滤、校验、编码关键的校验逻辑是否可靠例如一个简单的if (value ! null)不能防注入。构造POC验证本地验证在开发或测试环境尝试构造恶意输入触发SQL语句执行。查看Mybatis打印的日志开启mybatis.configuration.log-impl为STDOUT_LOGGING观察最终执行的SQL语句是否被篡改。时间盲注验证如果页面没有明显回显可以尝试构造时间延迟的POC。例如在参数中尝试注入 AND SLEEP(5) --观察请求响应时间是否明显延长。注意此操作仅限授权测试环境切勿在生产环境尝试错误回显验证尝试注入单引号、双引号、反斜杠\等看是否会触发数据库语法错误并将错误信息回显到前端这有助于确认漏洞存在和数据库类型。6.3 审计报告撰写要点发现漏洞后需要清晰地记录和报告漏洞位置精确到文件、方法、行号。例如src/main/resources/mapper/UserMapper.xml:25。漏洞代码贴出有问题的代码片段。风险分析说明为什么这里是漏洞攻击者可能如何利用例如可导致数据泄露、篡改、删除。数据流追踪简要描述用户输入如何到达漏洞点。复现步骤提供具体的HTTP请求示例或参数值证明漏洞可被利用。修复建议给出具体的、可操作的修复方案。例如“将${name}改为#{name}”或“在Service层添加白名单校验只允许‘id’和‘create_time’作为排序字段”。风险等级根据漏洞利用难度和影响范围评估为高危、中危或低危。通过系统性的工具辅助和严谨的人工分析你就能成为一名高效的Mybatis代码审计者。记住安全是一个持续的过程代码审计是其中至关重要的一环。每一次严谨的审计都是在为系统的安全防线添砖加瓦。