Skip to content

Commit 8eef23c

Browse files
authored
fix(scanner): repair prologue-fallback cascade recovery (#112)
1 parent 44112e2 commit 8eef23c

8 files changed

Lines changed: 283 additions & 133 deletions

File tree

.clang-format

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ Standard: Latest
1212
# and a line whose excess is inside one literal is left alone.
1313
ColumnLimit: 120
1414
ReflowComments: Always
15+
# ReflowComments: Always otherwise re-wraps the deep hang-indented continuation lines of a /** */ Doxygen block and
16+
# mangles them (collapsing the alignment under the @tag, occasionally emitting a stray "* *"). CommentPragmas marks
17+
# comment lines that must never be reflowed: matching 3+ leading content spaces (the hang indent under a Doxygen tag)
18+
# protects every continuation line, which inhibits reflow of the whole block, so hand-wrapped doc-blocks are left intact.
19+
# Plain // implementation comments carry no such indent and are still reflowed to the column limit. The IWYU default
20+
# pattern is preserved.
21+
CommentPragmas: '^ IWYU pragma:|^ {3,}'
1522
BreakStringLiterals: false
1623
IndentWidth: 4
1724
TabWidth: 4

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,9 @@ m_pending_messages.fetch_add(1, std::memory_order_acq_rel);
221221

222222
### Formatting and tooling
223223

224-
C++ formatting is codified in the root `.clang-format` (LLVM base, Allman braces for functions/classes, 4-space indent, east-side pointers, includes never reordered). Run clang-format over the changed `*.cpp`/`*.hpp` before committing. CI runs an advisory check in `.github/workflows/quality.yml` (clang-format 20, the version pinned there) over the tracked project sources; submodules under `external/` are never formatted. The hard column limit is 120 (`ColumnLimit: 120`): code and comments must fit within it, and the formatter reflows comment text that runs past it (`ReflowComments: Always`). String literals are the one sanctioned exception: `BreakStringLiterals: false` keeps long log and error messages as single greppable literals, so a line whose excess sits inside one literal is left alone rather than split. When a message line still exceeds 120 columns, split the literal by hand at sentence or clause boundaries using adjacent string-literal concatenation (the compiler fuses the fragments back into one literal at compile time): keep the distinctive lead phrase whole in its own fragment so it stays greppable, and mind the space at each seam -- a missing seam space silently corrupts the message. Do not work around the limit with trailing comments -- keep a member's documentation on the line(s) above it (per the comment conventions) instead of letting a trailing comment push the line out. The comment-marker rules above are guarded by an advisory CI step, `scripts/check_comment_style.py` (no trailing `///<`, no multi-line `///`, no block tag on a `///` line).
224+
C++ formatting is codified in the root `.clang-format` (LLVM base, Allman braces for functions/classes, 4-space indent, east-side pointers, includes never reordered). Run clang-format over the changed `*.cpp`/`*.hpp` before committing. CI runs an advisory check in `.github/workflows/quality.yml` (clang-format 20, the version pinned there) over the tracked project sources; submodules under `external/` are never formatted. The hard column limit is 120 (`ColumnLimit: 120`): code and comments must fit within it, and the formatter reflows plain `//` comment text that runs past it (`ReflowComments: Always`), while `/** */` Doxygen doc-blocks are exempted from reflow via `CommentPragmas` (see the next paragraph). String literals are the one sanctioned exception: `BreakStringLiterals: false` keeps long log and error messages as single greppable literals, so a line whose excess sits inside one literal is left alone rather than split. When a message line still exceeds 120 columns, split the literal by hand at sentence or clause boundaries using adjacent string-literal concatenation (the compiler fuses the fragments back into one literal at compile time): keep the distinctive lead phrase whole in its own fragment so it stays greppable, and mind the space at each seam -- a missing seam space silently corrupts the message. Do not work around the limit with trailing comments -- keep a member's documentation on the line(s) above it (per the comment conventions) instead of letting a trailing comment push the line out. The comment-marker rules above are guarded by an advisory CI step, `scripts/check_comment_style.py` (no trailing `///<`, no multi-line `///`, no block tag on a `///` line).
225225

226-
`ReflowComments: Always` only re-wraps comment text that *overflows* 120; it does not re-fill a block wrapped well short of the limit, nor re-join a Doxygen block hand-wrapped with an off-style decoration (a two-space hang after the `*`, or `@brief` placed inline on the `/**` line), and the clang-format check is advisory. Reflow such blocks by hand to fill the column, changing only line breaks (never wording): keep CRLF line endings (a text-mode read/write that flips them to LF rewrites the whole file); keep each `@tag` on the first line of its paragraph with continuations hanging-aligned under the tag content (the `@note`/`@return` style used throughout `hook_manager.hpp`); leave `@param`/`@tparam` description-aligned and single-line `/** ... */` untouched. Verify with `awk 'length>120'` (empty) and confirm the file's comment text is unchanged token-for-token.
226+
`ReflowComments: Always` re-wraps plain `//` comment text that overflows 120, but `CommentPragmas: '^ IWYU pragma:|^ {3,}'` exempts `/** */` Doxygen doc-blocks from reflow: it marks any comment line indented three or more spaces -- the hang under a Doxygen `@tag` -- as un-reflowable, and a single protected continuation line inhibits reflow of the whole block. So the formatter never re-wraps, re-fills, or mangles a doc-block; doc-blocks are hand-maintained (without the pragma, `ReflowComments: Always` collapses the hang-alignment of a re-wrapped block and can emit a stray `* *`). The clang-format check is advisory regardless. Reflow doc-blocks by hand to fill the column, changing only line breaks (never wording): keep CRLF line endings (a text-mode read/write that flips them to LF rewrites the whole file); keep each `@tag` on the first line of its paragraph with continuations hanging-aligned under the tag content (the `@note`/`@return` style used throughout `hook_manager.hpp`); leave `@param`/`@tparam` description-aligned and single-line `/** ... */` untouched. Because the formatter will not wrap a doc-block for you, verify length by hand with `awk 'length>120'` (empty) and confirm the comment text is unchanged token-for-token.
227227

228228
Markdown files (`*.md`) are **not** hard-wrapped at 80 columns. Write one logical line per paragraph, list item, and blockquote line and let editors soft-wrap; do not insert manual line breaks mid-paragraph. Fenced code blocks, tables, and any line indented four or more spaces (indented code, nested list sub-paragraphs) are kept verbatim. As in code, use `--` rather than an em-dash or en-dash.
229229

docs/misc/aob-signatures.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ if (range)
251251
}
252252
```
253253

254-
One range scan covers both `.text` and `.rdata` / `.data` candidates, so there is no `ScannerKind` parameter. An invalid range returns `ResolveError::InvalidRange` and never falls back to a whole-process scan (which would re-introduce the cross-module shadowing the overload exists to prevent). `resolve_cascade_in_module_with_prologue_fallback` is the module-scoped counterpart of the prologue-fallback resolver: the rewritten near-JMP must be found inside the range, but its jump destination may still point at a sibling mod's trampoline outside the module, so the destination is validated only against "lies in some loaded module".
254+
One range scan covers both `.text` and `.rdata` / `.data` candidates, so there is no `ScannerKind` parameter. An invalid range returns `ResolveError::InvalidRange` and never falls back to a whole-process scan (which would re-introduce the cross-module shadowing the overload exists to prevent). `resolve_cascade_in_module_with_prologue_fallback` is the module-scoped counterpart of the prologue-fallback resolver: the rewritten near-JMP must be found inside the range, but its jump destination may still point at a sibling mod's trampoline outside the module, so the destination is validated only as a plausible pointer on a committed, execute-readable page -- not constrained to the scanned range or to any loaded module, because SafetyHook and relay-trampoline detours can allocate code outside every image.
255255

256256
> Use `resolve_cascade_in_module` only for a single contiguous mapped image. For packed or protected targets whose code is unpacked into separate `VirtualAlloc` regions, use the whole-process `resolve_cascade` (which `scan_executable_regions` walks for exactly that reason).
257257
@@ -502,7 +502,7 @@ DetourModKit::Logger::get_instance().info(
502502

503503
`resolve_cascade` is fine when the target function still looks the way your signature remembers it. It stops working as soon as another mod, loaded earlier in the process, inline-hooks the same function: SafetyHook, MinHook, and most hand-rolled detour libraries overwrite the first five bytes with a near-JMP (`E9 ?? ?? ?? ??`) to their trampoline. Your Direct-mode candidate that matches on a prologue byte sequence now sees `E9` instead of `48 89 5C 24 ...`, and the scan misses even though the function itself is still present.
504504

505-
`resolve_cascade_with_prologue_fallback` handles that exact scenario. On the happy path it is identical to `resolve_cascade`. If every candidate misses, it walks the list again and, for each Direct-mode candidate, rebuilds the pattern with the first five tokens replaced by `E9 ?? ?? ?? ??` while preserving the literal tail. It scans with the rewritten pattern and then applies two guardrails before accepting a hit: the rewritten pattern must resolve to exactly one location across all executable regions (a unique JMP into the sibling mod's trampoline, not an arbitrary near-JMP that happens to share a tail shape), and the `E9` displacement must land inside a loaded module (so the jump target is a real function, not garbage). RipRelative candidates are skipped in the fallback phase since they target instructions deeper than the 5-byte prologue and are unaffected by the overwrite.
505+
`resolve_cascade_with_prologue_fallback` handles that exact scenario. On the happy path it is identical to `resolve_cascade`. If every candidate misses, it walks the list again and, for each Direct-mode candidate, rebuilds the pattern with the first five tokens replaced by `E9 ?? ?? ?? ??` while preserving the literal tail. It scans with the rewritten pattern and then applies two guardrails before accepting a hit: the rewritten pattern must resolve to exactly one location across all executable regions (a unique JMP into the sibling mod's trampoline, not an arbitrary near-JMP that happens to share a tail shape), and the `E9` displacement must resolve to a committed, execute-readable page (so the jump target is real code, not unmapped or data-only garbage). The destination is deliberately *not* required to lie inside a loaded module: SafetyHook trampolines and relay-style detours can live outside every image, so an in-module requirement would reject the precise recovery this path exists for. The recovered address honors the candidate's `|` anchor offset exactly as the direct pass would, so a `|`-anchored Direct candidate resolves to the same byte whether it matched directly or through the fallback. RipRelative candidates are skipped in the fallback phase since they target instructions deeper than the 5-byte prologue and are unaffected by the overwrite.
506506

507507
```cpp
508508
const auto hit = sc::resolve_cascade_with_prologue_fallback(

include/DetourModKit/scanner.hpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,8 @@ namespace DetourModKit
477477
* @brief Cascade resolver with inline-hooked-prologue recovery.
478478
* @details Equivalent to resolve_cascade() on the happy path. If every candidate fails, rebuilds each
479479
* Direct-mode candidate's pattern with the first 5 bytes replaced by `E9 ?? ?? ?? ??` (the near-JMP
480-
* signature that SafetyHook and MinHook write when another mod already hooked the target) and retries.
481-
* If the recovery path succeeds the log line calls this out explicitly.
480+
* shape used by SafetyHook and other rel32 inline detours) and retries. If the recovery path succeeds
481+
* the log line calls this out explicitly.
482482
*
483483
* RipRelative candidates are skipped in the fallback phase since they target instructions deeper than
484484
* the 5-byte prologue and are unaffected by the overwrite.
@@ -541,11 +541,11 @@ namespace DetourModKit
541541
* near-JMP overwrites a code prologue, never data, so a match in .rdata / .data would be a false
542542
* positive (the data-capable readable sweep is only used for the primary candidate pass).
543543
*
544-
* The rebuilt near-JMP must be FOUND inside @p range, but its jump destination is intentionally NOT
545-
* constrained to @p range. When a sibling mod inline-hooks the target, its E9 jumps to a trampoline
546-
* the sibling allocated outside this image, so the destination is validated only against "lies in some
547-
* loaded module" (which still rejects a jump into unmapped or data-only memory). Requiring the
548-
* destination in-range would reject the very recovery this path exists to perform.
544+
* The rebuilt near-JMP must be found inside @p range, but its jump destination is intentionally not
545+
* constrained to @p range or to any loaded module. When a sibling mod inline-hooks the target, its E9
546+
* usually jumps to a VirtualAlloc'd trampoline outside every image, so the destination is validated as
547+
* a plausible pointer on a committed, execute-readable page instead. This still rejects jumps into
548+
* unmapped or data-only memory without rejecting the recovery this path exists to perform.
549549
*
550550
* @param candidates Ordered candidates.
551551
* @param label Human-readable identifier used in log messages.

src/scanner.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,21 @@ std::vector<Scanner::detail::ExecutableWindow> Scanner::detail::collect_executab
868868
return windows;
869869
}
870870

871+
// Single-address sibling of the executable-page gate scan_regions_filtered applies per region. One VirtualQuery,
872+
// matched against the identical mask (MEM_COMMIT, EXECUTABLE_PAGE_FLAGS, not PAGE_GUARD / PAGE_NOACCESS), so the
873+
// prologue-recovery fallback can vet a decoded E9 destination without re-deriving the Windows page masks or
874+
// constraining it to a loaded module (a sibling mod's trampoline is VirtualAlloc'd outside every image).
875+
bool Scanner::detail::is_executable_address(std::uintptr_t address) noexcept
876+
{
877+
MEMORY_BASIC_INFORMATION mbi{};
878+
if (VirtualQuery(reinterpret_cast<LPCVOID>(address), &mbi, sizeof(mbi)) == 0)
879+
{
880+
return false;
881+
}
882+
const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
883+
return mbi.State == MEM_COMMIT && (mbi.Protect & EXECUTABLE_PAGE_FLAGS) != 0 && !protection_unsafe;
884+
}
885+
871886
const std::byte *DetourModKit::Scanner::scan_executable_regions(const CompiledPattern &pattern, size_t occurrence)
872887
{
873888
if (pattern.empty() || occurrence == 0)

0 commit comments

Comments
 (0)