Module 4: ntdll.dll Stub Anatomy
The 20-byte pattern that every direct syscall technique is built on.
From Theory to Bytes
Now we move from abstract concepts to concrete bytes. Every Nt*/Zw* syscall function in ntdll.dll follows a predictable machine code pattern. Hell's Gate reads these bytes directly from memory to extract the SSN. In this module, you will learn the exact byte layout of a syscall stub, how to find these stubs via the Export Address Table (EAT), and the data structures Hell's Gate uses to represent them.
The Syscall Stub Pattern
On x64 Windows 10 and later, every Nt* syscall stub in ntdll.dll follows this byte pattern:
ASM; Standard ntdll syscall stub layout (x64, Windows 10+)
; Total: approximately 20 bytes
;
; Offset Bytes Instruction
; ------ ----------------- -----------
; 0x00 4c 8b d1 mov r10, rcx ; save 1st param (RCX clobbered by syscall)
; 0x03 b8 XX XX 00 00 mov eax, <SSN> ; load System Service Number
; 0x08 f6 04 25 08 03 fe test byte ptr [7FFE0308h], 1 ; check SharedUserData
; 0x0F 7f 01 ; (VBS/HVCI check)
; 0x10 75 03 jnz short +3 ; if set, use int 2e path
; 0x12 0f 05 syscall ; Ring 3 -> Ring 0
; 0x14 c3 ret ; return to caller
; 0x15 cd 2e int 0x2e ; alternative syscall path
; 0x17 c3 ret
Stub Layout Varies by Windows Version
The exact byte layout has changed across Windows versions. On Windows 7, the stub is simpler (no hypervisor check). On early Windows 10 builds, the test/jnz sequence differs. The critical constant that Hell's Gate relies on is the first 8 bytes: the mov r10, rcx (3 bytes) followed by mov eax, SSN (5 bytes). These are stable across all x64 Windows versions. Hell's Gate checks for the byte pattern 4c 8b d1 b8 at the start to confirm a clean, unhooked stub.
Byte-Level Breakdown
Let us examine the bytes that Hell's Gate reads and validates:
| Offset | Bytes | Meaning | Hell's Gate Check |
|---|---|---|---|
| 0x00 | 4c 8b d1 | mov r10, rcx | Byte 0 must be 0x4c, byte 1 must be 0x8b, byte 2 must be 0xd1 |
| 0x03 | b8 | mov eax, (opcode) | Byte 3 must be 0xb8 |
| 0x04 | XX XX 00 00 | SSN value (little-endian DWORD) | Extract as *(WORD*)(stub + 4) -- the SSN |
| 0x06-0x07 | 00 00 | High 2 bytes of SSN (always zero, SSNs < 0x10000) | Used as secondary validation |
Hell's Gate reads the SSN from bytes at offset 4 and 5 of the stub. Since SSNs are always less than ~500, bytes 6 and 7 are always 0x00, which provides an additional integrity check.
C// How Hell's Gate reads the SSN from a stub pointer
// 'pFunctionAddress' points to the start of the Nt* function
BYTE* stub = (BYTE*)pFunctionAddress;
// Validate the stub pattern
if (stub[0] == 0x4c && // mov r10, rcx
stub[1] == 0x8b &&
stub[2] == 0xd1 &&
stub[3] == 0xb8) { // mov eax, <imm32>
// Extract SSN from bytes 4-5 (little-endian 16-bit)
WORD ssn = *(WORD*)(stub + 4);
// ssn now holds the System Service Number
}
Finding Stubs via the Export Address Table
To find the syscall stubs, Hell's Gate walks the Export Address Table (EAT) of ntdll.dll. The EAT is a PE data structure that maps function names to their addresses. Here is the process:
EAT Walking Process
Hell's Gate Data Structures
The original Hell's Gate code defines two key structures to represent a resolved syscall:
C// From HellsGate main.c - the VX_TABLE_ENTRY structure
typedef struct _VX_TABLE_ENTRY {
PVOID pAddress; // Address of the Nt* function in ntdll
DWORD64 dwHash; // DJB2 hash of the function name
WORD wSystemCall; // The resolved SSN (System Service Number)
} VX_TABLE_ENTRY, *PVX_TABLE_ENTRY;
// The VX_TABLE holds entries for all syscalls we need
typedef struct _VX_TABLE {
VX_TABLE_ENTRY NtAllocateVirtualMemory;
VX_TABLE_ENTRY NtProtectVirtualMemory;
VX_TABLE_ENTRY NtCreateThreadEx;
VX_TABLE_ENTRY NtWaitForSingleObject;
} VX_TABLE, *PVX_TABLE;
Each VX_TABLE_ENTRY stores three pieces of information: the address of the function in ntdll (for reading the stub bytes), a hash of the function name (for locating it in the EAT without storing the plaintext name), and the resolved SSN (the value that will be placed in EAX before syscall).
PEB Walking to Find ntdll Base
Before parsing the EAT, Hell's Gate needs the base address of ntdll.dll. It finds this by walking the Process Environment Block (PEB):
C// Hell's Gate finds ntdll.dll through the PEB
// The PEB is accessible via the TEB (Thread Environment Block)
// which is at gs:[0x60] on x64 Windows
// PEB->Ldr->InMemoryOrderModuleList contains loaded modules
// Module order: [0] = process exe, [1] = ntdll.dll
PPEB pPeb = (PPEB)__readgsqword(0x60); // Read PEB from TEB
PLDR_DATA_TABLE_ENTRY pLdrEntry =
(PLDR_DATA_TABLE_ENTRY)
((PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink // 2nd entry = ntdll
- 0x10); // adjust for LIST_ENTRY offset
PVOID ntdllBase = pLdrEntry->DllBase; // ntdll.dll base address
Why ntdll Is Always Second
In the InMemoryOrderModuleList, the first entry is always the process executable itself. The second entry is always ntdll.dll, because it is the first DLL loaded by the kernel (it is mapped before the process entry point runs). This is reliable across all Windows versions and is a standard technique used by many offensive tools.
API Hashing: Avoiding Plaintext Strings
Hell's Gate does not store function names as plaintext strings (which would be trivially detectable by static analysis). Instead, it uses DJB2 hashing to compare function names:
C// DJB2 hash function used by Hell's Gate
DWORD64 djb2(PBYTE str) {
DWORD64 dwHash = 0x7734773477347734; // Custom seed
CHAR c;
while (c = *str++)
dwHash = ((dwHash << 0x5) + dwHash) + c; // hash * 33 + c
return dwHash;
}
// Pre-computed hashes for target functions:
// djb2("NtAllocateVirtualMemory") = 0xF5BD373480A6B89B
// djb2("NtProtectVirtualMemory") = 0x858BCB1046FB6A37
// djb2("NtCreateThreadEx") = 0x64DC7DB288C5015F
// djb2("NtWaitForSingleObject") = 0xC6A2FA174E551BCB
When walking the EAT, Hell's Gate computes the DJB2 hash of each exported function name and compares it against pre-computed target hashes. This avoids embedding suspicious strings like "NtAllocateVirtualMemory" in the binary.
The Hashing Trade-Off
API hashing prevents simple string-based detection, but it introduces a different signature: the hash values and the hashing algorithm themselves become IOCs (Indicators of Compromise). Threat intelligence teams maintain databases of known API hash values. Using a custom seed (as Hell's Gate does with 0x7734773477347734) helps, but the DJB2 algorithm structure is still recognizable.
Pop Quiz: Stub Anatomy
Q1: In the ntdll syscall stub, at what byte offset is the SSN located?
mov r10, rcx (4c 8b d1), byte 3 is the mov eax opcode (b8), and bytes 4-7 are the SSN as a 32-bit little-endian immediate. The SSN occupies bytes 4 and 5 (with bytes 6-7 being 0x00 since SSNs are small).Q2: How does Hell's Gate detect that a stub has NOT been hooked?
Q3: Why does Hell's Gate use DJB2 hashing instead of string comparison for function names?