Import Table Hooks
See how an imported function pointer can be redirected inside a controlled process and safely restored.
Cheatsheet for Lesson 8.4
A one-page summary of “Import Table Hooks” you can keep beside your notes. Plain Markdown.
Lesson 8.4 — Import Table Hooks: cheatsheet
- Remember: An IAT hook changes a module’s imported destination; it does not rewrite every call to that function throughout the process.
- Also useful: Read the import tables like paired columns: one identifies the requested name, while the other holds its resolved address.
Reading map
- What the Import Address Table does
- Keep the function signature exact
- Find the import directory
- Change one pointer, then restore it
- Run the complete three-message proof
- How this connects to game labs
Formulas and patterns
your call site → MessageBoxW IAT slot → live user32!MessageBoxW
Review
- Q: What does an Import Address Table slot normally contain after the loader finishes? A: The resolved live address of an imported function
- Q: How does an import-table hook differ from an inline detour? A: It edits one pointer in a table that calls already use, leaving instructions alone
What the Import Address Table does
Section titled “What the Import Address Table does”The detours in Lesson 8.3 changed instruction bytes at a verified site. Sometimes a program already calls a function through a table of imported addresses. In that case, one verified table entry can change the destination while leaving the call instruction in place.
Your program can call MessageBoxW without knowing where user32.dll will load. The PE import data says, “I need this named function.” The Windows loader finds its live address and writes that address into the Import Address Table, or IAT.
Later, a normal imported call follows the pointer stored in that table:
your call site → MessageBoxW IAT slot → live user32!MessageBoxWIf a program deliberately replaces its own slot, the same call site follows the replacement:
your call site → MessageBoxW IAT slot → hooked_message_box └→ saved original MessageBoxWThis lab changes only the IAT of the course executable itself. It does not target another process, hide from security software, or bypass protections.
During the lab, the call still begins at the same instruction; only the pointer in the table chooses a different route:
When the patch owner is dropped, it puts the saved original pointer back into that same IAT slot.
Request call through the MessageBoxW slot
Step 1: Call site to IAT slot
The code does not hold the function’s address; it reads it from the import address table slot.
Data Slot value: MessageBoxW
Step 2: IAT slot to MessageBoxW
The slot holds the real destination, so the call lands in MessageBoxW.
The call instruction stays in place. Before the hook, its IAT slot holds MessageBoxW.
Request call through the same slot
Step 1: Call site to IAT slot
Identical instruction, identical slot address.
Data Slot value: hooked_message_box
Step 2: IAT slot to hooked_message_box
Installation replaced the slot’s contents after checking it still held the expected original.
Request Call the saved original
Step 3: hooked_message_box to MessageBoxW
The replacement can still reach the real function through the address it saved before installing.
Installation changes the verified slot value. The call instruction itself is untouched.
Alone Restore the saved slot value
Step 1: IAT slot (alone)
Removal puts back exactly the value that installation saved.
Request call through the same slot
Step 2: Call site to IAT slot
Still the same unchanged instruction.
Data Slot value: MessageBoxW
Step 3: IAT slot to MessageBoxW
The call reaches MessageBoxW again.
Writing the saved original back returns the route to the first picture.
Keep the function signature exact
Section titled “Keep the function signature exact”The replacement must accept the same arguments and use the same Windows calling convention:
type RawMessageBoxW = unsafe extern "system" fn( HWND, PCWSTR, PCWSTR, MESSAGEBOX_STYLE,) -> MESSAGEBOX_RESULT;
static ORIGINAL_MESSAGE_BOX: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn hooked_message_box( window: HWND, _text: PCWSTR, caption: PCWSTR, style: MESSAGEBOX_STYLE,) -> MESSAGEBOX_RESULT { let address = ORIGINAL_MESSAGE_BOX.load(Ordering::Acquire); if address == 0 { return MESSAGEBOX_RESULT(0); }
// 🔁 SAFETY: install() saved the original pointer from this exact IAT slot. let original: RawMessageBoxW = unsafe { std::mem::transmute(address) }; // 🛡️ SAFETY: the signature and Windows ABI match MessageBoxW. unsafe { original( window, w!("Hook reached the replacement IAT entry."), caption, style, ) }}extern "system" tells the compiler to use the calling convention Windows expects. If the convention, parameter sizes, or return type are wrong, the stack or registers can be interpreted incorrectly.
Find the import directory
Section titled “Find the import directory”For a loaded PE32 image:
- get the current executable base with
GetModuleHandleW(NULL); - verify
MZandPE\0\0; - require optional-header magic
0x10B; - read the import-directory RVA from data-directory entry 1;
- walk 20-byte import descriptors until the all-zero terminator;
- find
user32.dll; - walk its lookup names and IAT slots together;
- find
MessageBoxWby name.
Step 7 is the one that surprises people: each imported DLL has two parallel arrays, not one. The compiler writes them as identical lists of equal length, and the loader overwrites only one of them.
index lookup table (names) address table (IAT) 0 "MessageBoxW" 0x7FF8_1234_5000 <- written by the loader 1 "MessageBoxA" 0x7FF8_1234_5100 2 ordinal 0x201 0x7FF8_1234_5200 3 0 (terminator) 0Before the module is loaded, both arrays hold the same name references. During loading, Windows resolves each name and writes the resulting live address into the matching IAT slot, leaving the lookup table alone. That is exactly why you walk the two together: the lookup table still records which function this entry is, while the IAT records where that function currently lives. Searching the IAT by itself gives you addresses with no names attached; searching the lookup table by itself gives you names with nothing to patch.
(Some binaries ship without the separate lookup table. In a loaded image, the IAT has already been overwritten with live function addresses, so it cannot be treated as a list of name RVAs. This name-based lab refuses that case with a clear error. Handling it would require a different, independently verified way to identify the desired slot.)
The important matching loop is:
anyhow::ensure!( original_thunk != 0, "loaded image has no import-name lookup table");let names = original_thunk;
for index in 0..2048_usize { // 📏 Bound the thunk walk even if a malformed image omits its zero terminator. let name_cell = names .checked_add(index * 4) .context("name thunk overflowed")?; let name_value = u32_at(image, name_cell)?; if name_value == 0 { break; } if name_value & 0x8000_0000 != 0 { // 🔍 The high bit marks an ordinal import; there is no name pointer to read. continue; // this one was imported by number, not by name }
let import_name = usize::try_from(name_value)? .checked_add(2) // skip the two-byte hint .context("import name overflowed")?; if c_string_at(image, import_name)? != b"MessageBoxW" { continue; }
// 🧭 Names identify the function; FirstThunk identifies the live pointer // slot that the loader filled and the hook must replace. let slot_rva = first_thunk .checked_add(index * 4) .context("IAT overflowed")?; anyhow::ensure!(slot_rva + 4 <= image_size, "IAT slot outside image"); let slot = (base + slot_rva) as *mut u32; // ✅ The installer now owns this validated four-byte PE32 slot.}The limits stop a malformed table from becoming an endless walk through memory.
Change one pointer, then restore it
Section titled “Change one pointer, then restore it”VirtualProtect temporarily makes the four-byte slot writable. The code immediately restores the old page protection:
The helper below receives a validated PE32 import slot and the replacement 32-bit function pointer. It performs exactly those three operations and returns success only after the original page protection is restored:
unsafe fn write_slot(slot: *mut u32, value: u32) -> anyhow::Result<()> { let mut old = PAGE_PROTECTION_FLAGS::default(); // 🛡️ SAFETY: slot is the validated IAT cell in this executable. unsafe { VirtualProtect(slot.cast(), 4, PAGE_READWRITE, &mut old)? }; // 📏 SAFETY: the page is writable and the slot is exactly four bytes on PE32. // 🔁 Volatile keeps this externally visible pointer update as one explicit // memory operation; it does not make an invalid slot safe. unsafe { slot.write_volatile(value) };
let mut ignored = PAGE_PROTECTION_FLAGS::default(); // 🔒 SAFETY: old came from the successful protection change above. unsafe { VirtualProtect(slot.cast(), 4, old, &mut ignored)? }; Ok(())}Changing one pointer is not the whole lifecycle. The owning guard below remembers the original destination and puts it back when the guard is dropped, so the third message box in this lab uses the normal import again:
struct IatPatch { slot: *mut u32, original: u32,}
impl Drop for IatPatch { fn drop(&mut self) { // 🧹 SAFETY: this owner restores the same slot exactly once. if let Err(error) = unsafe { write_slot(self.slot, self.original) } { eprintln!("could not restore MessageBoxW IAT slot: {error:#}"); } // 🔒 Publish “no callable original” only after the slot restoration // attempt, so the replacement cannot observe cleanup out of order. ORIGINAL_MESSAGE_BOX.store(0, Ordering::Release); }}This is RAII applied to reverse engineering: owning the modification also means owning the cleanup.
If restoring page protection fails after the pointer write, the slot may
already contain the replacement even though write_slot returns an error.
A complete installer must retain enough state to retry or roll back that
partial installation; the returned Result alone does not undo it.
Run the complete three-message proof
Section titled “Run the complete three-message proof”An import slot is a pointer with ownership. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
save original = MessageBoxW
optionally replace the import slot
the caller reads the current slot
our wrapper forwards to the saved original
restore only if the slot still contains our replacement
report where the next call goesResult: the next call follows original. Restoring blindly could erase another owner’s change.
Final values: install = yes; other = no; slot = original; first_route = our_wrapper; original_calls = 1; restored = true.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
install = yes; other = no.
Step 2, line 2. The call instruction remains unchanged; only the imported pointer changes.
install = yes; other = no; slot = our_wrapper.
Step 3, line 3. The caller follows the pointer currently in the slot.
install = yes; other = no; slot = our_wrapper; first_route = our_wrapper.
Step 4, line 4. Our wrapper forwards to the saved original.
install = yes; other = no; slot = our_wrapper; first_route = our_wrapper; original_calls = 1.
Step 5, line 5. Our slot is still installed, so restore the saved pointer.
install = yes; other = no; slot = original; first_route = our_wrapper; original_calls = 1; restored = true.
Step 6, line 6. Result: the next call follows original. Restoring blindly could erase another owner’s change.
install = yes; other = no; slot = original; first_route = our_wrapper; original_calls = 1; restored = true.
A call reads a four-byte IAT slot. It first reaches MessageBoxW, then the slot’s pointer changes so the call reaches a replacement that forwards to the saved original. Cleanup puts the original pointer back and the next call takes the normal route.
Read the steps
- The loader has resolved user32.dll!MessageBoxW. The first call reads the import slot and reaches the real function.
- Matching the import name selects its four-byte IAT slot in this PE32 image. Bounds checks establish that this slot belongs to the loaded executable.
- The patch owner saves the slot’s original pointer. The replacement will use that same callable original, and cleanup will write it back.
- The installer temporarily makes the slot writable, writes the replacement pointer, and restores its protection. Only the destination stored in the slot changes.
- The next imported call follows the changed slot into the replacement. With the matching ABI, it forwards to the saved original and supplies the replacement message.
- Successful cleanup restores the saved pointer to the slot. The third call again follows the normal route. A failed restoration remains owned work.
The lab changes one PE32 pointer, preserves the original destination, and owns restoration. This is an offline call-route illustration.
Run this from windows-labs in the Windows VM. The three message boxes let
you observe the original import, the redirected call, and the restored call
in that order.
cargo run --release --target i686-pc-windows-msvc --bin iat_hook_labYou should see three message boxes:
- the untouched call displays “The normal import runs first.”
- the hooked call displays “Hook reached the replacement IAT entry.”
- after
IatPatchis dropped, the restored call displays “The original import is restored.”
Those three visible states prove interception and restoration. The full buildable source—including bounded PE parsing and every unsafe invariant—is iat_hook_lab.rs.
iat_hook_lab.rs
Show the codeHide the code
#[cfg(all(windows, target_arch = "x86"))]
mod lab {
use std::{
ffi::c_void,
sync::atomic::{AtomicUsize, Ordering},
};
use anyhow::{Context, Result};
use windows::{
Win32::{
Foundation::HWND,
System::{
LibraryLoader::GetModuleHandleW,
Memory::{PAGE_PROTECTION_FLAGS, PAGE_READWRITE, VirtualProtect},
},
UI::WindowsAndMessaging::{MB_OK, MESSAGEBOX_RESULT, MESSAGEBOX_STYLE, MessageBoxW},
},
core::{PCWSTR, w},
};
type RawMessageBoxW =
unsafe extern "system" fn(HWND, PCWSTR, PCWSTR, MESSAGEBOX_STYLE) -> MESSAGEBOX_RESULT;
static ORIGINAL_MESSAGE_BOX: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn hooked_message_box(
window: HWND,
_text: PCWSTR,
caption: PCWSTR,
style: MESSAGEBOX_STYLE,
) -> MESSAGEBOX_RESULT {
let address = ORIGINAL_MESSAGE_BOX.load(Ordering::Acquire);
if address == 0 {
return MESSAGEBOX_RESULT(0);
}
// SAFETY: install() stores the original MessageBoxW IAT target before the
// hook can run, and the signature exactly matches the imported function.
let original: RawMessageBoxW = unsafe { std::mem::transmute(address) };
// SAFETY: the saved function pointer and all arguments obey MessageBoxW's ABI.
unsafe {
original(
window,
w!("Rust reached the replacement IAT entry."),
caption,
style,
)
}
}
fn u16_at(bytes: &[u8], offset: usize) -> Result<u16> {
let end = offset.checked_add(2).context("offset overflowed")?;
Ok(u16::from_le_bytes(
bytes
.get(offset..end)
.context("u16 outside image")?
.try_into()?,
))
}
fn u32_at(bytes: &[u8], offset: usize) -> Result<u32> {
let end = offset.checked_add(4).context("offset overflowed")?;
Ok(u32::from_le_bytes(
bytes
.get(offset..end)
.context("u32 outside image")?
.try_into()?,
))
}
fn c_string_at(bytes: &[u8], offset: usize) -> Result<&[u8]> {
let tail = bytes.get(offset..).context("string RVA outside image")?;
let end = tail
.iter()
.position(|byte| *byte == 0)
.context("unterminated import name")?;
Ok(&tail[..end])
}
unsafe fn write_slot(slot: *mut u32, value: u32) -> Result<()> {
let mut old = PAGE_PROTECTION_FLAGS::default();
// SAFETY: slot points to the four-byte IAT cell found inside this module.
unsafe { VirtualProtect(slot.cast::<c_void>(), 4, PAGE_READWRITE, &mut old)? };
// SAFETY: VirtualProtect made this validated IAT cell writable.
unsafe { slot.write_volatile(value) };
let mut ignored = PAGE_PROTECTION_FLAGS::default();
// SAFETY: restore the exact protection returned by the first call.
unsafe { VirtualProtect(slot.cast::<c_void>(), 4, old, &mut ignored)? };
Ok(())
}
struct IatPatch {
slot: *mut u32,
original: u32,
}
impl Drop for IatPatch {
fn drop(&mut self) {
// SAFETY: the patch owns this validated slot and restores it once.
if let Err(error) = unsafe { write_slot(self.slot, self.original) } {
eprintln!("could not restore MessageBoxW IAT slot: {error:#}");
}
ORIGINAL_MESSAGE_BOX.store(0, Ordering::Release);
}
}
unsafe fn install() -> Result<IatPatch> {
// SAFETY: a null module name asks Windows for the current executable.
let module = unsafe { GetModuleHandleW(PCWSTR::null()) }?;
let base = module.0 as usize;
// Every loaded PE image maps at least its first header page. Read that
// page first, validate it, then use SizeOfImage for the bounded view.
// SAFETY: base is the current loaded module and its header page is mapped.
let header = unsafe { std::slice::from_raw_parts(base as *const u8, 4096) };
anyhow::ensure!(header.get(..2) == Some(b"MZ"), "missing MZ header");
let pe = usize::try_from(u32_at(header, 0x3c)?)?;
anyhow::ensure!(
header.get(pe..pe + 4) == Some(b"PE\0\0"),
"missing PE header"
);
let optional = pe.checked_add(24).context("optional-header overflowed")?;
anyhow::ensure!(u16_at(header, optional)? == 0x10b, "this lab expects PE32");
let image_size = usize::try_from(u32_at(header, optional + 56)?)?;
anyhow::ensure!(
(4096..=512 * 1024 * 1024).contains(&image_size),
"bad image size"
);
// SAFETY: SizeOfImage describes the mapped extent of the current module.
let image = unsafe { std::slice::from_raw_parts(base as *const u8, image_size) };
let import_rva = usize::try_from(u32_at(image, optional + 96 + 8)?)?;
anyhow::ensure!(import_rva != 0, "the executable has no import directory");
for descriptor_index in 0..256_usize {
let descriptor = import_rva
.checked_add(descriptor_index * 20)
.context("descriptor offset overflowed")?;
let original_thunk = usize::try_from(u32_at(image, descriptor)?)?;
let name_rva = usize::try_from(u32_at(image, descriptor + 12)?)?;
let first_thunk = usize::try_from(u32_at(image, descriptor + 16)?)?;
if original_thunk == 0 && name_rva == 0 && first_thunk == 0 {
break;
}
if !c_string_at(image, name_rva)?.eq_ignore_ascii_case(b"user32.dll") {
continue;
}
// In a loaded image FirstThunk holds resolved addresses. Without
// OriginalFirstThunk, this name-based lookup has no trustworthy
// import-name table, so it cannot identify a slot by name.
anyhow::ensure!(
original_thunk != 0,
"user32.dll has no OriginalFirstThunk; cannot match MessageBoxW by name"
);
let names = original_thunk;
for index in 0..2048_usize {
let name_cell = names
.checked_add(index * 4)
.context("name thunk overflowed")?;
let name_value = u32_at(image, name_cell)?;
if name_value == 0 {
break;
}
if name_value & 0x8000_0000 != 0 {
continue; // imported by ordinal, so it has no name to compare
}
let import_name = usize::try_from(name_value)?
.checked_add(2)
.context("import name overflowed")?;
if c_string_at(image, import_name)? != b"MessageBoxW" {
continue;
}
let slot_rva = first_thunk
.checked_add(index * 4)
.context("IAT overflowed")?;
anyhow::ensure!(slot_rva + 4 <= image_size, "IAT slot outside image");
let slot = (base + slot_rva) as *mut u32;
// SAFETY: the validated slot points to one aligned PE32 thunk cell.
let original = unsafe { slot.read_volatile() };
anyhow::ensure!(original != 0, "MessageBoxW IAT slot was null");
ORIGINAL_MESSAGE_BOX.store(original as usize, Ordering::Release);
// SAFETY: slot is a validated four-byte IAT cell in our own process.
unsafe { write_slot(slot, hooked_message_box as *const () as usize as u32)? };
return Ok(IatPatch { slot, original });
}
}
anyhow::bail!("MessageBoxW was not found in this executable's IAT")
}
pub fn run() -> Result<()> {
// SAFETY: literals are valid zero-terminated UTF-16 and no window owner is needed.
unsafe {
MessageBoxW(
None,
w!("The normal import runs first."),
w!("GHA IAT lab"),
MB_OK,
)
};
// SAFETY: install parses and writes only this executable's validated IAT slot.
let patch = unsafe { install()? };
// SAFETY: the call is redirected through the temporary IAT patch.
unsafe {
MessageBoxW(
None,
w!("You should not see this sentence."),
w!("GHA IAT lab"),
MB_OK,
)
};
drop(patch);
// SAFETY: dropping the patch restored the original imported function.
unsafe {
MessageBoxW(
None,
w!("The original import is restored."),
w!("GHA IAT lab"),
MB_OK,
)
};
Ok(())
}
}
#[cfg(all(windows, target_arch = "x86"))]
fn main() -> anyhow::Result<()> {
lab::run()
}
#[cfg(not(all(windows, target_arch = "x86")))]
fn main() {
eprintln!("Build this self-hooking lab for i686-pc-windows-msvc.");
}How this connects to game labs
Section titled “How this connects to game labs”An injected course DLL lives inside the game process, so the same idea can observe an imported graphics or file function used by that exact build. Before changing a game IAT:
- prove the function is actually imported by the main executable;
- use the exact function signature;
- reject an unknown PE format or module version;
- keep the hook tiny and non-blocking;
- restore the original slot before unloading the DLL.
The Urban Terror OpenGL chapter uses an instruction detour because its verified draw path is not expressed as a convenient main-executable IAT slot. Choose a hook based on the real call path, not because one technique sounds fashionable.
References: Microsoft PE format, GetModuleHandleW, and importing functions using __declspec(dllimport).
Understand the import slot
What does an Import Address Table slot normally contain after the loader finishes?
Call sites can load the resolved pointer from the IAT. A reversible lab hook saves that exact pointer, replaces one owned slot, and restores it afterward.
Finished Lesson 8.4?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.