Skip to content

Commit 7d043bd

Browse files
authored
feat: scanner, anchor, and hook update-resilience hardening (#123)
1 parent b7761e3 commit 7d043bd

26 files changed

Lines changed: 3103 additions & 243 deletions

AGENTS.md

Lines changed: 7 additions & 6 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
1010

1111
| Module | Description | Header |
1212
|--------|-------------|--------|
13-
| AOB Scanner | SIMD-accelerated pattern scanning with wildcards, RIP resolution, multi-candidate cascade resolver with prologue fallback, host-EXE cascade overloads, in-code constant (immediate/displacement) extraction, and string-reference (xref) resolution (fast lea/mov shape scan, plus an opt-in Zydis sweep for cmp/push/no-REX shapes) | `scanner.hpp` |
13+
| AOB Scanner | SIMD-accelerated pattern scanning with full-byte and per-nibble wildcards, cross-region-boundary overlap, RIP resolution, multi-candidate cascade resolver with E9/FF25 prologue-recovery fallback, host-EXE cascade overloads, in-code constant (immediate/displacement) extraction, and string-reference (xref) resolution (load site, enclosing function, or cached global pointer slot; fast lea/mov shape scan plus an opt-in Zydis sweep for cmp/push/no-REX shapes) | `scanner.hpp` |
1414
| Hook Manager | Inline, mid-function, and VMT hooks via SafetyHook with cross-module duplicate-hook detection | `hook_manager.hpp` |
1515
| Configuration | INI-based settings with key combo support and hot-reload (file watcher + hotkey) | `config.hpp`, `config_watcher.hpp` |
1616
| Logger | Synchronous singleton logger with format strings | `logger.hpp` |
1717
| Async Logger | Lock-free bounded queue logger with batched writes | `async_logger.hpp` |
1818
| Memory Utilities | Readability checks, region cache, safe pointer reads, typed SEH reads, PE module range queries | `memory.hpp` |
1919
| MSVC RTTI Walker | Recover mangled type names from runtime vtables; pointer-table scan with caller-owned cache; reverse name-to-vtable resolver and cached identity handle | `rtti.hpp` |
20-
| RTTI Self-Heal | Reverse-identify the object behind a pointer slot; self-heal a field offset after a patch shifts the struct layout; rigid multi-field drift solver; drift-telemetry report with a durable, diffable manifest | `rtti_dissect.hpp`, `drift_manifest.hpp` |
21-
| Anchor Registry | One declarative table over the self-healing backends (vtable-by-name, AOB/RIP cascade, in-code constant, string xref, pinned literal) plus two-signal quorum corroboration and optional post-resolve validators, resolved and reported in a single pass | `anchors.hpp` |
20+
| RTTI Self-Heal | Reverse-identify the object behind a pointer slot (typed-error and ordered candidate-fallback forms); self-heal a field offset after a patch shifts the struct layout; rigid multi-field drift solver; drift-telemetry report with a durable, diffable manifest (open-failure distinguished from corrupt) | `rtti_dissect.hpp`, `drift_manifest.hpp` |
21+
| Anchor Registry | One declarative table over the self-healing backends (vtable-by-name, AOB/RIP cascade, in-code constant, string xref, pinned literal) plus two-signal quorum corroboration with sub-anchor independence checks, optional post-resolve validators and opt-in validator policies, a manifest quality diagnostic, and a per-game scan profile (broad-mode default, candidate order, backend deny-list), resolved and reported in a single pass | `anchors.hpp`, `profile.hpp` |
2222
| Event Dispatcher | Typed pub/sub with RAII subscriptions | `event_dispatcher.hpp` |
2323
| Profiler | Scoped timing with Chrome Tracing export (zero-cost when disabled) | `profiler.hpp` |
2424
| Format Utilities | `std::format` helpers for addresses, bytes, and VK codes; string trim | `format.hpp` |
@@ -33,8 +33,8 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
3333
<details>
3434
<summary><strong>AOB Scanner</strong></summary>
3535

36-
- Find array-of-bytes (signatures) in memory with wildcard support
37-
- Rare-byte anchor heuristic: `parse_aob()` scores every literal byte in the pattern against a small frequency table (`0x00`, `0xCC`, `0x48`, `0x8B`, ...) and caches the rarest byte's index on `CompiledPattern::anchor`. `find_pattern()` drives its `memchr` sweep on that byte, so a signature like `48 8B 05 37 DE AD BE EF` anchors on `0x37` rather than the very common `0x48`, cutting false candidate hits by an order of magnitude on realistic code
36+
- Find array-of-bytes (signatures) in memory with full-byte literals, full wildcards (`??` / `?`), and per-nibble wildcard tokens (`4?` fixes the high nibble, `?5` the low nibble) for an operand where only one nibble is invariant across builds
37+
- Rare-byte anchor heuristic: `parse_aob()` scores every fully-known literal byte in the pattern against a small frequency table (`0x00`, `0xCC`, `0x48`, `0x8B`, ...) and caches the rarest byte's index on `CompiledPattern::anchor` (a per-nibble byte is never chosen, since the prefilter needs an exact byte; an all-nibble pattern still resolves via a masked compare at every position). `find_pattern()` drives its `memchr` sweep on that byte, so a signature like `48 8B 05 37 DE AD BE EF` anchors on `0x37` rather than the very common `0x48`, cutting false candidate hits by an order of magnitude on realistic code
3838
- SIMD-accelerated prefilter and pattern verification:
3939
- The `memchr` anchor prefilter and the verify pass both tier at runtime: AVX2 (32 bytes/iteration, runtime-detected on Haswell+ CPUs) over an SSE2 baseline (16 bytes/iteration), with a scalar tail. The self-provided prefilter does its own byte comparisons (never calling libc) so the AddressSanitizer interceptor cannot fault on the scanner's in-bounds reads
4040
- Opt-in AVX-512F + AVX-512BW verify tier (64 bytes/iteration), gated behind the `DMK_ENABLE_AVX512` build option and a runtime CPUID + XGETBV check; off by default and never selected on a CPU that lacks it (it falls back to AVX2)
@@ -43,7 +43,8 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
4343
- RIP-relative instruction resolution for extracting absolute addresses from x86-64 code (returns `std::expected` with typed `RipResolveError` for actionable diagnostics)
4444
- `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; a region decommitted or reprotected concurrently mid-sweep is skipped rather than faulting the host on MSVC, where each region read runs inside a structured-exception guard)
4545
- `scan_readable_regions()` -- the data-section sibling of `scan_executable_regions()` -- sweeps every committed readable page (`.rdata` / `.data`, read-only heaps) to reach C++ vtables, RTTI type descriptors, and read-only metadata the executable-only sweep cannot see (guard / no-access / uncommitted pages are skipped); opt a cascade into it with `resolve_cascade(..., ScannerKind::Readable)`
46-
- `resolve_cascade()` and the module-scoped `resolve_cascade_in_module()` -- ordered multi-candidate resolution (try signatures in priority order, return the first that resolves), with an optional hooked-prologue recovery pass; the `_in_module` variants confine the scan to one mapped image `[base, end)` and reject out-of-module resolutions, so a generic signature that also matches inside another injected module (a graphics overlay, a sibling mod) cannot shadow the correct in-module target. By default each candidate must match uniquely in the scanned scope: an ambiguous signature (more than one match) falls through to the next candidate instead of silently committing to an arbitrary match, so a too-loose pattern surfaces as a clean failure to fix rather than a wrong hook. Set `require_unique = false` per candidate only to opt out a deliberately non-unique, separately-verified candidate
46+
- The region-walking sweeps (`scan_executable_regions` / `scan_readable_regions` and the module-scoped scans) carry a `pattern_len - 1` overlap across adjacent accepted `VirtualQuery` regions, so a signature that straddles a protection split (two adjacent regions whose base protections differ, e.g. after a sibling `VirtualProtect` carves up `.text`) is still found, without re-counting a match that lies wholly inside one region
47+
- `resolve_cascade()` and the module-scoped `resolve_cascade_in_module()` -- ordered multi-candidate resolution (try signatures in priority order, return the first that resolves), with an optional hooked-prologue recovery pass that recovers both an `E9` near-jump and an `FF 25` RIP-relative indirect-jump overwritten prologue (so a target inline-hooked by another mod with either a near or a far jump is recovered); the `_in_module` variants confine the scan to one mapped image `[base, end)` and reject out-of-module resolutions, so a generic signature that also matches inside another injected module (a graphics overlay, a sibling mod) cannot shadow the correct in-module target. By default each candidate must match uniquely in the scanned scope: an ambiguous signature (more than one match) falls through to the next candidate instead of silently committing to an arbitrary match, so a too-loose pattern surfaces as a clean failure to fix rather than a wrong hook. Set `require_unique = false` per candidate only to opt out a deliberately non-unique, separately-verified candidate
4748
- `resolve_cascade_in_host_module()` / `resolve_cascade_in_host_module_with_prologue_fallback()` -- one-line convenience overloads that scope a cascade to the host EXE (`host_module_range()`), removing the boilerplate of building the range at every call site. They return `ResolveError::InvalidRange` if the host range cannot be determined. Use them only when the target lives in the host EXE; for a game whose logic is in a separate module (an engine DLL), resolve that module's range and call `resolve_cascade_in_module()` instead
4849
- `read_code_constant(cc, range?)` -- the code-side twin of the RTTI self-heal: declare an instruction site (an AOB cascade) plus which operand to read, and it decodes the live instruction and returns the current immediate or `[reg + disp]` displacement, so a hand-read array stride or struct displacement re-derives itself after a patch instead of being a baked literal. It always decodes (the `nominal` field is telemetry only, never a short-circuit, so a same-shape / different-value drift is reported as the new value), indexes the **visible** operands, resolves a RIP-relative operand to its absolute target, and fails closed (`DecodeFailed` / `UnexpectedShape` / `OperandOutOfRange`). Built on a Zydis decoder kept entirely inside the implementation, so no consumer needs Zydis headers
4950
- `is_likely_function_prologue(addr)` heuristic that rejects scan poison (zero pages, alignment pads, bare RET stubs) while still accepting JMP-shaped patched prologues so nested-hook scenarios resolve
@@ -56,6 +57,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
5657
- C++ wrapper around [SafetyHook](https://github.com/cursey/safetyhook) for creating and managing hooks
5758
- **Inline hooks** and **mid-function hooks** - target functions by direct address or AOB scan
5859
- **Same-address layering is teardown-safe**: when more than one managed hook stacks on one address, bulk teardown (`remove_all_hooks`, `shutdown`, the destructor) disables and destroys them newest-first so each prologue restore lands on still-valid bytes instead of a freed trampoline. `HookConfig::fail_if_already_hooked` refuses a second managed hook on an address this HookManager already hooks (a registry-exact check, in addition to the prologue-byte heuristic that catches foreign-module hooks); explicit single removals must still be ordered newest-first by the caller.
60+
- **Unsafe-prologue pre-flight**: inline and mid hook creation decodes the target's first byte under a fault guard and flags a leading `E8` (call rel32) or `0xCC`/`0xCD` (breakpoint) prologue -- a relative call whose displacement would be relocated wrongly, or an already-patched / padding entry. `HookConfig::prologue_policy` selects `InlineProloguePolicy::Warn` (the default: log and install anyway, preserving prior behaviour) or `Fail` (refuse with `HookError::TargetPrologueUnsafe`).
5961
- **VMT (virtual method table) hooks** - clone an object's vtable and replace individual method slots by index
6062
- Per-object interception of virtual calls (e.g., D3D device methods, game AI interfaces)
6163
- Apply a single hooked vtable to multiple objects
@@ -171,7 +173,7 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
171173
<summary><strong>RTTI Self-Heal (reverse dissection + offset recovery)</strong></summary>
172174
173175
- The reverse direction of the walker, slot-first. It reuses the same verified COL prelude (module-bound-checked, SEH-guarded) rather than duplicating it, and every entry point is noexcept and fails closed
174-
- `Rtti::identify_pointee_type(slot_addr, out)` reverse-identifies the object a pointer slot refers to. It accepts whichever shape resolves -- a pointer-to-object (deref once, resolve the pointee's vtable) or a direct object base (the slot is the object, its value is the vtable) -- so an object whose vtable lives in a different DLL than the struct still resolves. The reported `was_pointer` flag is a result, not a precondition
176+
- `Rtti::identify_pointee_type(slot_addr, out)` reverse-identifies the object a pointer slot refers to. It accepts whichever shape resolves -- a pointer-to-object (deref once, resolve the pointee's vtable) or a direct object base (the slot is the object, its value is the vtable) -- so an object whose vtable lives in a different DLL than the struct still resolves. The reported `was_pointer` flag is a result, not a precondition. `identify_pointee_typed(...)` is the typed form returning the specific fail-closed reason (`IdentifyError`) instead of a bool, and `identify_pointee_type_or(candidate, out, fallbacks...)` probes a primary slot then ordered fallback slots, returning the first that resolves and preserving the primary's error when all fail
175177
- `Rtti::reverse_scan_block(start, slot_count, out, stride?)` RTTI-labels every pointer slot in a struct (allocating triage tool; init-time only)
176178
- `Rtti::heal_landmark(lm)` / `Rtti::heal_offset(lm)` -- the self-healing offset resolver. Record a landmark once (`"a field of mangled type T sits near offset O within struct S"`); after a small patch shifts the layout, it scans a `+/-` window around the nominal offset, reverse-RTTI-identifies each slot, and returns the healed field offset. The nominal offset is checked first and short-circuits, the widened scan prefers the nearest match, and an equidistant tie fails closed as `Ambiguous` -- the same `require_unique` philosophy the module-scoped cascade uses, transplanted from an AOB scan to a slot scan
177179
- `Rtti::solve_fingerprint(base, landmarks, window)` recovers a single uniform shift across several co-moving fields when one landmark alone would be ambiguous in a dense region

0 commit comments

Comments
 (0)