
Détecteur en mode utilisateur qui capture les appels système indirects. Intercepte Hell's Hall, Tartarus' Gate, RecycledGate, les appels système VEH et bien d'autres.
Copyright (C) 2026 Adam Zypherion <[email protected]> — sous licence GPL-3.0.
Les appels système indirects sont simples une fois que vous en avez vu un. Le chargeur parcourt ntdll, lit le SSN dans le prologue de Nt*, trouve le 0F 05 deux octets plus loin, définit lui-même r10/rax/rdx/r8/r9, et jmp directement sur ces deux octets. L'appel système se produit depuis l'intérieur de ntdll. Votre hook sur l'export kernel32 n'est jamais touché. Votre hook sur l'export ntdll n'est jamais touché. [RSP] au moment du SYSCALL pointe vers la page RWX du chargeur, mais rien dans l'appel ne semble inhabituel de l'extérieur.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
Les variantes ont des noms. Tartarus' Gate et RecycledGate utilisent l'instruction syscall appartenant à un stub mais le SSN d'un autre, donc même si vous enregistrez le nom du stub que votre hook rapporte, c'est un mensonge. VEH syscall déclenche intentionnellement une violation d'accès et utilise son propre VEH pour réécrire le contexte afin que RIP atterrisse sur l'instruction syscall de ntdll avec les registres déjà préparés. Hell's Gate n'utilise pas du tout ntdll. Le chargeur écrit 0F 05 dans sa propre page RWX et s'exécute à partir de là.
La réponse en mode noyau (KM) est un pilote, je pourrais éventuellement m'y mettre et publier un projet, mais ce n'est pas prévu de sitôt :D
PAGE_GUARD semble devoir fonctionner. Marquez la page qui contient les octets syscall avec PAGE_GUARD, interceptez le STATUS_GUARD_PAGE_VIOLATION dans un VEH, inspectez, redirigez vers un trampoline privé, définissez le drapeau de piège, exécutez pas à pas, réarmez la page. Un piège par appel Nt, peu importe comment le chargeur y est arrivé. Cela fonctionne contre tout sauf Hell's Gate.
Le problème est que l'OS ne coopère pas. PAGE_GUARD est un coup unique, qu'est-ce que cela signifie ? chaque fois qu'il se déclenche, le bit est effacé et vous devez le remettre. Votre gestionnaire effectue des appels système (NtProtect pour remettre la garde, NtContinue pour reprendre) et ces appels système ont des stubs et ces stubs sont sur la page que vous venez de protéger. J'ai contourné la plupart de cela avec des stubs d'appel système privés construits sur une page séparée que nous possédions (allouer RWX, écrire mov r10,rcx; mov eax,SSN; syscall; ret, verrouiller RX, ne jamais toucher à ceux de ntdll), mais c'était fragile. Chaque version mineure de Windows changeait le timing. Chaque thread que l'échantillon engendrait était une autre course contre le worker d'intégrité qui reconstruisait la garde. Le succès de la démo était d'environ 30 à 50 pour cent sur dix exécutions sur la même machine.
Finalement, j'ai arrêté d'essayer de convaincre Windows que PAGE_GUARD devait se comporter comme je le voulais, et j'ai plutôt essayé de remplacer l'octet.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
L'octet à l'instruction syscall est 0F 05. Le premier octet seul (0F) est le préfixe d'une famille d'opcodes à deux octets incluant SYSCALL, CPUID et RDTSC. En lui-même, il n'est pas exécutable ; le CPU a besoin du second octet pour décoder. Si vous remplacez le premier octet par 0xCC (INT3), la paire devient CC 05, que le CPU décode comme INT3 suivi d'un octet errant qu'il n'atteint jamais. Tout chemin de code qui atterrit sur cette adresse déclenche EXCEPTION_BREAKPOINT.
C'est tout le mécanisme. Notre VEH intercepte le point d'arrêt, recherche à quel stub appartient l'adresse (nous construisons la carte au moment de l'initialisation en énumérant les exports de ntdll), exécute trois vérifications sur celui qui s'est présenté, et définit Context->Rip sur un trampoline privé qui effectue le véritable appel système et retourne. L'octet reste CC. Le prochain appelant le rencontre de la même manière, donc pas de basculement de protection de page en avant et en arrière.
Pour être clair : oui, il s'agit de hook par remplacement d'octet. Ce n'est juste pas à l'octet que les gens entendent habituellement par "hook ntdll". Le hook EDR classique remplace les premiers octets du stub par un JMP <my_func> :
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; remplace "mov r10, rcx"
...
0F 05 syscall
C3 ret
c'est exactement ce que les appels système indirects contournent. Le chargeur lit le SSN lui-même et saute directement sur le 0F 05, le prologue (et votre jmp) ne s'exécute jamais, le hook ne se déclenche jamais. Ce que nous faisons, c'est remplacer l'octet syscall lui-même :
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; non touché
B8 18 00 00 00 mov eax, 18h ; non touché
CC 05 int3 / 05 ; était 0F 05, nous avons écrit CC sur le 0F
C3 ret
maintenant, peu importe comment vous êtes arrivé à cette adresse. via le prologue, via un saut indirect qui saute le prologue, via une réécriture de contexte VEH-syscall qui dépose RIP là, peu importe. Si le CPU exécute cet octet, il piège. Même famille de technique que le hook classique, emplacement différent, couverture complètement différente.
Le trampoline ressemble à ceci :
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
Les trois vérifications sont inchangées par rapport à la version PAGE_GUARD car c'étaient les bonnes trois vérifications ; elles avaient juste besoin d'un endroit fiable pour vivre.
Adresse de retour. [RSP] est l'endroit où l'appel système serait retourné. Pour un appel réel, c'est à l'intérieur de ntdll, kernel32, kernelbase, ou l'une des DLL runtime associées. Pour Hell's Hall, c'est à l'intérieur de la page RWX à partir de laquelle le chargeur s'exécute. Nous avons une courte liste de cibles de retour de confiance construite à l'initialisation en appelant GetModuleHandle pour ces noms et en lisant les plages .text de leurs en-têtes PE.
SSN. Le prologue du stub s'est déjà exécuté avant d'atteindre l'octet syscall, donc eax contient la valeur qui y a été chargée. Si le chargeur a fait un échange Tartarus, cette valeur ne correspondra pas au SSN que nous avons lu dans ce même stub lors de l'énumération. Nous enregistrons la divergence, et le trampoline écrit le bon SSN avant son propre syscall, de sorte que la fonction noyau qui s'exécute est celle qui appartient à l'octet plutôt que celle que le chargeur voulait. La technique est enregistrée et neutralisée dans la même étape.
Parcours de la pile. RtlVirtualUnwind à partir du contexte actuel, cinq trames vers le haut. Le RIP de chaque trame doit être à l'intérieur d'un module connu et doit avoir une entrée RUNTIME_FUNCTION. Les shellcodes et gadgets ROP échouent à cela même lorsque [RSP] lui-même semble fiable, ce qu'un chargeur peut simuler (il peut prédire approximativement où son appelant sera en mémoire et forger une adresse de retour crédible à cet endroit).
Si une vérification échoue, nous poussons une petite structure dans un anneau sans verrou et le thread de vidage l'imprime la prochaine fois qu'il se réveille
[!! hallwatch !!] indirect syscall (untrusted caller, wrong ssn for this stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub encodes 0x00000018)
thread : 26388
Hell's Gate est l'endroit où INT3 cesse d'être utile. Nous n'avons jamais patché la page RWX du chargeur parce que nous ne savions pas qu'elle existait.
Ce que nous faisons à la place est le scanner le plus simple qui pourrait fonctionner. Toutes les 250 ms (essentiellement trop fréquent, gaspille des cycles CPU mais c'est pour une preuve de concept) le worker d'intégrité parcourt l'espace d'adressage avec VirtualQuery, en regardant toute région MEM_COMMIT avec une protection de page exécutable. Si la région est à l'intérieur d'un module chargé, elle est ignorée. Si elle est à l'intérieur de notre propre pool de trampolines, elle est ignorée. Ce qui reste est de la mémoire exécutable étrangère. Nous scannons jusqu'à 64 Ko de celle-ci pour les paires d'octets 0F 05, dédupliquons par adresse, enregistrons chaque hit unique une fois, je sais que c'est stupide et vous gaspillez aussi des cycles CPU, mais c'est une preuve de concept qui ne devrait pas minimiser la situation mais le fait actuellement.
Cela attrape Hell's Gate dans une nouvelle allocation RWX et cela attrape le shadow ntdll (un chargeur faisant NtMapViewOfSection sur ntdll.dll pour obtenir une copie privée à une nouvelle adresse). Le cas shadow est amusant. Le mappage n'est pas dans notre instantané de module, donc ses instructions syscall apparaissent comme des octets exécutables étrangers, même si elles proviennent d'une DLL signée parfaitement légitime sur le disque.
Autre variante : l'attaquant intègre 0F 05 dans le .text de son propre binaire, de sorte que le syscall réside à l'intérieur d'un module chargé au lieu d'une page RWX. Foreign-RWX ignore les modules chargés, donc cela pouvait passer. Nous avons donc un autre scanner qui parcourt les octets exécutables de chaque module, ignore ntdll, win32u et wow64cpu (les trois modules qui ont légitimement des instructions syscall), et signale 0F 05 seulement s'il y a un C3 dans les trois octets suivants. La vérification ret est ce qui le rend utilisable. 0F 05 seul apparaît souvent dans la sortie du compilateur comme parties d'instructions plus grandes, mais n'est jamais adjacent à un C3. Les vrais stubs syscall le sont toujours. Journal :
[hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate inside loaded module .text
Le worker d'intégrité a deux autres petites tâches. Il lit l'octet à chaque stub patché à chaque tick et écrit CC à nouveau s'il n'est pas CC. C'est le dé-hook le plus simple possible : l'échantillon lit le 0F original de quelque part (un nouveau mappage de ntdll, le PE sur disque, une table d'octets connue comme bonne) et l'écrit à nouveau sur notre patch. Nous le remarquons au tick suivant et patchons à nouveau.
Tous les cinquantième ticks (environ 12,5 secondes), le worker installe à nouveau notre VEH en tête de chaîne et supprime le handle précédent. Un échantillon peut parcourir LdrpVectorHandlerList et nous désenregistrer ; le worker limite la durée pendant laquelle cette fenêtre reste ouverte. Douze secondes et demie est une longue fenêtre si vous êtes attaqué et une courte si vous exécutez un malware dans un bac à sable. Je vais probablement la raccourcir une fois que j'aurai mesuré le coût de le faire plus souvent.
Le thread worker s'exécute avec un drapeau TLS défini que le VEH vérifie. Les propres appels système du worker (NtProtect pour le patching, NtQuery pour le scan) déclenchent nos points d'arrêt comme n'importe quoi d'autre, mais le drapeau indique au VEH de sauter la logique de détection et de rediriger silencieusement via le trampoline.
La section critique s'initialise elle-même la première fois que quelqu'un appelle, via un échange-comparaison à trois états (0 = non initialisé, 2 = occupé, 1 = prêt). C'est moche mais cela évite d'avoir besoin d'un constructeur statique à l'intérieur d'une DLL, ce qui sur Windows est un ensemble distinct de problèmes impliquant le verrou du chargeur, etc.
Une chose ABI à signaler. INT3 est un piège, ce qui signifie que Context->Rip pointe vers l'instruction suivante lorsque notre VEH est appelé, pas le piège lui-même. Si nous avons patché l'octet à 0x7FF827660372, Context->Rip arrive au VEH comme 0x7FF827660373. Le Record->ExceptionAddress pointe vers le piège, mais nous devons réinitialiser Context->Rip = ExceptionAddress avant de le rediriger vers le trampoline, sinon le trampoline démarre un octet trop tard et le syscall ne fait rien d'utile, je vous le dis parce que c'était un bug qui m'a pris un temps fou à trouver.
Il y a quatre exports. IscInitialize arme le détecteur ; DllMain l'appelle automatiquement mais vous pouvez l'appeler depuis un processus hôte si vous voulez une valeur de retour. IscGetDetectionCount retourne un compteur croissant de manière monotone. IscShutdown attend les gestionnaires en vol et restaure les octets 0F. IscFlush vide de manière synchrone l'anneau, utile si vous intégrez le détecteur dans un bac à sable qui a besoin des événements avant que l'échantillon ne se termine.
L'intégration minimale est LoadLibrary. DllMain gère l'initialisation et démarre les deux threads d'arrière-plan à partir de là.
Isc = Appels Système Indirects (Indirect Syscalls)
Choses que nous n'attrapons pas encore.
Un échantillon qui effectue des vérifications d'intégrité
Un échantillon utilisant des stubs que nous n'avons pas patchés. La liste blanche actuelle comporte environ 40 noms couvrant les primitives offensives de mémoire, processus, thread, section, jeton et fichier. Ajouter plus est incrémental tant que DllMain se termine rapidement. Patcher les 488 stubs depuis l'intérieur du verrou du chargeur, c'est juste euh...
Un échantillon s'exécutant avec des privilèges noyau. Ce n'est pas un problème en mode utilisateur.
INT3 est ce que nous avons finalement choisi, mais le mécanisme de piège n'est pas la partie intéressante. La raison pour laquelle il fonctionne mieux que PAGE_GUARD est que le piège ne nécessite pas de négociation continue avec l'OS. L'octet est CC. Il reste CC. L'OS n'a pas d'opinion sur les octets qui vivent dans ntdll.