很多开发者以为用了ORM框架就万事大吉,SQL注入跟自己没关系了。但事实是,当你在Hibernate、MyBatis、Entity Framework这些ORM工具里使用原生查询(Native Query)、字符串拼接SQL、动态条件拼接时,注入风险一点都没少。我见过太多项目,ORM用得很熟,结果在一个分页查询接口上,因为拼接了用户输入的排序字段,直接被拖库。今天这篇文章,我把主流ORM框架中原生查询的安全隐患全部拆解一遍,告诉你哪些写法是定时炸弹,哪些才是真正安全的姿势。

一、ORM框架为什么还会有SQL注入?核心原因就三个字:绕开了参数化。

ORM框架的核心防护机制是参数化查询(Parameterized Query),也叫预编译语句。框架会把SQL模板和参数分开传给数据库驱动,数据库引擎在编译阶段就把参数当数据处理,不会把它当SQL指令执行。但问题来了——当你觉得框架的查询语法不够灵活,或者想写一段复杂的动态SQL时,你很可能会选择"原生查询"模式。这时候,如果你手动拼接字符串,参数化就被你亲手绕开了。

具体来说,以下几种场景最容易出事:

第一,使用框架提供的createNativeQuery、executeSql等原生执行方法时,直接把变量塞进SQL字符串。第二,用字符串拼接构造WHERE条件、ORDER BY、LIMIT等动态部分。第三,在MyBatis中使用${}而不是#{}。第四,在JPA中使用@Query注解写原生SQL时用了字符串连接。这些都是高频踩坑点。

二、Hibernate/JPA原生查询的安全隐患和排查方法

Hibernate是Java生态里最主流的ORM框架之一。它提供了createNativeQuery方法来执行原生SQL。很多开发者图方便,直接这样写:

String sql = "SELECT * FROM users WHERE username = '" + username + "' AND status = " + status;
Query query = entityManager.createNativeQuery(sql);
List results = query.getResultList();

这段代码就是典型的SQL注入漏洞。username如果传入的是"' OR '1'='1",整个查询逻辑就被改写了。排查的时候,你要在代码库里全局搜索createNativeQuery、createSQLQuery这类方法调用,然后检查传入的SQL字符串是否包含拼接操作。

正确的做法是使用参数绑定:

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

注意,JPA的@Query注解也有同样的问题。如果你在Repository接口里这样写:

@Query(value = "SELECT * FROM users WHERE role = '" + role + "'", nativeQuery = true)
List findByRole(String role);

这在编译期就会报错,因为注解里不能用变量。但有些人会用SpEL表达式或者在Service层拼接好SQL再传进去,这就需要在代码审查时重点关注。建议在项目中建立规范:所有原生SQL必须使用命名参数或位置参数,禁止任何形式的字符串拼接。

三、MyBatis中${}和#{}的致命区别

MyBatis是国内用得最多的持久层框架,它的安全问题集中在${}的误用上。#{}会生成预编译语句,相当于JDBC的PreparedStatement,是安全的。而${}是直接字符串替换,等于把用户输入原样塞进SQL里,跟直接拼接没有本质区别。

危险写法:

<select id="getUsers" resultType="User">
    SELECT * FROM users WHERE username = '${username}'
</select>

安全写法:

<select id="getUsers" resultType="User">
    SELECT * FROM users WHERE username = #{username}
</select>

排查方法:在整个项目的XML Mapper文件和注解SQL中,全局搜索${}符号。每一个出现的地方都要确认:这里用${}是不是因为需要动态表名、动态列名?如果是,那就必须在Java代码层做白名单校验,绝不能直接把前端传来的值塞进去。比如动态排序字段,你应该在Service层维护一个允许排序的字段列表,用户传入的字段先跟白名单比对,匹配上了再拼接到SQL里。

// Service层白名单校验示例
List<String> allowedSortFields = Arrays.asList("id", "username", "created_at");
if (!allowedSortFields.contains(sortField)) {
    throw new IllegalArgumentException("非法排序字段");
}
String sql = "SELECT * FROM users ORDER BY " + sortField;

这种写法虽然用了拼接,但因为有白名单控制,实际上是安全的。关键在于:你不能信任任何来自外部的输入,哪怕是一个"排序字段"这种看起来人畜无害的参数。

四、Entity Framework(.NET)和Django ORM的原生查询风险

.NET的Entity Framework Core提供了FromSqlRaw和ExecuteSqlRaw方法。很多人在需要写复杂查询时会用这些方法,然后犯同样的错误:

var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
var users = context.Users.FromSqlRaw(sql).ToList();

正确做法是使用参数化:

var sql = "SELECT * FROM Users WHERE Email = {0}";
var users = context.Users.FromSqlRaw(sql, email).ToList();

Django ORM相对好一些,因为它的raw()方法强制要求参数以列表或字典形式传入,天然就避免了拼接。但如果你在Django里用了extra()或者自定义SQL片段,还是要小心。排查时重点看raw()调用里的参数传递方式,以及任何用format、f-string构造SQL的地方。

五、动态条件拼接是最大的灰色地带

实际业务中,最常见的不是简单的单参数查询,而是多条件动态拼接。比如一个用户搜索接口,可能有姓名、年龄、城市、状态等多个筛选条件,用户填哪个就查哪个。很多开发者会在代码里用StringBuilder拼接SQL:

StringBuilder sql = new StringBuilder("SELECT * FROM users WHERE 1=1");
if (name != null) {
    sql.append(" AND name = '").append(name).append("'");
}
if (age != null) {
    sql.append(" AND age = ").append(age);
}
// ...更多条件

这种写法在每个条件里都有注入风险。更安全的做法是用ORM框架自带的条件构造器,比如Hibernate的Criteria API、JPA的Specification、MyBatis的SqlBuilder或者动态SQL标签。以MyBatis为例:

<select id="searchUsers" resultType="User">
    SELECT * FROM users
    <where>
        <if test="name != null">
            AND name = #{name}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
    </where>
</select>

这种写法每个参数都走了#{}预编译,天然安全。如果你的项目里还在用StringBuilder手动拼SQL,建议逐步重构,这是技术债,迟早要还。

六、存储过程调用也不能掉以轻心

有些开发者觉得调用存储过程就安全了,因为SQL逻辑在数据库端。但如果你在调用存储过程时,参数是通过字符串拼接传进去的,一样有注入风险。比如在JPA中:

Query query = entityManager.createNativeQuery("CALL search_users('" + keyword + "')");

正确做法依然是参数绑定:

Query query = entityManager.createNativeQuery("CALL search_users(:keyword)");
query.setParameter("keyword", keyword);

排查时要注意,不光是SELECT/INSERT/UPDATE/DELETE语句,任何涉及数据库执行的调用都要检查参数传递方式,包括存储过程、函数调用、甚至是数据库的事件触发器相关代码。

七、系统性排查清单和工具建议

要彻底排查一个项目的ORM原生查询安全隐患,建议按以下步骤操作:

第一步,代码扫描。用静态分析工具(如SonarQube、FindBugs、Checkmarx)扫描项目,重点关注SQL拼接相关的规则。大多数工具都内置了SQL注入检测规则。

第二步,关键词搜索。在代码库中全局搜索以下关键词:createNativeQuery、createSQLQuery、FromSqlRaw、ExecuteSqlRaw、raw(、${}、format(、f"、StringBuilder配合SQL、concat配合SQL。每一个命中都要人工确认是否安全。

第三步,代码审查。重点审查Mapper XML文件、Repository接口、Service层中涉及数据库操作的方法。特别关注动态SQL、条件拼接、排序字段、分页参数这些高频风险点。

第四步,渗透测试。在上线前用SQLMap等工具对接口做自动化注入测试,尤其是那些接收用户输入的查询接口。自动化工具能发现人工审查容易遗漏的边界情况。

第五步,建立规范。在团队中明确规定:所有数据库操作必须使用参数化查询,禁止在任何场景下直接拼接用户输入。把这条写进代码规范文档,并在CI流程中加入静态检查。

八、总结:ORM不是免死金牌,安全意识才是

ORM框架确实大幅降低了SQL注入的概率,但它不是银弹。原生查询、动态拼接、白名单缺失、开发者的侥幸心理,每一个都能把防护击穿。真正的安全不是靠框架保证的,而是靠每一行代码的书写习惯。排查不是一次性的工作,而是要融入日常开发流程——代码审查、静态扫描、渗透测试,三道防线缺一不可。把今天讲的这些点落实到你的项目里,你的ORM使用才算真正安全。