做后端开发的这些年,我见过太多因为SQL注入被打穿的教训。有一次某传统企业上线一套报表系统,查询接口的参数被直接拼进SQL字符串里,攻击者用两个fragment就拖走了整张用户表。还有一套内部管理系统,登录页被人用万能密码直接绕过去,后台订单数据全亮在公网上。这些事故无一例外都发生在JDBC这一层,因为很多Java开发者习惯了直接写SQL字符串,根本没意识到自己随手敲的那些引号加号,正在给攻击者开大门。
JDBC(Java Database Connectivity)是我们连接关系型数据库的基本方式,也是SQL注入问题最容易暴露的战场。这个话题说再多都不为过:SQL注入常年霸榜各类Web安全风险列表,而JDBC里的PreparedStatement参数化查询,就是在代码层堵住漏洞最直接、也最有效的办法。
这篇文章我想站在JDBC 54.2这个具体版本的角度,把SQL注入从攻击原理到JDBC防护方案完整捋一遍。会讲清楚SQL注入为什么能生效、JDBC几种写法里哪些安全哪些危险、PreparedStatement背后到底做了什么,以及除了参数化之外,我们还需要补哪些配套防线。适合所有用Java写数据库操作的开发者,特别是刚接手老系统、准备做安全加固的朋友。
1. 先搞清楚SQL注入到底是怎么发生的
1.1 从一次登录绕过说起
看代码之前先把攻击原理说透。SQL注入的出发点特别简单:程序把用户输入的内容,当成SQL代码的一部分执行了。
我拿最常见的登录逻辑举例。假设有这么一张用户表:
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL );然后你的登录代码长这样:
String sql = "SELECT * FROM user WHERE username='" + username + "' AND password='" + password + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);这是我在无数老项目里见过的最经典写法。正常用户输入admin和123456,最终SQL是:
SELECT * FROM user WHERE username='admin' AND password='123456'但攻击者不这么玩。他在用户名字段里输入:
admin' OR '1'='1密码随便填一个x,拼出来的SQL就变成了:
SELECT * FROM user WHERE username='admin' OR '1'='1' AND password='x'注意看,因为OR '1'='1'这个条件永远为真,AND的优先级虽然比OR高,但整条语句只要OR左边成立,右边根本无所谓。更狠一点的写法是用户名输入' OR 1=1 --,利用注释把后面密码条件直接抹掉:
SELECT * FROM user WHERE username='' OR 1=1 -- ' AND password='x'执行结果就是返回整张表的第一行数据,通常是管理员账号。攻击者不需要知道任何密码,直接就登录了。
我早期带过一个实习生,第一次看到这种攻击方式时很震惊,他说“不就是多打了一个单引号吗”。对,就是多打了一个单引号。但这一个引号,意味着SQL语句的结构被改变了,你原本想表达“用户名等于某个值”这个条件,被篡改成了“只要某个恒真的逻辑成立就放行”。这就是SQL注入最底层的逻辑:攻击者通过闭合已有的引号、括号,往SQL里塞进自己的代码片段,改变语句的真实语义。
1.2 拼接为什么致命:从SQL语法层面看
要真正理解防护方案的原理,不能只停在“会出安全事件”的层面,得理解它为什么能改变语义。
SQL本质上是字符串。当你用Java的+号把变量拼进SQL字符串时,最终送到数据库服务器的,是一个完整的、包含数据和逻辑指令的文本。数据库收到这段文本后,必须先做词法分析、语法解析,才能生成执行计划。也就是说,所有被拼接进去的内容,都进入了语法解析流程。
这里有个关键点:数据库本身无法区分,哪一段是你程序里写死的SQL结构,哪一段是用户提交的数据。它只能从文本层面判断,而这恰恰给了攻击者可乘之机。用户输入的单引号,在SQL语法里就是字符串边界符;输入的分号,可能就是语句分隔符;输入的--,就是注释符。数据库老老实实地按语法规则解析,然后执行了攻击者想要的逻辑。
我经常用一个比喻来解释这件事:SQL语句是一张已经写好的答题卡,但你用“拼接”这种方式,相当于让用户在答题卡上自己加问题、改选项、甚至改标题。数据库阅卷时只会按改过后的答题卡给分,哪知道哪些是原题哪些是后加的?
理解了这一层,再看JDBC的防护方案就会很通透:防护的核心思路,是不让用户输入有机会进入SQL语法解析阶段。思路不同,做法也不同,下面专门展开讲。
2. JDBC里的高危写法盘点
2.1 Statement字符串拼接:最典型的反面教材
先用一个表格把JDBC访问数据库的几种经典方式拉出来对比一下:
| 方式 | 代码特征 | 安全性 | 适用场景 |
|---|---|---|---|
| Statement + 字符串拼接 | conn.createStatement()+ 直接拼接 | 极危险,极易注入 | 动态DDL、无参数的特殊语句,但也得小心 |
| Statement + 手动转义 | 拼接前手动replace引号 | 半吊子,容易漏 | 不推荐,转义不彻底 |
| PreparedStatement + 参数绑定 | setString/setInt填充占位符 | 安全,推荐 | 所有常规DML操作 |
| CallableStatement + 存储过程 | 调用数据库存储过程 | 看存储过程内部实现 | 业务复杂时可选 |
最危险的就是第一种。注意看这两行Java代码的区别:
// 危险 Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM user WHERE id = " + id); // 安全 PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?"); ps.setInt(1, id); ResultSet rs = ps.executeQuery();代码就差了那么几行,本质却天差地别。前者是“把数据当代码执行”,后者是“代码结构固定,数据只按数据走”。
我见过不少开发者会有这样一种想法:只要对用户的输入做一下转义,把单引号替换成\',不就能防止注入了吗?
这个思路听起来有道理,实操却极其不靠谱。首先,不同数据库的转义规则不完全一样,MySQL跟Oracle就有差异,尤其是涉及反斜杠和字符集时,很容易出漏洞。其次,转义只能处理一部分已知的攻击模式,攻击者可以玩各种编码、注释拆分、宽字节注入的花样。亘古不变的真理是:过滤和转义是黑名单思维,攻击者有无数种办法绕过黑名单。所以业内普遍把参数化查询作为首选,而不是靠手动转义。
2.2 你以为安全了?三个危险变种
我排查过很多项目的SQL代码,发现有些开发者明明用了PreparedStatement,却还是漏了洞。典型的有三个变种:
第一个变种是把参数拼到SQL模板里。比如:
String keyword = request.getParameter("k"); String sql = "SELECT * FROM product WHERE name LIKE '%" + keyword + "%'"; PreparedStatement ps = conn.prepareStatement(sql); ps.executeQuery();这里虽然用了PreparedStatement对象,但实际执行时相当于传了一个完整拼接好的字符串进去,占位符一个都没有,数据库拿到的还是拼接后的整段SQL。PreparedStatement的外壳救不了你,本质还是Statement拼接。
第二个变种是表名、字段名、排序字段用拼接。参数化查询的占位符只能放在值的位置,表名这些结构部分没法用?代替。于是有同学图省事,直接"SELECT * FROM " + tableName + " WHERE id = ?"。如果tableName来路不正,攻击者完全可以把你传的表名改成user; DROP TABLE user --,或者干脆改成别的表去拖数据。结构位置上的拼接,比值的拼接更危险,因为这里连引号都省了,直接写关键字就行。
第三个变种出现在ORM框架的滥用上。我记得在某公司审查过一套MyBatis写的代码,里面到处是${}而不是#{}。熟悉MyBatis的朋友知道,#{}底层走的是PreparedStatement参数绑定,而${}是直接把变量值拼进SQL文本。两者长得像,安全性完全不在一个量级。不少接手老项目的同事根本没留意这个细节,糊里糊涂地以为MyBatis框架本身就安全,结果把${}拼进去的线上SQL漏成了筛子。
我拿MyBatis举例说明一下:
<!-- 安全写法 --> <select id="getUser" resultType="User"> SELECT * FROM user WHERE username = #{name} </select> <!-- 危险写法,等于字符串拼接 --> <select id="getUser" resultType="User"> SELECT * FROM user WHERE username = '${name}' </select>我遇到过一次线上告警,某后台管理系统的操作日志突然多了一批异常的查询记录,一查全是SELECT * FROM product WHERE name = '%乱码%',就是有人故意在搜索框里塞注入payload,撞上了${}写法的接口。还好那个系统没有敏感数据,不然又是一场“删库跑路”的灾难。
3. 用PreparedStatement把漏洞堵在源头
3.1 预编译到底做了什么
很多人只知道PreparedStatement安全,但说不清安全在哪。我试着把底层机制讲清楚。
当你写下这一段代码:
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE username=? AND password=?"); ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery();JDBC驱动处理这条SQL的方式,跟Statement完全不一样。SQL模板SELECT * FROM user WHERE username=? AND password=?会被预先交给数据库进行解析、编译、生成执行计划。此时SQL结构已经定型:username和password这两个值的位置是两个占位符,数据库清楚地知道这里将来只是塞“数据”,不会有任何“指令”。之后,setString传进去的字符串,走的是与结构分离的数据通道,直接作为参数值传给执行计划。
这也是PreparedStatement名字的含义:预编译。数据库不是拿到最终拼接文本才开始工作,而是先完成语法解析,再接收纯数据。用户输入的内容根本没有机会参与SQL结构定义,单引号、分号、注释符统统只当普通字符处理。就算用户在用户名里输入' OR '1'='1,数据库也只会认为这个字符串就是要比较的完整值,语法层面不会把它拆出新的逻辑。
这里要提一个常见误解:有人以为PreparedStatement安全是靠驱动对参数做了“转义”。严格说,安全性并不依赖于转义,而是来自SQL与数据的分离。很多数据库驱动确实会在协议层以二进制方式传输参数值,从而避免了文本层的语义混淆。理解了这一点,你就能明白为什么参数化查询比手工转义可靠得多——它从机制上消除了“数据变成代码”的可能性。
我翻了JDBC 54.2版本的相关文档,发现这个版本对PreparedStatement的缓存和参数传递做了进一步的优化,服务端预编译的复用在连接池场景下表现更稳定。这部分细节用到的时候可以去查阅具体Release说明,但安全性的核心机制是不变的:SQL模板与参数值分离传递。
3.2 各种边界场景的参数化写法
理解了原理,实际操作就顺理成章。下面我把日常开发中高频出现的几种参数化写法列出来,都是可以直接抄的作业。
第一个是常规增删改查。以查询为例:
String sql = "SELECT * FROM user WHERE id = ? AND status = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, userId); ps.setString(2, "ACTIVE"); ResultSet rs = ps.executeQuery();注意占位符的下标从1开始,不是0。类型要跟数据库字段类型对应,setInt、setString、setLong、setDate该用哪个用哪个。不要图省事全用setString,类型不对会导致索引失效或隐式转换,轻则性能下降,重则查询结果不对。
第二个是INSERT和UPDATE,参数化一样支持,没什么区别:
String sql = "INSERT INTO user (username, password) VALUES (?, ?)"; PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, username); ps.setString(2, encryptedPassword); int affected = ps.executeUpdate(); ResultSet keys = ps.getGeneratedKeys(); if (keys.next()) { System.out.println("自增ID:" + keys.getLong(1)); }第三个是模糊查询。很多人在这里踩坑,以为LIKE ?里可以加通配符,实际上通配符应该拼在Java层的参数里:
// 正确做法:通配符在参数里,不在SQL模板里 String keyword = request.getParameter("keyword"); String pattern = "%" + keyword + "%"; String sql = "SELECT * FROM product WHERE name LIKE ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, pattern); ResultSet rs = ps.executeQuery();这样写既安全,又不会遇到“参数里带%没被转义”的坑。你可能会想,如果把%key%直接拼SQL模板会怎样?那又回到拼接的老路上了,危险。
第四个是IN子句。IN后面要跟多个占位符,需要动态生成:
List<Integer> ids = Arrays.asList(1, 2, 3, 4); StringBuilder sql = new StringBuilder("SELECT * FROM user WHERE id IN ("); for (int i = 0; i < ids.size(); i++) { sql.append("?,"); } sql.setLength(sql.length() - 1); // 去掉最后的逗号 sql.append(")"); PreparedStatement ps = conn.prepareStatement(sql.toString()); int index = 1; for (Integer id : ids) { ps.setInt(index++, id); } ResultSet rs = ps.executeQuery();这里占位符的数量是由Java代码控制的,SQL结构依然固定,传入的ids集合中每个值都通过setInt走数据通道,完全没有注入空间。
我见过有人在这个场景偷懒:
String idStr = String.join(",", ids); String sql = "SELECT * FROM user WHERE id IN (" + idStr + ")";看着挺省事,但攻击者只要在ids里塞一个1) OR 1=1 --就能拖出全表。写代码这种事,该多几行的地方真不能省。
4. 纵深防御:参数化之外的几道防线
4.1 给数据库账号上镣铐:最小权限
参数化查询从根儿上防住了绝大多数注入攻击,但它不是银弹。我见过系统里所有业务代码共用一个数据库超级账号的情况,这个账号不但能增删改查,还能DROP DATABASE。这就像给所有员工发了一把万能钥匙,门锁再结实也没意义。
最小权限原则在这里尤其重要。应用连接数据库使用的账号,应该只为当前业务开通必要权限。比如一个报表系统,账号原则上只需要SELECT;一个订单系统,核心表可能有SELECT、INSERT、UPDATE;但绝不能随手给DELETE、DROP、ALTER,更不应该给GRANT OPTION。
我建议在项目启动阶段就做一次数据库账号权限盘点,表格可以这样列:
| 应用模块 | 连接账号 | 可用数据库 | 已授权操作 | 是否必要时才开放 |
|---|---|---|---|---|
| 用户中心 | app_user_rw | user_db | SELECT/INSERT/UPDATE | 是 |
| 报表分析 | app_report_ro | report_db | 仅SELECT | 是 |
| 批量运维 | app_admin | admin_db | SELECT/INSERT/UPDATE/DELETE | 否,应拆分 |
有的团队可能会问:应用代码可能在某些场景下需要DDL,比如动态建表。我的建议是,这类需求应该走专门的运维工单流程,用独立的 migrations 工具去执行,而不是让业务代码的数据库账号具备DDL权限。哪怕这意味着上线流程稍微复杂一点,也值得。因为攻击者即使通过SQL注入拿到了系统权限,如果没有DDL和DROP权限,能造成的危害也会小一大截。
4.2 输入校验:把脏数据挡在门外
第二个防线的思路是:就算参数化已经把“数据”和“SQL”分离了,你仍然应该在业务逻辑层对输入做合理性校验。这不是替代参数化,而是在参数化之上再加一层过滤。
比如,用户传入的ID明显应该是长整型,结果传进来一串带英文字母的字符串,直接拦截返回参数错误;比如邮箱字段不符合邮箱正则,手机号不是11位数字,提前在前端和Controller层就挡掉。
有一种说法是“参数化之后输入校验就没意义了”,我不认同。参数化管的是“数据库会怎么执行”,输入校验管的是“业务逻辑能否被干扰”。比如一个查询接口,参数是日期范围,本来写死在程序里的业务规则是只能查最近30天,攻击者通过篡改请求参数传了个1900年的日期进去。这个值虽然通过参数化安全地传给SQL执行了,不会造成注入,但可能导致全表扫描,把数据库拖垮,这就是业务层面的滥用。合理的做法是在参数化之前就校验日期范围必须在最近30天内,超出即拒绝。
常用工具也顺手推荐一下:Hibernate Validator的@Pattern、@Size、@Min、@Max注解,可以直接在DTO上做约束;复杂的规则再配合自定义校验器。别过度设计,核心原则就一句话:不能信任客户端的任何输入。
4.3 日志与审计:最后一道看得见的防线
最后一条防线是数据库操作日志和异常监控。很多人觉得日志是给测试看的,实际上安全防护里,日志是你发现攻击尝试的重要线索。
我在某个项目里给DAO层加了统一的SQL执行日志切面,记录PreparedStatement最终执行的SQL模板、参数类型和具体的预热参数(对于敏感字段可以脱敏)。上线之后两周,日志里就出现了好几条明显是攻击测试的记录,有人往搜索接口里传' OR 1=1 --,状态码虽然有被拦下的痕迹,但如果没有日志,我根本不知道那段时间有人在扫描这个系统。
具体操作上,可以在数据库驱动层开启慢SQL日志,同时把错误日志中SQL语法异常单独汇总。攻击者如果在某个接口反复触发语法错误,往往说明他在尝试构造注入payload。这种模式一旦被监控捕获,就该告警了。
这里也给个小建议:日志中记录的参数值如果是密码、身份证号、银行卡号这类敏感信息,要做脱敏处理,只记录前几位或者干脆打码。否则日志本身又会变成数据泄露的出口,那就得不偿失了。
5. 线上排障实录:SQL注入防护的高频坑
5.1 LIKE模糊查询和IN子句处理
上面列场景时已经写了正确写法,这里我集中说下线上容易遇到的坑。
先看LIKE的坑。有同事从老系统里接过来一段代码,SQL长这样:
String sql = "SELECT * FROM product WHERE name LIKE '%" + searchKey + "%'";这个接口天天被安全扫描用例打成“高风险SQL注入”,然后开发很委屈:“我已经把keyword传给PreparedStatement了啊?”问题就出在占位符没有真正使用,keyword在SQL模板阶段就被拼进字符串了。
正确改法我已经给出过:Java层拼%keyword%,SQL模板层只写LIKE ?。
再看IN的坑。我第一次在项目里写这个工具方法时也踩了坑:setString把整串"1,2,3"塞进一个占位符,结果查出来永远是0条数据。后来才意识到,PreparedStatement的每个?对应一个单独的SQL参数,IN (?)只代表一个值,参数类型如果是VARCHAR,数据库会拿'1,2,3'这个整体去匹配数值列,自然查不到。要支持多个值,必须动态生成多个占位符,逐个绑定。
还有一个隐藏比较深的坑:动态生成占位符时,如果ids集合是一个不可信的请求参数列表,数量极大,比如传入上万个子项,数据库端有可能触发参数数量上限或者SQL长度超限。这种场景要设一个阈值,比如最多200个,超出就走分批查询或者临时表。
5.2 动态排序字段不落参数化怎么办
这个场景很多人会遇到:前端传orderBy=price&orderType=desc,后端拼SQL时排序字段没法用占位符,于是有人直接:
String sql = "SELECT * FROM product ORDER BY " + orderBy + " " + orderType;这是极危险的操作。排序字段是SQL结构的一部分,?不能出现在ORDER BY后面某个值的位置,所以不能参数化。但结构位置不能参数化,不代表必须冒险拼接,正确的解法是用白名单映射。
我常用的做法是在Java代码里维护一个安全映射表:
private static final Map<String, String> SORT_COLUMN_MAP = new HashMap<>(); static { SORT_COLUMN_MAP.put("price", "price"); SORT_COLUMN_MAP.put("createTime", "create_time"); SORT_COLUMN_MAP.put("sales", "sales_volume"); } // 使用: String column = SORT_COLUMN_MAP.getOrDefault(orderBy, "create_time"); String direction = "desc".equalsIgnoreCase(orderType) ? "DESC" : "ASC"; String sql = "SELECT * FROM product ORDER BY " + column + " " + direction;column来自固定的Map,不可能被用户控制;direction做三元校验,非法值直接用默认。排序方向其实也可以直接在Java里判断逻辑,但用三元表达式做好白名单是最省事的方式。
同理,表名、字段名这类结构位置上的动态内容,标准做法永远是白名单。你也许会觉得Map方式不够灵活,但安全第一,宁可在功能上死板一点,不能拿片段拼接去换那点灵活性。
5.3 常见问题速查表
平时排查问题上,我把几类高频问题整理成一个速查表,关键时刻按表排查很快:
| 症状 | 可能原因 | 正确做法 |
|---|---|---|
| 登录可以被万能密码绕过 | Statement直接拼接SQL | 改为PreparedStatement参数绑定 |
| 用了PreparedStatement但扫描仍报漏洞 | SQL模板里还在拼接参数 | 检查?是否真正占位,别让参数提前进模板 |
| LIKE查询查不到数据 | 通配符写在SQL模板里或占位符用法错误 | 在Java层拼%key%,SQL只写LIKE ? |
| IN子句查询不到数据 | 把整个IN集合当字符串塞进单个占位符 | 动态生成多个占位符,逐个set |
| ORDER BY传参会报语法错或者被拖数据 | 排序字段直接拼接 | 白名单映射 + 默认值兜底 |
| 数据库被清空 | 账号权限过大 | 最小权限分配,别让应用账号有DROP/DDL权限 |
| 高危账号在多套系统间共用 | 运维图省事复用高权账号 | 一应用一账号,权限按业务收敛 |
我做安全加固时,习惯先把所有DAO层代码用正则扫一遍,找包含+号拼接、Statement、${}的位置,再逐个改成参数化写法。扫完一层后,再检查数据库账号权限,最后把日志和监控补上。三步走完之后,至少能在JDBC这个层面把SQL注入的洞填得比较稳固。
我个人在实际操作中最大的体会是:SQL注入防护不是一个“修一次就完事”的点,而是一种写代码的习惯。每次写完数据库操作,我会习惯性问自己一个问题:这条SQL是拼接的还是参数化的?参数有没有可能包含业务之外的输入?如果有,该不该在这个地方加白名单校验?这个习惯帮我挡住过不少隐患,离开开发机之前多问一句,胜过线上出了事之后熬通宵排查。最后再分享一个小技巧吧:可以给项目加一条简单的代码规范,要求所有DAO方法签名里的参数都必须以@Param形式显式声明,配合编译期或审查工具自动排查Statement和${}的用法。把“安全写SQL”做成团队默认动作,远比靠某一个人小心谨慎来得可靠。