flawopen.com/sql-injection/Java
●
CWE-89 · 严重
安全研究 · FlawOpen
Java SQL 注入:JDBC、JPA、MyBatis
Java SQL 注入源于把用户输入拼进查询文本。用绑定参数传值,并为 ORDER BY 等标识符使用白名单,防止输入改写 SQL 结构。[1], [2], [3], [7], [4], [6]
💡 通俗易懂的原理解析 (ELI5)
想象一家工厂先锁定金属模板,再接收客户标签。JDBC PreparedStatement 先用 ? 插槽构建模板,随后 setLong(1, userId) 把值放入插槽而不改动指令。若在准备前拼接文本,文字就被刻进模板。JdbcTemplate 和 JPQL setParameter 也会把值分开。MyBatis 的写法直接体现差异:#{status} 是绑定值,${status} 则是插入的 SQL 文本。ORDER BY 字段是标识符,不是值;应映射到允许列表,或批准过的 Sort.by 属性。[2], [3], [7], [4], [8]
核心概念与专有名词
PreparedStatement- 在提供任何值之前,先由带
?标记的 SQL 编译而成的 JDBC 语句。值通过setLong(1, id)等类型化 setter 传入,永远无法改变查询。写成prepareStatement("..." + id)只会把攻击者的文本编译进去,起不到任何保护作用。[2], [1] JdbcTemplate query(sql, args)- Spring 的
queryForList(sql, Long.class, status)和update(sql, args...)会通过 PreparedStatement 把末尾的参数绑定到?标记上。NamedParameterJdbcTemplate用:name标记做同样的事。往sql参数里拼接字符串会绕过这一切。[3], [2] JPQL / HQL injection- JPA 和 Hibernate 的查询同样是从字符串解析的。
createQuery("... = '" + email + "'")让输入改变 JPQL,Hibernate 再把它翻译成 SQL。请用:email配合setParameter,或使用 Criteria API。[7], [1] MyBatis #{} vs ${}- 在 MyBatis 映射文件中,
#{status}会变成绑定的?参数,而${status}会作为文本直接贴进 SQL。${}只适用于你自己选定的值,例如来自白名单的列名。[4] Spring Data Sort- Spring Data 会把
Sort.by("email")转换成针对映射属性的 ORDER BY,并拒绝未知的属性名,因此应优先使用它,而不是手写 ORDER BY。但仍要用白名单映射请求中的键,防止用户按你不想公开的字段排序。[8]
框架映射
- 本指南所述弱点的 CWE 参考: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
攻击执行流程分解
步骤 1
请求中的值进入仓储层
Spring 的 @GetMapping 处理方法接收 @RequestParam String id 并传给 findEmail()。由于声明类型是 String,Spring 不会将该值转换为其他类型;在依赖其格式前,应显式校验。[9]
步骤 2
步骤 3
源代码对比:漏洞与安全实现
存在漏洞的实现
// UserRepository.java:下面每个查询都把请求数据拼进查询文本
public class UserRepository {
private final DataSource dataSource;
private final JdbcTemplate jdbc;
private final EntityManager em;
public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
this.dataSource = dataSource;
this.jdbc = jdbc;
this.em = em;
}
// JDBC:Statement 会连同值一起解析拼接后的字符串
public String findEmail(String userId) throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT email FROM users WHERE id = " + userId)) {
return rs.next() ? rs.getString(1) : null;
}
}
// JdbcTemplate:Spring 看到 SQL 之前拼接就已完成
public List<Long> findOrderIds(String status) {
return jdbc.queryForList("SELECT id FROM orders WHERE status = '" + status + "'", Long.class);
}
// JPA:JPQL 本身由输入构造,再被翻译成 SQL
public List<User> findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'", User.class)
.getResultList();
}
// ORDER BY 字段直接取自请求
public List<User> listUsers(String sortField) {
return em.createQuery("SELECT u FROM User u ORDER BY u." + sortField, User.class)
.getResultList();
}
}
加固后的安全修复
// UserRepository.java:值为绑定参数,排序字段取自白名单
public class UserRepository {
private static final Map<String, String> SORTED_QUERIES = Map.of(
"id", "SELECT u FROM User u ORDER BY u.id",
"email", "SELECT u FROM User u ORDER BY u.email",
"created", "SELECT u FROM User u ORDER BY u.createdAt");
private final DataSource dataSource;
private final JdbcTemplate jdbc;
private final EntityManager em;
public UserRepository(DataSource dataSource, JdbcTemplate jdbc, EntityManager em) {
this.dataSource = dataSource;
this.jdbc = jdbc;
this.em = em;
}
// JDBC:先编译 ? 标记,再通过类型化 setter 设置值
public String findEmail(long userId) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT email FROM users WHERE id = ?")) {
ps.setLong(1, userId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
// JdbcTemplate:末尾参数被绑定到 ? 标记
public List<Long> findOrderIds(String status) {
return jdbc.queryForList("SELECT id FROM orders WHERE status = ?", Long.class, status);
}
// JPA:命名参数,由 setParameter 绑定值
public List<User> findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email)
.getResultList();
}
// 查白名单;只有常量 JPQL 片段会进入查询
public List<User> listUsers(String sortKey) {
String jpql = SORTED_QUERIES.getOrDefault(sortKey, SORTED_QUERIES.get("id"));
return em.createQuery(jpql, User.class)
.getResultList();
}
}
工程与系统安全加固清单
- ✓遇到 SpotBugs 的
SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE以及 Find Security Bugs 的SQL_INJECTION_JDBC、SQL_INJECTION_SPRING_JDBC、SQL_INJECTION_JPA时让构建失败。[10], [5] - ✓拒绝任何 SQL 参数由
+、String.format或StringBuilder构造的Statement.execute*、createQuery、createNativeQuery或 JdbcTemplate 调用。[2], [3], [7] - ✓在 CI 中搜索 MyBatis 映射文件里的
${;每一处都必须是代码中写死、并在映射文件中注明的白名单值。[4] - ✓用
Sort.by()或属性名的Map白名单构造 ORDER BY,绝不直接使用@RequestParam。[8], [7] - ✓请求参数应声明为实际的领域类型(数值 ID 用
long,UUID ID 用UUID),这样 Spring 可在进入仓库逻辑前拒绝转换失败的值。防止 SQL 注入仍应依靠 SQL 参数绑定。[9], [2]
参考来源
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- Oracle — Using Prepared Statements (JDBC tutorial)
- Spring Framework — JdbcTemplate
- MyBatis — Mapper XML: String Substitution
- Find Security Bugs — SQL_INJECTION rules
- OWASP SQL Injection Prevention Cheat Sheet
- Jakarta EE Tutorial — The Jakarta Persistence Query Language
- Spring Data JPA — Query Methods and Sorting
- Spring Framework — @RequestParam
- SpotBugs — SQL Injection Detectors