刚接手一个存量项目的时候,我最先做的往往不是看业务流程图,而是翻代码里所有跟数据库相关的调用。好几次下来发现一个规律:凡是出现“字符串拼接SQL”的地方,要么已经出过安全事故,要么就是处在爆雷的边缘。SQL注入这话题听着像老古董,但只要还有人图省事、贪方便,把用户输入直接卷进SQL语句,它就永远不会过时。Java后端绕不开JDBC、MyBatis、Spring Data这几条技术栈,每条链路都有安全写法和危险写法,差一层写法,线上就差一条“裸奔”的数据通道。这篇文章不兜圈子,直接从SQL注入的产生原理说起,把预编译、参数绑定、白名单校验、最小权限这些防御手段的原理和代码逐一拆开,最后给你一份能直接对着自查的清单。
先说清楚,这篇文章面向的是正在写Java业务代码的开发者,不管你用传统JDBC还是MyBatis,还是Spring Data JPA,里面的坑大概率你都踩过。我尽量不堆概念,把每步“为什么这么做”讲明白,你照着改就能用。
1. 先搞明白:SQL注入到底是怎么发生的
1.1 一句话本质:把用户输入当成了SQL代码的一部分
大多数人对SQL注入的理解停留在“输入特殊字符会出问题”,但要真正防住它,得先想清楚一件事:数据库解析SQL的时候,分不清哪些字符是你写的逻辑,哪些是用户填的数据。当你用字符串拼接的方式把参数塞进SQL,拼接完成的那一刻,参数里的内容就已经被当成“SQL源码”的一部分送给了数据库。数据库不关心这段语句里哪部分是程序员的意图,哪部分是用户的输入,它只看最终的文本。
我常用一个类比来解释:预编译SQL就像一张已经打印好的申请表,上面有固定的栏位,“姓名:”“年龄:”。你让用户填的是表格里的内容,他填什么都不会改变表格的格式。但字符串拼接SQL等于把这张表重新打印一遍,用户填的内容如果带上了“新增一行”“删除一列”这样的指令,那重新打印出来的表格就完全变样了。只要你把输入拼进了文本,他写进去的引号、分号、注释符,都会成为SQL语法的一部分。
所以SQL注入的本质不是“有人输入了坏东西”,而是“程序把不可信的数据和可信的代码混在了一条语法规则里”。任何防御手段,只要没把数据和代码彻底分开,都只是打补丁。
1.2 Java项目中最容易“中招”的三类代码位置
结合我这些年做代码审计的经验,Java项目里SQL注入的高发位置其实非常集中,大概就三类。
第一类是传统JDBC手工拼SQL。多见于一些老系统或者赶工期的模块,代码大概是这样的:
String sql = "select id, name, email from user_info where name = '" + userName + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql);这种写法一旦userName被传入' or '1'='1,整条SQL就变成了select id, name, email from user_info where name = '' or '1'='1',条件恒真,直接把全表数据查出来。如果后端还用同一个账号执行其他操作,后果更严重。
第二类是MyBatis XML里误用${}。很多人知道#{}安全,但一遇到动态排序、动态表名,就习惯性写${},也没做任何校验。${}是纯文本替换,MyBatis在构建SQL时直接把它拼进语句,本质上和第一类问题没有区别。
第三类是JPA/Hibernate里手工拼JPQL或原生SQL。Spring Data JPA的方法名派生查询是安全的,但@Query注解里如果出现了字符串加号拼接,一样会把注入带进来。
这三类问题的共同点都是:在某个环节图方便,绕过了框架提供的参数化能力。所以排查的时候,优先盯这三类位置。
2. 第一道防线:JDBC里的参数化查询
2.1 PreparedStatement为什么能挡住注入
很多人知道用PreparedStatement能防注入,但讲不清原理。其实关键在于数据库的预编译机制。当你执行connection.prepareStatement(sql)时,SQL语句会被发送到数据库端,数据库先把语句骨架解析好,生成执行计划,此时语句里的?还只是一个占位符。之后你再调用setString、setLong等方法绑定数据,数据库拿到的是“纯数据”,而不是“SQL片段”。不管这个数据里带了引号、分号还是注释符,它都只会被当作字段值来处理,不可能再去改变SQL的结构。
这就是参数化查询强大地方:安全不是靠拦截特殊字符,而是从架构上让数据根本没有机会成为代码。所以它也是一切SQL注入防御的基石。
还有个细节值得多说一句:预编译不仅能防注入,对数据库性能也有帮助。同一个SQL骨架只要执行过一次,后面再传不同参数,数据库可以复用执行计划,省去重复解析的开销。在高频查询场景下,效果比普通的Statement更稳定。
2.2 一个标准参数化查询的完整写法
我直接给你一段我自己项目里常用的写法模板,涵盖查询、更新、删除三类操作:
public UserAccount findByName(String name) { String sql = "select id, name, email, status from user_account where name = ? and status = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, name); ps.setInt(2, 1); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { UserAccount user = new UserAccount(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setEmail(rs.getString("email")); user.setStatus(rs.getInt("status")); return user; } } } catch (SQLException e) { log.error("query user by name failed, name={}", name, e); } return null; }这里有几个细节你要注意。第一,?占位符只代表参数的位置,不能把表名、列名、排序关键字也放进占位符,数据库不允许这些结构对象被参数绑定。第二,setString、setInt这类类型化方法要写完整,不要图省事全用setObject。虽然多数驱动能把Object转成正确类型,但显式声明类型能减少驱动推断带来的意外行为,也让代码review的人一眼看清你绑定的到底是什么。第三,资源用try-with-resources,避免连接泄漏。SQL注入防御不只是写SQL那一步,连接管理、异常处理都会影响整体安全性。
更新和删除同理:
String updateSql = "update user_account set email = ? where id = ?"; // ... String deleteSql = "delete from user_account where id = ?";一个原则:凡是业务里要接收外部参数,一律走PreparedStatement,Java项目里没有任何理由对普通业务查询使用Statement。
2.3 使用Statement拼字符串的反面案例
有人会辩解:我做了“转义”,把单引号替换成'',是不是就可以了?我可以明确告诉你,这不是一个可靠的防御方案。转义只是改变了字符的表示方式,并不能改变数据与代码混杂的本质。只要还是拼字符串,攻击者就有机会通过编码、注释、数据库特性绕过一次转义。我见过的很多真实攻击,靠的全是开发者“我以为转义就够了”的心态。
再来一个更隐蔽的反面案例,带批量操作的:
String ids = request.getParameter("ids"); // 例如:1,2,3 String sql = "select * from order_info where id in (" + ids + ")";in语句里的参数列表一旦用字符串拼接,危害比单个条件拼接更大,因为注入点直接放在括号里,构造起来更灵活。批量in的正确做法是动态生成对应的占位符,一组?去绑定每个元素,Java端循环设置参数。这样既安全,还能让SQL骨架保持稳定。
3. 框架层防守细节:MyBatis、Spring Data JPA的“坑”与“正确姿势”
3.1 MyBatis中#{}与${}的边界,别再踩
MyBatis的#{}和${},是Java开发里最容易被混淆的一组概念。#{}在解析阶段会被替换成一个JDBC占位符?,然后走参数绑定,安全。${}则是直接文本替换,MyBatis把变量内容原封不动拼到SQL字符串里,再把它当作完整SQL发送,等同于手工拼接,不安全。
我用个简单例子说明。假设用户登录:
<select id="findByUsername" resultType="com.demo.UserAccount"> select id, name, email, status from user_account where name = #{username} </select>这里#{username}最终等价于preparedStatement.setString(1, username),用户输入' or '1'='1只会被当作一个字符串值去匹配。但如果你把#{username}换成${username},MyBatis生成出来的SQL就变成了where name = ' or '1'='1,逻辑立刻被改写。
那${}是不是就一定不能用?不是。它只应该出现在那些数据库语法不允许参数化的位置,比如动态表名、动态列名、排序字段。这些位置一旦必须使用${},你就不能只依赖框架,必须自己补上白名单校验。这个我在第4部分详细讲。
3.2 模糊搜索、批量IN、动态排序三个高发场景的安全写法
这三个场景是MyBatis日常开发里最容易写出“看似能跑、实则裸奔”代码的地方,我把安全写法直接贴出来。
第一个是模糊搜索。很多人喜欢这么写:
<select id="searchUser" resultType="..."> select * from user_account where name like '%${keyword}%' </select>一旦keyword里出现%或_,查询范围会被意外放大,出现or这类字符更是直接注入。正确写法是先用Java代码构造模糊匹配参数,再交给#{}:
String keyword = param.getKeyword(); String pattern = "%" + keyword + "%";<select id="searchUser" resultType="..."> select * from user_account where name like #{pattern} </select>如果你想连通配符都一并处理掉,防止用户输入%或_扩大范围,可以在Java端做一次转义,把!、%、_统一处理,然后SQL里配合escape '!'。这样既防注入,又防“模糊搜索变成全表匹配”。
第二个是批量IN。不要拼字符串,用foreach生成一组占位符:
<select id="findByIds" resultType="..."> select * from order_info where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>Mapper接口里对应方法:
List<OrderInfo> findByIds(@Param("ids") List<Long> ids);这个写法生成的SQL是where id in ( ?, ?, ? ),每个元素都是独立占位符,完全参数化。要注意的是,如果ids列表很大,比如上万条,数据库对in的占位符数量会有上限,你的DAO层需要对这个列表做分片,每500个或1000个一组分批查询,再把结果合并。
第三个是动态排序。排序字段不能参数化,这是数据库的限制,所以这里只能用白名单方案。我习惯在Service层加一层映射:
private static final Map<String, String> SORT_FIELD_MAP = new HashMap<>(); static { SORT_FIELD_MAP.put("createTime", "create_time"); SORT_FIELD_MAP.put("name", "name"); SORT_FIELD_MAP.put("status", "status"); } String sortField = SORT_FIELD_MAP.getOrDefault(param.getSortField(), "create_time"); String sortOrder = "asc".equalsIgnoreCase(param.getSortOrder()) ? "asc" : "desc";然后XML里再使用${sortField}和${sortOrder}。因为这两个值已经在上游被严格映射过,不存在被用户任意写入的可能。白名单方案是我见过最稳妥的排序安全方案,比黑名单拦截靠谱得多。
3.3 JPA/Hibernate里那些容易被忽略的拼接点
Spring Data JPA的方法名派生查询是安全的,比如findByName(String name)、findByStatusAndCreateTimeBetween(...),框架会把这些方法名解析成预编译的JPQL。但只要你开始手写@Query,坑就来了。
危险写法示例:
@Query(value = "select * from user_account where name = '" + name + "'", nativeQuery = true) List<UserAccount> findByName(String name);这里和Statement拼接没有本质区别。Spring Data JPA虽然会执行这个字符串,但它执行的是你拼好的“成品SQL”,用户输入已经混进语法里了。正确写法是用命名参数或位置参数:
@Query(value = "select * from user_account where name = :name and status = :status", nativeQuery = true) List<UserAccount> findByName(@Param("name") String name, @Param("status") Integer status);JPQL同理:
@Query("select u from UserAccount u where u.name = :name") List<UserAccount> findByName(@Param("name") String name);还有个容易被忽略的场景是EntityManager.createNativeQuery。有些复杂统计查询,开发人员直接用字符串拼条件,然后createNativeQuery(sql)执行。这种写法照样会注入,唯一的防御还是把它改成createNativeQuery(sql).setParameter(...),或者干脆用JPA的CriteriaQuery,在Java代码层面用类型安全的方式构建查询条件。
4. 纵深防御:输入校验、最小权限和异常隔离
4.1 白名单校验:哪些场景必须做
预编译能解决绝大部分参数绑定问题,但数据库语法不允许参数化的那几个位置,必须用白名单校验兜底。什么时候必须做?我的判断标准很简单:只要这个值最终会出现在SQL语句的表名、列名、排序字段、group by字段等任何“结构位置”,就必须走白名单。
白名单的做法是,先在代码里维护一组合法值,然后只接受这组值中的某一个。比如用户传了一个排序字段orderField,你就在代码里比对,它必须是create_time、update_time、id、status中的一个,否则就用默认值。再比如一个系统需要动态切换表名,那就直接写死一张合法表名映射表,让用户只能通过预设码值选择,而不是直接传表名词。
白名单也适合用在业务枚举上。比如一个接口只接受status=1或status=2,如果传来3,直接拒绝。这不仅能防注入,还能防逻辑绕过。举个例子,一个查询订单的接口,如果允许用户传入任意状态值,攻击者可能把状态改成-1或其他异常值,从而触发不同的查询行为。
我见过一些人用黑名单拦截select、insert、drop这些关键词,这是错误方向。黑名单永远堵不全,而且会给正常业务数据带来误伤。比如用户搜索内容恰好包含“select”这个词,你把他拦了,业务就错了。白名单的核心逻辑是“只放行已知合法值”,在这个前提下,用户输入根本不可能变成SQL结构的一部分。
4.2 数据库账号最小权限:给攻击制造障碍
很多人把SQL注入防御的全部精力放在代码层,却忽略了数据库权限这层纵深防线。你要想明白一个道理:代码防注入是第一道门,但没有任何一道门是百分百可靠的。万一某条SQL确实绕过了预编译,数据库账号的权限就是最后一道物理隔离。
我在实际项目里推动过这样的规范:应用连接数据库的账号,只授予业务必需的DML权限。比如一个普通的订单查询服务,只需要SELECT权限;需要写入的服务,就只给对应表的INSERT、UPDATE权限;如果没有特殊理由,绝对不给DDL权限,也就是不能DROP、ALTER、CREATE。这样即使注入真的发生,攻击者也只是在有限的表上增删改查,无法直接删库跑路。
更进一步的做法是把读写账号分离。读流量走只读账号,写流量走读写账号,业务代码里按需切换。这既有利于性能规划,也能缩小注入攻击的影响面。如果你用云数据库,通常都支持创建不同的账号并精准授权到库或表级别,配置起来并不复杂。唯一要注意的是别贪省事,所有服务共用一个数据库超级账号,等于给攻击者送了一份大礼。
4.3 错误信息里别把SQL吐给客户端
还有一层容易被忽视的防护:异常信息的隔离。你预编译做得再好,如果接口在出错时直接把SQLException堆栈返回给前端,等于把SQL骨架、表结构、字段信息都泄露给了潜在攻击者。攻击者拿着这些信息,就能针对性地构造更精确的注入尝试。
一个常见的错误场景是这样的:接口抛异常后被全局异常处理器捕获,直接把errorMessage返回给页面。用户输入一个特殊字符,数据库报了个语法错误,堆栈里跟着打印出完整的SQL,用户就看到了你的表名和字段名。正确做法是,在Service层或DAO层捕获SQLException,记到服务端日志里,返回给前端的只有“系统繁忙,请稍后再试”这样不含任何内部信息的提示。同时还要注意,日志里打印SQL时不要带着用户原始输入一起打印,否则日志系统被拖走时也会泄漏敏感查询条件。
5. 从实战看排查:常见注入尝试与代码自查方法
5.1 从日志视角观察常见的恶意输入特征
有些朋友会问:我怎么知道自己的系统有没有被扫描?我一般建议先看日志。互联网上大量自动扫描工具会往目标接口的输入参数里塞固定模式的字符串,比如单引号、' or '1'='1、;、--等。如果你在日志系统里看到这些参数出现在头像昵称、搜索关键词、订单备注等原本不该出现特殊字符的字段里,基本可以断定有人在试探。
看到这些日志,不应该去写一个黑名单把这些关键字过滤掉,而是要顺着入口找到对应的DAO方法,确认它是否已经参数化。如果已经是预编译,那恶意输入只会被当作普通字符串处理,不会造成实际危害,顶多带来一点无效日志;如果发现这个接口还在拼字符串,不用犹豫,立刻修掉。另外要养成一个习惯:线上日志的关键字段做脱敏,不要把完整URL参数原样打出来,尤其是不该出现登录态信息的接口。
5.2 静态扫描与Code Review的四个重点
代码扫描工具可以在CI阶段自动发现大部分SQL注入风险,但你要把规则配置对。常见的规则配置一般会标记这几个模式:直接使用Statement执行字符串、MyBatis的${}出现在非白名单位置、@Query注解里出现字符串拼接、createNativeQuery传入了动态字符串。我建议把这些规则都设成“错误级别”,也就是代码合并前阻塞的那种,而不是只给个“警告”。
Code Review阶段人工重点看四个位置:第一,所有接收外部参数的Mapper方法,看XML里用的是#{}还是${};第二,所有动态排序或动态字段选择的地方,看上游是否做了白名单映射;第三,所有in查询,看是不是foreach参数化;第四,所有新增的老系统对接代码,确认没有绕过现有DAO直接创建Statement。只要这四类位置都查过,SQL注入的出漏率会大幅下降。
5.3 自查清单:对着这条表格检查一遍
| 检查位置 | 正确做法 | 错误做法 | 说明 |
|---|---|---|---|
| JDBC查询 | PreparedStatement + 参数绑定 | Statement + 字符串拼接 | 所有业务查询一律参数化 |
| 参数写法 | 显式setString/setInt/setLong | 全用setObject | 类型明确,执行计划更稳定 |
| MyBatis普通条件 | #{} | ${} | #{}生成占位符,${}文本替换 |
| MyBatis模糊查询 | #{pattern} | '%${keyword}%' | 通配符也建议转义 |
| MyBatis批量IN | foreach生成多个#{} | in (拼接字符串) | 列表过大时分片查询 |
| 动态排序字段 | 白名单映射后使用 | 直接接收用户字段名 | 结构位置无法参数化,必须白名单 |
| 表名/列名变更 | 预设枚举 + 白名单 | 直接拼接用户输入 | 最容易被忽略的注入点 |
| JPA查询 | 命名参数/位置参数 | 注解里字符串拼接 | 方法名派生查询安全,但要管住@Query |
| 错误信息 | 服务端记日志,返回脱敏提示 | 异常堆栈直接返回前端 | 避免泄露SQL骨架和表结构 |
| 数据库账号 | 业务最小权限 | 超级账号或DDL权限 | 即使注入发生也扩大不了破坏面 |
6. 二次注入与数据生命周期里的隐藏风险
6.1 存储下来的数据,也可能变成下一次注入的燃料
聊一个很多人没考虑过的点:二次注入。它的意思是,用户在第一次请求时存入了一条数据,这条数据本身是安全的,因为插入时走的是参数化。但过了一段时间,某个后台功能读取了这条数据,又把它拼进了另一条SQL里。这个时候,攻击者当初存进去的内容,就变成了第二次SQL执行里的“代码片段”。
举个具体场景。用户注册时把昵称设成abc' or '1'='1,注册接口用的是参数化插入,数据库里存的确实是这个字符串,没有任何危害。但如果后台有个统计功能,把用户昵称拼进一条SQL日志查询语句里执行,那么这条拼接SQL就会再次产生注入效果。二次注入之所以隐蔽,是因为它不在第一次请求的堆栈里,很难通过单次接口测试暴露出来。
防御二次注入的原则只有一条:无论数据从哪里来,只要它是外部不可信的输入,使用时就一律参数化。不要因为“这条数据已经在数据库里,应该安全”就放松警惕。数据库里的数据可能是很多年前存进去的,也可能来自导入工具、老系统同步,无法保证每一条都经过了严格校验。
6.2 报表系统、导入导出和定时任务里的漏网之鱼
除了常规接口,还有三类模块特别容易藏SQL注入。第一类是报表系统,因为报表查询条件多、字段多,很多人图省事,动态拼了一大段SQL然后用原生查询执行。第二类是数据导入,Excel或CSV里的单元格内容可能包含恶意字符串,如果导入逻辑里做了拼接,比如把单元格内容当作过滤条件再查一次,就会出问题。第三类是定时任务,半夜跑批的脚本一般没有人现场验收,写的时候如果偷懒拼接,等发现问题时已经晚了。
处理这几种场景要从架构上定规矩:所有动态查询入口,必须经过同一个DAO层或查询服务,不允许在Controller层或定时任务里直接拿连接建Statement。只要把SQL构造集中管理,就能在一个地方统一做参数化和审计,漏网概率也会小很多。
6.3 规范制度比单个函数更管用
防SQL注入这件事,单靠某一个开发人员写对一段代码是远远不够的,需要变成团队规范。我自己在项目组里推行过几条制度,效果还不错,分享给你参考。
第一条,代码评审清单里固定加上“SQL注入专项检查”这一项,任何一个涉库功能的MR没有过这一项检查,不允许合并。第二条,新员工入职培训里专门花半小时讲#{}和${}的区别,并且让他们在测试环境故意跑一次注入用例,亲眼看到预编译和拼接的差距。第三条,每隔一个迭代做一次存量代码扫描,把新增的疑似拼接代码拉出来逐一确认。这些制度不需要太多成本,但能极大降低团队平均水平线。
还有一个我个人的建议:如果你的系统里有那种跟随业务动态变化的表名、库名,不要直接在代码里拼。设计一个路由表,把这些结构信息存在配置中心或数据库里,通过一个稳定的服务去解析,解析结果再走白名单映射。这样业务扩展时不会给SQL注入留出新的口子。
写到这里,我最想强调的还是那句老话:安全不是某一个API的功劳,而是整个数据链路里每一步都在坚持“数据与代码分离”。我每次巡检代码都爱问开发同事一句,“这里如果拼进去,结果会变成什么样?”问多了,大家自己也学会在写SQL前多想一步了。这套东西不难,难的是在所有代码里都贯彻一致的手感。希望这文章能让你少踩一两个坑。