Skip to content
Lesson 8.2In-Process Tools, Input, and Integration

DLL Loading Across Process Boundaries

Follow the module path, process handle, remote memory, and loader call involved in loading a DLL into a controlled game process.

Lesson 8.2
Read or listenPrint chapter or book
About 20 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← β†’
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 8.2Key ideas, reference and questions

A one-page summary of β€œDLL Loading Across Process Boundaries” you can keep beside your notes. Plain Markdown.

Lesson 8.2 β€” DLL Loading Across Process Boundaries: cheatsheet

  • Remember: Match the DLL and target architectures before remote loading; a valid path cannot repair an incompatible binary.
  • Also useful: Treat a remote allocation as borrowed workspace with a named custodian, responsible for returning it on every exit path.

Key terms

  • LoadLibraryW: Here it is LoadLibraryW, which is why the same-base assumption above matters.

Review

  • Q: Why should an injector avoid assuming that one local function pointer is valid in the target process? A: ASLR and architecture differences can give modules different live addresses
  • Q: Why might a tool restrict itself to a short allowlist of known targets? A: A mistyped or mistaken command cannot act on an unrelated process
Download cheatsheet (.md)

Lesson 8.1 built a DLL with a tiny DllMain and an explicit gha_start, but that file is still on disk. To run it inside the course game, the injector must ask the target process to load the exact DLL, wait for loading to finish, and only then call its start function. Every temporary handle and allocation needs an owner along the way.

This injector accepts only the exact target builds used in the book. Put the allowlist in code so a mistyped command cannot select an unrelated process.

const ALLOWED_TARGETS: &[&str] = &[
"wesnoth.exe", // Wesnoth 1.14.9, 32-bit
"ac_client.exe", // AssaultCube 1.2.0.2, 32-bit
"Quake3-UrT.exe", // Urban Terror 4.3.4, 32-bit
"flare.exe", // Flare 1.12, 32-bit course build
"wyrmsun.exe", // Wyrmsun 5.0.1, 32-bit course build
];
fn allowed_target(name: &str) -> bool {
ALLOWED_TARGETS.iter().any(|allowed| allowed.eq_ignore_ascii_case(name))
}

This chapter covers the classic Windows loader sequence. Protected-process work and anti-cheat bypasses are separate subjects and are outside this tool’s contract.

It is worth knowing that this is one route among several, so you do not mistake it for the only one. The approaches differ along three axes: who allocates the memory in the target, what causes the target to start executing it, and β€” the important one β€” whether the Windows loader is involved at all.

This route uses the loader. You hand the target a path and ask it to run LoadLibraryW, and the loader then maps the sections, applies relocations, resolves imports, prepares thread-local storage, runs DllMain, and adds the module to the process’s module list.

That last step is easy to overlook and is precisely why this book takes this route. Because the loader registers the module, your DLL appears in the inventory that Lesson 12.1 builds, GetModuleHandle can find it, and unloading runs through the same bookkeeping in reverse. Every step is observable and reversible.

Routes that avoid the loader have to do the loader’s job themselves β€” walking the relocation table, resolving each import by hand, and handling TLS β€” which is where the PE structure from Lesson 5.1 stops being background reading. They exist mainly so that a module does not appear in that list, which is a goal about avoiding detection rather than about understanding the program, and it is not what this book is for.

Open exact game process
Allocate remote memory
Write absolute DLL path
Find LoadLibraryW
Start remote thread
Wait and check result
Free memory and close handles

Every box can fail and must clean up what earlier boxes created.

The copied path is only needed while the DLL loadsWaiting for the loader separates the release of the path buffer from the loaded DLL’s lifetime and its explicit start.
  1. Data Copy the NUL-terminated UTF-16 path

    Step 1: Injector to Course target

    The path has to exist in the target’s memory, because the loader runs there and reads it from there.

  2. Request Run LoadLibraryW on that path

    Step 2: Injector to Course target

    The loader reads the path and maps the DLL into the target.

  3. Alone Loader maps the DLL

    Step 3: Course target (alone)

    This work happens inside the target, on the target’s time.

  4. Reply Wait finishes: loading is done

    Step 4: Course target to Injector

    The injector waits for the loader, so it knows the path is no longer being read.

  5. Control Release the path buffer

    Step 5: Injector to Course target

    The path was temporary workspace. Freeing it does not unload the DLL.

  6. Request Call gha_start

    Step 6: Injector to Course target

    Starting the DLL’s work is a separate, explicit step after loading.

Waiting for the loader separates the release of the path buffer from the loaded DLL’s lifetime and its explicit start.

Windows LoadLibraryW expects a zero-terminated UTF-16 path:

use std::{os::windows::ffi::OsStrExt, path::Path};
fn wide_path(path: &Path) -> anyhow::Result<Vec<u16>> {
let absolute = path.canonicalize()?;
let mut wide: Vec<u16> = absolute.as_os_str()
.encode_wide()
.collect();
wide.push(0);
Ok(wide)
}

A relative path depends on the target’s working directory and is easy to misresolve.

Model the allocation so cleanup is not optional:

an early ? returns
normal continuation
VirtualAllocEx succeeds
RemoteAllocation owns process + address
write_exact writes the wide path
RemoteAllocation is dropped
Drop::drop calls VirtualFreeEx

RemoteAllocation ties remote-memory release to one Rust owner’s lifetime. It stores the process handle and remote address, then its Drop method releases the address whether the caller finishes normally or returns early with an error.

struct RemoteAllocation<'a> {
process: &'a Process,
address: *mut std::ffi::c_void,
}
impl Drop for RemoteAllocation<'_> {
fn drop(&mut self) {
// SAFETY: this allocation belongs to this process and is released once.
let _ = unsafe {
VirtualFreeEx(self.process.raw_handle(), self.address, 0, MEM_RELEASE)
};
}
}

Allocate and write the path with the actual windows crate calls:

let remote_address = unsafe {
VirtualAllocEx(
process.raw_handle(),
None,
path_bytes.len(),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
)
};
anyhow::ensure!(!remote_address.is_null(), "VirtualAllocEx failed");
let remote_path = RemoteAllocation {
process: &process,
address: remote_address,
};
process.write_exact(remote_path.address as usize, path_bytes)?;

Process::write_exact checks the returned byte count from WriteProcessMemory. RemoteAllocation::drop pairs the successful allocation with VirtualFreeEx even when a later ? returns early.

Every step can fail, and cleanup still happens. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

let process = open_process(pid)?;
let addr = VirtualAllocEx(process, path_len);
ensure!(!addr.is_null(), "VirtualAllocEx failed");
let remote = RemoteAllocation { process, addr };
process.write_exact(addr, path_bytes)?;
let load = GetProcAddress(kernel32, "LoadLibraryW")?;
let thread = CreateRemoteThread(process, load, addr)?;
let module = wait_for_thread(&thread)?;
ensure!(module != 0, "LoadLibraryW returned null");
// scope ends: remote is dropped, VirtualFreeEx runs

The function ends. remote is dropped, VirtualFreeEx releases the path block, and the handles close. The path was only needed while LoadLibraryW ran.

Final values: process = closed; remote_memory = released; thread = closed; addr = 0x01F40000; write = ok; load = LoadLibraryW found; module = 0x6A2E0000.

Read every step of the lesson’s example
  1. Step 1, line 1. The loader opens the exact game process by id. Everything after this needs that handle, and it is closed when the function ends.

    process = open; remote_memory = none; thread = none.

  2. Step 2, line 2. VirtualAllocEx asks the other process for a block big enough for the DLL path. It returns the block's address, 0x01F40000.

    process = open; remote_memory = none; thread = none; addr = 0x01F40000.

  3. Step 3, line 3. The address is not null, so the check passes.

    process = open; remote_memory = none; thread = none; addr = 0x01F40000.

  4. Step 4, line 4. Wrap the address in RemoteAllocation at once. From here on, whichever way the function exits, its drop method will release the block.

    process = open; remote_memory = owned at 0x01F40000; thread = none; addr = 0x01F40000.

  5. Step 5, line 5. write_exact copies the full path into the remote block and checks that every byte arrived.

    process = open; remote_memory = owned at 0x01F40000; thread = none; addr = 0x01F40000; write = ok.

  6. Step 6, line 6. GetProcAddress finds LoadLibraryW in kernel32. That is the function the remote thread will run.

    process = open; remote_memory = owned at 0x01F40000; thread = none; addr = 0x01F40000; write = ok; load = LoadLibraryW found.

  7. Step 7, line 7. CreateRemoteThread starts a thread in the game that runs LoadLibraryW with the path as its argument. The handle is wrapped so it also closes automatically.

    process = open; remote_memory = owned at 0x01F40000; thread = started; addr = 0x01F40000; write = ok; load = LoadLibraryW found.

  8. Step 8, line 8. wait_for_thread waits for the thread to finish and returns its result, which is the loaded module's handle.

    process = open; remote_memory = owned at 0x01F40000; thread = finished; addr = 0x01F40000; write = ok; load = LoadLibraryW found; module = 0x6A2E0000.

  9. Step 9, line 9. The module handle is not zero, so the DLL loaded and the check passes.

    process = open; remote_memory = owned at 0x01F40000; thread = finished; addr = 0x01F40000; write = ok; load = LoadLibraryW found; module = 0x6A2E0000.

  10. Step 10, line 10. The function ends. remote is dropped, VirtualFreeEx releases the path block, and the handles close. The path was only needed while LoadLibraryW ran.

    process = closed; remote_memory = released; thread = closed; addr = 0x01F40000; write = ok; load = LoadLibraryW found; module = 0x6A2E0000.

The high-level loader has several checked steps. One address-sharing assumption in this course version is called out immediately after the code:

let kernel32 = unsafe { GetModuleHandleW(w!("kernel32.dll")) }?;
let load_library = unsafe { GetProcAddress(kernel32, s!("LoadLibraryW")) }
.context("kernel32 did not export LoadLibraryW")?;
let start: LPTHREAD_START_ROUTINE =
Some(unsafe { std::mem::transmute(load_library) });
let thread = unsafe {
CreateRemoteThread(
process.raw_handle(),
None,
0,
start,
Some(remote_path.address.cast_const()),
0,
None,
)
}?;
let thread = OwnedHandle::from_raw(thread)?;
let module_handle = wait_for_thread(&thread, "the LoadLibraryW thread")?;
anyhow::ensure!(module_handle != 0, "LoadLibraryW returned null");

Two things about that sequence deserve an explanation, because both look wrong the first time you read them.

Why does the sample pass a locally resolved LoadLibraryW address to the game? GetProcAddress returns an address in the injector’s process. DLLs often map at the same base in both processes, which makes this common course shortcut work, but that is an assumption to check, not a Windows guarantee. Microsoft documents that a DLL can load at a different base when its preferred one is unavailable, and CreateRemoteThread can even succeed with an invalid start address before the new thread faults. For a reusable injector, find the target process’s actual system-DLL base, resolve or verify the export at that base, and refuse to start the thread if the address is not established. The buildable course injector currently relies on the shared-base assumption in this exact lab. Never apply its locally obtained address to arbitrary game DLLs. See DLL memory protection and CreateRemoteThread.

Why can LoadLibraryW be used as a thread start routine in this 32-bit lab? Both use the Windows system calling convention and take one pointer argument. Here the HMODULE result fits in the thread’s 32-bit DWORD exit code. The remote allocation holding the wide path is the argument. The code requires a nonzero exit code before calculating the DLL-relative gha_start address. This return-value shortcut does not carry over unchanged to a 64-bit injector.

The seven arguments of that CreateRemoteThread call, with the value this lab passes for each:

CreateRemoteThread, argument by argument

hProcesshandlerequired

The game’s process handle, opened with the rights this step needs, including the right to create a thread.

lpThreadAttributesoptionaldefault None

Security settings for the new thread’s handle. None takes the defaults, and the handle cannot be inherited by child processes.

dwStackSizebytesdefault 0

The new thread’s stack size. Zero uses the default of the game’s executable.

lpStartAddressaddress in the gamerequired

Where the thread begins running. Here it is LoadLibraryW, which is why the same-base assumption above matters.

lpParameteraddress in the game

The one pointer handed to the start routine: the remote allocation that holds the wide DLL path.

dwCreationFlagsflagsdefault 0

Zero starts the thread at once. CREATE_SUSPENDED would create it paused.

lpThreadIdoptionaldefault None

Where to store the new thread’s ID. The lab does not need it.

return valuethread handle

The handle of the new thread, which the lab wraps in an owned type so it is always closed. A thread that starts is not a thread that succeeded: the lab still waits for it and reads its exit code.

The wrapper methods are small unsafe boundaries over:

  • OpenProcess;
  • VirtualAllocEx / VirtualFreeEx;
  • WriteProcessMemory;
  • CreateRemoteThread;
  • waiting and reading the thread exit code;
  • CloseHandle.

A 32-bit DLL loads into a 32-bit process. A 64-bit DLL loads into a 64-bit process. Check both the loader and target architecture before allocating anything.

For the versions in this book, install the 32-bit compilation target and build the DLL for it:

Terminal window
rustup target add i686-pc-windows-msvc
cargo build --release --target i686-pc-windows-msvc

Pass the absolute DLL produced under target\i686-pc-windows-msvc\release\. A 64-bit injector may still need extra care to inspect a 32-bit target, so the simplest course setup builds the injector as 32-bit too.

When the DLL loads, Windows calls its DllMain under the loader lock. The course DLL does only one small thing there: DisableThreadLibraryCalls. The injector then finds the exported gha_start address by its DLL-relative offset and starts a second remote thread after LoadLibraryW has returned. That guarantees the real worker and hook installation happen outside DllMain and outside the loader lock.

Game processInjectorGame processInjectorloader lock held:maps sections, resolves imports,runs DllMainoutside the loader lock:gha_start spawns the workerCreateRemoteThread(LoadLibraryW, dll path)thread exit code = DLL basecompute gha_start's RVAfrom a local, unloaded copyCreateRemoteThread(DLL base + RVA)

On every error:

  1. stop waiting on or close the remote thread handle;
  2. free the remote path allocation;
  3. close the process handle.

RAII types make this happen during early returns.

Windows VM
  1. Build both injector and DLL for i686-pc-windows-msvc.
  2. Start one allowed game in the exact version and enter an offline/local match.
  3. Run the injector with the process name and absolute DLL path. The course code uses the locally resolved LoadLibraryW address under the shared-base assumption described above.
  4. Require a nonzero LoadLibraryW thread result and a zero gha_start thread result. If either check fails, stop instead of trying another address or retrying blindly.
  5. Inspect the target’s module list and confirm that the expected DLL actually loaded. Confirm its log prints the matched target profile and verified original bytes. These independent observations test the result; they do not make the shared-base assumption a general guarantee.
  6. Trigger the visible result: Wesnoth gold becomes 999, AssaultCube recoil disappears, or Urban Terror renders the local wallhack.
  7. Toggle the feature off, verify the original bytes are restored, and exit the game normally.

If the loader thread returns zero, inspect the target PID, both architectures, the canonical DLL path, and the Windows error. If the target exits or faults before a loader result is available, the remote start address may have been invalid; treat that as a failed experiment and do not rerun it unchanged. Do not suggest β€œrun as administrator” until the earlier facts are correct.

The completed tool performs a real injection into the course games with exact target checks, owned handles, bounded waits, and cleanup. Its local-system-DLL address assumption remains a limitation of this version.

The complete buildable source is injector.rs. It includes the allowlist, architecture check, UTF-16 conversion, allocation, both remote threads, gha_start RVA calculation under ASLR, timeout handling, exit-code checks, and cleanup. Its shared process/handle wrappers are in process.rs and handle.rs.

injector.rs

windows-labs/src/bin/ Β· Rust, 238 lines

Show the codeHide the code
#[cfg(windows)]
mod app {
    use std::{ffi::c_void, os::windows::ffi::OsStrExt, path::Path, time::Duration};

    use anyhow::{Context, Result};
    use gha_windows_labs::{OwnedHandle, Process};
    use windows::{
        Win32::{
            Foundation::{FreeLibrary, HMODULE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
            System::{
                LibraryLoader::{
                    DONT_RESOLVE_DLL_REFERENCES, GetModuleHandleW, GetProcAddress, LoadLibraryExW,
                },
                Memory::{
                    MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx,
                    VirtualFreeEx,
                },
                Threading::{
                    CreateRemoteThread, GetExitCodeThread, LPTHREAD_START_ROUTINE,
                    PROCESS_CREATE_THREAD, PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION,
                    PROCESS_VM_READ, PROCESS_VM_WRITE, WaitForSingleObject,
                },
            },
        },
        core::{PCWSTR, s, w},
    };

    const ALLOWED_TARGETS: &[&str] = &[
        "wesnoth.exe",
        "ac_client.exe",
        "Quake3-UrT.exe",
        "flare.exe",
        "wyrmsun.exe",
    ];

    struct RemoteAllocation<'a> {
        process: &'a Process,
        address: *mut c_void,
    }

    impl Drop for RemoteAllocation<'_> {
        fn drop(&mut self) {
            // SAFETY: this allocation belongs to this process and is released once.
            let _ =
                unsafe { VirtualFreeEx(self.process.raw_handle(), self.address, 0, MEM_RELEASE) };
        }
    }

    struct LocalModule(HMODULE);

    impl Drop for LocalModule {
        fn drop(&mut self) {
            // SAFETY: this process owns the reference returned by LoadLibraryExW.
            let _ = unsafe { FreeLibrary(self.0) };
        }
    }

    fn wait_for_thread(thread: &OwnedHandle, description: &str) -> Result<u32> {
        let timeout_ms = u32::try_from(Duration::from_secs(10).as_millis())?;
        // SAFETY: the thread handle remains live for the wait.
        let wait = unsafe { WaitForSingleObject(thread.raw(), timeout_ms) };
        if wait == WAIT_TIMEOUT {
            anyhow::bail!("{description} did not finish within 10 seconds");
        }
        if wait == WAIT_FAILED {
            return Err(windows::core::Error::from_thread().into());
        }
        anyhow::ensure!(wait == WAIT_OBJECT_0, "unexpected wait result: {}", wait.0);

        let mut exit_code = 0_u32;
        // SAFETY: the thread has finished and the output pointer is writable.
        unsafe { GetExitCodeThread(thread.raw(), &mut exit_code) }?;
        Ok(exit_code)
    }

    fn wide_absolute_path(path: &Path) -> Result<Vec<u16>> {
        let absolute = path
            .canonicalize()
            .with_context(|| format!("cannot find DLL at {}", path.display()))?;
        let mut wide: Vec<u16> = absolute.as_os_str().encode_wide().collect();
        wide.push(0); // LoadLibraryW expects a zero-terminated UTF-16 string.
        Ok(wide)
    }

    fn as_bytes(wide: &[u16]) -> &[u8] {
        // SAFETY: a u16 slice is contiguous; its byte view is exactly twice as long.
        unsafe { std::slice::from_raw_parts(wide.as_ptr().cast(), wide.len() * 2) }
    }

    pub fn run() -> Result<()> {
        let mut arguments = std::env::args().skip(1);
        let process_name = arguments
            .next()
            .context("usage: injector <allowed-process.exe> <absolute-or-relative-dll-path>")?;
        let dll_path = arguments.next().context("missing DLL path")?;

        anyhow::ensure!(
            ALLOWED_TARGETS
                .iter()
                .any(|allowed| allowed.eq_ignore_ascii_case(&process_name)),
            "{process_name:?} is not one of the course targets"
        );

        let entry = Process::find(&process_name)?;
        let access = PROCESS_CREATE_THREAD
            | PROCESS_QUERY_INFORMATION
            | PROCESS_VM_OPERATION
            | PROCESS_VM_READ
            | PROCESS_VM_WRITE;
        let process = Process::open_with_access(entry, access)?;
        anyhow::ensure!(
            process.is_32_bit()?,
            "the course DLLs require a 32-bit target"
        );

        let wide_path = wide_absolute_path(Path::new(&dll_path))?;
        let path_bytes = as_bytes(&wide_path);

        // Reserve and commit readable/writable memory inside the game.
        // SAFETY: the process handle is live; null asks Windows to choose the address.
        let remote_address = unsafe {
            VirtualAllocEx(
                process.raw_handle(),
                None,
                path_bytes.len(),
                MEM_COMMIT | MEM_RESERVE,
                PAGE_READWRITE,
            )
        };
        anyhow::ensure!(!remote_address.is_null(), "VirtualAllocEx failed");
        let remote_path = RemoteAllocation {
            process: &process,
            address: remote_address,
        };
        process.write_exact(remote_path.address as usize, path_bytes)?;

        // kernel32 is already loaded in this injector. Resolve LoadLibraryW there.
        // For the same architecture, this system DLL export is usable by the target.
        // SAFETY: both names are static zero-terminated strings.
        let kernel32 = unsafe { GetModuleHandleW(w!("kernel32.dll")) }?;
        // SAFETY: `kernel32` is live and the exported name is valid ASCII.
        let load_library = unsafe { GetProcAddress(kernel32, s!("LoadLibraryW")) }
            .context("kernel32 did not export LoadLibraryW")?;

        // CreateRemoteThread expects the same address with the thread-procedure type.
        // On 32-bit Windows both signatures use the system ABI and one pointer argument.
        // SAFETY: this 32-bit course injector and target use the Windows system
        // ABI; LoadLibraryW accepts one pointer and its HMODULE result fits u32.
        let thread_start: LPTHREAD_START_ROUTINE = Some(unsafe {
            std::mem::transmute::<
                unsafe extern "system" fn() -> isize,
                unsafe extern "system" fn(*mut c_void) -> u32,
            >(load_library)
        });

        // SAFETY: the start address is LoadLibraryW and its argument points to the
        // zero-terminated UTF-16 path that remains allocated until the thread exits.
        let thread = unsafe {
            CreateRemoteThread(
                process.raw_handle(),
                None,
                0,
                thread_start,
                Some(remote_path.address.cast_const()),
                0,
                None,
            )
        }?;
        let thread = OwnedHandle::from_raw(thread)?;

        let module_handle = wait_for_thread(&thread, "the LoadLibraryW thread")?;
        anyhow::ensure!(module_handle != 0, "LoadLibraryW returned null");

        // Map the same DLL in this injector without running DllMain. That lets
        // us ask Windows where the exported `gha_start` function sits relative
        // to the DLL base. The relative offset is identical in the game even
        // when ASLR chooses a different base address there.
        // SAFETY: `wide_path` is still a zero-terminated UTF-16 path.
        let local_module = unsafe {
            LoadLibraryExW(
                PCWSTR(wide_path.as_ptr()),
                None,
                DONT_RESOLVE_DLL_REFERENCES,
            )
        }
        .context("could not map the DLL locally to find gha_start")?;
        let local_module = LocalModule(local_module);
        // SAFETY: the module is live and the export name is zero-terminated.
        let local_start = unsafe { GetProcAddress(local_module.0, s!("gha_start")) }
            .context("the DLL does not export gha_start")?;
        let local_start_address = local_start as *const () as usize;
        let local_base = local_module.0.0 as usize;
        let start_offset = local_start_address
            .checked_sub(local_base)
            .context("gha_start was below the local DLL base")?;
        let remote_start_address = (module_handle as usize)
            .checked_add(start_offset)
            .context("remote gha_start address overflowed")?;
        // SAFETY: this is the `gha_start` RVA in the same 32-bit DLL image.
        // SAFETY: this address is the verified gha_start RVA in the same
        // 32-bit DLL image, whose export uses LPTHREAD_START_ROUTINE's ABI.
        let remote_start: LPTHREAD_START_ROUTINE = Some(unsafe {
            std::mem::transmute::<usize, unsafe extern "system" fn(*mut c_void) -> u32>(
                remote_start_address,
            )
        });

        // SAFETY: gha_start has the documented LPTHREAD_START_ROUTINE ABI and
        // accepts a null argument. The DLL stays loaded in the target.
        let start_thread = unsafe {
            CreateRemoteThread(process.raw_handle(), None, 0, remote_start, None, 0, None)
        }?;
        let start_thread = OwnedHandle::from_raw(start_thread)?;
        let start_result = wait_for_thread(&start_thread, "the gha_start thread")?;
        anyhow::ensure!(
            start_result == 0,
            "gha_start returned error code {start_result}"
        );

        println!(
            "Injected and started {} in {} (PID {}). Module handle: {module_handle:#010x}",
            Path::new(&dll_path).display(),
            process.name(),
            process.id()
        );
        Ok(())
    }
}

#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    app::run()
}

#[cfg(not(windows))]
fn main() {
    eprintln!("The injector uses Windows APIs and must be built on Windows.");
}

process.rs

windows-labs/src/windows_impl/ Β· Rust, 350 lines

Show the codeHide the code
use std::{ffi::c_void, mem::size_of};

use anyhow::{Context, Result};
use windows::Win32::{
    Foundation::ERROR_NO_MORE_FILES,
    System::{
        Diagnostics::{
            Debug::{FlushInstructionCache, ReadProcessMemory, WriteProcessMemory},
            ToolHelp::{
                CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW,
                PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPMODULE,
                TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS,
            },
        },
        Memory::{
            MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE, PAGE_EXECUTE_READ,
            PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS,
            PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VirtualProtectEx,
            VirtualQueryEx,
        },
        SystemInformation::{
            IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_UNKNOWN,
        },
        Threading::{
            IsWow64Process2, OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_INFORMATION,
            PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
        },
    },
};

use super::OwnedHandle;

#[derive(Clone, Debug)]
pub struct ProcessEntry {
    pub id: u32,
    pub name: String,
}

#[derive(Clone, Copy, Debug)]
pub struct MemoryRegion {
    pub base: usize,
    pub size: usize,
    pub readable: bool,
    pub writable: bool,
    pub executable: bool,
}

#[derive(Debug)]
pub struct Process {
    handle: OwnedHandle,
    id: u32,
    name: String,
}

fn wide_text(buffer: &[u16]) -> String {
    let end = buffer
        .iter()
        .position(|unit| *unit == 0)
        .unwrap_or(buffer.len());
    String::from_utf16_lossy(&buffer[..end])
}

fn no_more_files(error: &windows::core::Error) -> bool {
    error.code() == ERROR_NO_MORE_FILES.to_hresult()
}

impl Process {
    /// Lists every running process with ToolHelp.
    pub fn list() -> Result<Vec<ProcessEntry>> {
        // SAFETY: no borrowed pointers are passed; the returned snapshot is owned below.
        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;

        let mut raw = PROCESSENTRY32W {
            dwSize: size_of::<PROCESSENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable for the duration of the call.
        unsafe { Process32FirstW(snapshot.raw(), &mut raw) }?;

        let mut processes = Vec::new();
        loop {
            processes.push(ProcessEntry {
                id: raw.th32ProcessID,
                name: wide_text(&raw.szExeFile),
            });

            // SAFETY: `raw` remains a valid PROCESSENTRY32W output buffer.
            match unsafe { Process32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        Ok(processes)
    }

    pub fn find(name: &str) -> Result<ProcessEntry> {
        Self::list()?
            .into_iter()
            .find(|entry| entry.name.eq_ignore_ascii_case(name))
            .with_context(|| format!("process {name:?} is not running"))
    }

    pub fn open_by_name(name: &str, allow_write: bool) -> Result<Self> {
        let entry = Self::find(name)?;
        Self::open(entry, allow_write)
    }

    pub fn open(entry: ProcessEntry, allow_write: bool) -> Result<Self> {
        let mut access: PROCESS_ACCESS_RIGHTS = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ;
        if allow_write {
            access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE;
        }

        Self::open_with_access(entry, access)
    }

    pub fn open_with_access(entry: ProcessEntry, access: PROCESS_ACCESS_RIGHTS) -> Result<Self> {
        // SAFETY: the PID is a value; inheritance is disabled; `OwnedHandle` owns the result.
        let handle = unsafe { OpenProcess(access, false, entry.id) }?;
        Ok(Self {
            handle: OwnedHandle::from_raw(handle)?,
            id: entry.id,
            name: entry.name,
        })
    }

    pub fn is_32_bit(&self) -> Result<bool> {
        let mut process_machine = IMAGE_FILE_MACHINE::default();
        let mut native_machine = IMAGE_FILE_MACHINE::default();
        // SAFETY: both output pointers remain valid for the call.
        unsafe {
            IsWow64Process2(
                self.handle.raw(),
                &mut process_machine,
                Some(&mut native_machine),
            )?;
        }
        Ok(process_machine == IMAGE_FILE_MACHINE_I386
            || (process_machine == IMAGE_FILE_MACHINE_UNKNOWN
                && native_machine == IMAGE_FILE_MACHINE_I386))
    }

    pub fn id(&self) -> u32 {
        self.id
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn raw_handle(&self) -> windows::Win32::Foundation::HANDLE {
        self.handle.raw()
    }

    pub fn read_exact(&self, address: usize, output: &mut [u8]) -> Result<()> {
        let mut bytes_read = 0_usize;
        // SAFETY: `output` is writable for its length. Windows validates the remote range.
        unsafe {
            ReadProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                output.as_mut_ptr().cast(),
                output.len(),
                Some(&mut bytes_read),
            )?;
        }
        anyhow::ensure!(bytes_read == output.len(), "short read at {address:#x}");
        Ok(())
    }

    pub fn read_bytes(&self, address: usize, count: usize) -> Result<Vec<u8>> {
        let mut bytes = vec![0_u8; count];
        self.read_exact(address, &mut bytes)?;
        Ok(bytes)
    }

    pub fn read_u32(&self, address: usize) -> Result<u32> {
        let bytes: [u8; 4] = self
            .read_bytes(address, 4)?
            .try_into()
            .expect("the read length is exactly four");
        Ok(u32::from_le_bytes(bytes))
    }

    pub fn read_f32(&self, address: usize) -> Result<f32> {
        Ok(f32::from_bits(self.read_u32(address)?))
    }

    pub fn write_exact(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut bytes_written = 0_usize;
        // SAFETY: `bytes` is readable for its length. Windows validates the remote range.
        unsafe {
            WriteProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                bytes.as_ptr().cast(),
                bytes.len(),
                Some(&mut bytes_written),
            )?;
        }
        anyhow::ensure!(bytes_written == bytes.len(), "short write at {address:#x}");
        Ok(())
    }

    pub fn write_u32(&self, address: usize, value: u32) -> Result<()> {
        self.write_exact(address, &value.to_le_bytes())
    }

    /// Writes machine code, flushes the CPU instruction cache, and restores protection.
    pub fn write_code(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut old = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: Windows validates the target range and writes `old` before returning.
        unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                PAGE_EXECUTE_READWRITE,
                &mut old,
            )?;
        }

        let write_result = self.write_exact(address, bytes);
        if write_result.is_ok() {
            // SAFETY: the process handle is live and the written range is the one above.
            unsafe {
                FlushInstructionCache(
                    self.handle.raw(),
                    Some(address as *const c_void),
                    bytes.len(),
                )?;
            }
        }

        let mut ignored = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: this restores the protection returned by the first call.
        let restore_result = unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                old,
                &mut ignored,
            )
        };

        write_result?;
        restore_result?;
        Ok(())
    }

    pub fn module(&self, wanted_name: &str) -> Result<(usize, usize)> {
        // Include SNAPMODULE32 so a 64-bit tool can list a 32-bit game module.
        // SAFETY: the PID is valid and the returned snapshot is owned below.
        let snapshot =
            unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.id) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;
        let mut raw = MODULEENTRY32W {
            dwSize: size_of::<MODULEENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable.
        unsafe { Module32FirstW(snapshot.raw(), &mut raw) }?;
        loop {
            if wide_text(&raw.szModule).eq_ignore_ascii_case(wanted_name) {
                return Ok((raw.modBaseAddr as usize, raw.modBaseSize as usize));
            }
            // SAFETY: `raw` remains a valid output buffer.
            match unsafe { Module32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        anyhow::bail!("module {wanted_name:?} was not found in {}", self.name)
    }

    pub fn regions(&self, start: usize, end: usize) -> Result<Vec<MemoryRegion>> {
        let mut current = start;
        let mut regions = Vec::new();

        while current < end {
            let mut raw = MEMORY_BASIC_INFORMATION::default();
            // SAFETY: `raw` is a valid output structure; Windows validates the address.
            let returned = unsafe {
                VirtualQueryEx(
                    self.handle.raw(),
                    Some(current as *const c_void),
                    &mut raw,
                    size_of::<MEMORY_BASIC_INFORMATION>(),
                )
            };
            if returned == 0 {
                break;
            }

            let protection = raw.Protect;
            let blocked = protection.contains(PAGE_GUARD) || protection.contains(PAGE_NOACCESS);
            let readable = !blocked
                && raw.State == MEM_COMMIT
                && [
                    PAGE_READONLY,
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let writable = readable
                && [
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let executable = !blocked
                && [
                    PAGE_EXECUTE,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));

            let base = raw.BaseAddress as usize;
            let size = raw.RegionSize;
            regions.push(MemoryRegion {
                base,
                size,
                readable,
                writable,
                executable,
            });

            let next = base.checked_add(size).context("memory map overflowed")?;
            anyhow::ensure!(next > current, "memory map did not advance");
            current = next;
        }
        Ok(regions)
    }
}
Knowledge check

Account for separate address spaces

Why should an injector avoid assuming that one local function pointer is valid in the target process?

Finished Lesson 8.2?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 8: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.