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.
Cheatsheet for Lesson 12.2
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 inLoadLibraryWis 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
Loading a DLL is a coordinated operation
Section titled “Loading a DLL is a coordinated operation”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:
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 unloadAn explicit lifecycle makes partially initialized states visible. If verification fails before a patch is installed, cleanup should not pretend the patch exists.
What DllMain notifications mean
Section titled “What DllMain notifications mean”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.
Why ordinary-looking work can deadlock
Section titled “Why ordinary-looking work can deadlock”Imagine two locks:
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.
Control A takes game_lock
Step 1: Thread A to game_lock
Thread A holds the game’s own lock while it works.
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.
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.
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:
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 entry point stays tiny
Section titled “The course entry point stays tiny”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.
Stage two begins after loading finishes
Section titled “Stage two begins after loading finishes”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.
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 waitResult: 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
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
start = outside.
Step 2, line 2. The callback starts inside serialized loader work.
start = outside; loader_lock = held.
Step 3, line 3. Keep the callback small and let it return.
start = outside; loader_lock = held; callback_waits = false.
Step 4, line 4. Returning releases the lock.
start = outside; loader_lock = released; callback_waits = false.
Step 5, line 5. The exported start function can now create ordinary worker activity.
start = outside; loader_lock = released; callback_waits = false; worker = running.
Step 6, line 6. Result: ordinary work starts after loader initialization.
start = outside; loader_lock = released; callback_waits = false; worker = running.
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
- The injector requests LoadLibraryW. It waits for the loading thread to finish, instead of requesting application startup while loader work is still active.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
Why this version?
Section titled “Why this version?”The boundary is visible. A failure in LoadLibraryW is different from a failure in course initialization, and each stage returns its own result.
Test the lifecycle, not just startup
Section titled “Test the lifecycle, not just startup”For each exact game DLL profile:
- inject the exact 32-bit build;
- verify
gha_startreturns zero; - enable one lab feature;
- press End or call
gha_stop; - confirm patches restore and input is released;
- close the game normally;
- 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
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
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.
Avoid startup deadlocks
Why is loading another DLL or waiting for worker synchronization dangerous inside DllMain?
Waiting or re-entering loader work while loader lock is held can create a circular dependency. Keep notifications minimal and initialize later.
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.
Plain Markdown, saved in this browser.
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.