Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

</details>
Expand Down
89 changes: 87 additions & 2 deletions docs/misc/aob-signatures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -308,14 +392,15 @@ struct ResolveHit
};

[[nodiscard]] std::expected<ResolveHit, ResolveError>
resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label);
resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label,
ScannerKind kind = ScannerKind::Executable);

[[nodiscard]] std::expected<ResolveHit, ResolveError>
resolve_cascade_with_prologue_fallback(std::span<const AddrCandidate> 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

Expand Down
11 changes: 11 additions & 0 deletions docs/misc/rtti-walker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 66 additions & 2 deletions include/DetourModKit/scanner.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<ResolveHit, ResolveError>
resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label);
resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label,
ScannerKind kind = ScannerKind::Executable);

/**
* @brief Cascade resolver with inline-hooked-prologue recovery.
Expand Down
Loading
Loading