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. 现在我们就安全了吗?

✅ 如果你已经在所有地方都使用了参数化查询和白名单,那确实大大降低了风险。但安全是一个系统工程,仍需注意以下几点:

  1. 存储过程:也可能存在 SQL 注入风险,注意对参数做清洗
  2. 触发器:更隐蔽,有时你甚至不知道它存在
  3. 不安全的直接对象引用(IDOR):即便没有注入,也可能通过 ID 猜测获取未授权数据,OWASP 有相关 防护指南

📌 最好的策略是:永远保持警惕,引入“红队”做渗透测试

4. 损失控制技术(Damage Control)

作为良好的安全实践,我们应该实现 多层防御(Defense in Depth)。即使无法发现所有漏洞,也要尽量降低攻击造成的损失。

常见的措施包括:

  • 最小权限原则:数据库访问账号权限越小越好
  • 数据库层防护:如 H2 的禁止字面量选项
  • 短期凭证:定期更换数据库密码,Spring Cloud Vault 是个不错的选择
  • 日志审计:记录所有数据库操作,便于事后追溯
  • WAF / 入侵检测系统:可以识别已知攻击模式,甚至在 JVM 内部进行检测

5. 总结

本文讲解了 Java 应用中常见的 SQL 注入漏洞,以及如何通过参数化查询、白名单、JPA Criteria API 等手段进行防范。虽然这些技术并不复杂,但却是保护数据安全的关键。

完整代码可参考:GitHub 仓库


原始标题:SQL Injection and How to Prevent It? | Baeldung