Relocation-Aware Hashing: Detecting Code Patches
This post covers how Peregrine detects in-memory code patches by hashing the .text section of every loaded module and comparing it against the on-disk original. The core challenge is that the Windows loader rewrites parts of .text through base relocations [1], so a naive byte comparison produces false positives on virtually every module. Peregrine solves this by parsing the PE relocation directory, zeroing out every relocated byte in both copies, and then comparing SHA-256 digests.
Why Naive Hashing Produces False Positives
When the Windows loader maps a DLL at an address different from its preferred ImageBase, it walks the .reloc section and patches every absolute address reference in the image to account for the delta [1]. Many of those fixups land inside .text. These are legitimate modifications made by the OS itself, not by a cheat, but they change bytes. A straight hash comparison of disk vs. memory would flag every relocated module as tampered.
The fix is to identify every relocation site that falls within .text, zero those bytes out in both copies, and then hash. The relocated bytes carry no semantic information about tampering; they are purely a function of where the module happened to land in the address space.
The Relocation Directory: Block Structure and Entry Encoding
The PE relocation directory (data directory index 5) is a sequence of IMAGE_BASE_RELOCATION blocks. Each block covers a 4 KB page and contains an array of 16-bit entries, where the high 4 bits encode the relocation type and the low 12 bits encode the offset within the page [1].
The two relevant types:
| Type | Value | Meaning | Patch size |
|---|---|---|---|
IMAGE_REL_BASED_HIGHLOW | 3 | 32-bit absolute address | 4 bytes |
IMAGE_REL_BASED_DIR64 | 10 | 64-bit absolute address | 8 bytes |
Type 0 (IMAGE_REL_BASED_ABSOLUTE) is padding and gets skipped. Everything else is uncommon in modern Windows binaries.
Parsing the On-Disk PE: parse_pe_from_disk
The function parse_pe_from_disk in patch.rs takes raw file bytes, locates the .text section header and the relocation directory, and produces a list of (offset_within_text, patch_size) tuples describing every byte in .text that the loader is expected to modify.
It starts by validating the DOS and NT signatures, determining the machine type (to select 4-byte or 8-byte relocation patches), and locating the section table:
// From: detections/patch.rs
fn parse_pe_from_disk(file: &[u8]) -> Option<TextInfo> {
if file.len() < 0x40 {
return None;
}
let magic = u16::from_le_bytes([file[0], file[1]]);
if magic != IMAGE_DOS_SIGNATURE {
return None;
}
let lfanew = u32::from_le_bytes(file[0x3C..0x40].try_into().ok()?) as usize;
if lfanew + 4 > file.len() {
return None;
}
let sig = u32::from_le_bytes(file[lfanew..lfanew + 4].try_into().ok()?);
if sig != IMAGE_NT_SIGNATURE {
return None;
}
let machine = u16::from_le_bytes(file[lfanew + 4..lfanew + 6].try_into().ok()?);
let is64 = machine == IMAGE_FILE_MACHINE_AMD64;
let reloc_patch_size = if is64 { 8 } else { 4 };
let num_sec = u16::from_le_bytes(file[lfanew + 6..lfanew + 8].try_into().ok()?);
let opt_hdr_size =
u16::from_le_bytes(file[lfanew + 20..lfanew + 22].try_into().ok()?) as usize;
let opt_off = lfanew + 24;
let sec_off = opt_off + opt_hdr_size;
// ...
Next, it iterates the section table looking for .text:
// From: detections/patch.rs
for i in 0..num_sec as usize {
let so = sec_off + i * 40;
if so + 40 > file.len() {
break;
}
let name = &file[so..so + 8];
let end = name.iter().position(|&b| b == 0).unwrap_or(8);
if &name[..end] == b".text" {
text_virt_size = u32::from_le_bytes(file[so + 8..so + 12].try_into().ok()?);
text_rva = u32::from_le_bytes(file[so + 12..so + 16].try_into().ok()?);
text_raw_size = u32::from_le_bytes(file[so + 16..so + 20].try_into().ok()?);
text_raw = u32::from_le_bytes(file[so + 20..so + 24].try_into().ok()?);
text_virt_size = text_virt_size.max(text_raw_size);
found = true;
break;
}
}
The text_virt_size = text_virt_size.max(text_raw_size) line is a defensive measure. Some PE files have a virtual size smaller than the raw size on disk; taking the maximum ensures the full section content is considered.
Walking the Relocation Blocks
With the .text boundaries known, the function locates data directory entry 5, converts its RVA to a raw file offset via rva_to_raw, and walks the block chain. For each entry whose type is 3 or 10 and whose target RVA falls within .text, it records the offset and patch size:
// From: detections/patch.rs
let reloc_dir_idx = 5usize;
let num_rva_off = if is64 { opt_off + 108 } else { opt_off + 92 };
let dd_off = if is64 { opt_off + 112 } else { opt_off + 96 };
if num_rva_off + 4 <= file.len() {
let num_rva = u32::from_le_bytes(
file[num_rva_off..num_rva_off + 4].try_into().ok()?
) as usize;
if num_rva > reloc_dir_idx {
let rd = dd_off + reloc_dir_idx * 8;
if rd + 8 <= file.len() {
let reloc_rva = u32::from_le_bytes(file[rd..rd + 4].try_into().ok()?);
let reloc_size = u32::from_le_bytes(file[rd + 4..rd + 8].try_into().ok()?);
if reloc_rva != 0 && reloc_size != 0 {
if let Some(reloc_raw) = rva_to_raw(file, sec_off, num_sec, reloc_rva) {
let mut pos = reloc_raw;
let end = reloc_raw + reloc_size as usize;
let text_end = text_rva + text_virt_size;
while pos + 8 <= end && pos + 8 <= file.len() {
let brva =
u32::from_le_bytes(file[pos..pos + 4].try_into().ok()?);
let bsz =
u32::from_le_bytes(file[pos + 4..pos + 8].try_into().ok()?);
if bsz < 8 {
break;
}
let n = (bsz as usize - 8) / 2;
for j in 0..n {
let eoff = pos + 8 + j * 2;
if eoff + 2 > file.len() {
break;
}
let entry = u16::from_le_bytes(
file[eoff..eoff + 2].try_into().ok()?
);
let rtype = entry >> 12;
let offset = (entry & 0xFFF) as u32;
if rtype == 3 || rtype == 10 {
let rrva = brva + offset;
if rrva >= text_rva && rrva < text_end {
reloc_offsets.push((
(rrva - text_rva) as usize,
reloc_patch_size,
));
}
}
}
pos += bsz as usize;
}
}
}
}
}
}
Each block starts with an 8-byte header: a 4-byte VirtualAddress (the page RVA) and a 4-byte SizeOfBlock. The entries follow immediately after. The bsz < 8 guard terminates the walk on malformed blocks, preventing infinite loops.
The output is a TextInfo struct carrying the .text section geometry and the collected relocation offsets:
// From: detections/patch.rs
struct TextInfo {
text_rva: u32,
text_raw: u32,
text_raw_size: u32,
text_virt_size: u32,
reloc_offsets: Vec<(usize, usize)>,
}
RVA-to-Raw Offset Conversion
The helper rva_to_raw converts a relative virtual address to its position in the on-disk file by scanning the section table for the section that contains the RVA:
// From: detections/patch.rs
fn rva_to_raw(file: &[u8], sec_off: usize, num_sec: u16, rva: u32) -> Option<usize> {
for i in 0..num_sec as usize {
let off = sec_off + i * 40;
if off + 40 > file.len() {
break;
}
let va = u32::from_le_bytes(file[off + 12..off + 16].try_into().ok()?);
let raw_sz = u32::from_le_bytes(file[off + 16..off + 20].try_into().ok()?);
let raw_ptr = u32::from_le_bytes(file[off + 20..off + 24].try_into().ok()?);
if rva >= va && rva < va + raw_sz {
return Some((raw_ptr + (rva - va)) as usize);
}
}
None
}
This is standard PE parsing. The section’s VirtualAddress, SizeOfRawData, and PointerToRawData fields determine where in the file a given RVA maps to [1].
Zeroing Relocation Sites and Comparing Digests
With the relocation map built, both the disk and memory copies of .text have the relocation sites zeroed out by the same function:
// From: detections/patch.rs
fn zero_relocs(data: &mut [u8], offsets: &[(usize, usize)]) {
for &(off, sz) in offsets {
for k in 0..sz {
if off + k < data.len() {
data[off + k] = 0;
}
}
}
}
Then each buffer gets SHA-256’d and the hex digests are compared:
// From: detections/patch.rs
fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
The Full Module Scan: check_process_modules
The public function check_process_modules ties everything together. It opens the target process, enumerates loaded modules via EnumProcessModulesEx [2], and for each module performs the disk-vs-memory comparison:
// From: detections/patch.rs
pub fn check_process_modules(pid: u32) -> Result<Vec<ModuleCheck>, String> {
let proc = ProcessHandle::open(pid).ok_or("OpenProcess failed")?;
let modules = proc.modules();
let mut results = Vec::new();
let max_bytes = 8 * 1024 * 1024;
for m in &modules {
// ... build ModuleCheck entry ...
let disk_path = wow64_fixup(&m.path, m.base);
let file_bytes = match std::fs::read(&disk_path) {
Ok(b) => b,
Err(e) => { /* record error, continue */ }
};
let ti = match parse_pe_from_disk(&file_bytes) {
Some(t) => t,
None => { /* record error, continue */ }
};
let read_sz = (ti.text_virt_size as usize)
.min(ti.text_raw_size as usize)
.min(max_bytes);
let mut disk_data = file_bytes[raw..raw + read_sz].to_vec();
let mut mem_data = match proc.read_memory(mem_addr, read_sz) {
Some(d) => d,
None => { /* record error, continue */ }
};
zero_relocs(&mut disk_data, &ti.reloc_offsets);
zero_relocs(&mut mem_data, &ti.reloc_offsets);
let dh = sha256_hex(&disk_data);
let mh = sha256_hex(&mem_data);
entry.matched = Some(dh == mh);
// ...
}
Ok(results)
}
Several details are worth noting:
- The
max_bytescap of 8 MB prevents attempting to read enormous.textsections in a single shot. - The
read_sztakes the minimum of virtual and raw sizes, ensuring the comparison covers only bytes that exist in both the disk file and memory. - Each module’s result is recorded regardless of whether comparison succeeded, with an
errorfield capturing any failures along the way.
WOW64 Path Fixup
The wow64_fixup function handles a classic Windows gotcha. When a 32-bit (WOW64) process has modules whose paths point to System32, the actual binaries live in SysWOW64 [3]. Without this correction, the scanner would hash the wrong (64-bit) file:
// From: detections/patch.rs
fn wow64_fixup(disk_path: &str, base: usize) -> String {
if base >= 0x1_0000_0000 {
return disk_path.to_string();
}
let sys_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
let sys32 = format!(r"{}\System32", sys_root);
if disk_path.to_lowercase().starts_with(&sys32.to_lowercase()) {
let rest = &disk_path[sys32.len()..];
let wow = format!(r"{}\SysWOW64{}", sys_root, rest);
if Path::new(&wow).exists() {
return wow;
}
}
disk_path.to_string()
}
The heuristic is simple: if the module’s base address is below the 4 GB boundary (indicating a 32-bit module) and the path contains System32, the function checks whether a corresponding SysWOW64 file exists and redirects accordingly.
The Per-Module Result Structure
Each module produces a ModuleCheck entry reporting the module path, both SHA-256 digests, and whether they matched:
// From: detections/patch.rs
#[derive(Debug, Clone, Serialize)]
pub struct ModuleCheck {
pub path: String,
pub base: u64,
pub size: u32,
pub section: Option<String>,
pub section_size: Option<usize>,
pub mem_sha256: Option<String>,
pub disk_sha256: Option<String>,
pub matched: Option<bool>,
pub error: Option<String>,
}
If matched is false for any module, that module’s .text section has been modified in memory by something other than the Windows loader. NOP’d damage calculations, flipped conditional jumps, injected trampolines: they all show up here.
The Process Abstraction in pe.rs
The underlying process access comes from pe.rs, which wraps Windows APIs behind a safe ProcessHandle type. The handle is opened with PROCESS_QUERY_INFORMATION and PROCESS_VM_READ [4]:
// From: detections/pe.rs
pub struct ProcessHandle(pub HANDLE);
impl ProcessHandle {
pub fn open(pid: u32) -> Option<Self> {
let h = unsafe {
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid)
}.ok()?;
if h == INVALID_HANDLE_VALUE { None } else { Some(Self(h)) }
}
pub fn read_memory(&self, addr: usize, size: usize) -> Option<Vec<u8>> {
let mut buf = vec![0u8; size];
let mut read = 0usize;
let ok = unsafe {
ReadProcessMemory(self.0, addr as *const _, buf.as_mut_ptr() as *mut _, size, Some(&mut read))
};
if ok.is_err() || read == 0 { return None; }
buf.truncate(read);
Some(buf)
}
// ...
}
Module enumeration uses EnumProcessModulesEx with LIST_MODULES_ALL to capture both native and WOW64 modules, then GetModuleInformation and GetModuleFileNameExW for each [2]. The Drop implementation ensures the handle is always closed.
Limitations Worth Naming
This approach has several known edges:
- Only
.textis checked. A cheat that patches.rdataor injects a new section entirely would not be caught by this scan. - The disk file is trusted. A cheat that also patches the on-disk binary, or that performs a TOCTOU race between the file read and the hash, could evade detection.
- Unusual section layouts. Modules compiled with incremental linking or non-standard section names might not contain all executable code in
.text. - Relocation-only coverage. Other loader modifications (such as bound import optimizations) are not accounted for, though these are rare in practice on modern Windows.
These are solvable problems, but they require additional detection layers. Peregrine treats this scan as one signal among several rather than a standalone verdict.
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: The .reloc Section (Image Only),” PE/COFF Specification, https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#the-reloc-section-image-only
[2] Microsoft, “EnumProcessModulesEx function,” https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-enumprocessmodulesex
[3] Microsoft, “File System Redirector,” https://learn.microsoft.com/en-us/windows/win32/winprog64/file-system-redirector
[4] Microsoft, “Process Security and Access Rights,” https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights