Vous resterez bien encore un peu : éviter l’exécution Fork&Run .NET avec InlineExecute-Assembly

Un homme regarde un écran d’ordinateur alors qu’il travaille tard le soir, en train d’écrire du code

Certains d’entre vous l’aiment et d’autres le détestent, mais à ce stade, il n’est pas surprenant que le .NET soit là pour durer un peu plus longtemps que prévu. Le cadre des exigences .NET fait partie intégrante du système d’exploitation de Microsoft, la version la plus récente de .NET étant .NET Core. Core est le successeur multiplateforme du cadre des exigences qui apporte .NET sur Linux et macOS. Le .NET n’a donc jamais été aussi populaire qu’aujourd’hui pour les échanges post-exploitation, aussi bien chez les adversaires que chez les red teams. Cet article s’intéressera à un nouveau fichier d’objets de balise (BOF) qui permet aux opérateurs d’exécuter des assemblages .NET en cours via Cobalt Strike, au lieu du module d’exécution et d’assemblage intégré traditionnel, qui utilise la technique fork and run.

Contexte

Cobalt Strike, un logiciel populaire de simulation d’adversaires, a reconnu la tendance des red teams à s’éloigner des outils PowerShell au profit de C# en raison de l’augmentation de la capacité de détection pour PowerShell. D’ailleurs, en 2018, la version 3.11 de Cobalt Strike a introduit le module execute-assembly. Cela a permis aux opérateurs de tirer parti de la puissance des assemblages .NET post-exploitation en les exécutant en mémoire sans risquer de laisser ces outils sur le disque. Bien que la capacité de charger des assemblages .NET en mémoire via du code non géré n’était ni nouvelle ni inconnue au moment de sa sortie, je dirais que Cobalt Strike a porté cette capacité au grand public et a contribué à continuer de renforcer la popularité de .NET pour les tâches de post-exploitation.

Le module execute-assembly de Cobalt Strike utilise la technique fork and run, qui consiste à créer un nouveau processus sacrificiel, à y injecter votre code malveillant de post-exploitation, à exécuter ce code et, une fois terminé, à tuer le nouveau processus. Cela présente à la fois ses avantages et ses inconvénients. L’avantage de la méthode fork and run est que l’exécution intervient en dehors de notre processus d’implantation d’une balise : ainsi, si un problème survient ou est détecté lors de notre tâche de post-exploitation, notre implantation a beaucoup plus de chances de survivre. Pour simplifier, cela contribue vraiment à la stabilité globale de l’implantation. Cependant, comme les fournisseurs de sécurité se sont aperçus de ce comportement de fork and run, cette technique a ajouté ce que Cobalt Strike admet être un modèle d’OPSEC coûteux.

Depuis la version 4.1 publiée en juin 2020, Cobalt Strike a introduit une nouvelle fonctionnalité pour tenter de résoudre ce problème : les fichiers d’objets de balise (BOF). Les BOF permettent aux opérateurs d’éviter les schémas d’exécution bien connus décrits ci-dessus ou d’autres défaillances OPSEC telles que l’utilisation de cmd.exe/powershell.exe en exécutant des fichiers d’objets en mémoire au sein du même processus que notre implantation de balise. Je ne vais pas présenter le fonctionnement interne des BOF, mais voici quelques articles de blog que j’ai trouvés intéressants :

Si vous lisez les articles ci-dessus, nous devrions comprendre que les BOF n’étaient pas vraiment la solution salvatrice que nous espérions. Si vous rêviez de réécrire tous ces outils .NET géniaux et de les transformer en BOF, vos rêves sont désormais anéantis. Désolé. Tout espoir n’est cependant pas perdu, car à mon avis, les BOF peuvent offrir de superbes résultats. D’ailleurs, je me suis récemment amusé (et un peu tiré les cheveux) à repousser les limites de ce qui peut être fait avec eux. Tout d’abord, j’ai créé CredBandit qui effectue un vidage complet de la mémoire d’un processus tel que LSASS et le renvoie via votre canal de communication de balise existant. Aujourd’hui, je publie InlineExecute-Assembly, qui peut être utilisé pour exécuter des assemblages .NET dans votre processus de balise sans modification de votre outil .NET favori. Voyons pourquoi j’ai écrit le BOF, certaines de ses principales fonctionnalités, ses mises en garde et comment il peut être utile lors de simulations d’adversaires et de red teams.

Les dernières actualités technologiques, étayées par des avis d’experts

Restez au fait des tendances les plus étonnantes du secteur dans le domaine de l’IA, de l’automatisation, des données et bien d’autres avec la newsletter Think. Consultez la Déclaration de confidentialité d’IBM.

Merci ! Vous êtes abonné(e).

Vous recevrez votre abonnement en anglais. Vous trouverez un lien de désabonnement dans chaque newsletter. Vous pouvez gérer vos abonnements ou vous désabonner ici. Consultez la Déclaration de confidentialité d’IBM pour plus d’informations.

Pourquoi InlineExecute-Assembly ?

La raison derrière la création d’InlineExecute-Assembly est assez simple. Je voulais trouver un moyen pour notre équipe de simulation d’adversaires d’exécuter des assemblages .NET en cours de traitement afin d’éviter certains des pièges OPSEC évoqués plus haut lors de l’utilisation de Cobalt Strike dans des environnements matures. J’avais également besoin de cet outil pour ne pas imposer à notre équipe plus de temps de développement en apportant des modifications à la plupart de nos outils .NET actuels. Il devait aussi être stable, aussi stable que puisse être un BOF complexe, car la dernière chose que nous voulons, c’est perdre l’une de nos rares balises dans l’environnement. Il devait donc fonctionner aussi facilement que possible pour l’opérateur et pour le module execute-assembly de Cobalt Strike.

Mixture of Experts | 12 décembre, épisode 85

Décryptage de l’IA : Tour d’horizon hebdomadaire

Rejoignez notre panel d’ingénieurs, de chercheurs, de chefs de produits et autres spécialistes de premier plan pour connaître l’essentiel de l’actualité et des dernières tendances dans le domaine de l’IA.

Fonctions principales

Chargement du Common Language Runtime (CLR)

Je sais, c’est assez évident. Nous n’irions pas très loin sans elle. Blague à part, les subtilités du fonctionnement du CLR et de ce qui se passe en coulisses peuvent faire l’objet d’un article de blog. Nous allons donc passer en revue ce que le BOF utilise à un très haut niveau lorsqu’il charge le CLR via du code non géré.

Capture d’écran Chargement du CLR

Chargement du CLR

Comme le montre la capture d’écran simplifiée ci-dessus, les principales étapes que le BOF va suivre pour charger le CLR sont les suivantes :

  1. Effectue un appel vers CLRCreateInstance qui sera utilisé pour récupérer notre interface ICLRMetaHost.
  2. ICLRMETAHost -> GetRuntime est ensuite utilisé pour obtenir les informations d’exécution pour la version de. NET que nous demandons. Si votre assemblage a été créé avec la version 3.5 ou antérieure de .NET, nous demanderons la version v2.0.50727. Si votre assemblage a été créé avec .NET 4.0 ou une version ultérieure, nous demanderons la version v4.0.30319. Il existe une fonction dans le BOF qui nous aidera à déterminer quelle version notre assemblage .NET utilise automatiquement, mais nous en parlerons plus tard.
  3. Une fois que nous disposons des informations relatives à l’exécution, nous utilisons ICLRRuntimeInfo->IsLoadable pour vérifier si l’exécution peut être chargée dans le processus. Cette opération prend également en compte le fait que d’autres exécutions peuvent déjà être chargées et définiront notre valeur BOOL fLoadable sur 1 (true) si notre exécution peut être chargée dans le processus.
  4. Si tout est correct, nous exécuterons ensuite ICLRRuntimeInfo->GetInterface pour charger le CLR dans notre processus et récupérer une interface vers ICorRunTimeHost.
  5. Enfin, nous appellerons ICorRuntimeHost->Start, ce qui lancera le CLR.

Le CLR est donc initialisé, mais il reste encore quelques étapes à franchir avant de pouvoir exécuter nos assemblages .NET préférés. Nous devons créer notre instance AppDomain, ce que Microsoft décrit comme « un environnement isolé dans lequel les applications s’exécutent ». En d’autres termes, ils seront utilisés pour charger et exécuter nos assemblages .NET post-exploitation.

Capture d’écran : AppDomain en cours de création et assemblage en cours de chargement/exécution

Création d’AppDomain et chargement/exécution de l’assemblage

Comme le montre la capture d’écran simplifiée ci-dessus, les principales étapes du BOF pour charger et invoquer notre assemblage .NET sont les suivantes :

  1. Utiliser ICorRuntimeHost->CreateDomain pour créer notre AppDomain unique
  2. Utiliser IUnknown->QueryInterface (pAppDomainThunk) pour obtenir un pointeur vers l’interface AppDomain
  3. Créer notre SafeArray et y copier les octets de notre assemblage .NET
  4. Charger notre assemblage via AppDomain->Load_3
  5. Obtenir notre point d’entrée dans notre assemblage via Assembly->EntryPoint
  6. Invoquer notre assemblage via MethodInfo->Invoke_3

J’espère que vous comprenez désormais l’exécution de .NET avec du code non géré. Toutefois, cela ne nous permet toujours pas d’avoir un outil opérationnellement performant. Nous allons donc examiner quelques fonctionnalités qui ont été implémentées dans le BOF pour le faire passer de moyen à fantastique.

Rediriger le STDOUT de la console vers le pipeline ou l’emplacement de messagerie nommé : éviter la modification des outils

Vous vous demandez probablement pourquoi c’est important. Si vous êtes comme moi et que votre temps vous importe, vous ne devriez pas le passer à modifier presque tous les assemblages .NET pour que son point d’entrée renvoie une chaîne avec toutes vos données qui seraient normalement acheminées vers une sortie standard de console, pas vrai ? C’est bien ce que je pensais. Pour éviter cela, nous devons rediriger notre sortie standard vers un pipeline nommé ou un emplacement de messagerie, lire la sortie après qu’elle ait été écrite, puis la ramener à son état d’origine. De cette manière, nous pouvons exécuter nos assemblages non modifiés comme nous le ferions depuis cmd.exe ou powershell.exe. Avant de passer en revue le code, je dois remercier @N4k3dTurtl3 et son article de blog sur l’assemblage en cours d’exécution et les emplacements de messagerie. C’est ce qui m’a poussé à mettre en œuvre cette technique dans ma propre implantation C privée lorsqu’elle est sortie pour la première fois et, plusieurs mois plus tard, j’ai porté cette même fonctionnalité sur un BOF. Maintenant que les propriétés ont été attribuées, observons un exemple simplifié de la manière dont cela serait réalisé en redirigeant le stdout vers un pipeline nommé ci-dessous :

Capture d’écran : Redirection de la sortie standard de la console vers le pipeline nommé et retour en arrière

Rediriger la sortie standard de la console vers le pipeline nommé et revenir en arrière

Déterminer la version .NET de l’assemblage

Rappelez-vous que lorsque nous avons chargé le CLR via ICLRMetaHost -> GetRuntime, nous devions spécifier la version du cadre des exigences .NET dont nous avions besoin. Vous vous souvenez que cela dépend de la version avec laquelle notre assemblage .NET a été compilé ? Ce ne serait pas vraiment amusant de devoir spécifier manuellement la version requise à chaque fois. Heureusement pour nous, @b4rtik a implémenté une fonction intéressante pour gérer cela dans son module execute-assembly pour cadre des exigences Metasploit, que nous pouvons facilement intégrer dans nos propres outils présentés ci-dessous :

Capture d’écran : Fonction qui lit notre assemblage .NET et aide à déterminer quelle version .NET nous devons utiliser lors du chargement du CLR

Fonction qui lit notre assemblage .NET et aide à déterminer quelle version .NET nous devons utiliser lors du chargement du CLR

Essentiellement, cette fonction fait que lorsqu’elle est transmise à nos octets d’assemblage, elle lira ces octets et recherchera les valeurs hexagone de 76 34 2E 30 2E 33 30 33 31 39, qui lorsqu’elle est convertie en ASCII est v4.0.30319. J’espère que cela vous semble familier. Si cette valeur est trouvée lors de la lecture de l’assemblage, la fonction renvoie 1 ou true, et si elle n’est pas trouvée, elle renvoie 0 ou false. Nous pouvons l’utiliser pour déterminer facilement la version à charger selon que 1/true ou 0/false revient, comme le montre l’exemple de code ci-dessous :

Capture d’écran : Instruction if/else pour définir la variable de version .NET

Argument if/else pour définir la variable de version .NET

Application d’un correctif à l’Antimalware Scan Interface (AMSI)

Nous ne pouvons pas parler de l’ingénierie offensive .NET sans parler de l’AMSI. Même si nous n’entrerons pas dans les détails de ce qu’est l’AMSI et toutes les façons dont elle peut être contournée, car cela a été abordé à maintes reprises, nous allons expliquer pourquoi il peut être nécessaire de corriger l’AMSI selon ce que vous décidez d’exécuter via le BOF. Par exemple, si vous décidez d’exécuter Seatbelt sans aucune obfuscation, vous remarquerez rapidement que vous n’avez reçu aucune sortie en retour et que votre balise est morte. Oui, morte morte. En effet, l’AMSI a détecté votre assemblage, a déterminé qu’il était malveillant et vous a stoppé net, comme une soirée un peu trop bruyante. Ce n’est pas l’idéal. Nous avons deux bonnes options pour AMSI : soit nous pouvons obscurcir nos outils .NET via un élément comme ConfuserX ou Invisibility Cloak, soit désactiver AMSI en utilisant différentes techniques. Dans notre cas, nous utiliserons un élément de RastaMouse, qui sert à corriger le fichier amsi.dll en mémoire pour qu’il renvoie E_INVALIDARG et renvoie un résultat d’analyse de 0. Comme indiqué dans l’article de blog, il est généralement interprété comme AMSI_RESULT_CLEAN. Examinons ci-dessous une version simplifiée du code pour un processus x64 :

Capture d’écran : Application de correctifs en mémoire d’AmsiScanBuffer

Correction d’AmsiScanBuffer

Comme vous pouvez le voir dans la capture d’écran ci-dessus, nous procédons simplement comme suit :

  1. Charger amsi.dll et obtenir un pointeur vers AmsiScanBuffer
  2. Modifier la protection de la mémoire
  3. Appliquer un correctif dans nos octets amsiPatch[]
  4. Faire revenir la protection de la mémoire à son état d’origine

En l’implémentant dans notre outil, nous devrions désormais être en mesure d’exécuter la version par défaut de Seatbelt.exe en utilisant l’indicateur –amsi pour contourner la détection AMSI, comme indiqué ci-dessous :

Capture d’écran : Exemple de contournement de l’AMSI par InlineExecute-Assemby

Exemple de contournement AMSI InlineExecute-Assembly

Application d’un correctif à l’Event Tracing for Windows (ETW)

Heureusement pour les défenseurs, il n’y a pas qu’AMSI pour détecter des tâches .NET malveillantes en utilisant ETW. Malheureusement, comme AMSI, il est assez facile pour les adversaires de le contourner. @xpn a d’ailleurs fait des recherches vraiment impressionnantes sur la façon dont cela pourrait être fait. Voici un exemple simplifié de la manière dont vous pourriez appliquer des correctifs ETW pour le désactiver complètement :

Capture d’écran : Correction dans la mémoire d’EtwEventWrite

Correction d’EtwEventWrite

Comme vous pouvez le voir sur la capture d’écran ci-dessus, les étapes sont quasiment identiques à celles que nous avons suivies pour corriger l’AMSI. Je ne vais donc pas revenir sur les étapes pour celui-ci. Vous pouvez voir une capture d’écran avant-après montrant l’utilisation de l’indicateur –etw ci-dessous :

Capture d’écran : Utiliser Process Hacker pour consulter les propriétés de PowerShell.exe avant de lancer InlineExecute-Assembly avec l’indicateur –etw

Utiliser Process Hacker pour consulter les propriétés de PowerShell.exe avant de lancer InlineExecute-Assembly avec l’indicateur –etw

Capture d’écran : Exécution de InlineExecute-Assembly avec l’indicateur –etw

Exécuter InlineExecute-Assembly à l’aide de l’indicateur –etw

Capture d’écran : Utilisation de Process Hacker pour afficher les mêmes propriétés de PowerShell.exe après l’exécution de InlineExecute-Assembly

Utiliser Process Hacker pour afficher les mêmes propriétés de PowerShell.exe après avoir exécuté InlineExecute-Assembly

AppDomaines uniques, Named Pipes, Mail Slots

Par défaut, l’AppDomain, le Named Pipe ou le Mail Slot créés utilisent la valeur par défaut « totesLegit ». Ces valeurs peuvent être modifiées pour mieux s’intégrer dans l’environnement que vous testez, soit en modifiant le script d’agression fourni, soit via des indicateurs de ligne de commande à la volée. Vous trouverez ci-dessous un exemple de modification via la ligne de commande :

Capture d’écran du terminal montrant l’exécution de la commande InlineExecute-Assembly --dotnetassembly /root/Desktop/MessageBoxCS.exe dans la balise. La sortie inclut des messages d’état : exécution d’InlineExecute-Assembly, appel de l’hébergeur (a envoyé 16 319 octets), sortie reçue « Hello From .NET ! » et le message de fin « InlineExecute-Assembly Finish ».

Exemple de InlineExecute-Assembly utilisant un nom unique AppDomain et un nom unique de pipeline nommé

Capture d’écran : Exemple de nom unique AppDomain ChangedMe

Exemple unique de nom AppDomain ChangedMe

Capture d’écran : Exemple LookAtMe de pipeline nommé unique

Exemple de pipeline LookAtMe nommé unique

Capture d’écran : Exemple de suppression d’AppDomain une fois l’exécution terminée

Exemple de suppression d’AppDomain une fois l’exécution terminée

Capture d’écran : Exemple de suppression d’un pipeline nommé après une exécution réussie

Exemple de suppression du pipeline nommé après l’exécution terminée

Mises en garde

Cette section sera en quelque sorte une répétition de ce que j’explique dans le référentiel GitHub, mais j’ai pensé qu’il était important de rappeler quelques éléments à garder à l’esprit lorsque vous utilisez cet outil :

  1. Bien que j’ai essayé de le rendre aussi stable que possible, il n’y a aucune garantie que les choses ne tomberont jamais en panne et que les balises ne se désactiveront pas. Nous ne disposons pas du luxe supplémentaire de la technique fork and run qui permet à notre balise de rester en vie en cas de problème. C’est le compromis avec les BOF. Cela dit, je ne saurais trop insister sur l’importance de tester vos assemblages à l’avance pour vous assurer qu’ils fonctionneront correctement.
  2. Étant donné que le BOF est exécuté en processus et prend en charge votre balise en cours d’exécution, cela doit être pris en compte avant d’être utilisé pour des assemblages de longue durée. Si vous choisissez d’exécuter un élément qui prendra beaucoup de temps à renvoyer des résultats, votre balise ne sera pas active pour exécuter d’autres commandes avant que les résultats reviennent et que votre assemblage ait fini de s’exécuter. Cela ne respecte pas non plus le temps de pause. Par exemple, si votre délai de pause est réglé sur 10 minutes et que vous exécutez le BOF, vous obtiendrez les résultats dès que le BOF aura terminé son exécution.
  3. À moins que des modifications ne soient apportées aux outils qui chargent les PE en mémoire (par exemple, SafetyKatz), ceux-ci tueront très probablement votre balise. Beaucoup de ces outils fonctionnent bien avec l’execute-assembly parce qu’ils sont capables d’envoyer leur sortie de console depuis le processus sacrifié avant de quitter. Quand ils sortent via notre BOF en cours, ils suppriment notre processus, ce qui tue notre balise. Ils peuvent être modifiés, mais je vous conseille d’exécuter ces types d’assemblages via un assemblage d’exécution, car d’autres éléments non compatibles OPSEC pourraient être chargés dans votre processus et ne pas être supprimés.
  4. Si votre assemblage utilise Environment.Exit, cette fonction devra être supprimée car elle interrompt le processus et la balise.
  5. Les noms des pipelines et des emplacements de messagerie doivent être uniques. Si vous ne recevez pas de données en retour et que votre balise est toujours en vie, il est fort probable que vous deviez sélectionner un autre nom de pipeline ou d’emplacement de messagerie.

Considérations défensives

Voici quelques considérations défensives :

  1. Cette fonction utilise PAGE_EXECUTE_READWRITE lors de l’application des correctifs de mémoire AMSI et ETW. Cela a été fait exprès et devrait être un signal d’alarme, car très peu de programmes disposent de plages mémoire avec la protection mémoire de PAGE_EXECUTE_READWRITE.
  2. Le nom par défaut du pipeline nommé créé est « totesLegit ». Cela a été fait exprès et les détections de signatures peuvent être utilisées pour le signaler.
  3. Le nom par défaut de l’emplacement de messagerie créé est « totesLegit ». Cela a été fait exprès et les détections de signatures peuvent être utilisées pour le signaler.
  4. Le nom par défaut de l’AppDomain chargé est « totesLegit ». Cela a été fait exprès et les détections de signatures peuvent être utilisées pour le signaler.
  5. De bons conseils pour détecter l’utilisation malveillante de .NET (par @bohops) ici, (par F-Secure) ici et ici
  6. Recherche d’un CLR .NET chargé dans des processus suspects, tels que des processus non gérés sur lesquels le CLR ne devrait jamais être chargé.
  7. En savoir plus sur la traçabilité des événements.
  8. Recherche d’autres IOC de balise Cobalt Strike ou des IOC de sortie/communication C2 connus.