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]
프레임워크 매핑
- 이 가이드에서 설명하는 약점의 CWE 참조: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
단계별 공격 실행 흐름
단계 1
요청 값이 리포지토리에 도달
Spring @GetMapping 핸들러가 @RequestParam String id를 받아 findEmail()에 넘깁니다. 선언된 타입이 String이므로 Spring은 값을 다른 타입으로 변환하지 않습니다. 형식을 신뢰하기 전에 명시적으로 검증하세요.[9]
단계 2
단계 3
소스 코드 비교: 취약한 구현 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();
}
}
엔지니어링 및 시스템 보안 강화 체크리스트
- ✓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] - ✓ORDER BY는
Sort.by()나 속성 이름의Map허용 목록으로 만들며,@RequestParam을 직접 쓰지 않습니다.[8], [7] - ✓요청 파라미터를 실제 도메인 타입(숫자 ID는
long, UUID ID는UUID)으로 선언해 Spring이 저장소 로직 전에 변환 실패를 거부하도록 하세요. SQL Injection 방어는 계속 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