[CS300 #251] SQL 인젝션 — 데이터가 코드로 바뀌는 순간
컴퓨터공학 300 주제 시리즈의 251번째 글이다. 전체 지도는 여기.
한 줄 요약
SQL 인젝션은 사용자 입력이 SQL 문장의 문법 일부로 해석되는 취약점이며, 해법은 입력을 “잘 거르는 것” 이 아니라 파라미터화 쿼리로 코드와 데이터를 처음부터 분리하는 것이다.
왜 필요한가
SQL 인젝션은 가장 오래되고 가장 잘 알려진 웹 취약점 중 하나지만 사라지지 않는다. MITRE 의 CWE Top 25 에 해마다 오르고, OWASP Top 10:2025 의 인젝션 범주 설명은 SQL 인젝션 관련 CVE 가 1만 4천 건을 넘는다고 적고 있다. 영향은 데이터 전체 유출, 인증 우회, 데이터 변조·삭제, DB 설정에 따라서는 서버 명령 실행까지 간다.
원리가 단순한데도 계속 나오는 이유는 문자열을 이어 붙여 쿼리를 만드는 코드가 너무 쉽게 쓰이기 때문이다. 급한 관리자 도구, 동적 검색 필터, 정렬 기능, ORM 의 raw 쿼리 탈출구에서 반복된다.
핵심 개념
원리: 문법과 데이터의 경계가 무너진다
코드: "SELECT * FROM users WHERE name = '" + name + "'"
입력: x' OR '1'='1
결과: SELECT * FROM users WHERE name = 'x' OR '1'='1'
└─ 따옴표가 닫히고 조건이 추가됨
DB 입장에서는 개발자가 쓴 부분과 사용자가 쓴 부분을 구별할 방법이 없다. 하나의 문자열이 통째로 파서에 들어가기 때문이다. 이것이 모든 인젝션(SQL, OS 명령, LDAP, 템플릿)의 공통 구조다. 서로 다른 언어의 경계에서 데이터가 코드로 해석된다.
유형
| 유형 | 특징 |
|---|---|
| 인밴드(UNION 기반, 오류 기반) | 결과나 오류 메시지가 응답에 직접 나온다 |
| 블라인드(불리언, 시간 기반) | 응답이 참/거짓에 따라 미묘하게 달라지거나 지연된다. 한 비트씩 추출 |
| 2차(second-order) | 입력 시점에는 안전하게 저장됐다가, 나중에 다른 기능이 그 값을 이어 붙일 때 터진다 |
2차 인젝션은 “DB 에서 꺼낸 값은 믿을 수 있다” 는 착각에서 나온다. 출처가 어디든 SQL 문법에 섞는 순간 위험하다.
대책의 우선순위 (OWASP 치트시트 기준)
- 파라미터화 쿼리(prepared statement): 쿼리 구조를 먼저 보내고 값은 따로 바인딩한다. 값은 어떤 문자가 들어 있어도 문법이 될 수 없다. 1순위 대책이다.
- 안전하게 작성된 저장 프로시저: 프로시저 안에서 다시 동적 SQL 을 이어 붙이면 의미가 없다.
- 허용 목록 입력 검증: 테이블명·컬럼명·정렬 방향처럼 플레이스홀더로 바인딩할 수 없는 식별자에 쓴다.
- 이스케이프: 최후의 수단. DB·문자셋마다 규칙이 달라 실수하기 쉬워 OWASP 도 권장하지 않는다.
심층 방어로 함께 쓰는 것:
- 최소 권한 DB 계정: 애플리케이션 계정에 DROP, 다른 스키마 접근, 파일 읽기 권한을 주지 않는다.
- 오류 메시지 숨기기: DB 오류 원문을 사용자에게 보내지 않는다(오류 기반 추출 차단).
흔한 오해
| 오해 | 실제 |
|---|---|
| “따옴표만 이스케이프하면 된다” | 숫자 컨텍스트(WHERE id = + id)에는 따옴표가 필요 없다. 문자셋 문제로 이스케이프가 우회된 사례도 있다 |
| “ORM 을 쓰니 안전하다” | ORM 의 raw SQL, 문자열 포맷한 where 절, 정렬 파라미터는 똑같이 위험하다 |
| “WAF 가 막아 준다” | 패턴 매칭은 인코딩·주석·대소문자 변형으로 우회된다. 보조 수단일 뿐이다 |
| “입력 길이를 제한하면 된다” | 짧은 페이로드로도 충분하다 |
직접 해 보기
메모리 SQLite 로 취약 코드와 개선 코드를 나란히 돌린다. 내 프로세스 안의 임시 DB 이므로 안전하다.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, role TEXT);
INSERT INTO users(name, role) VALUES ('alice','user'), ('bob','user'), ('root','admin');
""")
# 취약: 문자열 이어 붙이기
def find_vulnerable(name):
sql = f"SELECT id, name, role FROM users WHERE name = '{name}'"
return sql, db.execute(sql).fetchall()
# 개선: 플레이스홀더 — 값은 데이터로만 전달된다
def find_safe(name):
sql = "SELECT id, name, role FROM users WHERE name = ?"
return sql, db.execute(sql, (name,)).fetchall()
evil = "x' OR '1'='1"
for f in (find_vulnerable, find_safe):
sql, rows = f(evil)
print(f"{f.__name__:<16} {sql}\n{'':<16} -> {rows}")
# 식별자(컬럼명)는 플레이스홀더로 못 넣는다 -> 허용 목록
SORTABLE = {"id", "name"}
def list_users(order_by):
if order_by not in SORTABLE:
raise ValueError(f"정렬 불가 컬럼: {order_by!r}")
return db.execute(f"SELECT name FROM users ORDER BY {order_by}").fetchall()
print(list_users("name"))
try:
list_users("(SELECT role FROM users WHERE name='root')")
except ValueError as e:
print("거부:", e)
실행 결과(Python 3.12):
find_vulnerable SELECT id, name, role FROM users WHERE name = 'x' OR '1'='1'
-> [(1, 'alice', 'user'), (2, 'bob', 'user'), (3, 'root', 'admin')]
find_safe SELECT id, name, role FROM users WHERE name = ?
-> []
[('alice',), ('bob',), ('root',)]
거부: 정렬 불가 컬럼: "(SELECT role FROM users WHERE name='root')"
취약 버전은 이름이 x' OR '1'='1 인 사용자를 찾는 대신 전체 테이블을 돌려줬다. 개선 버전은 정확히 그 이름을 가진 사용자를 찾았고, 없으니 빈 결과다. 입력을 검사하거나 바꾸지 않았는데도 안전하다. 경계가 구조적으로 분리됐기 때문이다.
ORDER BY 처럼 식별자가 들어갈 자리는 플레이스홀더를 쓸 수 없다. 이때는 허용 목록이 유일한 정답이다. 사용자 입력을 그대로 넣지 않고, 미리 정한 값 중 하나를 고르게 한다.
현업에서는
- 코드 리뷰 패턴:
execute(f"...{,"..." + var + "...",String.format으로 만든 SQL, MyBatis 의${}(#{}가 아니라)는 즉시 확인 대상이다. 정적 분석 도구 대부분이 이 패턴을 잡지만 동적 쿼리 빌더 안쪽은 놓친다. - 검색 필터: 조건이 선택적으로 붙는 검색 화면은 동적 SQL 이 필요하다. 조건 조각마다 플레이스홀더를 쓰고 값 목록을 함께 쌓는 방식으로 짠다.
LIKE검색은%·_와일드카드를 이스케이프해야 의도치 않은 전체 검색을 막는다(이건 인젝션이 아니라 성능·의미 문제다). - DB 계정 분리: 마이그레이션용 계정(DDL 권한)과 애플리케이션 계정(DML 만)을 나눈다. 쿠버네티스 시크릿도 둘을 따로 두고, 앱 파드에는 앱 계정만 마운트한다.
- 로그에서 징후 찾기: WAF·DB 로그에
UNION SELECT,information_schema,SLEEP(같은 문자열이 반복되면 스캐닝이다. 실제 성공 여부는 DB 쪽 오류율과 응답 크기 이상치로 함께 본다.
확인 문제
- SQL 인젝션의 근본 원인을 한 문장으로 설명하라.
- 파라미터화 쿼리가 이스케이프보다 나은 이유는?
- 정렬 컬럼명을 사용자에게 받아야 한다. 어떻게 안전하게 처리하는가?
- 2차 SQL 인젝션이란 무엇인가?
- 파라미터화 쿼리를 쓰는데도 DB 계정 권한을 최소화하는 이유는?
풀이
- 사용자 입력이 SQL 문장에 문자열로 합쳐져 DB 파서가 데이터를 문법으로 해석하기 때문이다.
- 쿼리 구조와 값이 별도로 전달되어 값이 문법이 될 가능성이 구조적으로 없다. 이스케이프는 DB·문자셋·컨텍스트별 규칙을 정확히 지켜야 해 실수 여지가 크다.
- 허용 목록(미리 정한 컬럼명 집합)과 대조해 일치하는 값만 쓴다. 입력값을 그대로 쿼리에 넣지 않는다.
- 처음 입력 때는 안전하게 저장된 값이 나중에 다른 코드에서 SQL 에 이어 붙여지며 실행되는 인젝션.
- 놓친 다른 인젝션 경로나 다른 취약점이 있을 때 피해 범위를 제한하는 심층 방어다.