1. 简介
尽管 SQL 注入是最广为人知的安全漏洞之一,但它依旧稳居臭名昭著的 OWASP Top 10 榜首 —— 现在它被归入更广泛的“注入类”攻击中。
在本文中,我们将探讨在 Java 中导致 SQL 注入的常见编码错误,并介绍如何利用 JVM 标准库中的 API 来避免这些问题。我们还会分析使用 JPA、Hibernate 等 ORM 框架能提供哪些保护,以及它们仍存在的盲区。
2. 应用为何容易受到 SQL 注入攻击?
注入攻击之所以成功,是因为对于很多应用而言,执行某些逻辑的唯一方式就是动态生成代码,并由其他系统或组件执行。如果在生成代码的过程中使用了未经验证的用户输入,就会给攻击者留下可乘之机。
这句话听起来可能有点抽象,所以我们来看一个典型的例子:
public List<AccountDTO>
unsafeFindAccountsByCustomerId(String customerId)
throws SQLException {
// ❌ 危险代码,切勿使用
String sql = "select "
+ "customer_id,acc_number,branch_id,balance "
+ "from Accounts where customer_id = '"
+ customerId
+ "'";
Connection c = dataSource.getConnection();
ResultSet rs = c.createStatement().executeQuery(sql);
// ...
}
这段代码的问题很明显:我们直接将 customerId
的值拼接到 SQL 查询中,完全没有进行任何校验。如果这个值确实来自可信源,那当然没问题,但你能确保吗?
假设这个方法被用于一个 REST 接口的实现,那么攻击就变得非常简单:只需要发送一个精心构造的参数值,使其拼接后改变 SQL 的语义:
curl -X GET \
'http://localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \
如果这个参数没有被校验,最终传入方法的将是:
abc' or '1' = '1
拼接后的 SQL 语句会变成:
select customer_id, acc_number,branch_id, balance
from Accounts where customerId = 'abc' or '1' = '1'
结果就是返回所有账户数据,显然这不是我们想要的。
你可能会想:“我可不会这么蠢,用字符串拼接来构造 SQL”。但别急,这个例子虽然简单,但在以下场景中我们可能真的不得不这么做:
- 动态搜索条件的复杂查询(如根据用户输入动态添加
UNION
) - 动态排序或分组(如前端表格组件调用的 REST 接口)
2.1. 我用的是 JPA,应该很安全吧?
❌ 这是一个常见误区。JPA 和其他 ORM 框架虽然减少了手写 SQL 的需求,但并不能阻止我们写出存在 SQL 注入风险的代码。
来看 JPA 版本的示例:
public List<AccountDTO> unsafeJpaFindAccountsByCustomerId(String customerId) {
String jql = "from Account where customerId = '" + customerId + "'";
TypedQuery<Account> q = em.createQuery(jql, Account.class);
return q.getResultList()
.stream()
.map(this::toAccountDTO)
.collect(Collectors.toList());
}
问题依然存在:我们使用了未经验证的用户输入来构造 JPA 查询,因此同样存在 SQL 注入风险。
3. 防范手段
既然知道了 SQL 注入是什么,下面我们来看看如何保护代码。
3.1. 参数化查询(Prepared Statements)
这是最有效的防御手段之一:使用带有占位符(?
)的预编译语句来插入用户输入。除非 JDBC 驱动有 bug,否则这种方式是免疫 SQL 注入的。
重构示例代码如下:
public List<AccountDTO> safeFindAccountsByCustomerId(String customerId)
throws Exception {
String sql = "select "
+ "customer_id, acc_number, branch_id, balance from Accounts"
+ "where customer_id = ?";
Connection c = dataSource.getConnection();
PreparedStatement p = c.prepareStatement(sql);
p.setString(1, customerId);
ResultSet rs = p.executeQuery();
// 省略处理逻辑
}
在 JPA 中,也可以使用命名参数:
String jql = "from Account where customerId = :customerId";
TypedQuery<Account> q = em.createQuery(jql, Account.class)
.setParameter("customerId", customerId);
// 执行查询并返回结果(省略)
如果你在 Spring Boot 中运行这段代码,可以设置 logging.level.sql=DEBUG
,查看实际生成的 SQL:
[DEBUG][SQL] select
account0_.id as id1_0_,
account0_.acc_number as acc_numb2_0_,
account0_.balance as balance3_0_,
account0_.branch_id as branch_i4_0_,
account0_.customer_id as customer5_0_
from accounts account0_
where account0_.customer_id=?
可以看到,ORM 层也使用了预编译语句。
✅ 优势:
- 更安全
- 性能更好(数据库可缓存查询计划)
⚠️ 注意:占位符只能用于值,不能用于表名、列名等结构。如下代码是 ❌ 错误的:
// ❌ 错误示例
PreparedStatement p = c.prepareStatement("select count(*) from ?");
p.setString(1, tableName);
JPA 同样不支持:
// ❌ 错误示例
String jql = "select count(*) from :tableName";
TypedQuery q = em.createQuery(jql,Long.class)
.setParameter("tableName", tableName);
3.2. JPA Criteria API
既然手动拼接 JQL 容易出错,我们可以使用 JPA 提供的 Criteria API 来避免这个问题。
示例代码:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId));
TypedQuery<Account> q = em.createQuery(cq);
// 执行查询并返回结果(省略)
虽然代码量变多了,但好处是:
✅ 完全避免了 SQL/JQL 字符串拼接
✅ 更适合构建复杂查询
✅ 更安全
3.3. 用户输入清洗(Sanitization)
这是一种对用户输入进行过滤的技术,确保其在后续处理中是安全的。常见的方法有:
- 白名单(推荐):定义合法值范围,只接受这些值
- 黑名单:尝试识别非法模式(对防御 SQL 注入效果有限)
我们来增强之前的示例,支持按列排序:
private static final Set<String> VALID_COLUMNS_FOR_ORDER_BY
= Collections.unmodifiableSet(Stream
.of("acc_number","branch_id","balance")
.collect(Collectors.toCollection(HashSet::new)));
public List<AccountDTO> safeFindAccountsByCustomerId(
String customerId,
String orderBy) throws Exception {
String sql = "select "
+ "customer_id,acc_number,branch_id,balance from Accounts"
+ "where customer_id = ? ";
if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) {
sql = sql + " order by " + orderBy;
} else {
throw new IllegalArgumentException("Nice try!");
}
Connection c = dataSource.getConnection();
PreparedStatement p = c.prepareStatement(sql);
p.setString(1,customerId);
// 处理结果集(省略)
}
同样的方法也可以用在 JPA 中:
final Map<String,SingularAttribute<Account,?>> VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of(
new AbstractMap.SimpleEntry<>(Account_.ACC_NUMBER, Account_.accNumber),
new AbstractMap.SimpleEntry<>(Account_.BRANCH_ID, Account_.branchId),
new AbstractMap.SimpleEntry<>(Account_.BALANCE, Account_.balance))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
SingularAttribute<Account,?> orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy);
if (orderByAttribute == null) {
throw new IllegalArgumentException("Nice try!");
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root)
.where(cb.equal(root.get(Account_.customerId), customerId))
.orderBy(cb.asc(root.get(orderByAttribute)));
TypedQuery<Account> q = em.createQuery(cq);
// 执行查询(省略)
3.4. 现在我们就安全了吗?
✅ 如果你已经在所有地方都使用了参数化查询和白名单,那确实大大降低了风险。但安全是一个系统工程,仍需注意以下几点:
- 存储过程:也可能存在 SQL 注入风险,注意对参数做清洗
- 触发器:更隐蔽,有时你甚至不知道它存在
- 不安全的直接对象引用(IDOR):即便没有注入,也可能通过 ID 猜测获取未授权数据,OWASP 有相关 防护指南
📌 最好的策略是:永远保持警惕,引入“红队”做渗透测试。
4. 损失控制技术(Damage Control)
作为良好的安全实践,我们应该实现 多层防御(Defense in Depth)。即使无法发现所有漏洞,也要尽量降低攻击造成的损失。
常见的措施包括:
- ✅ 最小权限原则:数据库访问账号权限越小越好
- ✅ 数据库层防护:如 H2 的禁止字面量选项
- ✅ 短期凭证:定期更换数据库密码,Spring Cloud Vault 是个不错的选择
- ✅ 日志审计:记录所有数据库操作,便于事后追溯
- ✅ WAF / 入侵检测系统:可以识别已知攻击模式,甚至在 JVM 内部进行检测
5. 总结
本文讲解了 Java 应用中常见的 SQL 注入漏洞,以及如何通过参数化查询、白名单、JPA Criteria API 等手段进行防范。虽然这些技术并不复杂,但却是保护数据安全的关键。
完整代码可参考:GitHub 仓库