Hintergrund
In Windows 10 (Version RS4) hat Microsoft die Windows Hypervisor Platform (WHP) API eingeführt. Diese API macht die integrierte Hypervisorfunktionalität von Microsoft für Windows-Anwendungen im Benutzermodus verfügbar. Im Jahr 2024 nutzte der Autor diese API, um ein persönliches Projekt zu erstellen: einen 16-Bit-MS-DOS-Emulator namens DOSVisor. Wie in den Release Notes erwähnt, gab es immer Pläne, dieses Konzept weiterzuentwickeln und es zur Emulation von Windows-Anwendungen zu verwenden. Elastic bietet zweimal im Jahr eine Forschungswoche (ON Week) für Mitarbeiter an, um an persönlichen Projekten zu arbeiten, was eine großartige Gelegenheit bietet, mit der Arbeit an diesem Projekt zu beginnen. Dieses Projekt wird (einfallslos) WinVisor heißen, inspiriert von seinem Vorgänger DOSVisor.
Hypervisoren bieten Virtualisierung auf Hardwareebene, sodass die CPU nicht mehr über Software emuliert werden muss. Dadurch wird sichergestellt, dass Anweisungen genau so ausgeführt werden, wie sie auf einer physischen CPU ausgeführt würden, während sich softwarebasierte Emulatoren in Grenzfällen oft inkonsistent verhalten.
Dieses Projekt zielt darauf ab, eine virtuelle Umgebung zum Ausführen von Windows x64-Binärdateien zu erstellen, die das Protokollieren (oder Einbinden) von Systemaufrufen ermöglicht und die Introspektion des Speichers ermöglicht. Das Ziel dieses Projekts ist es nicht, eine umfassende und sichere Sandbox aufzubauen - standardmäßig werden alle Systemaufrufe einfach protokolliert und direkt an den Host weitergeleitet. In seiner ursprünglichen Form ist es trivial, dass Code, der auf dem virtualisierten Gast ausgeführt wird, auf den Host "escapet". Die sichere Sicherung einer Sandbox ist eine schwierige Aufgabe und geht über den Rahmen dieses Projekts hinaus. Die Einschränkungen werden am Ende des Artikels noch näher beschrieben.
Obwohl die WHP-API seit 6 Jahren (zum Zeitpunkt des Schreibens) verfügbar ist, scheint es, dass sie nicht in vielen öffentlichen Projekten verwendet wurde, außer in komplexen Codebasen wie QEMU und VirtualBox. Ein weiteres bemerkenswertes Projekt ist Alex Ionescus Simpleator - ein leichtgewichtiger Windows-Emulator im Benutzermodus, der auch die WHP-API verwendet. Dieses Projekt hat viele der gleichen Ziele wie WinVisor, obwohl der Ansatz für die Implementierung ganz anders ist. Das WinVisor-Projekt zielt darauf ab, so viel wie möglich zu automatisieren und einfache ausführbare Dateien (z. ping.exe) universell out of the box.
In diesem Artikel wird das allgemeine Design des Projekts, einige der aufgetretenen Probleme und deren Behebung behandelt. Einige Funktionen werden aufgrund von Einschränkungen der Entwicklungszeit begrenzt sein, aber das Endprodukt wird zumindest ein brauchbarer Proof-of-Concept sein. Links zum Quellcode und zu den Binärdateien, die auf GitHub gehostet werden, finden Sie am Ende des Artikels.
Grundlagen des Hypervisors
Hypervisoren werden von VT-x (Intel) und AMD-V (AMD) Erweiterungen unterstützt. Diese hardwaregestützten Frameworks ermöglichen die Virtualisierung, indem sie die Ausführung eines oder mehrerer virtueller Computer auf einer einzigen physischen CPU ermöglichen. Diese Erweiterungen verwenden unterschiedliche Befehlssätze und sind daher nicht von Natur aus miteinander kompatibel. Für jeden muss separater Code geschrieben werden.
Intern verwendet Hyper-V hvix64.exe für den Intel-Support und hvax64.exe für den AMD-Support. Die WHP-API von Microsoft abstrahiert diese Hardwareunterschiede und ermöglicht es Anwendungen, virtuelle Partitionen unabhängig vom zugrunde liegenden CPU-Typ zu erstellen und zu verwalten. Der Einfachheit halber konzentriert sich die folgende Erklärung ausschließlich auf VT-x.
VT-x fügt einen zusätzlichen Satz von Anweisungen hinzu, der als VMX (Virtual Machine Extensions) bezeichnet wird und Anweisungen wie VMLAUNCHenthält, die die Ausführung einer VM zum ersten Mal starten, und VMRESUME, die nach einem VM-Beenden wieder in die VM eintritt. Ein VM-Exit tritt auf, wenn bestimmte Bedingungen vom Gast ausgelöst werden, z. B. bestimmte Anweisungen, E/A-Portzugriff, Seitenfehler und andere Ausnahmen.
Im Mittelpunkt von VMX steht die Virtual Machine Control Structure (VMCS), eine Datenstruktur pro VM, die den Status des Gast- und Hostkontexts sowie Informationen über die Ausführungsumgebung speichert. Das VMCS enthält Felder, die den Prozessorstatus, Steuerungskonfigurationen und optionale Bedingungen definieren, die Übergänge vom Gast zurück zum Host auslösen. VMCS-Felder können mit den Anweisungen VMREAD und VMWRITE gelesen oder geschrieben werden.
Während eines VM-Beendens speichert der Prozessor den Gaststatus im VMCS und wechselt zurück in den Hoststatus, damit der Hypervisor eingreifen kann.
Übersicht über WinVisor
Dieses Projekt nutzt die Vorteile der High-Level-Natur der WHP-API. Die API macht die Hypervisorfunktionalität für den Benutzermodus verfügbar und ermöglicht es Anwendungen, den virtuellen Speicher vom Hostprozess direkt dem physischen Speicher des Gasts zuzuordnen.
Die virtuelle CPU arbeitet fast ausschließlich im CPL3 (User-Mode), mit Ausnahme eines kleinen Bootloaders, der auf CPL0 (Kernel-Modus) läuft, um den CPU-Zustand vor der Ausführung zu initialisieren. Dies wird im Abschnitt Virtuelle CPU näher beschrieben.
Das Einrichten des Speicherplatzes für eine emulierte Gastumgebung umfasst das Zuordnen der ausführbaren Zieldatei und aller DLL-Abhängigkeiten, gefolgt von der Auffüllung anderer interner Datenstrukturen wie dem Process Environment Block (PEB), dem Thread Environment Block (TEB), KUSER_SHARED_DATAusw.
Das Zuordnen der EXE- und DLL-Abhängigkeiten ist einfach, aber die genaue Pflege interner Strukturen, wie z. B. der PEB, ist eine komplexere Aufgabe. Diese Strukturen sind groß, meist nicht dokumentiert, und ihr Inhalt kann je nach Windows-Version variieren. Es wäre relativ einfach, einen minimalistischen Satz von Feldern aufzufüllen, um eine einfache "Hello World"-Anwendung auszuführen, aber es sollte ein verbesserter Ansatz gewählt werden, um eine gute Kompatibilität zu gewährleisten.
Anstatt manuell eine virtuelle Umgebung aufzubauen, startet WinVisor eine angehaltene Instanz des Zielprozesses und klont den gesamten Adressraum in den Gast. Die Datenverzeichnisse Import Address Table (IAT) und Thread Local Storage (TLS) werden vorübergehend aus den PE-Headern im Arbeitsspeicher entfernt, um das Laden von DLL-Abhängigkeiten zu verhindern und zu verhindern, dass TLS-Callbacks ausgeführt werden, bevor der Einstiegspunkt erreicht wird. Der Prozess wird dann fortgesetzt, sodass die übliche Prozessinitialisierung fortgesetzt werden kann (LdrpInitializeProcess), bis er den Einstiegspunkt der ausführbaren Zieldatei erreicht, woraufhin der Hypervisor gestartet wird und die Kontrolle übernimmt. Dies bedeutet im Wesentlichen, dass Windows die ganze harte Arbeit für uns erledigt hat und wir jetzt über einen vorab ausgefüllten Adressraum im Benutzermodus für die ausführbare Zieldatei verfügen, der zur Ausführung bereit ist.
Anschließend wird ein neuer Thread in einem angehaltenen Zustand erstellt, wobei die Startadresse auf die Adresse einer benutzerdefinierten Ladefunktion verweist. Diese Funktion füllt den IAT auf, führt TLS-Callbacks aus und führt schließlich den ursprünglichen Einstiegspunkt der Zielanwendung aus. Dies simuliert im Wesentlichen, was der Hauptthread tun würde, wenn der Prozess nativ ausgeführt würde. Der Kontext dieses Threads wird dann in die virtuelle CPU "geklont", und die Ausführung beginnt unter der Kontrolle des Hypervisors.
Der Arbeitsspeicher wird bei Bedarf in den Gast ausgelagert, und Systemaufrufe werden abgefangen, protokolliert und an das Hostbetriebssystem weitergeleitet, bis der virtualisierte Zielprozess beendet wird.
Da die WHP-API nur die Zuordnung von Speicher aus dem aktuellen Prozess zum Gast zulässt, ist die Hauptlogik des Hypervisors in einer DLL gekapselt, die in den Zielprozess injiziert wird.
Virtuelle CPU
Die WHP-API bietet einen "benutzerfreundlichen" Wrapper um die zuvor beschriebene VMX-Funktionalität, was bedeutet, dass die üblichen Schritte, wie z. B. das manuelle Ausfüllen des VMCS vor dem Ausführen VMLAUNCH, nicht mehr erforderlich sind. Außerdem wird die Funktionalität im Benutzermodus verfügbar gemacht, was bedeutet, dass kein benutzerdefinierter Treiber erforderlich ist. Die virtuelle CPU muss jedoch weiterhin ordnungsgemäß über HHP initialisiert werden, bevor der Zielcode ausgeführt wird. Die wichtigen Aspekte werden im Folgenden beschrieben.
Kontrollregister
Nur die Steuerregister CR0, CR3und CR4 sind für dieses Projekt relevant. CR0 und CR4 werden verwendet, um CPU-Konfigurationsoptionen wie geschützten Modus, Auslagerung und PAE zu aktivieren. CR3 enthält die physische Adresse der PML4 Auslagerungstabelle, die im Abschnitt Memory Paging ausführlicher beschrieben wird.
Modellspezifische Register
Modellspezifische Register (MSRs) müssen ebenfalls initialisiert werden, um den korrekten Betrieb der virtuellen CPU sicherzustellen. MSR_EFER enthält Flags für erweiterte Funktionen, z. B. das Aktivieren des langen Modus (64 Bit) und das SYSCALL von Anweisungen. MSR_LSTAR enthält die Adresse des Syscall-Handlers, und MSR_STAR enthält die Segmentselektoren für den Übergang zu CPL0 (und zurück zu CPL3) während Syscalls. MSR_KERNEL_GS_BASE enthält die Schattenbasisadresse des GS Selektors.
Globale Deskriptortabelle
Die Global Descriptor Table (GDT) definiert die Segmentdeskriptoren, die im Wesentlichen Speicherbereiche und deren Eigenschaften für die Verwendung im geschützten Modus beschreiben.
Im Long-Modus ist der GDT nur begrenzt nutzbar und größtenteils ein Relikt der Vergangenheit - x64 arbeitet immer in einem Flat-Memory-Modus, was bedeutet, dass alle Selektoren auf 0basieren. Die einzigen Ausnahmen hiervon sind die Register FS und GS , die für threadspezifische Zwecke verwendet werden. Auch in diesen Fällen werden ihre Basisadressen nicht durch die GDT definiert. Stattdessen werden MSRs (wie MSR_KERNEL_GS_BASE oben beschrieben) verwendet, um die Basisadresse zu speichern.
Trotz dieser Obsoleszenz ist die GDT immer noch ein wichtiger Bestandteil des x64-Modells. Die aktuelle Berechtigungsstufe wird z. B. durch den Selektor CS (Codesegment) definiert.
Segment des Aufgabenzustands
Im Long-Modus wird das Task State Segment (TSS) einfach verwendet, um den Stack-Zeiger zu laden, wenn Sie von einer niedrigeren Berechtigungsstufe zu einer höheren wechseln. Da dieser Emulator mit Ausnahme des anfänglichen Bootloaders und der Interrupt-Handler fast ausschließlich in CPL3 arbeitet, wird dem CPL0-Stack nur eine einzige Seite zugeordnet. Die TSE ist als spezieller Systemeintrag innerhalb des GDT hinterlegt und belegt zwei Steckplätze.
Interrupt-Deskriptortabelle
Die Interrupt-Deskriptor-Tabelle (IDT) enthält Informationen zu jedem Interrupt-Typ, z. B. die Handler-Adressen. Dies wird im Abschnitt Interrupt-Behandlung ausführlicher beschrieben.
Bootloader
Die meisten der oben genannten CPU-Felder können mit WHP-Wrapper-Funktionen initialisiert werden, aber die Unterstützung bestimmter Felder (z. XCR0) wurde erst in späteren Versionen der WHP-API (Windows 10 RS5) eingeführt. Der Vollständigkeit halber enthält das Projekt einen kleinen "Bootloader", der beim Start mit CPL0 läuft und die letzten Teile der CPU manuell initialisiert, bevor der Zielcode ausgeführt wird. Im Gegensatz zu einer physischen CPU, die im 16-Bit-Realmodus gestartet wird, wurde die virtuelle CPU bereits für die Ausführung im Long-Modus (64-Bit) initialisiert, wodurch der Bootvorgang etwas einfacher wird.
Die folgenden Schritte werden vom Bootloader ausgeführt:
-
Laden Sie das GDT mit der
LGDTAnweisung. Der Quelloperand für diese Anweisung gibt einen 10-Byte-Speicherblock an, der die Basisadresse und das Limit (Größe) der Tabelle enthält, die zuvor gefüllt wurde. -
Laden Sie das IDT mit der
LIDTAnweisung. Der Quelloperand für diese Anweisung verwendet das gleiche Format wie LGDT, das oben beschrieben wurde. -
Setzen Sie den TSS-Selektorindex mit der Anweisung
LTRin das Aufgabenregister. Wie oben erwähnt, existiert der TSS-Deskriptor als spezieller Eintrag innerhalb der GDT (in diesem Fall bei0x40). -
Das XCR0-Register kann mit dem Befehl
XSETBVgesetzt werden. Dabei handelt es sich um ein zusätzliches Steuerregister, das für optionale Funktionen wie z.B. AVX verwendet wird. Der native Prozess führt XGETBV aus, um den Hostwert abzurufen, der dann überXSETBVim Bootloader in den Gast kopiert wird.
Dies ist ein wichtiger Schritt, da DLL-Abhängigkeiten, die bereits geladen wurden, während des Initialisierungsprozesses möglicherweise globale Flags festgelegt haben. So prüft ucrtbase.dll z.B. beim Start, ob die CPU AVX über die CPUID Anweisung unterstützt und setzt in diesem Fall ein globales Flag, damit die CRT AVX-Befehle aus Optimierungsgründen verwenden kann. Wenn die virtuelle CPU versucht, diese AVX-Befehle auszuführen, ohne sie zuvor explizit in XCR0 aktiviert zu haben, wird eine nicht definierte Befehlsausnahme ausgelöst.
-
Aktualisieren Sie
DS,ESundGSDatensegmentselektoren manuell auf ihre CPL3-Entsprechungen (0x2B). Führen Sie die AnweisungSWAPGSaus, um die TEB-Basisadresse ausMSR_KERNEL_GS_BASEzu laden. -
Verwenden Sie abschließend die Anweisung
SYSRET, um in CPL3 zu wechseln. Vor derSYSRETAnweisung wirdRCXauf eine Platzhalteradresse (CPL3-Einstiegspunkt) festgelegt, undR11wird auf den anfänglichen CPL3 RFLAGS-Wert (0x202) gesetzt. Die AnweisungSYSRETschaltet die SegmentselektorenCSundSSautomatisch auf ihre CPL3-Äquivalente vonMSR_STARum.
Wenn die SYSRET Anweisung ausgeführt wird, wird aufgrund der ungültigen Platzhalteradresse in RIPein Seitenfehler ausgelöst. Der Emulator fängt diesen Seitenfehler ab und erkennt ihn als "spezielle" Adresse. Die anfänglichen CPL3-Registerwerte werden dann in die virtuelle CPU kopiert, RIP wird aktualisiert, um auf eine benutzerdefinierte Ladefunktion im Benutzermodus zu verweisen, und die Ausführung wird fortgesetzt. Diese Funktion lädt alle DLL-Abhängigkeiten für die ausführbare Zieldatei, füllt die IAT-Tabelle auf, führt TLS-Rückrufe aus und führt dann den ursprünglichen Einstiegspunkt aus. Die Importtabelle und TLS-Callbacks werden in dieser Phase und nicht früher verarbeitet, um sicherzustellen, dass ihr Code in der virtualisierten Umgebung ausgeführt wird.
Auslagerung des Speichers
Die gesamte Speicherverwaltung für den Gast muss manuell durchgeführt werden. Dies bedeutet, dass eine Auslagerungstabelle ausgefüllt und verwaltet werden muss, damit die virtuelle CPU eine virtuelle Adresse in eine physische Adresse übersetzen kann.
Virtuelle Adressübersetzung
Für diejenigen, die mit der Auslagerung in x64 nicht vertraut sind, verfügt die Auslagerungstabelle über vier Ebenen: PML4, PDPT, PDund PT. Für jede angegebene virtuelle Adresse durchläuft die CPU jede Schicht der Tabelle und erreicht schließlich die physische Zieladresse. Moderne CPUs unterstützen auch 5-Level-Paging (falls die 256 TB adressierbarer Speicher, die durch 4-Level-Paging angeboten werden, nicht ausreichen!), aber das ist für die Zwecke dieses Projekts irrelevant.
Die folgende Abbildung veranschaulicht das Format einer virtuellen Beispieladresse:
Anhand des obigen Beispiels würde die CPU die physische Seite berechnen, die der 0x7FFB7D030D10 virtuellen Adresse über die folgenden Tabelleneinträge entspricht: PML4[0xFF] -> PDPT[0x1ED] -> PD[0x1E8] -> PT[0x30]. Schließlich wird der Offset (0xD10) zu dieser physischen Seite hinzugefügt, um die genaue Adresse zu berechnen.
Bits 48 - 63 innerhalb einer virtuellen Adresse werden in der 4-Ebenen-Auslagerung nicht verwendet und sind im Wesentlichen vorzeichenerweitert, um mit den Bits 47übereinzustimmen.
Das CR3 Steuerregister enthält die physische Adresse der Basis- PML4 Tabelle. Wenn die Auslagerung aktiviert ist (obligatorisch im langen Modus), beziehen sich alle anderen Adressen im Kontext der CPU auf virtuelle Adressen.
Seitenfehler
Wenn der Gast versucht, auf den Arbeitsspeicher zuzugreifen, löst die virtuelle CPU eine Seitenfehlerausnahme aus, wenn die angeforderte Seite nicht bereits in der Auslagerungstabelle vorhanden ist. Dadurch wird ein VM-Exit-Ereignis ausgelöst und die Steuerung an den Host zurückgegeben. In diesem Fall enthält das CR2 Kontrollregister die angeforderte virtuelle Adresse, obwohl die WHP-API diesen Wert bereits in den VM-Exit-Kontextdaten bereitstellt. Der Host kann dann die angeforderte Seite dem Speicher zuordnen (falls möglich) und die Ausführung fortsetzen oder einen Fehler auslösen, wenn die Zieladresse ungültig ist.
Host-/Gastspeicherspiegelung
Wie bereits erwähnt, erstellt der Emulator einen untergeordneten Prozess, und der gesamte virtuelle Speicher innerhalb dieses Prozesses wird dem Gast unter Verwendung desselben Adresslayouts direkt zugeordnet. Die Hypervisor-Plattform-API ermöglicht es uns, den virtuellen Speicher aus dem Host-Benutzermodus-Prozess direkt dem physischen Speicher des Gastes zuzuordnen. Die Auslagerungstabelle ordnet dann virtuelle Adressen den entsprechenden physischen Seiten zu.
Anstatt den gesamten Adressraum des Prozesses im Voraus abzubilden, wird dem Gast eine feste Anzahl physischer Seiten zugewiesen. Der Emulator enthält einen sehr einfachen Speicher-Manager, und Seiten werden "bei Bedarf" zugeordnet. Wenn ein Seitenfehler auftritt, wird die angeforderte Seite ausgelagert und die Ausführung wird fortgesetzt. Wenn alle Seiten-"Slots" voll sind, wird der älteste Eintrag ausgetauscht, um Platz für den neuen zu schaffen.
Neben der Verwendung einer festen Anzahl aktuell zugeordneter Seiten verwendet der Emulator auch eine Seitentabelle mit fester Größe. Die Größe der Seitentabelle wird bestimmt, indem die maximal mögliche Anzahl von Tabellen für die Anzahl der zugeordneten Seiteneinträge berechnet wird. Dieses Modell führt zu einem einfachen und konsistenten Layout des physischen Speichers, geht jedoch auf Kosten der Effizienz. Tatsächlich nehmen die Auslagerungstabellen mehr Platz ein als die eigentlichen Seiteneinträge.
Es gibt eine einzelne PML4-Tabelle, und im schlimmsten Fall verweist jeder zugeordnete Seiteneintrag auf eindeutige PDPT/PD/PT-Tabellen. Da jede Tabelle 4096 Byte groß ist, kann die Gesamtgröße der Seitentabelle mit der folgenden Formel berechnet werden:
PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)
Standardmäßig lässt der Emulator zu, dass 256 Seiten gleichzeitig zugeordnet werden können (insgesamt1024KB ). Mit der obigen Formel können wir berechnen, dass dies 3076KB für die Auslagerungstabelle erfordert, wie unten dargestellt:
In der Praxis werden viele der Seitentabelleneinträge gemeinsam genutzt, und ein Großteil des für die Auslagerungstabellen zugewiesenen Speicherplatzes bleibt ungenutzt. Da dieser Emulator jedoch auch mit einer kleinen Anzahl von Seiten gut funktioniert, ist dieser Aufwand kein großes Problem.
Die CPU verwaltet einen Cache auf Hardwareebene für die Auslagerungstabelle, der als Translation Lookaside Buffer (TLB) bezeichnet wird. Bei der Übersetzung einer virtuellen Adresse in eine physische Adresse prüft die CPU zunächst den TLB. Wenn kein passender Eintrag im Cache gefunden wird (bekannt als "TLB-Fehler"), werden stattdessen die Auslagerungstabellen gelesen. Aus diesem Grund ist es wichtig, den TLB-Cache zu leeren, wenn die Auslagerungstabellen neu erstellt wurden, um zu verhindern, dass sie nicht mehr synchron sind. Die einfachste Möglichkeit, den gesamten TLB zu leeren, besteht darin, den CR3 Registerwert zurückzusetzen.
Behandlung von Systemaufrufen
Während der Ausführung des Zielprogramms müssen alle Systemaufrufe, die innerhalb des Gastprogramms auftreten, vom Host verarbeitet werden. Dieser Emulator verarbeitet sowohl SYSCALL Anweisungen als auch ältere (Interrupt-basierte) Systemaufrufe. SYSENTER wird nicht im langen Modus verwendet und daher von WinVisor nicht unterstützt.
Schneller Systemaufruf (SYSCALL)
Wenn eine SYSCALL Anweisung ausgeführt wird, wechselt die CPU zu CPL0 und lädt RIP aus MSR_LSTAR. Im Windows-Kernel würde dies auf KiSystemCall64. SYSCALL Anweisungen lösen nicht inhärent ein VM-Exit-Ereignis aus, aber der Emulator legt MSR_LSTAR auf eine reservierte Platzhalteradresse fest – in diesem Fall 0xFFFF800000000000 . Wenn eine SYSCALL Anweisung ausgeführt wird, wird ein Seitenfehler ausgelöst, wenn RIP auf diese Adresse gesetzt wird, und der Aufruf kann abgefangen werden. Dieser Platzhalter ist eine Kerneladresse in Windows und verursacht keine Konflikte mit dem Adressraum im Benutzermodus.
Im Gegensatz zu älteren Systemaufrufen tauscht die SYSCALL -Anweisung den RSP Wert während des Übergangs zu CPL0 nicht aus, sodass der Stack-Zeiger im Benutzermodus direkt von RSPabgerufen werden kann.
Legacy-Systemaufrufe (INT 2E)
Legacy-Interrupt-basierte Systemaufrufe sind langsamer und haben mehr Overhead als die SYSCALL Anweisung, werden aber trotzdem von Windows unterstützt. Da der Emulator bereits ein Framework für die Behandlung von Interrupts enthält, ist das Hinzufügen von Unterstützung für Legacy-Systemaufrufe sehr einfach. Wenn ein Legacy-Syscall-Interrupt abgefangen wird, kann er nach einigen kleineren Übersetzungen an den "allgemeinen" Syscall-Handler weitergeleitet werden – insbesondere nach dem Abrufen des gespeicherten User-Mode- RSP -Werts aus dem CPL0-Stack.
Syscall-Weiterleitung
Nachdem der Emulator den "Hauptthread" erstellt hat, dessen Kontext in die virtuelle CPU geklont wird, wird dieser native Thread als Proxy wiederverwendet, um Systemaufrufe an den Host weiterzuleiten. Durch die Wiederverwendung desselben Threads wird die Konsistenz für den TEB und den Kernelstatus zwischen dem Gast und dem Host gewahrt. Insbesondere Win32k basiert auf vielen threadspezifischen Zuständen, die sich im Emulator widerspiegeln sollten.
Wenn ein Systemaufruf auftritt, entweder durch eine SYSCALL Anweisung oder einen Legacyinterrupt, fängt der Emulator ihn ab und überträgt ihn an eine universelle Handlerfunktion. Die Syscall-Nummer wird im Register RAX gespeichert, und die ersten vier Parameterwerte werden in R10, RDX, R8bzw. R9gespeichert. R10 wird für den ersten Parameter anstelle des üblichen RCX Registers verwendet, da die SYSCALL Anweisung RCX mit der Rücksprungadresse überschreibt. Der Legacy-Syscall-Handler in Windows (KiSystemService) verwendet R10 ebenfalls aus Kompatibilitätsgründen, sodass er im Emulator nicht anders behandelt werden muss. Die restlichen Parameter werden aus dem Stack abgerufen.
Wir kennen nicht die genaue Anzahl der Parameter, die für eine bestimmte Syscall-Nummer erwartet werden, aber glücklicherweise spielt das keine Rolle. Wir können einfach einen festen Betrag verwenden, und solange die Anzahl der gelieferten Parameter größer oder gleich der tatsächlichen Anzahl ist, funktioniert der Systemaufruf korrekt. Ein einfacher Assembly-Stub wird dynamisch erstellt, wobei alle Parameter ausgefüllt, der Ziel-Systemaufruf ausgeführt und sauber zurückgegeben wird.
Tests haben gezeigt, dass die maximale Anzahl von Parametern, die derzeit von Windows-Systemaufrufen verwendet werden, 17 ist (NtAccessCheckByTypeResultListAndAuditAlarmByHandle, NtCreateTokenExund NtUserCreateWindowEx). WinVisor verwendet 32 als maximale Anzahl von Parametern, um eine mögliche zukünftige Erweiterung zu ermöglichen.
Nach dem Ausführen des Systemaufrufs auf dem Host wird der Rückgabewert auf RAX im Gast kopiert. RIP wird dann an eine SYSRET Anweisung (oder IRETQ für Legacy-Systemaufrufe) übertragen, bevor die virtuelle CPU für einen nahtlosen Übergang zurück in den Benutzermodus fortgesetzt wird.
Protokollierung von Systemaufrufen
Standardmäßig leitet der Emulator Gast-Systemaufrufe einfach an den Host weiter und protokolliert sie in der Konsole. Es sind jedoch einige zusätzliche Schritte erforderlich, um die rohen Systemaufrufe in ein lesbares Format zu konvertieren.
Der erste Schritt besteht darin, die Systemrufnummer in einen Namen umzuwandeln. Syscall-Nummern setzen sich aus mehreren Teilen zusammen: Bits 12 - 13 enthalten den Index der Systemdiensttabelle (0 für ntoskrnl, 1 für win32k), und Bits 0 - 11 enthalten den Systemaufrufindex innerhalb der Tabelle. Diese Informationen ermöglichen es uns, einen Reverse-Lookup innerhalb des entsprechenden Benutzermodus-Moduls (ntdll / win32u) durchzuführen, um den ursprünglichen Systemaufrufnamen aufzulösen.
Der nächste Schritt besteht darin, die Anzahl der Parameterwerte zu bestimmen, die für jeden Systemaufruf angezeigt werden sollen. Wie oben erwähnt, übergibt der Emulator 32 Parameterwerte an jeden Systemaufruf, auch wenn die meisten von ihnen nicht verwendet werden. Das Protokollieren aller 32 Werte für jeden Systemaufruf wäre jedoch aus Gründen der Lesbarkeit nicht ideal. Ein einfacher NtClose(0x100) Aufruf wird z. B. als NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...)gedruckt. Wie bereits erwähnt, gibt es keine einfache Möglichkeit, die genaue Anzahl der Parameter für jeden Systemaufruf automatisch zu bestimmen, aber es gibt einen Trick, den wir verwenden können, um sie mit hoher Genauigkeit zu schätzen.
Dieser Trick stützt sich auf die 32-Bit-Systembibliotheken, die von WoW64 verwendet werden. Diese Bibliotheken verwenden die stdcall-Aufrufkonvention, was bedeutet, dass der Aufrufer alle Parameter auf den Stack schiebt und sie intern vom Aufgerufenen bereinigt werden, bevor sie zurückgegeben werden. Im Gegensatz dazu platziert systemeigener x64-Code die ersten 4 Parameter in Registern, und der Aufrufer ist für die Verwaltung des Stapels verantwortlich.
Zum Beispiel endet die Funktion NtClose in der WoW64-Version von ntdll.dll mit der Anweisung RET 4 . Dadurch werden weitere 4 Byte nach der Rückgabeadresse aus dem Stapel entfernt, was bedeutet, dass die Funktion einen Parameter akzeptiert. Wenn die verwendete Funktion RET 8verwendet, würde dies darauf hindeuten, dass sie 2 Parameter verwendet und so weiter.
Obwohl der Emulator als 64-Bit-Prozess ausgeführt wird, können wir die 32-Bit-Kopien von ntdll.dll und win32u.dll immer noch in den Speicher laden - entweder manuell oder mit SEC_IMAGEzugeordnet. Eine benutzerdefinierte Version von GetProcAddress muss geschrieben werden, um die WoW64-Exportadressen aufzulösen, aber das ist eine triviale Aufgabe. Von hier aus können wir automatisch den entsprechenden WoW64-Export für jeden Systemaufruf finden, nach der RET Anweisung suchen, um die Anzahl der Parameter zu berechnen, und den Wert in einer Nachschlagetabelle speichern.
Diese Methode ist nicht perfekt, und es gibt eine Reihe von Möglichkeiten, wie dies fehlschlagen kann:
- Eine kleine Anzahl von nativen Systemaufrufen existiert in WoW64 nicht, wie z.B.
NtUserSetWindowLongPtr. - Wenn eine 32-Bit-Funktion einen 64-Bit-Parameter enthält, wird sie intern in 2x 32-Bit-Parameter aufgeteilt, während die entsprechende 64-Bit-Funktion nur einen einzigen Parameter für denselben Wert benötigt.
- Die WoW64-Syscall-Stub-Funktionen in Windows können sich so ändern, dass die vorhandene
RETAnweisungssuche fehlschlägt.
Trotz dieser Fallstricke sind die Ergebnisse für die überwiegende Mehrheit der Systemaufrufe genau, ohne dass Sie sich auf hartcodierte Werte verlassen müssen. Darüber hinaus werden diese Werte nur zu Protokollierungszwecken verwendet und haben keinen Einfluss auf andere Zwecke, sodass kleinere Ungenauigkeiten in diesem Zusammenhang akzeptabel sind. Wenn ein Fehler erkannt wird, wird wieder die maximale Anzahl von Parameterwerten angezeigt.
Syscall-Hooking
Wenn dieses Projekt für Sandboxing-Zwecke verwendet würde, wäre es aus offensichtlichen Gründen unerwünscht, alle Systemaufrufe blind an den Host weiterzuleiten. Der Emulator enthält ein Framework, mit dem bestimmte Systemaufrufe bei Bedarf einfach eingebunden werden können.
Standardmäßig sind nur NtTerminateThread und NtTerminateProcess eingebunden, um das Beenden des Gastprozesses abzufangen.
Behandlung von Interrupts
Interrupts werden durch die IDT definiert, die gefüllt wird, bevor die Ausführung der virtuellen CPU beginnt. Wenn ein Interrupt auftritt, wird der aktuelle CPU-Status auf den CPL0-Stack übertragen (SS, RSP, RFLAGS, CS, RIP) und RIP auf die Zielhandlerfunktion festgelegt.
Wie bei MSR_LSTAR für den SYSCALL-Handler füllt der Emulator alle Interrupt-Handler-Adressen mit Platzhalterwerten (0xFFFFA00000000000 - 0xFFFFA000000000FF). Wenn ein Interrupt auftritt, tritt innerhalb dieses Bereichs ein Seitenfehler auf, den wir abfangen können. Der Interrupt-Index kann aus den untersten 8 Bit der Zieladresse extrahiert werden (z. B. 0xFFFFA00000000003 ist INT 3), und der Host kann ihn bei Bedarf verarbeiten.
Derzeit verarbeitet der Emulator nur INT 1 (Einzelschritt), INT 3 (Haltepunkt) und INT 2E (Legacy-Systemaufruf). Wenn ein anderer Interrupt abgefangen wird, wird der Emulator mit einem Fehler beendet.
Wenn ein Interrupt behandelt wurde, wird RIP an eine IRETQ Anweisung übergeben, die sauber in den Benutzermodus zurückkehrt. Einige Arten von Interrupts schieben einen zusätzlichen "Fehlercode"-Wert auf den Stack - wenn dies der Fall ist, muss er vor der IRETQ Anweisung gepoppt werden, um eine Beschädigung des Stacks zu vermeiden. Das Interrupt-Handler-Framework in diesem Emulator enthält ein optionales Flag, um dies transparent zu handhaben.
Hypervisor-Fehler bei freigegebenen Seiten
Windows 10 eine neue Art von freigegebener Seite eingeführt, die sich in der Nähe von KUSER_SHARED_DATAbefindet. Diese Seite wird von Timing-bezogenen Funktionen wie RtlQueryPerformanceCounter und RtlGetMultiTimePreciseverwendet.
Die genaue Adresse dieser Seite kann mit NtQuerySystemInformationüber die Informationsklasse SystemHypervisorSharedPageInformation abgerufen werden. Die Funktion LdrpInitializeProcess speichert die Adresse dieser Seite während des Prozessstarts in einer globalen Variablen (RtlpHypervisorSharedUserVa).
Die WHP-API scheint einen Fehler zu enthalten, der dazu führt, dass die WHvRunVirtualProcessor -Funktion in einer Endlosschleife stecken bleibt, wenn diese freigegebene Seite dem Gast zugeordnet wird und die virtuelle CPU versucht, von ihr zu lesen.
Zeitliche Zwänge schränkten die Fähigkeit ein, dies vollständig zu untersuchen; Es wurde jedoch ein einfacher Workaround implementiert. Der Emulator patcht die NtQuerySystemInformation Funktion innerhalb des Zielprozesses und zwingt sie, STATUS_INVALID_INFO_CLASS für SystemHypervisorSharedPageInformation Anforderungen zurückzugeben. Dies führt dazu, dass der ntdll Code auf herkömmliche Methoden zurückgreift.
Demos
Im Folgenden finden Sie einige Beispiele für gängige ausführbare Windows-Dateien, die in dieser virtualisierten Umgebung emuliert werden:
Begrenzungen
Der Emulator weist mehrere Einschränkungen auf, die es unsicher machen, ihn in seiner aktuellen Form als sichere Sandbox zu verwenden.
Sicherheitsaspekte
Es gibt mehrere Möglichkeiten, die VM mit einem "Escape" zu versehen, z. B. einfach einen neuen Prozess/Thread zu erstellen, asynchrone Prozeduraufrufe (APCs) zu planen usw.
Systemaufrufe im Zusammenhang mit der Windows-GUI können auch geschachtelte Aufrufe direkt vom Kernel zurück in den Benutzermodus ausführen, wodurch die Hypervisorschicht derzeit umgangen wird. Aus diesem Grund werden ausführbare GUI-Dateien wie notepad.exe nur teilweise virtualisiert, wenn sie unter WinVisor ausgeführt werden.
Um dies zu veranschaulichen, enthält WinVisor einen -nx Befehlszeilenschalter für den Emulator. Dadurch wird erzwungen, dass das gesamte EXE-Zielimage vor dem Starten der virtuellen CPU im Arbeitsspeicher als nicht ausführbar markiert wird, was zum Absturz des Prozesses führt, wenn der Hostprozess versucht, den Code nativ auszuführen. Es ist jedoch immer noch unsicher, sich darauf zu verlassen – die Zielanwendung könnte den Bereich wieder ausführbar machen oder einfach ausführbaren Speicher an anderer Stelle zuweisen.
Wenn die WinVisor-DLL in den Zielprozess eingefügt wird, befindet sie sich im selben virtuellen Adressraum wie die ausführbare Zieldatei. Dies bedeutet, dass der Code, der unter der virtuellen CPU ausgeführt wird, direkt auf den Speicher innerhalb des Host-Hypervisor-Moduls zugreifen kann, wodurch er möglicherweise beschädigt werden könnte.
Nicht ausführbarer Gastspeicher
Während die virtuelle CPU für die Unterstützung von NX eingerichtet ist, werden derzeit alle Speicherbereiche mit vollem RWX-Zugriff in den Gast gespiegelt.
Nur Single-Thread
Der Emulator unterstützt derzeit nur die Virtualisierung eines einzelnen Threads. Wenn die ausführbare Zieldatei zusätzliche Threads erstellt, werden diese nativ ausgeführt. Um mehrere Threads zu unterstützen, könnte ein Pseudo-Scheduler entwickelt werden, der dies in Zukunft handhaben kann.
Das parallele Windows-Ladeprogramm ist deaktiviert, um sicherzustellen, dass alle Modulabhängigkeiten von einem einzelnen Thread geladen werden.
Software-Ausnahmen
Ausnahmen für virtualisierte Software werden derzeit nicht unterstützt. Wenn eine Ausnahme auftritt, ruft das System die Funktion KiUserExceptionDispatcher wie gewohnt nativ auf.
Fazit
Wie oben zu sehen ist, funktioniert der Emulator in seiner aktuellen Form mit einer Vielzahl von ausführbaren Dateien. Während es derzeit für die Protokollierung von Systemaufrufen und Interrupts effektiv ist, wäre noch viel weitere Arbeit erforderlich, um es sicher für die Verwendung zu Malware-Analysezwecken zu machen. Trotzdem bietet das Projekt einen effektiven Rahmen für die zukünftige Entwicklung.
Projekt-Links
https://github.com/x86matthew/WinVisor
Den Autor finden Sie auf X bei @x86matthew.
