DLL Identity and Search Order
A loaded module has a path, bytes, signer, and dependency context; names alone cannot prove which code entered a game.
Cheatsheet for Lesson 12.1
A one-page summary of “DLL Identity and Search Order” you can keep beside your notes. Plain Markdown.
Lesson 12.1 — DLL Identity and Search Order: cheatsheet
- Remember: A DLL name is a clue; path, build, hash, architecture, and loaded range make the observation specific.
- Also useful: Module loading grows a dependency tree: each imported library can bring its own imports, so the final loaded set is larger than one executable’s list.
Reading map
- A module name is not a full identity
- Imports begin a dependency graph
- Search order is a compatibility and security boundary
- Take a ToolHelp module snapshot
- Run a clean baseline
- Turn the list into a baseline
- Snapshot limitations
- Keep defensive observation separate from loader manipulation
Review
- Q: A new DLL appears in a game's module inventory. What should you check first? A: Its full path, hash, signer or source, load timing, and whether the baseline expected it
- Q: A process reports that it loaded a library named
compress.dll. Why is that name alone weak evidence about which code ran? A: Many files share one name, so path, hash and signer say more
The previous chapter treated the game as a running process. A process dump or module snapshot can list its DLLs, but a familiar DLL name alone does not prove which file supplied the code. Module identity is the next layer of evidence. We will follow that identity through loading and API resolution, then ask which privileges protect the kernel boundary and how a physical-memory capture relates to a process address. At each stage, the practical question is the same: what did the tool actually observe and trust?
A module name is not a full identity
Section titled “A module name is not a full identity”A game may report that it loaded opengl32.dll, zlib1.dll, or another familiar name. The name alone does not say which file supplied the bytes. The full path, file hash, signer, architecture, and loaded range provide stronger identity.
That matters for debugging as well as defense. Loading the wrong version of a legitimate DLL can cause missing exports, crashes, changed structure layouts, or code patterns that no longer match the ones this course uses.
The same DLL name can identify different bytes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
basename = zlib1.dll
read the selected loaded path
record this build fingerprint and architecture
record its loaded range
compare the full identity with the baseline
report which evidence changedResult: this fixture matches its baseline identity.
Final values: installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll; fingerprint = recorded-build-A; architecture = 32-bit; base = 0x1000; end = 0x2000; matches_baseline = true.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
installation = A.
Step 2, line 1. Both records begin with the same name.
installation = A; basename = zlib1.dll.
Step 3, line 2. The process inventory supplies the actual loaded path.
installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll.
Step 4, line 3. Fingerprint labels are illustrative records, not computed cryptographic digests.
installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll; fingerprint = recorded-build-A; architecture = 32-bit.
Step 5, line 4. This run has a specific loaded range.
installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll; fingerprint = recorded-build-A; architecture = 32-bit; base = 0x1000; end = 0x2000.
Step 6, line 5. Compare the complete record rather than accepting the basename.
installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll; fingerprint = recorded-build-A; architecture = 32-bit; base = 0x1000; end = 0x2000; matches_baseline = true.
Step 7, line 6. Result: this fixture matches its baseline identity.
installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll; fingerprint = recorded-build-A; architecture = 32-bit; base = 0x1000; end = 0x2000; matches_baseline = true.
Imports begin a dependency graph
Section titled “Imports begin a dependency graph”The PE import table names DLLs and functions the image expects. The Windows loader must locate suitable DLL files, map them, resolve imported function addresses, and repeat the process for those DLLs’ dependencies.
game.exe├─ SDL2.dll│ └─ system dependencies├─ game-specific support.dll└─ Windows system DLLsThe final loaded set can also include modules requested later with LoadLibrary, graphics layers, accessibility tools, capture software, overlays, or optional game components.
The loader owns the transition from a requested dependency name to a mapped, registered module in the process:
A module snapshot observes the published records after these steps. It does not own the mappings and must not treat a snapshot entry as permission to unload one.
Search order is a compatibility and security boundary
Section titled “Search order is a compatibility and security boundary”When code supplies only a DLL name instead of a full path, Windows follows documented resolution rules. Those rules depend on factors such as packaged-app identity, API sets (virtual DLL names that Windows redirects to whichever real DLL implements them on that build), side-by-side manifests, already loaded modules, the KnownDLLs list (a fixed set of core system DLLs, recorded in the registry, that Windows always loads from the trusted system directory instead of searching for them), safe DLL search mode (a per-process setting that moves the current working directory later in the search order, so a file planted there is less likely to be picked over the real system DLL), the executable directory, system directories, and configured search paths.
Do not reduce that to “Windows always checks this one folder first.” The precise order depends on how the load was requested and the Windows environment.
For the most common case, an unpackaged desktop program that calls LoadLibrary with a bare name while safe DLL search mode is on (the default), Microsoft documents this order:
The first searched folder that contains a file with that name supplies the DLL. Every check above the numbered searches happens before any folder is searched, which is why a planted file cannot stand in for a KnownDLL. Packaged apps and a few redirection features add steps that the diagram leaves out.
Predict first
C:\Games\Demo\demo.exe calls LoadLibrary with the bare name example.dll, which is not loaded yet and not a KnownDLL. One copy is in C:\Windows\System32 and another in the current folder, C:\Users\Player\Downloads. Safe DLL search mode is on.
Which file loads?
Answer: The copy in System32. With safe search mode on, System32 is search 2 and the current folder is search 5. The game folder (search 1) has no copy, so search 2 finds the file first and the search stops there.
The search, safe mode on
Folders, in the order they are searched
What a module snapshot then reports
Search 2, the System32, is the first with a file named example.dll, so that file loads. The short name is the same whichever file won.
With safe DLL search mode off, the current folder moves up to search 2, right after the application’s folder; holding it back behind the system folders is what the setting does. Either way, both copies report the same short name, example.dll. Only the full path, and the file’s hash and signer, tell a tool which one actually loaded.
Safer application design uses:
- supported installation directories with controlled permissions;
- manifests and supported side-by-side versioning where appropriate;
LoadLibraryExflags that express the intended search scope;- absolute paths for application-controlled plug-ins when practical;
- no writable current-working directory inserted into dependency search.
This lesson observes what actually loaded. It does not place replacement DLLs into candidate folders or redirect the loader.
Take a ToolHelp module snapshot
Section titled “Take a ToolHelp module snapshot”TH32CS_SNAPMODULE lists modules matching the tool’s native architecture. TH32CS_SNAPMODULE32 also asks a 64-bit inspector to include 32-bit modules. The course uses both because the historical game builds are 32-bit.
Each MODULEENTRY32W includes:
modBaseAddr: live module base;modBaseSize: mapped image size;szModule: short module name;szExePath: full file path;- the owner PID and module handles used by Windows.
The structure has a fixed layout, and the caller must set dwSize first because that layout’s size differs between 32-bit and 64-bit builds. In the 32-bit build this course uses, it looks like this:
MODULEENTRY32W in a 32-bit build. The first eight fields are 4 bytes each, so they fill 8 × 4 = 32 = 2 × 16 = 0x20 bytes. szModule holds 256 UTF-16 units of 2 bytes each: 512 = 2 × 256 = 0x200 bytes, so szExePath starts at 0x20 + 0x200 = 0x220. It holds 260 units: 520 = 512 + 8 = 0x208 bytes, so the structure ends at 0x220 + 0x208 = 0x428, which is 4 × 256 + 2 × 16 + 8 = 1,064 bytes. That is the size Rust's size_of reports for the structure, and so the value the lab puts in dwSize for this build. A 64-bit build widens the two pointer fields to 8 bytes and aligns them, so its size is different.Complete lab source: module_inventory.rs
module_inventory.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use std::mem::size_of;
use gha_windows_labs::{OwnedHandle, Process};
use windows::Win32::{
Foundation::ERROR_NO_MORE_FILES,
System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW,
TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32,
},
};
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()
}
let process_name = std::env::args()
.nth(1)
.unwrap_or_else(|| "wesnoth.exe".to_owned());
let process = Process::find(&process_name)?;
let flags = TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32;
// SAFETY: the PID is a value and OwnedHandle owns the returned snapshot.
let snapshot = unsafe { CreateToolhelp32Snapshot(flags, process.id) }?;
let snapshot = OwnedHandle::from_raw(snapshot)?;
let mut entry = MODULEENTRY32W {
dwSize: size_of::<MODULEENTRY32W>() as u32,
..Default::default()
};
// SAFETY: entry has the required size and remains writable during the call.
unsafe { Module32FirstW(snapshot.raw(), &mut entry) }?;
let mut modules = Vec::new();
loop {
modules.push((
entry.modBaseAddr as usize,
entry.modBaseSize as usize,
wide_text(&entry.szModule),
wide_text(&entry.szExePath),
));
// SAFETY: entry remains a correctly sized writable output structure.
match unsafe { Module32NextW(snapshot.raw(), &mut entry) } {
Ok(()) => {}
Err(error) if no_more_files(&error) => break,
Err(error) => return Err(error.into()),
}
}
modules.sort_unstable_by_key(|(base, _, _, _)| *base);
println!(
"{} (PID {}) loaded {} module(s)",
process.name,
process.id,
modules.len()
);
for (base, size, name, file) in modules {
println!(" {base:#010x} {size:#010x} {name}");
println!(" {file}");
}
println!("The snapshot was read-only; no loader state was changed.");
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This read-only module inventory must run on Windows.");
}#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::mem::size_of; use gha_windows_labs::{OwnedHandle, Process}; use windows::Win32::{ Foundation::ERROR_NO_MORE_FILES, System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW, TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32, }, };
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() }
let process_name = std::env::args().nth(1) .unwrap_or_else(|| "wesnoth.exe".to_owned()); let process = Process::find(&process_name)?; let flags = TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32; // SAFETY: the PID is a value and OwnedHandle owns the snapshot. let snapshot = unsafe { CreateToolhelp32Snapshot(flags, process.id) }?; let snapshot = OwnedHandle::from_raw(snapshot)?; let mut entry = MODULEENTRY32W { dwSize: size_of::<MODULEENTRY32W>() as u32, ..Default::default() };
// SAFETY: entry has the required size and is writable. unsafe { Module32FirstW(snapshot.raw(), &mut entry) }?; let mut modules = Vec::new(); loop { modules.push(( entry.modBaseAddr as usize, entry.modBaseSize as usize, wide_text(&entry.szModule), wide_text(&entry.szExePath), )); // SAFETY: entry remains a valid output structure. match unsafe { Module32NextW(snapshot.raw(), &mut entry) } { Ok(()) => {} Err(error) if no_more_files(&error) => break, Err(error) => return Err(error.into()), } }
modules.sort_unstable_by_key(|(base, _, _, _)| *base); println!( "{} (PID {}) loaded {} module(s)", process.name, process.id, modules.len(), ); for (base, size, name, file) in modules { println!(" {base:#010x} {size:#010x} {name}"); println!(" {file}"); } println!("The snapshot was read-only; loader state was unchanged."); Ok(())}
#[cfg(not(windows))]fn main() { eprintln!("This read-only module inventory must run on Windows.");}Run a clean baseline
Section titled “Run a clean baseline”Start a fresh local game without optional overlays or mod loaders:
cd windows-labscargo build --release --target i686-pc-windows-msvc ` --bin module_inventory
.\target\i686-pc-windows-msvc\release\module_inventory.exe wesnoth.exeCapture the result again after entering a match. Some games delay-load graphics, audio, or networking components, so a later list may legitimately be longer.
Repeat the experiment for AssaultCube and Urban Terror:
.\target\i686-pc-windows-msvc\release\module_inventory.exe ac_client.exe.\target\i686-pc-windows-msvc\release\module_inventory.exe Quake3-UrT.exeTurn the list into a baseline
Section titled “Turn the list into a baseline”An approved baseline describes each non-system module with:
- full canonical path;
- SHA-256 hash from the integrity-manifest lesson;
- file size and version;
- expected publisher or project source;
- why and when the game loads it;
- which exact game build the entry belongs to.
A different module is not automatically malicious. It may be a graphics capture layer, accessibility tool, language pack, supported mod, or updated dependency. Investigate before removing anything.
Snapshot limitations
Section titled “Snapshot limitations”The module list is a moment-in-time view. A DLL may load or unload immediately afterward. A module manually mapped without normal loader registration may not appear in the ToolHelp loader list. A protected or cross-architecture target may restrict enumeration.
A DLL that loads and unloads between two snapshots appears in neither list, even though its code ran inside the game:
Each of these sources sees module loading from a different place, so each one covers something another misses:
- ToolHelp module list: the modules the loader has registered, at one instant;
- ETW image-load events: every image mapping and unmapping over time, including DLLs that came and went between snapshots;
- file hashes and signatures: which bytes are on disk, and who signed them;
- the process memory map: every committed region, including executable memory that no module in the list accounts for;
- the PE import table: which DLLs the executable declares it needs before it runs;
- debugger module events: load and unload notifications as they happen.
Keep defensive observation separate from loader manipulation
Section titled “Keep defensive observation separate from loader manipulation”The course does not hide modules, unlink loader entries, overwrite an existing module, or exploit search-order mistakes. Those actions corrupt the loader records that this chapter’s tools read, and that debuggers and crash reporters rely on too.
The complete inventory is module_inventory.rs. Combine its paths with integrity_manifest.rs for a repeatable baseline.
References: dynamic-link library search order, ToolHelp snapshots, and MODULEENTRY32W.
Explain a changed module list
A new DLL appears in a game's module inventory. What should you check first?
Different files can share one short module name. The full path, the hash of the exact bytes, the signer, and a comparison with the approved baseline show which file was loaded and whether the baseline expected it.
Finished Lesson 12.1?
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.