InfoSec Write-ups

A collection of write-ups from the best hackers in the world on topics ranging from bug bounties and CTFs to vulnhub machines, hardware challenges and real life encounters. Subscribe to our weekly newsletter for the coolest infosec updates: https://weekly.infosecwriteups.com/

Follow publication

DLL Injection With Rust

Credit: LRQA Nettitude

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 and WriteProcessMemory, then invoked with CreateRemoteThread (which calls the LoadLibrary 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 process
  • injection() — This is our function we want to run we inject into the process
  • allocate_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 CreateThreadwithin 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
}
  1. First, it is defined as an unsafe function, as it is going to be making calls to unsafe functions.
  2. Second, it is defined as extern which allows it to be called by foreign code (such as when injected into a process).
  3. 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.
  4. Finally, it returns a u32 , as this is the expected returned value of a function called in DLLMain

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:

  1. Call to AllocateConsole() API function to create a new console
  2. Define stdout_handle by making a call to CreateFileA() . This creates a file handle for standard output.
  3. Check that the stdout_handle was created successfully
  4. It sets the standard output handle to our newly created handle
  5. 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:

  1. Get the handle to the target process, in this case Notepad.exe
  2. Create a Syringe for the target process
  3. Inject the payload
  4. Wait 10 seconds
  5. 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:

Published in InfoSec Write-ups

A collection of write-ups from the best hackers in the world on topics ranging from bug bounties and CTFs to vulnhub machines, hardware challenges and real life encounters. Subscribe to our weekly newsletter for the coolest infosec updates: https://weekly.infosecwriteups.com/

No responses yet

Write a response