
别急着找漏洞先搞清楚Java里SQL注入是怎么发生的做了几年的代码审计接触过不少Java项目发现很多人一上来就拿着工具扫扫出一堆疑似注入点结果复核的时候一半都是误报。真正的问题反而被漏掉。Java代码审计里的SQL注入跟PHP那种直接拼字符串的套路不太一样它的触发面更隐蔽组合方式更多有时候一个不起眼的自定义ORM方法就能让你翻车。这篇文章我会从我实际审过的项目出发把Java侧SQL注入的成因、审计思路、利用细节和修复方案完整过一遍。不管你是刚转安全方向的新手还是在写业务代码时想顺便排查一下自己项目的工程师这篇文章都能给你一条比较清晰的排查路径。我会尽量把每一步的操作意图和背后的原理讲清楚而不是只给你一堆结论。1. SQL注入在Java项目里的真实触发面先说个基本判断Java项目里SQL注入不是说有没有用PreparedStatement这么简单。哪怕你的代码里到处都是PreparedStatement只要有一个地方用了字符串拼接整个防线就可能被撕开。而审计的关键就是找到那些被绕过或者从未被考虑的入口。1.1 框架封装带来的审计盲区现在大部分Java项目都用MyBatis、JPA或者Hibernate这类ORM框架很多开发者天然觉得用了框架就安全了。这是个很深的误解。框架只是帮你把SQL语句包装了一下最终拼出来的SQL还是得交给JDBC去执行。如果开发者在XML映射文件里写了${}而不是#{}那跟直接在代码里拼字符串没有任何区别。MyBatis是我在审计中遇到问题最多的场景。${}的写法在动态排序、动态表名、动态字段选择时特别常见因为#{}会被预编译成占位符没办法用在ORDER BY或者表名的位置。很多开发图省事直接从外部参数取值往${}里塞这种漏洞在代码里看起来非常人畜无害。还有一种情况是JPA的Query注解配合原生SQL。用Query(value SELECT * FROM user WHERE name :name, nativeQuery true)确实安全但如果你写的是Query(value SELECT * FROM user WHERE name name)性质就完全变了。这种把外部变量直接拼进JPQL或原生SQL的写法我在实际项目中见过不少次。1.2 JDBC手工拼SQL的老问题依然存在别以为老一套就没市场了。我接触过一些维护了七八年的老系统代码里直接使用Statement和Connection的地方并不少。这些老代码的可读性通常很差业务逻辑挤在一大串if-else里SQL语句被拆成很多段拼接。审计的时候必须一条一条顺着往下看特别要盯那些executeQuery、executeUpdate、execute方法前面的字符串是怎么来的。注意判断一个SQL注入点是否可利用不能只看有没有用PreparedStatement要看外部输入是否进入了不可控的SQL结构。哪怕参数值被包在单引号里只要拼接方式对注入者照样能通过闭合引号、注释符来改变SQL执行逻辑。1.3 存储过程与函数里的隐藏洞有些系统为了性能或者封装逻辑会把SQL写在数据库的存储过程里。代码审计时如果只盯着Java层很容易漏掉这一块。存储过程内部如果用了动态SQL比如EXECUTE IMMEDIATE而且执行时直接拼入了参数那应用的入口代码再安全也没用。这个方向在传统银行、政务类系统外包项目中尤其常见承接方为了快速交付经常把复杂业务写成存储过程。结合我审过的两套政务系统的经验存储过程的注入点往往带着一层合法业务的外衣目标参数是一个编号或者时间段开发者觉得这种输入一定是数字或者固定格式实际上只要拼接前没做严格的校验注入者就能通过构造特殊字符串改变整条语句的语义。2. 从零开始梳理审计入口全局搜索与调用链追踪做Java代码审计第一步动作通常是确定攻击面。攻击面不只是用户输入的HTTP参数还包括消息队列里的消息内容、定时任务读取的文件数据、第三方接口回调的参数等等。每一条可能被外部影响的路径理论上都可能成为SQL注入的输入源。2.1 全局搜索关键词定位危险方法面对一个陌生项目我一般先做两件事第一步看pom.xml搞清楚项目用了哪些持久层框架和版本第二步直接全局搜索高危关键词。MyBatis项目重点搜${在XML文件里把这个符号出现的位置全部列出来。JDBC层重点搜Statement、createStatement、executeQuery、execute这种裸调用的位置。JPA项目重点搜nativeQuery以及字符串常量池里有没有可拼接的痕迹。我看到不少审计员会带着正则去扫比如([])\s*\这种能扫出Java代码里的字符串拼接操作。但要注意这个正则的误报率很高因为日志拼接、错误信息拼接都会命中。真正有价值的是沿着拼接结果反向追数据流。审计效率上建议先扫XML和注解里的SQL再扫Java代码最后扫存储过程脚本。按这个顺序大多数情况下能在两三个小时内锁定高危点。2.2 从Controller到Mapper的完整调用链追踪找到可疑的拼接点之后不能直接下结论要往回走调用链这条SQL的数据是谁传进来的中间经过了哪些方法有没有做过过滤或者类型转换。我习惯用这样一个链路来记录HTTP参数 - Controller方法参数 - Service层DTO - Dao/Mapper接口参数 - XML中的${} - SQL在这个链路的每一层都要问三个问题参数类型是String还是Integer有没有使用白名单或黑名单校验有没有把参数从List或Map中重新取出再拼接我曾经在一个项目里遇到过一个看着很安全的Mapper接口参数类型是MapString, Object方法名叫做selectByCondition。XML里面对这个Map的取值用了一连串的if标签做判空可排序字段直接用了${sortColumn}而sortColumn是由前端传过来中间没有任何校验。最坑的是Controller层还做了参数的trim处理让人误以为已经清洁过了。这种多层包装地把戏审计时不能只看入口和出口中间过程也得聊。3. 实战拆解一个典型的MyBatis动态排序注入理论说再多都不如直接走一遍实际场景。我拿一个实际审过的员工管理系统来举例——这个系统不算复杂但正好踩中了MyBatis动态SQL前端可控排序字段这个典型组合。3.1 漏洞现场还原系统里面有个员工列表页支持按指定列排序。前端通过sortField参数告诉后端按哪个字段排序同时用sortOrder控制升序降序。后端的Mapper接口大概是这样的ListEmployee selectEmployeeList(Param(condition) EmployeeQuery condition);对应的XML片段长这样select idselectEmployeeList resultTypeEmployee SELECT id, name, department, salary, hire_date FROM employee where if testcondition.name ! null and condition.name ! AND name LIKE CONCAT(%, #{condition.name}, %) /if if testcondition.department ! null and condition.department ! AND department #{condition.department} /if /where ORDER BY ${condition.sortField} ${condition.sortOrder} /select看到${condition.sortField}基本可以定性了。排序字段从外部传入直接拼接在SQL里没有白名单限制。而sortOrder虽然是降序升序二选一但同样直接拼了进去。3.2 构造Payload验证危害判断能否利用我习惯通过报错回显来验证。先构造一个正常的请求然后往sortField里塞一个单引号/sys/employee/list?sortFieldidsortOrderasc此时数据库大概率会报语法错误页面上如果开了异常信息展示就会看到类似于### Error querying database. Cause: com.mysql.cj.jdbc.exceptions.CommunicationsException: ... You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near id asc这种能回显SQL片段的情况是最容易往下走的。接下来可以用extractvalue构造报错注入/sys/employee/list?sortFieldid AND extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schemadatabase()),0x7e))sortOrderascMySQL 5.7及以上版本会通过extractvalue的报错信息回显查询结果。实际利用时如果group_concat的结果太长被截断可以配合limit逐条读取或者用substring分段截取。提示不同的数据库报错注入写法差异很大MySQL有extractvalue和updatexmlOracle可以用ctxsys.drithsx.snSQL Server则常用类型转换报错。审的时候看清楚了目标库类型再去构造对应Payload别拿一套通吃。3.3 为什么排序字段这么容易踩雷细想一下这种漏洞出现率高除了框架使用不当外还有两点客观原因第一排序功能太常见几乎每个管理后台都有而且往往会做成通用组件。开发为了复用就把字段名设计成参数从列表页的列配置里直接映射前端传什么后端就用什么完全没想过这里还需要一层校验。第二#{}在MyBatis里不能用于排序字段和表名这不是开发者乱用而是框架本身的能力边界。问题在于很多开发者知道#{}安全却不知道${}危险或者知道危险但认为排序字段嘛最多报个错造成不了太大危害。这是很危险的想法。一旦拼接点出现在ORDER BY之后注入者不仅能报错注入还能通过if或case when构造布尔盲注。就算目标系统对报错信息做了处理通过响应时间差异做时间盲注也是完全可行的。4. 更隐蔽的高危变种IN子句与LIKE模糊查询排序字段算是动态SQL里比较典型的问题但审计时不能只盯这一处。MyBatis框架下还有两个特别容易出洞的变体值得单独拿出来说。4.1 IN子句拼接的经典翻车MyBatis的foreach标签处理集合参数时正常写法是select idselectByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select这个写法是安全的#{}方法会把每个元素都处理成预编译参数。但我见过一种改造过的写法开发觉得foreach生成的一串?占位符在SQL层面有些场景不好用于是自己写了一个工具方法把ListInteger直接用String.join拼成了1,2,3然后塞进${ids}。表面上看ids如果来自数字类型转换似乎没问题。但实际场景中很少有参数会规规矩矩地只传数字如果ids直接来自前端逗号分隔的字符串注入者传1,2,3) UNION SELECT username,password FROM user --整个查询语义就被改写了。4.2 LIKE拼接的百分号陷阱模糊查询也是重灾区。安全写法是用CONCAT拼参数if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if但有些代码是这么写的if testkeyword ! null and keyword ! AND title LIKE %${keyword}% /if这相当于在SQL里直接拼入了未经过滤的输入值。注入者传入%或者_会影响通配符匹配传入 OR 11 --直接改写条件。这条和排序字段一样属于看一眼就能定级的漏洞但审计时容易被模糊查询的平凡外观迷惑下意识觉得不就是查个关键字嘛。我个人的排查习惯是如果在一个项目里看到了${就把所有出现点列成一张清单逐个判断其上下文是排序、表名、字段名还是普通条件值。如果出现在普通条件值里基本可以直接定“高危”出现在排序和表名里要继续看有没有白名单。5. 二次注入与编码绕过的审计要点前面讲的都是第一次执行SQL时直接注入还有一种更难发现的情况叫二次注入。审计难度比普通注入高很多因为漏洞点和使用点分离单看任何一处都未必有问题。5.1 二次注入的原理与排查方法二次注入的典型流程是第一次把用户输入的恶意数据存入数据库因为这一次执行用的是PreparedStatement参数被完整保存没有触发注入。后续某个功能读取这条数据把它重新拼进一条新的SQL语句里此时恶意数据中的单引号或注释符开始发挥作用注入成功。网上购物平台里比较常见的场景是用户注册时把用户名设置为admin--因为注册用的是预编译参数数据原样入库。后续管理员在后台执行“重置用户密码”或者“编辑用户信息”时后台代码把用户名从库里取出来直接拼到UPDATE语句中于是单引号闭合了原有赋值--注释掉了剩余语句注入发生。排查二次注入时不能只盯着入口参数得把数据从表里查出后是否再次进入SQL拼接点这条路捋清楚。实际操作中可以搜索类似getXxx()方法的结果是否被直接拼入新SQL重点查看批量操作、导入导出、定时任务这几种后置处理场景。5.2 编码绕过的几种实况除了二次注入编码绕过也是审计中容易看走眼的。常见的有参数经过URLDecoder或自定义解码后再拼SQL参数经过了Base64解码参数经过了unicode或全角半角转换数据库连接的characterEncoding设置与页面编码不一致第一种情况最常见。有些系统为了支持某些特殊字符会在Controller层对参数做URLDecoder.decode然后再传给Service层拼SQL。如果WAF或自建过滤器是在解码前做的检测那么注入者只需把关键payload做一次URL编码就能绕过检测比如把 UNION SELECT编码成%27%20UNION%20SELECT在到达拼接点之前被解码还原。这种场景下光看框架的预编译机制是不够的因为拼接点本来就在使用${}。审计时要额外关注所有对参数进行解码、转换、拼接的处理并沿着变换后的值继续追踪。6. 查询不同数据库的注入Payload思路Java后端对接的数据库五花八门能碰到的至少是MySQL、Oracle、SQL Server、PostgreSQL这四种。作为审计人员至少要能够在确认注入点后快速判断目标库类型并构造对应的验证Payload。实际识别数据库类型的方式有很多通过报错信息中的关键字、通过特定函数的响应差异、或者通过HTTP响应头的Server字段辅助判断。MySQL和PostgreSQL的报错信息比较直白Oracle和SQL Server则会带着各自的版本号特征。构造验证Payload时的几个代表性例子MySQLid AND extractvalue(1,concat(0x7e,database(),0x7e))-- -Oracleid AND 1ctxsys.drithsx.sn(1,(SELECT user FROM dual))-- -SQL Serverid; IF (SELECT COUNT(*) FROM sysobjects)0 WAITFOR DELAY 0:0:5--PostgreSQLid; SELECT pg_sleep(5)--这里给一个提醒如果目标项目有完善的异常处理报错注入可能看不到任何东西。这时候可以优先尝试时间盲注配合AND SLEEP(5)或AND pg_sleep(5)观察响应时间。时间盲注虽然速度慢但通用性最强也是审计验证里最可靠的兜底手段。7. 修复方案落地改代码只是第一步在一个漏洞群里如果注入点上全是${}大多数人第一反应是全改成#{}。这个方向没错但落地时也要注意别把功能改崩了。特别是排序字段和表名这种没法预编译的位置修复思路要做取舍。7.1 排序字段与表名的安全处理排序字段最稳妥的修法是白名单映射。在后端维护一个允许排序的字段集合前端传过来的字段名先查映射表查到了才用查不到就使用默认排序。这里还有一个细节白名单的value必须是代码里写死的列名不能直接拿外部传值去充当列名。比如前端传sortFieldid后端映射到id这个值时其实这个value已经经过了内部校验。表名的处理思路类似。如果业务确实需要动态表名建议枚举或者配置化管理确保待选的表名集合有限且可控拼接前在代码里做一次严格匹配。对于IN子句和LIKE查询没有什么特殊技巧就是要坚持#{}和foreach的标准写法。类如把List转成字符串再拼接的做法应当直接禁止并且可以在代码规范里明确写出禁止将集合转为逗号分隔字符串后通过${}传入SQL这一条。7.2 纵深防御的实践建议修代码的同时也要考虑到代码审计的特点人总会犯错总会有漏网之鱼。所以即便修完已知问题仍然建议在数据库、应用、网络三层做防御。数据库账号权限最小化是最有效的兜底。比如一个普通业务系统查询账号本不需要DROP、CREATE、GRANT这类权限。如果注入者即使注入成功也无法执行敏感操作危害等级就会下降不少。应用层面可以加一层统一的输入校验过滤器但不要只做黑名单因为黑名单永远有绕过空间。我见过一个项目在过滤器里直接拦截了select、union这些关键词结果注入者用UNION的注释分割写法就绕过去了例如UN/**/ION。注意任何基于关键词拦截的方案都有被绕过的风险。常见绕过方式包括注释符穿插、十六进制编码、大小写混写、等价函数替换等。对于白名单校验才是比较靠谱的方向。7.3 修复后的回归验证流程修复不等于改两行代码就完事。我在实际工作中会走一个固定的验证流程重新对原POC做测试确认Payload不再生效用绕过变体重复测试比如编码绕过、注释符绕过确认正常业务功能不受影响比如排序功能、模糊查询功能仍然可用用工具或手工方式检查XML文件里是否还有其他${这个流程走完才敢说这个点真修好了。8. 常用审计工具与排查思路整理不少人问我做Java代码审计是用工具扫靠谱还是手工审靠谱。我的观点是工具能帮你提高效率但替代不了人脑分析数据流的能力。8.1 静态扫描工具的定位与局限目前市面上能用的Java静态扫描工具有不少选择。有的开源工具侧重于常见的OWASP Top 10检测规则库里有SQL注入、XSS、反序列化等条目。也有商业级工具可以做跨方法的数据流分析精度会高一些但成本也上去了。这些工具最大的问题是误报。一次扫描能出几十上百条告警真正能利用的也就几条。如果你对框架底层不清楚很容易在误报里浪费大量时间。我的建议是工具扫出来的所有告警都应该回到代码里做人工确认而且要把优先级给到那些入口可控拼接点险恶的组合。8.2 手工审计的排查清单结合上面的内容整理一份我平时用的排查清单排查项搜索关键词重点关注MyBatis动态SQL${是否外部可控、是否在白名单内JDBC拼接createStatement、executeQuery参数来源、拼接位置JPA原生查询nativeQuery、Query是否有字符串连接存储过程调用CallableStatement过程内部是否有动态SQL二次注入get SQL拼接库内数据是否进入新SQL编码绕过decode、new String解码后是否未过滤进入SQL每次审计新项目我都会把这个清单过一遍覆盖度已经比较稳定了。8.3 从漏洞本质反推安全编码规范最后聊一点方法论。审过的项目多了你会发现SQL注入问题看似五花八门本质上都是同一个原因没有区分数据和结构。数据是参数值结构是SQL语法本身。PreparedStatement之所以有效本质上是把数据和结构分离了让数据库不再把参数内容当作可执行语法来解析。所有不安全的写法都是在某个环节上模糊了这条边界。理解了这一点就给团队定安全编码规范时就有了抓手。比如不允许外部输入出现在ORDER BY、表名、字段名这类结构位置不允许将集合参数转换成字符串后传入SQL统一使用#{}占位符动表名和排序时强制做白名单。这些规定如果能落地至少能堵住九成以上的SQL注入问题。我在实际项目里还有一个个人习惯就是审计时不仅看漏洞本身还会把这个漏洞点周围的代码一次性看完。排序字段处出了问题附近的查询条件写法大概率也有类似隐患。这种由点及面的排查方式能让一次审计的价值翻倍。希望大家在审自己的项目时也能带着这种思路去深入而不是停留在扫出漏洞改掉就算完的层面。