SQL注入至今仍是Web应用最致命的安全漏洞之一。很多开发者以为用了Hibernate这类ORM框架就万事大吉,实际上如果写法不对,照样能把数据库底裤扒光。问题的核心不在于你用原生SQL还是HQL,而在于你是否把用户输入当作代码来执行了。命名参数查询之所以能防注入,本质是它把SQL逻辑和数据彻底分开了——数据库在解析SQL时就知道哪些是占位符,用户输入再狡猾也只能当作字符串字面量处理,永远不可能逃逸出来改变SQL语法结构。

原生SQL拼接为什么是灾难

先看一段典型的危险代码。假设有个登录接口,后端用原生SQL拼接用户名密码:

String username = request.getParameter("username");
String password = request.getParameter("password");
String sql = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'";
Query query = entityManager.createNativeQuery(sql, User.class);
List results = query.getResultList();

攻击者在用户名输入框填入 ' OR '1'='1' -- ,密码随便写。拼出来的SQL变成:

SELECT * FROM users WHERE username='' OR '1'='1' --' AND password='whatever'

单引号闭合了前面的字符串,OR条件恒真,两个减号把后面密码验证部分全注释掉了。这条SQL会返回users表里所有记录,攻击者直接绕过认证。更狠的变种是通过UNION SELECT拖库,或者利用堆叠查询执行DROP TABLE。问题的根源在于字符串拼接让用户输入参与了SQL语法构建,输入里的单引号、分号、注释符这些特殊字符获得了语法层面的解释权。

有些开发者觉得加个转义函数就安全了,比如把单引号替换成两个单引号。这种思路有严重缺陷:第一,不同数据库的转义规则不一样,MySQL用反斜杠,Oracle用双引号,你很难覆盖所有边界情况;第二,注入点不一定在字符串字面量里,比如数值型字段、ORDER BY子句、表名这些位置根本没法用转义来防;第三,多层嵌套转义容易出bug,一个地方漏掉就全盘皆输。所以OWASP等安全组织明确建议:参数化查询是防SQL注入的唯一正确方式,转义只能当辅助手段。

Hibernate命名参数的工作原理

Hibernate的命名参数查询用冒号加参数名作为占位符,用户输入通过setParameter方法绑定,永远不会和SQL模板混在一起。改写上面的登录逻辑:

String username = request.getParameter("username");
String password = request.getParameter("password");
String sql = "SELECT * FROM users WHERE username = :username AND password = :password";
Query query = entityManager.createNativeQuery(sql, User.class);
query.setParameter("username", username);
query.setParameter("password", password);
List results = query.getResultList();

这次攻击者再输入 ' OR '1'='1' -- ,数据库收到的是编译好的执行计划加上参数值。SQL模板在prepare阶段就已经完成语法解析,确定了WHERE子句是两个等值比较。参数值在execute阶段才传入,数据库只把它当字符串数据,里面的单引号、减号全部按字面量处理。最终执行的逻辑等价于“查找用户名为 ' OR '1'='1' -- 且密码为某值的记录”,显然找不到任何结果。注入攻击完全失效。

命名参数的优势不止安全。可读性上,:username 比一堆问号占位符直观得多,SQL长了以后不用数第几个问号对应哪个参数。维护性上,参数名和Java变量名可以对应起来,重构时不容易搞错位置。Hibernate对命名参数的支持也很全面,原生SQL、JPQL、Criteria API都能用,参数类型自动推断,日期、枚举等复杂类型自动转换,还能设置参数的各种选项比如时区、精度。

原生SQL参数化查询的正确姿势

不用Hibernate的项目,JDBC原生的PreparedStatement同样能实现参数化。核心API是Connection.prepareStatement预编译SQL,再用setXxx方法绑定参数:

String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();

问号占位符是JDBC标准的参数标记方式,效果和命名参数一样——SQL模板先发给数据库编译,参数值后传。但问号占位符在SQL很长、参数很多时维护成本高,比如几十个参数的INSERT语句,数序号很容易出错。Hibernate的命名参数本质上是对JDBC占位符的封装,在底层会把命名参数转换成对应数据库驱动的占位符格式,开发体验更好但安全原理完全相同。

有个常见误区:以为用了Hibernate的createNativeQuery就自动安全了。实际上安全取决于你怎么传参数。如果你用字符串拼接构造SQL再传给createNativeQuery,Hibernate也救不了你。安全的是参数绑定机制本身,不是某个API名称。所以代码审查时应该重点关注SQL字符串里有没有变量拼接,而不是看用了哪个框架。

动态SQL场景的深度对比

现实业务中很多查询条件是可选的,比如后台管理系统的多条件筛选。原生SQL拼接处理动态条件非常痛苦,而且极其危险。常见的不安全写法:

String sql = "SELECT * FROM products WHERE 1=1";
if (keyword != null && !keyword.isEmpty()) {
    sql += " AND name LIKE '%" + keyword + "%'";
}
if (categoryId != null) {
    sql += " AND category_id = " + categoryId;
}
if (minPrice != null) {
    sql += " AND price >= " + minPrice;
}

这段代码每个条件分支都是注入点。keyword里的百分号和单引号、categoryId和minPrice里可能被插入子查询,攻击面大得惊人。即使用PreparedStatement,动态拼接SQL模板本身也容易引入漏洞,因为拼接的是SQL片段而非纯数据。

Hibernate结合命名参数处理动态条件就安全得多。可以用Criteria API或者JPQL配合条件判断来构建查询:

StringBuilder hql = new StringBuilder("FROM Product p WHERE 1=1");
Map params = new HashMap<>();

if (keyword != null && !keyword.isEmpty()) {
    hql.append(" AND p.name LIKE :keyword");
    params.put("keyword", "%" + keyword + "%");
}
if (categoryId != null) {
    hql.append(" AND p.category.id = :categoryId");
    params.put("categoryId", categoryId);
}
if (minPrice != null) {
    hql.append(" AND p.price >= :minPrice");
    params.put("minPrice", minPrice);
}

Query query = session.createQuery(hql.toString());
for (Map.Entry entry : params.entrySet()) {
    query.setParameter(entry.getKey(), entry.getValue());
}

这里虽然动态拼接了HQL模板,但用户输入始终通过命名参数绑定,没有直接拼进查询字符串。攻击者输入的任何恶意字符都被限制在参数值范围内,无法修改HQL的语法结构。不过要注意,拼接HQL模板时如果用了用户输入作为表名、字段名或ORDER BY方向,仍然有风险。这些场景下建议用白名单校验,或者用Criteria API的元模型来避免字符串拼接。

Criteria API:终极安全方案

如果动态查询特别复杂,Hibernate的Criteria API是更安全的选择。它完全用面向对象的方式构建查询,零字符串拼接:

CriteriaBuilder cb = session.getCriteriaBuilder();
CriteriaQuery cq = cb.createQuery(Product.class);
Root root = cq.from(Product.class);
List predicates = new ArrayList<>();

if (keyword != null && !keyword.isEmpty()) {
    predicates.add(cb.like(root.get("name"), "%" + keyword + "%"));
}
if (categoryId != null) {
    predicates.add(cb.equal(root.get("category").get("id"), categoryId));
}
if (minPrice != null) {
    predicates.add(cb.ge(root.get("price"), minPrice));
}

cq.where(predicates.toArray(new Predicate[0]));
List results = session.createQuery(cq).getResultList();

Criteria API在编译期就能检查字段名是否正确,重构实体类时IDE会自动更新引用,安全性和开发效率都拉到最高。它的底层同样使用参数化查询,所有用户输入自动绑定为参数。唯一的代价是代码量稍多,复杂关联查询的可读性不如SQL直观,但换来的是零注入风险。

存储过程调用也要参数化

不少遗留系统重度依赖存储过程,调用时同样存在注入风险。错误写法是把参数值拼进CALL语句:

String sql = "CALL sp_search('" + keyword + "')";

正确做法是用Hibernate的StoredProcedureQuery或JDBC的CallableStatement,通过参数绑定传值:

StoredProcedureQuery spq = entityManager.createStoredProcedureQuery("sp_search");
spq.registerStoredProcedureParameter("keyword", String.class, ParameterMode.IN);
spq.setParameter("keyword", keyword);
spq.execute();

存储过程内部也要注意,如果过程体里用EXEC或sp_executesql拼接动态SQL,同样可能产生二次注入。安全是纵深防御,每一层都要做好参数化。

性能层面的额外收益

参数化查询除了安全,还能带来明显的性能提升。数据库在处理SQL时,先要解析语法、生成执行计划、优化查询路径,这个过程消耗不小。如果每次查询的SQL文本都不同(因为拼接了不同参数值),数据库无法复用之前的执行计划,每次都要重新解析优化。参数化查询的SQL模板固定不变,数据库可以缓存执行计划,后续相同模板的查询直接复用,在高并发场景下性能差距能达到数倍甚至数十倍。

以MySQL为例,PreparedStatement默认开启服务端预编译,同一连接的重复执行会命中查询缓存。Oracle和PostgreSQL也有类似的计划缓存机制。Hibernate的命名参数查询在底层用的就是PreparedStatement,自动享受这些数据库优化。所以参数化查询是安全和性能双赢的做法,没有任何理由不用。

常见反模式与避坑指南

第一种反模式:in子句的参数化。很多人以为可以这样写:

String sql = "SELECT * FROM users WHERE id IN (:ids)";
query.setParameter("ids", "1,2,3");

这不行,参数绑定会把整个字符串当作一个值,实际执行的逻辑是查找id等于字符串"1,2,3"的记录。正确做法是动态生成多个占位符:

List idList = Arrays.asList(1L, 2L, 3L);
String sql = "SELECT * FROM users WHERE id IN (:ids)";
Query query = session.createNativeQuery(sql, User.class);
query.setParameterList("ids", idList);

Hibernate的setParameterList方法会自动展开为多个参数占位符,既安全又方便。

第二种反模式:ORDER BY和GROUP BY的参数化。这两个子句需要的不是数据值而是列名或排序方向,参数绑定无法处理标识符。安全做法是白名单校验:

private static final Set ALLOWED_COLUMNS = Set.of("id", "username", "create_time", "score");
private static final Set ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List getUsersByOrder(String orderBy, String direction) {
    if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
        throw new IllegalArgumentException("Invalid column: " + orderBy);
    }
    if (direction == null || !ALLOWED_DIRECTIONS.contains(direction.toUpperCase())) {
        throw new IllegalArgumentException("Invalid direction: " + direction);
    }
    String sql = "SELECT * FROM users ORDER BY " + orderBy + " " + direction.toUpperCase();
    return session.createNativeQuery(sql, User.class).getResultList();
}

第三种反模式:LIKE查询的转义。用户输入可能包含百分号或下划线这些LIKE通配符,攻击者可以构造 % 来匹配所有记录进行遍历。参数化查询本身不处理这种情况,需要额外转义:

String escapedKeyword = keyword.replace("\\", "\\\\")
                               .replace("%", "\\%")
                               .replace("_", "\\_");
query.setParameter("keyword", "%" + escapedKeyword + "%");

同时在SQL的LIKE子句后加上ESCAPE子句指定转义字符。

框架选型与团队规范建议

从安全实践角度,团队应该在代码规范层面明确禁止SQL字符串拼接。所有数据库操作统一使用命名参数或Criteria API,Code Review时把字符串拼接SQL作为阻断项。可以在CI流水线里集成静态代码扫描工具,自动检测SQL拼接模式。Hibernate的原生SQL查询功能要限制使用场景,确实需要手写SQL的复杂查询,也必须走参数化路线。

对于遗留系统里大量存在的拼接SQL,重构优先级应该按数据敏感度排序:涉及用户凭证、个人隐私、支付信息的接口最先改,其次是后台管理系统的查询接口,最后是内部只读报表。重构时可以用Hibernate的@NamedNativeQuery注解把SQL模板集中管理,配合参数绑定统一收口。

总结下来,防止SQL注入不需要什么高深技术,核心就一条铁律:永远不要用字符串拼接的方式把用户输入放进SQL语句。Hibernate的命名参数查询和JDBC的PreparedStatement都是这条铁律的工程化实现,它们用占位符把代码和数据隔离,从机制上杜绝了注入可能。选哪个工具不重要,重要的是理解背后的原理,并在每一行代码里贯彻到底。