DLL Injection With Rust

Overview
DLL injection is a commonly used and high value technique in both legitimate software modification and offensive security. It can do everything from extend a programs functionality to executing arbitrary attacker shellcode. Because of it’s high value, it’s a fundamental technique for any malware developer. In this article, I discuss how DLL injection is performed and walk through an example of creating and injecting a DLL written in Rust.
The completed code for this exercise can be found below.
Note: this article is written purely for educational purposes. The tactics, techniques, and procedures discussed here are intended for students, defenders, and legitimate offensive security practitioners.
What is DLL Injection?
In short: DLL injection is a method of executing arbitrary code within the address space of a separate listed process by allocating virtual address space and injecting an attacker controlled Dynamic-Link Library (DLL). MITRE defines the methodology as such:
DLL injection is commonly performed by writing the path to a DLL in the virtual address space of the target process before loading the DLL by invoking a new thread. The write can be performed with native Windows API calls such as
VirtualAllocEx
andWriteProcessMemory
, then invoked withCreateRemoteThread
(which calls theLoadLibrary
API responsible for loading the DLL).
Source: MITRE ATT&CK
The potential uses of such a technique should be obvious, but to name a few legitimate (and illegitimate uses):
- Extend the functionality of a program
- Fix a bug in a program
- Execute an attacker’s shellcode
- Modify the memory of the running process (such as hacking a game)
Writing a DLL in Rust
We’ll walk through a simple example of creating a DLL in Rust. Our DLL will attempt to allocate a new console to the injected process and print a line to that console. This DLL will have 3 functions:
DLLMain()
— The standard function that determines what actions will be taken when a DLL is inserted into the processinjection()
— This is our function we want to run we inject into the processallocate_console()
— A function that uses Windows API calls to allocate a new console to a process and redirect standard output and error to that console.
To write a DLL in Rust, it requires a number of modifications to cargo.toml. Rust supports the creation of DLL’s, but not as handily as some other C based languages. First, we need to define the [lib] element. This tells the compiler that the lib.rs file is going to be compiled as a DLL, it’s path, and what to name the DLL once it’s been compiled.
[package]
name = "dll_injection"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dll-syringe = '0.15.2'
winapi = { version = '0.3.9', features = ['minwindef', 'winnt', 'handleapi','processthreadsapi', 'consoleapi', 'errhandlingapi', 'processenv', 'fileapi']}
[lib]
crate-type = ["cdylib"]
src = "src/lib.rs"
name = "injection_dll"
[toolchain]
channel = "nightly"
targets = ["x86_64-pc-windows-msvc"]
We’ll also define the nightly
toolchain in cargo.toml
. Some of the features used in DLL injection in Rust are experimental, and thus not supported by the stable channel.
In the lib.rs file, Rust supports the use of a DLLMain
function. The syntax is relatively similar to to C++, as it uses an exhaustive match case to determine the actions of the process based on if the DLL is being attached or detached from the process or thread.
#[no_mangle]
pub unsafe extern "stdcall" fn DllMain(
_hinst_dll: HINSTANCE,
fdw_reason: DWORD,
_lpv_reserved: LPVOID
) -> BOOL {
match fdw_reason {
DLL_PROCESS_ATTACH => unsafe {
CloseHandle(CreateThread(null_mut(), 0, Some(injection), null_mut(), 0, null_mut()));
}
DLL_PROCESS_DETACH => {}
DLL_THREAD_ATTACH => {}
DLL_THREAD_DETACH => {}
_ => {}
}
1
}
When performing DLL injection, it’s best practice to use the Windows API function CreateThread
within the CloseHandle()
function. This helps ensure that our functionality runs and cleans up properly.
Within our DLL main code, the function we want to execute is the third argument of the CreateThread()
function call, where we see Some(injection)
. This is a function, called injection
, defined in the same lib.rs
file.
The prelude to this function is critical. It must be defined a specific way in order to ensure that it functions correctly when used in DLLMain
unsafe extern "system" fn injection(_lp_parameter: *mut c_void) -> u32 {
match allocate_console() {
Ok(_) => {}
Err(e) => {
println!("Error: {:?}", e);
println!("Error code: {:?}", GetLastError());
}
}
println!("Hello, from a DLL!");
1
}
- First, it is defined as an
unsafe
function, as it is going to be making calls to unsafe functions. - Second, it is defined as
extern
which allows it to be called by foreign code (such as when injected into a process). - Third, it has a single parameter
_lp_parameter: *mut c_void
. This is generally used to pass an argument to a function being called when the DLL attaches to the process, such as the process handle. However, we are not using this argument in our function, so it is prepended with an underscore. - Finally, it returns a
u32
, as this is the expected returned value of a function called inDLLMain
We’ve now properly defined our first DLLMain
and the function to be injected. Within the injection
function, we can see that it makes a call to allocate_console()
. This will use Windows API calls to allocate a new console for us.
unsafe fn allocate_console() -> Result<*mut c_void, Box<dyn Error>> {
// Allocate a console
if AllocConsole() == 0 {
return Err(Box::new(std::io::Error::last_os_error()));
}
// Redirect STDOUT to the new console
let stdout_handle = CreateFileA(
b"CONOUT$\0".as_ptr() as *const i8,
GENERIC_WRITE | GENERIC_READ,
0,
null_mut(),
OPEN_EXISTING,
0,
null_mut()
);
if stdout_handle == INVALID_HANDLE_VALUE {
let error_code = GetLastError();
return Err(format!("Failed to open CONOUT$ for STDOUT. Error code {}", error_code).into());
}
if SetStdHandle(STD_OUTPUT_HANDLE, stdout_handle) == 0 {
let error_code = GetLastError();
return Err(format!("Failed to redirect STDOUT. Error code {}", error_code).into());
}
// Redirect stderr
let stderr_handle = CreateFileA(
b"CONOUT$\0".as_ptr() as *const i8,
GENERIC_WRITE | GENERIC_READ,
0,
null_mut(),
OPEN_EXISTING,
0,
null_mut()
);
if stderr_handle == INVALID_HANDLE_VALUE {
let error_code = GetLastError();
return Err(format!("Failed to open CONOUT$ for STDERR. Error code {}", error_code).into());
}
if SetStdHandle(STD_OUTPUT_HANDLE, stderr_handle) == 0 {
let error_code = GetLastError();
return Err(format!("Failed to redirect STDERR. Error code {}", error_code).into());
}
Ok(null_mut())
}
This looks much more complex than it actually is:
- Call to
AllocateConsole()
API function to create a new console - Define
stdout_handle
by making a call toCreateFileA()
. This creates a file handle for standard output. - Check that the
stdout_handle
was created successfully - It sets the standard output handle to our newly created handle
- It performs the previous three steps to redirect standard error as well.
And with that, we have our DLL. Our final, complete library is below
use std::error::Error;
use std::ptr::null_mut;
use winapi::ctypes::c_void;
use winapi::shared::minwindef::{ HINSTANCE, DWORD, LPVOID, BOOL };
use winapi::um::processenv::SetStdHandle;
use winapi::um::winbase::STD_OUTPUT_HANDLE;
use winapi::um::winnt::{
DLL_PROCESS_ATTACH,
DLL_PROCESS_DETACH,
DLL_THREAD_ATTACH,
DLL_THREAD_DETACH,
GENERIC_READ,
GENERIC_WRITE,
};
use winapi::um::processthreadsapi::CreateThread;
use winapi::um::handleapi::{ CloseHandle, INVALID_HANDLE_VALUE };
use winapi::um::errhandlingapi::GetLastError;
use winapi::um::consoleapi::AllocConsole;
use winapi::um::fileapi::{ CreateFileA, OPEN_EXISTING };
unsafe extern "system" fn injection(_lp_parameter: *mut c_void) -> u32 {
match allocate_console() {
Ok(_) => {}
Err(e) => {
println!("Error: {:?}", e);
println!("Error code: {:?}", GetLastError());
}
}
println!("Hello, from a DLL!");
1
}
unsafe fn allocate_console() -> Result<*mut c_void, Box<dyn Error>> {
// Allocate a console
if AllocConsole() == 0 {
return Err(Box::new(std::io::Error::last_os_error()));
}
// Redirect STDOUT to the new console
let stdout_handle = CreateFileA(
b"CONOUT$\0".as_ptr() as *const i8,
GENERIC_WRITE | GENERIC_READ,
0,
null_mut(),
OPEN_EXISTING,
0,
null_mut()
);
if stdout_handle == INVALID_HANDLE_VALUE {
let error_code = GetLastError();
return Err(format!("Failed to open CONOUT$ for STDOUT. Error code {}", error_code).into());
}
if SetStdHandle(STD_OUTPUT_HANDLE, stdout_handle) == 0 {
let error_code = GetLastError();
return Err(format!("Failed to redirect STDOUT. Error code {}", error_code).into());
}
// Redirect stderr
let stderr_handle = CreateFileA(
b"CONOUT$\0".as_ptr() as *const i8,
GENERIC_WRITE | GENERIC_READ,
0,
null_mut(),
OPEN_EXISTING,
0,
null_mut()
);
if stderr_handle == INVALID_HANDLE_VALUE {
let error_code = GetLastError();
return Err(format!("Failed to open CONOUT$ for STDERR. Error code {}", error_code).into());
}
if SetStdHandle(STD_OUTPUT_HANDLE, stderr_handle) == 0 {
let error_code = GetLastError();
return Err(format!("Failed to redirect STDERR. Error code {}", error_code).into());
}
Ok(null_mut())
}
#[no_mangle]
pub unsafe extern "stdcall" fn DllMain(
_hinst_dll: HINSTANCE,
fdw_reason: DWORD,
_lpv_reserved: LPVOID
) -> BOOL {
match fdw_reason {
DLL_PROCESS_ATTACH => unsafe {
CloseHandle(CreateThread(null_mut(), 0, Some(injection), null_mut(), 0, null_mut()));
}
DLL_PROCESS_DETACH => {}
DLL_THREAD_ATTACH => {}
DLL_THREAD_DETACH => {}
_ => {}
}
1
}
Injecting a DLL with Rust
The are many ways to approach the actual injection. There are commercial DLL injection tools, or you can do it the hard way and write it yourself. Fortunately, there’s already a Rust crate made specifically to handle DLL injection, dll_syringe
. In the context of this example, we’re going to build the DLL injection tool in main.rs
under the same project as our DLL, that way it can all be compiled together.
The injection process is very straight forward:
- Get the handle to the target process, in this case Notepad.exe
- Create a Syringe for the target process
- Inject the payload
- Wait 10 seconds
- Eject the payload.
use std::thread::sleep;
use dll_syringe::{Syringe, process::OwnedProcess};
fn main() {
// Find the target process
let target_process = OwnedProcess::find_first_by_name("Notepad.exe").unwrap();
print!("Found target process: {:?}", target_process);
// Create a syringe for the target process
let syringe = Syringe::for_process(target_process);
// Inject the DLL into the target process
let injected_payload = syringe.inject("target/debug/injection_dll.dll").unwrap();
println!("Injected payload: {:?}", injected_payload);
sleep(std::time::Duration::from_secs(10));
println!("Ejecting DLL");
syringe.eject(injected_payload).unwrap();
}
Check out the demo below to see how it all works in action: