
Detector de modo de usuário que captura chamadas de sistema indiretas. Captura Hell's Hall, Tartarus' Gate, RecycledGate, e chamadas de sistema VEH e muitos outros.
Copyright (C) 2026 Adam Zypherion <[email protected]> — licenciado sob GPL-3.0.
Syscalls indiretos são simples depois que você já viu um. O loader percorre ntdll, lê o SSN do prólogo de Nt*, encontra os dois bytes 0F 05 mais adiante, define r10/rax/rdx/r8/r9 por conta própria e faz jmp diretamente para esses dois bytes. O syscall acontece de dentro do ntdll. Seu hook na exportação kernel32 nunca é tocado. Seu hook na exportação ntdll nunca é tocado. [RSP] no momento do SYSCALL aponta de volta para a página RWX do loader, mas nada na chamada parece incomum visto de fora.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
As variantes têm nomes. Tartarus' Gate e RecycledGate usam a instrução syscall pertencente a um stub, mas o SSN de outro; então, mesmo que você registre o nome do stub que seu hook reporta, é uma mentira. VEH syscall intencionalmente dispara uma violação de acesso e usa seu próprio VEH para reescrever o contexto de forma que RIP caia na instrução syscall do ntdll com os registradores já preparados. Hell's Gate não usa ntdll. O loader escreve 0F 05 em sua própria página RWX e executa a partir dali.
A resposta em modo kernel (KM) é um driver. Talvez um dia eu chegue lá e lance um projeto, mas não vai acontecer tão cedo :D
PAGE_GUARD parece que deveria funcionar. Marque a página que contém os bytes do syscall com PAGE_GUARD, capture o STATUS_GUARD_PAGE_VIOLATION em um VEH, inspecione, redirecione para um trampolim privado, ative a flag de trap, execute um único passo, saia, rearme a página. Um trap por chamada Nt, não importa como o loader chegou lá. Funciona contra tudo, exceto Hell's Gate.
O problema é que o SO não coopera. PAGE_GUARD é de uso único — o que isso significa? toda vez que é acionado, o bit é limpo e você precisa recolocá-lo. Seu handler faz syscalls (NtProtect para recolocar a guarda, NtContinue para retomar) e esses syscalls têm stubs, e esses stubs estão na página que você acabou de proteger. Eu contornei a maior parte disso com stubs de syscall privados construídos em uma página separada que controlávamos (alocar RWX, escrever mov r10,rcx; mov eax,SSN; syscall; ret, travar RX, nunca tocar no ntdll), mas era frágil. Cada versão menor do Windows mudava o tempo. Cada thread que a amostra gerava era outra corrida contra o worker de integridade que estava reconstruindo a guarda. O sucesso da demonstração ficava entre 30 e 50 por cento em dez execuções na mesma máquina.
Eventualmente parei de tentar convencer o Windows de que PAGE_GUARD deveria se comportar como eu queria e tentei sobrescrever o byte.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
O byte na instrução syscall é 0F 05. Apenas o primeiro byte (0F) é o prefixo para uma família de opcodes de dois bytes, incluindo SYSCALL, CPUID e RDTSC. Sozinho, não é executável; a CPU precisa do segundo byte para decodificar. Se você substituir o primeiro byte por 0xCC (INT3), o par se torna CC 05, que a CPU decodifica como INT3 seguido por um byte perdido que nunca é alcançado. Qualquer caminho de código que cair nesse endereço levantará EXCEPTION_BREAKPOINT.
Esse é o mecanismo inteiro. Nosso VEH captura o breakpoint, procura a qual stub o endereço pertence (construímos o mapa na inicialização enumerando as exportações do ntdll), executa três verificações em quem apareceu e define Context->Rip para um trampolim privado que faz o syscall real e retorna. O byte permanece CC. O próximo chamador acerta do mesmo jeito, então não há alternância de proteção de página.
Para ser claro: sim, isso é hooking por sobrescrita de byte. Apenas não é no byte que as pessoas geralmente chamam de "hook ntdll". O hook clássico do EDR sobrescreve os primeiros bytes do stub com um JMP <my_func>:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; sobrescreve "mov r10, rcx"
...
0F 05 syscall
C3 ret
É exatamente isso que os syscalls indiretos contornam. O loader lê o SSN e pula direto para 0F 05, o prólogo (e seu jmp) nunca executa, o hook nunca dispara. O que fazemos é sobrescrever o próprio byte do syscall:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; intacto
B8 18 00 00 00 mov eax, 18h ; intacto
CC 05 int3 / 05 ; era 0F 05, escrevemos CC sobre o 0F
C3 ret
Agora não importa como você chegou àquele endereço. Através do prólogo, através de um salto indireto que pula o prólogo, através de uma reescrita de contexto VEH que larga RIP ali, tanto faz. Se a CPU executar aquele byte, ela prende. Mesma família de técnica do hook clássico, localização diferente, cobertura completamente diferente.
O trampolim se parece com isto:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
As três verificações são as mesmas da versão PAGE_GUARD porque eram as três verificações certas; elas só precisavam de um lugar confiável para viver.
Endereço de retorno. [RSP] é para onde o syscall teria retornado. Para uma chamada real, fica dentro de ntdll, kernel32, kernelbase ou uma das DLLs de runtime relacionadas. Para Hell's Hall, fica dentro de qualquer página RWX de onde o loader está executando. Temos uma pequena lista de alvos de retorno confiáveis, construída na inicialização chamando GetModuleHandle para esses nomes e lendo os intervalos .text de seus cabeçalhos PE.
SSN. O prólogo do stub já rodou antes de chegar ao byte do syscall, então eax contém qualquer valor que foi carregado nele. Se o loader fez uma troca Tartarus, esse valor não vai corresponder ao SSN que lemos deste mesmo stub na enumeração. Registramos a incompatibilidade, e o trampolim escreve o SSN correto antes de seu próprio syscall, de modo que a função do kernel que executa é aquela pertencente ao byte, não a que o loader queria. A técnica é registrada e neutralizada na mesma etapa.
Stack walk. RtlVirtualUnwind a partir do contexto atual, cinco quadros acima. O RIP de cada quadro deve estar dentro de um módulo conhecido e deve ter uma entrada RUNTIME_FUNCTION. Shellcode e gadgets ROP falham nisso mesmo quando [RSP] parece confiável, o que um loader pode falsificar (ele pode prever aproximadamente onde seu chamador estará na memória e forjar um endereço de retorno plausível lá).
Se alguma verificação falhar, empurramos uma pequena struct para um anel livre de bloqueio e a thread de drenagem imprime na próxima vez que acordar:
[!! hallwatch !!] syscall indireto (chamador não confiável, ssn errado para este stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub codifica 0x00000018)
thread : 26388
Hell's Gate é onde INT3 para de ajudar. Nunca corrigimos a página RWX do loader porque nunca soubemos que ela existia.