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

Dynamic Windows API Resolution

Runtime export lookup turns an optional Windows API into a checked capability with an explicit ABI and fallback.

Lesson 12.3
Read or listenPrint chapter or book
About 29 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 12.3Key ideas, reference and questions

A one-page summary of “Dynamic Windows API Resolution” you can keep beside your notes. Plain Markdown.

Lesson 12.3 — Dynamic Windows API Resolution: cheatsheet

  • Remember: Finding an optional export chooses a capability path; the chosen call still needs ordinary error handling.
  • Also useful: A typed function pointer is a delivery contract, naming where arguments go and how results return—not merely an address to jump to.

Key terms

  • stdcall: 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.
  • Option: The Option is intentional.
  • length: length means two different things on the two sides of the call.

Review

  • Q: What should a runtime API resolver do when an optional export is not present? A: Return None and use the documented lower-capability fallback
  • Q: What are sensible steps for resolving an optional operating-system function at run time? A: Get the library, find the export, check it, then call or fall back
Download cheatsheet (.md)

Import tables are not the only way to call an API

Section titled “Import tables are not the only way to call an API”

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:

  1. obtain a handle to the system DLL;
  2. look up the named export;
  3. convert the address to one proven function-pointer type;
  4. 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.
  1. Request GetModuleHandleW("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.

  2. Request GetProcAddress(module, "QueryFullProcessImageNameW")

    Step 2: Tool to kernel32 exports

    The name is looked up in kernel32’s export table.

  3. Data Some(address)

    Step 3: kernel32 exports to Tool

    The export exists, so an address comes back. So far it is only an address.

  4. Alone transmute 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. Request call(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.

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.
  1. Request GetModuleHandleW("kernel32.dll")

    Step 1: Tool to Loaded modules

    The same first step: borrow the loaded module’s handle.

  2. Request GetProcAddress(module, "QueryFullProcessImageNameW")

    Step 2: Tool to kernel32 exports

    The same lookup by name.

  3. Reply None: no such export

    Step 3: kernel32 exports to Tool

    On this Windows build the name is not in the export table. That is a supported state, not a crash.

  4. Alone use ImageNameSource::ToolHelpSnapshot

    Step 4: Tool (alone)

    The tool reads szExePath from a module snapshot instead, and labels the result with that source.

GetProcAddress finds no such name. The tool never calls an unknown address; it switches to the fallback it designed first.

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

    export = present; call = ok.

  2. Step 2, line 1. Feature detection checks the capability itself.

    export = present; call = ok; present = true; called = false.

  3. Step 3, line 3. Call the resolved function with its documented signature.

    export = present; call = ok; present = true; called = true; source = documented query.

  4. Step 4, line 4. The query returned the path.

    export = present; call = ok; present = true; called = true; source = documented query; result = fixture path.

  5. Step 5, line 5. Keep the API result and its source visible.

    export = present; call = ok; present = true; called = true; source = documented query; result = fixture path; limitation = call can fail despite presence.

  6. Step 6, line 6. Result: successful documented query. Export presence and operation success are separate checks.

    export = present; call = ok; present = true; called = true; source = documented query; result = fixture path; limitation = call can fail despite presence.

Define the contract from documentation and assembly

Section titled “Define the contract from documentation and assembly”

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.

Keep the type alias beside the resolver:

use windows::Win32::Foundation::{BOOL, HANDLE};
type QueryFullProcessImageNameW = unsafe extern "system" fn(
process: HANDLE,
flags: u32,
name: windows::core::PWSTR,
size: *mut u32,
) -> BOOL;

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.

Resolve from an already loaded system module

Section titled “Resolve from an already loaded system module”

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.

use windows::{
Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress},
core::{PCSTR, w},
};
unsafe fn resolve_query_name() -> anyhow::Result<Option<QueryFullProcessImageNameW>> {
// SAFETY: kernel32.dll is a NUL-terminated static string. 🔎
let module = unsafe { GetModuleHandleW(w!("kernel32.dll")) }?;
// SAFETY: the export name is a NUL-terminated static byte string.
let Some(raw) = (unsafe {
GetProcAddress(module, PCSTR(b"QueryFullProcessImageNameW\0".as_ptr()))
}) else {
return Ok(None);
};
// SAFETY: the SDK declaration and export name establish this system ABI.
let typed = unsafe {
core::mem::transmute::<unsafe extern "system" fn() -> isize, QueryFullProcessImageNameW>(raw)
};
Ok(Some(typed))
}

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.

Windows text APIs often accept a buffer plus an in/out length. Treat the length as untrusted after every call:

fn query_image_name(
function: QueryFullProcessImageNameW,
process: HANDLE,
) -> anyhow::Result<String> {
let mut buffer = vec![0_u16; 32_768];
let mut length = u32::try_from(buffer.len())?;
// SAFETY: the buffer is writable for `length` UTF-16 units, and the handle
// remains valid for the call. Windows writes the resulting unit count.
let ok = unsafe {
function(
process,
0,
windows::core::PWSTR(buffer.as_mut_ptr()),
&mut length,
)
};
anyhow::ensure!(ok.as_bool(), "QueryFullProcessImageNameW failed");
let used = usize::try_from(length)?;
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:

enum ImageNameSource {
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.

No
Yes
Yes
No
Need an optional Windows capability
Find the documented export
in an already-loaded module
Export present?
Try the documented fallback;
label its source and limits
Convert the address to the
exact documented function type
Call succeeds?
Return the full result
labelled with its source
Handle the call error
or documented retry case

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.

Test the resolver with:

  • the correct export name;
  • one deliberately misspelled name;
  • 32-bit and 64-bit builds;
  • a closed or insufficient-rights process handle;
  • a buffer whose returned length reaches its bound;
  • an ordinary imported call, then compare results.

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.

Knowledge check

Treat absence as a state

What should a runtime API resolver do when an optional export is not present?

Finished Lesson 12.3?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 12: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.