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.
Cheatsheet for Lesson 8.2
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 isLoadLibraryW, 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
Keep target identity explicit
Section titled βKeep target identity explicitβ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.
The classic loader sequence
Section titled βThe classic loader sequenceβEvery box can fail and must clean up what earlier boxes created.
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.
Request Run LoadLibraryW on that path
Step 2: Injector to Course target
The loader reads the path and maps the DLL into the target.
Alone Loader maps the DLL
Step 3: Course target (alone)
This work happens inside the target, on the targetβs time.
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.
Control Release the path buffer
Step 5: Injector to Course target
The path was temporary workspace. Freeing it does not unload the DLL.
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.
Use a wide absolute path
Section titled βUse a wide absolute pathβ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.
Own remote memory
Section titled βOwn remote memoryβModel the allocation so cleanup is not optional:
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 runsThe 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
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.
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.
Step 3, line 3. The address is not null, so the check passes.
process = open; remote_memory = none; thread = none; addr = 0x01F40000.
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.
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.
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.
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.
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.
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.
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.
Verify every boundary
Section titled βVerify every boundaryβ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.
Architecture must match
Section titled βArchitecture must matchβ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:
rustup target add i686-pc-windows-msvccargo build --release --target i686-pc-windows-msvcPass 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.
Loader-lock reminder
Section titled βLoader-lock reminderβ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.
Cleanup order
Section titled βCleanup orderβOn every error:
- stop waiting on or close the remote thread handle;
- free the remote path allocation;
- close the process handle.
RAII types make this happen during early returns.
Run the real game lab
Section titled βRun the real game labβ- Build both injector and DLL for
i686-pc-windows-msvc. - Start one allowed game in the exact version and enter an offline/local match.
- Run the injector with the process name and absolute DLL path. The course code uses the locally resolved
LoadLibraryWaddress under the shared-base assumption described above. - Require a nonzero
LoadLibraryWthread result and a zerogha_startthread result. If either check fails, stop instead of trying another address or retrying blindly. - 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.
- Trigger the visible result: Wesnoth gold becomes
999, AssaultCube recoil disappears, or Urban Terror renders the local wallhack. - 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
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
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)
}
}Account for separate address spaces
Why should an injector avoid assuming that one local function pointer is valid in the target process?
Processes have separate virtual address spaces. Resolve the target module and export using assumptions proven for that architecture and build.
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.
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.