FlawOpen

flawopen.com/sql-injection/Java

● CWE-89 · Критический
Исследования · FlawOpen

SQL-инъекция в Java: JDBC, JPA, MyBatis

SQL-инъекция в Java возникает при конкатенации ввода в запрос. Используйте параметры для значений и белый список для полей ORDER BY.[1], [2], [3], [7], [4], [6]

💡 Простыми словами (ELI5)

Представьте фабрику, которая фиксирует металлический трафарет до получения клиентских бирок. JDBC PreparedStatement сначала создаёт трафарет с гнездом ?, а setLong(1, userId) помещает значение в гнездо, не меняя инструкции. Конкатенация до подготовки запроса гравирует текст прямо в трафарете. JdbcTemplate и JPQL setParameter также отделяют значения. MyBatis наглядно показывает разницу: #{status} — связанное значение, а ${status} — вставленный SQL-текст. Поля ORDER BY — идентификаторы, а не значения: сопоставляйте их с allowlist или разрешённым свойством Sort.by.[2], [3], [7], [4], [8]

Ключевые понятия и термины

PreparedStatement
Оператор JDBC, скомпилированный из SQL с маркерами ? до того, как переданы какие-либо значения. Значения поступают через типизированные сеттеры вроде setLong(1, id) и не могут изменить запрос. prepareStatement("..." + id) компилирует текст атакующего и ничего не защищает.[2], [1]
JdbcTemplate query(sql, args)
queryForList(sql, Long.class, status) и update(sql, args...) в Spring привязывают последние аргументы к маркерам ? через 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]

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
// 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: сначала компилируется маркер ?, значение задаётся типизированным сеттером
    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();
    }
}

Чек-лист по защите системы для инженеров

Источники