Hooking VirtualAllocEx
Hooking VirtualAllocEx for remote process memory allocation monitoring in Rust
Intro
Next up we need to look at hooking VirtualAllocEx, a function which allows malware authors to allocate memory in a remote process, and required for remote process injection.
These changes have been committed to my repo, so check it out here.
Things get a little more complicated when it comes to hooking VirtualAllocEx, compared to when we hooked ZwOpenProcess, as we are dealing with 6 parameters for ZwAllocateVirtualMemory, not 4.
Dealing with more than four arguments
The Windows ABI and calling convention expects the first four parameters passed to a function to be placed into the following registers:
- RCX
- RDX
- R8
- R9
Which is what we utilised for ZwOpenProcess
. However, we now have an additional 2 arguments to worry about for our hook in ZwAllocateVirtualMemory. How do these get passed around
functions and syscalls? Well, Windows expects them on the stack, directly after the shadow space.
Shadow space
Shadow space isn’t half as evil as it sounds, it is a 32-byte space on the stack, above the return address, which can be used by the compiler / windows as it sees fit, but it’s okay to leave these uninitialised, aka we can leave junk data in there as we will be including room for shadow space in our assembly.
So, before we can even think about pushing them onto the stack to comply with ABI requirements, we need to allocate the 32-bytes of shadow space, plus 8 bytes for a return address, which in our case we don’t care about so we can just leave whatever junk is on the stack there. With that 40 bytes extra on the stack, we can push our 5th and 6th function argument which is expected by ZwAllocateVirtualMemory.
It’s also worth knowing that once you start pushing function arguments onto the stack (so, the 5th and onwards) they get pushed in reverse order, unlike the first 4 arguments which are sequential.
Updating the function pointer tracker
In the previous syscall hook post, we created a function for automating the in memory patching of syscalls, well, we need to do a little refactor now we are dealing with more than 1 - the last implementation you can find there was a little shoddy, so this new way of working will serve us better as we can properly iterate over each hooked pair (NTDLL and our callback function pointer).
The new struct for tracking hooked pairs looks like:
/// A structure to hold the stub addresses for each callback function we wish to have for syscalls.
///
/// The address of each function within the DLL will be used to overwrite memory in the syscall, allowing us to jmp
/// to the address.
pub struct StubAddresses<'a> {
addresses: HashMap<&'a str, Addresses>,
}
struct Addresses {
edr: usize,
ntdll: usize,
}
The callback for ZwAllocateVirtualMemory
Okay, now the moment you have been waiting for - putting this all together. In summary, we need to allocate enough room on the stack for 2 arguments, a pointer, and the shadow space. Then, we need to make sure everything is in the right place on the stack, and then we can make the syscall. To cut a long story short:
/// Syscall hook for ZwAllocateVirtualMemory
#[unsafe(no_mangle)]
unsafe extern "system" fn virtual_alloc_ex(
process_handle: HANDLE,
base_address: *mut c_void,
zero_bits: usize,
region_size: *mut usize,
allocation_type: u32,
protect: u32,
) {
let ssn = 0x18;
unsafe {
asm!(
"sub rsp, 0x38", // reserve shadow space + 8 byte ptr as it expects a stack of that size
"mov [rsp + 0x30], {1}", // 8 byte ptr + 32 byte shadow space + 8 bytes offset from 5th arg
"mov [rsp + 0x28], {0}", // 8 byte ptr + 32 byte shadow space
"mov r10, rcx",
"syscall",
"add rsp, 0x38",
in(reg) allocation_type,
in(reg) protect,
in("rax") ssn,
in("rcx") process_handle.0,
in("rdx") base_address,
in("r8") zero_bits,
in("r9") region_size,
options(nostack),
);
}
}
Whats next
Now, we need to start inspecting some of the arguments into the function, and communicate with our EDR engine that the syscall stub has been reached so we can apply my research technique, Ghost Hunting.