很多开发者有个根深蒂固的误区,认为用了ORM框架就彻底告别SQL注入了。事实上,ORM只是提供了参数化查询的便利,却无法阻止开发者写出危险的代码。ORM生成的SQL被注入,根源从来不是框架本身,而是使用框架的人绕过了安全机制。当你用ORM的字符串拼接功能去构造动态查询、或者直接执行原生SQL时,注入漏洞就悄无声息地打开了大门。这不是ORM的失败,而是安全意识的缺位。

ORM的参数化查询与字符串拼接的致命区别

绝大多数主流ORM框架,如Hibernate、MyBatis、Entity Framework、SQLAlchemy等,在正常使用其查询API时,底层都会自动生成预编译语句和参数化查询。例如在Java的JPA中,使用JPQL的命名参数或占位符,框架会将用户输入作为参数绑定,而不是拼接到SQL字符串中。这种情况下,恶意输入会被当作普通字符串处理,无法改变SQL语句的结构。

但问题出在开发者为了“灵活”,主动使用ORM提供的字符串拼接功能。比如MyBatis中使用${}而不是#{},JPA中使用CriteriaBuilder的concat拼接条件,或者直接调用createNativeQuery执行拼接后的SQL。这些操作等同于手动拼接字符串,将用户输入直接嵌入SQL语句,完全绕过了ORM的安全层。

// 危险示例:MyBatis中使用${}直接拼接
@Select("SELECT * FROM users WHERE username = '${username}'")
User findByUsername(String username);

// 安全示例:使用#{}参数化
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(String username);

上面两个例子看起来相似,但底层实现天差地别。${}是字符串替换,发生在SQL语句构建阶段,此时用户输入直接成为SQL的一部分。#{}是参数占位符,框架会将SQL与参数分离发送给数据库,数据库将其视为数据而非代码。攻击者输入admin' OR '1'='1,在第一个例子中会直接改变SQL逻辑,在第二个例子中只会被当作一个奇怪的用户名去匹配。

动态排序、分组和表名:最容易被忽视的注入点

参数化查询有一个天然限制:只能参数化值,不能参数化SQL关键字、表名、列名、排序方向等结构元素。当业务需要动态排序、动态指定分组字段或动态切换表名时,开发者往往被迫进行字符串拼接。这正是ORM框架下SQL注入的重灾区。

很多开发者知道查询条件要参数化,却对order by、group by后面的字段掉以轻心。比如一个常见的分页查询接口,允许前端传递排序字段和排序方向,后端用ORM拼接出最终SQL。如果不对排序字段做白名单校验,攻击者可以直接注入恶意SQL片段。

// 危险:直接拼接排序字段
String sql = "SELECT * FROM products ORDER BY " + sortField + " " + sortDirection;
Query query = entityManager.createNativeQuery(sql, Product.class);

// 安全:白名单校验排序字段
private static final Set ALLOWED_SORT_FIELDS = Set.of("id", "name", "price", "create_time");
private static final Set ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List getProductsByOrder(String sortField, String sortDirection) {
    if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
        throw new IllegalArgumentException("Invalid sort field");
    }
    if (sortDirection == null || !ALLOWED_DIRECTIONS.contains(sortDirection.toUpperCase())) {
        throw new IllegalArgumentException("Invalid sort direction");
    }
    // 此时可以安全拼接,因为值已经完全受控
    String sql = "SELECT * FROM products ORDER BY " + sortField + " " + sortDirection.toUpperCase();
    return entityManager.createNativeQuery(sql, Product.class).getResultList();
}

白名单校验是处理动态结构元素的核心原则。永远不要尝试用黑名单过滤,因为SQL语法极其复杂,绕过手段层出不穷。只有将输入限制在预定义的合法值集合内,才能从根本上杜绝注入。对于表名、列名、排序字段等场景,必须建立严格的白名单映射,将用户输入的标识符与实际的数据库对象名称解耦。

ORM原生SQL和动态查询构建器的陷阱

每个ORM框架都提供了执行原生SQL的能力,这本是为了处理复杂查询和性能优化,却成了注入漏洞的温床。JPA的createNativeQuery、Hibernate的createSQLQuery、MyBatis的@Select注解配合${}、Entity Framework的ExecuteSqlRaw,这些API接收字符串作为SQL语句,如果这个字符串的任意部分来自用户输入且未经处理,注入就会发生。

更隐蔽的是动态查询构建器。JPA的Criteria API、Hibernate的Criteria、Entity Framework的LINQ动态查询,这些API本身是安全的,因为它们最终生成参数化查询。但某些框架允许在动态构建过程中插入原生SQL片段。比如JPA中可以在CriteriaBuilder中使用function调用数据库函数,如果函数名或参数来自用户输入,同样可能被注入。此外,一些开发者为了图方便,在动态查询中混用字符串拼接来构造条件,这同样危险。

// 危险:在Criteria中使用字符串拼接构造条件
String username = request.getParameter("username");
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery query = cb.createQuery(User.class);
Root root = query.from(User.class);
query.where(cb.equal(root.get("username"), username)); // 这里是安全的

// 但如果开发者这样写:
query.where(cb.literal("username = '" + username + "'")); // 极其危险

还有一种常见情况是使用ORM的“命名查询”时,在XML配置或注解中硬编码了不安全的SQL片段。如果命名查询中使用了字符串替换而非参数占位符,注入风险就固化在了代码里。审查代码时,要特别关注所有SQL或类SQL字符串的构造方式,看是否有用户输入能够进入这些字符串。

存储过程和函数调用中的注入风险

ORM调用存储过程时,很多人误以为存储过程内部是安全的,从而放松了对外部参数的校验。实际上,如果存储过程内部使用了动态SQL并且拼接了传入的参数,注入同样会发生。ORM只是将参数传递给数据库,无法控制存储过程内部的行为。攻击者可以通过构造恶意参数,利用存储过程内部的字符串拼接执行任意SQL。

更危险的是,某些ORM框架允许通过原生SQL调用函数或存储过程,并且支持拼接参数。例如在PostgreSQL中,如果ORM生成的SQL是SELECT * FROM execute_function('用户输入'),而函数内部使用了动态SQL,注入就可能成功。开发者需要审查的不仅是应用层代码,还包括数据库层的存储过程、函数、触发器等,确保它们内部也使用了参数化查询。

LIKE查询和转义字符的隐患

LIKE模糊查询是另一个容易出问题的地方。ORM的参数化查询虽然能防止SQL注入,但LIKE查询的通配符%和_如果来自用户输入,可能导致信息泄露或性能攻击。攻击者可以输入%来匹配所有记录,或者输入大量通配符造成拒绝服务。这不是传统意义上的SQL注入,但同样属于注入类攻击的范畴。

正确的做法是在应用层对LIKE查询的通配符进行转义,或者根据业务需求决定是否允许用户使用通配符。如果业务上不需要通配符功能,应该对用户输入中的%和_进行转义处理,让它们失去特殊含义。如果业务需要通配符,应该限制通配符的使用方式,比如不允许通配符出现在字符串开头以避免全表扫描。

// 安全的LIKE查询:转义通配符
String userInput = "%user_input%";
String safeInput = userInput
    .replace("\\", "\\\\")
    .replace("%", "\\%")
    .replace("_", "\\_");
Query query = entityManager.createQuery(
    "SELECT u FROM User u WHERE u.username LIKE :username ESCAPE '\\'"
);
query.setParameter("username", "%" + safeInput + "%");
ORM框架的批量操作和IN子句陷阱

IN子句的动态参数数量变化时,很多ORM框架处理起来比较别扭。开发者可能会选择拼接字符串来构造IN子句,比如将用户输入的ID列表直接拼成'1','2','3'的形式嵌入SQL。这极其危险,因为ID值如果来自用户输入且未经验证,就可能包含恶意SQL片段。

大多数现代ORM已经支持将集合作为参数传递给IN子句,框架会自动展开为参数化查询。例如JPA中可以使用:ids参数绑定一个List集合,MyBatis中可以使用<foreach>标签遍历集合生成参数化占位符。开发者应该优先使用这些内置机制,而不是手动拼接。如果ORM版本较老不支持,也应该自行实现参数化,为集合中的每个元素生成独立的参数占位符。

// 安全:使用集合参数
List ids = Arrays.asList(1L, 2L, 3L);
Query query = entityManager.createQuery(
    "SELECT p FROM Product p WHERE p.id IN :ids"
);
query.setParameter("ids", ids);

// 危险:字符串拼接IN子句
String idList = request.getParameter("ids"); // 假设为"1,2,3"
String sql = "SELECT * FROM products WHERE id IN (" + idList + ")";
二次注入:ORM也防不住的持久化攻击

二次注入是一种更隐蔽的攻击方式。攻击者将恶意SQL片段存储在数据库中,当这个数据被后续的查询读取并用于构造另一个SQL语句时,注入就会发生。ORM的参数化查询可以防止第一次写入时的注入,但无法防止数据被读出后再次被拼接进新的SQL语句。

典型的场景是:用户注册时,用户名设置为admin'--,ORM将其参数化存储到数据库,不会产生注入。但后续某个功能需要根据用户名生成报表,开发者从数据库读出用户名后,直接拼接到新的SQL语句中执行,注入就发生了。防御二次注入的关键在于,永远不要信任来自数据库的数据,任何数据在用于构造SQL之前都必须经过参数化处理。

防御策略和最佳实践总结

理解ORM下SQL注入的根源后,防御策略就非常清晰了。第一,在所有可能的地方使用参数化查询,这是ORM提供的最强安全机制。第二,对于必须拼接的动态结构元素,如排序字段、表名、列名,使用严格的白名单校验,将用户输入映射到预定义的合法值。第三,避免在应用代码中拼接任何SQL字符串,包括原生SQL和动态查询构建器中的字符串拼接。第四,审查数据库层的存储过程和函数,确保它们内部也使用参数化查询。第五,对LIKE查询的通配符进行合理控制,防止信息泄露和性能攻击。第六,使用集合参数处理IN子句,避免手动拼接。第七,警惕二次注入,从数据库读取的数据再次用于SQL构造时,同样需要参数化处理。

ORM框架是强大的工具,但它不是安全银弹。安全责任最终落在开发者肩上。理解SQL注入的本质——将数据误当作代码执行——就能明白,只要存在字符串拼接且拼接内容受用户控制,注入就可能发生,无论是否使用了ORM。代码审查时,关注点不应是“是否使用了ORM”,而应是“是否存在将用户输入拼接到SQL字符串的操作”。这才是发现和修复ORM下SQL注入漏洞的正确思路。