FlawOpen

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]

框架映射

攻击执行流程分解

步骤 1

请求中的值进入仓储层

Spring 的 @GetMapping 处理方法接收 @RequestParam String id 并传给 findEmail()。由于声明类型是 String,Spring 不会将该值转换为其他类型;在依赖其格式前,应显式校验。[9]

步骤 2

字符串拼接构造 SQL

"... WHERE id = " + userId 生成一个字符串。使用无害的测试值 0 OR 1=1 时,它变成 WHERE id = 0 OR 1=1。[1], [2], [6]

步骤 3

驱动编译了攻击者的文本

createStatement().executeQuery(sql) 把整个字符串交给数据库解析。没有任何参数,所以数据库把 OR 1=1 当作 WHERE 子句的一部分。[2], [1]

步骤 4

所有行都被返回

条件对每一行都成立,于是查询返回了整张表。通过 JPQL 也是一样:Hibernate 会把被篡改的查询忠实地翻译成 SQL。[7], [1]

源代码对比:漏洞与安全实现

存在漏洞的实现
// 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();
    }
}

工程与系统安全加固清单

参考来源