别急着找漏洞,先搞清楚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接口,参数类型是Map<String, Object>,方法名叫做selectByCondition。XML里面对这个Map的取值用了一连串的<if>标签做判空,可排序字段直接用了${sortColumn},而sortColumn是由前端传过来,中间没有任何校验。最坑的是,Controller层还做了参数的trim处理,让人误以为"已经清洁过了"。这种多层包装地把戏,审计时不能只看入口和出口,中间过程也得聊。
3. 实战拆解:一个典型的MyBatis动态排序注入
理论说再多都不如直接走一遍实际场景。我拿一个实际审过的"员工管理系统"来举例——这个系统不算复杂,但正好踩中了MyBatis+动态SQL+前端可控排序字段这个典型组合。
3.1 漏洞现场还原
系统里面有个员工列表页,支持按指定列排序。前端通过sortField参数告诉后端按哪个字段排序,同时用sortOrder控制升序降序。后端的Mapper接口大概是这样的:
List<Employee> selectEmployeeList(@Param("condition") EmployeeQuery condition);对应的XML片段长这样:
<select id="selectEmployeeList" resultType="Employee"> SELECT id, name, department, salary, hire_date FROM employee <where> <if test="condition.name != null and condition.name != ''"> AND name LIKE CONCAT('%', #{condition.name}, '%') </if> <if test="condition.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?sortField=id'&sortOrder=asc此时数据库大概率会报语法错误,页面上如果开了异常信息展示,就会看到类似于:
### 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?sortField=id AND extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e))&sortOrder=ascMySQL 5.7及以上版本会通过extractvalue的报错信息回显查询结果。实际利用时如果group_concat的结果太长被截断,可以配合limit逐条读取,或者用substring分段截取。
提示:不同的数据库报错注入写法差异很大,MySQL有
extractvalue和updatexml,Oracle可以用ctxsys.drithsx.sn,SQL Server则常用类型转换报错。审的时候看清楚了目标库类型,再去构造对应Payload,别拿一套通吃。
3.3 为什么排序字段这么容易踩雷
细想一下,这种漏洞出现率高,除了框架使用不当外,还有两点客观原因:
第一,排序功能太常见,几乎每个管理后台都有,而且往往会做成通用组件。开发为了复用,就把字段名设计成参数,从列表页的列配置里直接映射,前端传什么后端就用什么,完全没想过这里还需要一层校验。
第二,#{}在MyBatis里不能用于排序字段和表名,这不是开发者乱用,而是框架本身的能力边界。问题在于很多开发者知道#{}安全,却不知道${}危险,或者知道危险但认为"排序字段嘛,最多报个错,造成不了太大危害"。
这是很危险的想法。一旦拼接点出现在ORDER BY之后,注入者不仅能报错注入,还能通过if或case when构造布尔盲注。就算目标系统对报错信息做了处理,通过响应时间差异做时间盲注也是完全可行的。
4. 更隐蔽的高危变种:IN子句与LIKE模糊查询
排序字段算是动态SQL里比较典型的问题,但审计时不能只盯这一处。MyBatis框架下还有两个特别容易出洞的变体,值得单独拿出来说。
4.1 IN子句拼接的经典翻车
MyBatis的<foreach>标签处理集合参数时,正常写法是:
<select id="selectByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>这个写法是安全的,#{}方法会把每个元素都处理成预编译参数。但我见过一种改造过的写法,开发觉得<foreach>生成的一串?占位符在SQL层面有些场景不好用,于是自己写了一个工具方法,把List<Integer>直接用String.join拼成了"1,2,3",然后塞进${ids}。
表面上看,ids如果来自数字类型转换,似乎没问题。但实际场景中很少有参数会规规矩矩地只传数字,如果ids直接来自前端逗号分隔的字符串,注入者传1,2,3) UNION SELECT username,password FROM user --,整个查询语义就被改写了。
4.2 LIKE拼接的百分号陷阱
模糊查询也是重灾区。安全写法是用CONCAT拼参数:
<if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if>但有些代码是这么写的:
<if test="keyword != null and keyword != ''"> AND title LIKE '%${keyword}%' </if>这相当于在SQL里直接拼入了未经过滤的输入值。注入者传入%或者_会影响通配符匹配,传入' OR 1=1 --直接改写条件。这条和排序字段一样,属于"看一眼就能定级"的漏洞,但审计时容易被模糊查询的平凡外观迷惑,下意识觉得"不就是查个关键字嘛"。
我个人的排查习惯是:如果在一个项目里看到了${,就把所有出现点列成一张清单,逐个判断其上下文是排序、表名、字段名,还是普通条件值。如果出现在普通条件值里,基本可以直接定“高危”;出现在排序和表名里,要继续看有没有白名单。
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时的几个代表性例子:
MySQL:
id' AND extractvalue(1,concat(0x7e,database(),0x7e))-- -Oracle:
id' AND 1=ctxsys.drithsx.sn(1,(SELECT user FROM dual))-- -SQL Server:
id'; IF (SELECT COUNT(*) FROM sysobjects)>0 WAITFOR DELAY '0:0:5'--PostgreSQL:
id'; SELECT pg_sleep(5)--这里给一个提醒:如果目标项目有完善的异常处理,报错注入可能看不到任何东西。这时候可以优先尝试时间盲注,配合AND SLEEP(5)或AND pg_sleep(5)观察响应时间。时间盲注虽然速度慢,但通用性最强,也是审计验证里最可靠的兜底手段。
7. 修复方案落地:改代码只是第一步
在一个漏洞群里,如果注入点上全是${},大多数人第一反应是全改成#{}。这个方向没错,但落地时也要注意别把功能改崩了。特别是排序字段和表名这种没法预编译的位置,修复思路要做取舍。
7.1 排序字段与表名的安全处理
排序字段最稳妥的修法是白名单映射。在后端维护一个允许排序的字段集合,前端传过来的字段名先查映射表,查到了才用,查不到就使用默认排序。这里还有一个细节:白名单的value必须是代码里写死的列名,不能直接拿外部传值去充当列名。比如前端传sortField=id,后端映射到"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注入问题。
我在实际项目里还有一个个人习惯,就是审计时不仅看漏洞本身,还会把这个漏洞点周围的代码一次性看完。排序字段处出了问题,附近的查询条件写法大概率也有类似隐患。这种由点及面的排查方式,能让一次审计的价值翻倍。希望大家在审自己的项目时,也能带着这种思路去深入,而不是停留在"扫出漏洞改掉就算完"的层面。