CYBER THREAT INTELLIGENCE

Une introduction à la rétro-ingénierie d’applications .NET AOT

14 min

Il y a un mois, des rapports sur l’activité de DuckTail (un groupe cybercriminel suspecté d’être basé au Vietnam) ont attiré notre attention. Après avoir détonné certains de leurs échantillons, nous avons observé qu’un nouveau compte utilisateur apparaissait sur la machine, bientôt suivi d’une connexion RDP d’un opérateur qui téléchargeait alors des outils supplémentaires, exfiltrait les cookies, etc.

L’attaque en elle-même ne présentait pas d’intérêt particulier, si ce n’est l’utilisation d’une fonctionnalité peu connue du langage .NET : la compilation AOT. Nous avons décidé de laisser l’aspect cybercriminel de côté pour nous pencher sur le fonctionnement interne de ces programmes.

Qu’est-ce que AOT ?

Rappels sur le .NET

La plupart des reversers ont une plutôt bonne opinion des programmes en .NET. Les outils habituels les prennent très bien en charge et permettent la plupart du temps de reconstituer le code source original avec beaucoup de fidélité. Bien sûr, le code est susceptible d’être obfusqué, mais dans le monde de la rétro-ingénierie, il peut arriver bien pire.

Les langages interprétés comme le .NET contiennent généralement beaucoup plus d’informations dans les binaires qu’ils produisent que leurs équivalents natifs — des informations qui s’avèrent précieuses pour les analystes. Le code source écrit par l’auteur du programme est traduit en « Langage Intermédiaire de Microsoft » (MSIL) durant l’étape de compilation, puis est interprété plus tard par un programme dédié (le « runtime »). L’intérêt de cette approche est que, en théorie, le programme écrit de la sorte peut fonctionner sur n’importe quel système d’exploitation et n’importe quelle architecture processeur, du moment qu’un runtime est disponible pour ceux-ci :

Bien que la compilation « just-in-time » (JIT) puisse utiliser les informations de contexte propre à l’exécution afin d’effectuer des optimisations, il est généralement admis que cette portabilité « gratuite » se fait au prix d’une légère baisse de performance, en comparaison des programmes compilés directement en assembleur.

La compilation « ahead of time » (AOT)

Et si l’aspect multi-plateforme n’intéresse pas les développeurs ? Peut-être connaissent-ils à l’avance sur quels systèmes leur application sera déployée ? Auquel cas, il n’y a pas vraiment de raison de s’infliger la baisse de performances due au langage intermédiaire. Si l’environnement de développement le permet, ils peuvent tout aussi bien générer un binaire natif directement, comme s’ils avaient écrit leur programme en C ou en C++. C’est ce qu’on appelle la compilation AOT (« ahead of time », ou « en avance de phase ») :

Pour les reversers, il s’agit d’une mauvaise nouvelle car la disparition du MSIL signifie que nous sommes contraints de commencer notre analyse au niveau assembleur.

Une recherche rapide sur VirusTotal révèle qu’à l’heure où j’écris ces mots, il y a 544 binaires AOT « bénins » sur la plateforme (dans le sens, qui ne sont détectés par aucun antivirus) pour 1667 binaires malveillants (détectés par au moins trois moteurs). Sans oublier le biais de sélection inhérent à VirusTotal, une estimation peu rigoureuse serait qu’environ 75 % des binaires AOT sont malveillants — un indicateur pertinent. D’autres chercheurs ont également découvert des virus compilés AOT activement utilisés.

Comment reconnaître les binaires AOT ?

D’après mes tests, il semble que les PE générés de cette manière présentent quelques caractéristiques :

  • Seulement un export (DotNetRuntimeDebugHeader)
  • Une section nommée .managed

On peut trouver de tels fichiers facilement sur VirusTotal. De plus, la version de .NET utilisée pour compiler le fichier est présente dans le binaire, sous la forme d’une chaîne de caractères que vous pourrez retrouver à l’aide de l’expression régulière suivante : ([.\— a-z0-9]*?)\d\+[a-f0-9]{40}\b.
Par exemple : 8.0.0+5535e31a712343a63f5d7d796cd874e563e5ac14, la dernière partie désignant le commit correspondant du code source du runtime — toujours utile pour vérifier si les dates de compilation n’auraient pas été falsifiées.

Mettre en place AOT pour un projet test

Créer un projet vierge pour se familiariser avec la compilation AOT n’est pas complètement trivial, surtout si vous n’avez pas d’expérience avec Visual Studio. Les prérequis sont les suivants :

  • Visual Studio 2022 (les versions plus anciennes ne prennent pas en charge la compilation AOT)
  • Sélectionner le composant « Desktop development with C++ » lors de l’installation
  • Installer le .NET SDK (version 7 ou plus récente)

Créez un nouveau projet « Application Console » en C#, et assurez-vous de cliquer sur « Enable native AOT publish » (option seulement présente à partir de .NET 8, pour la version 7 il faut ajouter manuellement la propriété <PublishAot>true</PublishAot> à votre fichier .csproj) :

Si vous n’avez pas fait de .NET depuis longtemps, vous découvrirez que le template « Hello World » ne fait plus qu’une ligne — la déclaration de la classe Program semble désormais implicite. N’hésitez pas à la restaurer, car cela permettra de voir plus clairement la correspondance entre le code source d’origine et le programme compilé. Pour générer le binaire AOT, faites un clic droit sur votre projet et sélectionnez « Publish » (ou « Publier »). Dans notre cas, le plus simple est de publier vers un dossier local. Il faut renseigner quelques options supplémentaires pour que la compilation ait lieu, principalement l’architecture processeurvisée (x64 ici) :

Une fois ceci fait, cliquer sur le bouton « Publier » devrait générer un binaire unique dans le dossier choisi. Notre « Hello World » d’une ligne pèse environ 1.2 MO (3 MO lorsque les optimisations sont désactivées), une nouvelle tragique puisqu’elle signifie que le programme contient énormément de code provenant de bibliothèques standard.

IDA Pro et AOT en pratique

L’étude de quelques échantillons nous conduit aux observations suivantes :

  • Le .NET AOT ressemble beaucoup à du C++, ce qui n’est guère surprenant puisque Visual Studio exigeait le composant C++ pour générer des binaires AOT.
  • La convention d’appel pour x64 semble standard, et utilise les registres RCX, RDX, R8 et R9 puis la pile pour passer ses arguments.
  • IDA ne possède pas de signatures pour les fonctions du runtime .NET compilées AOT, et rien n’est reconnu.
  • Les pointeurs vers les chaînes de caractères nous emmènent vers de la mémoire non initialisée dans une section appelée hydrated 😱

Ce dernier phénomène est attribuable à une optimisation qui vide à réduire la taille des binaires durant la compilation. Cela implique que nous serons forcés d’utiliser un débogueur pour voir quelles chaînes sont manipulées durant l’exécution.

Identifier les fonctions du runtime

Supposons que nous travaillons sur un programme inconnu compilé en .NET AOT. En premier lieu, il faudra reconnaître les fonctions qui proviennent du runtime et représentent 95 % du binaire. Sans celles-ci, il est à peu près certain que nous ne nous en sortirons pas.

Pour ce faire, nous allons emprunter une démarche similaire à ce que fait Hex-Rays avec le langage Go et son utilitaire go2pat — générer un programme .NET AOT qui contient tous les imports possibles, puis extraire les symboles et générer des signatures pour ceux-ci. Notre approche n’a pas besoin d’être aussi générique, car nous savons exactement quelle version de .NET nous ciblons grâce à la version présente dans le binaire (voir ci-dessus). Il nous faut juste un binaire qui importe le plus de fonctions possible. Malheureusement pour nous, le compilateur AOT supprime tout le code non utilisé, et il est impossible de désactiver ce comportement.

Nous pouvons cependant contourner ce problème, car bien qu’il faille utiliser toutes les fonctions importées, il n’est pas nécessaire d’en faire quoi que ce soit de significatif pour empêcher le compilateur de les optimiser. Mais comment générer un tel code ? Je n’ai aucune expérience de développement en .NET. Mais les modèles de langage, eux, en ont beaucoup. Après quelques requêtes incrémentales, j’ai pu produire un code source qui génère un binaire AOT de 10 MO — accompagné du fichier .PDB qui contient tous les symboles associés. Ce programme n’est certainement pas exhaustif, mais c’est un bon début.

Création de signatures pour IDA Pro

L’étape suivante consiste à générer des signatures pour ces fonctions .NET AOT connues. Pour celà, nous utilisons le script idb2pat.py de l’équipe FLARE, et obtenons un fichier .pat pour les ~31 000 fonctions contenues dans mon échantillon de test. Ce .pat doit ensuite être converti en .sig susceptible d’être utilisé par IDA ; je m’apprête à détailler cet aspect car je sais que la plupart des reversers n’ont pas souvent l’occasion de s’y adonner
(des instructions plus complètes sont disponibles ici).

En premier lieu, il faut télécharger les utilitaires FLAIR sur le centre de téléchargement de Hex-Rays. Nous partons d’un .pat qui contient des entrées de ce type :

488D05……..488D0D……..837908017501C3488BD0E9………….. 00 0000 001D :00000000 __GetNonGCStaticBase_System_Collections_System_SR :00000015 loc_140001015 ^00000003 ?__NONGCSTATICS@System_Collections_System_SR@@ ^0000000A off_14094E4D0 ^00000019 S_P_CoreLib_System_Runtime_CompilerServices_ClassConstructorRunner__CheckStaticClassConstructionReturnNonGCStaticBase

Chaque ligne, en substance, contient un pattern composé de suites d’octets (comme on en trouve aujourd’hui dans les règles Yara), suivi de longueurs, checksume, nom de la fonction ainsi que noms référencés. La documentation complète de ce format se trouve dans pat.txt, lui-même dans le dossier FLAIR. La commande suivante compile ces descriptions (plus ou moins) lisibles en signatures au format binaire :

sigmake.exe input.pat output.sig -n « [description] »

Il y a cependant des chances pour que cela ne fonctionne pas du premier coup, car idb2pat crée les patterns de manière automatique et il est probable que des collisions aient lieu (~800 dans mon cas). La méthode pour les résoudre consiste à éditer le fichier .exc généré par sigmake dans le même dossier et indiquer manuellement quelles fonctions doivent être conservées :

;--------- (delete these lines to allow sigmake to read this file)

; add '+' at the start of a line to select a module

; add '-' if you are not sure about the selection

; do nothing if you want to exclude all modules


memcpy_CopyDown_Intel                               BD 6EF1 [...]

memcpy_CopyDown_amd                                 BD 6EF1 [...]


Bool__System_IConvertible_ToByte                    00 0000 [...]

Bool__System_IConvertible_ToInt16                   00 0000 [...]

Bool__System_IConvertible_ToInt32                   00 0000 [...]

Bool__System_IConvertible_ToSByte                   00 0000 [...]

Bool__System_IConvertible_ToUInt16                  00 0000 [...]

Bool__System_IConvertible_ToUInt32                  00 0000 [...]

Bool__System_IConvertible_ToUInt64                  00 0000 [...]


[...]

Comme vous pourrez le voir, dans la plupart des cas les fonctions sont suffisamment similaires pour que n’importe quel nom convienne. On peut utiliser ici la fonction rechercher et remplacer d’un éditeur de texte pour ajouter un caractère « + » au début de chaque ligne qui suit une ligne vide (remplacer  ^$\r\n  par  \r\n\+ ). N’oubliez pas d’effacer les premières lignes du fichier comme indiqué, et relancez sigmake.

On peut maintenant placer le fichier .sig obtenu dans le dossier $IDAUSR/sig/pc, puis lancer la reconnaissance manuellement via le menu : File > Load File > FLIRT Signature File.

Les résultats sont très satisfaisants, car la barre de navigation en haut passe de ceci :

…à celà :

Vous trouverez mon fichier .sig ici, même si a priori il ne donnera des résultats que pour les binaires compilés avec .NET 7.0.

Et maintenant ?

Maintenant que les fonctions du runtime sont correctement identifiées, le dépôt de code original peut servir de documentation. Commençons avec RhpNewFast, une fonction cruciale qui a pour rôle d’allouer les objets. Elle reçoit un pointeur vers une structure MethodTable qui représente l’objet à instancier, que nous devons nous aussi reconnaître. Évidemment, nous n’avons pas les noms correspondant à ces structures statiques. Le code décompilé est parsemé d’appels de ce type :

v4 = RhpNewFast(&stru_140261200);

Les structures MethodTable (ou EETypes) sont composées d’un header comportant les informations de base au sujet du type d’objet correspondant (sa taille, les types associés, et des flags qui affectent le comportement du garbage collector), suivi d’une table de fonctions qui évoque les vtables du C++ :

Fort heureusement, ces méthodes sont identifiées correctement avec nos signatures, et nous pouvons déduire ici que l’objet instancié est une Exception (spécifiquement, OutOfMemoryException même si ce n’est pas visible sur la capture d’écran).

Y a-t-il moyen de faire mieux ? Le .NET prend en charge le RTTI. On pourrait certainement écrire un script Python qui reconstruit toutes les informations de typage (mais je n’ai pas suffisamment fouillé dans ces structures pour savoir comment) — à défaut, on peut obtenir facilement nos réponses avec un débogueur. Interceptez les appels à la fonction S_P_CoreLib_System_Type__GetTypeFromEETypePtr (ou son wrapper, Object__GetType), mettez un pointeur vers la MethodTable désirée dans ECX, et attendez l’appel à  NativeFormatRuntimeNamedTypeInfo__ToString pour obtenir le nom du type recherché (il suffit de suivre EAX sur le tas).

Conclusion : une approche générique pour les frameworks inconnus

Il ne fait aucun doute que les programmes en .NET AOT vont donner du fil à retordre aux reversers, a fortiori si on les compare à leurs équivalents en MSIL. Cependant, à l’aide des techniques présentées ci-dessus, nous avons pu récupérer les symboles ainsi que des informations de typage. Cela nous ramène à une situation proche de l’analyse de programmes en Go (mais avec un décompilateur fonctionnel). À partir d’ici je recommanderais de suivre l’approche que j’utilise pour Golang, à savoir utiliser un débogueur pour inspecter les appels aux fonctions de la bibliothèque standard et essayer de reconstruire le code original de cette manière.

Cet article illustrait en tout cas une approche générique qui permet de commencer l’analyse de binaires produits avec des frameworks inconnus (comme le .NET AOT, en tout cas de notre point de vue d’analyste). Lorsque cela est possible, j’entreprends toujours d’emprunter des raccourcis (au moins pour commencer), mais si vous voulez en savoir plus sur le fonctionnement interne du .NET AOT, je vous recommence l’article de blog de Michal Strehovsky sur le sujet.

Je conclurai en prédisant que les malwares AOT pourraient bien devenir de plus en plus répandus à l’avenir, et ce pour deux raisons :

  • Microsoft rend la publication AOT de programmes .NET de plus en plus facile avec les nouvelles versions de Visual Studio.
  • Il s’agit essentiellement d’un bouton « rendre mon application pénible à reverser », et cela finira bien par se savoir.

N’hésitez pas à me contacter sur Twitter si vous avez découvert d’autres techniques utiles pour analyser ces programmes !

Indicateurs de compromission

9ba3b2ce74d60e0960be0e2544f7497339f1f115db93afb94e5512a8c990f63f BotGetKeyChromium

268aec06d44359b21bfe1c0c13abb75d1e37add2c8512acb6e0a0835b939b9b9 BotGetKeyChromium

9b8a1424cd299629e8dccdb1c7c4f3caad78fecec083c9e27b6a3dc281d5b1ca BotGetKeyChromium

6d689bfc12d18a6e4dae9309e3260f71d93de1fb9864f8545cbc30a24e181b1f BotGetKeyChromium

7fd054a810f5d942bc18d91d8e31285b484982bf5c8ace0c12c8ad64b0f183d4 BotGetKeyChromium

9ba3b2ce74d60e0960be0e2544f7497339f1f115db93afb94e5512a8c990f63f BotGetKeyChromium

1e082ed9733b033a0c9b27a0d1146397771b350b013ea3e9fba228e1400a263f ResetMainBot

ab8d86ac204d9c9ae689d87b9d2f7319b38125f7659ff2ba7cbfed13cbf0a13d ResetMainBot

Règles Yara

import "pe"
rule NET_AOT {
    meta:
        description = "Detects .NET binaries compiled ahead of time (AOT)"
        author = "HarfangLab"
        distribution = "TLP:WHITE"
        version = "1.0"
        last_modified = "2024-01-08"
        hash = "5b922fc7e8d308a15631650549bdb00c"

    condition:
        pe.is_pe and pe.number_of_exports == 1 and
        pe.exports("DotNetRuntimeDebugHeader") and
        pe.section_index(".managed") >= 0
}