FlawOpen

flawopen.com/sql-injection/Java

● CWE-89 · 심각
보안 연구 · FlawOpen

Java SQL 인젝션: JDBC, JPA, MyBatis

Java SQL 인젝션은 입력값을 쿼리 문자열에 붙여 생깁니다. 값은 바인딩하고 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
값이 주어지기 전에 ? 마커가 있는 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]

소스 코드 비교: 취약한 구현 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: ? 마커를 먼저 컴파일하고 타입 있는 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();
    }
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처