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/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
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)
...
$ grep --similar
Similar writeups
- [web][free]rainbet— umdctf
- [web][free]SecretPickle— gpnctf
- [web][free]Secure Secretpickle— gpnctf
- [misc][free]ShinyHunter— hackthebox
- [crypto][free]Easy DSA— gpnctf