Brightness, reading palette, text size, comments and reading sound.Adds page background, hover cards, button corners, code colours, width and layout.Everything, including diagram styling, motion, inline code, depth and fine details.
Reading sound Off
Sound starts off. Play when you want it.
Original sounds, released into the public domain under CC0.
Margin comments
Hide author and reader comments without deleting them.
Page brightness
Lesson text size
Reading palette
More options
Reader finish
Modern adds semantic colours and refined controls. Soft depth adds frost and shadows.
Page background
Hover cards
Definitions, examples, tips, and lesson summaries that open over words and links. Off also removes the underlines.
Chat button
Which corner the button for asking questions about the book sits in. Top corners stop short of the lesson list and contents; at the bottom right it sits under the Notes button. It takes the colour of the chapter you are reading.
Notes button
Where the 📝 Notes button sits on lessons. Hidden leaves a thin tab on the edge; Alt + N brings it back.
Settings panel
Popover opens under the button. Side sheet slides in from the right with a dimmed page behind it.
Sidebar and header contrast
Original keeps the dark lesson list and header. Unified tints them to match the page and palette.
Reading width
How wide the lesson text column is. Standard is about 70 characters a line.
Table of contents
Light or dark for the “On this page” panel on the right, whatever the page is.
Lesson spacing
Code brightness
Syntax colours
Moving effects
System respects reduced motion while still advancing steps. On Play enables moving effects when you start a diagram. Off keeps manual steps.
Fine-tuning
Depth
Flat is the original. Soft adds faint shadows to diagram boxes, cards and buttons and lifts them slightly on hover.
Inline code
Classic keeps the badge look. Soft quiets the background and border for paragraphs full of keywords.
Floating buttons
Docked shows the Notes and chat buttons normally. Minimal makes them small icons that fade until you point at them.
Gradients
Use flat colours for headings, cards and diagram backgrounds.
Diagram background
Changes diagram surfaces; labels and role colours remain readable.
Diagram boxes
Heading rectangles
Diagram label backgrounds
Diagram frames
Diagram size
Page grid
Time per step
Semantic colours
Ligatures
What the colours mean
Terms, inputs, and data
Code and processing
Results and tips
Cautions
These colours follow your theme; their meanings stay the same. Data tables and diagram explanations use the information colour; code panels use processing. The lesson header and navigation identify its chapter. Correct answers are green; errors are red.
Alt + T palette · Alt + D page · Alt + C code · Alt + S syntax · Alt + H semantic · Alt + L ligatures · Alt + N lesson list · Alt + O on this page
12. Loaders, Kernel Services, and Trust Boundaries — print chapter
Game Hacking Academy
Chapter 12: Loaders, Kernel Services, and Trust Boundaries
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
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.
The PEimport 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:
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
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.
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
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:
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:
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
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.
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.
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
An explicit lifecycle makes partially initialized states visible. If verification fails before a patch is installed, cleanup should not pretend the patch exists.
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.
Neither thread can continue. The dangerous second lock may be hidden inside a function that loads a DLL, initializes COM, sends a blocking cross-thread message, starts complex runtime work, or waits for another thread.
A game thread and DllMain wait for each otherEach needed lock is already held by the opposite thread. The arrows are waits, not completed work.
Thread Agame_lockloader_lockThread B (in DllMain)
1
ControlA takes game_lock
Step 1: Thread A to game_lock
Thread A holds the game’s own lock while it works.
2
ControlB holds loader_lock
Step 2: Thread B (in DllMain) to loader_lock
DllMain runs while the loader lock is held, so Thread B holds it.
3
RequestA waits for loader_lock
Step 3: Thread A to loader_lock
Thread A needs the loader lock and Thread B has it, so A stops here.
4
RequestB 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.
All 4 steps shown
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
threadsoutside the loader lock, and why starting real work belongs in an
explicit start() that the host calls once loading has completed.
That is why DllMain should not:
load another DLL;
initialize COM;
create a window or call complex User32/Shell APIs;
wait for a worker thread;
acquire a lock also used by normal game code;
parse configuration, scan memory, install hooks, or run a hotkey loop;
perform heavy allocation or logging whose dependencies are unclear.
“It worked on my machine” does not prove the lock ordering is safe. Deadlocks depend on timing.
A deadlock needs a cycle of waits. Write each lock edge as “while holding A, this path may wait for B.” If another path can hold B and wait for A, no timeout makes the ordering correct. Remove the wait from the loader callback or establish one global lock order that every path follows.
The course DLL does one documented optimization during process attach: it disables per-thread attach/detach notifications that the library does not use.
#[unsafe(no_mangle)]
pubextern"system"fnDllMain(
instance: HINSTANCE,
reason: u32,
_reserved: *mutc_void,
) -> BOOL {
ifreason == DLL_PROCESS_ATTACH {
// 🛡️ SAFETY: instance is the module Windows is attaching now. This API
// only disables notifications; it does not allocate, wait, or scan while
// the loader lock is held.
let_ = unsafe {
DisableThreadLibraryCalls(HMODULE(instance.0))
};
}
BOOL::from(true)
}
It does not start the game lab, read memory, scan modules, or wait.
The DLL exports a separate function named gha_start. The injector waits for its LoadLibraryW thread to finish first. Only then does it call gha_start, after the initial loader work has returned.
Return from the loader callback before waiting. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
Windows maps sections and resolves imports
DllMain runs while the loader lock is held
choose whether the callback waits for a worker
return to release the loader lock
start normal work outside the callback
report progress or a circular wait
Result: ordinary work starts after loader initialization.
Watch it happenThe loader returns before the worker receives startup
A DLL image moves into the target while Windows maps sections, resolves imports, and prepares TLS. DllMain returns under the loader lock. LoadLibraryW then returns to the injector, which calls gha_start once and starts the worker outside the loader callback.
Read the steps
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:
// 🧹 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
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.
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.
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.
#[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.");}
Normally the Windows loader resolves a program’s imports before main. Sometimes a tool supports several Windows versions and wants to use a newer API only when it exists. Then it can:
obtain a handle to the system DLL;
look up the named export;
convert the address to one proven function-pointer type;
call it or choose a fallback.
This is useful compatibility work. It is not a reason to hide imports or replace well-supported APIs with undocumented ones.
The export exists: type it, then call itGetProcAddress returns an address. The declared function type turns it into a call with the right ABI and arguments.
ToolLoaded moduleskernel32 exports
1
RequestGetModuleHandleW("kernel32.dll")
Step 1: Tool to Loaded modules
kernel32.dll is already loaded in the tool’s process, so this borrows its handle without loading anything.
The export exists, so an address comes back. So far it is only an address.
4
Alonetransmute to the declared fn type
Step 4: Tool (alone)
The SDK declaration fixes the ABI, the four parameters and the BOOL result. Only now is the address callable.
5
Requestcall(process, 0, buffer, &mut length)
Step 5: Tool to kernel32 exports
The call can still fail, for example on a handle without query rights, so its result is checked as usual.
All 5 steps shown
GetProcAddress returns an address. The declared function type turns it into a call with the right ABI and arguments.
The export is missing: take the planned fallbackGetProcAddress finds no such name. The tool never calls an unknown address; it switches to the fallback it designed first.
ToolLoaded moduleskernel32 exports
1
RequestGetModuleHandleW("kernel32.dll")
Step 1: Tool to Loaded modules
The same first step: borrow the loaded module’s handle.
Do not infer one API’s presence only from a Windows version string. Compatibility settings, servicing changes, and alternative implementations can make version checks lie about the capability that matters. Look up the documented export, validate the result, and take the fallback when it is absent.
Presence still does not guarantee that one call will succeed. The handle may lack rights, the buffer may be too small, or the target may exit. Feature detection chooses a code path; normal error handling still applies inside that path.
Missing capability is different from a failed call. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
look up the documented export
if absent: choose the labelled fallback
otherwise call the typed function
check its actual return result
record result source and limitations
return a value or an explicit error
Result: successful documented query. Export presence and operation success are separate checks.
Final values: export = present; call = ok; present = true; called = true; source = documented query; result = fixture path; limitation = call can fail despite presence.
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.
export = present; call = ok.
Step 2, line 1. Feature detection checks the capability itself.
For a known API, the Windows SDK declaration is the primary contract. Static analysis can confirm what the compiled caller does: argument registers, stack space, result checks, and error handling.
The pointer type says more than “address of a function.” It records ABI, argument order, mutability, and result width.
extern "system" names a different calling convention on each of the two targets this course builds, so the same alias arranges the same four values in two different ways. On 32-bit x86 it means stdcall: the caller pushes the arguments from last to first, so when the function starts they sit just above the return address, one per 4-byte stack slot, and the function removes them itself before returning.
[esp]return address
[esp+0x04]process
[esp+0x08]flags
[esp+0x0C]name
[esp+0x10]size
4 arguments × 4 bytes = 16 = 0x10 bytes, removed by ret 0x10
32-bit stdcall: the stack as QueryFullProcessImageNameW begins. Each argument slot is 4 bytes above the previous one, so the offsets run 0x04, 0x08, 0x0C (12), 0x10 (16).
On x86-64, Windows passes the first four integer or pointer arguments in registers, in order, and returns an integer result in EAX, the low 32 bits of RAX:
RCXprocess
RDXflags
R8name
R9size
EAXBOOL result
arguments, first to fourth
return value
The x86-64 Windows calling convention: four arguments, four registers. BOOL is a 32-bit integer, so EAX is enough to carry it.
That is why a “close enough” signature fails in ways that have nothing to do with pointer size. Declare three parameters instead of four, and a 32-bit caller pushes 12 bytes while the function still removes 16 with ret 0x10, leaving the stack pointer 4 bytes away from where the caller expects it. On x86-64 the function reads a fourth argument from R9 anyway, whatever that register happens to hold, and writes the result length through it as if it were a pointer.
The resolver starts with kernel32.dll in the current process. It looks up
QueryFullProcessImageNameW by its exported name, returns None if that name
is absent, and otherwise converts the address to the ABI-checked function type
established above. The caller can then choose the fallback rather than calling
an unknown address.
The Option is intentional. “Export absent” is a supported compatibility state, not necessarily a fatal error.
Cache the typed result only when module lifetime makes that safe. An address obtained from a module becomes invalid if that module can unload. System modules used for the process lifetime are simpler, but a plug-in resolver should keep module ownership and function-pointer lifetime together.
anyhow::ensure!(used <= buffer.len(), "Windows returned an invalid length");
Ok(String::from_utf16_lossy(&buffer[..used]))
}
length means two different things on the two sides of the call. Suppose the process is Wesnoth at C:\Games\Wesnoth 1.14.9\wesnoth.exe, a 35-character path:
[0]C
[1]:
· · ·
[33]x
[34]e
[35]NUL
· · ·
[32767]0
35 units of text, so length comes back as 35
not counted
never read
The buffer after a successful call. Going in, length was 32,768, the vector's full size: 2^15 units, room for the longest extended-length Windows path of 32,767 characters plus its terminator. Coming back, it is the number of units written without the terminator, so buffer[..35] is exactly the path. The ensure! rejects a returned length larger than the buffer, because slicing past the end would panic.
Use an RAII-owned process handle from the earlier Windows chapters. The dynamic call does not change ownership rules.
An optional API is useful only if the missing case has a defined behavior:
enumImageNameSource {
FullProcessImageName,
ToolHelpSnapshot,
}
If the full-path API is unavailable, a ToolHelp module snapshot may supply the
main module’s path through MODULEENTRY32W.szExePath. It is a different
observation: enumeration can fail across architecture or protection boundaries,
and the module can change after the snapshot. Label the source and its limits
instead of claiming that every fallback is simply a shorter path.
Notice that a failed call does not automatically choose the fallback. The API
can exist while the handle, buffer, or target state is invalid. Preserve that
difference so compatibility logic does not hide an ordinary error.
Never call an unresolved address. Never replace None with address zero. Never choose a “close enough” function signature because two pointer sizes happen to match.
When you inspect a game or tool, dynamic resolution explains a common pattern: a string names an API, GetProcAddress returns a pointer, and a later indirect call uses it. Reconstructing that chain turns anonymous control flow into a documented contract.
The responsible end state is clear: one typed resolver, one fallback, visible
errors, and no hidden capability. That completes the DLL and API path. The
next lesson follows a device request through the driver stack: which kernel
component receives it, which device owns the work, and where the result returns.
Lesson 12.4
What an Operating System Kernel Does
The tools so far have asked Windows for something: a handle to a game, a
copy of four bytes, a list of loaded modules. Each time, something checked
whether the tool was allowed to ask, did the work, and sometimes said no. The
kernel is the core of the operating system that coordinates those requests
with the processor’s highest operating-system privilege. Ordinary programs go
through its controlled services to reach shared resources such as another
process’s memory, the disk, the display, or a network device.
This lesson answers four questions. How does the processor keep ordinary
programs away from the kernel’s powers? How does a program ask the kernel for
something anyway? What does the kernel do with its authority? And why can one
kernel bug stop the whole computer, when a bug in a game only ends the game?
A four-byte ReadProcessMemory request will carry these questions through the
user/kernel boundary. The next two lessons open that request up:
Lesson 12.5 follows the Windows DLL layers it passes through
on the way down, and Lesson 12.6 reads the exact bits the
processor checks and follows the request one layer at a time.
The kernel and a game run on the same processor, often on the same core, taking
turns many times a second. What stops the game from doing what the kernel does:
rewriting another process’s memory, reprogramming the disk controller, or
simply never giving the processor back?
A protection check in memory the restricted program can overwrite would not
stop it. The restriction has to be enforced beyond that writable memory. The
processor checks privilege and memory permissions before carrying out an access.
So the processor tracks a privilege level for the code it is running. It
checks privileged instructions and page translations against that level. x86
processors define four levels, numbered 0 to 3 and usually drawn as nested
rings, with ring 0 the most privileged. Windows uses two of them:
kernel mode, ring 0, for the kernel and drivers;
user mode, ring 3, for everything else: games, browsers, debuggers, and
every tool in this book.
The processor records the current level in two bits of one of its registers,
which Lesson 12.6
reads in a debugger. What matters here is what the processor refuses to do
while the level is 3.
At ring 3 the processor refuses two kinds of thing.
Privileged instructions. Some instructions only make sense for whoever runs
the machine. hlt stops the processor until the next interrupt. cli turns
interrupts off, which would silence the timer the kernel relies on to take the
processor back. mov cr3, rax switches to a different set of page tables,
which selects a process’s whole view of memory. The physical-memory lessons
in Chapter 13 will follow those page tables. wrmsr rewrites the processor’s internal
settings. At ring 3, each of these raises a general-protection fault
instead of running, and the kernel turns the fault into an exception that
normally ends the program.
Memory marked for the kernel. Page-table entries carry a
user/supervisor bit, bit 2. When it is 0 on a present mapping, a ring-3
access causes a page fault. Windows reserves a system range of virtual
addresses for the kernel. In a traditional shared page table, kernel pages can
be mapped there alongside a game’s pages, but their supervisor bit still
blocks the game. With KVA shadow,
the user-mode page table omits most kernel mappings and keeps only the pages
needed to enter the kernel; entry code switches to the fuller kernel page
table. In either arrangement, a game cannot read or write the kernel’s
privileged pages. Lesson 12.6
compares the flag bits of a user page and a kernel page.
Put the two checks together and user-mode code is boxed in. It cannot run the
instructions that change privilege or page mappings, and it cannot alter
the kernel’s page tables.
If ring 3 cannot touch kernel memory or raise its own level, how does
ReadProcessMemory ever reach code that can read another process? Through a
system call: an instruction that switches the processor to ring 0 and jumps
to an address the kernel chose. On x86-64 the instruction is syscall. When
the processor executes it, it:
copies the address of the next instruction into rcx and the flags register
into r11, so the kernel can come back later;
switches the privilege level to 0;
jumps to an address held in a processor setting that only ring 0 can change.
The kernel wrote that address during boot, using wrmsr.
Step 3 is what makes the design safe. A program chooses when to request a
service, never an arbitrary kernel instruction to run. The processor enters a
kernel-controlled path, which identifies the requested service and checks its
untrusted arguments. This is the controlled crossing that the Windows API
lessons relied on, now seen from the processor’s side.
Take a read like the ones in Lesson 6.3: a handle to the game
opened with only query and read rights, and a request for 4 bytes at address
0x0050_1000. Here is the whole round trip:
The call passes through kernel32.dll and kernelbase.dll to
NtReadVirtualMemory in ntdll.dll, which puts a service number, telling
the kernel which of its routines to run, into the eax register and executes
syscall. The kernel’s own NtReadVirtualMemory then checks two things before
it touches anything:
both address ranges, the 4 bytes in the game and your tool’s buffer, must lie
in user space, so ReadProcessMemory can never read kernel memory, whatever
handle you hold;
the handle must carry the right the operation needs. This one has
PROCESS_VM_READ, so the read is allowed. It lacks PROCESS_VM_WRITE, so a
write through it would be refused, and nothing can add that right later: a
handle’s rights are fixed when it is opened.
Only then does the kernel copy the 4 bytes into your buffer. It puts a status
code in eax, here STATUS_SUCCESS, which is 0, and executes sysret, the
reverse of syscall: the level drops back to 3 and your thread resumes after
its syscall. On a failure, kernelbase.dll turns the status into the error
number that GetLastError reports, such as 5 for access denied.
Lesson 12.6 follows each
step with its values: the kernel’s own stack, the saved registers, and the
arithmetic of the rights check.
A system call is one common way execution crosses from a user thread into the
kernel. Two others are exceptions, raised by the instruction that is running
(a page fault, a division by zero, the int3 breakpoint), and interrupts,
signals that arrive between instructions whether or not the running program
expects them.
The kernel does not interpret each ordinary game instruction. These entries
bring a user thread into kernel code when work needs its authority. Windows
also schedules its own kernel worker threads for work that need not begin in a
game’s call. Both facts fit the same model: the kernel is code with privileged
responsibilities, and the processor enforces who may run it.
The processor finds exception and interrupt handlers through the interrupt
descriptor table, which the kernel fills in at boot. Each entry is chosen by
a one-byte vector number. One byte holds 2⁸ = 256 values, so the table has
at most 256 entries, numbered 0 to 255. The processor fixes the meaning of
vectors 0 to 31: vector 3 is the breakpoint exception that the byte 0xCC,
int3, raises (Lesson 2.4), vector 13 is the general-protection
fault, and vector 14 is the page fault. Devices are given vectors from 32 up.
A computer has a handful of processor cores and, at any moment, hundreds or
thousands of threads (Lesson 6.6 described ready, running,
and waiting threads). The kernel’s scheduler decides which ready thread
runs on each core. Timer interrupts are one opportunity to reconsider that
choice; a thread blocking for input or a lock is another. Timer resolution and
interrupt scheduling vary by Windows version, hardware, and power state, so a
fixed tick interval is not a reliable description of every game session.
When a running thread stops to wait for input, a lock, or a timer, or when its
turn is interrupted, the scheduler can pick another thread. Changing threads is a context
switch: the kernel saves the old thread’s registers into its context, loads
the new thread’s registers, and, if the new thread belongs to a different
process, loads that process’s top-level page-table address into cr3. The
program never sees the switch. Its registers come back exactly as they were.
Switching to a thread of another processShort example values. Thread A belongs to the game (page tables at 0x7000); thread B belongs to another process (0x9000).
1Save A’s registers
CPU core
0x1A20rip
0xFE80rsp
42rax
0x7000cr3
↓ copy
A’s saved context
0x1A20rip
0xFE80rsp
42rax
Thread A stops. The kernel copies the core’s registers into A’s context in kernel memory.
2Load B’s registers and cr3
B’s saved context
0x3010rip
0xFD40rsp
7rax
0x9000its tables
↓ load
CPU core
0x3010rip
0xFD40rsp
7rax
0x9000cr3 changed
B is in another process, so cr3 also gets that process’s page tables. B continues where it stopped.
All 2 steps shown
Short example values. Thread A belongs to the game (page tables at 0x7000); thread B belongs to another process (0x9000).
Switching to a thread of the same processShort example values. Threads A and B both belong to the game, whose page tables are at 0x7000.
1Save A’s registers
CPU core
0x1A20rip
0xFE80rsp
42rax
0x7000cr3
↓ copy
A’s saved context
0x1A20rip
0xFE80rsp
42rax
Thread A stops. The kernel copies the core’s registers into A’s context in kernel memory.
2Load B’s registers; cr3 stays
B’s saved context
0x2F00rip
0xFA10rsp
0rax
0x7000its tables
↓ load
CPU core
0x2F00rip
0xFA10rsp
0rax
0x7000cr3 unchanged
Same process, same page tables, so cr3 is not touched. B continues where it stopped.
All 2 steps shown
Short example values. Threads A and B both belong to the game, whose page tables are at 0x7000.
Each process has its own page tables, and only the kernel writes them.
Reserving an address range and committing storage are distinct operations, as
Lesson 6.5 showed. Even after a page is committed, Windows may assign its
physical frame only on first access. That access faults, the kernel supplies a
zeroed page so old data cannot leak between processes, updates the mapping,
and retries the instruction. The game normally never sees this handled fault.
Lesson 6.5 looked at the result from outside, and
Lesson 13.1 will examine how physical memory fits beneath
that view.
When a device needs attention, because a key was pressed, a disk transfer
finished, or a network packet arrived, it raises an interrupt. The processor
finishes its current instruction, saves where it was, enters ring 0 if it was
not there already, and runs the handler for that vector. The interrupted code,
kernel or user, resumes afterward as if nothing had happened. Handlers must be
short, because other work on that core waits while they run;
Lesson 12.7 shows how drivers split the work.
Ordinary user programs reach devices through operating-system interfaces
rather than writing device registers directly. A driver knows a device’s
register layout and command protocol, and Windows routes requests through the relevant driver
stack. Lesson 12.7 follows one request down to the hardware.
The processor enforces privileged-instruction and page-permission checks in
hardware. Windows checks handle rights and validates request ranges in the
kernel, where the caller cannot simply skip the decision. The kernel also
enforces the access tokens and integrity levels from Lesson 6.3, the page
protections from Lesson 6.5, and the signing rules that decide which drivers
may load at all (Lesson 12.7).
Recognize the subsystem in a kernel routine’s name
Driver documentation and debugger stacks often use short prefixes as clues
to a routine’s subsystem. The table connects those names to the responsibilities
above; the linked routines are reading examples, not extra lab steps.
ZwCreateFile reaches the corresponding file service.
Nt and Zw need one extra distinction. For system calls from user mode,
their versions behave alike and treat arguments as untrusted user input. From
kernel mode, Zw treats parameters as coming from a trusted kernel source;
Nt uses the calling thread’s previous mode to decide how to interpret and
validate them. This is a calling-context rule, not a way for a user program
to gain ring 0. Microsoft’s Nt/Zw explanation
describes the distinction.
Older examples need a version check. ExAllocatePool
is deprecated; Microsoft’s migration guide
replaces it with ExAllocatePool2 for drivers targeting Windows 10 version 2004
or later. HalGetAdapter remains listed for existing drivers, but the documented
replacement is IoGetDmaAdapter.
An old name in a stack can explain the subsystem without being a good choice
for new code.
When user-mode code follows a bad pointer, the processor raises a page fault,
the kernel finds that nothing valid lives at that address, and it turns the
fault into an access-violation exception for that one process. In the worst
case one process ends; the kernel is the safety net under every user-mode
program.
Nothing sits beneath the kernel in the same way. Kernel code and drivers share
one address space and one set of data structures, so when a driver follows a
bad pointer by mistake, the unexpected fault may strike halfway through an
update to one of those structures, with a lock still held. Windows cannot prove
which of its records are still sound, so it stops the whole machine on purpose. That stop is a bug check,
better known as the blue screen: Windows shows a stop code, writes a crash
dump if it can, and restarts.
Lesson 12.9
follows that decision step by step, beside the other risks of trusted kernel
code. It is also why the book keeps its labs in user mode and inside a virtual
machine: a crash in a user-mode lab ends one process, and even a bug check
inside a virtual machine stops only that virtual machine.
Anti-cheat software exists to notice when another program interferes with a
game. A kernel component lets it use Windows facilities that user-mode code
cannot reach. One of them is a handle-operation callback, which runs while
another process opens a handle to the game and may remove rights from the
request. A tool that asks for read and write rights can receive a handle
without them, and its ReadProcessMemory then fails at exactly the rights
check described above.
The cost is that such code has the same authority as the rest of the kernel.
Its bugs become bug checks on players’ machines, and a vulnerability in it
becomes a vulnerability in every machine it is installed on.
Lesson 15.3
weighs what that extra visibility buys against those costs. First, the next
lessons in this chapter show what driver code actually does and why its
interface must be narrow.
The course tool can now name a process, open and own a limited handle, inspect
page metadata, and observe threads. Each operation was a call to a Windows
API. The remaining question is where such a call goes when the tool asks the
operating system to do something it cannot do directly.
A call to a Windows function such as VirtualQueryEx does not instantly jump into the deepest part of Windows. It usually passes through layers.
Use three separate ideas: contract, implementation path, and privilege
transition. Win32 documents the contract application code may rely on. User-mode
DLLs implement or forward that contract. A system-call instruction performs the
controlled CPU transition for work that requires kernel privilege.
The boxes are software layers, not necessarily four separate processes.
kernel32.dll and ntdll.dll are mapped into the same user-mode process as our
program. Microsoft may change the implementation path while preserving the Win32
contract, which is why “calling a deeper layer” is usually less stable rather than
more capable.
A call passes through layers in one process before the kernelThe application, the Win32 DLL and the ntdll stub all run in the same user-mode process. Only a controlled gate reaches the kernel.
ApplicationWin32 DLLntdll stubKernel
1
RequestCall a Win32 function
Step 1: Application to Win32 DLL
The application calls an ordinary documented function.
2
RequestCall the native stub
Step 2: Win32 DLL to ntdll stub
The DLL’s implementation calls a lower-level stub in ntdll. Still the same process, still user mode.
3
EncryptedControlled system-call gate
Step 3: ntdll stub to Kernel
The stub crosses into the kernel through a gate the CPU controls. This is the only privilege change.
4
AloneValidate the request
Step 4: Kernel (alone)
The kernel checks the arguments and the caller’s rights before acting.
5
ReplyResult
Step 5: Kernel to ntdll stub
The kernel returns a result or an error code.
6
ReplyReturn
Step 6: ntdll stub to Win32 DLL
The stub hands the result back up.
7
ReplyReturn to the caller
Step 7: Win32 DLL to Application
The Win32 function turns it into the value the application expects.
All 7 steps shown
The application, the Win32 DLL and the ntdll stub all run in the same user-mode process. Only a controlled gate reaches the kernel.
A Win32 call such as VirtualQueryEx passes down through user-mode DLLs and the system-call gate before the kernel validates it. Calling a deeper layer is usually less stable, not more capable. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Most game code, application code, and Windows DLL code runs in user mode. A user-mode mistake should not be able to overwrite the kernel or another process merely by using a bad pointer.
The Windows kernel and drivers run in kernel mode, where code can manage page tables, devices, scheduling, and other system-wide resources. A bug there can affect the whole computer. That is why the course does not turn ordinary game experiments into kernel-driver experiments.
The CPU enforces this border. A system call changes privilege through a defined entry point; it is not a magic function name and it is not the same thing as calling a normal function.
This design is often described as limited direct execution. The game normally
runs directly on the CPU for speed, but in user mode and within its address-space
permissions. Timers and interrupts let Windows regain control for scheduling. A system
call lets the game request a restricted operation through a validated entry path.
The model resolves a common confusion: Windows is not interpreting every ordinary
game instruction. Most instructions execute natively. Hardware privilege, address
translation, interrupts, and controlled entries provide the limits that let the OS
remain in control.
The documented Win32 API is the part application developers are normally expected to call. Examples from this course include:
ReadProcessMemory for a read-only inspector with query-and-read rights;
VirtualQueryEx for page metadata;
CreateFileW for files and local named pipes;
CreateFileMappingW for a shared-memory object.
Microsoft can change how a supported function is implemented while keeping its documented behavior. On modern Windows, an export in kernel32.dll may forward to another DLL such as KernelBase.dll. Your source code still calls the stable Win32 name.
A forwarded export is not a jump instruction the loader inserts. The export table entry itself can hold a string such as KERNELBASE.CreateFileW in place of a code address. When GetProcAddress resolves that entry, it reads the string, locates the named DLL, and looks up the function there instead — following another forward if that entry also happens to be one. Whoever asked for kernel32!CreateFileW never sees this redirection; they get back a usable address in KernelBase.dll either way, which is exactly why Microsoft can move an implementation between DLLs across Windows versions without breaking the caller’s import:
ntdll.dll exposes many functions whose names begin with Nt, such as NtQueryVirtualMemory. Some native functions are documented for limited uses and many are not stable application contracts.
For ordinary tools, prefer documented interfaces:
“Closer to the kernel” does not mean “better for normal application code.”
Calling an undocumented entry point or copying a system-call number from one Windows build can make a tool fragile. Numbers and internal structures can change. The course resolves one native export so you can see the layer, but it deliberately does not call it or build a direct-syscall bypass.
A DLL can publish an export table. Each exported name maps to an entry that the loader can resolve. GetProcAddress asks a loaded module for one of those exports.
The returned address is a virtual address in the current process. Address Space Layout Randomization, or ASLR, means the same DLL can be mapped at a different base address in another process or after a reboot. Store the name or an RVA when that is the stable identity; do not paste a displayed absolute address into tomorrow’s program.
The lab gets handles to DLLs already loaded in its own process, resolves three names, and prints their process-local addresses. It never opens a game process and never calls the native function:
Both DLLs are already present in a normal Windows console process. GetModuleHandleW borrows their existing module handles; it does not request another load or add a loader reference that we would later need to release.
PE export names are byte strings. The s!macro creates a terminated static PCSTR. DLL file names use Windows wide strings, so GetModuleHandleW receives values from the w! macro instead.
This original attempt mixes byte strings and wide strings. It is intentionally a non-compiling example: the Windows bindings reject the wrong parameter types before any call runs.
Use w! for the wide DLL filename and s! for the byte-encoded export name. Both strings are static and terminated; the DLL handle is borrowed for the lookup.
In this version, FARPROC already means an optional generic function pointer.
ok_or_else removes that Option wrapper; the helper returns its address as
usize for display. It does not call that pointer as VirtualQueryEx. The
version-pinned type
and lookup signature
explain this return-type choice.
Improved code
Optional: practise matching the full API names, module argument and address type to their jobs.
Microsoft’s C documentation uses names such as DWORD, BOOL, and HANDLE
to describe the API’s binary contract. Match that contract rather than choosing
a Rust type just because its name sounds similar:
Windows spelling
What it means at the boundary
Rust reading rule
DWORD
Unsigned 32-bit integer, even in a 64-bit process
Usually u32; it does not grow into usize.
BOOL
Signed 32-bit integer Boolean value
Raw i32, or the binding’s projected type or result; Rust bool has a different representation. For Boolean truth, test nonzero rather than equality to 1.
HANDLE
Opaque, pointer-sized object handle
Use the binding’s handle type; it is not a Rust reference that you may dereference.
These distinctions come from Windows coding conventions
and Windows data types.
Read each function’s return-value section too: its success and failure rules
can be more specific than its return type.
The lab’s string-width mistake is one instance of a broader convention.
In paired string APIs, W accepts UTF-16 code units and A accepts 8-bit text
in the API’s ANSI encoding; A is not a promise of ASCII or UTF-8. C headers
may select an unsuffixed name using the UNICODE macro. Calling the explicit
W function keeps the choice visible in Rust. See Working with Strings.
The ABI, or application binary interface, also specifies how arguments and
results pass between caller and callee. WINAPI and CALLBACK in C signatures
mark a calling convention, not an access right. Generated bindings supply the
matching declarations. For a callback declared with that convention, match its
exact signature and Rust’s extern "system" ABI:
its underlying convention depends on the target, including stdcall on 32-bit
Windows for non-variadic functions.
The GetModuleHandleW contract
is the ownership rule behind that note. Likewise, a string pointer must stay
valid for as long as the receiving API uses it; a pointer wrapper alone does
not own its storage.
Windows status and error types, including HRESULT and Result.
The generation tools have separate jobs: windows-bindgen
turns Windows metadata into Rust bindings; windows-metadata
reads and writes that metadata; windows-clang
extracts C/C++ header declarations using libclang. The lab consumes pre-generated
bindings. Add support or generation crates only when your own task needs them;
many support types are already re-exported by the API crate.
One similarly named crate, windows-reference,
boxes a value for a WinRT signature that requires IReference<T>. That is a
Windows interface object, distinct from ordinary Rust borrowing with &T.
The address inspector needs no such boxed value.
The program prints exported function names beside their addresses in this
process. Its first run establishes a local observation; the second run tests
whether those addresses are safe to treat as permanent identifiers.
Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc --bin api_layers
Run it again after restarting the virtual machine and compare the addresses. A difference is expected. The names and behavior are the meaningful pieces.
Then use module_inventory against wesnoth.exe, ac_client.exe, or Quake3-UrT.exe. You are looking at two sides of the same loader idea:
module_inventory shows which DLL images the game loaded;
api_layers shows how one process asks a loaded DLL for an exported name.
Lesson 12.4 showed the processor keeping user-mode code away
from the kernel and drew one ReadProcessMemory call crossing into the kernel
and back. This lesson opens both up. It reads the bits the processor actually
checks, then follows that one call a layer at a time, with every value the
kernel compares along the way.
The processor keeps the current privilege level in the lowest two bits of the
cs register, which tells the processor which code segment it is running. Two
bits can hold 2 × 2 = 4 patterns, 00, 01, 10, and 11, which is
exactly why there are four rings.
Open any 64-bit program in x64dbg and the register panel shows cs as 0033:
bit 70
bit 60
bit 51
bit 41
bit 30
bit 20
bit 11
bit 01
hex 3
hex 3
which code segment
level 11 = 3
cs = 0x33 in a 64-bit program. The upper bits choose a code segment; the lowest two bits are the current privilege level.
0x33 is 3 × 16 + 3 = 51. Each hex digit stands for four bits, so 0x33 is
0011 0011. Its lowest two bits are 11, which is 2 + 1 = 3: ring 3, user
mode. The kernel’s own code segment is 0x10, which is 0001 0000; its lowest
two bits are 00, ring 0.
A program cannot promote itself by loading 0x10 into cs. The processor
refuses any change of code segment that would raise the privilege level, except
through a few entry points that the kernel prepared in advance. The most
important of them is the syscall instruction, and the rest of this lesson
follows one trip through it.
Lesson 12.4 said that a page-table entry’s user/supervisor bit, bit 2,
decides whether ring 3 may touch the page. An entry combines the address of a
physical page with flags describing that mapping. For the ordinary 4 KiB
pages used here, the page address is aligned on a 4,096-byte boundary, leaving
the low twelve bits available for flags. Chapter 12’s
address-translation lesson will follow a complete walk;
here we need only the permission flags in the lowest byte:
bit 70
bit 61
bit 51
bit 40
bit 30
bit 21
bit 11
bit 01
dirty
accessed
user
write
present
The lowest byte of a page-table entry for a user page, 0x67.
0x67 is 6 × 16 + 7 = 103, and 103 = 64 + 32 + 4 + 2 + 1, so bits 6, 5, 2, 1,
and 0 are set. Bit 0 says the page is present, bit 1 that it may be written,
and bit 2 that ring 3 may use it. The processor itself sets bit 5, accessed,
the first time the page is used, and bit 6, dirty, the first time it is
written. Bits 3, 4, and 7 control caching and are 0 here.
A page that holds kernel data looks almost the same:
bit 70
bit 61
bit 51
bit 40
bit 30
bit 20
bit 11
bit 01
dirty
accessed
user = 0
write
present
The same byte for a kernel page, 0x63. Only bit 2 differs, so ring 3 is refused.
0x67 − 0x63 = 4, and 4 = 2², so exactly one bit differs between these two
example leaf entries: bit 2. A kernel page can be present, writable, and
recently used, and still refuse access from ring 3. In a real page-table walk,
the processor also considers permissions in the entries at higher levels; a
single leaf byte is a teaching example, not a complete access decision.
Flip the user bit of a page-table leafA leaf byte carries permission flags. Bit 2 says whether user-mode code (ring 3) may use the page at this level.
Leaf byte, bit 7 to bit 0
The leaf byte and the decision
Leaf byte 0x67
Bits
bit 7bit 0
0bit 7
1bit 6
1bit 5
0bit 4
0bit 3
1user (bit 2)
1bit 1
1bit 0
Decision at this level
Ring 3 access
Bit 2 is set: user mode may use this page here
Not the whole decision
Higher levels
The page-table levels above this one also contribute to the final access decision.
0x67 has bit 2 set, so ring 3 is allowed by this level.
Take a read like the ones in Lesson 6.3: a handle to the game
opened with only query and read rights, and a request for 4 bytes at address
0x0050_1000. Lesson 12.4 drew the whole round trip as one sequence diagram,
from your tool through kernel32.dll, kernelbase.dll, and ntdll.dll to the
processor and the kernel, and back. This section follows that diagram one
participant at a time.
ReadProcessMemory in kernel32.dll forwards to kernelbase.dll
(Lesson 12.5), which calls NtReadVirtualMemory in
ntdll.dll with the same arguments. The ntdll function is a stub: a few
instructions whose only job is to make the system call. On 64-bit Windows its
core is four instructions:
mov r10, rcx ; the first argument moves out of rcx
mov eax, <number> ; the system service number: which kernel routine to run
syscall ; enter the kernel
ret ; back to kernelbase, with the status in eax
The first line exists because of the first thing syscall does. The Windows
x64 calling convention passes a function’s first argument in rcx, but
syscall overwrites rcx with the return address, so the stub moves the
argument to r10 first, and the kernel knows to look for it there.
The number in eax is a system service number, an index into the kernel’s
table of routines. The numbers are internal to Windows and change between
builds, which is why this book never copies one into a program. In a debugger
the stub also shows a test and a jump to an older entry instruction, int 2Eh,
that some configurations use; either way, the processor arrives at an address
the kernel chose.
The kernel’s entry code assumes that everything user mode handed it could be
hostile. It switches from the program’s stack to a kernel stack that belongs to
the thread, because the program’s stack pointer could point anywhere. It saves
the user registers so they can be put back exactly. Then it looks the service
number up in its table, rejects a number that is out of range, and calls the
kernel’s own NtReadVirtualMemory.
The address ranges must lie inside user space. The request covers
0x0050_1000 up to 0x0050_1000 + 4 = 0x0050_1004, and the tool’s buffer is
also a user address. Both are far below the top of user space, which is a
little under 0x0000_8000_0000_0000 on 64-bit Windows. A range that reached
into the kernel’s half, or one long enough to run past the largest 64-bit
number and wrap around to a small address, would be rejected here. The
user/supervisor bit cannot do this job for the kernel: the copy runs in ring 0,
and ring 0 may touch supervisor pages. So the kernel checks every range itself,
and that check is why ReadProcessMemory cannot read kernel memory, whatever
handle you hold.
The handle must carry the right the operation needs. The kernel finds the
handle in the tool’s handle table and reads the rights granted when the handle
was opened. Reading needs PROCESS_VM_READ, which is 0x0010. This handle was
opened with PROCESS_QUERY_LIMITED_INFORMATION and PROCESS_VM_READ, so its
rights are 0x1000 + 0x0010 = 0x1010. Written as bits, one byte at a time:
150
140
130
121
110
100
90
80
hex 1
hex 0
query
Bits 15 to 8 of the handle's rights, 0x1010. 0x1000 is 16³ = 4,096 = 2¹², so it is bit 12.
70
60
50
41
30
20
10
00
hex 1
hex 0
write
read
Bits 7 to 0. 0x0010 is 16 = 2⁴, bit 4, the read right. Bit 5, the write right, is 0.
The test is a single AND. The kernel keeps only the bits that are set in both
the handle’s rights and the rights it needs, and allows the operation only if
every needed bit survived. 0x1010 AND 0x0010 = 0x0010, the whole
requirement, so the read is allowed.
WriteProcessMemory needs PROCESS_VM_WRITE, 0x0020. That is 2 × 16 = 32 =
2⁵, bit 5, and bit 5 of 0x1010 is 0, so 0x1010 AND 0x0020 = 0: denied,
whatever the tool’s author intended. The rights were fixed when the handle was
created, and nothing done with the handle afterward can add to them.
Only then does it copy. The source address belongs to the game’s address
space and the destination buffer belongs to the tool’s. The kernel must
interpret each in the correct process context while copying; the exact
internal mapping and copy strategy is a Windows implementation detail. If a
page becomes unavailable during the operation, the API may report a failed or
partial read. The caller must check the returned byte count instead of
assuming that asking for four bytes means four arrived.
The kernel places a status code in rax and executes sysret, the reverse of
syscall: the level drops back to 3, and execution resumes at the address saved
in rcx, which is the ret right after syscall in the stub.
The status is an NTSTATUS, a 32-bit code whose top two bits give its
severity: 00 success, 01 information, 10 warning, 11 error.
STATUS_SUCCESS is 0. STATUS_ACCESS_DENIED is 0xC000_0022; hex C is
binary 1100, so its top two bits are 11, an error. STATUS_PARTIAL_COPY is
0x8000_000D; hex 8 is 1000, so its top two bits are 10, a warning,
because some of the bytes did arrive. kernelbase.dll translates the status
into a Win32 error code for GetLastError, 5 for access denied and 299 for a
partial copy, and reports success or failure to your tool.
Successful status and exact byte count differ. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
read the selected inputs
evaluate each stated condition
combine the required conditions
perform the effect only when accepted
otherwise retain the old state
report the result and its limits
Result: all four bytes arrived in the tool’s separate buffer.
Watch it happenFour bytes cross address spaces after the kernel checks them
A native 64-bit read request moves from user-mode wrappers into the controlled kernel entry. Windows checks the four-byte range and read rights, copies four symbolic bytes from game memory into a separate tool buffer, and returns status zero and byte count four for the caller to check.
Read the steps
The tool requests four bytes starting at game address 0x00501000. Its destination buffer is separate. The symbols b0 through b3 stand for those four bytes, not chosen byte values.
ReadProcessMemory passes through kernel32 and kernelbase to the ntdll stub. The moving request remains above the privilege boundary: every wrapper is still user-mode code.
syscall enters the prepared kernel path. Entry code saves the user context, switches to the kernel stack, validates the service number, and dispatches this request.
The four-byte range ends at 0x00501000 + 4 = 0x00501004. The existing handle passes the read-right check: 0x1010 AND 0x0010 equals the full required 0x0010.
Windows interprets the source in the game address space and the destination in the tool address space. Copies of all four bytes travel to the tool’s buffer; the game keeps its bytes.
This successful path returns NTSTATUS 0 and a byte count of 4 through sysret and the application wrappers. The tool checks both success and the returned count; a partial copy would not make a four-byte value valid.
b0–b3 identify the requested bytes without inventing their contents. Their moving copies illustrate process context; the exact Windows copy implementation is unspecified.
The process handle in this example authorized a copy between two processes.
The processor supplied a controlled entrance; Windows checked the request
inside it. The next lesson returns to the process view and saves a debugging
snapshot of threads, modules, and registers in a dump. Chapter 12 then follows
requests across DLL and driver boundaries, where the same checks on authority,
arguments, and returned results still apply.
Lesson 12.7
How Device Drivers Work
Lesson 12.4 showed why ordinary user programs go through
operating-system interfaces to reach devices. Something has to turn a request
into device work, though. When you press a key, bytes arrive from a keyboard;
when a game loads a level, a disk controller delivers the file. The code that
operates the device is its device driver: code that knows which registers to read or write
and how to exchange commands with the device. The kernel-mode drivers studied here are .sys files in
the same PE format as programs and DLLs (Chapter 5), loaded into privileged
memory. Windows also supports some user-mode driver arrangements; this toy
path follows the kernel-mode kind.
This lesson follows one request from a program down to a device and back,
looks at how a driver talks to hardware through registers and interrupts,
traces a key press from a USB keyboard to a game, and ends with why Windows is
strict about which drivers it will load. Everything here stays conceptual.
The next lesson decodes the same requests and
registers bit by bit, and its lab runs a pretend device and driver as an
ordinary user-mode program.
Suppose two programs could drive the same disk controller directly. One writes
the number of the sector it wants; a microsecond later the other overwrites it
with a different sector; the first program receives the wrong data. Worse, a
program could point the controller’s copying engine at another process’s
memory. Devices hold state that everyone shares, and many can copy data to or
from physical RAM, so the operating system coordinates who may operate them
and which buffers they may reach. The later
DMA lesson examines those memory controls in detail.
Instead, Windows coordinates access through a driver stack. The I/O manager
checks the caller’s handle and sends the request onward; the receiving driver
must still validate the operation, lengths, and buffers it uses. This division
matters: an access check on a handle does not prove that the request’s contents
are safe for a device.
Windows represents each device as a device object, and several drivers can
stack on one device:
a bus driver talks to the bus the device is plugged into, such as USB or
PCI Express;
a function driver knows the device itself and does the real work;
optional filter drivers watch or adjust requests on their way past.
A request enters at the top of this driver stack, and each driver either
handles it or passes it to the driver below.
A program cannot call a function inside a driver, because the driver lives in
ring 0. Instead, the program opens the device much like a file, receives a
handle, and makes requests through two general-purpose calls:
ReadFile and WriteFile move data from or to the device;
DeviceIoControl sends a numbered command, a control code, for anything
that is not simply reading or writing, along with an input buffer and an
output buffer.
Both travel the system-call path from Lesson 12.4, through kernelbase.dll,
ntdll.dll, and syscall, and arrive at the kernel’s I/O manager, the
part of the kernel that runs every device request. The I/O manager checks the
handle and then describes the request in an I/O request packet, or
IRP: a kernel data structure that serves as the request’s work order. It
names the operation, carries the parameters and buffers, travels down the
driver stack, and comes back up carrying the result.
Here is a read from this lesson’s toy keypad, a simple imaginary device that
the lab in Lesson 12.8 simulates, made before any key has been pressed:
The driver’s dispatch routine is the function the I/O manager calls with a
new IRP. If the dispatch routine can finish at once, it completes the IRP,
handing it back with a status and a byte count. If it has to wait for the
hardware, it keeps the IRP, returns STATUS_PENDING, and completes the IRP
later, from the code that runs when the device interrupts. Meanwhile the
program’s thread waits inside ReadFile.
A read with no data yet completes after a key interruptThe toy read owns eight bytes of capacity. Only two event bytes are returned after the interrupt.
CallerDriverKeypad
1
RequestRead with an 8-byte buffer
Step 1: Caller to Driver
The request carries room for eight bytes.
2
AloneNo key event yet: park the IRP
Step 2: Driver (alone)
There is nothing to return, so the driver keeps the request instead of blocking.
3
ControlSTATUS_PENDING
Step 3: Driver to Caller
The caller is told the request is not finished.
4
DataKey interrupt: 2 event bytes
Step 4: Keypad to Driver
Later, a key press arrives as an interrupt carrying two bytes.
5
ReplyCopy 2 bytes, complete: count 2
Step 5: Driver to Caller
The driver fills the first two of the eight bytes and completes the IRP. The count says how many bytes are meaningful, not how large the buffer was.
All 5 steps shown
The toy read owns eight bytes of capacity. Only two event bytes are returned after the interrupt.
A DeviceIoControl request is checked twice. Each control code declares the
rights that the caller’s handle must hold, and the I/O manager refuses a
request from a handle without them before the driver ever runs. The IRP also
records that the request came from user mode, so the driver must treat every
parameter as untrusted: it compares the whole control code with the ones it
defines, and it never writes more bytes than the request says the caller’s
buffer can hold. Lesson 12.8
unpacks a control code into its four fields.
A device exposes a small set of registers: storage locations inside the
device that software reads to learn the device’s state and writes to give it
commands. On a modern PC most devices expose their registers through
memory-mapped I/O: the registers answer to a range of physical addresses,
as if they were memory. When the processor reads or writes one of those
addresses, the access goes to the device instead of to RAM. The driver asks the
kernel to map that physical range into kernel virtual addresses, and then reads
and writes it with ordinary load and store instructions. x86 also has an older
mechanism, port I/O, with its own in and out instructions; the classic
PS/2 keyboard controller answers at ports 0x60 and 0x64.
The toy keypad, modeled loosely on that PS/2 controller, has four registers,
each 4 bytes wide, one after another:
+0x00CONTROL
+0x04STATUS
+0x08DATA
+0x0CACK
driver writes
driver reads
driver writes
The toy keypad's register block. Each register is 4 bytes, so each one starts 4 bytes after the last (0x0C is 12) and the block fills 4 × 4 = 16 bytes.
CONTROL is where the driver switches the device on and allows it to
interrupt.
STATUS tells the driver whether key events are waiting, and how many.
DATA holds the event at the front of the device’s queue: a scan code,
a number that names a physical key position, plus whether that key was
pressed or released. The A key’s scan code is 0x1E.
ACK is where the driver tells the device that its interrupt has been
handled.
Unlike ordinary RAM, these registers can change because of device activity,
and a read can consume data.
Reading DATA removes the event it returns, so the next read returns the next
event, and a tool that read DATA “just to look” would silently swallow a key
press. STATUS can change between two reads with no instruction in between,
because the device changes it. So register accesses are never cached, merged,
or skipped: the kernel maps device memory as uncacheable, and drivers use
dedicated read-register and write-register routines that the compiler must
carry out exactly as written.
Lesson 12.8 places the
four registers at a physical address and decodes every bit in them.
Interrupts: how a device gets the driver’s attention
The driver could read STATUS over and over until an event appears. That is
polling, and it wastes a processor core on a device that sits idle almost
all the time. Instead, the keypad raises an interrupt, a hardware signal
that makes the processor stop between two instructions and run the handler the
kernel registered for that device’s vector (Lesson 12.4).
A driver’s interrupt handling comes in two halves:
The interrupt service routine, or ISR, runs immediately, while other
work on that core waits, so it does the minimum. It reads STATUS to confirm
the interrupt really came from its device, because several devices can share
one interrupt line; writes ACK so the device lowers the line; and queues the
second half.
The deferred procedure call, or DPC, runs moments later at a lower
priority, with interrupts enabled again. It does the real work: it drains the
events, fills the waiting IRP’s buffer, and completes the IRP.
For a press and release of A, the DPC collects two events, A pressed and A
released, and completes the parked read with those 2 bytes. The I/O manager
copies them into the program’s buffer and wakes its thread, and ReadFile
returns. Lesson 12.8
follows both halves register by register.
Registers suit a keypad, which delivers a byte now and then. A disk or a
network card moves megabytes, and fetching them one register read at a time
would keep a core busy doing nothing but copying. Such devices use direct
memory access, or DMA: the driver tells the device where a buffer is and
how big it is, the device copies the data straight into or out of RAM, and it
interrupts once when the whole transfer is done.
The address the driver gives the device is not a virtual address, because a
device has no access to any process’s page tables. It is a physical address or,
on a machine with an IOMMU, a device address that the IOMMU translates and
limits to the buffers the driver approved.
Lesson 13.1 explains
that translation and why Windows uses it to stop a malicious device from
reading all of memory.
A real keyboard is more complicated than the toy keypad, but every layer it
passes through is a device, a driver, or a queue from this lesson.
Lesson 1.2 introduced the keyboard as an input device the
game uses through Windows. Here are the steps inside that path, for a USB
keyboard on a modern PC:
An input event still has a recipient. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
USB report contains A usage 04
HID path produces the key event
US layout supplies virtual key 41
post WM_KEYDOWN to the foreground queue
the foreground program consumes the event
report whether this game received it
Result: this game observes A down from the delivered message.
Watch it happenA report becomes a queued key press in the game
An eight-byte USB report carrying A usage 04 moves into RAM. A moving event passes through USB and HID drivers, KBDHID scan code 1E, KBDCLASS’s queue, and raw input’s US-layout virtual key 41. WM_KEYDOWN is posted to the foreground queue and consumed by the game, which sees A down.
Read the steps
On the host’s poll, the USB keyboard replies with its eight-byte report. A is HID usage 0x04. The host controller copies the report into RAM by DMA and raises an interrupt.
The USB and HID driver stack interprets the report. The pictured 04 field identifies A; the other report fields are kept as placeholders because this tour follows only that key.
KBDHID maps A’s HID usage 0x04 to Windows scan code 0x1E. The representation changes; it is still the same physical key press.
KBDCLASS queues the key-down event. A queue holds the event until its next consumer can take it; posting an event does not directly execute the game’s input code.
The raw input thread consumes the event and maps scan code 0x1E using the active layout. On the US layout this is VK_A, 0x41, or 4 times 16 plus 1 equals 65.
Windows posts the key event to the foreground thread’s message queue. This route uses WM_KEYDOWN; a game registered for Raw Input instead consumes WM_INPUT.
The game’s GetMessage or PeekMessage loop removes the queued event. Only now does the game observe A down. The report in RAM and the game’s key state remain different representations.
Only A’s known usage is shown as a byte value. “mod” names the modifier field; dashes leave unrelated fields unspecified. Real keyboard layouts can map the same scan code differently.
A USB device never speaks first. The host controller asks each device for news
on a fixed schedule, and a keyboard asked 125 times a second is asked every
1,000 ÷ 125 = 8 milliseconds; many gaming keyboards are asked every
millisecond. The keyboard answers with a HID report (HID, human interface
device, is the USB standard for keyboards, mice, and controllers), which lists
the keys held down at that moment by their HID usage numbers. A is usage
0x04.
The host controller writes the report into RAM by DMA and interrupts, and the
report climbs the driver stack. The keyboard mapper driver, KBDHID.SYS,
converts usage 0x04 into scan code 0x1E, the numbering that Windows
keyboard drivers have used since PS/2 keyboards, and the keyboard class driver,
KBDCLASS.SYS, queues a key-down event.
The raw input thread, inside win32k, the kernel part of the Windows window
manager, reads those events. It translates the scan code into a virtual-key
code using the active keyboard layout, updates the key-state table that
GetAsyncKeyState reads (Lesson 6.9), and posts the event to
the message queue of the thread that owns the foreground window. On a US
layout, scan code 0x1E becomes VK_A, 0x41, which is also the ASCII code
for the letter A: 4 × 16 + 1 = 65. For security, Windows opens keyboards
exclusively for this thread, so an ordinary program cannot read keystrokes
straight from the keyboard’s driver.
The game’s message loop then takes the event out with GetMessage or
PeekMessage, as a WM_KEYDOWN message, or as WM_INPUT if the game
registered for Raw Input. Lesson 12.8
reads the report and the message bit by bit. Synthetic input from SendInput
joins this path at the raw input thread, above every driver in the diagram,
which is why programs watching input at that level can tell it apart from key
presses that came through a keyboard.
Loading a driver puts new code into ring 0, where, as Lesson 12.4 showed,
nothing sits beneath it to catch its mistakes and nothing can overrule it. So
64-bit Windows refuses kernel-mode code without a valid digital signature, the
kind of signature Lesson 10.8 checked on game files.
A signature answers two questions: who published this driver, and has it
changed since? It does not answer the question that matters most: is the code
safe? A correctly signed driver can still offer a control code that reads or
writes any memory for any caller. Attackers look for exactly those drivers, old
and legitimately signed, and load one to borrow its bug. So Windows also keeps
a vulnerable driver blocklist: a list of signed drivers known to be
exploitable, which it refuses to load.
The same reasoning shows why a cheat that ships as a driver endangers its own
user. It runs with the kernel’s full authority and comes from an anonymous
author. It often comes with instructions to switch off Secure Boot, the
firmware check that lets only signed boot components start, or memory
integrity, the Windows feature that refuses to run kernel code it has not
checked, or the blocklist. Each of those protects the machine against all
kernel malware, not only against cheats.
Lesson 12.9 gives the
signing rules in full and lists a cheat driver’s risks one by one, then builds
a read-only inventory of the drivers your own machine has loaded.
This lesson opens the numbers on the paths that
Lesson 12.7 followed: the parameters of a DeviceIoControl
request, the four fields packed into a control code, the toy keypad’s registers
byte by byte, the values its interrupt handlers read, and the bytes of a real
key press. It ends with a lab that runs the keypad and its driver as an
ordinary program.
A DeviceIoControl request carries more parameters than a read. These are the
parts of its IRP that the keypad driver reads (Windows keeps some in the IRP
itself and some in a per-driver stack location attached to it):
operationIRP_MJ_DEVICE_CONTROL
control code0x80006004
output length4 bytes
system bufferkernel copy of the caller's buffer
requestoruser mode
statusset at completion
informationbytes returned
What a driver reads from an IRP for a DeviceIoControl request. Requestor says the request came from user mode, so every parameter is untrusted.
The toy control code uses buffered I/O. Its method bits tell the I/O
manager to copy the caller’s input into a kernel system buffer before the
driver runs, then copy the output back after completion. With this method, the
driver works on that buffer rather than the program’s pointer. It still has to
respect the lengths: the output length says how many bytes the caller’s buffer
can hold, and writing even one byte more would overrun kernel memory.
A control code is a 32-bit number that packs four fields, built by a macro
called CTL_CODE:
bits 16 to 31, the device type: values from 0x8000 up belong to
hardware vendors, lower ones to Microsoft;
bits 14 and 15, the access the caller’s handle must hold: 0 for any, 1
for read, 2 for write, 3 for both;
bits 2 to 13, the function number: vendors use 0x800 and up;
bits 0 and 1, the method, which says how buffers travel: 0 means
buffered.
Packing the fields into one number uses the left shift from
Lesson 1.10: a shift
left by n bits multiplies a number by 2ⁿ when the result fits, so a shift by
4 bits moves it exactly one hex digit, because each hex digit stands for 4
bits.
The toy keypad defines one control code, get status, with device type
0x8000, function 0x801, the buffered method, and read access. Packing it
takes one shift per field:
0x8000 shifted left by 16 bits, which is 16 ÷ 4 = 4 hex digits, is
0x8000_0000;
the access value 1 shifted left by 14 bits is 2¹⁴ = 16,384. That is
4 × 4,096, and 4,096 = 16³ is the value of a 1 in the fourth hex digit, so
16,384 is 0x4000;
0x801 shifted left by 2 bits is 0x801 × 2² = 0x801 × 4 = 0x2004,
because 0x800 × 4 = 0x2000 (8 × 4 = 32, which is 0x20, and the two
zero digits stay behind it) and 1 × 4 = 4;
the method, 0, adds nothing.
0x8000_0000 + 0x4000 + 0x2004 = 0x8000_6004. The top half, 0x8000, is
the device type, and the low half, 0x6004, holds the other three fields. Here
are its sixteen bits, one byte at a time:
Pack four fields into one 32-bit control codeChange a field and watch the shifted pieces add up. The pieces never overlap, so the code can be taken apart again.
Fields, shifted pieces, control code
The four fields
Where each field lives
bit 31bit 0
device type0x8000 · bits 31 to 16
access1 · bits 15 to 14
function0x801 · bits 13 to 2
method0 · bits 1 to 0
Shifted into place
Each field moved to its bit position
0x800000000x8000 << 16
0x000040001 << 14
0x000020040x801 << 2
0x000000000 << 0
Control code
↓ add
Add the four pieces
0x80006004what the program passes to the driver
Each field is shifted to its own bit range and the four pieces are added: the control code is 0x80006004.
150
141
131
120
110
100
90
80
access 01
function, first 6 bits 100000
hex 6
hex 0
Bits 15 to 8 of the control code 0x8000_6004, the byte 0x60. Access 01 is read, and the function number begins here.
70
60
50
40
30
21
10
00
function, last 6 bits 000001
method 00
hex 0
hex 4
Bits 7 to 0, the byte 0x04. The function's twelve bits, 1000 0000 0001, are 0x801; method 00 is buffered.
The access field is a security check that happens before the driver runs. The
I/O manager compares it with the rights on the caller’s handle and refuses the
request, without ever calling the driver, if the handle lacks them. A driver
that declares a powerful command with access 0, any access, lets every
program that can open the device send it, which is one of the classic driver
mistakes. Drivers are also expected to compare the whole 32-bit code, not just
the function number, so that a code with the right function but a different
method or access is refused.
Pack and unpack four control-code fields. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
device_type = 0x8000; method = 0
place device_type in bits 16..31
place access in bits 14..15
place function in bits 2..13
combine the nonoverlapping fields
extract fields again and compare
Result: 0x80006000 extracts device 0x8000, access 1, function 2048, method 0. Packing did not itself grant the caller access.
The toy keypad’s four 32-bit registers take
4 × 4 = 16 bytes in all. Suppose the firmware placed them at physical address
0xFEB0_1000: CONTROL at offset +0x00, STATUS at +0x04, DATA at +0x08,
and ACK at +0x0C. STATUS therefore answers at 0xFEB0_1000 + 4 =
0xFEB0_1004. Here is the block after the driver has started the device and
the A key has been pressed and released, 8 bytes at a time:
+0x0003
+0x0100
+0x0200
+0x0300
+0x0401
+0x0502
+0x0600
+0x0700
CONTROL 0x00000003
STATUS 0x00000201
The first 8 bytes of the register block. Each register is little-endian, so its lowest byte comes first.
+0x081E
+0x0900
+0x0A00
+0x0B00
+0x0C00
+0x0D00
+0x0E00
+0x0F00
DATA 0x0000001E
ACK
The last 8 bytes. DATA shows what a read would return right now, and ACK always reads as 0.
CONTROL, 0x0000_0003, is written by the driver. Bit 0, enable, makes the
device scan its keys, and bit 1, interrupt enable, lets it interrupt. 3 = 2 +
1, so both bits are set. The other 30 bits are reserved and must be written as
0.
STATUS, 0x0000_0201, is read-only and describes the device at this
moment. Because the register is little-endian, byte +0x04 holds bits 0 to 7
and byte +0x05 holds bits 8 to 15. Byte +0x04 is 0x01: bit 0, data
ready, is set, so an event is waiting, and bit 1, overflow, is clear. Byte
+0x05 is 0x02: bits 8 to 15 form an 8-bit count of waiting events, here
2. As one number, 0x0201 = 2 × 256 + 1 = 513. Dividing 513 by 256 gives 2
remainder 1: the count, and the low byte’s flags.
DATA, 0x0000_001E, holds the event at the front of the device’s queue.
Bits 0 to 6 are the scan code, and bit 7 is set when the key was released
rather than pressed. 0x1E is 1 × 16 + 14 = 30, binary 0001 1110. Its bit 7
is 0, so the front event is A pressed: 30 is the scan code of the A key in
the numbering Windows uses, known as set 1. The event behind it is 0x9E:
bit 71
bit 60
bit 50
bit 41
bit 31
bit 21
bit 11
bit 00
released
scan code 001 1110 = 0x1E
The second event in the queue, 0x9E: the same key, released.
0x9E is 9 × 16 + 14 = 158. Bit 7 is worth 2⁷ = 128 = 0x80, and 158 − 128 =
30, so the remaining bits name the A key again: A released.
ACK is write-only and reads as 0. Writing a 1 to bit 0 tells the device
its interrupt has been handled, and the device lowers its interrupt signal;
writing a 1 to bit 1 clears the overflow flag. Bits written as 0 change
nothing. This write-1-to-clear convention is common because it lets a
driver clear one flag without first reading the register to preserve the
others.
Lesson 12.7 split a driver’s interrupt handling into a short interrupt service
routine and a deferred procedure call that does the real work. Here is that
sequence for the two events above, with the value each register returns:
The ISR writes 0x1 to ACK. Only bit 0 is set, so the device lowers its
interrupt line and leaves the overflow flag, bit 1, as it was. The last read of
STATUS returns 0: both events have been read, so the queue is empty, and the
overflow flag was never set. The 2 bytes the DPC collected, 0x1E and 0x9E,
are what the parked read receives when the DPC completes it.
In the simple boot format that every PC understands, a keyboard’s HID report
is 8 bytes:
byte 002
byte 100
byte 204
byte 300
byte 400
byte 500
byte 600
byte 700
modifiers
reserved
up to six keys held
A boot-format report while left Shift and A are held.
Byte 0 holds one bit per modifier key: bit 0 is left Ctrl, bit 1 left Shift,
bit 2 left Alt, bit 3 the left Windows key, and bits 4 to 7 the same four keys
on the right. 0x02 is binary 0000 0010, so only bit 1 is set: left Shift.
Bytes 2 to 7 list up to six keys currently held, each as a HID usage number.
The letters A to Z are usages 4 to 4 + 25 = 29, or 0x04 to 0x1D
(1 × 16 + 13 = 29), so 0x04 is A. Six slots is also why a keyboard reporting in boot format cannot report a
seventh key held at the same time.
KBDHID.SYS turns usage 0x04 into scan code 0x1E. The toy keypad marked a
release by setting bit 7, 0x80, which is how PS/2 keyboards sent it over
their wire. The events that KBDCLASS.SYS queues keep the scan code on its own
and mark a release with a separate break flag instead.
When the game’s WM_KEYDOWN message arrives, its lParam packs the details of
the key press into 32 bits:
0
0
1
E
0
0
0
1
flags
scan code
repeat count
0x00
0x1E
0x0001 = 1
The lParam of the WM_KEYDOWN for a first press of A, 0x001E_0001, one hex digit per box.
0x1E shifted left by 16 bits, which is 4 hex digits, is 0x001E_0000, and
adding a repeat count of 1 gives 0x001E_0001. The top byte, bits 24 to 31,
holds flags. For the matching WM_KEYUP, bit 30 (the key was down before)
and bit 31 (the key is being released) are set. Bit 31 is bit 7 of the top
byte, worth 2⁷ = 128 = 0x80, and bit 30 is its bit 6, worth 2⁶ = 64 =
0x40, so the top byte becomes 0x80 + 0x40 = 0xC0 and the lParam is
0xC01E_0001.
The lab toy_driver_lab.rs builds the
toy keypad and a driver for it as an ordinary user-mode program. Nothing in it
touches hardware or the kernel: the register block is a Rust struct, the
interrupt line is a bool, and an IRP is a small struct in a queue. What it
keeps from the real thing are the request checks and register behavior
described in Lesson 12.7 and here.
// Reading DATA is not a pure read: it removes the front event.
DATA => Ok(self.events.pop_front().map_or(0, u32::from)),
_ => Ok(0), // ACK is write only and reads as zero
}
}
register refuses an offset that is not a multiple of 4 or that lies past the
16-byte block, so a mistyped offset becomes an error instead of a read of the
wrong register. The driver’s ISR is as short as Lesson 12.7 says it should be:
Optional: practise the named operations and their inputs with a few whole-token blanks.
returnOk(false); // the line is shared, and another device raised it
}
device.write32(ACK, ACK_INTERRUPT)?;
self.dpc_queued = true;
Ok(true)
}
The dispatch routine compares the whole control code and checks the output
length before it copies anything, and a separate submit function plays the
I/O manager, refusing any request whose handle lacks the access bits its code
demands.
Build it, then run it from the rust-labs folder:
Terminal window
cargo run --bin toy_driver_lab
The program parks a read request, presses and releases A, prints the register
block drawn above, runs the ISR and the DPC, and shows the read completing with
0x1E and 0x9E. It then sends four control requests: one from a handle
without read access, one with the near-miss code 0x8000_6007, one with a
2-byte buffer, and a valid one. Finally it presses keys six times with no
driver running in between, to show the four-slot queue setting its overflow
flag. The tests beside it check the register bytes from this lesson, the
write-1-to-clear rule, the overflow, every refused request, and that waiting
reads complete in order.
Then set QUEUE_CAPACITY to 2 and work out which tests must now fail before
running them.
The toy kept driver mechanics inside an ordinary program: control codes,
buffers, register side effects, and interrupt work were all visible without
installing privileged code. The next lesson asks what changes when those
mechanics belong to a real loaded driver, and builds an inventory of the
components already trusted by Windows.
The DLL lessons followed identity, loading, and API resolution inside a
process. The driver lessons then followed a request through the I/O manager,
checked buffers, and device registers. We can now ask what authority those
components actually carry. A game tool’s process handle grants limited access;
a kernel driver can affect the whole machine. This lesson makes that trust
boundary visible with a read-only inventory of the drivers Windows has loaded.
The Windows kernel is the part of the operating system trusted to manage memory, devices, processes, and security boundaries. A normal game and the tools in this book run in user mode, often called ring 3. A kernel driver runs in kernel mode, often called ring 0.
The important model is which invariants a component can break. User-mode code
runs with page-table and privilege checks that confine ordinary faults to its allowed
address space and handles. Kernel-mode code participates in enforcing those checks;
it can change mappings, device state, process state, and security decisions for the
whole system.
A user-mode crash normally ends one process. A bad kernel pointer can crash the
entire computer or corrupt data belonging to any process. Moving code into a driver
therefore enlarges both its authority and the damage caused by a bug. “More
privilege” is not the same as “better engineering.”
How far a fault can reachA conceptual boundary picture, not a driver experiment: user-mode faults are usually contained, kernel faults are not.
1Three user-mode processes with separate memory
User mode: separately bounded memory
Process 1
process 1 memory
Process 2
process 2 memory
Process 3
process 3 memory
Kernel and drivers
Authority
mappings
devices
processes
security
Each process can only touch its own bounded memory.
2A user-mode fault usually ends one process
User mode: separately bounded memory
Process 1
process 1 memory
Process 2
process 2: fault
Process 3
process 3 memory
Kernel and drivers
Authority
mappings
devices
processes
security
The failing process stops; the others and the kernel carry on.
3A kernel fault can reach the whole system
User mode: separately bounded memory
Process 1
process 1: fault
Process 2
process 2: fault
Process 3
process 3: fault
Kernel and drivers
Authority
mappings
devices
processes
security
Kernel code has authority over mappings, devices, processes and security, so a fault there is not confined to one process.
All 3 steps shown
A conceptual boundary picture, not a driver experiment: user-mode faults are usually contained, kernel faults are not.
The boundary is useful because callers do not receive kernel authority merely
for asking Windows to do something. The kernel checks the request, and a sound
driver checks it again before touching device or privileged memory.
When user-mode code follows a bad pointer, the kernel finds that nothing valid
lives at that address and turns the page fault into an access-violation
exception for that one process. The program’s own handler, a debugger, or
Windows Error Reporting deals with it, and in the worst case one process ends.
The kernel is the safety net under every user-mode program.
There is no separate part of Windows that can repair arbitrary damage to its
own kernel state. A hypervisor may isolate a whole guest from the host, as
Lesson 13.3 will show, but it cannot resume a guest kernel whose internal
records are already corrupted. Kernel code does catch faults it expects, such
as touching a user buffer that another thread may have freed; the copy behind
ReadProcessMemory is written to catch exactly those. But a driver that follows
a bad pointer by mistake can raise an unexpected fault inside the kernel, and
kernel code and drivers share an address space and a set of data
structures: the scheduler’s lists, the memory manager’s page records, and the
locks that guard them. The fault may have struck halfway through updating one
of those structures, with a lock still held. Windows cannot generally prove
which of its own records remain sound, and carrying on could write corrupted
data to disk. For an unrecoverable kernel failure, Windows stops on purpose.
That deliberate stop is a bug check, better known as the blue screen.
Windows shows a stop code, such as 0x50 PAGE_FAULT_IN_NONPAGED_AREA or 0xD1
DRIVER_IRQL_NOT_LESS_OR_EQUAL, writes a crash dump of the whole machine if it
can (Lesson 14.3 will capture a dump of one process), and
restarts.
A bad pointer in a ring 3 program ends at most that one process. An unexpected bad pointer inside kernel code can stop the whole machine with a bug check, because kernel code and drivers share state. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Inside a virtual machine, even a bug check stops only that virtual machine
(Lesson 13.3), which is one more reason the book’s labs run
inside one, in user mode.
The old open-source games in this book do not need a driver. Their offline processes can be studied with documented Windows APIs, a debugger, a user-mode DLL, and reversible patches.
The course deliberately does not build:
arbitrary kernel or physical-memory read/write commands;
privilege-escalation paths;
security-product or anti-cheat bypasses;
hidden drivers, callbacks, threads, or processes;
loaders for vulnerable or untrusted drivers.
Those capabilities would expand the problem from studying one game process into controlling the operating system and concealing that control.
Microsoft’s own driver security guidance begins with the same design question: confirm that a kernel driver is actually required. If a service or application can do the job, it is the lower-risk boundary.
Loading a driver puts new code into ring 0, so Windows checks where a driver
came from before loading it. 64-bit Windows refuses kernel-mode code without a
valid digital signature, the kind of signature Lesson 10.8
checked on game files. Since Windows 10 version 1607, on a PC with Secure Boot
on, a new driver must be signed by Microsoft itself, through its hardware
developer program, which requires the publisher to prove its identity with an
extended-validation certificate. Secure Boot is the firmware check that
allows only signed boot components to start, which also keeps these signature
checks from being relaxed before Windows is running. Developers’ test-signing
modes relax them on purpose, so they belong only on a disposable test machine,
and this book never uses them.
A signature answers two questions: who published this driver, and has it
changed since? It does not say whether the code is safe, as the next section
shows. Attackers look for old, legitimately signed drivers with an exploitable
bug and load one to borrow it. Microsoft’s answer is the vulnerable driver
blocklist: a list of signed drivers known to be exploitable, which Windows
refuses to load. Since the Windows 11 2022 update the blocklist is on by
default, and it is always enforced when memory integrity is on.
The defensive problem: trusted code can still be vulnerable
A signed driver is not automatically a safe driver. If it exposes a badly protected command that can read or write arbitrary privileged memory, another program may abuse that power.
Defenders should treat driver loading as a rare, high-impact event:
Do not “test” a suspicious driver by loading it on your daily computer. Use an isolated disposable virtual machine and vendor-provided analysis guidance.
Some cheats for online games ship as drivers, on the theory that ring 0 sees
everything. Whatever such a driver does to a game, here is what it does to the
machine running it:
It runs with the kernel’s full authority and comes from an anonymous
author. Its bugs become bug checks and corrupted files on the user’s own
computer, with no vendor, testing, or update process behind it.
It often comes with instructions to switch protections off, such as
Secure Boot, memory integrity, the vulnerable driver blocklist, or signature
checks. Each of those protects the machine against all kernel malware, not
only against cheats, and turning one off lowers the defense for everything.
Many rely on an old signed driver with a known bug to reach ring 0. The
bug cannot tell who is asking: while that driver is loaded, any other program
on the machine can send the same control code and read or write kernel
memory.
A kernel driver is the most powerful place malware can live. Installing
one from an anonymous seller hands that seller the whole computer: saved
passwords, browser sessions, banking, and the power to hide further software.
Anti-cheat systems look for exactly these drivers, and the penalties
usually apply to the whole account, sometimes to the hardware.
The inventory calls EnumDeviceDrivers and GetDeviceDriverBaseNameW through the windows crate. It queries visible driver images, prints their base names, and uses no mutation APIs.
enumerate does not know the driver count in advance, so it grows its buffer and asks again until Windows reports the whole list fit:
#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::{ffi::c_void, mem::size_of}; use anyhow::Context; use windows::Win32::System::ProcessStatus::{EnumDeviceDrivers, GetDeviceDriverBaseNameW}; fn enumerate() -> anyhow::Result<Vec<*mut c_void>> { let mut drivers = vec![std::ptr::null_mut(); 256]; loop { let byte_capacity = u32::try_from(drivers.len() * size_of::<*mut c_void>()) .context("driver buffer is too large")?; let mut bytes_needed = 0_u32; // SAFETY: drivers points to byte_capacity writable bytes and // bytes_needed is a valid output pointer for the duration of the call. unsafe { EnumDeviceDrivers(drivers.as_mut_ptr(), byte_capacity, &mut bytes_needed)?; } let count = usize::try_from(bytes_needed)?.div_ceil(size_of::<*mut c_void>()); if count <= drivers.len() { drivers.truncate(count); return Ok(drivers); } anyhow::ensure!(count <= 16_384, "unreasonable driver count: {count}"); drivers.resize(count, std::ptr::null_mut()); } } let mut names = Vec::new(); for image_base in enumerate()? { if image_base.is_null() { continue; } let mut buffer = [0_u16; 260]; // SAFETY: image_base came from EnumDeviceDrivers and buffer is writable. let length = unsafe { GetDeviceDriverBaseNameW(image_base, &mut buffer) } as usize; if length == 0 || length > buffer.len() { continue; } names.push(String::from_utf16_lossy(&buffer[..length])); } names.sort_unstable_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); println!( "Loaded kernel drivers visible to this account: {}", names.len() ); for name in names { println!(" {name}"); } println!("No privileges were enabled and no driver state was changed."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This read-only driver inventory uses the Windows PSAPI.");}
println!("Loaded kernel drivers visible to this account: {}", names.len());
fornameinnames {
println!(" {name}");
}
println!("No privileges were enabled and no driver state was changed.");
Ok(())
}
Build and run it from a normal terminal:
Terminal window
cargo run --release --target i686-pc-windows-msvc --bin driver_inventory
The program does not enable debug privileges. On a hardened or newer Windows installation, the account may see fewer details. Treat that as the security boundary working; do not weaken the machine to make the list longer.
One list is only a snapshot. A useful defensive baseline also records:
Windows build and capture time;
driver file name, version, publisher, and signature result;
cryptographic hash;
why the driver is installed;
the approved update source.
Compare snapshots after installing a game, hardware utility, or anti-cheat component. A new driver is not automatically malicious, but it deserves a reason and a trustworthy origin.
A new driver is not automatically malicious, but it deserves a reason and a trustworthy origin: check publisher and signature, hash against the approved baseline, the block policy, and the installer that brought it. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Memory integrity, also called HVCI, uses virtualization-based security to enforce kernel code-integrity rules. The Microsoft vulnerable-driver blocklist prevents known risky drivers from loading when the relevant Windows protections or application-control policy enforce it.
For a normal learning machine:
keep Windows and driver updates current;
leave Secure Boot and memory integrity enabled when the hardware supports them;
use the Microsoft vulnerable-driver blocklist or an approved application-control policy;
remove abandoned hardware utilities and drivers you no longer need;
download drivers through Windows Update or the hardware vendor;
do not turn off protections merely to run an experiment.
If an old driver conflicts with memory integrity, first look for a supported update or remove the dependent software. Compatibility trouble is not a reason to leave the protection disabled.
Use Microsoft’s Windows Driver Frameworks, a disposable test VM, Driver Verifier, strict device access control, narrow IOCTLs, checked buffer lengths, least privilege, peer review, and the proper test-signing and release-signing workflows. Never expose arbitrary kernel, physical, or device memory to a user-mode caller.
The first three control gaps were a client flag trusted as
permission, a checksum that covered only part of what it protected, and an
environment signal standing in for validation. Eight more failures show how
the same mismatch appears in arithmetic, paths, errors, timing, and batches.
Every example lives in
bypass_patterns_lab.rs,
which uses no Windows APIs, no processes, no filesystem, and no network. The
“attacker” is a test function calling an intentionally bad policy. That keeps
the computer-science shape of each failure visible while keeping the code
useless as a recipe aimed at someone else’s machine.
//! Eight more bypass patterns, each reproduced against a deliberately weak//! toy control and then repaired.//!//! Nothing here touches another process, the filesystem, the network, or any//! security product. Every "attacker" is an ordinary test call into an//! intentionally bad policy. That keeps the computer-science shape of each//! bypass visible while keeping the code useless as a recipe aimed at anyone//! else's machine.//!//! Run it with://!//! ```text//! cargo run --bin bypass_patterns_lab//! cargo test --bin bypass_patterns_lab//! ```/// Why a request was allowed or refused.////// A control that answers only `true` or `false` is hard to test and hard to/// debug. Naming the reason turns every decision into evidence.#[derive(Clone, Copy, Debug, PartialEq, Eq)]enum Reason { Allowed, OutOfBounds, EscapesRoot, VerificationUnavailable, HashMismatch, WritesDisabled, StaleGeneration, BudgetExhausted, ItemInvalid,}/// One decision, recorded whether it allowed or denied the work.#[derive(Clone, Copy, Debug, PartialEq, Eq)]struct Decision { allowed: bool, reason: Reason,}impl Decision { const fn allow() -> Self { Self { allowed: true, reason: Reason::Allowed, } } const fn deny(reason: Reason) -> Self { Self { allowed: false, reason, } }}// ---------------------------------------------------------------------------// Pattern 4: an arithmetic wrap inside the bound check// ---------------------------------------------------------------------------mod wrapping_bound { //! Promise: "a read never leaves the mapped region." //! //! Unchecked assumption: that `offset + length` is the true end of the //! read. On a 64-bit machine that sum can wrap past zero and land back //! inside the allowed range. /// `offset + length` is exactly what a release build computes here. /// `wrapping_add` spells the wrap out so the lab behaves identically in /// debug and release builds. pub const fn weak_in_bounds(offset: usize, length: usize, region_size: usize) -> bool { offset.wrapping_add(length) <= region_size } /// A sum that cannot be represented is not "small"; it is unanswerable. pub const fn checked_in_bounds(offset: usize, length: usize, region_size: usize) -> bool { match offset.checked_add(length) { Some(end) => end <= region_size, None => false, } } /// Better still: ask the slice, which cannot be talked out of its own /// length. `get` returns `None` for every range the slice does not own. pub fn read_region(region: &[u8], offset: usize, length: usize) -> Option<&[u8]> { let end = offset.checked_add(length)?; region.get(offset..end) }}// ---------------------------------------------------------------------------// Pattern 5: a prefix compared before the path is resolved// ---------------------------------------------------------------------------mod canonicalization { //! Promise: "a mod can only open files under `assets/`." //! //! Unchecked assumption: that a string starting with `assets/` names a //! location under `assets/`. The `..` is resolved later, by something //! else, long after the check has passed. use super::{Decision, Reason}; /// The check reads the text the caller supplied, not the place it means. pub fn weak_inside_assets(request: &str) -> bool { request.starts_with("assets/") } /// Resolves `.` and `..` without touching a real filesystem. /// /// Returns `None` when the path climbs above its own root, which is the /// answer a caller actually needs. An absolute path is refused because the /// root it would escape to is not this program's to choose. pub fn resolve(request: &str) -> Option<Vec<&str>> { if request.starts_with('/') || request.starts_with('\\') { return None; } let mut resolved: Vec<&str> = Vec::new(); for segment in request.split(['/', '\\']) { match segment { "" | "." => {} ".." => { // Popping an empty stack means the path left the root. resolved.pop()?; } name => resolved.push(name), } } Some(resolved) } /// Resolve first, then compare the resolved location with the root. pub fn resolved_inside_assets(request: &str) -> bool { resolve(request).is_some_and(|segments| segments.first() == Some(&"assets")) } /// The same rule as a recorded decision, so a refusal can be explained. pub fn open_under_assets(request: &str) -> Decision { if resolved_inside_assets(request) { Decision::allow() } else { Decision::deny(Reason::EscapesRoot) } }}// ---------------------------------------------------------------------------// Pattern 6: an error treated as permission// ---------------------------------------------------------------------------mod fail_open { //! Promise: "an asset is loaded only when its hash matches the manifest." //! //! Unchecked assumption: that verification always produces an answer. When //! the manifest cannot be read there is no answer, and "no answer" is not //! the same as "yes." use super::{Decision, Reason}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VerifyError { ManifestUnreadable, } /// Looks up the expected hash and compares it. The error case models a /// manifest that is missing, truncated, or locked by another program. pub fn verify( asset: &str, actual_hash: u32, manifest: Option<u32>, ) -> Result<bool, VerifyError> { let _ = asset; let expected = manifest.ok_or(VerifyError::ManifestUnreadable)?; Ok(expected == actual_hash) } /// `unwrap_or(true)` turns every verification outage into a permission /// slip. Deleting the manifest is now easier than forging it. pub fn weak_gate(asset: &str, actual_hash: u32, manifest: Option<u32>) -> bool { verify(asset, actual_hash, manifest).unwrap_or(true) } /// Fail closed, and say which of the two failures happened. A load that /// stops because the manifest is unreadable needs a different fix from a /// load that stops because the bytes changed. pub fn strong_gate(asset: &str, actual_hash: u32, manifest: Option<u32>) -> Decision { match verify(asset, actual_hash, manifest) { Ok(true) => Decision::allow(), Ok(false) => Decision::deny(Reason::HashMismatch), Err(VerifyError::ManifestUnreadable) => Decision::deny(Reason::VerificationUnavailable), } }}// ---------------------------------------------------------------------------// Pattern 7: a second route to the same effect// ---------------------------------------------------------------------------mod second_route { //! Promise: "no byte changes while writes are disabled." //! //! Unchecked assumption: that every caller arrives through the guarded //! function. The batch helper was added later and calls the writer //! directly. use super::{Decision, Reason}; #[derive(Debug, Default)] pub struct Patcher { pub writes_allowed: bool, pub bytes: Vec<(usize, u8)>, pub decisions: Vec<Decision>, } impl Patcher { pub fn new(writes_allowed: bool) -> Self { Self { writes_allowed, bytes: Vec::new(), decisions: Vec::new(), } } /// The private writer. Nothing about it enforces the policy; it only /// performs the effect. fn write(&mut self, address: usize, value: u8) { self.bytes.push((address, value)); } /// The guarded entry point everyone remembers. pub fn apply_patch(&mut self, address: usize, value: u8) -> Decision { let decision = if self.writes_allowed { Decision::allow() } else { Decision::deny(Reason::WritesDisabled) }; self.decisions.push(decision); if decision.allowed { self.write(address, value); } decision } /// The convenience helper added in a later refactor. It reaches the /// same effect without asking the same question. pub fn weak_apply_batch(&mut self, patches: &[(usize, u8)]) { for &(address, value) in patches { self.write(address, value); } } /// Route the batch through the one guarded entry point. The guard is /// now impossible to forget, because there is only one way in. pub fn strong_apply_batch(&mut self, patches: &[(usize, u8)]) -> Vec<Decision> { patches .iter() .map(|&(address, value)| self.apply_patch(address, value)) .collect() } }}// ---------------------------------------------------------------------------// Pattern 8: a valid command accepted twice// ---------------------------------------------------------------------------mod replay { //! Promise: "each award is applied once." //! //! Unchecked assumption: that a command which was valid is still new. A //! correct integrity tag proves the bytes were not edited. It says nothing //! about whether they were already used. use super::{Decision, Reason}; #[derive(Clone, Copy, Debug)] pub struct AwardCommand { pub generation: u32, pub amount: u32, } #[derive(Debug)] pub struct Ledger { pub balance: u32, pub last_generation: u32, } impl Ledger { pub const fn new() -> Self { Self { balance: 0, last_generation: 0, } } /// The tag is verified, the amount is bounded — and the same message /// pays out every time it is submitted. pub const fn weak_award(&mut self, command: AwardCommand) -> Decision { self.balance = self.balance.saturating_add(command.amount); Decision::allow() } /// Bind each command to a position in a sequence, and refuse any /// position at or behind the last one applied. pub const fn strong_award(&mut self, command: AwardCommand) -> Decision { if command.generation <= self.last_generation { return Decision::deny(Reason::StaleGeneration); } self.last_generation = command.generation; self.balance = self.balance.saturating_add(command.amount); Decision::allow() } }}// ---------------------------------------------------------------------------// Pattern 9: a decision cached past the state it described// ---------------------------------------------------------------------------mod cached_capability { //! Promise: "turning writes off stops the tool from writing." //! //! Unchecked assumption: that a decision made at start-up still describes //! the settings in force at the moment of the effect. This is a cousin of //! the check/use gap: nothing raced, the answer simply outlived its input. use super::{Decision, Reason}; #[derive(Debug)] pub struct Tool { pub writes_allowed: bool, cached_allowed: Option<bool>, pub writes_performed: u32, } impl Tool { pub const fn new(writes_allowed: bool) -> Self { Self { writes_allowed, cached_allowed: None, writes_performed: 0, } } /// The first answer is stored and reused for the rest of the run. pub fn weak_write(&mut self) -> Decision { let allowed = *self.cached_allowed.get_or_insert(self.writes_allowed); if allowed { self.writes_performed += 1; Decision::allow() } else { Decision::deny(Reason::WritesDisabled) } } /// Re-read the setting where the write happens. A cache is only safe /// when something invalidates it, and nothing here does. pub const fn strong_write(&mut self) -> Decision { if self.writes_allowed { self.writes_performed += 1; Decision::allow() } else { Decision::deny(Reason::WritesDisabled) } } }}// ---------------------------------------------------------------------------// Pattern 10: a per-tick tolerance that accumulates// ---------------------------------------------------------------------------mod tolerance_drift { //! Promise: "a player cannot move faster than the movement rule allows." //! //! Unchecked assumption: that a tolerance small enough to be invisible in //! one tick is small enough to be harmless over a match. Distances are in //! centimetres so the arithmetic stays exact and the test is repeatable. use super::{Decision, Reason}; /// The largest legitimate step, in centimetres per tick. pub const MAX_STEP_CM: i64 = 400; /// Slack added to absorb network jitter and rounding. pub const TOLERANCE_CM: i64 = 5; /// How much total excess a player may accumulate before the window closes. pub const WINDOW_BUDGET_CM: i64 = 60; /// Every tick is judged on its own, so the answer is always "close /// enough" and the excess is never counted. pub const fn weak_step_allowed(step_cm: i64) -> bool { step_cm <= MAX_STEP_CM + TOLERANCE_CM } /// Runs `ticks` steps of exactly `step_cm` and returns the extra distance /// the weak rule permitted. pub const fn weak_excess_after(step_cm: i64, ticks: i64) -> i64 { if weak_step_allowed(step_cm) { (step_cm - MAX_STEP_CM) * ticks } else { 0 } } /// Keep the per-tick tolerance, but spend it from a budget. Jitter that /// averages out costs nothing; a steady lean in one direction runs out. #[derive(Debug, Default)] pub struct MovementWindow { spent_cm: i64, } impl MovementWindow { pub const fn new() -> Self { Self { spent_cm: 0 } } pub const fn spent_cm(&self) -> i64 { self.spent_cm } pub const fn step(&mut self, step_cm: i64) -> Decision { if step_cm > MAX_STEP_CM + TOLERANCE_CM { return Decision::deny(Reason::OutOfBounds); } let excess = if step_cm > MAX_STEP_CM { step_cm - MAX_STEP_CM } else { 0 }; if self.spent_cm + excess > WINDOW_BUDGET_CM { return Decision::deny(Reason::BudgetExhausted); } self.spent_cm += excess; Decision::allow() } /// Called when the measurement window rolls over. pub const fn reset(&mut self) { self.spent_cm = 0; } }}// ---------------------------------------------------------------------------// Pattern 11: a batch that fails halfway and keeps what it already did// ---------------------------------------------------------------------------mod partial_batch { //! Promise: "a rejected mod list changes nothing." //! //! Unchecked assumption: that refusing an item is the same as refusing the //! request. Validating and applying in the same pass means an item can be //! rejected only after its predecessors are already live. use super::{Decision, Reason}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Change { pub slot: usize, pub value: u8, } pub const SLOT_COUNT: usize = 4; const fn item_is_valid(change: Change) -> bool { change.slot < SLOT_COUNT && change.value != 0 } /// Validate-then-apply, one item at a time. The refusal is honest and the /// state is still wrong. pub fn weak_apply(slots: &mut [u8; SLOT_COUNT], changes: &[Change]) -> Decision { for &change in changes { if !item_is_valid(change) { return Decision::deny(Reason::ItemInvalid); } slots[change.slot] = change.value; } Decision::allow() } /// Two phases. Nothing is written until every item has been accepted, so a /// denial really does leave the state alone. pub fn strong_apply(slots: &mut [u8; SLOT_COUNT], changes: &[Change]) -> Decision { if !changes.iter().copied().all(item_is_valid) { return Decision::deny(Reason::ItemInvalid); } for &change in changes { slots[change.slot] = change.value; } Decision::allow() }}// ---------------------------------------------------------------------------// Demonstration// ---------------------------------------------------------------------------fn demo_wrapping_bound() { println!("4. Wrapping bound check"); let offset = usize::MAX - 3; println!( " weak says a 16-byte read at usize::MAX-3 fits in 4096 bytes: {}", wrapping_bound::weak_in_bounds(offset, 16, 4096) ); println!( " checked version: {}", wrapping_bound::checked_in_bounds(offset, 16, 4096) ); let region = [1_u8, 2, 3, 4]; println!( " slice access returns {:?} for the same wrapped range", wrapping_bound::read_region(®ion, offset, 16) );}fn demo_canonicalization() { println!("\n5. Prefix check before path resolution"); let request = "assets/../saves/profile.dat"; println!( " weak allows {request:?}: {}", canonicalization::weak_inside_assets(request) ); println!( " resolved decision: {:?}", canonicalization::open_under_assets(request) ); println!( " an ordinary path still works: {:?}", canonicalization::open_under_assets("assets/textures/hud.png") );}fn demo_fail_open() { println!("\n6. Verification error treated as permission"); println!( " weak gate with an unreadable manifest: {}", fail_open::weak_gate("hud.png", 0xdead_beef, None) ); println!( " strong gate: {:?}", fail_open::strong_gate("hud.png", 0xdead_beef, None) );}fn demo_second_route() { println!("\n7. A second route to the same effect"); let mut patcher = second_route::Patcher::new(false); patcher.weak_apply_batch(&[(0x1000, 0x90), (0x1001, 0x90)]); println!( " writes disabled, yet bytes changed: {}, decisions recorded: {}", patcher.bytes.len(), patcher.decisions.len() ); let mut patcher = second_route::Patcher::new(false); let decisions = patcher.strong_apply_batch(&[(0x1000, 0x90), (0x1001, 0x90)]); println!( " guarded batch changed {} bytes and recorded {} decisions", patcher.bytes.len(), decisions.len() );}fn demo_replay() { println!("\n8. Replay of a valid command"); let command = replay::AwardCommand { generation: 1, amount: 50, }; let mut ledger = replay::Ledger::new(); for _ in 0..4 { let _ = ledger.weak_award(command); } println!(" weak balance after four submissions: {}", ledger.balance); let mut ledger = replay::Ledger::new(); for _ in 0..4 { let _ = ledger.strong_award(command); } println!(" strong balance: {}", ledger.balance);}fn demo_cached_capability() { println!("\n9. A decision cached past its input"); let mut tool = cached_capability::Tool::new(true); let _ = tool.weak_write(); tool.writes_allowed = false; let _ = tool.weak_write(); println!( " writes after the setting was turned off: {}", tool.writes_performed ); let mut tool = cached_capability::Tool::new(true); let _ = tool.strong_write(); tool.writes_allowed = false; let second = tool.strong_write(); println!( " re-checked version: {} writes, second attempt {second:?}", tool.writes_performed );}fn demo_tolerance_drift() { println!("\n10. A tolerance that accumulates"); let step = tolerance_drift::MAX_STEP_CM + tolerance_drift::TOLERANCE_CM; println!( " weak rule allows every tick; after 600 ticks the player gained {} cm", tolerance_drift::weak_excess_after(step, 600) ); let mut window = tolerance_drift::MovementWindow::new(); let mut allowed_ticks = 0; for _ in 0..600 { if window.step(step).allowed { allowed_ticks += 1; } } println!( " budgeted rule allowed {allowed_ticks} of 600 ticks and spent {} cm", window.spent_cm() ); window.reset(); println!( " after the window rolls over the budget is {} cm again", window.spent_cm() );}fn demo_partial_batch() { println!("\n11. A batch that stops halfway"); let changes = [ partial_batch::Change { slot: 0, value: 7 }, partial_batch::Change { slot: 9, value: 3 }, ]; let mut slots = [0_u8; partial_batch::SLOT_COUNT]; let decision = partial_batch::weak_apply(&mut slots, &changes); println!(" weak decision {decision:?} left slots {slots:?}"); let mut slots = [0_u8; partial_batch::SLOT_COUNT]; let decision = partial_batch::strong_apply(&mut slots, &changes); println!(" two-phase decision {decision:?} left slots {slots:?}");}fn main() { demo_wrapping_bound(); demo_canonicalization(); demo_fail_open(); demo_second_route(); demo_replay(); demo_cached_capability(); demo_tolerance_drift(); demo_partial_batch();}#[cfg(test)]mod tests { use super::{ Reason, cached_capability, canonicalization, fail_open, partial_batch, replay, second_route, tolerance_drift, wrapping_bound, }; // -- Pattern 4 ---------------------------------------------------------- #[test] fn wrapped_sum_slips_through_the_weak_bound_check() { let offset = usize::MAX - 3; assert!(wrapping_bound::weak_in_bounds(offset, 16, 4096)); assert!(!wrapping_bound::checked_in_bounds(offset, 16, 4096)); } #[test] fn checked_bound_still_accepts_ordinary_reads() { assert!(wrapping_bound::checked_in_bounds(0, 4096, 4096)); assert!(!wrapping_bound::checked_in_bounds(1, 4096, 4096)); } #[test] fn slice_access_refuses_the_same_wrapped_range() { let region = [1_u8, 2, 3, 4]; assert_eq!( wrapping_bound::read_region(®ion, 1, 2), Some(®ion[1..3]) ); assert_eq!( wrapping_bound::read_region(®ion, usize::MAX - 3, 16), None ); } // -- Pattern 5 ---------------------------------------------------------- #[test] fn prefix_check_accepts_a_path_that_leaves_the_root() { let request = "assets/../saves/profile.dat"; assert!(canonicalization::weak_inside_assets(request)); assert!(!canonicalization::resolved_inside_assets(request)); } #[test] fn resolution_refuses_paths_that_climb_above_the_root() { assert_eq!(canonicalization::resolve("../secret"), None); assert_eq!(canonicalization::resolve("/etc/passwd"), None); assert_eq!(canonicalization::resolve("assets\\..\\..\\secret"), None); } #[test] fn resolution_keeps_ordinary_paths_working() { assert_eq!( canonicalization::resolve("assets/./textures/hud.png"), Some(vec!["assets", "textures", "hud.png"]) ); assert!(canonicalization::resolved_inside_assets( "assets/textures/../hud.png" )); } // -- Pattern 6 ---------------------------------------------------------- #[test] fn an_unreadable_manifest_must_not_grant_permission() { assert!(fail_open::weak_gate("hud.png", 1, None)); let decision = fail_open::strong_gate("hud.png", 1, None); assert!(!decision.allowed); assert_eq!(decision.reason, Reason::VerificationUnavailable); } #[test] fn a_changed_asset_and_an_outage_report_different_reasons() { assert_eq!( fail_open::strong_gate("hud.png", 1, Some(2)).reason, Reason::HashMismatch ); assert!(fail_open::strong_gate("hud.png", 1, Some(1)).allowed); } // -- Pattern 7 ---------------------------------------------------------- #[test] fn the_batch_helper_reaches_the_effect_without_the_guard() { let mut patcher = second_route::Patcher::new(false); patcher.weak_apply_batch(&[(0x1000, 0x90)]); assert_eq!(patcher.bytes.len(), 1); assert!(patcher.decisions.is_empty()); } #[test] fn every_route_through_the_guard_denies_and_records() { let mut patcher = second_route::Patcher::new(false); let decisions = patcher.strong_apply_batch(&[(0x1000, 0x90), (0x1001, 0x90)]); assert!(patcher.bytes.is_empty()); assert_eq!(decisions.len(), 2); assert!(decisions.iter().all(|decision| !decision.allowed)); assert_eq!(patcher.decisions.len(), 2); } // -- Pattern 8 ---------------------------------------------------------- #[test] fn one_valid_command_pays_out_repeatedly() { let command = replay::AwardCommand { generation: 1, amount: 50, }; let mut ledger = replay::Ledger::new(); for _ in 0..4 { assert!(ledger.weak_award(command).allowed); } assert_eq!(ledger.balance, 200); } #[test] fn a_generation_counter_accepts_each_command_once() { let command = replay::AwardCommand { generation: 1, amount: 50, }; let mut ledger = replay::Ledger::new(); assert!(ledger.strong_award(command).allowed); let repeat = ledger.strong_award(command); assert!(!repeat.allowed); assert_eq!(repeat.reason, Reason::StaleGeneration); assert_eq!(ledger.balance, 50); assert!( ledger .strong_award(replay::AwardCommand { generation: 2, amount: 25, }) .allowed ); assert_eq!(ledger.balance, 75); } // -- Pattern 9 ---------------------------------------------------------- #[test] fn a_cached_decision_outlives_the_setting_it_described() { let mut tool = cached_capability::Tool::new(true); assert!(tool.weak_write().allowed); tool.writes_allowed = false; assert!(tool.weak_write().allowed); assert_eq!(tool.writes_performed, 2); } #[test] fn re_reading_the_setting_stops_the_second_write() { let mut tool = cached_capability::Tool::new(true); assert!(tool.strong_write().allowed); tool.writes_allowed = false; let denied = tool.strong_write(); assert!(!denied.allowed); assert_eq!(denied.reason, Reason::WritesDisabled); assert_eq!(tool.writes_performed, 1); } // -- Pattern 10 --------------------------------------------------------- #[test] fn a_per_tick_tolerance_becomes_a_large_total() { let step = tolerance_drift::MAX_STEP_CM + tolerance_drift::TOLERANCE_CM; assert!(tolerance_drift::weak_step_allowed(step)); assert_eq!(tolerance_drift::weak_excess_after(step, 600), 3_000); } #[test] fn a_budget_stops_a_steady_lean_but_absorbs_jitter() { let step = tolerance_drift::MAX_STEP_CM + tolerance_drift::TOLERANCE_CM; let mut window = tolerance_drift::MovementWindow::new(); let allowed = (0..600).filter(|_| window.step(step).allowed).count(); assert_eq!(allowed, 12); assert_eq!(window.spent_cm(), tolerance_drift::WINDOW_BUDGET_CM); let mut window = tolerance_drift::MovementWindow::new(); for _ in 0..600 { assert!(window.step(tolerance_drift::MAX_STEP_CM - 20).allowed); } assert_eq!(window.spent_cm(), 0); } #[test] fn a_single_impossible_step_is_still_refused() { let mut window = tolerance_drift::MovementWindow::new(); let decision = window.step(tolerance_drift::MAX_STEP_CM * 3); assert!(!decision.allowed); assert_eq!(decision.reason, Reason::OutOfBounds); } // -- Pattern 11 --------------------------------------------------------- #[test] fn a_refused_batch_can_still_leave_changes_behind() { let mut slots = [0_u8; partial_batch::SLOT_COUNT]; let changes = [ partial_batch::Change { slot: 0, value: 7 }, partial_batch::Change { slot: 9, value: 3 }, ]; let decision = partial_batch::weak_apply(&mut slots, &changes); assert!(!decision.allowed); assert_eq!(slots, [7, 0, 0, 0]); } #[test] fn validating_the_whole_batch_first_leaves_state_untouched() { let mut slots = [0_u8; partial_batch::SLOT_COUNT]; let changes = [ partial_batch::Change { slot: 0, value: 7 }, partial_batch::Change { slot: 9, value: 3 }, ]; let decision = partial_batch::strong_apply(&mut slots, &changes); assert!(!decision.allowed); assert_eq!(decision.reason, Reason::ItemInvalid); assert_eq!(slots, [0, 0, 0, 0]); } #[test] fn a_valid_batch_still_applies_completely() { let mut slots = [0_u8; partial_batch::SLOT_COUNT]; let changes = [ partial_batch::Change { slot: 0, value: 7 }, partial_batch::Change { slot: 3, value: 3 }, ]; assert!(partial_batch::strong_apply(&mut slots, &changes).allowed); assert_eq!(slots, [7, 0, 0, 3]); }}
The interesting question is never “what was the trick?” It is always “what did
the control measure, and how did that stop matching what it promised?” Each
section below names the promise first, because a control with no stated promise
cannot be bypassed — only surprised.
Pattern 4: arithmetic that wraps inside the bound check
On a 64-bit machine that sum is computed modulo 2^64. Choose an offset near the
top of the range and the addition wraps past zero into a small number:
offset = 0xFFFF_FFFF_FFFF_FFFC (usize::MAX - 3)
length = 16
offset + length wraps to 12
12 <= 4096 -> the check says yes
Nothing overflowed visibly. In a release build there is no panic, no warning,
and the comparison is perfectly true. The read then starts at an address near
the top of the address space.
An offset plus a length that wraps around 2⁶⁴Adding in 64 bits wraps silently. A wrapped end can look small and pass a check; checked_add reports the overflow.
Add, wrap, then check
Adding offset and length
Offset
0xFFFFFFFFFFFFFFFC
↓ add
Length
+ 16
↓ equals
Exact sum (needs 65 bits)
0x1000000000000000C
↓ the top bit is lost
Stored in 64 bits (sum mod 2⁶⁴)
0x000000000000000C = 12
Checks for a 4096-byte region
Naive: wrapped end ≤ region size?
12 ≤ 4096: looks fine (wrong)
checked_add
None: overflow reported
Verdict
invalid range
The true end needs 65 bits, so it wraps to 12. That is within 4096, so a naive check passes a range whose start is invalid. checked_add reports the overflow.
Watch it happenA wrapped end loses the start of the requested range
A request starts at MAX minus 3 and asks for 16 bytes. Four bytes reach the 64-bit limit and twelve wrap to addresses 0 through 11. Its unchecked end is 12, so comparison with a 4096-byte region falsely succeeds. Checked addition instead refuses the overflowing sum.
Read the steps
The start is MAX minus 3, meaning 2 to the 64th power minus 4. A 16-byte request begins outside the 4096-byte mapped region.
Four requested bytes reach MAX. The remaining 16 minus 4 = 12 bytes wrap into addresses 0 through 11. The exclusive end becomes 12.
The comparison sees 12 less than or equal to 4096, which is true. It checks the wrapped end while losing the invalid start.
That true comparison falsely permits the request. The highlighted high addresses remain outside the mapped region.
checked_add reports None because the mathematical sum cannot fit. Refuse before attempting the read. A slice must also own the complete range.
The two rows magnify opposite ends of the address space. They are not adjacent mapped memory. Refusing overflow preserves the range check.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
The repair is not a bigger integer. It is refusing to answer a question that
has no answer:
matchoffset.checked_add(length) {
Some(end) => end <= region_size,
None => false,
}
Better still, hand the question to the slice, which cannot be argued out of its
own length — region.get(offset..end) returns None for every range it does
not own. Prefer asking a value that knows the answer over recomputing the
answer yourself.
Pattern 5: a prefix compared before the path is resolved
The check reads the text the caller supplied, not the location that text names:
"assets/../saves/profile.dat" starts with "assets/" -> allowed
resolves to "saves/profile.dat" -> outside the root
This is the same mistake as the archive extraction in Lesson 10.6, and it is
worth seeing twice, because the two look nothing alike in code and are
identical in structure. Both compare a string against a rule that only the
resolved path can satisfy.
The repair is to resolve first and compare second. The lab’s resolve
walks the segments, pops on .., and returns None the moment the path climbs
above its own root — because “this path escapes” is an answer the caller needs,
not an error to paper over.
verify returns Result<bool, VerifyError>, so there are three outcomes, not
two: matched, did not match, and could not tell. unwrap_or(true) folds the
third into the first.
Look at what that does to an attacker’s job. Forging a file that hashes to the
expected value is hard. Making the manifest unreadable — deleting it, locking
it, truncating it — is trivial, and now produces the same result.
The repair is to fail closed and to keep the third outcome distinct:
Those two denials need different responses from a human. One means the file
changed. The other means your verification is broken, which is a bigger
problem, and collapsing them into one boolean throws that distinction away.
self.write(address, value); // the private writer, directly
}
}
The guard was never removed or weakened. It was simply routed around. Worse,
the batch path records no decisions at all, so the audit log shows nothing
happened while the bytes changed.
The repair is structural rather than additional. Do not add a second copy of
the check to the batch helper; make the batch helper call the guarded entry
point. One way in means one place to be correct:
A tag proves the bytes were not edited. It says nothing about whether they were
already used. Freshness and integrity are separate properties, and a check for
one is silently assumed to cover the other far too often.
The repair binds each command to a position in a sequence and refuses any
position at or behind the last one applied:
ifcommand.generation <= self.last_generation {
returnDecision::deny(Reason::StaleGeneration);
}
Note what this costs: the receiver now has to remember something. Every
anti-replay design pays that price somewhere — a counter, a window of recent
identifiers, a timestamp with a bounded skew. A design that claims to stop
replay while remaining completely stateless has not stopped replay.
Pattern 9: a decision cached past the state it described
The first call stores the answer; every later call reuses it. Turn writes off
afterwards and the tool keeps writing, because it is still answering a question
asked at start-up.
This is a cousin of the check/use gap from Lesson 15.7, with one instructive
difference: nothing raced. There is no unlucky timing to reproduce and no
second thread involved. The answer simply outlived the input it was computed
from. Single-threaded code can have this bug, which is why “we are
single-threaded” is not a reason to skip the review.
The repair is to re-read the setting where the write happens. A cache is
only safe when something invalidates it, and nothing here does.
Promise: a player cannot move faster than the movement rule allows.
Every movement check needs slack for jitter and rounding, so the rule allows a
few centimetres over the maximum per tick. Each tick is judged alone, and each
tick is genuinely fine:
max step 400 cm
tolerance 5 cm
step taken 405 cm -> allowed, every single time
over 600 ticks: 600 x 5 = 3,000 cm of free distance
Thirty metres, and no individual check was ever wrong. This is the pattern that
most resembles real anti-cheat tuning, because the tolerance is not a mistake —
remove it and honest players trip the rule constantly.
The repair keeps the per-tick tolerance and adds a budget across a window:
letexcess = (step_cm - MAX_STEP_CM).max(0);
ifself.spent_cm + excess > WINDOW_BUDGET_CM {
returnDecision::deny(Reason::BudgetExhausted);
}
Now jitter that averages out costs nothing, because a step under the maximum
spends zero budget. A steady lean in one direction runs out after twelve ticks.
The lab test asserts both halves of that claim, which is the point: a tolerance
rule needs a test proving it still accepts ordinary play, or the next person
will quietly widen it until it does.
Pattern 11: a batch that fails halfway and keeps what it did
if !item_is_valid(change) { returnDecision::deny(Reason::ItemInvalid); }
slots[change.slot] = change.value;
}
The refusal is honest. The state is still wrong:
changes: [ {slot 0, value 7}, {slot 9, value 3} ]
result: Denied(ItemInvalid), slots = [7, 0, 0, 0]
Slot 0 was written before slot 9 was rejected. A caller that reads “denied” and
assumes nothing happened is now working from a state nobody intended, and the
half-applied change is exactly the sort of thing that survives a restart.
The repair is two phases: validate every item, then apply every item.
Nothing is written until the whole request is known to be acceptable, so a
denial genuinely means no change. When the effect cannot be made atomic, the
alternative is an explicit rollback path — never a comment saying callers
should re-check.
Read the middle two columns together. In every row the control measured
something true, and something true was not the same as the thing promised.
That gap is the whole subject.
When you meet an evasion claim in the wild, the useful response is not to run
it. It is to work out which row of that table it belongs to:
Question
Where the answer comes from
What did the control promise?
documentation, or the check’s own comment
What does it actually measure?
the code at the branch
How can the two come apart?
the pattern table above
What shows the gap in practice?
a decision that contradicts the promise
Where does the invariant belong?
the shared point where the effect becomes real
What test keeps it fixed?
one negative case and one positive case
The last row is the one people skip. A repair with only a negative test tends
to be over-tightened until ordinary behavior breaks; a repair with only a
positive test tends to drift back. Both, or it will not hold.
Fail-open: treating an inconclusive check as permission.
Choke point: the single place every route to an effect must pass through.
Freshness: the property that a message has not been used before, which
integrity alone does not provide.
Tolerance budget: an allowance spent across a window rather than granted
per event.
Two-phase apply: validating an entire request before performing any part
of it.
The eleven game-and-boundary patterns in Lessons 15.8 and 12.10 are variations on one question:
does the check guard the state and effect it claims to guard? Chapter 14
extends the machine model beyond one running operating system: a hypervisor
provides a guest with virtual hardware, a console begins from a trusted boot
chain, and an emulator reconstructs a machine in software. In each case, ask
which component owns the state and which boundary controls an effect.