Finding the Hooks: IAT and EAT Scanning

This post covers how Peregrine detects Import Address Table (IAT) and Export Address Table (EAT) hooks by walking both tables for every loaded module and flagging any entry that points outside the address range of known modules. These hooks never modify a single instruction byte, so integrity hashing alone will not detect them. The scanner lives in hooks.rs and relies on the PE parsing infrastructure in pe.rs.

How IAT and EAT Hooks Work

When a PE calls an imported function, such as CreateFileW from kernel32.dll, it does not call the function directly. The compiler emits an indirect call through a slot in the Import Address Table, which the loader fills at image load time with the resolved address of the target function [1]. Overwriting that slot redirects every callsite in the module without modifying any executable code.

The Export Address Table is the mirror image. A DLL publishes its exports as an array of RVAs in the export directory [1]. When another module resolves an import, it looks up the target RVA in the exporting DLL’s EAT. Patching an EAT entry means that all future resolutions of that function, by any DLL loaded after the hook is installed, get the attacker’s address instead of the real one.

Both hooks leave .text completely untouched. Detecting them requires walking the tables and validating where the pointers actually land.

IAT Scanning: Walking the Import Descriptors

The IAT scan starts by enumerating all loaded modules, parsing each module’s PE header from process memory, and locating data directory entry 1 (the import directory). Each IMAGE_IMPORT_DESCRIPTOR is a 20-byte structure containing the RVA of the DLL name, the RVA of the Import Lookup Table (ILT), and the RVA of the IAT itself [1].

// From: detections/hooks.rs
pub fn check_iat_hooks(pid: u32) -> Result<Vec<IatHook>, String> {
    let proc = ProcessHandle::open(pid).ok_or("OpenProcess failed")?;
    let modules = proc.modules();
    let mut results = Vec::new();

    for m in &modules {
        let pe = match parse_pe_header(&proc, m.base) {
            Some(p) => p,
            None => continue,
        };

        let (import_rva, _) = match get_data_directory(&proc, &pe, IMAGE_DIRECTORY_ENTRY_IMPORT) {
            Some(d) => d,
            None => continue,
        };
        if import_rva == 0 {
            continue;
        }

        let ptr_size = if pe.is64 { 8usize } else { 4 };
        let ordinal_flag: u64 = if pe.is64 { 1 << 63 } else { 1 << 31 };

The ptr_size and ordinal_flag adjust for architecture. On x64, IAT entries are 8 bytes and the ordinal flag lives in bit 63; on x86, they are 4 bytes with the flag in bit 31 [1].

Iterating Import Descriptors and IAT Slots

The code walks the import descriptor array. Each descriptor is 20 bytes; a descriptor with both iat_rva and name_rva zeroed marks the end of the list [1]:

// From: detections/hooks.rs
        let mut desc = m.base + import_rva as usize;
        loop {
            let ilt_rva = match proc.read_u32(desc) { Some(v) => v, None => break };
            let name_rva = match proc.read_u32(desc + 12) { Some(v) => v, None => break };
            let iat_rva = match proc.read_u32(desc + 16) { Some(v) => v, None => break };

            if iat_rva == 0 && name_rva == 0 {
                break;
            }

            let dll_name = if name_rva != 0 {
                proc.read_cstring(m.base + name_rva as usize, 256)
                    .unwrap_or_else(|| "?".into())
            } else {
                "?".into()
            };

For each descriptor, the scan walks the IAT and the name table (ILT) in parallel. The ILT provides the function name or ordinal; the IAT holds the resolved address. If the ILT RVA is zero, which happens in some bound imports, the code falls back to using the IAT itself as the name source:

// From: detections/hooks.rs
            let name_tbl = if ilt_rva != 0 { ilt_rva } else { iat_rva };
            let mut iat_addr = m.base + iat_rva as usize;
            let mut name_addr = m.base + name_tbl as usize;
            let mut idx = 0u32;

            loop {
                let iat_val = match proc.read_ptr(iat_addr, pe.is64) {
                    Some(v) => v, None => break,
                };
                let name_val = match proc.read_ptr(name_addr, pe.is64) {
                    Some(v) => v, None => break,
                };

                if iat_val == 0 { break; }

                let func_name = if name_val != 0 && (name_val & ordinal_flag) == 0 {
                    let hint_addr = m.base + (name_val & 0x7FFF_FFFF) as usize + 2;
                    proc.read_cstring(hint_addr, 256)
                        .unwrap_or_else(|| format!("ordinal_{idx}"))
                } else {
                    format!("ordinal_{idx}")
                };

The + 2 skips past the hint index in the IMAGE_IMPORT_BY_NAME structure to reach the actual name string [1]. The & 0x7FFF_FFFF mask strips the upper bit for the 32-bit case as a defensive measure against malformed PEs.

Detecting IAT Hooks: Pointers Outside Module Ranges

The detection logic checks whether each resolved IAT address falls within the bounds of any known loaded module:

// From: detections/hooks.rs
                if !addr_in_modules(iat_val, &modules) {
                    results.push(IatHook {
                        module: m.name().to_string(),
                        imported_dll: dll_name.clone(),
                        function: func_name,
                        iat_value: iat_val,
                    });
                }

                iat_addr += ptr_size;
                name_addr += ptr_size;
                idx += 1;
            }

            desc += 20;
        }
    }

    Ok(results)
}

The addr_in_modules helper from pe.rs checks the address against every module’s [base, base + size) range:

// From: detections/pe.rs
pub fn addr_in_modules(addr: u64, modules: &[ModuleEntry]) -> bool {
    modules
        .iter()
        .any(|m| addr >= m.base as u64 && addr < (m.base + m.size) as u64)
}

If an IAT entry points to memory that is not backed by any loaded module, something has redirected that import. Legitimate function addresses always reside within a module’s mapped image [2]. An address pointing into a VirtualAlloc’d buffer, a manually mapped DLL, or shellcode on the heap is a strong indicator of hooking.

Each detection is collected into an IatHook struct identifying the affected module, the imported DLL, the function name, and the suspicious address:

// From: detections/hooks.rs
#[derive(Debug, Clone, Serialize)]
pub struct IatHook {
    pub module: String,
    pub imported_dll: String,
    pub function: String,
    pub iat_value: u64,
}

EAT Scanning: Validating Export Function Pointers

The EAT scan follows the same principle but operates on the export directory (data directory entry 0). The export directory structure contains the number of functions, the number of names, and RVAs for the function address array, name pointer array, and ordinal array [1]:

// From: detections/hooks.rs
pub fn check_eat_hooks(pid: u32) -> Result<Vec<EatHook>, String> {
    let proc = ProcessHandle::open(pid).ok_or("OpenProcess failed")?;
    let modules = proc.modules();
    let mut results = Vec::new();

    for m in &modules {
        let pe = match parse_pe_header(&proc, m.base) {
            Some(p) => p,
            None => continue,
        };

        let (export_rva, export_size) =
            match get_data_directory(&proc, &pe, IMAGE_DIRECTORY_ENTRY_EXPORT) {
                Some(d) => d,
                None => continue,
            };
        if export_rva == 0 || export_size == 0 {
            continue;
        }

        let ea = m.base + export_rva as usize;
        let num_funcs = match proc.read_u32(ea + 20) { Some(v) => v, None => continue };
        let num_names = match proc.read_u32(ea + 24) { Some(v) => v, None => continue };
        let fn_rva = match proc.read_u32(ea + 28) { Some(v) => v as usize, None => continue };
        let name_rva = match proc.read_u32(ea + 32) { Some(v) => v as usize, None => continue };
        let ord_rva = match proc.read_u32(ea + 36) { Some(v) => v as usize, None => continue };

Resolving Ordinals to Names for Diagnostics

Before checking function addresses, the scanner builds an ordinal-to-name map. The export directory uses a level of indirection: the name pointer array and ordinal array are parallel, so ordinal_array[i] gives the index into the function address array for the function named by name_pointer_array[i] [1]:

// From: detections/hooks.rs
        let mut ord_to_name = std::collections::HashMap::new();
        for i in 0..num_names as usize {
            if let (Some(nrva), Some(ord)) = (
                proc.read_u32(m.base + name_rva + i * 4),
                proc.read_u16(m.base + ord_rva + i * 2),
            ) {
                if let Some(name) = proc.read_cstring(m.base + nrva as usize, 128) {
                    ord_to_name.insert(ord as u32, name);
                }
            }
        }

Forwarded Exports vs. Actual Hooks

There is a PE subtlety the scanner must handle: forwarded exports. A forwarded export is one where the function RVA points into the export directory itself rather than to executable code. The bytes at that location are an ASCII string like "NTDLL.RtlAllocateHeap", and the loader resolves it by following the chain [1]. This is not a hook; it is the PE format’s equivalent of a symbolic link.

The code handles this by checking whether the RVA falls within [export_rva, export_rva + export_size) and skipping those entries:

// From: detections/hooks.rs
        for i in 0..num_funcs {
            let frva = match proc.read_u32(m.base + fn_rva + i as usize * 4) {
                Some(v) => v,
                None => continue,
            };
            if frva == 0 {
                continue;
            }

            let fname = ord_to_name
                .get(&i)
                .cloned()
                .unwrap_or_else(|| format!("ordinal_{i}"));

            // Skip forwarded exports: their RVA points inside the export directory
            if frva >= export_rva && frva < export_rva + export_size {
                continue;
            }

            let target = m.base as u64 + frva as u64;
            if target < m.base as u64 || target >= (m.base + m.size) as u64 {
                results.push(EatHook {
                    module: m.name().to_string(),
                    function: fname,
                    rva: frva,
                    target_addr: target,
                });
            }
        }

The detection condition: add the module base to the function RVA, and check whether the resulting absolute address falls within the module’s own [base, base + size) range. If it does not, someone has written an RVA into the EAT that points outside the module. That is a hook.

// From: detections/hooks.rs
#[derive(Debug, Clone, Serialize)]
pub struct EatHook {
    pub module: String,
    pub function: String,
    pub rva: u32,
    pub target_addr: u64,
}

The PE Parsing Infrastructure in pe.rs

Both scanners rely on shared infrastructure from pe.rs. The parse_pe_header function reads the DOS and NT headers from process memory, determines the architecture, and locates the data directory array:

// From: detections/pe.rs
pub fn parse_pe_header(proc: &ProcessHandle, base: usize) -> Option<PeInfo> {
    let dos = proc.read_memory(base, 0x40)?;
    if u16::from_le_bytes([dos[0], dos[1]]) != IMAGE_DOS_SIGNATURE {
        return None;
    }
    let lfanew = u32::from_le_bytes([dos[0x3C], dos[0x3D], dos[0x3E], dos[0x3F]]) as usize;
    let sig = proc.read_u32(base + lfanew)?;
    if sig != IMAGE_NT_SIGNATURE {
        return None;
    }

    let machine = proc.read_u16(base + lfanew + 4)?;
    let is64 = machine == IMAGE_FILE_MACHINE_AMD64;
    let opt_off = base + lfanew + 24;

    let (num_rva_off, data_dir_off) = if is64 {
        (opt_off + 108, opt_off + 112)
    } else {
        (opt_off + 92, opt_off + 96)
    };

    let num_rva = proc.read_u32(num_rva_off)?;
    Some(PeInfo { base, is64, data_dir_off, num_rva })
}

The get_data_directory function retrieves a specific directory entry by index:

// From: detections/pe.rs
pub fn get_data_directory(proc: &ProcessHandle, pe: &PeInfo, idx: u32) -> Option<(u32, u32)> {
    if idx >= pe.num_rva {
        return Some((0, 0));
    }
    let off = pe.data_dir_off + idx as usize * 8;
    let rva = proc.read_u32(off)?;
    let size = proc.read_u32(off + 4)?;
    Some((rva, size))
}

An important design point: parse_pe_header operates on live process memory via ReadProcessMemory [3], not on the disk file. This is deliberate. The scanner needs to see the in-memory state of the tables, which is where the hooks live.

What These Scans Catch and What They Miss

IAT scanning catches the common case where a cheat or injected DLL overwrites IAT entries to redirect API calls. For example, hooking NtQuerySystemInformation to hide processes, or GetAsyncKeyState to inject synthetic input.

EAT scanning catches the case where an attacker modifies a DLL’s export table so that future import resolutions get the attacker’s code rather than the real function.

Neither scan detects inline hooks, where the first few bytes of the actual function are overwritten with a jump to the hook handler. That is a separate detection, and one that the relocation-aware hashing from patch.rs partially addresses for code within .text.

There is also the question of false positives. Some legitimate software (security products, accessibility tools, managed runtimes) installs hooks that would trigger these checks. A production anti-cheat would maintain an allowlist of known modules and hook patterns. Peregrine’s scanner produces a report and leaves the verdict to the caller.


This post was generated by an LLM based on code from Peregrine Anti-Cheat. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.

References

[1] Microsoft, “PE Format,” PE/COFF Specification, https://learn.microsoft.com/en-us/windows/win32/debug/pe-format

[2] Microsoft, “Memory Protection Constants,” https://learn.microsoft.com/en-us/windows/win32/memory/memory-protection-constants

[3] Microsoft, “ReadProcessMemory function,” https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-readprocessmemory