Techniques de contournement d'antivirus et d'EDR

Antivirus, anti-malware ou EDR sont des outils couramment utilisés pour prévenir des attaques.

Ces solutions peuvent cependant être contournées. Dans cet article, nous nous attarderons sur les différentes techniques d’évasion d’antivirus qui peuvent être implémentées dans un loader : un programme dont l’objectif est d’exécuter une charge malveillante sur une machine en contournant les différentes protections en place.

À travers des cas concrets, nous verrons en quoi le développement d’un tel outil peut être utile pour une équipe de pentesters. Nous présenterons notamment les différentes mesures de sécurité qui peuvent être rencontrées ainsi que les techniques permettant de les contourner.

Plan détaillé de l’article :

Pourquoi développer un loader ?

Lors de nos missions, notamment les pentests d’infrastructure et réseau, nos pentesters peuvent être confrontés à la nécessité d’exécuter des outils sur des machines équipées d’antivirus.

De tels outils sont également utilisés lors de véritables cyberattaques et sont donc logiquement bloqués par les solutions de sécurité en place. C’est par exemple le cas d’outils comme Rubeus et SharpHound, utilisés pour évaluer la sécurité d’Active Directory.

Néanmoins, ces outils sont nécessaires pour la bonne conduite d’un test d’intrusion. Il devient donc important de pouvoir contourner les protections mises en place.

C’est là que l’utilisation d’un loader devient intéressante. En effet, un loader permet de prendre un programme informatique détecté par les solutions antivirus et de le transformer en une version non détectée.

Comment fonctionne un antivirus/EDR ?

Avant même de commencer à vouloir créer un loader, il est important de comprendre pourquoi un programme informatique est considéré comme malveillant ou non par les solutions de sécurité.

Les antivirus/EDR disposent de plusieurs outils pour qualifier un programme informatique :

Tout programme informatique peut être passé dans une fonction de hachage, par exemple SHA-256, pour produire une signature unique.

L’analyse par signature consiste à conserver en mémoire une liste de toutes les signatures connues comme appartenant à des programmes malveillants, puis de comparer chaque nouvelle signature avec cette liste.

Bien que cette approche soit intéressante, elle ne suffit pas à elle seule pour garantir l’efficacité d’un antivirus. En effet, elle est relativement simple à contourner. Le moindre changement, même mineur, dans un programme viendra complètement changer sa signature.

De plus, cette méthode de sécurité ne protège pas contre les nouvelles menaces dont la signature n’est pas encore répertoriée.

Par exemple, le hash SHA256 correspondant à la release 2.2.0 de Mimikatz sur Github est :

Si un programme avec un hash similaire est téléchargé sur une machine, il sera immédiatement détecté par la plupart des antivirus.

L’analyse statique consiste en la recherche dans un programme d’une ou plusieurs chaînes de caractères connues comme appartenant à un programme malveillant.

L’outil DefenderCheck de Matt Hand permet par exemple de déterminer quelles chaînes de caractères dans un exécutable donné sont détectées par Windows Defender.

Ci-dessous un exemple avec le programme Mimikatz :

Mimikatz est détecté via l’analyse statique de Windows Defender

On remarque que Defender qualifie le programme de malveillant sur la base d’une chaîne de caractères contenant le mot « mimikatz ».

On peut également observer ce phénomène en employant une règle YARA. Par exemple, utilisons une règle YARA qui repère la présence de la chaîne de caractères suivante dans un fichier exécutable « Portable Executable » :

## / \ ##  /*** Benjamin DELPY `gentilkiwi` ( [email protected] )

Nous constatons que l’alerte « Windows_Hacktool_Mimikatz_1388212a » est déclenchée lorsque nous utilisons notre règle YARA sur « mimikatz.exe », ce qui n’est pas le cas avec « notepad.exe » (car ce dernier ne contient pas la chaîne de caractères en question) :

Règle Yara pour détecter Mimikatz

Si nous lançons le programme « mimikatz.exe », nous constatons que la chaîne de caractère est bien présente :

La règle Yara a bien détecté la chaîne

On remarque donc bien que l’analyse statique d’un programme peut permettre de le caractériser comme une menace.

La détection heuristique a pour objectif de comprendre le fonctionnement d’un programme et de déterminer les actions qu’il s’apprête à réaliser sur un système.

Cela peut être accompli au moyen d’une sandbox : une machine virtuelle isolée dans laquelle le programme potentiellement dangereux peut s’exécuter. Le logiciel antivirus pourra ensuite vérifier les actions réalisées par le programme pendant son exécution et chercher des indicateurs d’action malveillante.

De la même manière, la détection comportementale consiste à observer les actions effectuées par un programme pendant son exécution afin de repérer toute activité suspecte.

Par exemple, certains appels à l’API de Windows effectués dans un certain ordre sont connus pour être des patterns caractéristiques de programmes malveillants.

Lors de l’écriture d’un programme informatique, un développeur peut faire appel à des bibliothèques.

Sur un système Windows, cela se fait par l’intermédiaire de DLL (Dynamic Link Library). Une DLL est un programme qui exporte des fonctions prêtes à l’emploi. Un développeur peut alors choisir de charger certaines DLL dans son programme pour bénéficier des fonctions qu’elles exportent. Un certain nombre de DLL (par exemple « user32.dll » ou « kernel32.dll ») sont présentes de base sur tous les systèmes Windows et exportent des fonctions utiles aux développeurs.

Ces fonctions sont documentées par Microsoft et forment ce qui est couramment appelé l’API Windows.

Par exemple, un développeur peut faire appel à la fonction « MessageBoxA » exportée par la DLL « user32.dll » pour afficher une boîte de dialogue :

Ouverture d’une boîte de dialogue via MessageBoxA

L’Import Address Table est une table relative à chaque Portable Executable (contenu dans l’import directory de l’optionnal header du PE) qui contient une liste des DLL chargées et de leurs fonctions exportées utilisées par le programme.

Dans l’exemple ci-dessous, apparaît le début de l’Import Address Table pour le programme « notepad.exe » :

Début du contenu de la IAT de Notepad.exe

Cette table est observée par la plupart des antivirus, et la présence de certaines fonctions peut déclencher une alerte.

Par exemple, la présence des fonctions « OpenProcess », « VirtualAllocEx », « WriteProcessMemory » et « CreateRemoteThreadEx » (toutes exportées par « kernel32.dll ») ensemble dans un même programme le rendra fortement suspect. En effet, ces 4 fonctions utilisées ensemble permettent des techniques d’injection de processus souvent utilisées par des programmes malveillants.

L’AMSI est une interface proposée par le système d’exploitation Windows, que tout développeur peut utiliser pour intégrer une protection antivirus à son programme.

Plus concrètement, un développeur peut choisir de charger la DLL « AMSI.dll » dans son programme et utiliser les fonctions exportées par cette DLL. Par exemple, la fonction « AmsiScanString » prend en entrée une chaîne de caractères et retourne « AMSI_RESULT_CLEAN » s’il n’y a pas de menace détectée et « AMSI_RESULT_DETECTED » dans le cas contraire.

L’AMSI agit donc comme un pont entre un programme donné et un antivirus (« amsi.dll » fonctionne par défaut avec Windows Defender, mais les vendeurs d’antivirus peuvent créer leurs propres « amsi.dll » pour fonctionner avec leurs produits).

L’utilisation de l’AMSI n’est pas nécessaire dans tous les programmes. Par exemple, écrire une chaîne de caractères dans Notepad sur Windows ne présente pas de risque particulier. Par conséquent, on remarque que « amsi.dll » n’est pas chargée par « notepad.exe » :

AMSI.dll n’est pas chargée par Notepad.exe

En revanche, certaines commandes PowerShell peuvent clairement compromettre l’intégrité d’un système. Il est donc logique que les développeurs de Microsoft aient choisi d’intégrer l’AMSI au sein de « powershell.exe » :

AMSI.dll est bien chargée par Powershell.exe

L’ETW est un mécanisme permettant de suivre et de journaliser un grand nombre d’événements déclenchés par les applications et les drivers.

Historiquement, l’ETW remplissait principalement des fonctions de débogage. Au fil du temps, le nombre important de données remontées par ce système a fini par intéresser les vendeurs de solutions de protection, qui y ont vu l’opportunité de détecter des activités malveillantes en analysant les flux remontés par l’ETW.

L’ETW se compose de trois éléments distincts :

Fournisseurs (providers)

Divers composants système ou applications tierces au sein du système d’exploitation Windows peuvent instrumenter leur code pour envoyer des évènements à un fournisseur. Par exemple, l’Event Tracing for Windows – Threat Intelligence provider.

À plusieurs endroits du code de Windows associés à des fonctionnalités critiques, des appels de fonction associés à l’Event Tracing for Windows – Threat Intelligence provider sont observés. Par exemple, la fonction « MiReadWriteVirtualMemory » fait un appel à « EtwTiLogReadWriteVm ».

Nous pouvons l’observer en utilisant IDA sur « ntoskrnl.exe » (le composant noyau de Windows) :

EtwTiLogReadWriteVm utilise EtwProviderEnabled

Si nous examinons le fonctionnement de la fonction « EtwTiLogReadWriteVm », nous constatons qu’elle utilise la fonction « EtwProviderEnabled » pour vérifier si un fournisseur spécifique est activé pour la journalisation des événements :

EtwProviderEnabled permet de vérifier la bonne activation d’un provider donné

En consultant la documentation de Microsoft pour cette fonction, nous remarquons que le premier argument correspond à un pointeur vers le handle du fournisseur pour lequel nous voulons vérifier si la journalisation est correctement activée :

Si l’on se concentre sur ce paramètre en particulier, nous comprenons qu’il peut nous fournir une indication sur le fournisseur utilisé :

Or ici, l’argument en question est « EtwThreatIntProvRegHandle » :

EtwThreatIntProvRegHandle est passé en argument de EtwProviderEnabled

Nous pouvons donc conclure que chaque fois que « NtReadVirtualMemory » est utilisé, des événements seront envoyés au fournisseur « Event Tracing for Windows – Threat Intelligence ».

Consommateurs (consumers)

Les consommateurs sont les divers programmes qui vont utiliser les journaux fournis par les fournisseurs pour agir en conséquence.

Reprenons l’exemple du fournisseur « Event Tracing for Windows – Threat Intelligence ». Il est très probable qu’un antivirus vienne consommer les journaux d’événements qu’il fournit.

En effet, comme nous l’avons vu, ce fournisseur remonte de nombreuses informations liées à l’utilisation de fonctions critiques. Ainsi, un antivirus pourrait avoir accès à certaines informations indiquant une compromission et agir en conséquence.

Contrôleurs (controllers)

Dans l’ETW, les contrôleurs sont des composants logiciels chargés de gérer le processus de traçage des événements. Leur rôle principal est d’initier, surveiller et contrôler les sessions de traçage.

Il est donc à noter que pour la plupart des actions que nous effectuons sur un système Windows, des journaux d’événements sont remontés vers les antivirus/EDR, ce qui ajoute une autre méthode de détection des actions suspectes.

Pour bien saisir le concept de hooking d’API, nous devons d’abord aborder les syscalls.

Comme évoqué précédemment, la plupart des fonctions de l’API Windows sont exportées par « kernel32.dll ». Ces fonctions ne communiquent pas directement avec le kernel ; pour cela, elles doivent utiliser des syscalls.

Ces appels système agissent comme une interface permettant aux programmes d’interagir avec le système d’exploitation Windows. La plupart d’entre eux sont exportés par « ntdll.dll », et la convention de nommage veut qu’ils commencent par les lettres « Nt » (bien que toutes les fonctions de la NT API ne soient pas des syscalls).

Prenons un exemple : si un développeur souhaite utiliser la fonction « OpenProcess » de l’API Windows, cette fonction appellera en fait la fonction « NtOpenProcess » de « ntdll.dll ».

Ce phénomène peut être observé dans « x64dbg » :

La fonction OpenProcess fait appel à NtOpenProcess

Voyons maintenant ce que fait « NtOpenProcess » dans IDA :

NtOpenProcess effectue un syscall

Comme nous pouvons le constater à l’intérieur de « NtOpenProcess », très peu d’actions sont effectuées. Cela s’explique par le fait que, comme la plupart des fonctions commençant par Nt, « NtOpenProcess » se trouve en réalité dans le kernel. Les versions « ntdll » de ces fonctions effectuent simplement des syscalls pour invoquer leurs homologues en mode kernel.

Nous avons donc vu de quelle manière des fonctions de « kernel32.dll » peuvent interagir avec le système.

À présent, cherchons à comprendre en quoi les antivirus peuvent s’appuyer sur ce principe pour détecter des actions malveillantes.

Lorsqu’une solution de sécurité (tel qu’un EDR) est installée sur une machine, elle peut chercher à faire de l’API hooking. Pour ce faire, la solution de sécurité surveillera la machine pour détecter la création de nouveaux processus.

Lorsqu’un nouveau processus est lancé, l’EDR injectera sa propre DLL à l’intérieur de celui-ci. L’EDR cherchera les adresses mémoires des autres DLL dont il souhaite surveiller les fonctions. Par exemple, un EDR souhaitant surveiller « NtProtectVirtualMemory » de « ntdll.dll », trouvera d’abord l’adresse de base de « ntdll.dll » dans le processus injecté, puis l’adresse de la fonction « NtProtectVirtualMemory ».

Une fois ces actions effectuées, l’EDR remplacera les premiers octets à l’adresse de base de la fonction ciblée (responsable de l’exécution du syscall), par des octets correspondant à une instruction de saut (jmp) vers le code de sa propre DLL.

Ainsi, avant le hooking :

Après le hooking :

De cette manière, l’EDR est libre d’effectuer les tests de sécurité qu’il juge nécessaires et est capable de surveiller tout appel à des fonctions de l’API Windows.

Si on revient à l’Address Import Table (IAT), dont nous avons parlé précédemment dans cet article, un attaquant qui parviendrait à ne pas faire apparaître de fonction suspecte dans la IAT serait tout de même détecté par la technique de l’API hooking ! Cette méthode est donc très efficace contre les programmes malveillants.

Enfin, il peut arriver que certaines solutions de sécurité surveillent les connexions établies par la machine et bloquent une menace en fonction de certains indicateurs.

Par exemple, un blocage pourrait être décidé si un programme initie une connexion vers une adresse IP connue pour être associée à des serveurs malveillants. Cela permet de renforcer la sécurité en empêchant les logiciels malveillants de communiquer avec des serveurs dangereux.

Quelles sont les différentes techniques de contournement d’antivirus et d’EDR ?

Comme nous l’avons constaté, les solutions de sécurité disposent de nombreuses techniques pour détecter une activité malveillante. Dans cette seconde partie, nous explorerons différentes manières de contourner ces protections.

La détection par signature repose sur la signature numérique générée par une fonction de hachage sur un programme donné. Si nous modifions ne serait-ce qu’un seul bit de données de notre programme, sa signature sera complètement différente. Il est donc assez simple de contourner cette protection.

Par exemple, nous pouvons changer le nom d’une des variables de notre programme, ce qui entraînera une signature différente et évitera ainsi la détection basée sur cette signature.

Éviter la détection statique des logiciels malveillants n’est pas nécessairement techniquement complexe, mais cela peut demander du temps.

L’objectif est de modifier certains éléments qui pourraient être détectés, comme les noms de fonctions par exemple.

Prenons l’exemple suivant d’un programme en Go :

Nous pouvons compiler ce programme en utilisant la commande suivante : `$ go build helloWorld.go`.

Une fois cela fait, utilisons la commande strings sur Linux :

$ strings helloWorld | grep myHello
main.myHelloWorldFunc
main.myHelloWorldFunc

Comme nous pouvons le constater, le nom de notre fonction « myHelloWorldFunc » est clairement visible. Pour éviter cela, une solution serait de changer manuellement le nom de cette fonction. Cependant, sur un programme de taille conséquente, renommer chaque fonction prendrait du temps. Pour éviter cette perte de temps, nous pouvons chercher à automatiser cette tâche. En utilisant le langage Go, l’excellente bibliothèque « garble » permet d’obfusquer les binaires Go.

Reprenons notre programme « helloWorld » et utilisons cette fois « garble » pour le compiler :

Nous constatons que le nom de la fonction « myHelloWorldFunc » n’est plus visible en clair. En effet, garble vient effectuer les modifications suivantes.

Il remplace :

  • Autant d’identifiants que possible par des hashs.
  • Les chemins des packages par des hashs.
  • Les noms de fichiers et les informations de position par des hashs.

Et :

  • Supprime toutes les informations de construction et de module.
  • Supprime les informations de débogage et les tables de symboles via l’option -ldflags= »-w -s ».
  • Obfusque les littéraux, si le drapeau -literals est donné.

De plus, dans un loader destiné à charger un shellcode (une chaîne de caractères qui représente un code binaire exécutable, dans notre cas l’outil que nous souhaitons rendre indétectable préalablement mis sous forme de shellcode), il est crucial que ce shellcode ne soit pas clairement visible dans notre Portable Executable (PE).

Ainsi, nous pouvons chiffrer notre shellcode (par exemple, en utilisant AES).

Ce dernier ne sera déchiffré que lors de l’exécution de notre programme. De cette manière, une simple analyse statique de notre programme ne permettra pas de trouver notre shellcode en clair et de déclencher une alerte.

Pour contourner la détection heuristique et comportementale, plusieurs techniques sont disponibles. Une approche efficace pourrait consister à éviter l’analyse de loader dans un environnement de sandbox.

Pour ce faire, nous pourrions rechercher des signes indiquant que notre programme est exécuté dans un environnement sandbox.

Par exemple, un indicateur pourrait être de savoir si la machine sur laquelle notre programme s’exécute est dans un domaine Active Directory ou non.

Pour ce faire, nous pourrions utiliser le code suivant :

Dans ce code, nous utilisons d’abord la fonction « NetGetJoinInformation », qui retourne, entre autres, le paramètre « status ».

Ce dernier est structuré de la manière suivante :

La valeur de « NetSetupDomainName » peut nous renseigner sur l’appartenance ou non à un domaine de la machine sur laquelle nous nous trouvons.

Nous pourrions également effectuer des tests supplémentaires, tels que la vérification de la quantité de mémoire RAM, le nombre de cœurs CPU, l’espace disque disponible, ou la présence de pilotes de virtualisation comme « C:\Windows\System32\drivers\VBoxGuest.sys » ou « C:\Windows\System32\drivers\VBoxMouse.sys » :

Avec ce code, notre loader arrêtera son exécution normale s’il constate la présence de « C:\Windows\System32\drivers\VBoxMouse.sys » sur la machine. En plus des tests anti-sandbox, nous pouvons également ajouter des fonctionnalités bénignes à notre code. Cela ajoutera de la confusion concernant l’objectif réel de notre programme et diminuera sa ressemblance avec d’autres programmes malveillants.

Enfin, il est important de garder à l’esprit que les tests anti-sandbox sont à double tranchant : si nous cachons effectivement la véritable nature de notre programme, le fait d’effectuer ce genre de test peut déjà donner un indicateur sur la nature malveillante de celui-ci.

Notre loader étant en Go, nous chargeons les DLL et fonctions de la manière suivante :

Cette approche consiste à récupérer un handle sur la DLL pendant l’exécution. Ainsi, les fonctions et DLL utilisées ne sont pas répertoriées dans la IAT :

Nos fonctions ne sont pas présentes dans la IAT

Comme nous pouvons le constater, la fonction « VirtualAllocEx » ne figure pas dans la IAT malgré son utilisation dans le code. Une autre approche pourrait consister à recoder une implémentation de la fonction « GetProcAddress ».

Cependant, il est important de noter que la fonction « GetProcAddress » apparaît de toute façon dans la IAT pour les programmes en Go. Par conséquent, réimplémenter cette fonction ne serait pas forcément utile dans notre cas.

Nous avons précédemment vu que la DLL « amsi.dll » est chargée par certains programmes, et que ses fonctions, telles que « AmsiScanString » ou « AmsiScanBuffer » peuvent être utilisées pour vérifier le caractère suspect de certaines entrées.

C’est notamment le cas de PowerShell, qui utilise l’AMSI. Dans le scénario suivant, un attaquant a ouvert une console PowerShell et connaît le PID (par exemple « 4552 ») du processus correspondant. Explorons comment nous pourrions rendre l’AMSI inopérante en utilisant un code en Go.

Nous commençons par définir une variable pour le PID « 4552 », puis nous cherchons l’adresse de la fonction « AmsiScanString » :

Nous créons également à cette occasion la variable « patch » avec l’octet C3. En assembleur, C3 correspond à une instruction « ret », ce qui signifie une sortie de la procédure en cours.

Ensuite, nous récupérons un handle vers le processus PowerShell via son PID :

Puis nous utilisons la fonction « WriteProcessMemory » de la bibliothèque « sys/windows » :

De cette manière, à l’adresse de la fonction « AmsiScanBuffer » pour le processus de PID 4552, nous écrivons une instruction « ret ».

Ainsi, à chaque appel de cette fonction, une sortie de procédure sera immédiatement effectuée, désactivant de fait cette fonctionnalité de l’AMSI.

Après quelques modifications pour patcher les autres fonctions de l’AMSI, nous pouvons tester notre programme. Avant de l’avoir lancé, nous constatons que l’AMSI bloque une commande contenant la chaîne de caractères « amsiscanstring » :

Nous sommes bloqués par l’AMSI

Après avoir lancé notre programme, l’AMSI est patché et ne bloque plus les commandes contenant des chaînes de caractères potentiellement malveillantes :

L’AMSI est patchée

L’approche pour patcher l’ETW suit un schéma similaire à celui de l’AMSI. Les étapes impliquées sont les suivantes :

  1. Acquisition d’un handle vers notre propre processus.
  2. Identification des adresses des fonctions liées à l’ETW (comme dans cet exemple, « EtwEventWrite »).
  3. Pour chaque adresse, utilisation de la fonction « WriteProcessMemory » pour y ajouter l’instruction assembleur « ret ».

Cette technique déclenche un retour de procédure à chaque appel des fonctions patchées, ce qui empêche la remontée des données de journalisation vers l’ETW.

Dans cet exemple nous obtenons un handle vers le processus (ici avec le PID 9368) que nous souhaitons patcher pour l’ETW :

Nous récupérons l’adresse de la fonction « EtwEventWrite » et créons la variable « patch » avec l’octet C3, correspondant à l’instruction « ret » :

Enfin nous utilisons la fonction « WriteProcessMemory » pour patcher « EtwEventWrite » :

Avant d’appliquer le patch, nous pouvons observer que la fonction « EtwEventWrite » fonctionne normalement :

La fonction EtwEventWrite avant le patch

Après l’application du patch, il ne reste plus que l’instruction « ret », entraînant une sortie de procédure :

Nous avons donc réussi à patcher correctement la fonction « EtwEventWrite ». Cependant, en inspectant « ntdll.dll », nous constatons que « EtwEventWrite » peut éventuellement faire appel à « EtwEventWriteFull », qui à son tour peut faire appel à « NtTraceEvent » et effectuer un syscall :

Nous pouvons patcher différemment

Une autre méthode plus efficace pour patcher l’ETW consisterait donc à placer une instruction « ret » directement au niveau de « NtTraceEvent ».

De plus on notera que cette technique n’est pas fonctionnelle pour le cas spécifique de l’EtwTI (ETW Threat Intelligence provider), ce qui pourrait faire l’objet d’un prochain article.

Dans la première partie, nous avons exploré comment certaines solutions de sécurité peuvent effectuer un hooking sur certaines fonctions de l’API Windows afin d’analyser plus en profondeur le comportement des programmes.

Lorsque nous développons un programme, nous souhaitons éviter autant que possible cette analyse. Pour ce faire, nous cherchons à contourner ce hooking de fonction, car nous voulons utiliser les fonctions nécessaires à notre programme sans subir cette analyse.

Pour ce faire, il existe plusieurs méthodes, et nous présentons ici la technique des « Indirect Syscalls ». Cependant, il est essentiel de noter que cette technique n’est qu’une parmi d’autres pour contourner l’API hooking.

Avant d’expliquer ce que sont les « Indirect Syscalls », il est important de comprendre le principe des « Direct Syscalls ».

Comme mentionné précédemment, les fonctions de l’API Windows finissent par appeler des fonctions commençant par « Nt » (ou « Zw », pour des questions de simplifications, on considèrera dans cet article que « Nt » et « Zw » sont analogues) pour interagir avec le kernel de Windows.

Ces fonctions « Nt » peuvent réaliser des « syscalls », c’est à dire des appels système permettant d’appeler leurs homologues dans le noyau de Windows. Lorsqu’un nouveau processus démarre, la première DLL chargée est généralement « ntdll.dll », qui exporte la plupart des fonctions « Nt ».

Ensuite, un éventuel EDR peut charger sa propre DLL et hooker les fonctions exportées par « ntdll.dll », comme évoqué précédemment.

Le principe des « Direct Syscalls » est le suivant : au lieu de rechercher l’adresse de « ntdll.dll » (par exemple avec « GetModuleHandle ») puis l’adresse de la fonction « Nt » que nous voulons utiliser (par exemple avec « GetProcAddress »), nous allons directement implémenter le code assembleur correspondant à la fonction « Nt » souhaitée dans notre code.

Ainsi, nous n’avons plus besoin des fonctions de « ntdll.dll » pour effectuer des syscalls.

Cependant, il existe une difficulté dans la mise en œuvre de cette technique : chaque fonction responsable d’un syscall est associée à un SSN (Syscall Service Number). Ce SSN est transmis vers le noyau et lui permet d’identifier la fonction qu’il doit exécuter.

Une fonction « Nt » en assembleur respecte généralement la forme suivante :

Prenons l’exemple de « NtOpenProcess » :

SSN de NtOpenProcess

Ici, nous observons que le SSN de NtOpenProcess est « 26 » (la valeur « 0x26 » est placée dans le registre « eax » via l’instruction « mov eax, 26 » pour être passée comme argument vers le kernel).

La difficulté principale dans l’implémentation de notre propre code assembleur pour les fonctions « Nt » réside dans le fait que les SSN des différentes fonctions peuvent changer d’un système Windows à un autre. Inscrire de manière statique les différents SSN dans notre code est donc une option risquée, car nous pourrions rencontrer des problèmes de correspondance. Une solution plus intéressante consiste donc à calculer dynamiquement les SSN des différents syscalls.

Le danger des direct syscalls

Un problème avec l’utilisation des direct syscalls est que certaines instructions syscalls existent en dehors de « ntdll.dll », ce qui est un comportement peu courant.

Une solution de sécurité pourrait rechercher des syscalls en dehors de « ntdll.dll » et déclencher une alerte en cas d’occurrence. Une implémentation moins susceptible d’être détectée consiste à utiliser des « indirect syscalls ».

Les indirect syscalls fonctionnent de la même manière, à une différence près : au lieu d’effectuer le syscall directement depuis le code assembleur que nous implémentons, nous effectuons une instruction jump (jmp) vers l’adresse mémoire du syscall qui nous intéresse dans « ntdll.dll ».

Au lieu d’avoir :

Nous aurons :

Un autre avantage des indirect syscalls est que le SSN de notre syscall ayant été placé dans le registre « eax » avec l’instruction « mov eax, SSN », nous pouvons effectuer un saut (jmp) vers l’adresse de n’importe quel syscall dans « ntdll.dll ». Idéalement, nous utiliserons l’adresse d’un syscall appartenant à une fonction différente de celle que nous souhaitons véritablement utiliser.

Par exemple, si nous voulons faire un indirect syscall pour la fonction « NtOpenProcess » :

Adresse du syscall de NtOpenProcess

Notre instruction de saut peut ne pas pointer vers l’adresse du syscall de « NtOpenProcess (0x00007FFCD824D522) », mais plutôt vers le syscall de « NtAllocateVirtualMemory (0x00007FFCD824D362) » :

Adresse du syscall de NtAllocateVirtualMemory

Résoudre les SSN dynamiquement avec la technique Hell’s Gate

Comme nous l’avons vu, nous souhaitons idéalement pouvoir résoudre les SSN de manière dynamique. Avant d’expliquer le fonctionnement de « Hell’s Gate », il est important de préciser que les SSN associés à chaque syscall sont incrémentaux.

Pour être plus clair, prenons l’exemple ci-dessous :

Les SSN sont incrémentaux

Nous observons effectivement que le SSN de chaque fonction « Nt » (ou Zw) équivaut au SSN de la fonction précédente +1.

Expliquons maintenant le fonctionnement de Hell’s Gate. Cette technique fonctionne comme il suit :

  • Nous définissons deux structures :
    •  _VX_TABLE_ENTRY : contient une adresse de fonction, le hash du nom de la fonction correspondante, et son SSN.
    • _VX_TABLE : contient la liste de toutes les _VX_TABLE_ENTRY (une par fonction Nt).
  • Pour chaque nom de fonction « Nt », nous appliquons une fonction de hachage (djb2). La liste des correspondances « hash -> nom de fonction Nt » est conservée pour une utilisation ultérieure.
  • Nous accédons au TEB (thread environment block) via RtlGetThreadEnvironmentBlock, qui contient le PEB (process environment block).
  • À partir du PEB, nous pouvons accéder à l’EAT (export address table : liste de toutes les fonctions exportées) de « ntdll.dll ».
  • Pour chaque nom de fonction trouvé dans l’EAT de ntdll.dll, nous appliquons une fonction de hachage (djb2).
  • Nous comparons ces hashs avec ceux contenus dans notre liste de correspondances établie à la deuxième étape.
  • En cas de correspondance entre deux hashs, nous initialisons les éléments d’adresse et de nom haché pour une _VX_TABLE_ENTRY. Cette _VX_TABLE_ENTRY est ensuite ajoutée à la _VX_TABLE.
  • Pour chaque _VX_TABLE_ENTRY dans la _VX_TABLE, nous nous rendons à l’adresse de la fonction. Ensuite, nous recherchons la séquence d’octets : 0x4c, 0x8b, 0xd1, 0xb8, correspondant aux instructions « mov r10, rcx » et « mov eax, SSN ». Si cette séquence d’octets n’est pas trouvée (indiquant donc la présence probable d’un hook), nous passons à l’adresse suivante jusqu’à trouver le bon pattern. Une fois trouvé, nous initialisons le dernier élément SSN pour la _VX_TABLE_ENTRY actuelle. Si nous atteignons la séquence d’octets 0x4c, 0x8b, 0xd1, 0xb8 correspondant aux instructions « syscall » et « ret », cela signifie que nous avons passé le SSN sans le trouver. La résolution du SSN en question a donc échoué, et nous passons à la _VX_TABLE_ENTRY suivante.

Après avoir effectué ces opérations pour chaque _VX_TABLE_ENTRY, nous obtenons une table de correspondance « nom de fonction Nt -> SSN » que nous avons calculée dynamiquement.

Conclusion

En conclusion, il est important de noter que le domaine de l’évasion antivirus est en constante évolution.

Les techniques actuellement efficaces peuvent devenir obsolètes rapidement face aux avancées des solutions de sécurité. Le contournement des antivirus/EDR demeure complexe, nécessitant un niveau élevé de personnalisation et l’utilisation combinée de plusieurs techniques. Les stratégies discutées dans cet article ne représentent qu’une partie de l’éventail des méthodes disponibles et ne sont pas nécessairement les plus efficaces en 2024.

Des concepts et des techniques plus récentes pourraient être explorés dans des articles ultérieurs. Parmi ceux-ci, on peut citer :

  • Le principe de fonctionnement des Kernel callbacks.
  • Le patch de l’EtwTI, qui peut nécessiter l’exploitation d’un driver vulnérable (technique du BYOVD – Bring Your Own Vulnerable Driver) et le fonctionnement de Patchguard.
  • Des versions plus avancées pour résoudre dynamiquement les SSN, telles que HalosGate, évolution de Hell’s Gate.

Auteur : Arthur LE FAOU – Pentester @Vaadata