From 092c32e94ff533139a109bcfe76eafed69c5a703 Mon Sep 17 00:00:00 2001 From: Quang Trinh Date: Sat, 30 May 2026 03:14:40 +0700 Subject: [PATCH] feat(scanner): add scan_readable_regions for data-section AOB scanning Whole-process readable-region sweep (the data-section sibling of scan_executable_regions): reaches .rdata/.data, C++ vtables, and RTTI type descriptors the executable-only sweep cannot. Adds a ScannerKind selector so resolve_cascade can opt into the readable sweep (default stays Executable, so existing call sites recompile unchanged). Skips guard/no-access/uncommitted pages and excludes the compiled pattern's own buffer from self-matching. --- AGENTS.md | 2 +- README.md | 1 + docs/misc/aob-signatures.md | 89 ++++++++- docs/misc/rtti-walker.md | 11 ++ include/DetourModKit/scanner.hpp | 68 ++++++- src/scanner.cpp | 172 +++++++++++------ tests/test_scanner.cpp | 312 +++++++++++++++++++++++++++++++ 7 files changed, 594 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a1dde9..e105c0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ make clean # Remove all build directories ```text include/DetourModKit/ # Public headers -- one per module - scanner.hpp # AOB pattern scanning with AVX2/SSE2 + scanner.hpp # AOB pattern scanning (executable + readable/data regions) with AVX2/SSE2 hook_manager.hpp # SafetyHook wrapper (inline, mid, and VMT hooks) async_logger.hpp # Lock-free MPMC queue logger logger.hpp # Synchronous singleton logger diff --git a/README.md b/README.md index 9fa6d58..51382e6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ DetourModKit is a full-featured C++ toolkit designed to simplify common tasks in - 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 (pure-execute pages without a read bit are skipped to avoid access violations) +- `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)` - `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 diff --git a/docs/misc/aob-signatures.md b/docs/misc/aob-signatures.md index ae5b588..540ff39 100644 --- a/docs/misc/aob-signatures.md +++ b/docs/misc/aob-signatures.md @@ -197,6 +197,84 @@ Internally `find_pattern` does NOT scan byte-by-byte from the start of the patte 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. +### 4.7 Scanning data sections (`scan_readable_regions`) + +`scan_executable_regions` filters to execute-readable pages, so it cannot reach +`.rdata` / `.data`. When the thing you need to locate is data rather than code, +use `scan_readable_regions()`. It walks the same `VirtualQuery` loop but accepts +every committed readable region (`PAGE_READONLY`, `PAGE_READWRITE`, +`PAGE_WRITECOPY`, and the three execute-readable variants), so it reaches C++ +vtables, RTTI type descriptors, localized string pools, and read-only metadata +tables. + +```cpp +const auto* match = sc::scan_readable_regions(*pattern); +``` + +It takes the same optional `occurrence` parameter and applies `pattern.offset` +exactly once, identically to `scan_executable_regions`. The accepted set is a +strict superset: a pattern present in `.text` is found by both. Guard pages +(`PAGE_GUARD`), no-access pages (`PAGE_NOACCESS`), and uncommitted regions are +skipped and never dereferenced. + +Because the compiled pattern's own `bytes` buffer lives in readable heap memory, +a whole-process sweep would otherwise match the needle against itself. The scan +excludes any match overlapping that buffer, so it never hands back the address of +your own pattern storage. (`scan_executable_regions` never had this problem: the +needle is not executable.) + +Two costs come with the wider reach: + +- **More bytes inspected.** A typical x64 game maps hundreds of MB of data + versus tens of MB of code, so a readable sweep can run several times longer + than an executable one. Resolve at startup or on a worker, never on the render + thread, and cache the result. +- **Higher collision risk.** `.rdata` pointer tables and constant pools look + random, so a pattern that is unique in `.text` may collide in data. Supply at + least 8 literal bytes and confirm the hit (occurrence count, or a follow-up + structural check). + +#### Prefer an RTTI name anchor over a raw vtable header + +The obvious data signature for a class is its vtable header: the RTTI Complete +Object Locator pointer followed by the first few virtual-function pointers. The +trap is that every one of those qwords is an *absolute, relocated* pointer: +`value = image_base + RVA`. On x64 the image base is at least 64 KiB aligned, so +only the low 2 bytes of each pointer are invariant across launches; the higher +bytes move with the ASLR slide. A "24-byte vtable header" therefore yields only +about 6 reliably stable literal bytes unless the module happens to load at its +preferred base, which inflates the collision risk above. + +The robust anchor is the RTTI **type-descriptor name** string itself, for +example the mangled `.?AVClassName@ns@@`. It is plain ASCII baked into the +binary, fully ASLR-invariant, and tens of literal bytes long, so it effectively +never collides. The flow is: + +1. `scan_readable_regions` for the mangled name to find the `TypeDescriptor`. +2. Walk the MSVC RTTI structures from the descriptor to the vtable (see + [rtti-walker.md](rtti-walker.md), which documents the COL / TypeDescriptor / + self-RVA layout the `Rtti` module already encodes). + +This pairs with the `Rtti` walker's opposite direction (vtable to name): one +finds a vtable from a known name, the other recovers a name from a known vtable. + +#### Using the readable scanner inside a cascade + +`resolve_cascade` takes a trailing `ScannerKind` argument (default +`ScannerKind::Executable`). Pass `ScannerKind::Readable` to resolve a candidate +whose signature lives in a data section. `ResolveMode::Direct` already returns +`match + disp_offset`, which is exactly the data address, so no new resolve mode +is needed: + +```cpp +const auto hit = sc::resolve_cascade( + k_vtable_candidates, "voice_buff_vtable", sc::ScannerKind::Readable); +``` + +`resolve_cascade_with_prologue_fallback` is intentionally executable-only: its +recovery path rebuilds a hooked near-JMP prologue, which is meaningless for a +data match. + ## 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. @@ -284,6 +362,12 @@ enum class ResolveMode : std::uint8_t RipRelative // Read int32 disp at (match + disp_offset); target = match + instr_end_offset + disp }; +enum class ScannerKind : std::uint8_t +{ + Executable, // scan_executable_regions: committed execute-readable pages + Readable // scan_readable_regions: all committed readable pages (superset) +}; + struct AddrCandidate { std::string_view name; @@ -308,14 +392,15 @@ struct ResolveHit }; [[nodiscard]] std::expected -resolve_cascade(std::span candidates, std::string_view label); +resolve_cascade(std::span candidates, std::string_view label, + ScannerKind kind = ScannerKind::Executable); [[nodiscard]] std::expected resolve_cascade_with_prologue_fallback(std::span candidates, std::string_view label); ``` -Both functions take a span so you can pass a `std::array`, `std::vector`, or any contiguous container. The `label` is the human-readable tag used when the winning candidate is logged; the `winning_name` on the returned `ResolveHit` aliases the matched candidate's `name` field, so the storage that holds those `string_view`s must outlive the hit (static string literals or an `std::array` in static storage are the usual patterns). `ResolveHit::address` is the post-resolution absolute address: for `Direct` candidates it equals `match + disp_offset`, and for `RipRelative` candidates it is the target of the displacement already resolved (not the raw match pointer), so callers can hook or call it directly. +Both functions take a span so you can pass a `std::array`, `std::vector`, or any contiguous container. `resolve_cascade` searches with `scan_executable_regions` by default; pass `ScannerKind::Readable` to resolve data-section candidates with `scan_readable_regions` (see [4.7](#47-scanning-data-sections-scan_readable_regions)). `resolve_cascade_with_prologue_fallback` is executable-only by construction. The `label` is the human-readable tag used when the winning candidate is logged; the `winning_name` on the returned `ResolveHit` aliases the matched candidate's `name` field, so the storage that holds those `string_view`s must outlive the hit (static string literals or an `std::array` in static storage are the usual patterns). `ResolveHit::address` is the post-resolution absolute address: for `Direct` candidates it equals `match + disp_offset`, and for `RipRelative` candidates it is the target of the displacement already resolved (not the raw match pointer), so callers can hook or call it directly. ### 6.3 Basic usage diff --git a/docs/misc/rtti-walker.md b/docs/misc/rtti-walker.md index bbdbe0a..9fab875 100644 --- a/docs/misc/rtti-walker.md +++ b/docs/misc/rtti-walker.md @@ -96,6 +96,17 @@ if (n > 0) `type_name_into` is the right choice for per-frame probes or diagnostic captures that must not allocate. The buffer is always NUL-terminated when `out_len > 0`, and the failure path sets `out[0] = '\0'` so misuse cannot leak stale stack contents. +### Resolving a vtable from a type name (reverse direction) + +The walker runs vtable to name. The inverse, name to vtable, is the natural way to bootstrap a class marker at init: you know the mangled name but not yet the vtable address. Because the `TypeDescriptor` name string lives in `.rdata`, this direction needs a data-section scan, which the executable-only sweep cannot do. `Scanner::scan_readable_regions` (see [aob-signatures.md](aob-signatures.md), section 4.7) provides it. + +The flow inverts the [ABI layout](#abi-layout) above: + +1. `scan_readable_regions` for the mangled name string (for example `.?AVMyClass@ns@@`, including the trailing NUL) to land on `td + 0x10`. The name is plain ASCII and fully ASLR-invariant, so it is a far stronger anchor than the vtable header, whose entries are relocated pointers that shift with the ASLR slide. +2. Subtract `0x10` to reach the `TypeDescriptor` base, then locate the COL whose `pTypeDescriptor` RVA points back at it and read `vtable = col_address + ...` via the same self-RVA / image-base arithmetic the walker uses internally. + +This is the recommended path for "find the one vtable for a known class" because it does not depend on a volatile constructor or on the per-launch byte values of relocated vtable pointers. + ## Performance notes - The walker issues two SEH-guarded reads per call on the cold path: one for the COL pointer at `vtable - 8`, one batched read of the 24-byte `ColHead`. On MSVC each `__try` frame is essentially free on the success path. On MinGW each read goes through `VirtualQuery`, which is microseconds-class; the batched ColHead read keeps the MinGW cost down to two syscalls instead of four. diff --git a/include/DetourModKit/scanner.hpp b/include/DetourModKit/scanner.hpp index 4258983..90814a5 100644 --- a/include/DetourModKit/scanner.hpp +++ b/include/DetourModKit/scanner.hpp @@ -284,6 +284,62 @@ namespace DetourModKit */ [[nodiscard]] const std::byte *scan_executable_regions(const CompiledPattern &pattern, size_t occurrence = 1); + /** + * @brief Scans all committed readable memory regions for a byte pattern. + * @details Data-section sibling of scan_executable_regions. Walks the + * process address space via VirtualQuery and scans every + * committed region whose base protection is PAGE_READONLY, + * PAGE_READWRITE, PAGE_WRITECOPY, or one of the three + * execute-readable variants. This reaches .rdata / .data and + * read-only heaps: C++ vtables, RTTI type descriptors, localized + * string pools, and other read-only metadata that the + * executable-only sweep cannot see. + * @param pattern The compiled pattern to search for. + * @param occurrence Which occurrence to return (1-based). 1 = first match. + * Passing 0 returns nullptr. + * @return Pointer to the match (adjusted by pattern offset), or nullptr if + * not found. + * @note The accepted protection set is a strict superset of + * scan_executable_regions: execute-readable code pages are included, + * so a pattern present in .text is found by both. Callers that + * specifically want non-code matches must post-filter (e.g. against + * Memory::module_range_for). + * @note Guard pages (PAGE_GUARD), no-access pages (PAGE_NOACCESS), and + * uncommitted regions are skipped: the first two fault on any touch + * and are never dereferenced. + * @note `pattern.offset` is applied to the returned pointer, matching + * scan_executable_regions. Callers must not add it manually. + * @note The compiled pattern's own `bytes` buffer is itself readable + * memory and would otherwise match the needle against itself. The + * scan excludes any match overlapping that buffer, so it never + * returns the caller's pattern storage. (scan_executable_regions + * is unaffected because that storage is not executable.) + * @warning The readable address space is far larger than the executable + * subset (a typical x64 game process maps hundreds of MB of data + * versus tens of MB of code) and .rdata pointer tables look + * random, so a pattern unique in .text may collide in data. + * Supply patterns with enough literal bytes (>= 8) to keep the + * false-positive rate low. An RTTI mangled-name anchor is fully + * ASLR-invariant and far stronger than a raw vtable-header + * signature, whose relocated pointers vary per launch. + * @warning A trailing `|` marker (offset == pattern.size()) yields a + * one-past pointer; bounds-check before dereferencing. + * @warning A pattern that straddles a region boundary is not found: each + * region is scanned independently. PE-loaded sections are + * contiguous, so normal module scanning is unaffected. + */ + [[nodiscard]] const std::byte *scan_readable_regions(const CompiledPattern &pattern, size_t occurrence = 1); + + /** + * @enum ScannerKind + * @brief Selects which whole-process scanner a cascade resolves against. + */ + enum class ScannerKind : std::uint8_t + { + Executable, ///< scan_executable_regions: committed execute-readable pages. + Readable ///< scan_readable_regions: all committed readable pages (superset). + }; + /** * @enum ResolveMode * @brief How a cascade candidate's pattern maps to a final address. @@ -360,7 +416,10 @@ namespace DetourModKit /** * @brief Try candidates in order; return the first successful address. * @details Each candidate's pattern is compiled via parse_aob() and - * searched via scan_executable_regions(). Direct mode returns + * searched via the scanner selected by @p kind: + * scan_executable_regions() for ScannerKind::Executable (the + * default) or scan_readable_regions() for ScannerKind::Readable + * when the target lives in .rdata / .data. Direct mode returns * @c match + disp_offset. RipRelative mode treats @c match + * disp_offset as a disp32 field and resolves against * @c match + instr_end_offset. On success, the winning @@ -378,10 +437,15 @@ namespace DetourModKit * * @param candidates Ordered list of candidates. Empty -> EmptyCandidates. * @param label Human-readable identifier used in log messages. + * @param kind Which scanner to search with. Defaults to + * ScannerKind::Executable so existing call sites are + * unchanged; pass ScannerKind::Readable for data-section + * targets. * @return ResolveHit on success; ResolveError on failure. */ [[nodiscard]] std::expected - resolve_cascade(std::span candidates, std::string_view label); + resolve_cascade(std::span candidates, std::string_view label, + ScannerKind kind = ScannerKind::Executable); /** * @brief Cascade resolver with inline-hooked-prologue recovery. diff --git a/src/scanner.cpp b/src/scanner.cpp index 78d9441..8db4185 100644 --- a/src/scanner.cpp +++ b/src/scanner.cpp @@ -621,6 +621,88 @@ std::expected DetourModKit::Scanner::f return std::unexpected(RipResolveError::PrefixNotFound); } +namespace +{ + // Region-walking AOB scan shared by scan_executable_regions and + // scan_readable_regions. Walks the committed regions of the process address + // space via VirtualQuery and runs find_pattern_raw against every region + // whose base protection is present in accept_mask, returning the Nth match + // (1-based, adjusted by pattern.offset) or nullptr. + // + // Guard, no-access, and uncommitted regions are always skipped: PAGE_GUARD + // raises STATUS_GUARD_PAGE_VIOLATION on the first touch and PAGE_NOACCESS + // faults even for reads, so neither is safe to dereference. The Windows base + // protections (PAGE_READONLY, PAGE_READWRITE, ... , PAGE_EXECUTE_WRITECOPY) + // are mutually exclusive single bits, so a bitwise-AND against a mask of the + // acceptable bases is a sound membership test. PAGE_GUARD is a modifier bit + // OR-ed onto a base value (a guarded read-only page reads as PAGE_READONLY | + // PAGE_GUARD), so it must be excluded separately or it would satisfy the + // mask and be scanned. + // + // Each region is scanned through the raw helper so the final + // `+ pattern.offset` applies exactly once (the public find_pattern already + // applies offset; calling it here would double-apply). A pattern straddling + // two adjacent VAD entries is therefore not found; PE-loaded sections are + // contiguous, so normal module scanning is unaffected. + const std::byte *scan_regions_filtered(const Scanner::CompiledPattern &pattern, + size_t occurrence, DWORD accept_mask) noexcept + { + // The compiled pattern's own bytes buffer lives in readable heap memory, + // so a whole-process readable sweep would match the needle against + // itself and could return the caller's pattern storage instead of the + // intended target. Exclude any match that overlaps that buffer. The + // executable sweep never reaches pattern.bytes (the heap is not + // executable), so this is a no-op there and keeps both scanners + // consistent: a scan never matches the needle's own storage. The needle + // is the caller's allocation, so no real target can share its range. + const auto needle_lo = reinterpret_cast(pattern.bytes.data()); + const auto needle_hi = needle_lo + pattern.size(); + + size_t matches_remaining = occurrence; + MEMORY_BASIC_INFORMATION mbi{}; + uintptr_t addr = 0; + + while (VirtualQuery(reinterpret_cast(addr), &mbi, sizeof(mbi))) + { + const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0; + + if (mbi.State == MEM_COMMIT && (mbi.Protect & accept_mask) != 0 && + !protection_unsafe && mbi.RegionSize >= pattern.size()) + { + const auto *region_start = reinterpret_cast(mbi.BaseAddress); + + const std::byte *match = find_pattern_raw(region_start, mbi.RegionSize, pattern); + while (match != nullptr) + { + const auto match_addr = reinterpret_cast(match); + const bool self_match = match_addr < needle_hi && + (match_addr + pattern.size()) > needle_lo; + if (!self_match) + { + --matches_remaining; + if (matches_remaining == 0) + return match + pattern.offset; + } + + // Continue scanning past the current match. + const size_t consumed = static_cast(match - region_start) + 1; + if (consumed >= mbi.RegionSize) + break; + match = find_pattern_raw(match + 1, mbi.RegionSize - consumed, pattern); + } + } + + const uintptr_t next = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; + assert(next > addr && "VirtualQuery returned a non-advancing region"); + if (next <= addr) + break; // Overflow guard. + addr = next; + } + + return nullptr; + } +} // anonymous namespace + const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPattern &pattern, size_t occurrence) { if (pattern.empty() || occurrence == 0) @@ -641,62 +723,33 @@ const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPa constexpr DWORD READABLE_EXEC_FLAGS = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + return scan_regions_filtered(pattern, occurrence, READABLE_EXEC_FLAGS); +} - size_t matches_remaining = occurrence; - MEMORY_BASIC_INFORMATION mbi{}; - uintptr_t addr = 0; - - while (VirtualQuery(reinterpret_cast(addr), &mbi, sizeof(mbi))) - { - // 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); - - // 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; - if (matches_remaining == 0) - return match + pattern.offset; +const std::byte *DetourModKit::Scanner::scan_readable_regions(const CompiledPattern &pattern, size_t occurrence) +{ + if (pattern.empty() || occurrence == 0) + return nullptr; - // Continue scanning past the current match - const size_t consumed = static_cast(match - region_start) + 1; - if (consumed >= mbi.RegionSize) - break; - match = find_pattern_raw(match + 1, mbi.RegionSize - consumed, pattern); - } - } + Logger &logger = Logger::get_instance(); - const uintptr_t next = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; - assert(next > addr && "VirtualQuery returned a non-advancing region"); - if (next <= addr) - break; // Overflow guard - addr = next; + if (!pattern_has_literal_byte(pattern)) + { + logger.warning("scan_readable_regions: pattern contains no literal " + "bytes (all wildcards); returning first readable region " + "start unchanged"); } - return nullptr; + // Superset of READABLE_EXEC_FLAGS: every committed region we can read, + // including .rdata / .data (PAGE_READONLY / PAGE_READWRITE / PAGE_WRITECOPY) + // and read-only heaps, plus the execute-readable variants. The semantic is + // "find this pattern anywhere readable", so execute-readable code pages are + // intentionally included rather than deduplicated against + // scan_executable_regions; callers wanting non-code matches post-filter. + constexpr DWORD READABLE_FLAGS = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | + PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | + PAGE_EXECUTE_WRITECOPY; + return scan_regions_filtered(pattern, occurrence, READABLE_FLAGS); } Scanner::SimdLevel DetourModKit::Scanner::active_simd_level() noexcept @@ -885,7 +938,8 @@ namespace CascadeAttempt scan_candidates(std::span candidates, bool &all_parse_failed, - DetourModKit::Logger &logger) + DetourModKit::Logger &logger, + DetourModKit::Scanner::ScannerKind kind) { all_parse_failed = true; for (size_t i = 0; i < candidates.size(); ++i) @@ -899,7 +953,10 @@ namespace continue; } all_parse_failed = false; - const auto *match = DetourModKit::Scanner::scan_executable_regions(*compiled); + const auto *match = + (kind == DetourModKit::Scanner::ScannerKind::Readable) + ? DetourModKit::Scanner::scan_readable_regions(*compiled) + : DetourModKit::Scanner::scan_executable_regions(*compiled); if (match != nullptr) { const auto addr = resolve_candidate_match( @@ -988,7 +1045,7 @@ namespace std::expected DetourModKit::Scanner::resolve_cascade(std::span candidates, - std::string_view label) + std::string_view label, ScannerKind kind) { auto &logger = Logger::get_instance(); @@ -999,7 +1056,7 @@ DetourModKit::Scanner::resolve_cascade(std::span candidates } bool all_parse_failed = true; - const auto attempt = scan_candidates(candidates, all_parse_failed, logger); + const auto attempt = scan_candidates(candidates, all_parse_failed, logger, kind); if (attempt.success) { const auto &winner = candidates[attempt.index]; @@ -1031,8 +1088,11 @@ DetourModKit::Scanner::resolve_cascade_with_prologue_fallback( return std::unexpected(ResolveError::EmptyCandidates); } + // Prologue recovery is a code-shape heuristic (it rebuilds a hooked + // near-JMP prologue), so this resolver is executable-only by construction; + // the readable sweep is meaningless for it. bool all_parse_failed = true; - auto attempt = scan_candidates(candidates, all_parse_failed, logger); + auto attempt = scan_candidates(candidates, all_parse_failed, logger, ScannerKind::Executable); if (attempt.success) { const auto &winner = candidates[attempt.index]; diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index f8cf7ab..4fb0617 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -1,7 +1,9 @@ #include +#include #include #include #include +#include #include #include "DetourModKit/scanner.hpp" @@ -1354,6 +1356,279 @@ TEST(ScannerExecRegionTest, SkipsGuardPages) VirtualFree(exec_mem, 0, MEM_RELEASE); } +// --- Tests for scan_readable_regions --- + +namespace +{ + // Writes a signature into dst and returns the matching AOB string. The AOB + // is built as ASCII hex, a different byte sequence that cannot itself match + // the binary signature. The step (37) is coprime to 256, so the generated + // bytes are distinct for any run shorter than 256. marker_index, when + // non-negative, inserts a `|` offset token before that byte. + std::string write_signature(std::byte *dst, std::size_t count, std::uint8_t seed, + std::ptrdiff_t marker_index = -1) + { + static constexpr char hex_digits[] = "0123456789ABCDEF"; + std::string aob; + aob.reserve(count * 4); + for (std::size_t i = 0; i < count; ++i) + { + const auto value = static_cast( + seed + static_cast(i) * 37u + 11u); + dst[i] = static_cast(value); + if (i != 0) + { + aob.push_back(' '); + } + if (marker_index >= 0 && static_cast(marker_index) == i) + { + aob.append("| "); + } + aob.push_back(hex_digits[value >> 4]); + aob.push_back(hex_digits[value & 0x0F]); + } + return aob; + } + + // Enumerates the readable-memory occurrences of a pattern up to a cap. + // scan_readable_regions sweeps the whole process, so a signature staged by a + // test legitimately appears in more than one readable place: the target + // buffer, plus any transient copy the optimizer leaves on the stack while + // building it. (The compiled needle is excluded by the scanner itself.) + // Tests therefore assert that the target address is among the occurrences, + // not that it is the first one, which keeps them independent of memory + // layout and optimizer behaviour across toolchains. + std::vector collect_readable_hits(const Scanner::CompiledPattern &pattern) + { + constexpr std::size_t scan_cap = 64; + std::vector hits; + for (std::size_t occ = 1; occ <= scan_cap; ++occ) + { + const auto *hit = Scanner::scan_readable_regions(pattern, occ); + if (hit == nullptr) + { + break; + } + hits.push_back(hit); + } + return hits; + } + + bool hits_contain(const std::vector &hits, const std::byte *target) + { + return std::find(hits.begin(), hits.end(), target) != hits.end(); + } + + bool any_hit_in_range(const std::vector &hits, + const std::byte *lo, const std::byte *hi) + { + return std::any_of(hits.begin(), hits.end(), + [lo, hi](const std::byte *h) + { return h >= lo && h < hi; }); + } +} // namespace + +TEST(ScannerReadableRegionTest, FindsPatternInReadOnlyMemory) +{ + // .rdata is mapped PAGE_READONLY: write the signature while writable, then + // flip to read-only to model a real data section. + void *ro_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(ro_mem, nullptr); + + auto *bytes = reinterpret_cast(ro_mem); + std::memset(bytes, 0x00, 4096); + + const std::string aob = write_signature(&bytes[512], 16, 0x11); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(ro_mem, 4096, PAGE_READONLY, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + const auto hits = collect_readable_hits(*pattern); + EXPECT_TRUE(hits_contain(hits, &bytes[512])); + + // The scanner skips the compiled pattern's own bytes buffer (the needle), + // so that readable copy is never returned. + EXPECT_FALSE(hits_contain(hits, pattern->bytes.data())); + + // The executable-only sweep must not reach a PAGE_READONLY region. + EXPECT_EQ(Scanner::scan_executable_regions(*pattern), nullptr); + + VirtualFree(ro_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, FindsPatternInReadWriteData) +{ + void *rw_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(rw_mem, nullptr); + + auto *bytes = reinterpret_cast(rw_mem); + std::memset(bytes, 0x00, 4096); + + const std::string aob = write_signature(&bytes[256], 16, 0x29); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + const auto hits = collect_readable_hits(*pattern); + EXPECT_TRUE(hits_contain(hits, &bytes[256])); + + const std::byte *exec_hit = Scanner::scan_executable_regions(*pattern); + EXPECT_EQ(exec_hit, nullptr); + + VirtualFree(rw_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, SupersetIncludesExecutableReadable) +{ + // PAGE_EXECUTE_READ is in both masks, so a pattern in executable-readable + // memory must be found by the readable sweep as well as the executable one. + 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); + + const std::string aob = write_signature(&bytes[128], 16, 0x3D); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(exec_mem, 4096, PAGE_EXECUTE_READ, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + const auto hits = collect_readable_hits(*pattern); + EXPECT_TRUE(hits_contain(hits, &bytes[128])); + + // The executable buffer is the only executable copy (the needle and any + // transient stack copy are not executable), so it is the first exec hit. + const std::byte *exec_hit = Scanner::scan_executable_regions(*pattern); + ASSERT_NE(exec_hit, nullptr); + EXPECT_EQ(exec_hit, &bytes[128]); + + VirtualFree(exec_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, SkipsGuardPages) +{ + // A guarded read-only page reads as PAGE_READONLY | PAGE_GUARD; the guard + // modifier must exclude it from the readable sweep, otherwise the first + // touch raises STATUS_GUARD_PAGE_VIOLATION. + void *guard_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(guard_mem, nullptr); + + auto *bytes = reinterpret_cast(guard_mem); + std::memset(bytes, 0x00, 4096); + + const std::string aob = write_signature(&bytes[0], 16, 0x57); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(guard_mem, 4096, PAGE_READONLY | PAGE_GUARD, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + // The guarded region must be skipped: no occurrence may fall inside it. + // (Transient readable copies of the signature elsewhere are allowed.) + const auto hits = collect_readable_hits(*pattern); + EXPECT_FALSE(any_hit_in_range(hits, bytes, bytes + 4096)); + + VirtualFree(guard_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, SkipsNoAccessPages) +{ + void *na_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(na_mem, nullptr); + + auto *bytes = reinterpret_cast(na_mem); + std::memset(bytes, 0x00, 4096); + + const std::string aob = write_signature(&bytes[0], 16, 0x6B); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(na_mem, 4096, PAGE_NOACCESS, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + const auto hits = collect_readable_hits(*pattern); + EXPECT_FALSE(any_hit_in_range(hits, bytes, bytes + 4096)); + + VirtualFree(na_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, NthOccurrence) +{ + void *ro_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(ro_mem, nullptr); + + auto *bytes = reinterpret_cast(ro_mem); + std::memset(bytes, 0x00, 4096); + + // Two copies of the same signature in one region (same seed -> same bytes). + const std::string aob = write_signature(&bytes[100], 16, 0x84); + (void)write_signature(&bytes[600], 16, 0x84); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(ro_mem, 4096, PAGE_READONLY, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + + // Both copies must be reachable across the enumerated occurrences. + const auto hits = collect_readable_hits(*pattern); + EXPECT_TRUE(hits_contain(hits, &bytes[100])); + EXPECT_TRUE(hits_contain(hits, &bytes[600])); + + VirtualFree(ro_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, RespectsPatternOffset) +{ + void *ro_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(ro_mem, nullptr); + + auto *bytes = reinterpret_cast(ro_mem); + std::memset(bytes, 0x00, 4096); + + // 8-byte signature with a `|` marker after byte 3; the returned pointer must + // be the marked byte, with pattern.offset applied exactly once. + constexpr size_t region_offset = 320; + const std::string aob = write_signature(&bytes[region_offset], 8, 0x9C, /*marker_index=*/3); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(ro_mem, 4096, PAGE_READONLY, &old_protect)); + + const auto pattern = Scanner::parse_aob(aob); + ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->offset, 3); + + // The marked byte of the buffer copy is at region_offset + 3. + const auto hits = collect_readable_hits(*pattern); + EXPECT_TRUE(hits_contain(hits, &bytes[region_offset + 3])); + + VirtualFree(ro_mem, 0, MEM_RELEASE); +} + +TEST(ScannerReadableRegionTest, EmptyPattern) +{ + Scanner::CompiledPattern empty; + const std::byte *result = Scanner::scan_readable_regions(empty); + EXPECT_EQ(result, nullptr); +} + +TEST(ScannerReadableRegionTest, ZeroOccurrence) +{ + auto pattern = Scanner::parse_aob("5E 91 C4 2A 7F 38 D6 0B E3 4C 9A 17 62 F5 8D 30"); + ASSERT_TRUE(pattern.has_value()); + + const std::byte *result = Scanner::scan_readable_regions(*pattern, 0); + EXPECT_EQ(result, nullptr); +} + TEST(ScannerStringTest, RipResolveErrorToString_IsNoexcept) { static_assert(noexcept(rip_resolve_error_to_string(RipResolveError::NullInput))); @@ -1988,6 +2263,43 @@ TEST(ScannerCascade, NoMatchReturnsError) EXPECT_EQ(result.error(), Scanner::ResolveError::NoMatch); } +TEST(ScannerCascade, ReadableKindResolvesDataSectionMatch) +{ + // A Direct-mode candidate whose signature lives in PAGE_READONLY data is + // reachable only through ScannerKind::Readable; the executable default must + // miss it. + void *ro_mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(ro_mem, nullptr); + + auto *bytes = reinterpret_cast(ro_mem); + std::memset(bytes, 0x00, 4096); + + // The AOB string backs the candidate's string_view, so it must outlive the + // resolve_cascade calls below. + const std::string aob = write_signature(&bytes[384], 16, 0xC1); + + DWORD old_protect = 0; + ASSERT_TRUE(VirtualProtect(ro_mem, 4096, PAGE_READONLY, &old_protect)); + + Scanner::AddrCandidate cands[] = { + {"data-sig", aob, Scanner::ResolveMode::Direct, 0, 0}, + }; + + // ScannerKind::Readable reaches data sections, so the cascade resolves a + // signature that lives in PAGE_READONLY memory; the executable default + // cannot see it and reports NoMatch. + const auto readable = + Scanner::resolve_cascade(cands, "data-cascade", Scanner::ScannerKind::Readable); + EXPECT_TRUE(readable.has_value()); + + const auto executable = + Scanner::resolve_cascade(cands, "data-cascade", Scanner::ScannerKind::Executable); + ASSERT_FALSE(executable.has_value()); + EXPECT_EQ(executable.error(), Scanner::ResolveError::NoMatch); + + VirtualFree(ro_mem, 0, MEM_RELEASE); +} + namespace { struct ExecBuffer