webfreeeasy

SecretPickle

gpnctf

Task: FastAPI app deserializes a 'secretpickle' blob (base64 + XOR with a key hardcoded in source) via pickle.loads, giving unauthenticated RCE. Solution: forge arbitrary pickle opcodes since the XOR key is known, install a request hook to capture decrypted payloads, trigger the adminbot whose pyodide client logs in as admin sending the FLAG (its password) in plaintext, then read it back.

$ ls tags/ techniques/
forged_pickle_opcodesxor_known_keyserver_rcerequest_hook_captureplaintext_credential_capture

$ cat /etc/rate-limit

Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.

SecretPickle — GPNCTF (kitctf / GPN24)

Description

The only serialization method that I found in the restaurant were Pickles. So I made an encrypted version of it that nobody can crack!

We are given secretpickle-easy.tar.gz with app/{adminbot.py, client.py, secretpickle.py, server.py, index.html, deps/...}. The goal is to read the flag, which lives at /flag.txt inside the adminbot container.

Architecture

  • Frontend runs entirely in the browser via Pyodide (client.py + secretpickle.py). It parses URL query params with yaml.safe_load and renders server results via DOMPurify.sanitize + document.write.

  • Backend is FastAPI (server.py). One key endpoint:

    @app.post("/{b64:path}") async def secretpickle_handle(request: Request, b64: str): pl = secretpickle_load(b64) # -> pickle.loads action = pl.get("action", pl.get("a")) params = pl.get("params", pl.get("p", {})) res = await action_handler(action, params, pl) return secretpickle_dump(res)

    Actions: home, hello, register, login, whoami, encrypt, decrypt, adminbot.

  • adminbot (adminbot.py, Playwright/chromium). On every visit it:

    1. registers admin with password=FLAG,
    2. logs in as admin (which stores username/password in the headless browser's localStorage),
    3. runs whoami,
    4. then navigates to the attacker-supplied URL.
    FLAG = open("/flag.txt").read().strip() ... await page.goto(action_url("register", username="admin", password=FLAG)) await page.goto(action_url("login", username="admin", password=FLAG)) await page.goto(action_url("whoami")) await page.goto(url) # attacker-controlled

Analysis — the fake "encryption"

secretpickle.py is the entire crypto:

SECRETPICKLE_OBJECT_PREFIX = bytes.fromhex("8004 950000000000000000 7d 94 28") # proto4 + FRAME(len=0) + EMPTY_DICT + MEMOIZE + MARK # "128 random bits, so same security as AES-128" SECRETPICKLE_XOR_KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3") # HARDCODED def secretpickle_dump(decoded, encoder=pickle.dumps): raw = encoder(decoded) trimmed = raw[len(SECRETPICKLE_OBJECT_PREFIX):] # strip fixed 14-byte prefix return base64.b64encode(xor(trimmed, KEY)).decode() def secretpickle_load(encoded, decoder=pickle.loads): decoded = base64.b64decode(encoded) untrimmed = SECRETPICKLE_OBJECT_PREFIX + xor(decoded, KEY) # re-prepend prefix return decoder(untrimmed) # pickle.loads!

...

$ grep --similar

Similar writeups