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/
$ 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 withyaml.safe_loadand renders server results viaDOMPurify.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:- registers
adminwithpassword=FLAG, - logs in as
admin(which storesusername/passwordin the headless browser'slocalStorage), - runs
whoami, - 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 - registers
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
- [web][free]Secure Secretpickle— gpnctf
- [web][Pro]Соленый огурец (Salty Pickle)— hackerlab
- [web][Pro]Lab 362 — LogPulse — Insecure Deserialization via Pickle Session Cookie— hackadvisor
- [web][Pro]Lab 379 — CrawlBase — Stored XSS to SSRF to Pickle Deserialization RCE— hackadvisor
- [crypto][free]Easy DSA— gpnctf