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

$ 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 = 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)

...

$ grep --similar

Similar writeups