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

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.

Lesson 12.1
Read or listenPrint chapter or book
About 30 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.1Key ideas, reference and questions

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
Download cheatsheet (.md)

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 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 changed

Result: 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
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    installation = A.

  2. Step 2, line 1. Both records begin with the same name.

    installation = A; basename = zlib1.dll.

  3. Step 3, line 2. The process inventory supplies the actual loaded path.

    installation = A; basename = zlib1.dll; path = /fixture/A/zlib1.dll.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

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 DLLs

The 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:

EXE requests a dependency
Resolve a file identity
Create the image mapping
Map sections into memory
Apply relocations
Resolve imported functions
Publish the loaded module record

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:

yes
no
yes
no
yes
no
LoadLibrary with a bare DLL name
DLL redirection, an API set,
or a side-by-side manifest?
load the DLL it points to
a module with that name
already loaded?
reuse the loaded module
listed in KnownDLLs?
load the trusted
system copy
search 1: the application's directory
search 2: the system directory
search 3: the 16-bit system directory
search 4: the Windows directory
search 5: the current directory
search 6: the directories on PATH

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.

Which example.dll does LoadLibrary load?C:\Games\Demo\demo.exe calls LoadLibrary("example.dll"). It is not loaded yet and not a KnownDLL, so folders are searched in order.

The search, safe mode on

Folders, in the order they are searched

The first folder with the file wins
game folder1: no file
System322: found
System3: not reached
Windows4: not reached
current folder5: not reached
PATH folders6: not reached

What a module snapshot then reports

szModule (short name)
example.dllidentical for every copy
szExePath (full path)
C:\Windows\System32\example.dllthe file that actually loaded

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;
  • LoadLibraryEx flags 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.

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:

+0x000dwSize
+0x004th32ModuleID
+0x008th32ProcessID
+0x00CGlblcntUsage
+0x010ProccntUsage
+0x014modBaseAddr
+0x018modBaseSize
+0x01ChModule
+0x020szModule, 256 UTF-16 units
+0x220szExePath, 260 UTF-16 units
the caller sets this
the module's loaded range
NUL-terminated names
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

windows-labs/src/bin/ · Rust, 75 lines

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

Start a fresh local game without optional overlays or mod loaders:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc `
--bin module_inventory
.\target\i686-pc-windows-msvc\release\module_inventory.exe wesnoth.exe

Capture 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:

Terminal window
.\target\i686-pc-windows-msvc\release\module_inventory.exe ac_client.exe
.\target\i686-pc-windows-msvc\release\module_inventory.exe Quake3-UrT.exe

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.

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:

ETW trace sessiongame processmodule_inventoryETW trace sessiongame processmodule_inventoryboth snapshots miss helper.dll,while the trace shows it came and wentsnapshot 1module list ALoadLibrary maps helper.dllimage-load event, helper.dllFreeLibrary unmaps helper.dllimage-unload event, helper.dllsnapshot 2module list A again

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.

Knowledge check

Explain a changed module list

A new DLL appears in a game's module inventory. What should you check first?

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.

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.