From 3f6bf6a68645a058e668fc0f762f3918a2d268f9 Mon Sep 17 00:00:00 2001 From: Quang Trinh Date: Sun, 19 Apr 2026 07:00:40 +0700 Subject: [PATCH] fix(scanner): correct offset semantics docs and harden find_pattern - Hoist empty/all-wildcard validation out of internal primitive so warnings log once per public call, not per region or per occurrence. - Use unsigned modular arithmetic in resolve_rip_relative to avoid signed pointer overflow UB. - Correct docs/misc/aob-signatures.md: find_pattern applies pattern.offset internally; remove misleading manual-add guidance. - Add regression tests for offset-at-end, all-wildcard Nth occurrence, zero/empty/null guards, and RIP-relative wraparound contract. --- AGENTS.md | 2 +- README.md | 4 +- docs/misc/aob-signatures.md | 488 +++++++++++++++++++++++++++++++ include/DetourModKit/scanner.hpp | 103 +++++-- src/scanner.cpp | 322 +++++++++++++------- tests/test_scanner.cpp | 458 ++++++++++++++++++++++++++++- 6 files changed, 1239 insertions(+), 138 deletions(-) create mode 100644 docs/misc/aob-signatures.md diff --git a/AGENTS.md b/AGENTS.md index 66109da..c91a392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,7 @@ dispatcher.emit_safe(PlayerStateChanged{.health = player->health}); - **Concurrency tests:** Use `std::atomic stop` flag pattern with multiple threads. See `AsyncMode_ConcurrentLogAndDisable` in `test_logger.cpp` for the reference pattern. - **Build flag:** Tests are enabled with `DMK_BUILD_TESTS=ON` (on by default in debug presets). -For detailed coverage analysis, see [docs/tests/README.md](docs/tests/README.md). For hot-reload testing patterns, see [docs/hot-reload/README.md](docs/hot-reload/README.md). +For detailed coverage analysis, see [docs/tests/README.md](docs/tests/README.md). For hot-reload testing patterns, see [docs/hot-reload/README.md](docs/hot-reload/README.md). For AOB signature construction, the Scanner API, and RIP-relative resolution, see [docs/misc/aob-signatures.md](docs/misc/aob-signatures.md). After any code change, build and run the full test suite before committing: diff --git a/README.md b/README.md index 9f504d5..23d1dc8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ DetourModKit is a lightweight C++ toolkit designed to simplify common tasks in g - `|` offset markers for targeting a specific instruction within a wider pattern (e.g., `"48 8B 88 B8 00 00 00 | 48 89 4C 24 68"` sets the offset to byte 7) - Nth-occurrence matching (1-based) for patterns that hit multiple locations - RIP-relative instruction resolution for extracting absolute addresses from x86-64 code (returns `std::expected` with typed `RipResolveError` for actionable diagnostics) -- `scan_executable_regions()` for scanning all committed executable pages in the process - useful for games with packed or protected binaries that unpack code into anonymous memory outside any loaded module +- `scan_executable_regions()` for scanning all committed executable pages in the process - useful for games with packed or protected binaries that unpack code into anonymous memory outside any loaded module (pure-execute pages without a read bit are skipped to avoid access violations) @@ -187,6 +187,7 @@ For detailed coverage analysis and test architecture, see the [Test Coverage Gui ## Guides +* [AOB Signature Scanning Guide](docs/misc/aob-signatures.md) - Pattern syntax, RIP-relative resolution, and patch-proof signature practices * [Hot-Reload Development Guide](docs/hot-reload/README.md) - Development workflow for iterating on hooks with live reload * [Test Coverage Guide](docs/tests/README.md) - Coverage analysis, test architecture, and module-level breakdown @@ -865,6 +866,7 @@ For practical reference and real-world usage examples: * **KCD1-TPVToggle**: [https://github.com/tkhquang/KCD1Tools/tree/main/TPVToggle](https://github.com/tkhquang/KCD1Tools/tree/main/TPVToggle) * **KCD2-TPVToggle**: [https://github.com/tkhquang/KCD2Tools/tree/main/TPVToggle](https://github.com/tkhquang/KCD2Tools/tree/main/TPVToggle) * **CrimsonDesert-EquipHide**: [https://github.com/tkhquang/CrimsonDesertTools/tree/main/CrimsonDesertEquipHide](https://github.com/tkhquang/CrimsonDesertTools/tree/main/CrimsonDesertEquipHide) +* **CrimsonDesert-LiveTransmog**: [https://github.com/tkhquang/CrimsonDesertTools/tree/main/CrimsonDesertLiveTransmog](https://github.com/tkhquang/CrimsonDesertTools/tree/main/CrimsonDesertLiveTransmog) ## Acknowledgements diff --git a/docs/misc/aob-signatures.md b/docs/misc/aob-signatures.md new file mode 100644 index 0000000..c8bc91e --- /dev/null +++ b/docs/misc/aob-signatures.md @@ -0,0 +1,488 @@ +# AOB Signature Scanning Guide + +Practical reference for building, maintaining, and resolving array-of-bytes (AOB) signatures with DetourModKit's `Scanner` module. Written for humans first, but structured so LLM tools can pick specific sections cleanly. + +## Contents + +1. [Background: what an AOB is and why](#1-background-what-an-aob-is-and-why) +2. [How to find a patch-proof signature](#2-how-to-find-a-patch-proof-signature) +3. [DMK pattern syntax reference](#3-dmk-pattern-syntax-reference) +4. [Scanner API tour](#4-scanner-api-tour) +5. [RIP-relative resolution](#5-rip-relative-resolution) +6. [Patch-proof patterns (cache, fallback, verify)](#6-patch-proof-patterns-cache-fallback-verify) +7. [Worked examples](#7-worked-examples) +8. [DOs and DON'Ts](#8-dos-and-donts) +9. [Troubleshooting](#9-troubleshooting) +10. [Further reading](#10-further-reading) + +--- + +## 1. Background: what an AOB is and why + +An **AOB** (array of bytes, also called a **signature** or **sigscan**) is a short byte sequence picked from the `.text` section of a target binary that uniquely identifies an assembly instruction (or small run of instructions) at runtime. Tools like DMK's `Scanner` walk memory looking for that sequence and return the matching address. + +Why it matters for modding: + +- Module bases change every process launch on Windows (ASLR) and absolute offsets change with every compiler build. A hard-coded RVA fails the next patch day. +- Signatures bind to the **instruction semantics**, not to the binary layout. Good signatures survive many patches; great ones survive entire major version bumps. +- Once an AOB locates the instruction, DMK's hook manager or an `std::expected` RIP resolver turns it into an absolute address you can hook, read, or call. + +Two rules set the ceiling on signature quality: + +- **Sign CODE, not DATA.** Assembly instructions move when code recompiles, but compilers reshuffle *order* much more often than they change *opcodes* for the same source line. Data tables (strings, vtables, constants) move even more aggressively and are a poor anchor. +- **Wildcard anything the compiler or linker can move.** Immediate values, RIP-relative displacements, jump targets, RVAs, vtable offsets, register indices inside VEX prefixes: all of these are the normal suspects. + +## 2. How to find a patch-proof signature + +A patch-proof signature is short, unique, and contains only bytes that describe **opcodes and register encodings**, with wildcards covering everything the linker or compiler is free to renumber. + +### 2.1 Workflow in IDA / Ghidra / x64dbg + +1. **Locate the instruction you want to hook.** Prefer a load/store whose target is the value you care about, or the first instruction of a function whose prologue is distinctive. +2. **Copy the raw bytes** of 12 to 32 bytes around it (enough to span 3 to 6 instructions). +3. **Wildcard volatile operands.** For each instruction in the window: + - Wildcard all immediate operands (8-, 16-, 32-, 64-bit) and RIP-relative displacements (`disp32`) with `??` tokens covering each byte. + - Replace RVAs and jmp/call targets with `??`s. + - Keep opcodes, ModRM bytes, REX prefixes, register selectors. +4. **Shrink.** Start with the minimum that returns a single hit in the target module and grow one instruction at a time if duplicates appear. +5. **Validate against at least three game versions or builds**, ideally including one version you know was compiled differently. Signatures that only survive one build are brittle by construction. + +> Platform scope: this guide assumes Windows x64 (module base resolution via the PE loader, RIP-relative `disp32` encoding, and the `PAGE_EXECUTE_*` protection flags enforced by `VirtualQuery`). On 32-bit x86 the displacement forms, prefix tables, and ABI details differ; on non-Windows targets the page-protection taxonomy and module enumeration APIs are entirely different. The Scanner API itself is pure C++23 and may work elsewhere, but the worked examples below have only been exercised on Windows x64. + +### 2.2 Byte-by-byte anatomy of a good signature + +Given the instruction + +```text +; 7 bytes total +48 8B 05 ?? ?? ?? ?? mov rax, [rip + ] +``` + +`48` is the REX.W prefix (64-bit operand), `8B` is `MOV r64, r/m64`, `05` is the ModRM byte encoding `rax, [rip + disp32]`, and the next four bytes are a `disp32` that the linker recomputes every build. Wildcarding just those four bytes gives you a 7-byte signature that works across almost every rebuild unless someone changes the target register or replaces the instruction. + +If you need higher uniqueness, chain one or two adjacent instructions: + +```text +48 8B 05 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? +; mov rax, [rip+disp32] +; test rax, rax +; je rel32 +``` + +That chain is distinctive without committing to any of the shifting fields. + +### 2.3 Anchoring rules of thumb + +| Situation | What to do | +| --------- | ---------- | +| Signature returns multiple hits | Add bytes forward or backward, or add a unique neighbouring instruction; don't just make it longer with more wildcards | +| Signature uses a static address | Wildcard the disp32/imm and widen with a neighbour; never bake an address into the signature body | +| Only one copy in the file but spans padding | Watch for `CC`/`90` alignment bytes: linkers rebalance padding, so don't cross those boundaries | +| Function inlined differently between builds | Move the anchor to the callee, or pick a caller whose prologue is still unique | +| Anti-tamper or packer rewrites bytes | Use `scan_executable_regions()` (searches anonymous executable pages) and plan on a multi-candidate fallback | + +## 3. DMK pattern syntax reference + +Parsed by `Scanner::parse_aob(std::string_view)` ([include/DetourModKit/scanner.hpp](../../include/DetourModKit/scanner.hpp)). Tokens are split on whitespace (space, tab, `\r`, `\n`, `\f`, `\v`). Leading or trailing whitespace is ignored. + +| Token | Meaning | +| ----- | ------- | +| `48`, `8B`, `FF` | Literal byte. Must be exactly two hex digits. Case-insensitive. | +| `??` | Wildcard byte: any value matches at this position. | +| `?` | Same as `??`. Accepted for brevity. | +| `\|` | Offset marker: records the byte position of the next token as the "point of interest", or the position one past the last byte (`offset == bytes.size()`) if placed at the very end of the pattern. Stored on `CompiledPattern::offset`. Cannot appear more than once. | + +`parse_aob` returns `std::nullopt` on any malformed token (e.g. `"GG"`, `"1FF"`, three-character tokens, a second `|`) and logs an error through the shared Logger. A malformed token surfaces as `AOB Parser: Invalid token '' at position . Expected hex byte (e.g., FF), '?', or '??'.`. Empty or whitespace-only input is treated as a parse failure. + +Example with an offset marker: + +```text +"48 8B 88 B8 00 00 00 | 48 89 4C 24 68" +``` + +The `|` sits after seven literal bytes, so `CompiledPattern::offset == 7`. This lets you anchor on a wide distinctive window while hooking the second instruction in the chain. + +## 4. Scanner API tour + +Public namespace: `DetourModKit::Scanner`. Errors are returned, never thrown. + +### 4.1 Parse once, scan many + +`parse_aob()` allocates two small vectors and copies your bytes. It's fine to call it once at startup and reuse the result. + +```cpp +#include + +namespace dmk = DetourModKit; +namespace sc = dmk::Scanner; + +const auto pattern = sc::parse_aob("48 8B 05 ?? ?? ?? ?? 48 85 C0"); +if (!pattern) +{ + // malformed string; parse_aob already logged the reason + return false; +} +``` + +### 4.2 Scanning a module range + +Pass a module base and size: + +```cpp +const HMODULE h = ::GetModuleHandleW(L"game.exe"); +MODULEINFO mi{}; +::GetModuleInformation(::GetCurrentProcess(), h, &mi, sizeof(mi)); + +const auto* match = sc::find_pattern( + static_cast(mi.lpBaseOfDll), + mi.SizeOfImage, + *pattern); +if (!match) +{ + return false; +} + +// match already points at the `|`-marked byte (or at the pattern start when no +// `|` marker is present). find_pattern applies pattern->offset internally, so +// do NOT add it yourself; doing so double-applies and walks past the target. +const auto* target = match; +``` + +### 4.3 Nth occurrence + +`find_pattern` has a second overload that returns the Nth hit (1-based). Passing `0` returns `nullptr` by contract. + +```cpp +const auto* third = sc::find_pattern(base, size, *pattern, 3); +``` + +### 4.4 Process-wide scan + +When the target binary is packed, decrypted into anonymous executable pages, or you don't know which module owns the code yet, use `scan_executable_regions()`. It walks `VirtualQuery` and scans every committed `PAGE_EXECUTE_READ*` region that isn't a guard page. + +```cpp +const auto* match = sc::scan_executable_regions(*pattern); +``` + +The function accepts an optional `occurrence` parameter (1-based) for Nth-match semantics; it defaults to `1` and applies `pattern.offset` to the returned pointer. + +Pure-execute pages (`PAGE_EXECUTE` with no read bit) are skipped deliberately: such pages are not guaranteed readable and feeding them to `find_pattern` would raise an access violation. Only `PAGE_EXECUTE_READ`, `PAGE_EXECUTE_READWRITE`, and `PAGE_EXECUTE_WRITECOPY` regions are inspected; guard and no-access pages are skipped unconditionally. + +Note: both `find_pattern()` and `scan_executable_regions()` apply `pattern.offset` to the returned pointer. Callers must never add `pattern->offset` manually on top of the return value; doing so double-applies the offset and walks past the intended byte. + +> Do not scan on the render thread. A full-module sweep can run into the tens of milliseconds; a process-wide walk (`scan_executable_regions`) can exceed an entire frame budget on a large game. Resolve signatures at startup, during a loading screen, or on a background worker, and cache the resulting addresses. + +### 4.5 SIMD tier + +```cpp +switch (sc::active_simd_level()) +{ +case sc::SimdLevel::Avx2: /* 32 bytes per iteration */ break; +case sc::SimdLevel::Sse2: /* 16 bytes per iteration */ break; +case sc::SimdLevel::Scalar: /* byte-by-byte fallback */ break; +} +``` + +Useful for logging and for deciding whether a large scan should run during boot or be deferred. + +### 4.6 Anchor heuristic and why sparse bytes scan faster + +Internally `find_pattern` does NOT scan byte-by-byte from the start of the pattern. It inspects every non-wildcard byte in the pattern, scores each against a small frequency table (`0x00`, `0xCC`, `0x90`, `0xFF`, `0x48`, `0x8B`, `0x0F`, ... in rough order of "how often this byte appears in typical x64 `.text`"), and picks the rarest one as the anchor. The anchor byte drives a `memchr` sweep; the full pattern is only verified at positions where `memchr` finds the anchor. + +Implication: a pattern whose literal bytes are all REX prefixes / common opcodes (`48 8B`, `48 89`, `FF 15`, `E8 ?? ?? ?? ??`) forces the scanner to verify at almost every address. Add at least one uncommon byte (any byte outside the frequency table) and the scan typically drops from tens of milliseconds to sub-millisecond on a full-module sweep. If you have a choice between two otherwise equivalent anchors, pick the one containing a rarer byte. + +## 5. RIP-relative resolution + +x86-64 code uses RIP-relative addressing heavily. The 4-byte displacement stored inside the instruction is relative to the address of the *next* instruction: `target = instruction_address + instruction_length + disp32`. DMK exposes two helpers and a set of prefix constants. + +### 5.1 Two-step: find the match, then resolve + +Best when the instruction is part of a wider signature, or when the disp32 is not at the end (e.g. the instruction has an immediate suffix). + +```cpp +const auto* hit = sc::find_pattern(base, size, *pattern); +if (!hit) + return false; + +// Suppose the matched instruction is `mov rax, [rip+disp32]` (7 bytes, disp32 at offset 3). +const auto resolved = sc::resolve_rip_relative(hit, /*disp_offset=*/3, /*instr_len=*/7); +if (!resolved) +{ + dmk::Logger::get_instance().error( + "RIP resolve failed: {}", + dmk::rip_resolve_error_to_string(resolved.error())); + return false; +} + +const uintptr_t absolute = *resolved; +``` + +Error values (`RipResolveError`): + +| Error | Meaning | +| ----- | ------- | +| `NullInput` | `instruction_address` or `search_start` was null | +| `PrefixNotFound` | (find-and-resolve only) no match within `search_length` | +| `RegionTooSmall` | (find-and-resolve only) `search_length < prefix_len + 4` | +| `UnreadableDisplacement` | disp32 bytes failed `Memory::is_readable()` | + +### 5.2 One-step: find the prefix and resolve in the same call + +Best when the opcode you want to hook has its disp32 **immediately after the prefix you supply** (e.g. `E8 disp32`, `E9 disp32`, `48 8B 05 disp32`). DMK ships ready-made prefix constants in `scanner.hpp`: + +| Constant | Bytes | Encodes | +| -------- | ----- | ------- | +| `PREFIX_CALL_REL32` | `E8` | `call rel32` | +| `PREFIX_JMP_REL32` | `E9` | `jmp rel32` | +| `PREFIX_MOV_RAX_RIP` | `48 8B 05` | `mov rax, [rip+disp32]` | +| `PREFIX_MOV_RCX_RIP` | `48 8B 0D` | `mov rcx, [rip+disp32]` | +| `PREFIX_MOV_RDX_RIP` | `48 8B 15` | `mov rdx, [rip+disp32]` | +| `PREFIX_MOV_RBX_RIP` | `48 8B 1D` | `mov rbx, [rip+disp32]` | +| `PREFIX_LEA_RAX_RIP` | `48 8D 05` | `lea rax, [rip+disp32]` | +| `PREFIX_LEA_RCX_RIP` | `48 8D 0D` | `lea rcx, [rip+disp32]` | +| `PREFIX_LEA_RDX_RIP` | `48 8D 15` | `lea rdx, [rip+disp32]` | + +Example: + +```cpp +const auto resolved = sc::find_and_resolve_rip_relative( + hit, // start of a short search window + /*search_length=*/64, + sc::PREFIX_CALL_REL32, // E8 + /*instruction_length=*/5); // E8 + disp32 +``` + +### 5.3 What these helpers will not resolve + +`resolve_rip_relative` deliberately understands only the 32-bit signed displacement form. The following need manual handling: + +- Short jumps (`EB rel8`, `Jcc rel8`) with 8-bit displacements. +- 16-bit displacements and legacy `EA ptr16:32` far jumps. +- Indirect calls through memory: `FF 15 disp32` and `FF 25 disp32`. The disp32 points to a **pointer**; DMK returns the pointer's address, not the final target. Dereference it yourself. +- Instructions where the disp32 is interrupted by a SIB byte combination or a VEX/EVEX prefix boundary: supply your own longer `opcode_prefix` that covers up to the disp32 start. + +## 6. Patch-proof patterns (cache, fallback, verify) + +The raw Scanner API is intentionally low-level. Anything beyond a single call-site benefits from a thin layer above it. Below are patterns battle-tested in consumer projects. + +### 6.1 Cache the `CompiledPattern` + +`parse_aob` is cheap but not free. If you scan repeatedly (hot-reload, re-scan after a level load, fallback between candidates), parse once and hold the `CompiledPattern` in a static or a class member: + +```cpp +struct AobCandidate +{ + const char* name; // "player_ctx_v1" + const char* pattern; + std::ptrdiff_t offset_to_hook = 0; +}; + +struct CompiledCandidate +{ + const AobCandidate* source; + DetourModKit::Scanner::CompiledPattern compiled; +}; + +// Compile once at startup: +std::vector compile_all(std::span raw) +{ + std::vector out; + out.reserve(raw.size()); + for (const auto& c : raw) + { + if (auto parsed = DetourModKit::Scanner::parse_aob(c.pattern)) + { + out.push_back({&c, std::move(*parsed)}); + } + } + return out; +} +``` + +### 6.2 Multi-candidate fallback + +For a single logical hook, ship two or three signatures: one tight one for the current build, one wider one for the previous build, and a generic one as a safety net. Try them in order, stop on the first hit, log which one won. + +```cpp +uintptr_t resolve_first_hit( + std::span candidates, + const std::byte* base, std::size_t size, + const AobCandidate** matched_out) +{ + for (const auto& c : candidates) + { + const auto* hit = DetourModKit::Scanner::find_pattern(base, size, c.compiled); + if (hit) + { + if (matched_out) *matched_out = c.source; + return reinterpret_cast(hit) + c.source->offset_to_hook; + } + } + return 0; +} +``` + +### 6.3 Verify after match + +A lone signature hit is necessary but not sufficient. Two lightweight checks catch the overwhelming majority of mis-hits: + +- **First-byte sanity check.** A function prologue does not start with `0x00`, `0xC2`, `0xC3`, or (usually) `0xCC`. Reject obvious garbage before you hand the address to SafetyHook. +- **`Memory::is_readable()` guard.** Confirm the resolved address is inside a committed page with an expected protection flag before dereferencing or hooking. + +```cpp +bool looks_like_prologue(const std::byte* addr) +{ + if (!DetourModKit::Memory::is_readable(addr, 1)) + return false; + + const auto b = static_cast(*addr); + return b != 0x00 && b != 0xC2 && b != 0xC3 && b != 0xCC; +} +``` + +### 6.4 Negative offsets + +DMK's helpers assume you want to land *on* the match. Real-world hooks sometimes want to step backward from the match, for example to arrive at the function start after anchoring on a later landmark. Store a signed offset per candidate and apply it after the match succeeds: + +```cpp +struct AddrCandidate +{ + const char* name; + const char* pattern; + std::ptrdiff_t disp_offset; // negative allowed +}; + +const auto parsed = sc::parse_aob(candidate.pattern); +if (!parsed) return 0; + +const auto* hit = sc::find_pattern(base, size, *parsed); +if (!hit) return 0; + +const auto* target = hit + candidate.disp_offset; // may walk backwards +``` + +### 6.5 Name every candidate + +Anonymous signatures make regressions unreadable. Attach a human-friendly label to every candidate (`"player_ctx_load_v1"`, `"fire_weapon_v2_backcompat"`). Log that label when a hit is found or when all candidates fail. It pays for itself the first time a patch breaks one of thirty signatures. + +## 7. Worked examples + +### 7.1 Hook a direct `call rel32` + +```cpp +const auto pattern = sc::parse_aob("E8 ?? ?? ?? ?? 48 89 43 10"); +if (!pattern) return; + +const auto* hit = sc::find_pattern(module_base, module_size, *pattern); +if (!hit) return; + +// hit points at 0xE8; the full call is 5 bytes with disp32 at offset 1. +const auto target = sc::resolve_rip_relative(hit, /*disp_offset=*/1, /*instr_len=*/5); +if (!target) return; + +hook_mgr.create_inline_hook("callee_hook", *target, &Detour_Callee, + reinterpret_cast(&g_callee_orig), {}); +``` + +If your pattern embeds a `|` marker, `find_pattern` has already applied `pattern->offset` to `hit`: pass `hit` directly to `resolve_rip_relative`. Adding `pattern->offset` again would double-apply and advance past the opcode. + +### 7.2 Resolve a global pointer via `mov rax, [rip+disp32]` + +```cpp +// Search 64 bytes from the match for the mov, then resolve. +const auto ptr_addr = sc::find_and_resolve_rip_relative( + hit, 64, sc::PREFIX_MOV_RAX_RIP, /*instr_len=*/7); +if (!ptr_addr) +{ + logger.error("mov rax, [rip+disp32] not found: {}", + dmk::rip_resolve_error_to_string(ptr_addr.error())); + return; +} + +// ptr_addr is the absolute address of the pointer slot, not the pointee. +auto global_ptr = dmk::Memory::read_ptr_unsafe( + reinterpret_cast(*ptr_addr)); +``` + +If `hit` came from a pattern with a `|` offset marker, `find_pattern` has already applied the offset, so `hit` already points at the marked byte: pass it directly. Adding `pattern->offset` would double-apply and start the search window past the intended opcode. + +### 7.3 Scan a packed binary + +```cpp +// Code decrypted into anonymous executable pages outside any loaded module. +const auto pattern = sc::parse_aob("48 8B ?? ?? ?? ?? ?? 48 85 C0 74 ?? E8"); +if (!pattern) return; + +const auto* hit = sc::scan_executable_regions(*pattern); +if (!hit) return; + +// scan_executable_regions() already applied pattern->offset. +``` + +### 7.4 Second occurrence with an offset marker + +```cpp +// "48 8B 88 B8 00 00 00 | 48 89 4C 24 68" +// Use the second hit (e.g. the one inside the actual setter, not the reader). +const auto pattern = sc::parse_aob("48 8B 88 B8 00 00 00 | 48 89 4C 24 68"); +if (!pattern) return; + +const auto* hit = sc::find_pattern(base, size, *pattern, /*occurrence=*/2); +if (!hit) return; + +// hit already lands on the `mov [rsp+0x68], rcx` because find_pattern applied +// pattern->offset. Do not add pattern->offset again. +const auto* anchor = hit; +``` + +Reminder: both `find_pattern` overloads return the marked byte when a `|` marker is present (and the match start when it is absent). `pattern->offset` is applied for you; adding it manually double-applies. + +## 8. DOs and DON'Ts + +### DO + +- **Do** prefer code anchors over data anchors. +- **Do** wildcard every immediate operand (addresses, RVAs, relative offsets, jmp/call targets). +- **Do** keep signatures as short as will return a unique hit: 7 to 16 bytes is the common sweet spot. +- **Do** cache `CompiledPattern` if you scan more than once. +- **Do** ship at least one fallback candidate per hook for long-lived projects. +- **Do** verify the match with `Memory::is_readable()` and a first-byte sanity check before hooking. +- **Do** log which named candidate matched; anonymous signatures are unmaintainable at scale. +- **Do** treat the pointer returned by `find_pattern` and `scan_executable_regions` as already offset-adjusted; both apply `pattern->offset` for you. + +### DON'T + +- **Don't** include a static address or RVA in the signature body: it will change next build. +- **Don't** extend a signature into the `CC`/`90` padding between functions: linkers rebalance padding freely. +- **Don't** anchor on a short `Jcc rel8` conditional jump. Compilers flip freely between the `rel8` and `rel32` encodings (from a 2-byte `74 xx` to a 6-byte `0F 84 xx xx xx xx`, or vice versa) whenever the branch distance crosses a threshold, and even trivial edits to unrelated code can push the branch into a different encoding. The opcode byte changes, so the signature stops matching. +- **Don't** assume `resolve_rip_relative` hands back the call target for `FF 15 disp32` / `FF 25 disp32`. The disp32 addresses a pointer slot, and DMK returns that slot's absolute address; you must dereference it (for example with `Memory::read_ptr_unsafe`) to obtain the final destination. +- **Don't** ship a pattern with zero literal bytes (every token `??`). `find_pattern` will emit a warning and "match" at the region start every time, which is almost never what the caller wants. +- **Don't** call `parse_aob` in a hot loop on user-supplied strings; it logs every malformed input. +- **Don't** add `pattern->offset` to the pointer returned by `find_pattern` or `scan_executable_regions`; they already apply it. Double-applying walks past the intended byte and is a common source of mysteriously-wrong resolved addresses. +- **Don't** ignore a `PrefixNotFound` or `UnreadableDisplacement` error: they almost always mean the signature lost its context, not that the code simply moved. +- **Don't** trust a single-build signature in a long-lived mod without a fallback. + +## 9. Troubleshooting + +| Symptom | Likely cause | Remedy | +| ------- | ------------ | ------ | +| `parse_aob` returns `nullopt` | Malformed token, three-digit hex, stray `\|` | Check the log; `parse_aob` names the offender | +| `find_pattern` returns `nullptr` every time | Wildcards too broad, or the literal bytes include a byte the binary never has | Reduce wildcard count; print a few hex dumps around the expected site | +| `find_pattern` hits the wrong site | Signature not unique | Pick a tighter neighbour, or use the Nth-occurrence overload with a confirmed N | +| `resolve_rip_relative` returns `UnreadableDisplacement` | Match landed inside a guard page or at a region edge | Validate the caller's `search_length` and `instruction_length`; consider `scan_executable_regions` | +| Hit address crashes on first call | Missing post-match verification; anchor drifted into padding on a new build | Add `looks_like_prologue` and an `is_readable` check before hooking | +| Works locally, fails on a different machine | Packer or anti-cheat transforming the module between load and scan | Switch to `scan_executable_regions`; add a later re-scan on first frame | +| Multi-GB scan is slow | Patterns whose only literal bytes are common (`48 8B`, `E8`, etc.) | Broaden the anchor to include a rarer byte; the anchor selector prefers rarer bytes | + +## 10. Further reading + +- [C++ Core Guidelines - in-house coding standards](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines) +- [omni's hackpad: Fixing Hacks When a Game Gets Patched](https://badecho.com/index.php/2021/10/05/fixing-hacks-after-patch/) +- [Reloaded II Cheat Sheet: Signature Scanning](https://reloaded-project.github.io/Reloaded-II/CheatSheet/SignatureScanning/) +- [UE4SS: Fixing missing AOBs (advanced)](https://docs.ue4ss.com/dev/guides/fixing-compatibility-problems-advanced.html) +- [Guided Hacking: C++ Signature Scan Tutorial](https://guidedhacking.com/threads/c-signature-scan-pattern-scanning-tutorial.3981/) +- [AlliedModders Wiki: Signature Scanning](https://wiki.alliedmods.net/Signature_scanning) +- DMK source of truth: + - [include/DetourModKit/scanner.hpp](../../include/DetourModKit/scanner.hpp) + - [src/scanner.cpp](../../src/scanner.cpp) + - [tests/test_scanner.cpp](../../tests/test_scanner.cpp) diff --git a/include/DetourModKit/scanner.hpp b/include/DetourModKit/scanner.hpp index 74342b0..4e674d5 100644 --- a/include/DetourModKit/scanner.hpp +++ b/include/DetourModKit/scanner.hpp @@ -1,6 +1,7 @@ #ifndef DETOURMODKIT_SCANNER_HPP #define DETOURMODKIT_SCANNER_HPP +#include #include #include #include @@ -57,11 +58,32 @@ namespace DetourModKit */ struct CompiledPattern { - std::vector bytes; ///< Pattern bytes (wildcard positions contain arbitrary values) - std::vector mask; ///< 0xFF = match this byte, 0x00 = wildcard (skip) - size_t offset = 0; ///< Byte offset from pattern start to the point of interest. - ///< Set by `|` marker in the AOB string, or 0 if absent. - ///< May equal bytes.size() when `|` appears at the end. + /** + * @brief Pattern bytes, one per token in the source AOB string. + * @details Entries at wildcard positions (mask byte == 0x00) contain + * arbitrary values and must not be compared against memory. + */ + std::vector bytes; + + /** + * @brief Per-byte match mask paralleling @ref bytes. + * @details 0xFF marks a literal byte that must match exactly; 0x00 + * marks a wildcard slot to skip. Sized identically to + * @ref bytes. + */ + std::vector mask; + + /** + * @brief Byte offset from pattern start to the point of interest. + * @details Set by the `|` marker in the AOB string, or 0 if absent. + * May equal bytes.size() when `|` appears at the end of the + * pattern. The offset is non-negative under the current + * parser (`|` cannot precede tokens), but the type is + * signed to match pointer-arithmetic conventions + * (C++ Core Guidelines ES.106) and to future-proof against + * negative anchors. + */ + std::ptrdiff_t offset = 0; /** * @brief Returns the size of the pattern. @@ -95,11 +117,26 @@ namespace DetourModKit * @param start_address Pointer to the beginning of the memory region to scan. * @param region_size The size (in bytes) of the memory region to scan. * @param pattern The compiled pattern to search for. - * @return const std::byte* Pointer to the first occurrence of the pattern within - * the specified region. Returns nullptr if pattern not found. + * @return const std::byte* Pointer to the match within the specified region, + * already adjusted by `pattern.offset`. Returns nullptr if pattern + * not found. + * @note A pattern with zero literal bytes (every token wildcarded) returns + * `start_address` (plus offset) and emits a warning through the shared + * Logger. This case almost always indicates a caller bug; the behaviour + * is preserved for backwards compatibility but should not be relied upon. + * @note `pattern.offset` (set by a `|` marker in the AOB string) is applied + * exactly once. When no marker is present `offset == 0` and the returned + * pointer is the match start. Callers must NOT add `pattern.offset` + * manually; doing so double-applies and will miss the intended byte. + * @warning When `pattern.offset == pattern.size()` (a trailing `|` marker), + * the returned pointer addresses one-past the matched range. Depending + * on where in the region the match landed, this may also be + * one-past the scanned region. The pointer is valid for arithmetic + * and bounds comparisons but MUST NOT be dereferenced without an + * explicit readability check (e.g. `Memory::is_readable`). */ [[nodiscard]] const std::byte *find_pattern(const std::byte *start_address, size_t region_size, - const CompiledPattern &pattern); + const CompiledPattern &pattern); /** * @brief Scans a memory region for the Nth occurrence of a byte pattern. @@ -108,21 +145,26 @@ namespace DetourModKit * @param pattern The compiled pattern to search for. * @param occurrence Which occurrence to return (1-based). 1 = first match. * Passing 0 returns nullptr. - * @return const std::byte* Pointer to the Nth occurrence, or nullptr if fewer - * than N matches exist. + * @return const std::byte* Pointer to the Nth occurrence (already adjusted + * by `pattern.offset`), or nullptr if fewer than N matches exist. + * @note Like the single-occurrence overload, `pattern.offset` is applied + * exactly once. Callers must NOT add it manually. + * @warning A trailing `|` marker produces a one-past pointer identical in + * kind to the single-occurrence overload; do not dereference + * without a bounds or readability check. */ [[nodiscard]] const std::byte *find_pattern(const std::byte *start_address, size_t region_size, - const CompiledPattern &pattern, size_t occurrence); + const CompiledPattern &pattern, size_t occurrence); // Common x86-64 RIP-relative opcode prefixes (bytes preceding the disp32 field) - inline constexpr std::byte PREFIX_MOV_RAX_RIP[] = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x05}}; - inline constexpr std::byte PREFIX_MOV_RCX_RIP[] = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x0D}}; - inline constexpr std::byte PREFIX_MOV_RDX_RIP[] = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x15}}; - inline constexpr std::byte PREFIX_MOV_RBX_RIP[] = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x1D}}; - inline constexpr std::byte PREFIX_LEA_RAX_RIP[] = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x05}}; - inline constexpr std::byte PREFIX_LEA_RCX_RIP[] = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x0D}}; - inline constexpr std::byte PREFIX_LEA_RDX_RIP[] = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x15}}; - inline constexpr std::byte PREFIX_CALL_REL32[] = {std::byte{0xE8}}; - inline constexpr std::byte PREFIX_JMP_REL32[] = {std::byte{0xE9}}; + inline constexpr std::array PREFIX_MOV_RAX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x05}}; + inline constexpr std::array PREFIX_MOV_RCX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x0D}}; + inline constexpr std::array PREFIX_MOV_RDX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x15}}; + inline constexpr std::array PREFIX_MOV_RBX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x1D}}; + inline constexpr std::array PREFIX_LEA_RAX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x05}}; + inline constexpr std::array PREFIX_LEA_RCX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x0D}}; + inline constexpr std::array PREFIX_LEA_RDX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x15}}; + inline constexpr std::array PREFIX_CALL_REL32 = {std::byte{0xE8}}; + inline constexpr std::array PREFIX_JMP_REL32 = {std::byte{0xE9}}; /** * @brief Resolves an absolute address from an x86-64 RIP-relative instruction. @@ -148,6 +190,11 @@ namespace DetourModKit * @param opcode_prefix The opcode byte sequence to search for (disp32 must follow immediately). * @param instruction_length Total length of the instruction in bytes. * @return The resolved absolute address, or RipResolveError describing the failure. + * @warning For indirect-call / indirect-jump forms (`FF 15 disp32`, `FF 25 disp32`) + * the returned address is the *pointer slot* (the address that stores + * the final target), not the target itself. Dereference it with + * `Memory::read_ptr_unsafe` (or an equivalent checked read) to obtain + * the callee / jump destination. */ [[nodiscard]] std::expected find_and_resolve_rip_relative( const std::byte *search_start, @@ -164,6 +211,22 @@ namespace DetourModKit * @param pattern The compiled pattern to search for. * @param occurrence Which occurrence to return (1-based). 1 = first match. * @return Pointer to the match (adjusted by pattern offset), or nullptr if not found. + * @note Pure-execute pages (`PAGE_EXECUTE` without any read bit) are skipped: + * they are not guaranteed readable and dereferencing them raises an + * access violation. Only `PAGE_EXECUTE_READ`, `PAGE_EXECUTE_READWRITE`, + * and `PAGE_EXECUTE_WRITECOPY` regions are inspected. Guard and + * no-access pages are skipped unconditionally. + * @note `pattern.offset` is applied to the returned pointer, matching + * `find_pattern`. Callers must not add it manually. + * @warning A trailing `|` marker (offset == pattern.size()) yields a + * one-past pointer; bounds-check before dereferencing. + * @note A pattern that straddles a region boundary (e.g. two separately + * allocated `PAGE_EXECUTE_READ` regions that happen to be adjacent) + * will not be found: each region is scanned independently. PE-loaded + * code does not cross section boundaries so normal module scanning is + * unaffected, but JIT-compiled code (Mono, Unreal AngelScript) or + * heavily unpacked payloads may split contiguous bytes across VAD + * entries. */ [[nodiscard]] const std::byte *scan_executable_regions(const CompiledPattern &pattern, size_t occurrence = 1); diff --git a/src/scanner.cpp b/src/scanner.cpp index a5ca085..3a34f9e 100644 --- a/src/scanner.cpp +++ b/src/scanner.cpp @@ -55,7 +55,8 @@ namespace */ bool cpu_has_avx2() noexcept { - static const bool result = []() -> bool { + static const bool result = []() -> bool + { #if defined(__GNUC__) || defined(__clang__) // Check CPUID is supported and query leaf 7 unsigned int eax = 0, ebx = 0, ecx = 0, edx = 0; @@ -202,7 +203,7 @@ std::optional DetourModKit::Scanner::parse_aob(std::st { if (!aob_str.empty()) { - logger.warning("AOB Parser: Input string became empty after trimming."); + logger.debug("AOB Parser: Input string became empty after trimming."); } return std::nullopt; } @@ -234,7 +235,7 @@ std::optional DetourModKit::Scanner::parse_aob(std::st logger.error("AOB Parser: Multiple '|' offset markers at position {}.", token_idx); return std::nullopt; } - result.offset = result.bytes.size(); + result.offset = static_cast(result.bytes.size()); offset_set = true; } else if (token == "??" || token == "?") @@ -253,16 +254,21 @@ std::optional DetourModKit::Scanner::parse_aob(std::st } else { - logger.error("AOB Parser: Invalid token '{}' at position {}." - " Expected hex byte (e.g., FF), '?' or '?\?'.", + // Split the literal around '??' to dodge the C++ trigraph + // ??' (interpreted as a `|`), which trips -Wtrigraphs on + // GCC and would otherwise require disabling the warning TU-wide. + logger.error("AOB Parser: Invalid token '{}' at position {}. " + "Expected hex byte (e.g., FF), '?', or '?" + "?'.", token, token_idx); return std::nullopt; } } else { - logger.error("AOB Parser: Invalid token '{}' at position {}." - " Expected hex byte (e.g., FF), '?' or '?\?'.", + logger.error("AOB Parser: Invalid token '{}' at position {}. " + "Expected hex byte (e.g., FF), '?', or '?" + "?'.", token, token_idx); return std::nullopt; } @@ -280,131 +286,194 @@ std::optional DetourModKit::Scanner::parse_aob(std::st return result; } -const std::byte *DetourModKit::Scanner::find_pattern(const std::byte *start_address, size_t region_size, - const CompiledPattern &pattern) +namespace { - Logger &logger = Logger::get_instance(); - const size_t pattern_size = pattern.size(); - - if (pattern_size == 0) + // Internal scan primitive: returns the match *start* without applying + // pattern.offset. The public find_pattern wrappers apply the offset + // exactly once on top of this result; scan_executable_regions also calls + // this directly so its own final offset-application remains correct. + const std::byte *find_pattern_raw(const std::byte *start_address, size_t region_size, + const Scanner::CompiledPattern &pattern) noexcept; + + // Shared guard for "pattern has no literal bytes". Returning start_address + // preserves backwards compatibility for callers that rely on the degenerate + // "all wildcards matches anywhere" behaviour, but the call site is almost + // always a bug. Logging once per public entry (rather than per internal + // find_pattern_raw iteration) keeps the warning visible without flooding + // logs when the Nth-occurrence overload or scan_executable_regions loops. + bool pattern_has_literal_byte(const Scanner::CompiledPattern &pattern) noexcept { - logger.error("find_pattern: Pattern is empty. Cannot scan."); - return nullptr; + for (const std::byte m : pattern.mask) + { + if (m != std::byte{0x00}) + return true; + } + return false; } - if (!start_address) + + // Shared precondition check for the public find_pattern overloads. Returns + // false when the caller must short-circuit with nullptr (empty pattern or + // null start_address). Emits the all-wildcard warning itself so callers + // do not duplicate it; in that case the caller still continues scanning. + bool validate_find_pattern_inputs(const std::byte *start_address, + const Scanner::CompiledPattern &pattern, + Logger &logger) noexcept { - logger.error("find_pattern: Start address is null. Cannot scan."); - return nullptr; + if (pattern.empty()) + { + logger.error("find_pattern: Pattern is empty. Cannot scan."); + return false; + } + if (!start_address) + { + logger.error("find_pattern: Start address is null. Cannot scan."); + return false; + } + if (!pattern_has_literal_byte(pattern)) + { + logger.warning("find_pattern: pattern contains no literal bytes " + "(all wildcards); returning region start unchanged"); + } + return true; } - if (region_size < pattern_size) +} // anonymous namespace + +const std::byte *DetourModKit::Scanner::find_pattern(const std::byte *start_address, size_t region_size, + const CompiledPattern &pattern) +{ + Logger &logger = Logger::get_instance(); + if (!validate_find_pattern_inputs(start_address, pattern, logger)) { return nullptr; } - // Select the best anchor byte: the non-wildcard byte with the lowest frequency score. - // Ties are broken by first occurrence for deterministic behavior. - size_t best_anchor = pattern_size; // invalid = all wildcards - uint8_t best_score = UINT8_MAX; - for (size_t i = 0; i < pattern_size; ++i) + const std::byte *match = find_pattern_raw(start_address, region_size, pattern); + if (!match) + return nullptr; + return match + pattern.offset; +} + +namespace +{ + const std::byte *find_pattern_raw(const std::byte *start_address, size_t region_size, + const Scanner::CompiledPattern &pattern) noexcept { - if (pattern.mask[i] != std::byte{0x00}) + const size_t pattern_size = pattern.size(); + + if (pattern_size == 0 || !start_address || region_size < pattern_size) { - uint8_t score = byte_frequency_class(static_cast(pattern.bytes[i])); - if (best_anchor == pattern_size || score < best_score) + return nullptr; + } + + // Select the best anchor byte: the non-wildcard byte with the lowest + // frequency score. Ties are broken by first occurrence for deterministic + // behavior. + size_t best_anchor = pattern_size; // invalid = all wildcards + uint8_t best_score = UINT8_MAX; + for (size_t i = 0; i < pattern_size; ++i) + { + if (pattern.mask[i] != std::byte{0x00}) { - best_anchor = i; - best_score = score; - if (score == 0) + const uint8_t score = byte_frequency_class(static_cast(pattern.bytes[i])); + if (best_anchor == pattern_size || score < best_score) { - break; // Cannot improve on score 0 + best_anchor = i; + best_score = score; + if (score == 0) + { + break; // Cannot improve on score 0 + } } } } - } - - // All wildcards: matches immediately at start - if (best_anchor == pattern_size) - { - return start_address; - } - const std::byte target_byte = pattern.bytes[best_anchor]; - const unsigned char target_val = static_cast(target_byte); + // All wildcards: the pattern has no literal bytes to anchor on, so the + // search degenerates to "always match at region start". The public + // wrappers log the warning exactly once per call; repeated internal + // iterations (Nth occurrence, per-region scans) stay quiet. + if (best_anchor == pattern_size) + { + return start_address; + } - const std::byte *search_start = start_address + best_anchor; - const std::byte *const search_end = start_address + (region_size - pattern_size) + best_anchor; + const std::byte target_byte = pattern.bytes[best_anchor]; + const unsigned char target_val = static_cast(target_byte); - while (search_start <= search_end) - { - const void *found = memchr(search_start, static_cast(target_val), - static_cast(search_end - search_start + 1)); + const std::byte *search_start = start_address + best_anchor; + const std::byte *const search_end = start_address + (region_size - pattern_size) + best_anchor; - if (!found) + while (search_start <= search_end) { - break; - } + const void *found = memchr(search_start, static_cast(target_val), + static_cast(search_end - search_start + 1)); + + if (!found) + { + break; + } - const std::byte *current_scan_ptr = static_cast(found); - const std::byte *pattern_start = current_scan_ptr - best_anchor; + const std::byte *current_scan_ptr = static_cast(found); + const std::byte *pattern_start = current_scan_ptr - best_anchor; - // Verify the full pattern at this position. - // Three-tier SIMD: AVX2 (32B) -> SSE2 (16B) -> scalar (1B). - bool match_found = true; - size_t j = 0; + // Verify the full pattern at this position. + // Three-tier SIMD: AVX2 (32B) -> SSE2 (16B) -> scalar (1B). + bool match_found = true; + size_t j = 0; #ifdef DMK_HAS_AVX2 - if (cpu_has_avx2()) - { - j = verify_pattern_avx2(pattern_start, pattern, 0); - if (j == 0 && pattern_size >= 32) + if (cpu_has_avx2()) { - // Mismatch in AVX2 range - match_found = false; + j = verify_pattern_avx2(pattern_start, pattern, 0); + if (j == 0 && pattern_size >= 32) + { + // Mismatch in AVX2 range + match_found = false; + } } - } #endif // DMK_HAS_AVX2 #ifdef DMK_HAS_SSE2 - for (; match_found && j + 16 <= pattern_size; j += 16) - { - const __m128i mem = _mm_loadu_si128( - reinterpret_cast(pattern_start + j)); - const __m128i pat = _mm_loadu_si128( - reinterpret_cast(pattern.bytes.data() + j)); - const __m128i msk = _mm_loadu_si128( - reinterpret_cast(pattern.mask.data() + j)); - - const __m128i xored = _mm_xor_si128(mem, pat); - const __m128i masked = _mm_and_si128(xored, msk); - const __m128i cmp = _mm_cmpeq_epi8(masked, _mm_setzero_si128()); - - if (_mm_movemask_epi8(cmp) != 0xFFFF) + for (; match_found && j + 16 <= pattern_size; j += 16) { - match_found = false; - break; + const __m128i mem = _mm_loadu_si128( + reinterpret_cast(pattern_start + j)); + const __m128i pat = _mm_loadu_si128( + reinterpret_cast(pattern.bytes.data() + j)); + const __m128i msk = _mm_loadu_si128( + reinterpret_cast(pattern.mask.data() + j)); + + const __m128i xored = _mm_xor_si128(mem, pat); + const __m128i masked = _mm_and_si128(xored, msk); + const __m128i cmp = _mm_cmpeq_epi8(masked, _mm_setzero_si128()); + + if (_mm_movemask_epi8(cmp) != 0xFFFF) + { + match_found = false; + break; + } } - } #endif // DMK_HAS_SSE2 - for (; match_found && j < pattern_size; ++j) - { - if (pattern.mask[j] != std::byte{0x00} && pattern_start[j] != pattern.bytes[j]) + for (; match_found && j < pattern_size; ++j) { - match_found = false; + if (pattern.mask[j] != std::byte{0x00} && pattern_start[j] != pattern.bytes[j]) + { + match_found = false; + } } - } - if (match_found) - { - return pattern_start; + if (match_found) + { + return pattern_start; + } + + // No match, continue searching from next position + search_start = current_scan_ptr + 1; } - // No match, continue searching from next position - search_start = current_scan_ptr + 1; + return nullptr; } - - return nullptr; -} +} // anonymous namespace const std::byte *DetourModKit::Scanner::find_pattern(const std::byte *start_address, size_t region_size, const CompiledPattern &pattern, size_t occurrence) @@ -414,20 +483,29 @@ const std::byte *DetourModKit::Scanner::find_pattern(const std::byte *start_addr return nullptr; } + Logger &logger = Logger::get_instance(); + if (!validate_find_pattern_inputs(start_address, pattern, logger)) + { + return nullptr; + } + const std::byte *cursor = start_address; size_t remaining = region_size; size_t found_count = 0; + // Iterate via the raw helper so the `match + 1` continuation stays + // correct regardless of the pattern's offset marker. Offset is applied + // exactly once when we return the Nth hit. while (remaining >= pattern.size()) { - const std::byte *match = find_pattern(cursor, remaining, pattern); + const std::byte *match = find_pattern_raw(cursor, remaining, pattern); if (!match) { break; } if (++found_count == occurrence) { - return match; + return match + pattern.offset; } const size_t advance = static_cast(match - cursor) + 1; cursor += advance; @@ -456,8 +534,14 @@ std::expected DetourModKit::Scanner::r int32_t displacement; std::memcpy(&displacement, disp_ptr, sizeof(int32_t)); - auto base = reinterpret_cast(instruction_address); - return base + instruction_length + static_cast(static_cast(displacement)); + // Compute the target in unsigned modular arithmetic so the math stays + // well-defined on every input, including kernel-range instruction + // addresses (where intptr_t would be negative and signed overflow is UB). + // The displacement is sign-extended first so negative disp32 values wrap + // to the correct 64-bit offset. + const uintptr_t base = reinterpret_cast(instruction_address); + const uintptr_t disp_sext = static_cast(static_cast(displacement)); + return base + instruction_length + disp_sext; } std::expected DetourModKit::Scanner::find_and_resolve_rip_relative( @@ -504,8 +588,21 @@ const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPa if (pattern.empty() || occurrence == 0) return nullptr; - constexpr DWORD EXEC_FLAGS = PAGE_EXECUTE | PAGE_EXECUTE_READ | - PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + Logger &logger = Logger::get_instance(); + + if (!pattern_has_literal_byte(pattern)) + { + logger.warning("scan_executable_regions: pattern contains no literal " + "bytes (all wildcards); returning first readable region " + "start unchanged"); + } + + // Only scan pages we can actually *read*. Bare PAGE_EXECUTE grants execute + // rights without read, so dereferencing such a page raises an access + // violation. Omitting it keeps find_pattern safe on all walked regions. + constexpr DWORD READABLE_EXEC_FLAGS = PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | + PAGE_EXECUTE_WRITECOPY; size_t matches_remaining = occurrence; MEMORY_BASIC_INFORMATION mbi{}; @@ -513,12 +610,33 @@ const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPa while (VirtualQuery(reinterpret_cast(addr), &mbi, sizeof(mbi))) { - if (mbi.State == MEM_COMMIT && (mbi.Protect & EXEC_FLAGS) != 0 && - (mbi.Protect & PAGE_GUARD) == 0 && mbi.RegionSize >= pattern.size()) + // Skip non-readable / hostile protection states regardless of the + // execute bits: guard pages trigger STATUS_GUARD_PAGE_VIOLATION on + // access, and PAGE_NOACCESS will AV even for reads. + const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0; + const bool execute_only = (mbi.Protect & PAGE_EXECUTE) != 0 && + (mbi.Protect & READABLE_EXEC_FLAGS) == 0; + + if (execute_only && !protection_unsafe && mbi.State == MEM_COMMIT) + { + if (logger.is_enabled(LogLevel::Trace)) + { + logger.trace("scan_executable_regions: skipping pure-execute " + "region at {} (size {}) - not readable", + Format::format_address(reinterpret_cast(mbi.BaseAddress)), + mbi.RegionSize); + } + } + + if (mbi.State == MEM_COMMIT && (mbi.Protect & READABLE_EXEC_FLAGS) != 0 && + !protection_unsafe && mbi.RegionSize >= pattern.size()) { const auto *region_start = reinterpret_cast(mbi.BaseAddress); - const std::byte *match = find_pattern(region_start, mbi.RegionSize, pattern); + // Use the raw helper so our own `+ pattern.offset` at the final + // return applies exactly once (the public find_pattern already + // applies offset; calling it here would double-apply). + const std::byte *match = find_pattern_raw(region_start, mbi.RegionSize, pattern); while (match != nullptr) { --matches_remaining; @@ -529,7 +647,7 @@ const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPa const size_t consumed = static_cast(match - region_start) + 1; if (consumed >= mbi.RegionSize) break; - match = find_pattern(match + 1, mbi.RegionSize - consumed, pattern); + match = find_pattern_raw(match + 1, mbi.RegionSize - consumed, pattern); } } diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index 44514af..e95fc22 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -507,7 +507,7 @@ TEST(ScannerTest, parse_aob_offset_marker) auto result = Scanner::parse_aob("48 8B 88 B8 00 00 00 | 48 89 4C 24 68"); ASSERT_TRUE(result.has_value()); EXPECT_EQ(result->size(), 12u); - EXPECT_EQ(result->offset, 7u); + EXPECT_EQ(result->offset, 7); EXPECT_EQ(result->bytes[0], std::byte{0x48}); EXPECT_EQ(result->bytes[7], std::byte{0x48}); } @@ -517,7 +517,7 @@ TEST(ScannerTest, parse_aob_offset_marker_at_start) auto result = Scanner::parse_aob("| 48 8B 05"); ASSERT_TRUE(result.has_value()); EXPECT_EQ(result->size(), 3u); - EXPECT_EQ(result->offset, 0u); + EXPECT_EQ(result->offset, 0); } TEST(ScannerTest, parse_aob_offset_marker_at_end) @@ -525,14 +525,14 @@ TEST(ScannerTest, parse_aob_offset_marker_at_end) auto result = Scanner::parse_aob("48 8B 05 |"); ASSERT_TRUE(result.has_value()); EXPECT_EQ(result->size(), 3u); - EXPECT_EQ(result->offset, 3u); + EXPECT_EQ(result->offset, 3); } TEST(ScannerTest, parse_aob_no_offset_marker) { auto result = Scanner::parse_aob("48 8B 05"); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->offset, 0u); + EXPECT_EQ(result->offset, 0); } TEST(ScannerTest, parse_aob_multiple_offset_markers_fails) @@ -546,11 +546,13 @@ TEST(ScannerTest, parse_aob_offset_marker_with_wildcards) auto result = Scanner::parse_aob("?? ?? | 48 8B ??"); ASSERT_TRUE(result.has_value()); EXPECT_EQ(result->size(), 5u); - EXPECT_EQ(result->offset, 2u); + EXPECT_EQ(result->offset, 2); } -TEST(ScannerTest, find_pattern_with_offset_marker) +TEST(ScannerTest, FindPattern_OffsetMarker_ReturnsMarkedByte) { + // v3.0 contract: find_pattern applies pattern.offset to the returned + // pointer, so a `|` marker lands the caller directly on the anchored byte. std::vector data(256, std::byte{0x00}); data[50] = std::byte{0xAA}; data[51] = std::byte{0xBB}; @@ -559,13 +561,13 @@ TEST(ScannerTest, find_pattern_with_offset_marker) auto pattern = Scanner::parse_aob("AA BB | CC DD"); ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->offset, 2); auto result = Scanner::find_pattern(data.data(), data.size(), *pattern); ASSERT_NE(result, nullptr); - EXPECT_EQ(result - data.data(), 50); - - // The caller can use result + pattern->offset to get the marked position - EXPECT_EQ((result + pattern->offset) - data.data(), 52); + // Returned pointer is the marked byte (offset 2 into the match), NOT the + // raw match start. Adding pattern->offset manually would double-apply. + EXPECT_EQ(result - data.data(), 52); } // --- Nth-occurrence matching --- @@ -645,8 +647,10 @@ TEST(ScannerTest, find_pattern_nth_occurrence_zero) EXPECT_EQ(result, nullptr); } -TEST(ScannerTest, find_pattern_nth_occurrence_with_offset) +TEST(ScannerTest, FindPattern_NthOccurrence_WithOffsetMarker) { + // v3.0 contract: the Nth-occurrence overload also applies pattern.offset, + // returning the marked byte of the Nth match (not the match start). std::vector data(256, std::byte{0x00}); data[40] = std::byte{0xAA}; data[41] = std::byte{0xBB}; @@ -657,11 +661,13 @@ TEST(ScannerTest, find_pattern_nth_occurrence_with_offset) auto pattern = Scanner::parse_aob("AA | BB CC"); ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->offset, 1); auto result = Scanner::find_pattern(data.data(), data.size(), *pattern, 2); ASSERT_NE(result, nullptr); - EXPECT_EQ(result - data.data(), 100); - EXPECT_EQ((result + pattern->offset) - data.data(), 101); + // The second match starts at data[100]; the `|` sits after the first byte, + // so find_pattern returns data[101] directly. + EXPECT_EQ(result - data.data(), 101); } TEST(ScannerTest, find_pattern_nth_occurrence_with_overlap) @@ -1192,7 +1198,7 @@ TEST(ScannerExecRegionTest, RespectsPatternOffset) auto pattern = Scanner::parse_aob("D3 7A E9 15 | 82 F6 4B C0 37 A1 5E 94"); ASSERT_TRUE(pattern.has_value()); - EXPECT_EQ(pattern->offset, 4u); + EXPECT_EQ(pattern->offset, 4); const std::byte *result = Scanner::scan_executable_regions(*pattern); ASSERT_NE(result, nullptr); @@ -1201,6 +1207,45 @@ TEST(ScannerExecRegionTest, RespectsPatternOffset) VirtualFree(exec_mem, 0, MEM_RELEASE); } +TEST(ScannerExecRegionTest, OffsetStillAppliedExactlyOnce) +{ + // Regression guard for the internal find_pattern_raw split: after + // unification, both find_pattern and scan_executable_regions apply + // pattern.offset exactly once. Placing a uniquely-valued pattern in an + // executable region and scanning via both paths must return the same + // marked byte, not the marked byte + offset (which would be a double + // application). + void *exec_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + ASSERT_NE(exec_mem, nullptr); + + auto *bytes = reinterpret_cast(exec_mem); + std::memset(bytes, 0xCC, 4096); + + // Distinctive 8-byte pattern at offset 300 with `|` marker after byte 3. + constexpr size_t region_offset = 300; + const std::byte sig[] = { + std::byte{0x71}, std::byte{0xE3}, std::byte{0x9A}, std::byte{0x4D}, + std::byte{0x06}, std::byte{0xBF}, std::byte{0x52}, std::byte{0x18}}; + std::memcpy(&bytes[region_offset], sig, sizeof(sig)); + + auto pattern = Scanner::parse_aob("71 E3 9A | 4D 06 BF 52 18"); + ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->offset, 3); + + // scan_executable_regions path: should land on the marked byte. + const std::byte *exec_hit = Scanner::scan_executable_regions(*pattern); + ASSERT_NE(exec_hit, nullptr); + EXPECT_EQ(exec_hit, &bytes[region_offset + 3]); + + // find_pattern path over the same region: must agree exactly with the + // scan_executable_regions result (both apply offset once). + const std::byte *direct_hit = Scanner::find_pattern(bytes, 4096, *pattern); + ASSERT_NE(direct_hit, nullptr); + EXPECT_EQ(direct_hit, exec_hit); + + VirtualFree(exec_mem, 0, MEM_RELEASE); +} + TEST(ScannerExecRegionTest, SkipsGuardPages) { void *exec_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); @@ -1447,3 +1492,388 @@ TEST(ScannerTest, find_pattern_avx2_path_not_found) const auto *result = Scanner::find_pattern(data.data(), data.size(), *pattern); EXPECT_EQ(result, nullptr); } + +namespace +{ + void write_disp32(std::byte *dst, int32_t value) noexcept + { + std::memcpy(dst, &value, sizeof(value)); + } +} + +TEST(ScannerRipResolveTest, resolve_rip_relative_null_input_returns_error) +{ + const auto result = Scanner::resolve_rip_relative(nullptr, 1, 5); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), RipResolveError::NullInput); +} + +TEST(ScannerRipResolveTest, resolve_rip_relative_positive_displacement) +{ + // Fake `E8 disp32` (call rel32, 5 bytes total). disp32 starts at offset 1. + std::vector buffer(5, std::byte{0x00}); + buffer[0] = std::byte{0xE8}; + write_disp32(buffer.data() + 1, 0x1000); + + const auto result = Scanner::resolve_rip_relative(buffer.data(), 1, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = reinterpret_cast(buffer.data()) + 5 + 0x1000; + EXPECT_EQ(*result, expected); +} + +TEST(ScannerRipResolveTest, resolve_rip_relative_negative_displacement) +{ + // Signed disp32 must produce a lower absolute address via sign-extension. + std::vector buffer(16, std::byte{0x00}); + buffer[0] = std::byte{0xE9}; + write_disp32(buffer.data() + 1, -0x200); + + const auto result = Scanner::resolve_rip_relative(buffer.data(), 1, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = reinterpret_cast(buffer.data()) + 5 - 0x200; + EXPECT_EQ(*result, expected); +} + +TEST(ScannerRipResolveTest, resolve_rip_relative_mov_rax_rip_shape) +{ + // Full 7-byte `mov rax, [rip+disp32]`: 48 8B 05 disp32. + std::vector buffer(7, std::byte{0x00}); + buffer[0] = std::byte{0x48}; + buffer[1] = std::byte{0x8B}; + buffer[2] = std::byte{0x05}; + write_disp32(buffer.data() + 3, 0x4000); + + const auto result = Scanner::resolve_rip_relative(buffer.data(), 3, 7); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = reinterpret_cast(buffer.data()) + 7 + 0x4000; + EXPECT_EQ(*result, expected); +} + +TEST(ScannerRipResolveTest, resolve_rip_relative_unreadable_displacement) +{ + // Allocate two adjacent pages. Page 1 is RW, page 2 is NO_ACCESS. + // Place the opcode at the last byte of page 1 so the disp32 read straddles + // into page 2 and Memory::is_readable() fails for the disp32 window. + SYSTEM_INFO sys_info{}; + ::GetSystemInfo(&sys_info); + const SIZE_T page_size = sys_info.dwPageSize; + + auto *region = static_cast(::VirtualAlloc( + nullptr, page_size * 2, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); + ASSERT_NE(region, nullptr); + + DWORD old_protect = 0; + ASSERT_TRUE(::VirtualProtect(region + page_size, page_size, PAGE_NOACCESS, &old_protect)); + + auto *fake_instr = region + page_size - 1; + *fake_instr = std::byte{0xE8}; + + const auto result = Scanner::resolve_rip_relative(fake_instr, 1, 5); + + EXPECT_FALSE(result.has_value()); + if (!result.has_value()) + { + EXPECT_EQ(result.error(), RipResolveError::UnreadableDisplacement); + } + + ::VirtualFree(region, 0, MEM_RELEASE); +} + +TEST(ScannerRipResolveTest, find_and_resolve_null_input_returns_error) +{ + const auto result = Scanner::find_and_resolve_rip_relative( + nullptr, 16, Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), RipResolveError::NullInput); +} + +TEST(ScannerRipResolveTest, find_and_resolve_region_too_small_returns_error) +{ + std::vector buffer(2, std::byte{0x00}); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), RipResolveError::RegionTooSmall); +} + +TEST(ScannerRipResolveTest, find_and_resolve_prefix_not_found_returns_error) +{ + std::vector buffer(64, std::byte{0x90}); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), RipResolveError::PrefixNotFound); +} + +TEST(ScannerRipResolveTest, find_and_resolve_call_rel32_happy_path) +{ + std::vector buffer(64, std::byte{0x90}); + + constexpr size_t instr_offset = 20; + buffer[instr_offset] = std::byte{0xE8}; + write_disp32(buffer.data() + instr_offset + 1, 0x80); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = + reinterpret_cast(buffer.data() + instr_offset) + 5 + 0x80; + EXPECT_EQ(*result, expected); +} + +TEST(ScannerRipResolveTest, find_and_resolve_mov_rax_rip_multi_byte_prefix) +{ + std::vector buffer(64, std::byte{0x90}); + + constexpr size_t instr_offset = 12; + buffer[instr_offset + 0] = std::byte{0x48}; + buffer[instr_offset + 1] = std::byte{0x8B}; + buffer[instr_offset + 2] = std::byte{0x05}; + write_disp32(buffer.data() + instr_offset + 3, 0x1234); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_MOV_RAX_RIP, 7); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = + reinterpret_cast(buffer.data() + instr_offset) + 7 + 0x1234; + EXPECT_EQ(*result, expected); +} + +TEST(ScannerRipResolveTest, find_and_resolve_returns_first_match_only) +{ + std::vector buffer(64, std::byte{0x90}); + + buffer[8] = std::byte{0xE8}; + write_disp32(buffer.data() + 9, 0x10); + + buffer[32] = std::byte{0xE8}; + write_disp32(buffer.data() + 33, 0x20); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected_first = + reinterpret_cast(buffer.data() + 8) + 5 + 0x10; + EXPECT_EQ(*result, expected_first); +} + +TEST(ScannerRipResolveTest, find_and_resolve_match_at_region_boundary) +{ + // Prefix sits at the last position where prefix + disp32 still fits in the region. + std::vector buffer(16, std::byte{0x90}); + const size_t instr_offset = buffer.size() - 5; + buffer[instr_offset] = std::byte{0xE8}; + write_disp32(buffer.data() + instr_offset + 1, 0x40); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = + reinterpret_cast(buffer.data() + instr_offset) + 5 + 0x40; + EXPECT_EQ(*result, expected); +} + +// Regression guard for the PREFIX_* migration from C-array to std::array. +// Ensures the constants still expose `.size()`, decay into std::span cleanly, +// and feed through find_and_resolve_rip_relative without source changes. +TEST(ScannerRipResolveTest, PrefixConstants_AreStdArraysAndUsableAsSpan) +{ + static_assert(Scanner::PREFIX_CALL_REL32.size() == 1, + "PREFIX_CALL_REL32 must expose std::array::size()"); + EXPECT_EQ(Scanner::PREFIX_CALL_REL32[0], std::byte{0xE8}); + + std::vector buffer(5, std::byte{0x90}); + buffer[0] = std::byte{0xE8}; + write_disp32(buffer.data() + 1, 0x10); + + const auto result = Scanner::find_and_resolve_rip_relative( + buffer.data(), buffer.size(), Scanner::PREFIX_CALL_REL32, 5); + + ASSERT_TRUE(result.has_value()); + const uintptr_t expected = + reinterpret_cast(buffer.data()) + 5 + 0x10; + EXPECT_EQ(*result, expected); +} + +// Parser must reject obvious non-hex tokens. The error path used to emit a +// `\?` escape artefact; this test guards parse_aob's rejection behaviour +// without trying to inspect the Logger output (there is no public capture +// helper in the test suite, so message text is intentionally unchecked). +TEST(ScannerTest, ParseAob_WildcardErrorMessage_UsesCleanQuestionMarks) +{ + auto result = Scanner::parse_aob("GG"); + EXPECT_FALSE(result.has_value()); + + auto result_mixed = Scanner::parse_aob("48 GG 8B"); + EXPECT_FALSE(result_mixed.has_value()); +} + +// An all-wildcard pattern has no literal bytes to anchor on. find_pattern's +// contract is to return `start_address` in that case (and log a warning). +// This guard-rails the behaviour so future refactors don't silently flip it. +TEST(ScannerTest, FindPattern_AllWildcards_ReturnsStartWithWarning) +{ + Scanner::CompiledPattern all_wild; + all_wild.bytes = {std::byte{0x00}, std::byte{0x00}, std::byte{0x00}}; + all_wild.mask = {std::byte{0x00}, std::byte{0x00}, std::byte{0x00}}; + + std::vector buffer(32, std::byte{0xAA}); + + const auto *first = Scanner::find_pattern(buffer.data(), buffer.size(), all_wild); + ASSERT_NE(first, nullptr); + EXPECT_EQ(first, buffer.data()); + + // Stable across repeated calls. + const auto *second = Scanner::find_pattern(buffer.data(), buffer.size(), all_wild); + EXPECT_EQ(second, buffer.data()); +} + +// Negative disp32 must land before the instruction. This guards the refactored +// signed-arithmetic path in resolve_rip_relative - an unsigned-only cast chain +// would still produce the correct bit pattern modulo 2^64, but the signed form +// is the one humans can read, so a direct signed comparison is the contract. +TEST(ScannerTest, ResolveRipRelative_NegativeDisplacement_ComputesCorrectTarget) +{ + // CALL rel32 with disp32 = -0x20. Encoded little-endian: E0 FF FF FF. + alignas(4) std::byte buffer[5] = { + std::byte{0xE8}, + std::byte{0xE0}, std::byte{0xFF}, std::byte{0xFF}, std::byte{0xFF}}; + + ASSERT_TRUE(Memory::init_cache()); + const auto result = Scanner::resolve_rip_relative(buffer, 1, 5); + ASSERT_TRUE(result.has_value()); + + const auto *expected_ptr = buffer + 5 - 0x20; + EXPECT_EQ(*result, reinterpret_cast(expected_ptr)); + Memory::shutdown_cache(); +} + +// Exercise the full VirtualQuery walk. The test cannot portably set up a +// pure-execute page, but it can verify the walk across whatever mix of +// protections the current process happens to have does not AV. The fix in +// scan_executable_regions is what makes this safe in the presence of +// PAGE_EXECUTE-only regions injected by third-party modules. +TEST(ScannerTest, ScanExecutableRegions_SurvivesProcessWalk_DoesNotCrash) +{ + // A distinctive pattern unlikely to appear in the host process. If it does + // match something, that is still a success for the "does not AV" contract. + auto pattern = Scanner::parse_aob("DE AD BE EF CA FE BA BE 13 37 C0 DE"); + ASSERT_TRUE(pattern.has_value()); + + const auto *hit = Scanner::scan_executable_regions(*pattern); + (void)hit; // Either result (match or nullptr) is acceptable; we care that we returned. + SUCCEED(); +} + +// Regression guard: find_pattern applies pattern.offset exactly once. A pattern +// whose `|` marker sits at the very end (offset == pattern.size()) must return +// a pointer one past the final pattern byte, not somewhere deeper. +TEST(ScannerTest, FindPattern_OffsetAtEnd_ReturnsPastLastByte) +{ + std::vector data(64, std::byte{0x00}); + data[10] = std::byte{0xDE}; + data[11] = std::byte{0xAD}; + data[12] = std::byte{0xBE}; + data[13] = std::byte{0xEF}; + + auto pattern = Scanner::parse_aob("DE AD BE EF |"); + ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->offset, 4); + + const auto *result = Scanner::find_pattern(data.data(), data.size(), *pattern); + ASSERT_NE(result, nullptr); + EXPECT_EQ(result, &data[14]); +} + +// All-wildcard Nth occurrence must still advance one byte at a time rather +// than loop-stalling, and must return the Nth "match" address (region start +// + N - 1) without log-spamming for every internal iteration. +TEST(ScannerTest, FindPattern_AllWildcards_NthOccurrenceAdvances) +{ + Scanner::CompiledPattern all_wild; + all_wild.bytes = {std::byte{0x00}, std::byte{0x00}}; + all_wild.mask = {std::byte{0x00}, std::byte{0x00}}; + + std::vector buffer(16, std::byte{0xAB}); + + const auto *first = Scanner::find_pattern(buffer.data(), buffer.size(), all_wild, 1); + ASSERT_NE(first, nullptr); + EXPECT_EQ(first, buffer.data()); + + const auto *second = Scanner::find_pattern(buffer.data(), buffer.size(), all_wild, 2); + ASSERT_NE(second, nullptr); + EXPECT_EQ(second, buffer.data() + 1); + + const auto *third = Scanner::find_pattern(buffer.data(), buffer.size(), all_wild, 3); + ASSERT_NE(third, nullptr); + EXPECT_EQ(third, buffer.data() + 2); +} + +// Nth overload with occurrence == 0 must early-return before touching any +// other state (e.g. pattern validation), preserving the 1-based contract. +TEST(ScannerTest, FindPattern_NthZeroOccurrence_ReturnsNullptr) +{ + auto pattern = Scanner::parse_aob("CC"); + ASSERT_TRUE(pattern.has_value()); + + std::vector data = {std::byte{0xCC}, std::byte{0xCC}}; + const auto *result = Scanner::find_pattern(data.data(), data.size(), *pattern, 0); + EXPECT_EQ(result, nullptr); +} + +// Nth overload rejects an empty pattern the same way the single-hit overload +// does. Without the public-entry guard, the raw helper would be asked to scan +// with a zero-size pattern, tripping the `remaining >= 0` sentinel path. +TEST(ScannerTest, FindPattern_NthEmptyPattern_ReturnsNullptr) +{ + Scanner::CompiledPattern empty_pattern; + std::vector data(16, std::byte{0x00}); + + const auto *result = Scanner::find_pattern(data.data(), data.size(), empty_pattern, 1); + EXPECT_EQ(result, nullptr); +} + +// Nth overload validates the start pointer too, so callers can't accidentally +// scan from a null base even when they pass a positive region size. +TEST(ScannerTest, FindPattern_NthNullStart_ReturnsNullptr) +{ + auto pattern = Scanner::parse_aob("CC"); + ASSERT_TRUE(pattern.has_value()); + + const auto *result = Scanner::find_pattern( + static_cast(nullptr), 32, *pattern, 1); + EXPECT_EQ(result, nullptr); +} + +// resolve_rip_relative must sign-extend the 32-bit displacement before adding +// it to the instruction base, so a negative disp32 lands at the expected +// two's-complement target. This test pins that contract with disp = -1 and a +// 5-byte instruction: target must equal base + 5 + (-1) = base + 4. +TEST(ScannerTest, ResolveRipRelative_NegativeDisp32_ProducesExpectedTarget) +{ + std::vector code = { + std::byte{0xE8}, + std::byte{0xFF}, std::byte{0xFF}, std::byte{0xFF}, std::byte{0xFF}}; + + const auto result = Scanner::resolve_rip_relative(code.data(), 1, 5); + ASSERT_TRUE(result.has_value()); + + const uintptr_t base = reinterpret_cast(code.data()); + // disp = -1, instruction_length = 5 => target = base + 5 + (-1) = base + 4. + const uintptr_t expected = base + 5 + static_cast(static_cast(-1)); + EXPECT_EQ(*result, expected); + EXPECT_EQ(*result, base + 4); +}