1. 注入攻击全景:从原理到Java生态的威胁面
注入攻击,说白了就是攻击者往程序正常的数据流里掺杂质,让程序把这些杂质当成指令去执行。在Java应用开发里,最常见的是SQL注入,但很多人容易忽略命令注入、LDAP注入、XPath注入、模板注入这些同样致命的变种。我早年做代码审计的时候,见过不少Java项目把防御重点全压在SQL注入上,结果被一条拼出来的系统命令打穿了整个服务。
为什么要专门聊Java场景下的注入攻击?因为Java的生态链特别长,从JDBC直连到MyBatis、Hibernate这类ORM框架,再到Spring Data JPA,每一层都有自己处理用户输入的姿势,有些姿势天生安全,有些姿势则需要开发者手动兜底。也就是说,注入漏洞往往不是框架的问题,而是使用框架的人在不该拼接的地方做了拼接。
解决什么问题呢?简单说,这篇文章想把"注入攻击"这件事从原理层面讲透:攻击者到底是怎么改写了你的查询语义,为什么预编译能挡住大多数攻击,以及除了预编译之外你还得做哪些事情才能把风险压到足够低。适合谁看?我觉得三类人最需要:一是刚接触Java安全、想把基础打扎实的后端开发;二是负责项目代码评审的技术骨干,想有一套可以落地的检查清单;三是写过一些漏洞但没系统梳理过防御体系的同学。
先建立一个整体认知框架。注入攻击的核心矛盾只有一个:数据与代码的边界被打破了。在SQL语境里,你原本只希望用户输入充当一个字符串字面量,但如果没有正确的转义或参数化处理,输入里携带的引号、注释符、UNION关键字就会摇身一变,成为SQL语句结构的一部分。这种"文本被重新解释"的机制,是所有注入漏洞的底层共性。
Java应用面临的注入面大致可以分成四类,我整理了一下:
| 注入类型 | 攻击入口 | 后果严重度 |
|---|---|---|
| SQL注入 | 拼接SQL的DAO层、MyBatis的${}占位符 | 数据泄露、数据篡改、甚至命令执行 |
| 命令注入 | ProcessBuilder、Runtime.exec拼接系统命令 | 服务器被直接接管 |
| LDAP/XPath注入 | 目录查询、XML解析的过滤器表达式拼接 | 绕过认证、越权读取数据 |
| 模板注入 | Velocity、FreeMarker模板内容由用户可控 | 服务器端任意文件读取、RCE |
这四类有个共同特点:凡是需要把"用户可控的字符串"交给解释器(SQL引擎、Shell、LDAP服务器、模板引擎)的场景,只要有拼接,就一定有风险。理解了这一点,你再看安全文档里反复强调的"参数化查询""白名单校验""最小权限",本质上都是在做同一件事——让输入永远停在数据的身份上,不给它变成代码的机会。
2. 为什么说预编译是防SQL注入的第一道防线
2.1 从一条经典攻击语句说起
先回顾一个最经典的场景。假设登录接口的SQL长这样:
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);攻击者在用户名框里输入:
' OR '1'='1SQL就被拼成了:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '''1'='1'恒为真,整个WHERE子句失去约束力,攻击者不需要密码就能返回任意用户记录。如果后面再接上UNION SELECT、注释符--,攻击者甚至可以直接枚举数据库表结构。
这个例子里,问题出现得很直接:用户输入被当成SQL文本的一部分,和开发者的写好的SQL片段无缝拼接了。而预编译Statement之所以能解决这个问题,靠的不是复杂的过滤,而是改变了解释器的工作方式。
2.2 PreparedStatement为什么能守住边界
PreparedStatement的核心机制是"先编译、后传参"。JDBC驱动在执行预编译语句时,会先把SQL的结构(骨架)发送给数据库,数据库完成语法解析和优化,生成执行计划;之后传入的?参数,一律被当作纯数据值处理,不再参与语法解析。也就是说,参数里的引号、关键字、注释符,在数据库看来就是一堆没有特殊意义的字符序列。
用生活里的事情打个比方:你去银行填汇款单,单据上"汇款金额"一栏写得再花哨,柜员只会把它当作数字录入,而不会因为你写了"汇一万块顺便把我账户余额清零"就把多余的话当成指令执行。PreparedStatement就是这张结构固定的汇款单。
我在实际项目里写登录查询,会严格遵循这种写法:
String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; try (PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); pstmt.setString(2, password); try (ResultSet rs = pstmt.executeQuery()) { // 处理结果 } }需要强调一个细节:参数化查询的价值是"彻底",不是"概率"。它不依赖你猜攻击者会用什么关键字,所以不存在绕过的问题。这也是为什么OWASP把参数化查询列为SQL注入防御的首选方案。
2.3 框架里的参数化陷阱:${} 与 #{} 的天壤之别
到了ORM框架这一层,事情就开始变得微妙了。MyBatis提供了两种占位符,#{}走的是PreparedStatement的参数绑定,是安全的;${}是直接做字符串替换,拼完再执行,相当于把Statement的坑原封不动搬进了XML映射文件里。
见过不少踩坑的案例:排序字段这种"动态列名"的需求,开发者图省事,直接在Mapper里写了ORDER BY ${sortField},然后从前端接收sort参数。攻击者传一个sort=1;DROP TABLE users;--,那这条SQL在数据库里执行的结果,就真的是执行计划外面多了一串破坏指令。
我在自己的项目里定了一条硬规矩:凡是传入值只参与数据比较,一律用#{};凡是必须动态拼接的片段(如表名、列名、排序字段),一律走后端白名单映射,不做透传。比如排序需求,前端传sort=name_desc,后端在Map里查一下对应上真实的列名和方向,查不到就返回默认排序。这样用户输入永远接触不到真正的SQL片段。
2.4 存储过程能替代参数化吗
很多团队为了防注入,喜欢把业务逻辑写进存储过程。这个思路有它的价值,但要分两种写法。正确做法是存储过程内部也使用参数化查询或sp_executesql之类的参数绑定;错误做法是存储过程内部用字符串拼接生成动态SQL再执行,那安全性和直接在应用层拼接没有本质区别。所以,存储过程本身不是安全边界,它内部的执行方式才是。
3. 防注入的完整纵深:不是只靠一条防线
3.1 输入验证:该拦截的格式在入口就拦下
预编译能挡住SQL语义篡改,但挡不住"合法但有风险"的输入。举个例子,你有一个按用户名查资料的功能,预编译下username参数是安全了,但如果业务要求支持模糊查询,用户传入%和_这两种通配符,查询范围就被悄悄扩大了。这种情况严格说不是SQL注入,但属于"查询语义被意外改变"。我的处理方式是约定规则:如果业务上不允许模糊匹配,就在入口层对%、_做显式转义或直接拒绝;如果允许,就把通配符的过滤做成显式的、有注释说明的逻辑。
再比如,涉及用户输入拼进LIKE子句的场景:
String keyword = userInput.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); String sql = "SELECT * FROM products WHERE name LIKE ? ESCAPE '\\\\'"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, "%" + keyword + "%");这里的ESCAPE子句指定了转义字符,让%和_在用户输入里失去通配符语义。这种细节很容易被忽略,我做过几次测试,发现不少人知道预编译能防注入,却不知道通配符还能造成查询范围扩散。
3.2 输出编码:数据库之外的另一个战场
注入攻击不只在数据库层面发生。当你把用户输入原样拼进HTML页面时,可能产生存储型XSS;拼进日志文件时,可能产生日志注入;拼进JSON返回前端时,也可能破坏数据结构。虽然这些严格说不算"注入攻击"家族里SQL那一支,但它们的本质仍然是"用户输入改变了输出上下文的结构"。
防御思路是一致的:数据在进入某个解释上下文之前,必须按那个上下文的要求做编码。进SQL用参数绑定,进HTML做HTML实体编码,进JS做JS编码,进URL做URL编码。我在写Web层过滤器时,会在统一的地方做输入校验和输出编码,而不是每个接口分别处理,这样能避免"这个接口处理了、那个接口忘了"的状态。
3.3 最小权限:数据库账号不是越大越好
这是我见过的最多团队忽略的点。很多时候,应用连接数据库用的账号是root或者admin,拥有整个库的DDL权限。这意味着一旦SQL注入真的发生,攻击者不只是能查数据,还能DROP TABLE、能写文件、能通过INTO OUTFILE往服务器上写Webshell。
我经手过的项目都强制推行账号拆分:应用使用只具备DML权限(SELECT/INSERT/UPDATE/DELETE)的账号连接业务库,并且只授权到具体库表,不授权到全局;需要执行DDL的迁移脚本,走独立的部署流程,用单独的账号跑。这样即使注入点在某个查询接口,攻击者也做不了破坏性操作,损失被限制在单次数据访问范围内。
3.4 异常信息处理:别把数据库底裤露出来
数据库驱动抛出的异常里经常带着完整的SQL语句,甚至包含参数值。如果Web层直接把异常堆栈返回给前端,那不光是暴露技术细节的问题,有时候攻击者根据报错信息就能判断出表结构字段名。正确做法是:在全局异常处理器中,给用户返回统一、模糊的错误提示;完整异常详情只记录到服务端日志,并且日志中要对敏感参数做脱敏。
我当时在一个外部接口对接项目里,就因为线上环境返回了带SQL片段的异常信息,被安全扫描工具直接标记为"信息泄露"风险。后来加了一个@RestControllerAdvice做统一兜底,问题才彻底解决。这个坑很值得强调,因为很多开发联调时习惯了看完整异常,上线时忘记关掉调试模式。
4. 实战复盘:JDBC、MyBatis、JPA与命令执行场景的代码级示范
4.1 JDBC直连:完整的安全写法
直接操作JDBC的场景在老旧项目里依然常见。这里把安全的写法拆成三步:
第一步,创建连接时设置连接属性,比如加上connectTimeout和socketTimeout,防止慢查询拖垮应用;第二步,所有SQL语句一律使用PreparedStatement;第三步,关闭资源时用try-with-resources,避免连接泄漏。
public User findUserByUsername(Connection conn, String username) throws SQLException { String sql = "SELECT id, username, password_hash FROM users WHERE username = ?"; try (PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); try (ResultSet rs = pstmt.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPasswordHash(rs.getString("password_hash")); return user; } } } return null; }有人会问,LIKE语句预编译参数的时候怎么传?这里需要额外注意,参数里带%是合法的,因为%在这条SQL里不是特殊字符——它只是普通的文本内容,除非SQL本身是LIKE ?这种写法,那%才会根据上下文被当作通配符。所以"把%写进参数"和"把%写进SQL文本"是两回事,前者安全,后者才可能带来语义变化。
4.2 MyBatis:从XML到注解的两种正确姿势
在MyBatis的XML映射文件里,推荐写法是:
<select id="findUserByUsername" resultType="User"> SELECT id, username, password_hash FROM users WHERE username = #{username} </select>#{username}会被MyBatis转成PreparedStatement的?,安全。但注意,如果参数是List,使用<foreach>循环拼接IN条件时,MyBatis会自动为每个元素生成一个?,那也是安全的。真正危险的是下面这种:
<select id="findUserBySort" resultType="User"> SELECT id, username FROM users ORDER BY ${sortColumn} </select>${sortColumn}是纯字符串替换,MyBatis不会做任何参数化处理。有经验的团队会在XML审阅时把所有${}挑出来,逐个确认它的值只能来自后端白名单。
4.3 JPA:JPQL和原生SQL的边界
Spring Data JPA里,方法名推导查询(如findByUsername)底层走的是参数绑定,通常安全。但是用@Query写原生SQL时,如果用字符串拼接的方式:
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true) List<User> findByBadName(String name);这就等于把预编译的优势主动扔掉了。正确做法是用命名参数或位置参数:
@Query(value = "SELECT * FROM users WHERE name = :name", nativeQuery = true) List<User> findByGoodName(@Param("name") String name);我见过有人写nativeQuery = true的时候习惯性把整个SQL拼出来,还说"反正是内部系统"。这其实是最要不得的心态。内部系统的数据通常更敏感,被攻破后影响也更集中。
4.4 命令注入:用户的输入永远不要进入Shell
命令注入在Java里的典型触发点有两个:Runtime.getRuntime().exec()和ProcessBuilder。如果一个文件名、一个URL参数被直接拼进命令字符串,攻击者就能用;、&&、|这类Shell控制符执行额外命令。
我遇到过一个实际情况:系统里有个功能是根据传入的IP执行ping命令做连通性检测,代码长这样:
Process process = Runtime.getRuntime().exec("ping -c 3 " + ip);攻击者传一个8.8.8.8; cat /etc/passwd,Shell解析时先执行ping,然后执行cat命令。那台服务器上跑的还有别的数据库备份脚本,后果不堪设想。
修复方式有几种,按推荐优先级排:
- 能不用命令就不用命令:Java里判断网络连通性可以直接用Socket连接测试,或者调用已有的库,没必要拉起系统进程;
- 用参数列表而不是命令字符串:
ProcessBuilder支持直接把参数放进列表,不走Shell解析:
ProcessBuilder pb = new ProcessBuilder("ping", "-c", "3", ip);这种方式下,ip即使包含; cat /etc/passwd,也只会被当作一个普通的ping目标字符串传给程序,Shell不会介入解析。
- 对参数做白名单校验:如果IP字段只能接收IP格式,直接正则校验
^[0-9a-fA-F:.]+$,不合法就拒绝。
我的经验是,命令注入的防御方案优先级永远是"消除命令"高于"加固命令",因为后者的防御链条更长,更容易漏。
5. 常见问题与排查技巧实录
问题一:预编译也拦不住批量参数怎么办
有时候一个SQL要传上千个IN参数,用PreparedStatement会生成上千个占位符,数据库处理起来吃力。我遇到这种情况下,有人干脆改回拼接IN列表,直接中招。这个场景的正确解法是分批处理或者用临时表关联,而不是放弃参数化。先把参数值写入一张临时表,再用JOIN关联查询,性能和安全性都能保住。
问题二:MyBatis的${}到底还有没有合法用途
有。动态表名、排序字段、动态SQL片段这些场景确实无法用占位符表达。但必须满足两个条件:第一,值只能来自后端代码硬编码的枚举,不接受前端传入的原始字符串;第二,如果实在要接收前端值,必须先做映射转换,比如把desc映射成常量DESC。有任何一步做不到,就别用${}。
问题三:已经上线了怎么排查注入漏洞
我会用三招:
- 把项目里所有SQL语句代码搜索一遍,正则匹配
+ username、+ password、" + "这类字符串拼接特征,定位可疑点; - 在数据库层开启查询日志,搜索历史记录里有没有
UNION、OR 1=1、SELECT嵌套等异常特征; - 用扫描工具跑一遍常见注入payload,观察接口响应时间、错误信息和返回结构有无异常变化。
但我要说句实在话:扫描工具只能发现已知Pattern,人工代码审计才是最终兜底。工具扫完没漏洞,不代表真的没漏洞。
问题四:LDAP注入和XPath注入容易被漏
很多Java项目里集成了LDAP或者XML操作,但安全测试重点全在SQL上。LDAP查询过滤器如果直接拼接输入,攻击者可以用)和(|(objectClass=*))等结构字符改写过滤逻辑。XML的XPath拼接也类似,可以用'] | //*[这类语法扩展路径。我建议是把它们和SQL注入放到同一个排查流程里,因为它们机制相通:用户输入进入了某个解释器的"结构位"。
问题五:为什么加了WAF还是被打穿了
WAF的作用是拦截已入库的特征,本质是"已知恶意"的对抗;而参数化查询、输入验证、输出编码做的是"任何输入都按数据对待",本质是结构性的免疫。WAF是最后一层补充,不是主要防线。项目里如果只有WAF而没有代码层的防御,等于大门口站了保安,窗户却全开着。
6. 代码审计中的注入检查清单与团队落地经验
最后分享一份我平时做代码评审时必过的检查清单,你可以直接抄走:
| 检查项 | 判定标准 |
|---|---|
| SQL语句是否存在字符串拼接 | 出现"SELECT..." + param即判不合规 |
PreparedStatement参数是否全部用? | 任一参数未绑定即判不合规 |
MyBatis映射中${}的使用 | 逐个确认来源是否限于后端白名单 |
| JPA原生SQL是否使用命名参数 | 拼接方式一律打回 |
| Runtime.exec/ProcessBuilder是否拼接用户输入 | 有拼接即不合规,阻塞发布 |
| 异常信息是否回传给前端 | 必须全局兜底,统一模糊提示 |
| 数据库账号权限是否遵循最小化 | 存在DDL权限的日常应用账号必须整改 |
| 动态表名/排序字段是否经过映射 | 透传前端值判不合规 |
把这份清单固化到CI流水线里,每次提交代码自动跑一遍,效果会比单纯靠人肉审查好得多。我现在的做法是在检查脚本里加了一个"扫描${}和+拼接"的规则,匹配到就让构建留痕,然后由一个固定的安全负责人复核。
团队落地这件事,我觉得最关键的还不是工具,而是让每个写代码的人都真正理解绑定参数意味着什么。我组织过几次内部培训,用同一个漏洞Demo演示拼接写法和参数化写法两种SQL到达数据库时的差异,很多人看完才明白为什么运维反复强调"不要拼接SQL"。
从实战视角再多说两句。第一,注入漏洞不是"写一次就会消失"的东西,它会在你重构SQL、换框架、加新功能时悄悄冒出来,所以防御必须是持续性的流程动作;第二,不要因为Java是静态强类型语言就觉得注入风险低,类型检查挡得住语法层面的错误,挡不住语义层面的攻击。
这个方向如果要继续深入,可以沿着两条线走:一条是攻防对抗的进阶——研究二次注入、宽字节注入、时间盲注这些变种,理解攻击者的手法可以帮你更扎实地建立防御直觉;另一条是体系化建设——把Web层、API层、数据层、基础设施层的安全能力串联起来,形成纵深防御。我自己更推荐后者,因为单点防御做得再好,也挡不住链条上最薄弱那一环的失守。