通过JDBC的Statement对象执行多条SQL语句,是SQL注入攻击中最危险的场景之一。攻击者只需要在用户输入中拼接一个分号,再跟上一条恶意语句,就能在一次请求中完成数据窃取、篡改甚至删除整张表的操作。最直接有效的解决方案就是:永远不要用Statement执行拼接的SQL,改用PreparedStatement进行参数化查询,同时在数据库层面禁止多语句执行(如MySQL的allowMultiQueries=false),再配合输入校验和最小权限原则,才能从根本上堵住这个漏洞。
很多开发人员在写Java代码时,习惯用Statement对象拼接SQL字符串,觉得方便快捷。但这种写法一旦遇上用户可控的输入,就等于把数据库的大门敞开了。尤其是当你允许一次执行多条语句时,攻击者可以用一条正常查询做掩护,后面跟着一条DROP TABLE或者INSERT INTO,后果不堪设想。下面我会从原理、风险、具体代码对比、防护策略四个层面,把这件事讲透。
一、Statement执行多条语句的原理和危险在哪JDBC中的Statement对象有一个execute()方法,它可以执行任意SQL字符串。如果你的SQL是这样拼的:
String sql = "SELECT * FROM users WHERE name = '" + userName + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql);
当userName的值是"admin' OR '1'='1"时,SQL就变成了:
SELECT * FROM users WHERE name = 'admin' OR '1'='1'
这已经是经典注入了。但更可怕的是,如果你用的是MySQL驱动,并且连接字符串里加了allowMultiQueries=true,攻击者输入"admin'; DROP TABLE users; --",SQL就变成了:
SELECT * FROM users WHERE name = 'admin'; DROP TABLE users; --'
一次请求,两条语句,第一条正常执行,第二条直接把表删了。这就是多语句执行带来的核心风险:攻击面被成倍放大。Statement本身不做任何参数转义,它就是原样把你拼好的字符串发给数据库执行,数据库根本分不清哪部分是你写的逻辑、哪部分是用户输入的恶意代码。
二、PreparedStatement为什么能解决这个问题PreparedStatement的工作原理是:SQL语句先发送到数据库进行预编译,参数部分用占位符(?)代替,然后单独把参数值绑定上去。数据库在预编译阶段就已经确定了SQL的结构,后续传入的参数值只会被当作数据处理,不会被当作SQL指令执行。
String sql = "SELECT * FROM users WHERE name = ?"; PreparedStatement pstmt = connection.prepareStatement(sql); pstmt.setString(1, userName); ResultSet rs = pstmt.executeQuery();
就算userName传入"admin' OR '1'='1",数据库也只会把它当成一个完整的字符串去匹配name字段,不会把单引号当作SQL语法的一部分。这就是参数化查询的本质:代码和数据在传输层面就被严格分离了。
需要特别注意的是,PreparedStatement并不是万能的。如果你在代码里仍然用字符串拼接来构造SQL的结构部分(比如表名、列名、ORDER BY方向),然后再用PreparedStatement绑定参数,那结构部分依然存在注入风险。正确的做法是:所有用户输入的内容都用参数绑定,动态的表名和列名必须通过白名单校验来控制。
三、数据库连接层面的防护配置光靠Java代码层面的防护还不够,你还需要在数据库连接配置上做限制。以MySQL为例,在JDBC连接URL中明确设置:
jdbc:mysql://localhost:3306/mydb?allowMultiQueries=false
这个参数默认就是false,但很多老项目或者从其他数据库迁移过来的项目会显式开启它,因为有些批量操作场景需要一次执行多条语句。如果你的业务不需要这个功能,务必关掉。关掉之后,即使攻击者在输入里加了分号,驱动层就会直接报错,根本不会把多条语句发给数据库。
对于PostgreSQL,它本身就不支持在一次execute中执行多条语句(除非用特定的扩展),所以风险相对小一些,但依然不能掉以轻心,参数化查询仍然是必须的。对于SQL Server,可以通过连接字符串中的"multipleActiveResultSets"等参数来控制行为,具体需要查阅对应驱动的文档。
四、输入校验和白名单机制参数化查询解决了大部分问题,但还有一些场景需要额外的输入校验。比如用户要选择排序字段,你不能用参数绑定列名,因为PreparedStatement的?只能绑定值,不能绑定标识符。这时候就需要白名单校验:
String sortColumn = request.getParameter("sort");
List<String> allowedColumns = Arrays.asList("name", "age", "create_time");
if (!allowedColumns.contains(sortColumn)) {
throw new IllegalArgumentException("非法排序字段");
}
String sql = "SELECT * FROM users ORDER BY " + sortColumn;
PreparedStatement pstmt = connection.prepareStatement(sql);
// 后续参数正常绑定
白名单的核心思想是:只允许明确知道安全的值通过,其他一律拒绝。这比黑名单(试图列出所有危险字符)要可靠得多,因为黑名单永远列不全,攻击者总能找到绕过方式。
另外,对于数字类型的输入、固定长度的字符串、枚举值等,都应该在Java层做严格的类型和格式校验。比如年龄字段,你应该用Integer.parseInt()去解析,如果解析失败就直接拒绝,而不是把原始字符串塞进SQL里。
五、最小权限原则和数据库账户隔离就算前面所有防护都做了,万一某个地方疏漏了,攻击者还是有可能注入成功。这时候,数据库账户的权限控制就是最后一道防线。应用程序连接数据库使用的账户,应该只拥有业务必需的最小权限。比如一个只做查询的模块,就不应该给它INSERT、UPDATE、DELETE、DROP的权限。一个只操作某几张表的服务,就不应该给它访问其他表的权限。
具体做法是:为不同的业务模块创建不同的数据库账户,每个账户只授予SELECT权限或者有限的DML权限,绝对不要用root或者sa账户去连接数据库。这样即使发生注入,攻击者能做的事情也被限制在很小的范围内,不至于造成灾难性后果。
六、ORM框架能不能完全替代手写SQL的防护很多团队用MyBatis、Hibernate这类ORM框架,觉得框架会自动处理注入问题。确实,MyBatis的#{}语法和Hibernate的HQL参数绑定都能防止大部分注入,但这不代表你可以高枕无忧。在MyBatis中,如果你用${}来拼接动态表名或者列名,一样会有注入风险:
<!-- 危险写法,使用${}会直接拼接 -->
<select id="getUser" resultType="User">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
正确的做法是,动态表名也要通过Java代码的白名单校验后再传入,或者使用MyBatis的<if>标签配合固定的表名枚举来实现。框架只是工具,安全意识和正确用法才是根本。
七、实战中常见的错误做法和纠正我见过不少项目犯这些错误:第一,用Statement配合字符串拼接做查询,理由是"这条SQL很简单,不会有问题"。第二,虽然用了PreparedStatement,但在拼接LIKE查询时直接把通配符拼进参数里,导致逻辑混乱。第三,连接池配置里开了allowMultiQueries却不知道有什么用。第四,把用户输入直接拼进ORDER BY、GROUP BY子句里。
纠正这些问题其实不难:统一代码规范,所有SQL执行必须走PreparedStatement或者ORM的参数绑定;代码审查时重点检查SQL拼接的地方;上线前用SQLMap之类的工具做一次自动化扫描。养成习惯比什么都重要。
八、总结和行动建议防止SQL注入通过JDBC Statement执行多条语句的风险,本质上是一个多层防御的问题。第一层,代码层面用PreparedStatement替代Statement,彻底杜绝字符串拼接。第二层,连接配置层面关闭多语句执行选项。第三层,输入层面做白名单校验和类型检查。第四层,数据库层面用最小权限账户隔离风险。第五层,定期做安全扫描和代码审计。任何单一层的防护都不能保证百分之百安全,但五层叠加之后,攻击成本会高到让绝大多数攻击者放弃。
作为开发者,你不需要成为安全专家,但你必须知道Statement拼接SQL是高危操作,必须知道PreparedStatement怎么用,必须知道连接参数里有哪些安全相关的配置。这些都是基本功,也是面试和实际工作中经常被考察的点。把这些做好,你的应用在SQL注入这个维度上就已经超过了市面上大部分项目。
