ORM框架设计的初衷是让开发者远离手写SQL,但它绝不是安全问题的免死金牌。很多团队以为用了Hibernate、MyBatis或Entity Framework就自动免疫SQL注入,这种认知本身就是最大的安全隐患。当你为了性能或复杂查询不得不编写原生SQL时,注入漏洞往往就藏在你最熟悉的代码模式里。
拼接字符串是万恶之源
最经典的陷阱就藏在最普通的业务代码中。假设你需要实现一个动态排序功能,前端传一个排序字段名,后端用JPA的原生查询接收:
String username = request.getParameter("username");
Query query = entityManager.createNativeQuery(
"SELECT * FROM users WHERE username = '" + username + "'"
);这段代码直接把用户输入拼进SQL,攻击者输入 ' OR '1'='1' -- 就能绕过认证。你可能会说现在谁还这么写,但现实中为了快速上线、临时修复或者接手遗留系统,这类代码比比皆是。更隐蔽的情况是拼接表名、列名、ORDER BY子句、GROUP BY子句,这些位置参数化查询无法覆盖,开发者被迫手动拼接,一旦忘记白名单校验,漏洞就敞开了。
参数化查询的边界盲区
很多开发者知道要用参数化查询,但不清楚它的能力边界。JDBC的PreparedStatement、MyBatis的#{}语法、JPA的setParameter都只能参数化值,不能参数化SQL结构部分。看这个MyBatis的例子:
// 错误的做法
@Select("SELECT * FROM users ORDER BY ${sortField} ${sortOrder}")
ListgetUsersByOrder(@Param("sortField") String sortField,
@Param("sortOrder") String sortOrder);这里用了${}而不是#{},MyBatis会直接拼接字符串,完全绕过参数化保护。正确的做法是必须在应用层做白名单校验:
private static final SetALLOWED_COLUMNS = Set.of("id", "username", "email", "create_time");
private static final SetALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public ListgetUsersByOrder(String sortField, String sortOrder) {
if (sortField == null || !ALLOWED_COLUMNS.contains(sortField)) {
throw new IllegalArgumentException("Invalid column: " + sortField);
}
if (sortOrder == null || !ALLOWED_DIRECTIONS.contains(sortOrder.toUpperCase())) {
throw new IllegalArgumentException("Invalid direction: " + sortOrder);
}
return mapper.getUsersByOrder(sortField, sortOrder.toUpperCase());
}这个白名单必须严格限定在数据库实际列名集合内,绝不能图省事用黑名单过滤几个关键字就完事。攻击者绕WAF的手段日新月异,黑名单永远跟不上。
ORM自动生成SQL的隐蔽风险
即使你完全不写原生SQL,ORM自动生成的查询也可能存在注入风险。Hibernate的HQL和JPQL在参数化方面做得不错,但一旦涉及动态查询构建,问题就来了。使用JPA Criteria API时,如果参数来自不可信来源且未经验证直接传入,同样危险:
// 危险:动态拼接JPQL String jpql = "SELECT u FROM User u WHERE u.role = '" + role + "'"; TypedQueryquery = entityManager.createQuery(jpql, User.class);
有些ORM提供了命名参数或位置参数,但开发者偷懒直接拼字符串,等于亲手把ORM的安全防护拆掉。更棘手的是某些ORM框架的"高级特性",比如Hibernate的createSQLQuery配合addEntity,如果SQL片段来自外部输入,防护形同虚设。
存储过程和动态SQL的连环陷阱
调用存储过程并不意味着安全。如果存储过程内部使用了动态SQL拼接,而传入的参数最终被拼进EXEC语句,注入风险依然存在。在应用层调用存储过程时,即便使用了CallableStatement和参数化,也无法防御存储过程内部的拼接漏洞:
// 应用层看似安全
CallableStatement stmt = conn.prepareCall("{call sp_search(?)}");
stmt.setString(1, keyword);
// 但存储过程内部可能这样写:
// SET @sql = CONCAT('SELECT * FROM products WHERE name LIKE \'%', keyword, '%\'');
// PREPARE stmt FROM @sql;
// EXECUTE stmt;这种跨层信任是安全审计的盲区,应用开发团队认为数据库层面处理好了,DBA认为应用层已经过滤干净,结果两头落空。
LIKE查询和通配符的忽略点
LIKE模糊查询是SQL注入的高发区。参数化查询能防止注入,但开发者往往忘记转义LIKE子句中的特殊字符。用户输入百分号%和下划线_如果不处理,轻则返回全表数据造成性能灾难,重则被利用进行盲注攻击:
// 即使用了参数化,也要转义LIKE通配符
String keyword = request.getParameter("keyword");
// 必须转义用户输入中的特殊字符
keyword = keyword.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
Query query = entityManager.createNativeQuery(
"SELECT * FROM articles WHERE title LIKE :keyword ESCAPE '\\'"
);
query.setParameter("keyword", "%" + keyword + "%");很多ORM教程根本不提这个细节,导致大量线上系统在LIKE查询处留下了信息泄露的隐患。
IN子句的参数化困境
IN查询是另一个重灾区。标准JDBC的PreparedStatement不支持直接参数化IN列表,很多开发者图方便就拼字符串:
// 危险的拼接
String ids = request.getParameter("ids"); // "1,2,3"
String sql = "SELECT * FROM orders WHERE id IN (" + ids + ")";正确的做法是根据列表长度动态生成占位符,或者使用ORM框架提供的支持。MyBatis的foreach标签、JPA的Criteria API都能安全处理,但前提是你要用对:
// MyBatis安全写法
@Select("" +
"#{id}" _ue_custom_node_="true">")
ListfindByIds(@Param("ids") Listids);如果ORM框架不支持动态IN,就老老实实循环构建占位符,多写几行代码比留下漏洞强得多。
二次注入和持久化污染
SQL注入不只在查询时发生,数据写入时埋下的雷同样致命。攻击者将恶意SQL片段存入数据库,当这段数据被后续的原生查询拼接使用时,触发二次注入。ORM的实体保存操作本身是安全的,但如果你在触发器、视图或者定时任务中使用了拼接SQL读取这些数据,污染就会爆发:
// 用户注册时,用户名被存入数据库 // 用户名: admin'; DROP TABLE users; -- // ORM的save方法是安全的,数据原样入库 // 但后续某个原生查询如果拼接了这个用户名: String sql = "SELECT * FROM logs WHERE operator = '" + username + "'"; // 二次注入触发
防御二次注入的核心原则是:永远不要信任数据库中的数据。即使数据是你自己写进去的,只要它曾经来自外部输入,在拼入SQL之前就必须做参数化或转义处理。
防御体系的正确构建方式
单一防御手段永远不够,必须建立纵深防御体系。第一层是输入验证,对所有用户输入做类型、长度、格式的白名单校验,拒绝一切不符合预期的数据。第二层是参数化查询,能参数化的地方绝不拼接。第三层是ORM安全配置,关闭不必要的数据库权限,使用只读账号执行查询操作。第四层是运行时防护,部署WAF监控异常SQL模式,但绝不能依赖WAF作为主要防线。第五层是代码审查和自动化扫描,把SQL注入检测集成到CI/CD流水线中,每次提交都自动检查是否存在拼接SQL的风险模式。
ORM降低了SQL注入的门槛,但没有消除它。真正的安全来自开发者对每一行代码的敬畏,尤其是那些看起来"没问题"的原生查询。下次当你准备写一个原生SQL的时候,先停下来问自己三个问题:这个查询真的不能用ORM的标准API实现吗?如果必须原生,所有参数都用参数化了吗?SQL结构部分有没有做白名单校验?三个问题都过关,再动手写代码。
