
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.
O que fazemos em vez disso é o escâner mais simples que poderia funcionar. A cada 250 ms (basicamente muito frequente, desperdiça ciclos de CPU mas é para uma prova de conceito), o worker de integridade percorre o espaço de endereços com VirtualQuery, olhando qualquer região MEM_COMMIT com uma proteção de página executável. Se a região estiver dentro de um módulo carregado, é ignorada. Se estiver dentro de nosso próprio pool de trampolins, é ignorada. O que resta é memória executável estrangeira. Escaneamos até 64 KB dela em busca de pares de bytes 0F 05, deduplicamos por endereço, registramos cada ocorrência única uma vez. Sei que é bobo e também desperdiça ciclos de CPU, mas é uma prova de conceito que não deveria minimizar a situação, mas atualmente o faz.
Isso captura Hell's Gate em uma nova alocação RWX e também captura shadow ntdll (um loader fazendo NtMapViewOfSection em ntdll.dll para obter uma cópia privada em um novo endereço). O caso shadow é divertido. O mapeamento não está em nosso instantâneo de módulos, então suas instruções syscall aparecem como bytes executáveis estrangeiros, mesmo que tenham vindo de uma DLL perfeitamente legítima e assinada em disco.
Outra variante: o atacante incorpora 0F 05 no .text do próprio binário, de modo que o syscall viva dentro de um módulo carregado em vez de uma página RWX. Foreign-RWX pula módulos carregados, então isso costumava escapar. Então temos outro escâner que percorre os bytes executáveis de cada módulo, pula ntdll, win32u e wow64cpu (os três módulos que legitimamente têm instruções syscall) e reporta 0F 05 apenas se houver um C3 nos próximos três bytes. A verificação ret é o que o torna utilizável. 0F 05 sozinho aparece na saída do compilador como partes de instruções maiores o tempo todo, mas nunca é adjacente a um C3. Stubs de syscall reais sempre são. Log:
[hallwatch] scan: module-text syscall stub em 0x00007FF7FFC9227C em HellsHall-Demo.exe -- Hell's Gate dentro do .text do módulo carregado
O worker de integridade tem mais duas pequenas tarefas. A cada tick, ele lê o byte em cada stub corrigido e escreve CC de volta se não for CC. Essa é a maneira mais simples de anti-hook: a amostra lê o 0F original de algum lugar (um mapeamento fresco de ntdll, o PE em disco, uma tabela de bytes conhecida) e o escreve de volta sobre nosso patch. Percebemos no próximo tick e corrigimos novamente.
A cada quinquagésimo tick (cerca de 12,5 segundos), o worker instala novamente nosso VEH no início da cadeia e remove o handle anterior. Uma amostra pode percorrer LdrpVectorHandlerList e nos desregistrar; o worker limita quanto tempo essa brecha fica aberta. Doze segundos e meio é uma janela longa se você está sendo atacado e curta se você está executando malware em um sandbox. Provavelmente vou encurtar depois de medir o custo de fazer isso com mais frequência.
A thread worker roda com uma flag TLS definida que o VEH verifica. Os próprios syscalls do worker (NtProtect para patch, NtQuery para escaneamento) acionam nossos breakpoints como qualquer outro, mas a flag diz ao VEH para pular a lógica de detecção e redirecionar silenciosamente através do trampolim.
A seção crítica se inicializa na primeira vez que alguém chama, por meio de uma troca comparativa de três estados (0 = não iniciado, 2 = ocupado, 1 = pronto). É feio, mas evita a necessidade de um construtor estático dentro de uma DLL, que no Windows é um conjunto separado de problemas envolvendo o lock do loader, etc.
Uma coisa de ABI que vale a pena mencionar. INT3 é um trap, o que significa que Context->Rip aponta para a próxima instrução quando nosso VEH é chamado, não para o trap em si. Se corrigimos o byte em 0x7FF827660372, Context->Rip chega ao VEH como 0x7FF827660373. O Record->ExceptionAddress aponta para o trap, mas temos que redefinir Context->Rip = ExceptionAddress antes de redirecioná-lo para o trampolim, caso contrário o trampolim começa um byte atrasado e o syscall não faz nada útil — estou lhe dizendo isso porque foi um bug que me custou um bom tempo para encontrar.
Há quatro exportações. IscInitialize arma o detector; DllMain a chama automaticamente, mas você pode chamá-la de um processo host se quiser um valor de retorno. IscGetDetectionCount retorna um contador monotonicamente crescente. IscShutdown espera os handlers em andamento e restaura os bytes 0F. IscFlush drena o anel de forma síncrona, útil se você está embutindo o detector em um sandbox que precisa dos eventos antes da amostra sair.
Integração mínima é LoadLibrary. DllMain lida com a inicialização e inicia ambas as threads de fundo a partir daí.
Isc = Syscalls Indiretos
Coisas que ainda não pegamos.
Uma amostra que faz verificações de integridade.
Uma amostra que usa stubs que não corrigimos. A allowlist atual tem cerca de 40 nomes cobrindo primitivas ofensivas de memória, processo, thread, seção, token e arquivo. Adicionar mais é incremental, desde que DllMain termine rapidamente. Corrigir todos os 488 stubs de dentro do lock do loader... é meio complicado.
Uma amostra rodando com privilégios de kernel. Não é um problema do modo usuário.
INT3 é o que acabamos escolhendo, mas o mecanismo de trap não é a parte interessante. A razão pela qual funciona melhor do que PAGE_GUARD é que o trap não requer uma negociação contínua com o SO. O byte é CC. Permanece CC. O SO não tem opinião sobre quais bytes vivem em ntdll.