
Benutzermodus-Detektor, der indirekte Syscalls abfängt. Fängt Hell's Hall, Tartarus' Gate, RecycledGate und VEH-Syscalls und viele weitere ab.
Copyright (C) 2026 Adam Zypherion <[email protected]> — lizenziert unter GPL-3.0.
Indirekte Syscalls sind einfach, sobald man einen gesehen hat. Der Loader durchläuft ntdll, liest die SSN aus dem Nt*-Prolog, findet die 0F 05 zwei Bytes weiter, setzt r10/rax/rdx/r8/r9 selbst und springt direkt zu diesen zwei Bytes. Der Syscall erfolgt von innerhalb von ntdll. Ihr Hook auf dem kernel32-Export wird nie berührt. Ihr Hook auf dem ntdll-Export wird nie berührt. [RSP] zum Zeitpunkt des SYSCALL zeigt zurück in die RWX-Seite des Loaders, aber nichts an dem Aufruf wirkt von außen ungewöhnlich.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
Die Varianten haben Namen. Tartarus' Gate und RecycledGate verwenden die Syscall-Anweisung eines Stubs, aber die SSN eines anderen, sodass selbst wenn Sie den Stubnamen protokollieren, den Ihr Hook meldet, es eine Lüge ist. VEH-Syscall löst absichtlich eine Zugriffsverletzung aus und verwendet seinen eigenen VEH, um den Kontext so umzuschreiben, dass RIP auf die Syscall-Anweisung von ntdll landet, wobei die Register bereits vorbereitet sind. Hell's Gate verwendet überhaupt kein ntdll. Der Loader schreibt 0F 05 in seine eigene RWX-Seite und führt von dort aus.
Die Kernel-Mode (KM)-Antwort ist ein Treiber; vielleicht komme ich irgendwann dazu und veröffentliche ein Projekt, aber es wird nicht so bald passieren :D
PAGE_GUARD scheint zu funktionieren. Markieren Sie die Seite, die die Syscall-Bytes enthält, mit PAGE_GUARD, fangen Sie die STATUS_GUARD_PAGE_VIOLATION in einem VEH ab, untersuchen Sie sie, leiten Sie zu einem privaten Trampolin um, setzen Sie das Trap-Flag, single-step hinaus, bewaffnen Sie die Seite neu. Ein Trap pro Nt-Aufruf, egal wie der Loader dorthin gelangt ist. Es funktioniert gegen alles außer Hell's Gate.
Das Problem ist, dass das Betriebssystem nicht mitspielt. PAGE_GUARD ist ein Einweg-Bit; was bedeutet das? Jedes Mal, wenn es auslöst, wird das Bit gelöscht und Sie müssen es wieder setzen. Ihr Handler führt Syscalls aus (NtProtect, um die Guard wieder zu setzen, NtContinue, um fortzusetzen), und diese Syscalls haben Stubs, und diese Stubs befinden sich auf der Seite, die Sie gerade bewacht haben. Ich habe das meiste davon mit privaten Syscall-Stubs umgangen, die auf einer separaten Seite erstellt wurden, die wir besaßen (RWX allokieren, mov r10,rcx; mov eax,SSN; syscall; ret schreiben, auf RX sperren, ntdll nie anfassen), aber es war fragil. Jede kleinere Windows-Version änderte das Timing. Jeder Thread, den die Probe erzeugte, war ein weiteres Rennen gegen den Integrity-Worker, der die Guard wieder aufbaute. Die Demo-Erfolgsquote lag bei etwa 30 bis 50 Prozent über zehn Läufe auf demselben Rechner.
Irgendwann hörte ich auf, Windows davon überzeugen zu wollen, dass PAGE_GUARD sich so verhalten sollte, wie ich es wollte, und versuchte stattdessen, das Byte zu überschreiben.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
Das Byte an der Syscall-Anweisung ist 0F 05. Das erste Byte allein (0F) ist der Präfix für eine Familie von Zwei-Byte-Opcodes, darunter SYSCALL, CPUID und RDTSC. Für sich allein ist es nicht ausführbar; die CPU benötigt das zweite Byte zur Dekodierung. Wenn Sie das erste Byte durch 0xCC (INT3) ersetzen, wird das Paar zu CC 05, was die CPU als INT3 gefolgt von einem verirrten Byte dekodiert, das sie nie erreicht. Jeder Codepfad, der auf diese Adresse trifft, löst EXCEPTION_BREAKPOINT aus.
Das ist der gesamte Mechanismus. Unser VEH fängt den Breakpoint ab, schaut nach, zu welchem Stub die Adresse gehört (wir erstellen die Map beim Initialisieren, indem wir die Exporte von ntdll aufzählen), führt drei Prüfungen auf demjenigen durch, der aufgetaucht ist, und setzt Context->Rip auf ein privates Trampolin, das den echten Syscall ausführt und zurückkehrt. Das Byte bleibt CC. Der nächste Aufrufer trifft darauf in gleicher Weise, sodass kein Seitenschutz hin und her geschaltet wird.
Um es klar zu sagen: Ja, das ist Hooking durch Byte-Überschreiben. Es ist nur nicht an dem Byte, das die Leute normalerweise mit "ntdll-Hook" meinen. Der klassische EDR-Hook überschreibt die ersten Bytes des Stubs mit einem JMP <my_func>:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; überschreibt "mov r10, rcx"
...
0F 05 syscall
C3 ret
genau das umgehen indirekte Syscalls. Der Loader liest die SSN selbst und springt direkt zur 0F 05, der Prolog (und Ihr jmp) wird nie ausgeführt, der Hook feuert nie. Was wir tun, ist das Syscall-Byte selbst zu überschreiben:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; unberührt
B8 18 00 00 00 mov eax, 18h ; unberührt
CC 05 int3 / 05 ; war 0F 05, wir haben CC über die 0F geschrieben
C3 ret
Jetzt spielt es keine Rolle, wie Sie zu dieser Adresse gelangt sind. Durch den Prolog, durch einen indirekten Sprung, der den Prolog überspringt, durch eine VEH-Syscall-Kontextumschreibung, die RIP dort hinwirft, was auch immer. Wenn die CPU dieses Byte ausführt, fängt sie. Dieselbe Familie von Techniken wie der klassische Hook, andere Stelle, völlig andere Abdeckung.
Das Trampolin sieht so aus:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
Die drei Prüfungen sind unverändert von der PAGE_GUARD-Version, weil es die richtigen drei Prüfungen waren; sie brauchten nur einen zuverlässigen Ort zum Leben.
Return-Adresse. [RSP] ist, wohin der Syscall zurückgekehrt wäre. Bei einem echten Aufruf ist sie innerhalb von ntdll, kernel32, kernelbase oder einem der zugehörigen Laufzeit-DLLs. Bei Hell's Hall liegt sie innerhalb der RWX-Seite, von der der Loader gerade ausführt. Wir haben eine kurze Liste vertrauenswürdiger Return-Ziele, die bei der Initialisierung durch Aufruf von GetModuleHandle für diese Namen und Lesen der .text-Bereiche aus ihren PE-Headern erstellt wird.
SSN. Der Prolog des Stubs ist bereits ausgeführt, bevor das Syscall-Byte erreicht wird, also enthält eax den Wert, der hineingeladen wurde. Wenn der Loader einen Tartarus-Tausch durchgeführt hat, stimmt dieser Wert nicht mit der SSN überein, die wir bei der Aufzählung aus diesem selben Stub ausgelesen haben. Wir protokollieren die Abweichung, und das Trampolin schreibt die korrekte SSN vor seinem eigenen Syscall, sodass die Kernel-Funktion, die ausgeführt wird, diejenige ist, die zum Byte gehört, nicht die, die der Loader wollte. Die Technik wird im selben Schritt protokolliert und neutralisiert.
Stack-Walk. RtlVirtualUnwind aus dem aktuellen Kontext, fünf Ebenen nach oben. Die RIP jeder Ebene sollte sich innerhalb eines bekannten Moduls befinden und einen RUNTIME_FUNCTION-Eintrag haben. Shellcode und ROP-Gadgets bestehen dies nicht, selbst wenn [RSP] selbst vertrauenswürdig aussieht, was ein Loader fälschen kann (er kann ungefähr vorhersagen, wo sein Aufrufer im Speicher sein wird, und eine glaubwürdige Rückkehradresse dort erzeugen).
Wenn eine Prüfung fehlschlägt, schieben wir eine kleine Struktur in einen lock-freien Ring und der Drain-Thread druckt sie beim nächsten Aufwachen aus:
[!! hallwatch !!] indirekter syscall (nicht vertrauenswürdiger Aufrufer, falsche SSN für diesen stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub codiert 0x00000018)
thread : 26388
Hell's Gate ist der Punkt, an dem INT3 nicht mehr hilft. Wir haben die RWX-Seite des Loaders nie gepatcht, weil wir nie von ihrer Existenz wussten.
Was wir stattdessen tun, ist der einfachste Scanner, der funktionieren könnte. Alle 250 ms (im Grunde zu häufig, verschwendet CPU-Zyklen, aber es ist nur ein PoC) durchläuft der Integrity-Worker den Adressraum mit VirtualQuery und sucht nach jeglichem MEM_COMMIT-Bereich mit einem ausführbaren Seitenschutz. Wenn der Bereich in einem geladenen Modul liegt, wird er übersprungen. Wenn er in unserem eigenen Trampolin-Pool liegt, wird er übersprungen. Was übrig bleibt, ist fremder ausführbarer Speicher. Wir scannen bis zu 64 KB davon nach 0F 05-Byte-Paaren, deduplizieren nach Adresse, protokollieren jeden eindeutigen Treffer einmal. Ich weiß, es ist dumm und Sie verschwenden auch CPU-Zyklen, aber es ist ein PoC, der die Situation nicht herunterspielen sollte, es aber derzeit tut.
Das fängt Hell's Gate in einer frischen RWX-Allokation und es fängt Schatten-ntdll (ein Loader, der NtMapViewOfSection auf ntdll.dll ausführt, um eine private Kopie an einer neuen Adresse zu erhalten) ab. Der Schatten-Fall ist ein lustiger. Die Zuordnung befindet sich nicht in unserem Modul-Snapshot, daher erscheinen ihre Syscall-Anweisungen als fremde ausführbare Bytes, obwohl sie von einer vollkommen legitimen signierten DLL auf der Festplatte stammen.
Eine andere Variante: Der Angreifer backt 0F 05 in seinen eigenen .text seiner Binärdatei ein, sodass der Syscall innerhalb eines geladenen Moduls und nicht auf einer RWX-Seite lebt. Foreign-RWX überspringt geladene Module, daher schlüpfte dies bisher durch. Also haben wir einen weiteren Scanner, der die ausführbaren Bytes jedes Moduls durchläuft, ntdll, win32u und wow64cpu (die drei Module, die legitimerweise Syscall-Anweisungen haben) überspringt und 0F 05 nur meldet, wenn innerhalb der nächsten drei Bytes ein C3 vorhanden ist. Die ret-Prüfung ist das, was es verwendbar macht. 0F 05 für sich allein taucht in Compilerausgaben ständig als Teil größerer Anweisungen auf, ist aber nie benachbart zu einem C3. Echte Syscall-Stubs sind es immer. Log:
[hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate innerhalb des .text eines geladenen Moduls
Der Integrity-Worker hat zwei weitere kleine Aufgaben. Er liest bei jedem Tick das Byte an jedem gepatchten Stub und schreibt CC zurück, wenn es nicht CC ist. Das ist der einfachste mögliche Unhook: Das Sample liest das originale 0F von irgendwo (einem frischen ntdll-Mapping, der PE auf der Platte, einer bekannten Byte-Tabelle) und schreibt es zurück über unseren Patch. Wir bemerken es beim nächsten Tick und patchen erneut darüber.
Jeder fünfzigste Tick (etwa 12,5 Sekunden) installiert der Worker unseren VEH erneut an der Spitze der Kette und entfernt das vorherige Handle. Ein Sample kann LdrpVectorHandlerList durchlaufen und uns abmelden; der Worker begrenzt, wie lange diese Lücke offen bleibt. Zwölfeinhalb Sekunden sind ein langes Fenster, wenn Sie angegriffen werden, und ein kurzes, wenn Sie Malware in einer Sandbox ausführen. Ich werde es wahrscheinlich verkürzen, sobald ich die Kosten dafür gemessen habe, es öfter zu tun.
Der Worker-Thread läuft mit einem gesetzten TLS-Flag, das der VEH prüft. Die eigenen Syscalls des Workers (NtProtect zum Patchen, NtQuery für den Scan) lösen wie alle anderen unsere Breakpoints aus, aber das Flag weist den VEH an, die Erkennungslogik zu überspringen und stillschweigend durch das Trampolin umzuleiten.
Der kritische Abschnitt initialisiert sich selbst beim ersten Mal, wenn jemand aufruft, über einen Drei-Zustands-Compare-Exchange (0 = nicht init, 2 = beschäftigt, 1 = bereit). Es ist hässlich, vermeidet aber die Notwendigkeit eines statischen Konstruktors innerhalb einer DLL, was unter Windows eine separate Reihe von Problemen mit der Loader-Sperre usw. darstellt.
Eine ABI-Sache, die erwähnenswert ist. INT3 ist ein Trap, was bedeutet, dass Context->Rip auf die nächste Anweisung zeigt, wenn unser VEH aufgerufen wird, nicht auf den Trap selbst. Wenn wir das Byte bei 0x7FF827660372 gepatcht haben, kommt Context->Rip beim VEH als 0x7FF827660373 an. Die Record->ExceptionAddress zeigt auf den Trap, aber wir müssen Context->Rip = ExceptionAddress zurücksetzen, bevor wir es zum Trampolin umleiten, sonst beginnt das Trampolin ein Byte zu spät und der Syscall tut nichts Nützliches. Ich sage Ihnen das, weil es ein Bug war, der eine Menge Zeit in Anspruch genommen hat, um ihn zu finden.
Es gibt vier Exporte. IscInitialize bewaffnet den Detektor; DllMain ruft ihn automatisch auf, aber Sie können ihn von einem Host-Prozess aus aufrufen, wenn Sie einen Rückgabewert möchten. IscGetDetectionCount gibt einen monoton steigenden Zähler zurück. IscShutdown wartet auf laufende Handler und stellt die 0F-Bytes wieder her. IscFlush leert den Ring synchron, nützlich, wenn Sie den Detektor in eine Sandbox einbetten, die die Ereignisse benötigt, bevor die Beendigung des Samples erfolgt.
Minimale Integration ist LoadLibrary. DllMain übernimmt die Initialisierung und startet von dort aus beide Hintergrundthreads.
Isc = Indirekte Syscalls
Dinge, die wir noch nicht abfangen.
Ein Sample, das Integritätsprüfungen durchführt.
Ein Sample, das Stubs verwendet, die wir nicht gepatcht haben. Die derzeitige Whitelist umfasst etwa 40 Namen, die offensive Speicher-, Prozess-, Thread-, Section-, Token- und Datei-Primitiven abdecken. Das Hinzufügen weiterer ist inkrementell, solange DllMain zügig abschließt. Das Patchen aller 488 Stubs von innerhalb der Loader-Sperre ist einfach ... ähm ...
Ein Sample, das mit Kernel-Berechtigungen läuft. Kein Usermode-Problem.
INT3 ist das, wofür wir uns letztendlich entschieden haben, aber der Trap-Mechanismus ist nicht der interessante Teil. Der Grund, warum es besser funktioniert als PAGE_GUARD, ist, dass der Trap keine laufende Verhandlung mit dem Betriebssystem erfordert. Das Byte ist CC. Es bleibt CC. Das Betriebssystem hat keine Meinung dazu, welche Bytes in ntdll leben.