Elastic Security Labs

WinVisor - Émulateur basé sur un hyperviseur pour les exécutables en mode utilisateur Windows x64

Un émulateur basé sur l'hyperviseur pour les binaires Windows x64.

25 minutes de lecturePerspectives
WinVisor – Un émulateur basé sur un hyperviseur pour les exécutables en mode utilisateur Windows x64

Arrière-plan

Dans Windows 10 (version RS4), Microsoft a introduit l'API Windows Hypervisor Platform (WHP). Cette API expose la fonctionnalité de l'hyperviseur intégré de Microsoft aux applications Windows en mode utilisateur. En 2024, l'auteur a utilisé cette API pour créer un projet personnel : un émulateur MS-DOS 16 bits appelé DOSVisor. Comme indiqué dans les notes de mise à jour, il a toujours été prévu de pousser ce concept plus loin et de l'utiliser pour émuler des applications Windows. Elastic organise deux fois par an une semaine de recherche (ON Week) pour permettre au personnel de travailler sur des projets personnels, ce qui constitue une excellente occasion de commencer à travailler sur ce projet. Ce projet s'appellera (sans imagination) WinVisor, inspiré par son prédécesseur DOSVisor.

Les hyperviseurs offrent une virtualisation au niveau du matériel, ce qui élimine la nécessité d'émuler l'unité centrale par le biais d'un logiciel. Cela garantit que les instructions sont exécutées exactement comme elles le seraient sur un processeur physique, alors que les émulateurs logiciels se comportent souvent de manière incohérente dans les cas extrêmes.

Ce projet vise à construire un environnement virtuel pour l'exécution de binaires Windows x64, permettant d'enregistrer (ou d'accrocher) les appels de système et d'introspecter la mémoire. L'objectif de ce projet n'est pas de construire un bac à sable complet et sécurisé - par défaut, tous les appels système seront simplement enregistrés et transmis directement à l'hôte. Dans sa forme initiale, il sera trivial pour le code s'exécutant dans l'invité virtualisé de s'échapper "" vers l'hôte. La sécurisation d'un bac à sable est une tâche difficile qui dépasse le cadre de ce projet. Les limitations seront décrites plus en détail à la fin de l'article.

Bien qu'elle soit disponible depuis 6 ans (au moment de la rédaction), il semble que l'API WHP n'ait pas été utilisée dans de nombreux projets publics autres que des bases de code complexes telles que QEMU et VirtualBox. Un autre projet notable est Simpleator d'Alex Ionescu - un émulateur léger du mode utilisateur de Windows qui utilise également l'API WHP. Ce projet a en grande partie les mêmes objectifs que WinVisor, bien que l'approche de la mise en œuvre soit très différente. Le projet WinVisor vise à automatiser autant que possible et à prendre en charge des exécutables simples (par ex. ping.exe) de manière universelle.

Cet article traite de la conception générale du projet, de certains problèmes rencontrés et de la manière dont ils ont été résolus. Certaines fonctionnalités seront limitées en raison des contraintes de temps liées au développement, mais le produit final sera au moins une preuve de concept utilisable. Des liens vers le code source et les binaires hébergés sur GitHub seront fournis à la fin de l'article.

Les bases de l'hyperviseur

Les hyperviseurs sont alimentés par les extensions VT-x (Intel) et AMD-V (AMD). Ces structures assistées par le matériel permettent la virtualisation en permettant à une ou plusieurs machines virtuelles de fonctionner sur une seule unité centrale physique. Ces extensions utilisent des jeux d'instructions différents et ne sont donc pas intrinsèquement compatibles les unes avec les autres ; un code distinct doit être écrit pour chacune d'entre elles.

En interne, Hyper-V utilise hvix64.exe pour la prise en charge d'Intel et hvax64.exe pour la prise en charge d'AMD. L'API WHP de Microsoft fait abstraction de ces différences matérielles, ce qui permet aux applications de créer et de gérer des partitions virtuelles quel que soit le type de processeur sous-jacent. Par souci de simplicité, l'explication qui suit se concentre uniquement sur le VT-x.

VT-x ajoute un jeu d'instructions supplémentaires appelé VMX (Virtual Machine Extensions), qui contient des instructions telles que VMLAUNCH, qui lance l'exécution d'une VM pour la première fois, et VMRESUME, qui réintègre la VM après l'avoir quittée. Une sortie de VM se produit lorsque certaines conditions sont déclenchées par l'invité, telles que des instructions spécifiques, des accès aux ports d'E/S, des défauts de page et d'autres exceptions.

Au cœur de VMX se trouve la structure de contrôle de la machine virtuelle (VMCS), une structure de données par VM qui stocke l'état des contextes hôte et invité ainsi que des informations sur l'environnement d'exécution. Le VMCS contient des champs qui définissent l'état du processeur, les configurations de contrôle et les conditions facultatives qui déclenchent des transitions de l'invité vers l'hôte. Les instructions VMREAD et VMWRITE permettent de lire ou d'écrire dans les champs VMCS.

Lors de la sortie d'une VM, le processeur enregistre l'état de l'invité dans le VMCS et revient à l'état de l'hôte pour l'intervention de l'hyperviseur.

Aperçu de WinVisor

Ce projet tire parti de la nature de haut niveau de l'API du WHP. L'API expose la fonctionnalité de l'hyperviseur au mode utilisateur et permet aux applications de mapper la mémoire virtuelle du processus hôte directement dans la mémoire physique de l'invité.

L'unité centrale virtuelle fonctionne presque exclusivement en CPL3 (mode utilisateur), à l'exception d'un petit chargeur de démarrage qui s'exécute en CPL0 (mode noyau) pour initialiser l'état de l'unité centrale avant l'exécution. Ce point sera décrit plus en détail dans la section consacrée à l'unité centrale virtuelle.

La construction de l'espace mémoire pour un environnement invité émulé implique le mappage de l'exécutable cible et de toutes les dépendances DLL, suivi du remplissage d'autres structures de données internes telles que le bloc d'environnement de processus (PEB), le bloc d'environnement de threads (TEB), KUSER_SHARED_DATA, etc.

La mise en correspondance des dépendances EXE et DLL est simple, mais le maintien précis des structures internes, telles que le PEB, est une tâche plus complexe. Ces structures sont volumineuses, pour la plupart non documentées, et leur contenu peut varier d'une version de Windows à l'autre. Il serait relativement simple de remplir un ensemble minimaliste de champs pour exécuter une simple application "Hello World", mais une approche améliorée devrait être adoptée pour assurer une bonne compatibilité.

Au lieu de créer manuellement un environnement virtuel, WinVisor lance une instance suspendue du processus cible et clone l'ensemble de l'espace d'adressage dans l'invité. Les répertoires de données IAT (Import Address Table) et TLS (Thread Local Storage) sont temporairement supprimés des en-têtes PE en mémoire afin d'empêcher le chargement des dépendances DLL et l'exécution des rappels TLS avant d'atteindre le point d'entrée. Le processus est ensuite repris, permettant à l'initialisation habituelle du processus de se poursuivre (LdrpInitializeProcess) jusqu'à ce qu'il atteigne le point d'entrée de l'exécutable cible, à partir duquel l'hyperviseur est lancé et prend le contrôle. Cela signifie essentiellement que Windows a fait tout le travail à notre place et que nous disposons maintenant d'un espace d'adressage en mode utilisateur prérempli pour l'exécutable cible qui est prêt à être exécuté.

Un nouveau thread est alors créé dans un état suspendu, avec l'adresse de départ pointant vers l'adresse d'une fonction de chargement personnalisée. Cette fonction remplit l'IAT, exécute les rappels TLS et enfin exécute le point d'entrée original de l'application cible. Cela simule essentiellement ce que le thread principal ferait si le processus était exécuté de manière native. Le contexte de ce thread est alors "cloné" dans le processeur virtuel, et l'exécution commence sous le contrôle de l'hyperviseur.

La mémoire est paginée dans l'invité si nécessaire, et les appels de service sont interceptés, enregistrés et transmis au système d'exploitation hôte jusqu'à ce que le processus cible virtualisé se termine.

Comme l'API WHP n'autorise que le mappage de la mémoire du processus en cours dans l'invité, la logique principale de l'hyperviseur est encapsulée dans une DLL qui est injectée dans le processus cible.

Unité centrale virtuelle

" L'API WHP fournit une enveloppe "conviviale autour de la fonctionnalité VMX décrite précédemment, ce qui signifie que les étapes habituelles, telles que le remplissage manuel du VMCS avant l'exécution de VMLAUNCH, ne sont plus nécessaires. Il expose également la fonctionnalité au mode utilisateur, ce qui signifie qu'un pilote personnalisé n'est pas nécessaire. Cependant, l'unité centrale virtuelle doit toujours être initialisée de manière appropriée via le WHP avant d'exécuter le code cible. Les aspects importants sont décrits ci-dessous.

Registres de contrôle

Seuls les registres de contrôle CR0, CR3 et CR4 sont pertinents pour ce projet. CR0 et CR4 sont utilisés pour activer les options de configuration de l'unité centrale telles que le mode protégé, la pagination et le PAE. CR3 contient l'adresse physique de la table de pagination PML4, qui sera décrite plus en détail dans la section sur la pagination en mémoire.

Registres spécifiques au modèle

Les registres spécifiques au modèle (MSR) doivent également être initialisés pour garantir le bon fonctionnement de l'unité centrale virtuelle. MSR_EFER contient des drapeaux pour des fonctions étendues, telles que l'activation du mode long (64 bits) et des instructions SYSCALL. MSR_LSTAR contient l'adresse du gestionnaire de syscall, et MSR_STAR contient les sélecteurs de segment pour la transition vers CPL0 (et le retour à CPL3) pendant les syscalls. MSR_KERNEL_GS_BASE contient l'adresse de base de l'ombre du sélecteur GS.

Tableau des descripteurs globaux

La table des descripteurs globaux (GDT) définit les descripteurs de segments, qui décrivent essentiellement les régions de mémoire et leurs propriétés pour une utilisation en mode protégé.

En mode long, le GDT n'a qu'une utilité limitée et n'est qu'une relique du passé - x64 fonctionne toujours en mode mémoire plate, ce qui signifie que tous les sélecteurs sont basés sur 0. Les seules exceptions à cette règle sont les registres FS et GS, qui sont utilisés à des fins spécifiques aux threads. Même dans ces cas, leurs adresses de base ne sont pas définies par le GDT. Au lieu de cela, les MSR (tels que MSR_KERNEL_GS_BASE décrit ci-dessus) sont utilisés pour stocker l'adresse de base.

Malgré cette obsolescence, le GDT reste un élément important du modèle x64. Par exemple, le niveau de privilège actuel est défini par le sélecteur CS (segment de code).

Segment d'état de la tâche

En mode long, le segment d'état de la tâche (TSS) est simplement utilisé pour charger le pointeur de pile lors de la transition d'un niveau de privilège inférieur à un niveau supérieur. Comme cet émulateur fonctionne presque exclusivement en CPL3, à l'exception du chargeur de démarrage initial et des gestionnaires d'interruption, une seule page est allouée à la pile CPL0. Le TSS est stocké en tant qu'entrée de système spéciale dans le GDT et occupe deux emplacements.

Tableau des descripteurs d'interruptions

La table des descripteurs d'interruption (IDT) contient des informations sur chaque type d'interruption, telles que les adresses des gestionnaires. Ce point sera décrit plus en détail dans la section sur la gestion des interruptions.

Chargeur de démarrage

La plupart des champs de l'unité centrale mentionnés ci-dessus peuvent être initialisés à l'aide des fonctions d'enveloppe du WHP, mais la prise en charge de certains champs (par ex. XCR0) n'est apparu que dans les versions ultérieures de l'API WHP (Windows 10 RS5). Pour être complet, le projet comprend un petit "bootloader", qui s'exécute au CPL0 lors du démarrage et initialise manuellement les dernières parties de l'unité centrale avant d'exécuter le code cible. Contrairement à une unité centrale physique, qui démarre en mode réel 16 bits, l'unité centrale virtuelle a déjà été initialisée pour fonctionner en mode long (64 bits), ce qui rend le processus de démarrage un peu plus simple.

Les étapes suivantes sont effectuées par le chargeur de démarrage :

  1. Chargez le GDT à l'aide de l'instruction LGDT. L'opérande source de cette instruction spécifie un bloc de mémoire de 10 octets qui contient l'adresse de base et la limite (taille) de la table qui a été remplie précédemment.

  2. Chargez l'IDT à l'aide de l'instruction LIDT. L'opérande source de cette instruction utilise le même format que le LGDT décrit ci-dessus.

  3. Placez l'index du sélecteur TSS dans le registre des tâches à l'aide de l'instruction LTR. Comme mentionné ci-dessus, le descripteur TSS existe en tant qu'entrée spéciale dans le GDT (à 0x40 dans ce cas).

  4. Le registre XCR0 peut être défini à l'aide de l'instruction XSETBV. Il s'agit d'un registre de contrôle supplémentaire utilisé pour des fonctions optionnelles telles que l'AVX. Le processus natif exécute XGETBV pour obtenir la valeur de l'hôte, qui est ensuite copiée dans l'invité via XSETBV dans le chargeur de démarrage.

Il s'agit d'une étape importante car les dépendances DLL qui ont déjà été chargées peuvent avoir défini des drapeaux globaux au cours de leur processus d'initialisation. Par exemple, ucrtbase.dll vérifie si le CPU supporte AVX via l'instruction CPUID au démarrage et, si c'est le cas, active un drapeau global pour permettre au CRT d'utiliser les instructions AVX pour des raisons d'optimisation. Si l'unité centrale virtuelle tente d'exécuter ces instructions AVX sans les activer explicitement dans XCR0 d'abord, une exception d'instruction non définie sera soulevée.

  1. Mettez manuellement à jour les sélecteurs de segments de données DS, ES et GS en les remplaçant par leurs équivalents CPL3 (0x2B). Exécutez l'instruction SWAPGS pour charger l'adresse de base du TEB à partir de MSR_KERNEL_GS_BASE.

  2. Enfin, utilisez l'instruction SYSRET pour passer au CPL3. Avant l'instruction SYSRET, RCX est mis à une adresse de remplacement (point d'entrée CPL3), et R11 est mis à la valeur initiale CPL3 RFLAGS (0x202). L'instruction SYSRET fait automatiquement basculer les sélecteurs de segments CS et SS sur leurs équivalents CPL3 de MSR_STAR.

Lorsque l'instruction SYSRET s'exécute, un défaut de page se produit en raison de l'adresse invalide de l'espace réservé dans RIP. L'émulateur détectera ce défaut de page et le reconnaîtra comme une adresse "spéciale". Les valeurs initiales du registre CPL3 seront alors copiées dans l'unité centrale virtuelle, RIP est mis à jour pour pointer vers une fonction de chargement en mode utilisateur personnalisée, et l'exécution reprend. Cette fonction charge toutes les dépendances DLL pour l'exécutable cible, remplit la table IAT, exécute les rappels TLS, puis exécute le point d'entrée original. La table d'importation et les rappels TLS sont traités à ce stade, plutôt qu'à un stade antérieur, afin de garantir l'exécution de leur code dans l'environnement virtualisé.

Pagination de la mémoire

Toute la gestion de la mémoire de l'invité doit être effectuée manuellement. Cela signifie qu'une table de pagination doit être remplie et maintenue, permettant à l'unité centrale virtuelle de traduire une adresse virtuelle en une adresse physique.

Traduction d'adresses virtuelles

Pour ceux qui ne sont pas familiers avec la pagination en x64, la table de pagination a quatre niveaux : PML4, PDPT, PD et PT. Pour une adresse virtuelle donnée, l'unité centrale parcourt chaque couche de la table, pour finalement atteindre l'adresse physique cible. Les processeurs modernes prennent également en charge la pagination à 5 niveaux (au cas où les 256 To de mémoire adressable offerts par la pagination à 4 niveaux ne suffiraient pas), mais cela n'a pas d'importance dans le cadre de ce projet.

L'image suivante illustre le format d'un exemple d'adresse virtuelle :

Dans l'exemple ci-dessus, l'unité centrale calcule la page physique correspondant à l'adresse virtuelle 0x7FFB7D030D10 à l'aide des entrées de table suivantes : PML4[0xFF] -> PDPT[0x1ED] -> PD[0x1E8] -> PT[0x30]. Enfin, le décalage (0xD10) sera ajouté à cette page physique pour calculer l'adresse exacte.

Les bits 48 - 63 d'une adresse virtuelle sont inutilisés dans la pagination à 4 niveaux et sont essentiellement étendus en signe pour correspondre au bit 47.

Le registre de contrôle CR3 contient l'adresse physique du tableau de base PML4. Lorsque la pagination est activée (obligatoire en mode long), toutes les autres adresses dans le contexte de l'unité centrale font référence à des adresses virtuelles.

Défauts de page

Lorsque l'invité tente d'accéder à la mémoire, l'unité centrale virtuelle lève une exception de défaut de page si la page demandée n'est pas déjà présente dans la table de pagination. Cela déclenchera un événement de sortie de la VM et redonnera le contrôle à l'hôte. Dans ce cas, le registre de contrôle CR2 contient l'adresse virtuelle demandée, bien que l'API WHP fournisse déjà cette valeur dans les données du contexte de sortie de la VM. L'hôte peut alors mapper la page demandée dans la mémoire (si possible) et reprendre l'exécution ou lancer une erreur si l'adresse cible n'est pas valide.

Mise en miroir de la mémoire hôte/invité

Comme indiqué précédemment, l'émulateur crée un processus enfant, et toute la mémoire virtuelle de ce processus sera mappée directement dans l'invité en utilisant la même disposition d'adresses. L'API de la plate-forme de l'hyperviseur nous permet de mapper la mémoire virtuelle du processus hôte en mode utilisateur directement dans la mémoire physique de l'invité. La table de pagination associe alors les adresses virtuelles aux pages physiques correspondantes.

Au lieu de mapper l'ensemble de l'espace d'adressage du processus en amont, un nombre fixe de pages physiques est alloué à l'invité. L'émulateur contient un gestionnaire de mémoire très basique, et les pages sont mappées "à la demande." Lorsqu'un défaut de page se produit, la page demandée est mise en page et l'exécution reprend. Si tous les emplacements de la page "" sont occupés, l'entrée la plus ancienne est remplacée par la nouvelle.

Outre l'utilisation d'un nombre fixe de pages actuellement mappées, l'émulateur utilise également une table de pages de taille fixe. La taille de la table des pages est déterminée en calculant le nombre maximal possible de tables pour le nombre d'entrées de pages mappées. Ce modèle permet d'obtenir un agencement simple et cohérent de la mémoire physique, mais au détriment de l'efficacité. En fait, les tables de pagination occupent plus d'espace que les entrées de page proprement dites.

Il existe une seule table PML4 et, dans le pire des cas, chaque entrée de page mappée fera référence à des tables PDPT/PD/PT uniques. Comme chaque table représente 4096 octets, la taille totale de la table des pages peut être calculée à l'aide de la formule suivante :

PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)

Par défaut, l'émulateur permet de mapper 256 pages à la fois (1024KB au total). En utilisant la formule ci-dessus, nous pouvons calculer qu'il faudra 3076KB pour la table de pagination, comme illustré ci-dessous :

Dans la pratique, de nombreuses entrées de la table des pages seront partagées et une grande partie de l'espace alloué aux tables de pagination restera inutilisée. Cependant, comme cet émulateur fonctionne bien même avec un petit nombre de pages, ce niveau de surcharge n'est pas un problème majeur.

L'unité centrale maintient un cache au niveau matériel pour la table de pagination, appelé Translation Lookaside Buffer (TLB). Lors de la conversion d'une adresse virtuelle en adresse physique, l'unité centrale vérifie d'abord la TLB. Si une entrée correspondante n'est pas trouvée dans le cache (on parle alors de "TLB miss"), les tables de pagination sont lues à la place. C'est pourquoi il est important de vider le cache TLB chaque fois que les tables de pagination ont été reconstruites afin d'éviter toute désynchronisation. La manière la plus simple de vider l'ensemble de la TLB est de réinitialiser la valeur du registre CR3.

Traitement des appels de service

Lorsque le programme cible s'exécute, tous les appels système qui se produisent au sein de l'invité doivent être gérés par l'hôte. Cet émulateur gère à la fois les instructions SYSCALL et les anciens appels de service (basés sur les interruptions). SYSENTER n'est pas utilisé en mode long et n'est donc pas supporté par WinVisor.

Appel de service rapide (SYSCALL)

Lorsqu'une instruction SYSCALL est exécutée, le CPU passe en CPL0 et charge RIP à partir de MSR_LSTAR. Dans le noyau Windows, il s'agit de KiSystemCall64. Les instructions SYSCALL ne déclenchent pas intrinsèquement un événement de sortie de la VM, mais l'émulateur place MSR_LSTAR à une adresse de remplacement réservée - 0xFFFF800000000000 dans ce cas. Lorsqu'une instruction SYSCALL est exécutée, un défaut de page est déclenché lorsque RIP est défini à cette adresse, et l'appel peut être intercepté. Cet espace réservé est une adresse du noyau dans Windows et n'entraînera aucun conflit avec l'espace d'adressage du mode utilisateur.

Contrairement aux anciens syscalls, l'instruction SYSCALL n'échange pas la valeur RSP pendant la transition vers CPL0, de sorte que le pointeur de pile en mode utilisateur peut être récupéré directement à partir de RSP.

Appels de service hérités (INT 2E)

Les anciens appels de service basés sur les interruptions sont plus lents et ont plus de frais généraux que l'instruction SYSCALL, mais malgré cela, ils sont toujours pris en charge par Windows. Comme l'émulateur contient déjà un cadre pour la gestion des interruptions, il est très simple d'ajouter la prise en charge des appels syscall hérités. Lorsqu'une interruption de syscall patrimonial est détectée, elle peut être transmise au gestionnaire de syscall "commun" après quelques traductions mineures - en particulier, la récupération de la valeur RSP stockée en mode utilisateur sur la pile CPL0.

Renvoi d'appel

Une fois que l'émulateur a créé le thread principal "" dont le contexte est cloné dans l'unité centrale virtuelle, ce thread natif est réutilisé comme proxy pour transmettre les appels de système à l'hôte. La réutilisation du même thread maintient la cohérence du TEB et de tout état du noyau entre l'invité et l'hôte. Win32k, en particulier, s'appuie sur de nombreux états spécifiques aux threads, qui doivent être reflétés dans l'émulateur.

Lorsqu'un appel de système se produit, soit par une instruction SYSCALL, soit par une interruption héritée, l'émulateur l'intercepte et le transfère à une fonction de gestion universelle. Le numéro du syscall est stocké dans le registre RAX et les quatre premières valeurs des paramètres sont stockées respectivement dans R10, RDX, R8 et R9. R10 est utilisé pour le premier paramètre au lieu du registre RCX habituel car l'instruction SYSCALL écrase RCX avec l'adresse de retour. L'ancien gestionnaire de syscall de Windows (KiSystemService) utilise également R10 pour des raisons de compatibilité, de sorte qu'il n'a pas besoin d'être traité différemment dans l'émulateur. Les autres paramètres sont récupérés sur la pile.

Nous ne connaissons pas le nombre exact de paramètres attendus pour un numéro de syscall donné, mais heureusement, cela n'a pas d'importance. Nous pouvons simplement utiliser une quantité fixe, et tant que le nombre de paramètres fournis est supérieur ou égal au nombre réel, l'appel de système fonctionnera correctement. Un simple stub d'assemblage sera créé dynamiquement, remplissant tous les paramètres, exécutant le syscall cible et retournant proprement.

Les tests ont montré que le nombre maximum de paramètres actuellement utilisés par les appels syscall de Windows est 17 (NtAccessCheckByTypeResultListAndAuditAlarmByHandle, NtCreateTokenEx et NtUserCreateWindowEx). WinVisor utilise 32 comme nombre maximal de paramètres pour permettre une éventuelle expansion future.

Après avoir exécuté le syscall sur l'hôte, la valeur de retour est copiée sur RAX dans l'invité. RIP est ensuite transféré vers une instruction SYSRET (ou IRETQ pour les anciens appels syscall) avant de reprendre l'unité centrale virtuelle pour une transition transparente vers le mode utilisateur.

Journalisation des appels système

Par défaut, l'émulateur transmet simplement les appels système de l'invité à l'hôte et les enregistre dans la console. Cependant, quelques étapes supplémentaires sont nécessaires pour convertir les appels de service bruts en un format lisible.

La première étape consiste à convertir le numéro syscall en un nom. Les numéros de syscall sont composés de plusieurs parties : les bits 12 - 13 contiennent l'index de la table de service du système (0 pour ntoskrnl, 1 pour win32k), et les bits 0 - 11 contiennent l'index du syscall dans la table. Cette information nous permet d'effectuer une recherche inverse dans le module correspondant du mode utilisateur (ntdll / win32u) afin de résoudre le nom de l'appel de service d'origine.

L'étape suivante consiste à déterminer le nombre de valeurs de paramètres à afficher pour chaque appel de service. Comme indiqué ci-dessus, l'émulateur transmet 32 valeurs de paramètres à chaque syscall, même si la plupart d'entre eux ne sont pas utilisés. Cependant, l'enregistrement de toutes les valeurs 32 pour chaque appel système ne serait pas idéal pour des raisons de lisibilité. Par exemple, un simple appel NtClose(0x100) sera imprimé sous la forme NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...). Comme indiqué précédemment, il n'existe pas de moyen simple de déterminer automatiquement le nombre exact de paramètres pour chaque appel système, mais il existe une astuce qui permet de l'estimer avec une grande précision.

Cette astuce repose sur les bibliothèques système 32 bits utilisées par WoW64. Ces bibliothèques utilisent la convention d'appel stdcall, ce qui signifie que l'appelant place tous les paramètres sur la pile, et qu'ils sont nettoyés en interne par l'appelant avant de revenir. En revanche, le code natif x64 place les premiers 4 paramètres dans des registres, et l'appelant est responsable de la gestion de la pile.

Par exemple, la fonction NtClose de la version WoW64 de ntdll.dll se termine par l'instruction RET 4. Cela permet de retirer 4 octets supplémentaires de la pile après l'adresse de retour, ce qui implique que la fonction prend un paramètre. Si la fonction utilise RET 8, cela signifie qu'elle prend 2 paramètres, et ainsi de suite.

Même si l'émulateur fonctionne comme un processus 64 bits, nous pouvons toujours charger les copies 32 bits de ntdll.dll et win32u.dll dans la mémoire - soit manuellement, soit en les mappant à l'aide de SEC_IMAGE. Une version personnalisée de GetProcAddress doit être écrite pour résoudre les adresses d'exportation WoW64, mais il s'agit d'une tâche triviale. À partir de là, nous pouvons trouver automatiquement l'exportation WoW64 correspondante pour chaque appel de système, rechercher l'instruction RET pour calculer le nombre de paramètres et stocker la valeur dans une table de recherche.

Cette méthode n'est pas parfaite et peut échouer de plusieurs manières :

  • Un petit nombre d'appels syscall natifs n'existent pas dans WoW64, comme NtUserSetWindowLongPtr.
  • Si une fonction 32 bits contient un paramètre 64 bits, elle sera divisée en deux paramètres 32 bits en interne, alors que la fonction 64 bits correspondante ne nécessiterait qu'un seul paramètre pour la même valeur.
  • Les fonctions du stub syscall WoW64 dans Windows pourraient être modifiées de telle sorte que la recherche de l'instruction RET existante échoue.

Malgré ces écueils, les résultats seront exacts pour la grande majorité des appels syscall sans avoir à se fier à des valeurs codées en dur. En outre, ces valeurs ne sont utilisées qu'à des fins de journalisation et n'affecteront rien d'autre, de sorte que des inexactitudes mineures sont acceptables dans ce contexte. Si une défaillance est détectée, il revient à l'affichage du nombre maximum de valeurs de paramètres.

Accrochage de Syscall

Si ce projet était utilisé à des fins de "sandboxing", il ne serait pas souhaitable, pour des raisons évidentes, de transmettre aveuglément tous les appels syscals à l'hôte. L'émulateur contient un cadre qui permet d'accrocher facilement des appels de système spécifiques si nécessaire.

Par défaut, seuls NtTerminateThread et NtTerminateProcess sont accrochés pour attraper la sortie du processus invité.

Gestion des interruptions

Les interruptions sont définies par l'IDT, qui est rempli avant le début de l'exécution de l'unité centrale virtuelle. Lorsqu'une interruption se produit, l'état actuel du CPU est placé sur la pile CPL0 (SS, RSP, RFLAGS, CS, RIP), et RIP est défini sur la fonction de gestion cible.

Comme pour MSR_LSTAR pour le gestionnaire SYSCALL, l'émulateur remplit toutes les adresses de gestionnaire d'interruption avec des valeurs de remplacement (0xFFFFA00000000000 - 0xFFFFA000000000FF). Lorsqu'une interruption se produit, un défaut de page se produit dans cette plage, que nous pouvons attraper. L'index d'interruption peut être extrait des 8 bits les plus bas de l'adresse cible (par exemple, 0xFFFFA00000000003 est INT 3), et l'hôte peut le traiter si nécessaire.

Actuellement, l'émulateur ne gère que INT 1 (single-step), INT 3 (breakpoint) et INT 2E (legacy syscall). Si une autre interruption est détectée, l'émulateur sort avec une erreur.

Lorsqu'une interruption a été traitée, RIP est transféré à une instruction IRETQ, qui revient proprement en mode utilisateur. Certains types d'interruptions placent une valeur supplémentaire du code d'erreur "" sur la pile - si c'est le cas, elle doit être retirée avant l'instruction IRETQ pour éviter une corruption de la pile. Le cadre de gestion des interruptions de cet émulateur contient un drapeau optionnel pour gérer cela de manière transparente.

Bogue de la page partagée de l'hyperviseur

Windows 10 a introduit un nouveau type de page partagée qui se trouve à proximité de KUSER_SHARED_DATA. Cette page est utilisée par les fonctions liées au chronométrage telles que RtlQueryPerformanceCounter et RtlGetMultiTimePrecise.

L'adresse exacte de cette page peut être récupérée avec NtQuerySystemInformation, en utilisant la classe d'information SystemHypervisorSharedPageInformation. La fonction LdrpInitializeProcess enregistre l'adresse de cette page dans une variable globale (RtlpHypervisorSharedUserVa) lors du démarrage du processus.

L'API WHP semble contenir un bogue qui provoque le blocage de la fonction WHvRunVirtualProcessor dans une boucle infinie si cette page partagée est mappée dans l'invité et que l'unité centrale virtuelle tente de la lire.

Des contraintes de temps ont limité la possibilité de mener une enquête approfondie sur ce point ; toutefois, une solution de contournement simple a été mise en œuvre. L'émulateur corrige la fonction NtQuerySystemInformation dans le processus cible et la force à renvoyer STATUS_INVALID_INFO_CLASS pour les demandes SystemHypervisorSharedPageInformation. Le code ntdll doit alors se rabattre sur les méthodes traditionnelles.

Démonstrations

Vous trouverez ci-dessous quelques exemples d'exécutables Windows courants émulés dans cet environnement virtualisé :

Limites

L'émulateur présente plusieurs limites qui font qu'il n'est pas possible de l'utiliser comme bac à sable sécurisé dans sa forme actuelle.

Questions de sécurité

Il existe plusieurs façons d'échapper à "" la VM, par exemple en créant simplement un nouveau processus/thread, en planifiant des appels de procédure asynchrones (APC), etc.

Les syscalls liés à l'interface graphique de Windows peuvent également effectuer des appels imbriqués directement en mode utilisateur à partir du noyau, ce qui contournerait actuellement la couche de l'hyperviseur. C'est pourquoi les exécutables GUI tels que notepad.exe ne sont que partiellement virtualisés lorsqu'ils sont exécutés sous WinVisor.

Pour le démontrer, WinVisor inclut un commutateur de ligne de commande -nx dans l'émulateur. Cela force l'image EXE cible entière à être marquée comme non exécutable dans la mémoire avant le démarrage de l'unité centrale virtuelle, ce qui provoque un plantage du processus si le processus hôte tente d'exécuter nativement une partie du code. Cependant, il n'est toujours pas sûr de s'y fier : l'application cible peut rendre la région à nouveau exécutable ou simplement allouer de la mémoire exécutable ailleurs.

Lorsque la DLL WinVisor est injectée dans le processus cible, elle existe dans le même espace d'adressage virtuel que l'exécutable cible. Cela signifie que le code exécuté sous l'unité centrale virtuelle est en mesure d'accéder directement à la mémoire du module de l'hyperviseur hôte, ce qui pourrait potentiellement la corrompre.

Mémoire d'invité non exécutable

Alors que le processeur virtuel est configuré pour prendre en charge NX, toutes les régions de mémoire sont actuellement mises en miroir dans l'invité avec un accès RWX complet.

Uniquement en mode monotâche

L'émulateur ne prend actuellement en charge que la virtualisation d'un seul thread. Si l'exécutable cible crée des threads supplémentaires, ils seront exécutés de manière native. Pour prendre en charge plusieurs threads, un pseudo-scheduler pourrait être développé à l'avenir.

Le chargeur parallèle de Windows est désactivé afin de garantir que toutes les dépendances du module sont chargées par un seul thread.

Exceptions logicielles

Les exceptions logicielles virtualisées ne sont pas prises en charge actuellement. Si une exception se produit, le système appellera la fonction KiUserExceptionDispatcher de manière native, comme d'habitude.

Conclusion

Comme nous l'avons vu plus haut, l'émulateur fonctionne bien avec un large éventail d'exécutables dans sa forme actuelle. Bien qu'il soit actuellement efficace pour enregistrer les appels syscall et les interruptions, il faudrait encore beaucoup de travail pour qu'il puisse être utilisé en toute sécurité à des fins d'analyse des logiciels malveillants. Malgré cela, le projet fournit un cadre efficace pour le développement futur.

Liens vers les projets

https://github.com/x86matthew/WinVisor

Vous pouvez trouver l'auteur sur X à l'adresse @x86matthew.

Partager cet article