
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).