
Un exploit de preuve de concept pour PyInstaller CVE-2019-16783
Ceci est mon POC pour la vulnérabilité Windows PyInstaller version < 3.6 qui existe pour l'option --onefile. Un attaquant pourrait exécuter des commandes et éventuellement effectuer une élévation de privilèges locale (LPE) en détournant une DLL importée par la DLL de l'interpréteur Python utilisée par le binaire PyInstaller. Une brève explication de la vulnérabilité et du processus d'exploitation suivra. Merci à Alter Solutions d'avoir découvert la vulnérabilité et d'avoir écrit sur leur découverte dans PagedOut #3 (page 55 du pdf).
La vulnérabilité a été causée par une création faible d'un répertoire utilisé par PyInstaller lors de l'exécution du binaire. PyInstaller construit un répertoire _MEIPIDX dans le répertoire temporaire de l'utilisateur et y met différentes choses, comme la DLL de l'interpréteur Python utilisée pour exécuter le code Python emballé dans un exécutable PE.
Le problème avec ce processus était que le répertoire construit pour NT AUTHORITY\SYSTEM était C:\Windows\Temp, ce qui permettait à quelqu'un à la fois de le deviner et d'écrire à l'intérieur. Ainsi, un détournement de DLL était possible, par exemple lorsque l'interpréteur Python était exécuté. Voici le commit corrigeant la vulnérabilité. Au lieu de se fier uniquement à certaines fonctions API standard pour créer le répertoire, les développeurs ont implémenté leur propre fonction pour avoir plus de contrôle sur la création du répertoire.
Tout d'abord, la mise en place d'un environnement pour ce POC n'était pas particulièrement difficile, car tout ce dont vous aviez besoin était la version correcte du paquet. Cependant, lorsque je l'ai installé, il plantait
Traceback (most recent call last):
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\runpy.py", line 194, in _run_module_as_main
return _run_code(code, main_globals, None,
...
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\site-packages\PyInstaller\building\utils.py", line 653, in <genexpr>
strip_paths_in_code(const_co, new_filename)
File "c:\users\ckrielle\appdata\local\programs\python\python38\lib\site-packages\PyInstaller\building\utils.py", line 660, in strip_paths_in_code
return code_func(co.co_argcount, co.co_kwonlyargcount, co.co_nlocals, co.co_stacksize,
TypeError: an integer is required (got type bytes)
Après avoir cherché un peu, j'ai vu que ma version de Python était en cause (j'utilise la 3.8.10). Ma version de Python rendait difficile l'installation de toute autre version 3.8.x, car lors de l'installation, l'installateur trouverait ma version Python38 et renverrait une erreur. J'ai également essayé de compiler une autre version 3.8, mais je n'ai pas pu car elle nécessitait Visual Studio 2015, alors que j'ai la version 2022. Finalement, j'ai choisi de télécharger Python 3.7.5, qui a parfaitement fonctionné. J'ai donc créé un environnement virtuel pour la version 3.7.
J'ai compris que la mise en place de l'environnement peut aller du déjà configuré à potentiellement prendre beaucoup de temps pour bien le configurer. Même si c'est important, cela pourrait potentiellement enlever le plaisir d'exploiter réellement notre cible.
Il y a deux étapes dans notre processus d'exploitation : trouver le répertoire du processus empaqueté par PyInstaller et y écrire notre DLL d'exploit. Pour la première partie, nous pouvons trouver le PID via les fonctions WINAPI standard (CreateToolhelp32Snapshot, Process32First, Process32Next). Cela a fonctionné sans problème. Ensuite, nous devons trouver le dernier numéro du répertoire _MEI. L'exploit original suggère d'utiliser simplement la fonction GetFileAttributesA et de vérifier si le code de statut retourné est FILE_ATTRIBUTE_DIRECTORY. Cependant, cela n'a pas fonctionné pour moi. Ma solution a été de créer un fichier dans chaque répertoire candidat, et si le fichier était créé avec succès, alors le répertoire existait. Pour une raison quelconque, le statut retourné pour le fichier par GetFileAttributesA était FILE_ATTRIBUTE_ARCHIVE, donc je vérifie cela. Avant de continuer, la boucle lors de la recherche du PID est due au fait que nous voulons injecter nos DLL lorsque le processus cible est exécuté, pour qu'elles soient prêtes avant le chargement.
Pour la DLL, nous devons détourner une DLL que l'interpréteur Python (python37.dll) importe. Pour obtenir les DLL que l'interpréteur importe, nous pourrions ouvrir la DLL avec PE-Bear. L'une des DLL système importées est version.dll. Nous pouvons donc créer notre propre DLL et abuser de l'ordre de recherche du chargeur Windows en la plaçant dans le répertoire temporaire du processus empaqueté par PyInstaller. De cette façon, lorsqu'il voudra importer la DLL, il importera notre DLL malveillante, plutôt que la bonne.

Cependant, cela ne fonctionnera pas. La raison est que l'importation de la fonction que l'interpréteur Python appelle depuis version.dll (spécifiquement VerQueryValueW d'après l'image) n'est pas résolue. Donc le programme plante. Pour résoudre ce problème, nous devons effectuer un proxy DLL. En bref, nous configurons notre DLL malveillante pour exporter les fonctions de la DLL d'origine, et nous apportons la DLL d'origine renommée, afin qu'elle puisse la charger et appeler la fonction à partir de là. Donc pour notre exploit, nous allons compiler une DLL malveillante avec un DllMain qui nous permettra d'exécuter notre code. Nous allons exporter toutes les fonctions de version.dll et copier la DLL système originale version2.dll et la placer dans le même répertoire. Ainsi, version.dll peut rediriger les appels de fonctions vers version2.dll. Et une fois que nous avons ces DLL, tout ce que nous avons à faire est de les copier dans le répertoire du processus, et d'attendre l'exécution du code. Pour une meilleure explication du Proxy/Détournement de DLL, lisez l'article lié.

Même si avec le recul ces étapes sont toutes simples et directes, ce n'était pas le cas lors du développement du POC. Il m'a fallu un certain temps pour comprendre le processus de l'exploit, et une fois que j'ai commencé à coder, j'ai commencé à mieux le comprendre. Pour ce qui est de la DLL, j'ai essayé de la compiler moi-même comme cela était sous-entendu dans le dépôt POC d'Alter Solutions. Ils ont une DLL séparée pour l'exécution de code, et une autre pour le proxy, qui chargeait payload.dll au moment de l'exécution (DllMain est appelé lors du chargement). Cependant, je n'ai pas réussi à la compiler correctement. Finalement, après avoir lu l'article ci-dessus, j'ai décidé d'utiliser le dépôt DLLProxyProject, qui a compilé la DLL que je voulais, et peut être utilisé généralement pour produire une DLL à des fins de proxy. J'ai essayé de copier son fichier DLLMain.cpp et de le compiler avec le fichier d'en-tête exports.h. Et même si cela a fonctionné, cela a produit une erreur :
Fatal Python error: init_sys_streams: can't initialize sys standard streams
OSError: [WinError 6] The handle is invalid
Current thread 0x00002ea8 (most recent call first):
Cette erreur pourrait être évitée avec le fichier Utils.cpp, alors j'ai décidé de garder ce projet pour ma compilation DLL (même si ça aurait été plus agréable si je l'avais fait complètement moi-même).
Après avoir mis en place l'environnement (utilisé psexec pour obtenir un shell NT AUTHORITY\SYSTEM), j'ai exécuté l'exploit, exécuté le binaire cible, et obtenu l'exécution de code en tant qu'administrateur.

Ce fut une très belle expérience, et je suis heureux de l'avoir vécue. J'ai vraiment envie de produire plus de POC pour d'autres CVE, et c'était une première cible parfaite. Lire la description de la vulnérabilité était intéressant, car j'ai compris que la principale difficulté pour implémenter un POC soi-même est de combler les lacunes pour les choses que l'auteur n'a pas expliquées (que ce soit intentionnellement ou non), et simplement comprendre ce que l'on lit. C'était une vulnérabilité simple, donc je n'ai pas fourni beaucoup d'efforts pour la comprendre. Dans un futur proche, après en avoir fait quelques autres, j'essaierai de faire un POC pour une vulnérabilité de corruption mémoire.