Click here for the English version of this article
Si vous dites à la plupart des experts en maldev que vous utilisez Python pour du malware, vous serez vu comme un imbécile ou comme un script skiddy (et probablement à juste titre). En effet, Python est généralement le premier langage que les débutant utilisent parce qu'il est high level et que la syntaxe est incroyablement facile. En fait, c'est presque l'exact opposé de ce que vous voudriez pour du malware. Beaucoup de couches d'abstraction veut généralement dire moins de liberté, les développeurs de malware font généralement beaucoup de manipulation de mémoire bas niveau, exécutent du code machine dans des scénarios d'injection, ont besoin d'une liberté complète sur la Windows API pour le chargement dynamique de DLL, les system calls... C'est pourquoi les gens du domaine utilisent généralement des langages compilés comme C. Mais et si je vous disais que Python a bel et bien des avantages dans le développement de malware?Python compile()
Dans le développement de malware, il y a ce concept appelé la virtualization de code, où le binaire embarque sa propre machine virtuelle, un interpréteur de bytecode personnalisé qui va lire le payload (un payload avec lequel l’analyse statistique a du mal, puisque c’est du bytecode custom).
Eh bien, ce concept n’est évidemment pas utilisé uniquement pour les malwares, vous pouvez le trouver dans des langages de haut niveau comme Java (JVM) et… Python (CPython).
Le bytecode est exécuté par ceval.c, l’interpréteur exécute sans problèmes le .pyc sans jamais voir votre code source !
Vous pouvez contrôler le compilateur Python pendant l’exécution en utilisant la fonction compile() :
1 | source = r"print('hello peeps')" |
Cela signifie que vous pouvez absolument exécuter du code qui n’est pas écrit dans la source / directement visible en analyse statique.
Voici un exemple simple utilisant la base64 :
1 | import base64 |
Maintenant, si vous croyez qu’il y aura du constant folding / propagation… Eh bien, voici le désassemblage du .pyc généré par Python pour notre petit script :
1 |
|
C’est un bon rappel que Python est lent, paresseux et inefficace je crois. Le compilateur ne fait presque aucun travail pour vous au moment de la compilation.
Ici, par simplicité, nous avons simplement encodé le payload en base64. Mais avec un stub plus complexe (XOR, junk code, brute force non déterministe, hashing), cela peut devenir extrêmement difficile de savoir quelle sera la sortie du script sans analyse dynamique.
Voilà. Nous verrons ce que nous pouvons faire avec cela.
PyInstaller
PyInstaller est cet outil “quick-and-dirty” que la plupart des gens qui connaissent Python ont utilisé. Je l’ai personnellement beaucoup utilisé pour exécuter mes scripts Python sur les ordinateurs de personnes non techniques pour qu’elles n’aient pas à installer un interpréteur Python.
En effet, PyInstaller en mode --onefile crée juste un PE avec tout le runtime compressé à l’intérieur, les DLLs de l’interpréteur, vos modules, votre code. Un simple hello world fait plus de 10 MB en Python…
C’est du junk code sauf qu’en fait non, la plupart des vendeurs d’AV n’ont pas de mécanisme de unpacking Python, et n’analysent jamais directement le code qui compte pendant l’analyse statique.
Aller plus loin avec une démo de code polymorphe
Voici un exemple de comment j’utiliserais ce mécanisme pour rendre un .pyc très pénible à analyser en analyse statique pour les reverse engineers et les AVs:
1 | #!/usr/bin/env python3 |
Tout cela c’est pour ouvrir calc.exe au passage.
Ce n’est nulle part près de ce que les gens font avec les passes d’obfuscation LLVM, mais c’est pénible à reverse, ce qui est déjà quelque chose. Rappelez-vous que ce n’est que Python.
Il y a plusieurs projets comme PyArmor, pyobfus, VMP-Protector, CodeEnigma qui font cela automatiquement avec plus de complexité, mais cela montre juste à quel point il peut être amusant et simple de faire le vôtre (ce qui le rend plus difficile à fingerprint, il pourrait y avoir des règles YARA pour une librairie connue)
Passer à la vitesse supérieure un vrai cas d’usage de Python compile() : Du code chiffré qui ne se déchiffre que sur une machine / un environnement spécifique
Tout cela est intéressant, mais rien jusque ici n’est vraiment original. Je pourrais m’arrêter là comme la plupart des articles : “Voilà, compiler du bytecode personnalisé” mais à la place, laissez-moi montrer un vrai cas d’usage : comment cela pourrait être utilisé dans la vraie vie, et les choses intéressantes que nous pouvons explorer avec ce concept.
Ma première idée était de faire du code Python qui ne pourrait s’exécuter que sur une machine spécifique. Non seulement cela, le code serait presque indéchiffrable s’il ne s’exécute pas sur la machine prévue. Probablement le pire cauchemar pour un analyste de malware.
Le script suivant le démontre, il est “impossible” de savoir ce que fait le script tant que vous n’êtes pas la cible, ce script compare l’adresse MAC de la machine avec un SHA256 de la MAC cible, si le hash correspond il déchiffre le payload AES128 avec une clé dérivée de l’adresse MAC trouvé, et exec(compile()) nous permet d’exécuter ce code déchiffré !
1 | #!/usr/bin/env python3 |
Je trouve la simplicité de ce code fascinante.
Python rend cela tellement facile, n’importe quel débutant en Python pourrait comprendre ce code après avoir appris ce que fait la fonction compile(), ce qui pourrait faire de Python un excellent langage pour draft / démontrer / showcase des mécanismes malware complexes dans certains cas.
Cet exemple n’est pas “incassable”. Bien qu’il y ait 2^46 ( = 281,474,976,710,656) adresses MAC possibles, la clé ne fait que 128bit et sur du hardware moderne ce n’est pas si difficile. Mais vous pourriez absolument rendre le ciphertext presque impossible à cracker en dérivant la clé à l’aide de variable d’environnement connue : le contenu d’un fichier spécifique que la cible possède, GUID, hardware fingerprinting, adresse IP, la structure des directory / du workspace les possibilités sont tout simplement infinies.
“C’est juste du Python, c’est juste du Python”, pourtant même un chercheur expert devant le code source ne peut vous dire quelle sera la sortie de ce script sur la machine cible, réfléchissez-y.
Un second exemple de cas d’usage réel
Donc je pense que cet article devient intéressant, mais ce n’est toujours pas la “weaponization” que j’ai promise, encore très loin de tout ce qui “pourrait être utilisé comme vecteur d’attaque”. Alors voici une autre façon, complètement différente mais très intéressante, de tirer parti de compile().
L’idée : cacher un payload à la vue de tous, directement dans le code source, et livrer le fichier .py. Oui vous l’avez deviné, nous allons utiliser de la stéganographie unicode.
Unicode est un système complexe avec TELLEMENT de code points. Ce que j’ai fait un jour, c’est que j’ai demandé à mon agent IA de me donner tous les caractères Unicode printable mais minuscules / zero-width / invisibles, avec une idée en tête : si je pouvais placer ces caractères dans un fichier source Python pour représenter des données binaires, puis les décoder, je pourrais utiliser compile() pour exécuter le résultat, essentiellement exécuter du code qui est littéralement invisible pour l’utilisateur (seule la logique de décodage et l’instruction compile() seraient visibles).
Mon agent IA a proposé plusieurs candidats, mais seuls les variation selectors se sont démarqués (U+FE00 -> U+FE0F et U+E0100 -> U+E01EF).
Cela fait 16 + 240 = 256 caractères invisibles que Python accepte volontiers dans votre code, puisque ces caractères sont printable Python vous laissera les utiliser dans votre code sans lever aucune erreur !
Cela pourrait vous rappeler les attaques “ASCII smuggling” sur les LLMs / prompt injections, qui utilisaient une technique similaire, sauf qu’ici nous utilisons cette stéganographie pour cacher le code afin de l’exécuter.
J’ai donc conçu un encoder qui prend du texte en bytes, les variation selectors ont assez de valeurs possibles :
1 | def encode(text): |
Le décodeur coule de sources:
1 |
|
Dans Visual Studio Code, les caractères invisibles sont surlignés en jaune avec un avertissement, mais pour un programmeur peu attentif, combiné à un peu d’ingénierie sociale (un code difficile à lire, de l’art ASCII dans le code qui peut faire passer un seul bloc jaune pour normal), cela peut facilement être ignoré.
Je l’ai testé avec import os; os.system('msg * test') (Messagebox Windows via CMD). Dans Visual Studio Code, voici à quoi cela ressemble :
Booom!!!!
Cela est tout de même impressionant, import os est encodé avec l’appel de la fonction os.system(), tout est invisible
Nous avons maintenant essentiellement réalisé une "backdoor" a la vue de tous : le code source .py est là, et pourtant il exécute quelque chose que nous NE POUVONS PAS VOIR.
Évidemment ça ne fonctionne pas dans n'importe quel terminal et éditeur de texte il faut le bon encodage et la bonne code page...Je pense que j’ai déjà fait assez, cet article est déjà plutôt long et nous avons exploré des concepts assez originaux utilisant la fonction compile().
Si vous voulez l’essayer vous-même, tout le code pour générer des payloads “hidden unicode” est dans ce répertoire GitHub : https://github.com/0xWill-A/python-invisible-code
Conclusion
J’espère que cet article a rendu Python moins inintéressant. Je ne cherche pas à dire que Python est un bon langage pour le malware ou qu’il deviendra un vecteur d’attaque majeur, c’est juste intéressant d’explorer les concepts sous-jacents et de voir comment les possibilités ne sont pas limitées, dans un langage qui est lui-même si limitant.
J’ai simplifié beaucoup de parties de l’article pour ne parler que de ce qui compte vraiment, pour l’exemple polymorphique il aurait été mieux de s’appuyer fortement sur la manipulation AST et des code objects précompilés : un code object polymorphique contournerait AMSI par exemple.
Je trouve aussi fascinant le contraste avec les langages compilés, à quel point exécuter des instructions brutes et les manipuler devient facile. En C vous auriez à générer le machine code, retirer les null bytes, utiliser VirtualAlloc() etc. En Python vous pouvez juste compiler des instructions VM “on the fly”.
Au passage, je cherche actuellement un job, embauchez moi!