Skip to content

Injecting DLLs With Rust

All right, dear reader. If you've followed along from Part 1 and Part 2, then you know how to:

  • Create a Windows dynamic-link library (DLL) using Rust
  • Inject DLLs into processes using Process Hacker
  • Inject DLLs into processes using Rust
  • Create application windows using Rust
  • Override a window's behavior using subclassing

In this chapter, we'll tie everything together by injecting hello.dll into a random process using Rust.

DLL Injection

Googling the term "dll injection" results in page after page of bad news; the term is primarily associated with malicious actors.

Wikipedia, however, presents a more neutral description which perfectly encapsulates the spirit in which we'll be approaching the topic:

In computer programming, DLL injection is a technique used for running code within the address space of another process by forcing it to load a dynamic-link library.[1] DLL injection is often used by external programs to influence the behavior of another program in a way its authors did not anticipate or intend. - source

Injection Techniques

Process Injection Techniques presents a good overview of the overall DLL injection landscape.

3 Effective DLL Injection Techniques for Setting API Hooksprovides implementation details for the two technique's that we'll explore together:

  • SetWindowsHookEx
  • CreateRemoteThread

SetWindowsHookEx

SetWindowsHookEx is a well documented part of the Windows Win32 API, which does what it says...

Enables the installation of "hooks", aka callback functions, at various points in an application, allowing you to intercept or passively sniff system events received by the hooked application.

but wait, there's more!

SetWindowsHookEx can be used to inject a DLL into another process... - source...

So, if you place your callback function in a DLL, the operating system conveniently loads said DLL into the application's address space.

Excellent.

CreateRemoteThread

CreateRemoteThread is a less well documented part of the Windows Win32 API, summarized:

Creates a thread that runs in the virtual address space of another process.

Once a "remote thread" is running in the application's address space, a mere LoadLibrary() call is all that's required to complete the DLL injection.

Further Reading


dll-syringe

The dll-syringe crate provides a convenient wrapper around CreateRemoteThread enabling us to inject DLLs programmatically, using Rust:

dll-syringe Example
use dll_syringe::{Syringe, process::OwnedProcess};

// find target process by name
let target_process = OwnedProcess::find_first_by_name("ExampleProcess").unwrap();

// create a new syringe for the target process
let syringe = Syringe::for_process(target_process);

// inject the payload into the target process
let injected_payload = syringe.inject("injection_payload.dll").unwrap();

// do something else

// eject the payload from the target (optional)
syringe.eject(injected_payload).unwrap();

Further Reading


hello_inject.exe

Let's create a hello_inject crate to take dll-syringe for a whirl:

cargo new hello_inject

After updating the workspace Cargo.toml...

Cargo.toml
[workspace]

members = [
    "hello",
    "hello_runner",
    "create_window",
    "hello_inject"
]

And, adding the dependencies to hello.dll and dll-syringe to Cargo.toml:

Cargo.toml
[dependencies]
hello = { path = "../hello" }
dll-syringe = "0.15.0"

Do a cargo check:

$ cargo check
   Compiling proc-macro2 v1.0.51
    ...
    Checking dll-syringe v0.15.0
error[E0554]: `#![feature]` may not be used on the stable release channel
 --> C:\Users\me\.cargo\registry\src\github.com-1ecc6299db9ec823\dll-syringe-0.15.0\src\lib.rs:2:1
  |
2 | #![feature(maybe_uninit_uninit_array, maybe_uninit_slice, linked_list_cursors)]
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...

This error indicates that dll-syringe uses features that haven't yet made it into the stable version of Rust.

To fix this, we need to install the bleeding edge version of rust, Rust Nightly.


Rust Nightly

Rustup installs Rust Nightly in parallel with your existing Rust installation.

rustup install nightly

To tell cargo to use Rust Nightly, we have to add the +nightly flag to our commands. For example, cargo check becomes cargo +nightly check:

$ cargo +nightly check
    ...
    Checking dll-syringe v0.15.0
    Checking hello_inject v0.1.0 (C:\...\hello_inject)
    Finished dev [unoptimized + debuginfo] target(s) in 22.37s

Of course, this gets annoying fast, so let's set Rust Nightly as default for the rest of this tutorial by using rustup default nightly.

This can be reverted using rustup default stable.

# Set Rust Nightly as default
rustup default nightly

# Update Rust Nightly to today's release
rustup update

# Set Rust Stable as default
rustup default stable

hello_inject.exe (cont.)

Ok, using Rust Nightly do a cargo build, which may throw a few ignorable warnings.

Now update main.rs with the following (taken straight from dll-syringe's README.MD):

main.rs
use dll_syringe::{Syringe, process::OwnedProcess};
use std::{thread, time};

fn main() {
    // find target process by name
    let target_process =
        OwnedProcess::find_first_by_name("create_window.exe").unwrap();

    // create a new syringe for the target process
    let syringe = Syringe::for_process(target_process);

    // inject the payload into the target process
    let injected_payload = syringe.inject("target\\debug\\hello.dll").unwrap();
    println!("DLL injected successfully!");

    // do something else
    let ten_millis = time::Duration::from_secs(10);

    println!("Sleeping for 10 secs...");
    thread::sleep(ten_millis);

    // eject the payload from the target (optional)
    syringe.eject(injected_payload).unwrap();
}

Fire up create_window with a:

cargo run --bin create_window

Open up another terminal and type:

$ cargo run --bin hello_inject
   ...
   Compiling dll-syringe v0.15.0
   Compiling hello_inject v0.1.0 (C:\...\hello_inject)
    Finished dev [unoptimized + debuginfo] target(s) in 35.35s
     Running `target\debug\hello_inject.exe`
DLL injected successfully!
Sleeping for 30 secs...

It works!

Identifying Targets

We are currently using find_first_by_name() to identify our injection target process.

This works great for simple apps, like Notepad, but what if we want to target Chrome, famous for spawning each tab in a separate process:

Pasted image 20230308140459.png

Luckily, OwnedProcess:: also provides the from_pid() method, so if we can figure out a window's process identifier (pid), we can target it.

By Window Handle

It would be useful if we could specify targets by their window handle, since this is something we can easily identify using Window Detective:

Pasted image 20230308145844.png

If you recall, we used GetWindowThreadProcessId() in hello.dll, as part of the enum_window_callback().

Using that code as a reference, we can write a find_pid_by_hwnd():

fn find_pid_by_hwnd(hwnd: u32) -> u32 {
    let mut pid = MaybeUninit::<u32>::zeroed();
    unsafe {
        GetWindowThreadProcessId(HWND(hwnd as isize), Some(pid.as_mut_ptr()));
        return pid.assume_init();
    };
}

And use it like:

// find target process window handle
let pid = find_pid_by_hwnd(HWND(0x00401B32));
let target_process = OwnedProcess::from_pid(pid).unwrap();

By Window Title

Many of the windows we want to target have window titles, for example, "Untitled - Notepad":

Pasted image 20230308150125.png

Leveraging find_pid_by_hwnd() and FindWindowA we can write find_pid_by_title():

pub fn find_pid_by_title(title: PCSTR) -> u32 {
    let hwnd = unsafe {
        FindWindowA(None, title)
    };

    return find_pid_by_hwnd(hwnd);
}

And use it like:

// find target process by window title
let pid = find_pid_by_title(s!("New Tab - Google Chrome"));
let target_process = OwnedProcess::from_pid(pid).unwrap();

Handling Bitness

The majority of us are running 64-bit Windows on 64-bit processors, however, we still use a surprising number of 32-bit applications.

Process Interoperability explains why this matters to us:

On 64-bit Windows, a 64-bit process cannot load a 32-bit dynamic-link library (DLL). Additionally, a 32-bit process cannot load a 64-bit DLL.

Which means that:

  • we need to build both 32-bit and 64-bit versions of hello.dll
  • we need to detect whether a process is 32-bit or 64-bit and inject the appropriate build of hello.dll

Build Targets

Rust makes it easy to generate multiple build targets for a crate or an entire workspace.

First, you have to add the 32-bit toolchain:

# Install 32-bit toolchain & target
rustup target add i686-pc-windows-msvc

Then, you specify which target to build using:

# Build 32-bit binaries
cargo build --target=i686-pc-windows-msvc

# Build 64-bit binaries
cargo build --target=x86_64-pc-windows-msvc

Nice, but cumbersome.

Since we only want multiple builds of hello.dll, we can add a [build] target to its Cargo.toml to specify that:

Cargo.toml
[build]
target = ["i686-pc-windows-msvc", "x86_64-pc-windows-msvc"]

Now when we hit cargo build, we get two new folders in our target directory:

 $ tree -L 2 -a|more
.
├── debug
   ├── create_window.exe
   ├── hello.dll
   ├── hello_inject.exe
   ├── hello_runner.exe
|  ...
├── i686-pc-windows-msvc
   └── debug
└── x86_64-pc-windows-msvc
    └── debug

The first containing 32-bit binaries and the latter containing 64-bit binaries.

Detecting Process Bitness

This one is easy, dll-syringe provides the OwnedProcess::is_x64() and OwnedProcess::is_x86() methods which let us identify which is which.

Picking is_x64(), we can write:

let dll_path = {
    if OwnedProcess::is_x64(&target_process).unwrap() {
        "target\\x86_64-pc-windows-msvc\\debug\\hello.dll"
    } else {
        "target\\i686-pc-windows-msvc\\debug\\hello.dll"
    }
};
...
let injected_payload = syringe.inject(dll_path).expect("Architecture mismatch!");

Implementing Interopablity

Now, if you'd done a cargo build, a few moments earlier, you'd have been greeted with a bunch of "mismatched types" errors, like:

$ cargo build
   Compiling hello v0.1.0 (C:\Users\me\source\blog_qa\hello)
error[E0308]: mismatched types
--> hello\src\lib.rs:38:70
|
| let result = SetWindowLongPtrW(handle, GWLP_WNDPROC, wnd_proc as isize);
|              -----------------                       ^^^^^^^^^^^^^^^^^ 
               |                                        expected `i32`, found `isize`
|              |
|              arguments to this function are incorrect
...

And:

error[E0308]: mismatched types
--> hello\src\lib.rs:39:60
|
| PREV_WNDPROC = transmute::<isize, WNDPROC>(result);
|                --------------------------- ^^^^^^ expected `isize`, found `i32`
|                |
|                arguments to this function are incorrect
...

With the rustc providing advice like:

help: you can convert an `i32` to an `isize` and panic if the ... value doesn't fit
|
| PREV_WNDPROC = transmute::<isize, WNDPROC>(result.try_into().unwrap());
|                                                  ++++++++++++++++++++

Why is this happening?

The Problem

Well, throughout this series, we've been writing fairly idiomatic Rust, trying to make sure we dot our t's and cross our i's.

For example, when writing:

let result = SetWindowLongPtrW(handle, GWLP_WNDPROC, wnd_proc);

rust-analyzer screamed:

error[E0308]: mismatched types
--> hello\src\lib.rs:38:70
|
| let result = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, wnd_proc);
|              -----------------                     ^^^^^^^^ 
|              |                                     expected `isize`, found fn item
|              |
|              arguments to this function are incorrect
...

Cajoling us into explicitly casting wnd_proc as isize, as directed.

However, rust-analyzer generates suggestions based on the currently configured toolchain, which in my case (and probably yours) is 64-bit.

Under the hood, windows-rs is doing this:

\src\Windows\Win32\UI\WindowsAndMessaging\mod.rs
#[cfg(target_pointer_width = "32")]
#[cfg(feature = "Win32_Foundation")]
pub use SetWindowLongW as SetWindowLongPtrW;

The #cfg[...] stuff is called Conditional compilation, the Rust version of #ifdef...

... which means that SetWindowLongPtrW is swapped out for SetWindowLongW when compiling for a 32-bit target.

But since you've read the documentation, you already saw this:

Note

Note To write code that is compatible with both 32-bit and 64-bit versions of Windows, use SetWindowLongPtr. When compiling for 32-bit Windows, SetWindowLongPtr is defined as a call to the SetWindowLong function.

\src\Windows\Win32\UI\WindowsAndMessaging\mod.rs
#[doc = "*Required features: ..."]
#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
#[cfg(feature = "Win32_Foundation")]
#[inline]
pub unsafe fn SetWindowLongPtrW<P0>(hwnd: P0, nindex: ..., dwnewlong: isize) -> isize
where
    P0: ::std::convert::Into<super::super::Foundation::HWND>,
{
    ::windows::core::link ! ( "user32.dll""system" fn SetWindowLongPtrW ( 
        hwnd : super::super::Foundation:: HWND , 
        nindex : WINDOW_LONG_PTR_INDEX , 
        dwnewlong : isize 
        ) -> isize 
    );
    SetWindowLongPtrW(hwnd.into(), nindex, dwnewlong)
}

#[doc = *Required features: ..."]
#[cfg(feature = "Win32_Foundation")]
#[inline]
pub unsafe fn SetWindowLongW<P0>(hwnd: P0, nindex: ..., dwnewlong: i32) -> i32
where
    P0: ::std::convert::Into<super::super::Foundation::HWND>,
{
    ::windows::core::link ! ( "user32.dll""system" fn SetWindowLongW ( 
        hwnd : super::super::Foundation:: HWND , 
        nindex : WINDOW_LONG_PTR_INDEX , 
        dwnewlong : i32 
        ) -> i32 
    );
    SetWindowLongW(hwnd.into(), nindex, dwnewlong)
}

Since SetWindowLongPtrW uses isize and SetWindowLongW uses i32, we get the type mismatch errors above.

Solution(s)

There are two ways to fix our issue:

Custom Type

We can define a custom type using a #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] macro to toggle it between isize and i32, like this:

// Work around a difference between the SetWindowLongPtrW API definition
// in windows-rs on 32-bit and 64-bit Windows.
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
type LongPtr = isize;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
type LongPtr = i32;
- source

Replacing all isize references with LongPtr, or whatever you name your custom type, for example:

let result = SetWindowLongPtrW(handle, GWLP_WNDPROC, wnd_proc as LongPtr);

Type Inference

Alternatively, we can use Rust's powerful type inference abilities, which allows rustc to infer the destination type based on contextual clues:

let result = SetWindowLongPtrW(handle, GWLP_WNDPROC, wnd_proc as _);

Here, the as _ indicates our type inference request, which rustc can easily do by referencing its signature from ...\WindowsAndMessaging\mod.rs.

Here's another example where inference works:

PREV_WNDPROC = transmute::<_, WNDPROC>(result);
...

Bring It Home

Go ahead and use type inference to fix each compile error and make sure workspace compiles with both a cargo build --target=i686-pc-windows-msvc and a cargo build --target=x86_64-pc-windows-msvc.

Further Reading

The Moment Of Truth

We are finally ready to see if our project works with both 32-bit and 64-bit applications.

Testing 32-bit Applications

If you have a 32-bit application in mind, go ahead and use it.

If you need a recommendation, Tera Term is a hand 32-bit application, you can grab a copy here.

It looks like:

Pasted image 20230312231208.png

It's a single process application, so you can use find_first_by_name() to grab it in hello_inject:

Pasted image 20230312231603.png

Here's what my main.rs looks like:

hello\src\main.rs
use dll_syringe::{Syringe, process::*};
use windows::{Win32::{UI::WindowsAndMessaging::{GetWindowThreadProcessId, FindWindowA}, Foundation::HWND}, core::PCSTR, s};
use std::{thread, time, borrow::BorrowMut, mem::MaybeUninit};

fn main() {
    // find target process by .exe
    let target_process =
        OwnedProcess::find_first_by_name("ttermpro.exe").expect("Couldn't find process, exiting!");

   let dll_path = {
        if OwnedProcess::is_x64(&target_process).unwrap() {
            "target\\x86_64-pc-windows-msvc\\debug\\hello.dll"
        } else {
            "target\\i686-pc-windows-msvc\\debug\\hello.dll"
        }
    };

    // create a new syringe for the target process
    let syringe = Syringe::for_process(target_process);

    let injected_payload = syringe.inject(dll_path).expect("Architecture mismatch!");
    println!("DLL injected successfully!");

    // do something else
    let ten_millis = time::Duration::from_secs(30);

    println!("Sleeping for 30 secs...");
    thread::sleep(ten_millis);

    // eject the payload from the target (optional)
    syringe.eject(injected_payload).unwrap();
}

fn find_pid_by_hwnd(hwnd: HWND) -> u32 {
    let mut pid = MaybeUninit::<u32>::zeroed();
    unsafe {
        GetWindowThreadProcessId(hwnd, Some(pid.as_mut_ptr()));
        return pid.assume_init();
    };
}

pub fn find_pid_by_title(title: PCSTR) -> u32 {
    let hwnd = unsafe {
        FindWindowA(None, title)
    };

    return find_pid_by_hwnd(hwnd);
}

Doing a cargo run --bin hello_inject should result in:

$ cargo run --bin hello_inject
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target\x86_64-pc-windows-msvc\debug\hello_inject.exe`
DLL injected successfully!
Sleeping for 30 secs...

Part 4 is here: Production Ready DLL Injection.

Having trouble? The code this series can be found here: peddamat/how-to-create-a-dll-using-rust


Last update: 2023-03-16