Skip to content
Lesson 12.2Loaders, Kernel Services, and Trust Boundaries

DLL Loading and the Loader Lock

Mapping imports and running DllMain require careful ordering; a tiny entry point avoids work that can deadlock the loader.

Lesson 12.2
Read or listenPrint chapter or book
About 27 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 12.2Key ideas, reference and questions

A one-page summary of “DLL Loading and the Loader Lock” you can keep beside your notes. Plain Markdown.

Lesson 12.2 — DLL Loading and the Loader Lock: cheatsheet

  • Remember: Begin the DLL’s real work only after loading returns, using an explicit entry point with its own result.
  • Also useful: Initialization is a dependency ladder: logging, workers, and hooks belong on steps whose supporting services are already ready.

Key terms

  • loader lock: Windows serializes important parts of loading with a process-wide synchronization mechanism commonly called the loader lock.
  • LoadLibraryW: A failure in LoadLibraryW is different from a failure in course initialization, and each stage returns its own result.

Formulas and patterns

  • reject new work -> request worker stop -> join outside loader lock; -> restore patches -> release resources -> allow unload

Review

  • Q: Why is loading another DLL or waiting for worker synchronization dangerous inside DllMain? A: The loader may already hold locks that a nested operation or worker needs, forming a cycle
  • Q: Why does an operating system loader hold a process-wide lock while initializing a library, and where does the library's start-up callback run relative to that lock? A: So no thread sees a half-built module; the callback runs while it is held
Download cheatsheet (.md)

When Windows loads a DLL, it does more than copy bytes. The loader maps PE sections, applies relocations when needed, resolves imports, prepares thread-local storage, updates module bookkeeping, and calls initialization entry points.

Other threads must not observe a half-initialized module. Windows serializes important parts of loading with a process-wide synchronization mechanism commonly called the loader lock.

That creates a special rule: Windows calls DllMain while the loader lock is held.

Initialization is a dependency-order problem

Section titled “Initialization is a dependency-order problem”

Each initialization step depends on other services already being ready. Import resolution depends on module loading; logging may depend on files, locks, or other DLLs; a hook may depend on the target module and worker state. DllMain runs inside the mechanism that is still satisfying some of those dependencies.

Draw the order before writing startup code:

Map the DLL
Finish loader notifications
Call explicit start
Verify target
Create worker resources
Install owned features

The arrows are dependency claims: a step may begin only after the step above it has completed.

Shutdown should reverse ownership, not merely reverse the list of function calls:

reject new work -> request worker stop -> join outside loader lock
-> restore patches -> release resources -> allow unload

An explicit lifecycle makes partially initialized states visible. If verification fails before a patch is installed, cleanup should not pretend the patch exists.

The entry point can receive reasons such as:

  • DLL_PROCESS_ATTACH: the DLL is being attached to the process;
  • DLL_THREAD_ATTACH: a new thread is being announced to DLLs;
  • DLL_THREAD_DETACH: a thread is ending;
  • DLL_PROCESS_DETACH: the DLL or process is being unloaded.

These are loader notifications, not general application events. The code is running in the context of whichever thread caused the load or unload, and notifications are serialized.

Imagine two locks:

wants loader_lock
wants game_lock
Thread A
holds game_lock
Thread B
holds loader_lock in DllMain

Neither thread can continue. The dangerous second lock may be hidden inside a function that loads a DLL, initializes COM, sends a blocking cross-thread message, starts complex runtime work, or waits for another thread.

A game thread and DllMain wait for each otherEach needed lock is already held by the opposite thread. The arrows are waits, not completed work.
  1. Control A takes game_lock

    Step 1: Thread A to game_lock

    Thread A holds the game’s own lock while it works.

  2. Control B holds loader_lock

    Step 2: Thread B (in DllMain) to loader_lock

    DllMain runs while the loader lock is held, so Thread B holds it.

  3. Request A waits for loader_lock

    Step 3: Thread A to loader_lock

    Thread A needs the loader lock and Thread B has it, so A stops here.

  4. Request B waits for game_lock

    Step 4: Thread B (in DllMain) to game_lock

    Thread B needs the game lock and Thread A has it, so B stops here too. The wait cycle is closed and neither thread can continue.

Each needed lock is already held by the opposite thread. The arrows are waits, not completed work.

The version that catches most game tools is subtler, because you never write the second lock yourself:

New threadDllMain (holds loader lock)New threadDllMain (holds loader lock)thread exists,but must run DLL_THREAD_ATTACH firstD holds the loader lock and waits for T.T needs the loader lock D holds. Deadlock.CreateThreadWaitForSingleObject(thread)needs the loader lock to runDLL_THREAD_ATTACH

CreateThread returns success, so nothing appears to have gone wrong. The new thread then blocks before executing a single line of your code, and the wait never finishes. This is precisely why the shutdown sequence above joins worker threads outside the loader lock, and why starting real work belongs in an explicit start() that the host calls once loading has completed.

That is why DllMain should not:

  • load another DLL;
  • initialize COM;
  • create a window or call complex User32/Shell APIs;
  • wait for a worker thread;
  • acquire a lock also used by normal game code;
  • parse configuration, scan memory, install hooks, or run a hotkey loop;
  • perform heavy allocation or logging whose dependencies are unclear.

“It worked on my machine” does not prove the lock ordering is safe. Deadlocks depend on timing.

A deadlock needs a cycle of waits. Write each lock edge as “while holding A, this path may wait for B.” If another path can hold B and wait for A, no timeout makes the ordering correct. Remove the wait from the loader callback or establish one global lock order that every path follows.

The course DLL does one documented optimization during process attach: it disables per-thread attach/detach notifications that the library does not use.

#[unsafe(no_mangle)]
pub extern "system" fn DllMain(
instance: HINSTANCE,
reason: u32,
_reserved: *mut c_void,
) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
// 🛡️ SAFETY: instance is the module Windows is attaching now. This API
// only disables notifications; it does not allocate, wait, or scan while
// the loader lock is held.
let _ = unsafe {
DisableThreadLibraryCalls(HMODULE(instance.0))
};
}
BOOL::from(true)
}

It does not start the game lab, read memory, scan modules, or wait.

The DLL exports a separate function named gha_start. The injector waits for its LoadLibraryW thread to finish first. Only then does it call gha_start, after the initial loader work has returned.

gha_startDllMainWindows loaderInjectorgha_startDllMainWindows loaderInjectorLoadLibraryWmap DLL, call DllMain(loader lock held)return (loader-safe work only)LoadLibraryW returnscall exported gha_startspawn worker thread, return

Return from the loader callback before waiting. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

Windows maps sections and resolves imports
DllMain runs while the loader lock is held
choose whether the callback waits for a worker
return to release the loader lock
start normal work outside the callback
report progress or a circular wait

Result: ordinary work starts after loader initialization.

Final values: start = outside; loader_lock = released; callback_waits = false; worker = running.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    start = outside.

  2. Step 2, line 2. The callback starts inside serialized loader work.

    start = outside; loader_lock = held.

  3. Step 3, line 3. Keep the callback small and let it return.

    start = outside; loader_lock = held; callback_waits = false.

  4. Step 4, line 4. Returning releases the lock.

    start = outside; loader_lock = released; callback_waits = false.

  5. Step 5, line 5. The exported start function can now create ordinary worker activity.

    start = outside; loader_lock = released; callback_waits = false; worker = running.

  6. Step 6, line 6. Result: ordinary work starts after loader initialization.

    start = outside; loader_lock = released; callback_waits = false; worker = running.

Watch it happenThe loader returns before the worker receives startup

A DLL image moves into the target while Windows maps sections, resolves imports, and prepares TLS. DllMain returns under the loader lock. LoadLibraryW then returns to the injector, which calls gha_start once and starts the worker outside the loader callback.

Read the steps
  1. The injector requests LoadLibraryW. It waits for the loading thread to finish, instead of requesting application startup while loader work is still active.
  2. Windows maps sections, applies needed relocations, resolves imports, prepares thread-local storage, and updates its loader records. These dependencies become ready inside the loader operation.
  3. Windows calls DllMain with the loader lock held. The course callback disables unused thread notifications and returns. It neither starts nor waits for the worker.
  4. The initial loading operation finishes and returns to the injector. The lock boundary is now behind this startup path. The separate gha_start call can begin.
  5. The first successful gha_start records STARTED and publishes STOP = false before spawning its worker. A duplicate startup or failed spawn returns an explicit error instead.
  6. The worker runs run_lab outside the initial loader callback. The loader never waits for this worker; later stop requests and joining belong to ordinary cleanup.

The shaded loader region is a synchronization boundary. Completing it is a dependency for the explicit startup handoff.

The exported function uses the Windows thread-procedure ABI because the course injector calls it as a separate thread entry:

#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
// 🔒 Publish startup exactly once. A second injector call must not create a
// second worker that races over the same hooks and cleanup state.
if STARTED.set(()).is_err() {
return 1; // already running
}
// 🔁 Release ordering publishes the new run state before the worker observes it.
STOP.store(false, Ordering::Release);
// 🧭 Complex work begins on this ordinary worker only after LoadLibraryW
// and DllMain have returned, so it is outside the loader-lock boundary.
let started = thread::Builder::new()
.name("gha-tool-worker".into())
.spawn(|| {
if let Err(error) = run_lab() {
eprintln!("game lab stopped: {error:#}");
}
});
if started.is_err() {
return 2;
}
0
}

OnceLock prevents duplicate startup. AtomicBool lets the exported stop function ask the worker loop to finish without blocking inside DllMain.

Separate “request stop” from “wait until stopped”

Section titled “Separate “request stop” from “wait until stopped””

The stop export sets a flag and returns:

#[unsafe(no_mangle)]
pub extern "system" fn gha_stop(_argument: *mut c_void) -> u32 {
// 🧹 Request-only API: the worker owns restoration and exits at its next safe
// checkpoint. Waiting here could recreate the loader-lock deadlock.
STOP.store(true, Ordering::Release);
0
}

The worker notices the flag, leaves its loop, releases any pressed input, and drops owned patches so their original bytes are restored. A loader callback should not wait for that worker while holding the loader lock.

Why creating a thread in DllMain is still the wrong design here

Section titled “Why creating a thread in DllMain is still the wrong design here”

You may see examples that call CreateThread directly from DLL_PROCESS_ATTACH. The new thread cannot safely finish DLL-dependent initialization until loader notifications are complete. Waiting for it inside DllMain can deadlock, and returning immediately creates a fragile ordering problem.

The explicit gha_start contract is easier to reason about and test:

The original loader callback starts a worker and installs hooks during DLL_PROCESS_ATTACH. That puts initialization under the loader lock, where a worker that needs DLL loading can deadlock.

Before: start work during the loader callback
unsafe extern "system" fn DllMain(_dll: HINSTANCE, reason: u32, _: *mut c_void) -> BOOL {
if reason == DLL_PROCESS_ATTACH { start_worker_and_install_hooks(); }
BOOL::from(true)
}

Move that work to an explicit startup boundary. The injector waits for loading to finish, then calls gha_start; its result reports whether course initialization succeeded.

Improved code

unsafe extern "system" fn DllMain(_dll: HINSTANCE, _reason: u32, _: *mut c_void) -> BOOL {
BOOL::from(true) // Only loader-safe work happens under the loader lock.
}
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
// The injector calls this only after its LoadLibraryW thread has finished.
start_worker_and_install_hooks().map_or(2, |_| 0)
}

Show the diff

unsafe extern "system" fn DllMain(_dll: HINSTANCE, reason: u32, _: *mut c_void) -> BOOL {
if reason == DLL_PROCESS_ATTACH { start_worker_and_install_hooks(); }
BOOL::from(true)
}
unsafe extern "system" fn DllMain(_dll: HINSTANCE, _reason: u32, _: *mut c_void) -> BOOL {
BOOL::from(true) // Only loader-safe work happens under the loader lock.
}
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
// The injector calls this only after its LoadLibraryW thread has finished.
start_worker_and_install_hooks().map_or(2, |_| 0)
}

Takeaway: finish loader notification before starting work that may depend on other DLLs.

The boundary is visible. A failure in LoadLibraryW is different from a failure in course initialization, and each stage returns its own result.

For each exact game DLL profile:

  1. inject the exact 32-bit build;
  2. verify gha_start returns zero;
  3. enable one lab feature;
  4. press End or call gha_stop;
  5. confirm patches restore and input is released;
  6. close the game normally;
  7. repeat to catch one-run initialization assumptions.

If the game hangs while loading or unloading, inspect lock order and work performed by entry points before adding timeouts. A timeout hides a deadlock; it does not repair one.

The complete entry-point implementation is dll.rs, and the two-stage caller is injector.rs.

dll.rs

windows-labs/src/windows_impl/ · Rust, 249 lines

Show the codeHide the code
//! DLL entry points and the small hotkey worker used by the course labs.

#![cfg(target_arch = "x86")]

use std::{
    ffi::c_void,
    path::Path,
    sync::{
        OnceLock,
        atomic::{AtomicBool, Ordering},
    },
    thread,
    time::Duration,
};

use anyhow::{Context, Result};
use windows::Win32::{
    Foundation::{HINSTANCE, HMODULE},
    System::{LibraryLoader::DisableThreadLibraryCalls, SystemServices::DLL_PROCESS_ATTACH},
    UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_END, VK_F1, VK_F2, VK_F3, VK_F4, VK_F5},
};
use windows::core::BOOL;

use super::{
    LocalPatch, OPENGL_CHAMS, OPENGL_OFF, OPENGL_WALLHACK, TARGET_UNDER_CROSSHAIR,
    assaultcube_aim_once, assaultcube_update_esp, enable_assaultcube_no_recoil,
    enable_assaultcube_show_all_radar, enable_wesnoth_map_reveal, install_assaultcube_esp_hook,
    install_assaultcube_trigger_hook, install_urban_terror_memory_wallhook,
    install_urban_terror_opengl_hook, install_wesnoth_stat_hook, install_wesnoth_terrain_hook,
    run_flare_farming_bot, run_wyrmsun_macro, send_left_mouse, set_urban_terror_opengl_mode,
    write_wesnoth_gold,
};

static STARTED: OnceLock<()> = OnceLock::new();
static STOP: AtomicBool = AtomicBool::new(false);

fn pressed_once(virtual_key: i32) -> bool {
    // SAFETY: GetAsyncKeyState accepts any virtual-key integer and returns a
    // value. The low bit means the key was pressed since the previous query.
    unsafe { GetAsyncKeyState(virtual_key) & 1 != 0 }
}

fn toggle(
    slot: &mut Option<LocalPatch>,
    install: impl FnOnce() -> Result<LocalPatch>,
) -> Result<()> {
    if let Some(mut patch) = slot.take() {
        patch.restore()?;
    } else {
        *slot = Some(install()?);
    }
    Ok(())
}

fn current_process_name() -> Result<String> {
    let executable = std::env::current_exe().context("Windows did not report the game path")?;
    let name = Path::new(&executable)
        .file_name()
        .context("the game path has no file name")?
        .to_string_lossy()
        .into_owned();
    Ok(name)
}

fn run_assaultcube() -> Result<()> {
    let mut trigger = None;
    let mut no_recoil = None;
    let mut radar = None;
    let mut esp = None;
    let mut aimbot_enabled = false;
    let mut mouse_down = false;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            if trigger.is_some() && mouse_down {
                send_left_mouse(false)?;
                mouse_down = false;
            }
            toggle(&mut trigger, install_assaultcube_trigger_hook)?;
            if trigger.is_none() {
                TARGET_UNDER_CROSSHAIR.store(false, Ordering::Release);
            }
        }
        if pressed_once(VK_F2.0.into()) {
            toggle(&mut no_recoil, enable_assaultcube_no_recoil)?;
        }
        if pressed_once(VK_F3.0.into()) {
            toggle(&mut radar, enable_assaultcube_show_all_radar)?;
        }
        if pressed_once(VK_F4.0.into()) {
            aimbot_enabled = !aimbot_enabled;
        }
        if pressed_once(VK_F5.0.into()) {
            toggle(&mut esp, install_assaultcube_esp_hook)?;
        }

        if aimbot_enabled {
            assaultcube_aim_once()?;
        }
        if esp.is_some() {
            assaultcube_update_esp()?;
        }

        let wants_mouse_down = trigger.is_some() && TARGET_UNDER_CROSSHAIR.load(Ordering::Acquire);
        if wants_mouse_down != mouse_down {
            send_left_mouse(wants_mouse_down)?;
            mouse_down = wants_mouse_down;
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(8));
    }

    if mouse_down {
        send_left_mouse(false)?;
    }
    // Dropping these values restores the original instructions in reverse
    // lexical order after the worker has stopped using them.
    drop(esp);
    drop(radar);
    drop(no_recoil);
    drop(trigger);
    Ok(())
}

fn run_wesnoth() -> Result<()> {
    write_wesnoth_gold(999)?;
    let mut terrain = None;
    let mut stat = None;
    let mut map = None;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            toggle(&mut terrain, install_wesnoth_terrain_hook)?;
        }
        if pressed_once(VK_F2.0.into()) {
            toggle(&mut stat, install_wesnoth_stat_hook)?;
        }
        if pressed_once(VK_F3.0.into()) {
            toggle(&mut map, enable_wesnoth_map_reveal)?;
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(20));
    }
    drop(map);
    drop(stat);
    drop(terrain);
    Ok(())
}

fn run_urban_terror() -> Result<()> {
    let mut memory_wallhook = None;
    let mut opengl_hook = None;
    let mut opengl_mode = OPENGL_OFF;

    while !STOP.load(Ordering::Acquire) {
        if pressed_once(VK_F1.0.into()) {
            toggle(&mut memory_wallhook, install_urban_terror_memory_wallhook)?;
        }
        if pressed_once(VK_F2.0.into()) {
            if opengl_mode == OPENGL_WALLHACK {
                set_urban_terror_opengl_mode(OPENGL_OFF)?;
                drop(opengl_hook.take());
                opengl_mode = OPENGL_OFF;
            } else {
                if opengl_hook.is_none() {
                    opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_WALLHACK)?);
                }
                set_urban_terror_opengl_mode(OPENGL_WALLHACK)?;
                opengl_mode = OPENGL_WALLHACK;
            }
        }
        if pressed_once(VK_F3.0.into()) {
            if opengl_mode == OPENGL_CHAMS {
                set_urban_terror_opengl_mode(OPENGL_OFF)?;
                drop(opengl_hook.take());
                opengl_mode = OPENGL_OFF;
            } else {
                if opengl_hook.is_none() {
                    opengl_hook = Some(install_urban_terror_opengl_hook(OPENGL_CHAMS)?);
                }
                set_urban_terror_opengl_mode(OPENGL_CHAMS)?;
                opengl_mode = OPENGL_CHAMS;
            }
        }
        if pressed_once(VK_END.0.into()) {
            break;
        }
        thread::sleep(Duration::from_millis(8));
    }

    set_urban_terror_opengl_mode(OPENGL_OFF)?;
    drop(opengl_hook);
    drop(memory_wallhook);
    Ok(())
}

fn run_lab() -> Result<()> {
    let process = current_process_name()?.to_ascii_lowercase();
    match process.as_str() {
        "wesnoth.exe" => run_wesnoth(),
        "ac_client.exe" => run_assaultcube(),
        "quake3-urt.exe" => run_urban_terror(),
        "flare.exe" => run_flare_farming_bot(&STOP),
        "wyrmsun.exe" => run_wyrmsun_macro(&STOP),
        _ => anyhow::bail!("{process:?} is not a configured course target"),
    }
}

/// Windows calls this under the loader lock. Do only the one documented,
/// loader-safe optimization here; the injector calls `gha_start` afterward.
#[unsafe(no_mangle)]
pub extern "system" fn DllMain(instance: HINSTANCE, reason: u32, _reserved: *mut c_void) -> BOOL {
    if reason == DLL_PROCESS_ATTACH {
        // SAFETY: `instance` is the module Windows is currently attaching.
        let _ = unsafe { DisableThreadLibraryCalls(HMODULE(instance.0)) };
    }
    BOOL::from(true)
}

/// Exported with the Windows thread-procedure signature so the injector can
/// call it safely after LoadLibraryW has returned and released the loader lock.
#[unsafe(no_mangle)]
pub extern "system" fn gha_start(_argument: *mut c_void) -> u32 {
    if STARTED.set(()).is_err() {
        return 1; // already running
    }
    STOP.store(false, Ordering::Release);
    let started = thread::Builder::new()
        .name("gha-tool-worker".into())
        .spawn(|| {
            if let Err(error) = run_lab() {
                eprintln!("game lab stopped: {error:#}");
            }
        });
    if started.is_err() {
        return 2;
    }
    0
}

#[unsafe(no_mangle)]
pub extern "system" fn gha_stop(_argument: *mut c_void) -> u32 {
    STOP.store(true, Ordering::Release);
    0
}

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.");
}

References: DllMain, dynamic-link library best practices, and preventing hangs.

Knowledge check

Avoid startup deadlocks

Why is loading another DLL or waiting for worker synchronization dangerous inside DllMain?

Finished Lesson 12.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 12: 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.