SQL 注入与 Java 中的 Prepared Statement

1. 总览

SQL 注入是 OWASP 列表中的顶级安全漏洞,源于手动拼接 SQL 字符串与用户输入。本文解释攻击机制,说明为何 PreparedStatement 能阻止它,以及如何在 JDBC、JPA/Hibernate 和 MyBatis 中应用。读完本文,你将能编写安全的查询代码,并学会编写测试来及早发现漏洞。

2. SQL 注入如何工作

当你直接将输入值拼接到 SQL 字符串中,而不是参数化时,就会发生这个错误。

// 不应该这样做
public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
    try (Statement stmt = connection.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next() ? mapRow(rs) : null;
    }
}

如果 username 是像 "minh" 这样的有效值,生成的 SQL 是:

SELECT * FROM customers WHERE username = 'minh'

但如果攻击者传入 username = "' OR '1'='1",SQL 语句就变成:

SELECT * FROM customers WHERE username = '' OR '1'='1'

条件 '1'='1' 总是成立,所以查询返回整个 customers 表 — 完全绕过了用户名验证逻辑。更糟的是,攻击者可以追加 ; DROP TABLE customers; -- 来删除数据,或使用 UNION SELECT 读取其他表(例如存储密码哈希的 users 表)。

注意: 输入不仅来自 HTML 表单。HTTP 头、Cookie、查询参数或文件上传数据都是潜在的攻击源。

3. Prepared Statement 如何解决这个问题

3.1. 参数化机制

PreparedStatement 将 SQL 结构(提前发送,由数据库解析并编译为执行计划)与 参数值(稍后发送,仅被视为纯数据)分开。因此,攻击者的输入永远不会被数据库解释为 SQL 语法。

public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, username);
        try (ResultSet rs = stmt.executeQuery()) {
            return rs.next() ? mapRow(rs) : null;
        }
    }
}
  • ? 是占位符,数据库预编译带有此占位符的语句。
  • stmt.setString(1, username) 为第 1 个参数位置(从 1 开始编号,不是 0)赋值。
  • 即使 username 包含 ' OR '1'='1,数据库仍然认为这是 一个单一的字符串 需要匹配,而不是 SQL 语法。查询返回空结果,因为没有用户名为这个奇怪字符串的用户。

3.2. 使用 Spring JdbcTemplate 的命名参数

如果你使用 Spring,NamedParameterJdbcTemplate 比用 ? 编号参数更易阅读。

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>
@Repository
public class CustomerRepository {

    private final NamedParameterJdbcTemplate jdbc;

    public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public Optional<Customer> findByUsername(String username) {
        String sql = "SELECT id, username, email FROM customers WHERE username = :username";
        Map<String, Object> params = Map.of("username", username);
        List<Customer> result = jdbc.query(sql, params, this::mapRow);
        return result.stream().findFirst();
    }

    private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
    }
}

:username 是命名占位符,Spring 内部将其转换为 ? 并安全地绑定值。这种方法避免了 SQL 有多个条件时参数顺序的混淆。

4. 在 JPA/Hibernate 中应用

JPA 默认生成使用 prepared statement 的 SQL,但如果使用不当的 API,你仍然可以自伤。

// 安全 — 使用命名参数的 JPQL
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// 危险 — 字符串拼接到 JPQL
public List<Order> findOrdersByStatusUnsafe(String status) {
    String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
    return entityManager.createQuery(jpql, Order.class).getResultList();
}
  • 安全示例中的 setParameter("status", status) 使 Hibernate 将 JPQL 转换为带 ? 的 SQL,然后通过 PreparedStatement 绑定值 — 攻击者无法干扰语句结构。
  • 在危险示例中,Hibernate 仍然解析 JPQL,但 JPQL 字符串在解析前已被“污染”,所以攻击者可以注入 JPQL injection(类似 SQL 注入,只是语法层不同)。

对于 Spring Data JPA,@Query 也需要使用参数而不是字符串拼接:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
    List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}

5. 无法参数化的情况: 表名、列名

PreparedStatement 只能参数化 值,不能参数化 标识符(表名、列名、ORDER BY 子句)。这是很多人忽视的情况。

// 根据用户选择的列动态排序
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("列排序无效: " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // 安全,因为已经白名单验证
    // ... 如常使用 PreparedStatement 执行
    return jdbc.query(sql, this::mapRow);
}
  • 由于不能写 ORDER BY ?,你必须在拼接字符串前 白名单验证 合法的值。
  • 永远不要使用黑名单(阻止 DROP、; 等关键字),因为攻击者总能通过编码或语法变体绕过。

6. 编写测试来及早发现漏洞

一个简单的测试帮你确认端点正确地防御了注入攻击。

@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
        String payload = "' OR '1'='1";

        mockMvc.perform(get("/api/customers").param("username", payload))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
        mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
               .andExpect(status().isBadRequest());
    }
}
class CustomerRepositoryTest {

    @Test
    void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
        CustomerRepository repo = new CustomerRepository(jdbcTemplate);

        Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");

        assertThat(result).isEmpty();
    }
}
  • 第一个测试发送经典的注入 payload,预期结果是 空的(因为没有用户名匹配那个字面字符串)而不是所有数据。
  • 第二个测试检查端点是否拒绝不在排序列白名单中的输入 — 返回 400 Bad Request 而不是执行危险的 SQL。
  • assertThat(result).isEmpty() 使用 AssertJ 确认 Optional 为空,证明查询没有被绕过。

7. 常见错误

  • 为动态输入使用 Statement.executeQuery(String): 在写演示时很方便,但在生产环境中是真正的后门。
  • 使用 String.format 来“简化” SQL: 仍然是字符串拼接,和 + 没区别。
  • 100% 信任 ORM: 如果你在调用 createQuery 前自己拼接字符串,JPQL/HQL/Criteria 仍然会被注入。
  • 手动转义而不是参数化: 自己写函数转义单引号(replace("'", "\\'"))容易遗漏情况(例如 Unicode 编码、多字节)且不如驱动程序的原生机制可靠。
  • 动态 IN 子句用错方式: 当构建 WHERE id IN (?, ?, ?) 且参数数量变化时,必须生成与列表大小相应数量的 ?,不要直接拼接值字符串。

注意: Prepared statement 能防止 SQL 注入,但无法防止其他层的注入,如命令注入、LDAP 注入或 NoSQL 注入。每个数据层都需要各自的参数化机制。

8. 何时不需要 PreparedStatement

如果 SQL 完全是静态的,不包含任何来自用户输入的动态值(例如 SELECT COUNT(*) FROM customers),使用普通 Statement 仍然是安全的,有时因为跳过了解析/绑定步骤会轻微地更快一点。但实际上,大多数查询都有动态条件,所以默认应该使用 PreparedStatement 或等效方案(JPQL 的 setParameter、MyBatis 的 #{} 而不是 ${})作为习惯,避免必须记住“哪些情况是安全的”。

9. 结论

SQL 注入源于在 SQL 字符串中混合代码和数据。PreparedStatement 从根本上通过分离两者来解决这个问题:命令结构提前编译,参数值稍后绑定时仅作为纯数据。应该在 JDBC、JPQL、MyBatis 中一致地应用这种做法,对列名/表名等动态标识符始终白名单验证,并用经典的注入 payload 编写测试,确保你的端点永远不会执行攻击者控制的 SQL。

web-securitysql-injectionjdbcspring-bootjava