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。