src/scanner_cascade.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file scanner_cascade.cpp | ||
| 3 | * @brief Multi-candidate cascade resolver with hooked-prologue recovery. | ||
| 4 | * | ||
| 5 | * The higher-level resolution layer over the scan primitives in scanner.cpp: | ||
| 6 | * it tries an ordered list of AOB candidates (whole-process, module-scoped, or host-EXE-scoped), enforces per-candidate | ||
| 7 | * uniqueness, and -- when every direct candidate misses -- rebuilds each Direct candidate's prologue as a near-JMP and | ||
| 8 | * retries to recover a target another mod already inline-hooked. Kept in its own translation unit so the cascade logic | ||
| 9 | * and the SIMD scan engine evolve independently; both share one public header (scanner.hpp) plus the internal | ||
| 10 | * module-scoped scan entry points in scanner_internal.hpp. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "DetourModKit/scanner.hpp" | ||
| 14 | #include "DetourModKit/memory.hpp" | ||
| 15 | #include "DetourModKit/rtti.hpp" | ||
| 16 | #include "DetourModKit/logger.hpp" | ||
| 17 | #include "DetourModKit/format.hpp" | ||
| 18 | #include "x86_decode.hpp" | ||
| 19 | |||
| 20 | #include "scanner_internal.hpp" | ||
| 21 | |||
| 22 | #include <array> | ||
| 23 | #include <cstddef> | ||
| 24 | #include <cstdint> | ||
| 25 | #include <optional> | ||
| 26 | #include <span> | ||
| 27 | #include <string> | ||
| 28 | #include <string_view> | ||
| 29 | #include <vector> | ||
| 30 | |||
| 31 | namespace DetourModKit | ||
| 32 | { | ||
| 33 | namespace | ||
| 34 | { | ||
| 35 | std::optional<std::uintptr_t> | ||
| 36 | 144 | resolve_candidate_match(std::uintptr_t match_addr, | |
| 37 | const DetourModKit::Scanner::AddrCandidate &candidate) noexcept | ||
| 38 | { | ||
| 39 | using DetourModKit::Scanner::ResolveMode; | ||
| 40 |
2/2✓ Branch 2 → 3 taken 142 times.
✓ Branch 2 → 8 taken 2 times.
|
144 | if (candidate.mode == ResolveMode::Direct) |
| 41 | { | ||
| 42 | 142 | const auto resolved = match_addr + static_cast<std::uintptr_t>(candidate.disp_offset); | |
| 43 | // Apply the same plausibility floor the RipRelative path uses below. A Direct result is normally a real | ||
| 44 | // in-process address, but a pathological disp_offset (a large negative value that underflows the match | ||
| 45 | // address, or a crafted candidate table) can resolve to 0, a low guard-page address, or a kernel-range | ||
| 46 | // / non-canonical value. Reject it here rather than commit to a hit the caller cannot later reverse; | ||
| 47 | // the module-scoped path layers a stricter contains() check on top. | ||
| 48 |
2/2✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 140 times.
|
142 | if (!DetourModKit::Memory::plausible_userspace_ptr(resolved)) |
| 49 | { | ||
| 50 | 2 | return std::nullopt; | |
| 51 | } | ||
| 52 | 140 | return resolved; | |
| 53 | } | ||
| 54 | 2 | const auto disp_addr = match_addr + static_cast<std::uintptr_t>(candidate.disp_offset); | |
| 55 | // Fault-guarded read instead of is_readable + raw memcpy: the page can change between the check and the | ||
| 56 | // copy (TOCTOU), so an unguarded memcpy could fault the host process. seh_read returns nullopt on any | ||
| 57 | // fault. | ||
| 58 | 2 | const auto disp = DetourModKit::Memory::seh_read<std::int32_t>(disp_addr); | |
| 59 |
1/2✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 2 times.
|
2 | if (!disp) |
| 60 | { | ||
| 61 | // A faulted displacement read is a miss, not a hit at address 0: the whole-process path has no in-range | ||
| 62 | // guard to reject a zero result, so returning nullopt here prevents a false ResolveHit at 0. | ||
| 63 | ✗ | return std::nullopt; | |
| 64 | } | ||
| 65 | 2 | const auto resolved = static_cast<std::uintptr_t>( | |
| 66 | 4 | static_cast<std::int64_t>(match_addr + static_cast<std::uintptr_t>(candidate.instr_end_offset)) + | |
| 67 | 2 | *disp); | |
| 68 | // Mirror resolve_rip_relative's plausibility floor. A corrupt or crafted displacement can resolve to 0, a | ||
| 69 | // low guard-page address, or a kernel-range value; the whole-process cascade path has no image to bound the | ||
| 70 | // result (the module-scoped path layers a stricter contains() check on top), so reject an implausible | ||
| 71 | // target here rather than commit to a hit the caller cannot later reverse. | ||
| 72 |
1/2✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2 times.
|
2 | if (!DetourModKit::Memory::plausible_userspace_ptr(resolved)) |
| 73 | { | ||
| 74 | ✗ | return std::nullopt; | |
| 75 | } | ||
| 76 | 2 | return resolved; | |
| 77 | } | ||
| 78 | |||
| 79 | // Resolves a name/string tier through its backend and lets misses or backend ambiguity fall through to the next | ||
| 80 | // candidate. This helper is intentionally not noexcept: the string-xref path may allocate while building its | ||
| 81 | // scan windows, and throwing out of the byte-mode noexcept helper would terminate the host. | ||
| 82 | 13 | std::optional<std::uintptr_t> resolve_named_candidate(const DetourModKit::Scanner::AddrCandidate &candidate, | |
| 83 | DetourModKit::Memory::ModuleRange range) | ||
| 84 | { | ||
| 85 | using DetourModKit::Scanner::ResolveMode; | ||
| 86 |
2/2✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 4 taken 8 times.
|
13 | if (candidate.mode == ResolveMode::RttiVtable) |
| 87 | { | ||
| 88 | // candidate.pattern is the MSVC mangled type name; the primary (COL.offset == 0) vtable is returned. | ||
| 89 | 5 | return DetourModKit::Rtti::vtable_for_type(candidate.pattern, range); | |
| 90 | } | ||
| 91 | // ResolveMode::StringXref: anchor on the immutable literal, then resolve its unique RIP-relative reference. | ||
| 92 | // Build the query with designated initializers, not positional brace-init, so a future StringRefQuery field | ||
| 93 | // reorder cannot silently misassign a facet. | ||
| 94 | const DetourModKit::Scanner::StringRefQuery query{ | ||
| 95 | .text = candidate.pattern, | ||
| 96 | 8 | .encoding = candidate.xref_encoding, | |
| 97 | 8 | .require_terminator = candidate.xref_require_terminator, | |
| 98 | 8 | .return_mode = candidate.xref_return, | |
| 99 | 8 | .broad_match = candidate.xref_broad_match, | |
| 100 | 8 | }; | |
| 101 |
1/2✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 12 not taken.
|
8 | const auto resolved = DetourModKit::Scanner::find_string_xref(query, range); |
| 102 |
2/2✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 8 taken 3 times.
|
8 | if (!resolved) |
| 103 | { | ||
| 104 | // Any StringXrefError is a fall-through, never a hit at address 0; the ambiguity variants are exactly | ||
| 105 | // the unique-only contract the name/string tiers promise. | ||
| 106 | 5 | return std::nullopt; | |
| 107 | } | ||
| 108 | 3 | return *resolved; | |
| 109 | } | ||
| 110 | |||
| 111 | // Minimum number of literal (non-wildcard) bytes the tail of the pattern must contain after dropping the first | ||
| 112 | // 5 prologue tokens. Five literal bytes still leave the rebuilt pattern shaped like a generic near-JMP plus a | ||
| 113 | // short common-instruction tail, which collides with thousands of unrelated E9 sites in a multi-megabyte .text | ||
| 114 | // section. Ten literal bytes is roughly two to four real instructions of context and reduces the false-positive | ||
| 115 | // rate to near zero on real binaries while staying inside the 12 to 20 byte sweet spot documented for fallback | ||
| 116 | // signatures. | ||
| 117 | constexpr int PROLOGUE_FALLBACK_MIN_TAIL_LITERALS = 10; | ||
| 118 | |||
| 119 | // Upper bound on hits the rebuilt fallback pattern may produce within the scanned scope (the module image when | ||
| 120 | // a range is supplied, the process's executable regions otherwise) before we reject it as ambiguous. The | ||
| 121 | // fallback only exists to recover the single site where a sibling mod inline-hooked the target function, so the | ||
| 122 | // legitimate rewritten pattern must match exactly once: the unique JMP into that mod's trampoline. Any value | ||
| 123 | // above 1 admits a false positive whose blast radius (a hook installed at an unrelated function) far outweighs | ||
| 124 | // the benefit of tolerating duplicate matches. | ||
| 125 | constexpr std::size_t PROLOGUE_FALLBACK_MAX_HITS = 1; | ||
| 126 | |||
| 127 | // Bytes each recognised inline-hook prologue shape overwrites. E9 rel32 is five bytes (one opcode plus one | ||
| 128 | // int32 displacement); FF 25 disp32 is six (a two-byte opcode plus one int32 RIP-relative displacement). The | ||
| 129 | // 14-byte FF 25 absolute form is the same two-byte opcode and a disp32 of zero, followed by the 8-byte absolute | ||
| 130 | // target inlined immediately after the instruction (RIP then points at it). The `mov rax, imm64; jmp rax` | ||
| 131 | // absolute jump is twelve bytes: REX.W B8 plus an 8-byte immediate, then the two-byte FF E0. | ||
| 132 | constexpr std::size_t PROLOGUE_PATCH_BYTES_E9 = 5; | ||
| 133 | constexpr std::size_t PROLOGUE_PATCH_BYTES_FF25 = 6; | ||
| 134 | constexpr std::size_t PROLOGUE_PATCH_BYTES_FF25_ABS64 = 14; | ||
| 135 | constexpr std::size_t PROLOGUE_PATCH_BYTES_MOV_RAX_JMP = 12; | ||
| 136 | |||
| 137 | /** | ||
| 138 | * @struct PrologueShape | ||
| 139 | * @brief One inline-hook prologue shape the fallback can rebuild and recover. | ||
| 140 | * @details @ref patch_bytes is how many leading prologue bytes the hook overwrites; @ref jump_prefix is the AOB | ||
| 141 | * fragment that replaces them; @ref decode recovers the absolute target the jump redirects to so the | ||
| 142 | * rebuilt match can be gated as a real hook rather than a coincidental opcode collision. | ||
| 143 | */ | ||
| 144 | struct PrologueShape | ||
| 145 | { | ||
| 146 | std::size_t patch_bytes = 0; | ||
| 147 | std::string_view jump_prefix; | ||
| 148 | std::optional<std::uintptr_t> (*decode)(std::uintptr_t) noexcept = nullptr; | ||
| 149 | }; | ||
| 150 | |||
| 151 | // Inline-hook prologue shapes the fallback tries, in order. E9 is the five-byte near jump SafetyHook / MinHook | ||
| 152 | // emit for a trampoline within rel32 reach; the rest are the far-jump shapes a hook emits when the trampoline | ||
| 153 | // is beyond rel32 reach: | ||
| 154 | // - FF 25 disp32: a six-byte RIP-relative indirect jump whose disp32 points at a separate pointer slot | ||
| 155 | // (a Detours-style far jump) -- decode_ff25_indirect dereferences that slot. | ||
| 156 | // - FF 25 00000000 <abs64>: the fourteen-byte absolute form, a disp32 of zero so the slot is the 8-byte | ||
| 157 | // absolute target inlined right after the instruction. It reuses decode_ff25_indirect, which already | ||
| 158 | // resolves the disp32==0 slot at address+6. A six-byte FF 25 shape cannot recover it: a 14-byte overwrite | ||
| 159 | // leaves a different surviving tail, so the two shapes are disjoint and never alias. | ||
| 160 | // - 48 B8 <imm64> FF E0: the twelve-byte `mov rax, imm64; jmp rax` absolute jump some libraries emit instead | ||
| 161 | // of the FF 25 form -- decode_mov_rax_imm64_jmp_rax returns the inlined imm64 directly (no slot read). | ||
| 162 | // Each shape rebuilds a distinct pattern from the original signature (its own patch_bytes leading drop plus its | ||
| 163 | // jump_prefix), and the prefixes' literal opcode bytes (E9 vs FF 25 vs 48 B8) make the shapes mutually | ||
| 164 | // exclusive at a real hook site, so the try order only affects which is attempted first, never correctness. E9 | ||
| 165 | // is first because it is by far the common case; the FF 25 variants precede the rarer mov rax form. | ||
| 166 | constexpr std::array<PrologueShape, 4> PROLOGUE_SHAPES = {{ | ||
| 167 | {PROLOGUE_PATCH_BYTES_E9, std::string_view{"E9 ?? ?? ?? ??"}, &DetourModKit::detail::decode_e9_rel32}, | ||
| 168 | {PROLOGUE_PATCH_BYTES_FF25, std::string_view{"FF 25 ?? ?? ?? ??"}, | ||
| 169 | &DetourModKit::detail::decode_ff25_indirect}, | ||
| 170 | {PROLOGUE_PATCH_BYTES_FF25_ABS64, std::string_view{"FF 25 00 00 00 00 ?? ?? ?? ?? ?? ?? ?? ??"}, | ||
| 171 | &DetourModKit::detail::decode_ff25_indirect}, | ||
| 172 | {PROLOGUE_PATCH_BYTES_MOV_RAX_JMP, std::string_view{"48 B8 ?? ?? ?? ?? ?? ?? ?? ?? FF E0"}, | ||
| 173 | &DetourModKit::detail::decode_mov_rax_imm64_jmp_rax}, | ||
| 174 | }}; | ||
| 175 | |||
| 176 | 286 | void append_hex_byte(std::string &out, std::byte value) | |
| 177 | { | ||
| 178 | 286 | constexpr char digits[] = "0123456789ABCDEF"; | |
| 179 | 286 | const auto byte_value = std::to_integer<unsigned>(value); | |
| 180 |
1/2✓ Branch 4 → 5 taken 286 times.
✗ Branch 4 → 7 not taken.
|
286 | out.push_back(digits[(byte_value >> 4) & 0xF]); |
| 181 |
1/2✓ Branch 5 → 6 taken 286 times.
✗ Branch 5 → 7 not taken.
|
286 | out.push_back(digits[byte_value & 0xF]); |
| 182 | 286 | } | |
| 183 | |||
| 184 | // Re-emits one parsed (byte, mask) pair as the AOB token parse_aob would accept: a full literal (mask 0xFF), a | ||
| 185 | // per-nibble token (mask 0xF0 / 0x0F), or a full wildcard (mask 0x00). Keeping the round-trip token-exact | ||
| 186 | // preserves a partially-masked tail byte instead of widening it to a full byte (which would over-constrain) or | ||
| 187 | // to a wildcard. | ||
| 188 | 286 | void append_pattern_token(std::string &out, std::byte value, std::byte mask) | |
| 189 | { | ||
| 190 | 572 | constexpr char digits[] = "0123456789ABCDEF"; | |
| 191 | 286 | const auto byte_value = std::to_integer<unsigned>(value); | |
| 192 |
1/4✓ Branch 6 → 7 taken 286 times.
✗ Branch 6 → 9 not taken.
✗ Branch 6 → 12 not taken.
✗ Branch 6 → 15 not taken.
|
286 | switch (std::to_integer<unsigned>(mask)) |
| 193 | { | ||
| 194 | 286 | case 0xFF: | |
| 195 |
1/2✓ Branch 7 → 8 taken 286 times.
✗ Branch 7 → 18 not taken.
|
286 | append_hex_byte(out, value); |
| 196 | 286 | break; | |
| 197 | ✗ | case 0xF0: | |
| 198 | ✗ | out.push_back(digits[(byte_value >> 4) & 0xF]); | |
| 199 | ✗ | out.push_back('?'); | |
| 200 | ✗ | break; | |
| 201 | ✗ | case 0x0F: | |
| 202 | ✗ | out.push_back('?'); | |
| 203 | ✗ | out.push_back(digits[byte_value & 0xF]); | |
| 204 | ✗ | break; | |
| 205 | ✗ | default: | |
| 206 | ✗ | out.append("??"); | |
| 207 | ✗ | break; | |
| 208 | } | ||
| 209 | 286 | } | |
| 210 | |||
| 211 | // Build from the parsed pattern so parse_aob remains the only token parser. The caller keeps original.offset | ||
| 212 | // and applies it after the fallback scan, which makes `|`-anchored recovery match the direct path exactly. | ||
| 213 | // patch_bytes leading prologue bytes are replaced by jump_prefix; the surviving literal tail is re-emitted | ||
| 214 | // token-exact. Only fully-known tail bytes count toward the literal-tail floor: a partially-masked nibble byte | ||
| 215 | // adds context but not a full byte of false-positive defense, so the conservative count keeps the uniqueness | ||
| 216 | // pre-filter strict. | ||
| 217 | 55 | std::string build_hooked_prologue_pattern(const DetourModKit::Scanner::CompiledPattern &original, | |
| 218 | std::size_t patch_bytes, std::string_view jump_prefix) | ||
| 219 | { | ||
| 220 |
2/2✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 51 times.
|
55 | if (original.size() < patch_bytes) |
| 221 | { | ||
| 222 | 4 | return {}; | |
| 223 | } | ||
| 224 | 51 | int literal_tail_count = 0; | |
| 225 |
2/2✓ Branch 11 → 6 taken 386 times.
✓ Branch 11 → 12 taken 51 times.
|
437 | for (std::size_t i = patch_bytes; i < original.size(); ++i) |
| 226 | { | ||
| 227 |
2/2✓ Branch 7 → 8 taken 379 times.
✓ Branch 7 → 9 taken 7 times.
|
386 | if (original.mask[i] == std::byte{0xFF}) |
| 228 | { | ||
| 229 | 379 | ++literal_tail_count; | |
| 230 | } | ||
| 231 | } | ||
| 232 |
2/2✓ Branch 12 → 13 taken 26 times.
✓ Branch 12 → 14 taken 25 times.
|
51 | if (literal_tail_count < PROLOGUE_FALLBACK_MIN_TAIL_LITERALS) |
| 233 | { | ||
| 234 | 26 | return {}; | |
| 235 | } | ||
| 236 |
1/2✓ Branch 16 → 17 taken 25 times.
✗ Branch 16 → 30 not taken.
|
25 | std::string out(jump_prefix); |
| 237 |
2/2✓ Branch 25 → 19 taken 286 times.
✓ Branch 25 → 26 taken 25 times.
|
311 | for (std::size_t i = patch_bytes; i < original.size(); ++i) |
| 238 | { | ||
| 239 |
1/2✓ Branch 19 → 20 taken 286 times.
✗ Branch 19 → 33 not taken.
|
286 | out.push_back(' '); |
| 240 |
1/2✓ Branch 22 → 23 taken 286 times.
✗ Branch 22 → 33 not taken.
|
286 | append_pattern_token(out, original.bytes[i], original.mask[i]); |
| 241 | } | ||
| 242 | 25 | return out; | |
| 243 | 25 | } | |
| 244 | |||
| 245 | // Counts up to (max_hits + 1) occurrences of `pattern`. When `range` is set the count is confined to that | ||
| 246 | // module image; otherwise it spans the whole process's executable regions. Returning max_hits+1 signals "too | ||
| 247 | // many to be unique". The count must use the same scope as the eventual match scan, or a pattern unique inside | ||
| 248 | // the target module but duplicated in a sibling overlay would be wrongly rejected (or accepted) on the wrong | ||
| 249 | // evidence. When first_match_out is non-null it receives the first occurrence (the n == 1 scan result, or | ||
| 250 | // nullptr when there were no hits) so a caller that needs both the count and the first match -- the same scan, | ||
| 251 | // same scope -- does not have to sweep again to fetch it. | ||
| 252 | // | ||
| 253 | // When incomplete_out is non-null it receives whether any scan in this count skipped a region that faulted | ||
| 254 | // mid-scan. A faulted region means the returned count is a lower bound: a second occurrence could have lived in | ||
| 255 | // the skipped bytes, so a count of 1 over an incomplete sweep does NOT prove uniqueness. The flag is cleared | ||
| 256 | // here once before the count loop and read after, so the result reflects only the scans this call performed | ||
| 257 | // (not a stale fault from an earlier scan on the same thread). The count itself is unchanged; the caller | ||
| 258 | // decides how to treat the incomplete signal. | ||
| 259 | 25 | std::size_t count_pattern_hits_bounded(const DetourModKit::Scanner::CompiledPattern &pattern, | |
| 260 | std::size_t max_hits, | ||
| 261 | std::optional<DetourModKit::Memory::ModuleRange> range, | ||
| 262 | const std::byte **first_match_out = nullptr, | ||
| 263 | bool *incomplete_out = nullptr) noexcept | ||
| 264 | { | ||
| 265 |
1/2✓ Branch 2 → 3 taken 25 times.
✗ Branch 2 → 4 not taken.
|
25 | if (first_match_out != nullptr) |
| 266 | { | ||
| 267 | 25 | *first_match_out = nullptr; | |
| 268 | } | ||
| 269 | // Clear once before the loop so the flag reflects only the scans below; the region walk OR-sets it on a | ||
| 270 | // faulted skip and never clears it, so a prior scan on this thread must not bleed into this verdict. | ||
| 271 | 25 | DetourModKit::Scanner::detail::scan_incomplete_flag() = false; | |
| 272 | 25 | std::size_t hits = 0; | |
| 273 |
2/2✓ Branch 18 → 6 taken 39 times.
✓ Branch 18 → 19 taken 3 times.
|
42 | for (std::size_t n = 1; n <= max_hits + 1; ++n) |
| 274 | { | ||
| 275 |
2/2✓ Branch 7 → 8 taken 31 times.
✓ Branch 7 → 10 taken 8 times.
|
39 | const auto *match = range ? Scanner::detail::scan_module_executable(pattern, *range, n) |
| 276 | 8 | : DetourModKit::Scanner::scan_executable_regions(pattern, n); | |
| 277 |
2/2✓ Branch 12 → 13 taken 22 times.
✓ Branch 12 → 14 taken 17 times.
|
39 | if (match == nullptr) |
| 278 | { | ||
| 279 | 22 | break; | |
| 280 | } | ||
| 281 |
3/4✓ Branch 14 → 15 taken 14 times.
✓ Branch 14 → 17 taken 3 times.
✓ Branch 15 → 16 taken 14 times.
✗ Branch 15 → 17 not taken.
|
17 | if (n == 1 && first_match_out != nullptr) |
| 282 | { | ||
| 283 | 14 | *first_match_out = match; | |
| 284 | } | ||
| 285 | 17 | ++hits; | |
| 286 | } | ||
| 287 |
1/2✓ Branch 19 → 20 taken 25 times.
✗ Branch 19 → 22 not taken.
|
25 | if (incomplete_out != nullptr) |
| 288 | { | ||
| 289 | 25 | *incomplete_out = DetourModKit::Scanner::detail::scan_incomplete_flag(); | |
| 290 | } | ||
| 291 | 25 | return hits; | |
| 292 | } | ||
| 293 | |||
| 294 | struct CascadeAttempt | ||
| 295 | { | ||
| 296 | std::uintptr_t address{0}; | ||
| 297 | std::size_t index{0}; | ||
| 298 | bool success{false}; | ||
| 299 | }; | ||
| 300 | |||
| 301 | 173 | CascadeAttempt scan_candidates(std::span<const DetourModKit::Scanner::AddrCandidate> candidates, | |
| 302 | bool &all_parse_failed, DetourModKit::Scanner::ScannerKind kind, | ||
| 303 | std::optional<DetourModKit::Memory::ModuleRange> range = std::nullopt) | ||
| 304 | { | ||
| 305 | using DetourModKit::Scanner::ResolveMode; | ||
| 306 | 173 | DetourModKit::Logger &logger = DetourModKit::Logger::get_instance(); | |
| 307 | 172 | all_parse_failed = true; | |
| 308 |
2/2✓ Branch 75 → 4 taken 184 times.
✓ Branch 75 → 76 taken 37 times.
|
221 | for (std::size_t i = 0; i < candidates.size(); ++i) |
| 309 | { | ||
| 310 | 184 | const auto &candidate = candidates[i]; | |
| 311 | |||
| 312 | // Name/string tiers branch before parse_aob: their pattern field is a mangled type name or literal, | ||
| 313 | // not byte syntax. They are module-scoped signals, so a range-less cascade uses the host image and an | ||
| 314 | // explicit-range cascade passes that image through. Marking the candidate as valid keeps a | ||
| 315 | // name/string-only miss on the NoMatch path; AllPatternsInvalid is reserved for byte rows whose AOB | ||
| 316 | // text cannot parse. | ||
| 317 |
4/4✓ Branch 5 → 6 taken 180 times.
✓ Branch 5 → 7 taken 5 times.
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 30 taken 172 times.
|
185 | if (candidate.mode == ResolveMode::RttiVtable || candidate.mode == ResolveMode::StringXref) |
| 318 | { | ||
| 319 | 13 | all_parse_failed = false; | |
| 320 |
1/2✓ Branch 8 → 9 taken 13 times.
✗ Branch 8 → 11 not taken.
|
13 | const auto selected_range = range ? *range : DetourModKit::Memory::host_module_range(); |
| 321 |
1/2✓ Branch 12 → 13 taken 13 times.
✗ Branch 12 → 78 not taken.
|
13 | const auto resolved = resolve_named_candidate(candidate, selected_range); |
| 322 |
2/2✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 16 taken 4 times.
|
13 | if (!resolved) |
| 323 | { | ||
| 324 | 9 | continue; | |
| 325 | } | ||
| 326 | // A module-scoped cascade still bounds the result to its image. Both backends already confine | ||
| 327 | // their search to selected_range, so for the host-module default (range unset) the result is | ||
| 328 | // in-image by construction and this check is skipped; it guards only the explicit-range path, | ||
| 329 | // mirroring the byte-mode contains() gate below. | ||
| 330 |
3/6✓ Branch 17 → 18 taken 4 times.
✗ Branch 17 → 23 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 4 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 4 times.
|
4 | if (range && !DetourModKit::Memory::contains(*range, *resolved)) |
| 331 | { | ||
| 332 | ✗ | continue; | |
| 333 | } | ||
| 334 | 4 | return CascadeAttempt{*resolved, i, true}; | |
| 335 | } | ||
| 336 | |||
| 337 |
1/2✓ Branch 30 → 31 taken 170 times.
✗ Branch 30 → 85 not taken.
|
172 | auto compiled = DetourModKit::Scanner::parse_aob(candidate.pattern); |
| 338 |
2/2✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 39 taken 168 times.
|
170 | if (!compiled) |
| 339 | { | ||
| 340 |
1/2✓ Branch 37 → 38 taken 2 times.
✗ Branch 37 → 79 not taken.
|
2 | logger.warning("Scanner: Failed to parse AOB for candidate '{}'.", |
| 341 |
1/2✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 2 times.
|
2 | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name); |
| 342 | 2 | continue; | |
| 343 | } | ||
| 344 | 168 | all_parse_failed = false; | |
| 345 | |||
| 346 | // Resolves the Nth occurrence in the active scope. A supplied module range takes precedence over | ||
| 347 | // `kind`: one scan of the contiguous image already covers both .text and .rdata / .data candidates, so | ||
| 348 | // the executable-vs-readable split is moot inside it. | ||
| 349 | 301 | const auto scan_for = [&](std::size_t occurrence) -> const std::byte * | |
| 350 | { | ||
| 351 |
2/2✓ Branch 3 → 4 taken 279 times.
✓ Branch 3 → 7 taken 22 times.
|
301 | if (range) |
| 352 | { | ||
| 353 | 279 | return Scanner::detail::scan_module_readable(*compiled, *range, occurrence); | |
| 354 | } | ||
| 355 | 22 | return (kind == DetourModKit::Scanner::ScannerKind::Readable) | |
| 356 |
2/2✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 11 taken 19 times.
|
22 | ? DetourModKit::Scanner::scan_readable_regions(*compiled, occurrence) |
| 357 | 22 | : DetourModKit::Scanner::scan_executable_regions(*compiled, occurrence); | |
| 358 | 168 | }; | |
| 359 | |||
| 360 |
1/2✓ Branch 39 → 40 taken 170 times.
✗ Branch 39 → 83 not taken.
|
168 | const auto *match = scan_for(1); |
| 361 |
2/2✓ Branch 40 → 41 taken 31 times.
✓ Branch 40 → 42 taken 139 times.
|
170 | if (match == nullptr) |
| 362 | { | ||
| 363 | 31 | continue; | |
| 364 | } | ||
| 365 | |||
| 366 | // Per-candidate uniqueness guard. A candidate flagged require_unique must match exactly once in the | ||
| 367 | // scanned scope; a second occurrence means the pattern is ambiguous, so the first (lowest-address) | ||
| 368 | // match is not provably the intended target. Skip it so the cascade falls through to the next candidate | ||
| 369 | // instead of committing to an arbitrary hit -- the choice between equally-matching sites is semantic | ||
| 370 | // and the scanner cannot make it. The extra occurrence scan only runs for a candidate that already | ||
| 371 | // matched and has require_unique set (the default); set require_unique = false to accept the first | ||
| 372 | // match and skip it. | ||
| 373 |
7/8✓ Branch 42 → 43 taken 133 times.
✓ Branch 42 → 46 taken 6 times.
✓ Branch 43 → 44 taken 133 times.
✗ Branch 43 → 83 not taken.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 46 taken 130 times.
✓ Branch 47 → 48 taken 3 times.
✓ Branch 47 → 54 taken 136 times.
|
139 | if (candidate.require_unique && scan_for(2) != nullptr) |
| 374 | { | ||
| 375 |
1/2✓ Branch 52 → 53 taken 3 times.
✗ Branch 52 → 81 not taken.
|
3 | logger.debug("Scanner: candidate '{}' skipped: matches more than once in " |
| 376 | "the scanned scope (require_unique).", | ||
| 377 |
1/2✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 3 times.
|
3 | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name); |
| 378 | 3 | continue; | |
| 379 | } | ||
| 380 | |||
| 381 | 136 | const auto addr = resolve_candidate_match(reinterpret_cast<std::uintptr_t>(match), candidate); | |
| 382 | // A RipRelative candidate whose displacement read faulted or resolved to an implausible address yields | ||
| 383 | // nothing here (resolve_candidate_match applies the same plausibility floor as resolve_rip_relative), | ||
| 384 | // so the whole-process path cannot commit to a near-null or kernel-range hit. The module-scoped path | ||
| 385 | // below adds a stricter in-image guard. | ||
| 386 |
2/2✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 58 taken 134 times.
|
136 | if (!addr) |
| 387 | { | ||
| 388 | 2 | continue; | |
| 389 | } | ||
| 390 | // Module-scoped resolutions must land inside the image. The match site is already in-range (the | ||
| 391 | // module-scoped scan only searches it), but a RipRelative disp read at an in-module instruction can | ||
| 392 | // still resolve outside the image (e.g. an import thunk in another module). Reject that here so the | ||
| 393 | // cascade falls through to the next candidate instead of committing to an out-of-module address -- a | ||
| 394 | // decision a post-resolution check by the caller could not reverse. | ||
| 395 |
6/6✓ Branch 59 → 60 taken 128 times.
✓ Branch 59 → 65 taken 6 times.
✓ Branch 63 → 64 taken 2 times.
✓ Branch 63 → 65 taken 126 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 66 → 68 taken 132 times.
|
134 | if (range && !DetourModKit::Memory::contains(*range, *addr)) |
| 396 | { | ||
| 397 | 2 | continue; | |
| 398 | } | ||
| 399 | 132 | return CascadeAttempt{*addr, i, true}; | |
| 400 |
2/2✓ Branch 71 → 72 taken 40 times.
✓ Branch 71 → 73 taken 132 times.
|
172 | } |
| 401 | 37 | return CascadeAttempt{0, 0, false}; | |
| 402 | } | ||
| 403 | |||
| 404 | struct PrologueFallbackResult | ||
| 405 | { | ||
| 406 | CascadeAttempt attempt{}; | ||
| 407 | // True until some Direct candidate yields a usable rebuilt pattern (enough literal tail to scan). | ||
| 408 | bool not_applicable{true}; | ||
| 409 | // True once any Direct candidate parsed -- i.e. the fallback had a real target to rebuild. Lets finalize | ||
| 410 | // tell "Direct present but tail too short" (PrologueFallbackNotApplicable) from "no Direct candidate at | ||
| 411 | // all" (a plain NoMatch); a name/string/RipRelative-only cascade leaves this false. | ||
| 412 | bool had_fallback_candidate{false}; | ||
| 413 | }; | ||
| 414 | |||
| 415 | // Attempts one prologue shape for a single Direct candidate: rebuilds the patched prologue as shape.jump_prefix | ||
| 416 | // plus the candidate's surviving literal tail, requires that rebuilt pattern to match exactly once in scope, | ||
| 417 | // decodes the jump to recover the redirected target, and gates that destination as a plausible, executable | ||
| 418 | // address. Returns the resolved attempt on success, or nullopt when this shape does not apply or does not | ||
| 419 | // uniquely recover a target. *applicable is set true once the shape produces a usable rebuilt pattern (enough | ||
| 420 | // literal tail), regardless of whether it then matches, so the caller can distinguish "no shape was applicable" | ||
| 421 | // from "applicable but no match". | ||
| 422 | 55 | std::optional<CascadeAttempt> try_prologue_shape(const DetourModKit::Scanner::AddrCandidate &candidate, | |
| 423 | std::size_t index, | ||
| 424 | const DetourModKit::Scanner::CompiledPattern &original, | ||
| 425 | std::optional<DetourModKit::Memory::ModuleRange> range, | ||
| 426 | const PrologueShape &shape, bool &applicable) | ||
| 427 | { | ||
| 428 |
1/2✓ Branch 2 → 3 taken 55 times.
✗ Branch 2 → 74 not taken.
|
55 | DetourModKit::Logger &logger = DetourModKit::Logger::get_instance(); |
| 429 |
1/2✓ Branch 3 → 4 taken 55 times.
✗ Branch 3 → 74 not taken.
|
55 | const std::string hooked = build_hooked_prologue_pattern(original, shape.patch_bytes, shape.jump_prefix); |
| 430 |
2/2✓ Branch 5 → 6 taken 30 times.
✓ Branch 5 → 7 taken 25 times.
|
55 | if (hooked.empty()) |
| 431 | { | ||
| 432 | 30 | return std::nullopt; | |
| 433 | } | ||
| 434 |
1/2✓ Branch 8 → 9 taken 25 times.
✗ Branch 8 → 72 not taken.
|
25 | auto compiled = DetourModKit::Scanner::parse_aob(hooked); |
| 435 |
1/2✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 25 times.
|
25 | if (!compiled) |
| 436 | { | ||
| 437 | ✗ | return std::nullopt; | |
| 438 | } | ||
| 439 | 25 | applicable = true; | |
| 440 | 25 | const std::byte *first_match = nullptr; | |
| 441 | 25 | bool count_incomplete = false; | |
| 442 | 25 | const std::size_t hits = count_pattern_hits_bounded(*compiled, PROLOGUE_FALLBACK_MAX_HITS, range, | |
| 443 | 25 | &first_match, &count_incomplete); | |
| 444 |
2/2✓ Branch 14 → 15 taken 11 times.
✓ Branch 14 → 16 taken 14 times.
|
25 | if (hits == 0) |
| 445 | { | ||
| 446 | 11 | return std::nullopt; | |
| 447 | } | ||
| 448 | // Fail closed when the uniqueness count ran over an incomplete sweep. A region that faulted mid-scan | ||
| 449 | // (concurrent decommit / reprotect) was skipped, so a second occurrence of the rebuilt jump pattern could | ||
| 450 | // have lived in the unscanned bytes. The fallback's whole safety rests on the rebuilt pattern matching | ||
| 451 | // exactly once -- the single site a sibling mod inline-hooked -- so an unproven count must be treated as | ||
| 452 | // ambiguous, not as a unique hit. Rejecting here lets the cascade fall through to the next candidate / | ||
| 453 | // shape rather than commit a hook at a possibly-wrong address whose blast radius (an unrelated function) | ||
| 454 | // is severe. | ||
| 455 |
1/2✗ Branch 16 → 17 not taken.
✓ Branch 16 → 23 taken 14 times.
|
14 | if (count_incomplete) |
| 456 | { | ||
| 457 | ✗ | logger.debug("Scanner: prologue fallback rejected for '{}': uniqueness count incomplete (a region " | |
| 458 | "faulted mid-scan); uniqueness unproven, failing closed", | ||
| 459 | ✗ | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name); | |
| 460 | ✗ | return std::nullopt; | |
| 461 | } | ||
| 462 |
2/2✓ Branch 23 → 24 taken 3 times.
✓ Branch 23 → 30 taken 11 times.
|
14 | if (hits > PROLOGUE_FALLBACK_MAX_HITS) |
| 463 | { | ||
| 464 |
1/2✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 63 not taken.
|
3 | logger.debug("Scanner: prologue fallback rejected for '{}': {} hits exceed uniqueness ceiling ({})", |
| 465 |
1/2✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 3 times.
|
3 | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name, hits, |
| 466 | PROLOGUE_FALLBACK_MAX_HITS); | ||
| 467 | 3 | return std::nullopt; | |
| 468 | } | ||
| 469 | // Reuse the first occurrence the count already located (hits is in [1, PROLOGUE_FALLBACK_MAX_HITS] here, so | ||
| 470 | // first_match is the unique match) instead of re-sweeping the same scope for it. | ||
| 471 | 11 | const auto match_addr = reinterpret_cast<std::uintptr_t>(first_match); | |
| 472 | 11 | const auto decoded = shape.decode(match_addr); | |
| 473 |
1/2✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 11 times.
|
11 | if (!decoded) |
| 474 | { | ||
| 475 | ✗ | return std::nullopt; | |
| 476 | } | ||
| 477 | 11 | const auto jmp_destination = *decoded; | |
| 478 | // The rewritten jump was FOUND inside the target (the module image when `range` is set, the process | ||
| 479 | // otherwise), but its destination must NOT be constrained to that module. When a sibling mod inline-hooks | ||
| 480 | // the target, its jump can land on a trampoline the sibling allocated outside every loaded module; | ||
| 481 | // SafetyHook does this for inline hooks, and an FF 25 far jump stores an absolute trampoline address in its | ||
| 482 | // slot. An in-module requirement would reject the very recovery this path exists for. Gate the destination | ||
| 483 | // on "plausible user-space pointer on a committed, execute-readable page" instead: that still rejects a | ||
| 484 | // jump into unmapped or data-only memory (a coincidental opcode match whose tail happened to align), while | ||
| 485 | // the uniqueness ceiling and the literal-tail floor remain the primary false-positive defense. | ||
| 486 |
3/4✓ Branch 36 → 37 taken 11 times.
✗ Branch 36 → 39 not taken.
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 50 taken 8 times.
|
22 | if (!DetourModKit::Memory::plausible_userspace_ptr(jmp_destination) || |
| 487 |
2/2✓ Branch 38 → 39 taken 3 times.
✓ Branch 38 → 40 taken 8 times.
|
11 | !Scanner::detail::is_executable_address(jmp_destination)) |
| 488 | { | ||
| 489 |
1/2✓ Branch 47 → 48 taken 3 times.
✗ Branch 47 → 65 not taken.
|
3 | logger.debug("Scanner: prologue fallback rejected for '{}': jump destination {} is not executable", |
| 490 |
1/2✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 3 times.
|
3 | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name, |
| 491 |
1/2✓ Branch 42 → 43 taken 3 times.
✗ Branch 42 → 69 not taken.
|
6 | Format::format_address(jmp_destination)); |
| 492 | 3 | return std::nullopt; | |
| 493 | } | ||
| 494 | |||
| 495 | // The rebuilt pattern compiled with offset 0: replacing the prologue with the jump prefix dropped the | ||
| 496 | // original | ||
| 497 | // `|` anchor, so its scan returned the bare match start. Reconstruct the anchored match the direct pass | ||
| 498 | // would have produced (match start plus the original anchor offset) before resolving, so a `|`-anchored | ||
| 499 | // Direct candidate recovered here lands on the same byte as the unhooked direct scan instead of short by | ||
| 500 | // the offset. | ||
| 501 | 8 | const auto anchored_match = match_addr + static_cast<std::uintptr_t>(original.offset); | |
| 502 | 8 | const auto addr = resolve_candidate_match(anchored_match, candidate); | |
| 503 |
1/2✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 8 times.
|
8 | if (!addr) |
| 504 | { | ||
| 505 | ✗ | return std::nullopt; | |
| 506 | } | ||
| 507 | 8 | return CascadeAttempt{*addr, index, true}; | |
| 508 | 55 | } | |
| 509 | |||
| 510 | PrologueFallbackResult | ||
| 511 | 19 | scan_candidates_hooked_prologue(std::span<const DetourModKit::Scanner::AddrCandidate> candidates, | |
| 512 | std::optional<DetourModKit::Memory::ModuleRange> range = std::nullopt) | ||
| 513 | { | ||
| 514 | using DetourModKit::Scanner::ResolveMode; | ||
| 515 | 19 | DetourModKit::Logger &logger = DetourModKit::Logger::get_instance(); | |
| 516 | 19 | PrologueFallbackResult out; | |
| 517 |
2/2✓ Branch 38 → 4 taken 22 times.
✓ Branch 38 → 39 taken 11 times.
|
33 | for (std::size_t i = 0; i < candidates.size(); ++i) |
| 518 | { | ||
| 519 | 22 | const auto &candidate = candidates[i]; | |
| 520 |
2/2✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 18 times.
|
22 | if (candidate.mode != ResolveMode::Direct) |
| 521 | { | ||
| 522 | 4 | continue; | |
| 523 | } | ||
| 524 | // Parse the original signature with the same parser the direct pass uses, so the fallback inherits its | ||
| 525 | // token validation and -- critically -- its `|` anchor offset, carried into the resolve below. A | ||
| 526 | // malformed pattern was already reported by the direct pass, so skip it silently here. | ||
| 527 |
1/2✓ Branch 7 → 8 taken 18 times.
✗ Branch 7 → 46 not taken.
|
18 | const auto original = DetourModKit::Scanner::parse_aob(candidate.pattern); |
| 528 |
1/2✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 18 times.
|
18 | if (!original) |
| 529 | { | ||
| 530 | ✗ | continue; | |
| 531 | } | ||
| 532 | // A Direct candidate that parses is a real fallback target, even if its literal tail later proves too | ||
| 533 | // short to rebuild. Recording that lets finalize distinguish "tail too short" | ||
| 534 | // (PrologueFallbackNotApplicable) from "no Direct candidate at all" (a plain NoMatch). | ||
| 535 | 18 | out.had_fallback_candidate = true; | |
| 536 | // Try each recognised inline-hook prologue shape in turn. The first that uniquely recovers an | ||
| 537 | // executable target wins; E9 is tried before FF 25 because it is by far the common case. | ||
| 538 | 18 | bool any_applicable = false; | |
| 539 |
2/2✓ Branch 21 → 12 taken 55 times.
✓ Branch 21 → 22 taken 10 times.
|
65 | for (const PrologueShape &shape : PROLOGUE_SHAPES) |
| 540 | { | ||
| 541 | 55 | bool applicable = false; | |
| 542 |
1/2✓ Branch 13 → 14 taken 55 times.
✗ Branch 13 → 41 not taken.
|
55 | const auto attempt = try_prologue_shape(candidate, i, *original, range, shape, applicable); |
| 543 |
2/2✓ Branch 14 → 15 taken 25 times.
✓ Branch 14 → 16 taken 30 times.
|
55 | if (applicable) |
| 544 | { | ||
| 545 | 25 | any_applicable = true; | |
| 546 | 25 | out.not_applicable = false; | |
| 547 | } | ||
| 548 |
2/2✓ Branch 17 → 18 taken 8 times.
✓ Branch 17 → 20 taken 47 times.
|
55 | if (attempt) |
| 549 | { | ||
| 550 | 8 | out.attempt = *attempt; | |
| 551 | 8 | return out; | |
| 552 | } | ||
| 553 | } | ||
| 554 |
2/2✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 29 taken 7 times.
|
10 | if (!any_applicable) |
| 555 | { | ||
| 556 |
1/2✓ Branch 27 → 28 taken 3 times.
✗ Branch 27 → 42 not taken.
|
3 | logger.debug("Scanner: prologue fallback skipped for '{}' (insufficient literal tail bytes)", |
| 557 |
1/2✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 3 times.
|
6 | candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name); |
| 558 | } | ||
| 559 |
2/3✓ Branch 31 → 32 taken 10 times.
✗ Branch 31 → 34 not taken.
✓ Branch 31 → 35 taken 8 times.
|
18 | } |
| 560 | 11 | return out; | |
| 561 | } | ||
| 562 | |||
| 563 | // Maps a finished non-fallback cascade attempt to the public result, emitting the single success debug line or | ||
| 564 | // the matching failure diagnostic. Shared by the whole-process and module-scoped resolvers so the three-way | ||
| 565 | // mapping (success / AllPatternsInvalid / NoMatch) stays identical across both. | ||
| 566 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 567 | 151 | finalize_cascade(const CascadeAttempt &attempt, bool all_parse_failed, | |
| 568 | std::span<const DetourModKit::Scanner::AddrCandidate> candidates, std::string_view label) | ||
| 569 | { | ||
| 570 | using DetourModKit::Scanner::ResolveError; | ||
| 571 | using DetourModKit::Scanner::ResolveHit; | ||
| 572 | 151 | DetourModKit::Logger &logger = DetourModKit::Logger::get_instance(); | |
| 573 |
2/2✓ Branch 3 → 4 taken 133 times.
✓ Branch 3 → 14 taken 18 times.
|
151 | if (attempt.success) |
| 574 | { | ||
| 575 | 133 | const auto &winner = candidates[attempt.index]; | |
| 576 |
1/2✓ Branch 10 → 11 taken 133 times.
✗ Branch 10 → 24 not taken.
|
132 | logger.debug("{} resolved via '{}' at {}", label, |
| 577 |
1/2✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 132 times.
|
133 | winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name, |
| 578 |
1/2✓ Branch 5 → 6 taken 133 times.
✗ Branch 5 → 28 not taken.
|
266 | Format::format_address(attempt.address)); |
| 579 | 133 | return ResolveHit{attempt.address, winner.name}; | |
| 580 | } | ||
| 581 |
2/2✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 19 taken 16 times.
|
18 | if (all_parse_failed) |
| 582 | { | ||
| 583 |
1/2✓ Branch 15 → 16 taken 2 times.
✗ Branch 15 → 29 not taken.
|
2 | logger.error("{}: every byte candidate pattern failed to parse.", label); |
| 584 | 2 | return std::unexpected(ResolveError::AllPatternsInvalid); | |
| 585 | } | ||
| 586 |
1/2✓ Branch 19 → 20 taken 16 times.
✗ Branch 19 → 30 not taken.
|
16 | logger.warning("{}: cascade resolve failed (no candidate matched).", label); |
| 587 | 16 | return std::unexpected(ResolveError::NoMatch); | |
| 588 | } | ||
| 589 | |||
| 590 | // Maps a finished cascade + prologue-fallback attempt to the public result. Mirrors finalize_cascade but adds | ||
| 591 | // the pre-hooked-prologue success line and the fallback-specific failure diagnostics (not-applicable vs | ||
| 592 | // no-match). | ||
| 593 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 594 | 22 | finalize_cascade_with_fallback(const CascadeAttempt &attempt, bool all_parse_failed, | |
| 595 | const PrologueFallbackResult &hooked, | ||
| 596 | std::span<const DetourModKit::Scanner::AddrCandidate> candidates, | ||
| 597 | std::string_view label) | ||
| 598 | { | ||
| 599 | using DetourModKit::Scanner::ResolveError; | ||
| 600 | using DetourModKit::Scanner::ResolveHit; | ||
| 601 | 22 | DetourModKit::Logger &logger = DetourModKit::Logger::get_instance(); | |
| 602 |
2/2✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 14 taken 19 times.
|
22 | if (attempt.success) |
| 603 | { | ||
| 604 | 3 | const auto &winner = candidates[attempt.index]; | |
| 605 |
1/2✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 41 not taken.
|
3 | logger.debug("{} resolved via '{}' at {}", label, |
| 606 |
1/2✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 3 times.
|
3 | winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name, |
| 607 |
1/2✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 45 not taken.
|
6 | Format::format_address(attempt.address)); |
| 608 | 3 | return ResolveHit{attempt.address, winner.name}; | |
| 609 | } | ||
| 610 |
2/2✓ Branch 14 → 15 taken 8 times.
✓ Branch 14 → 25 taken 11 times.
|
19 | if (hooked.attempt.success) |
| 611 | { | ||
| 612 | 8 | const auto &winner = candidates[hooked.attempt.index]; | |
| 613 |
1/2✓ Branch 21 → 22 taken 8 times.
✗ Branch 21 → 46 not taken.
|
8 | logger.debug("{} resolved via '{}' at {} (pre-hooked prologue; reusing target)", label, |
| 614 |
1/2✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 8 times.
|
8 | winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name, |
| 615 |
1/2✓ Branch 16 → 17 taken 8 times.
✗ Branch 16 → 50 not taken.
|
16 | Format::format_address(hooked.attempt.address)); |
| 616 | 8 | return ResolveHit{hooked.attempt.address, winner.name}; | |
| 617 | } | ||
| 618 |
1/2✗ Branch 25 → 26 not taken.
✓ Branch 25 → 30 taken 11 times.
|
11 | if (all_parse_failed) |
| 619 | { | ||
| 620 | ✗ | logger.error("{}: every byte candidate pattern failed to parse.", label); | |
| 621 | ✗ | return std::unexpected(ResolveError::AllPatternsInvalid); | |
| 622 | } | ||
| 623 | // PrologueFallbackNotApplicable means a Direct candidate was present to rebuild but its literal tail was | ||
| 624 | // too short -- not "no fallback ran". A cascade of only name/string/RipRelative tiers has no Direct row to | ||
| 625 | // rebuild, so its full miss is a plain NoMatch, not this diagnostic. | ||
| 626 |
4/4✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 36 taken 7 times.
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 36 taken 1 time.
|
11 | if (hooked.not_applicable && hooked.had_fallback_candidate) |
| 627 | { | ||
| 628 |
1/2✓ Branch 32 → 33 taken 3 times.
✗ Branch 32 → 52 not taken.
|
3 | logger.warning( |
| 629 | "{}: cascade resolve failed; prologue fallback not applicable (insufficient literal tail bytes).", | ||
| 630 | label); | ||
| 631 | 3 | return std::unexpected(ResolveError::PrologueFallbackNotApplicable); | |
| 632 | } | ||
| 633 |
1/2✓ Branch 36 → 37 taken 8 times.
✗ Branch 36 → 53 not taken.
|
8 | logger.warning("{}: cascade resolve failed (including prologue fallback).", label); |
| 634 | 8 | return std::unexpected(ResolveError::NoMatch); | |
| 635 | } | ||
| 636 | } // anonymous namespace | ||
| 637 | |||
| 638 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 639 | 13 | DetourModKit::Scanner::resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label, | |
| 640 | ScannerKind kind) | ||
| 641 | { | ||
| 642 |
1/2✓ Branch 2 → 3 taken 13 times.
✗ Branch 2 → 17 not taken.
|
13 | auto &logger = Logger::get_instance(); |
| 643 | |||
| 644 |
2/2✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 9 taken 11 times.
|
13 | if (candidates.empty()) |
| 645 | { | ||
| 646 |
1/2✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 15 not taken.
|
2 | logger.warning("Scanner: resolve_cascade for '{}' called with no candidates.", label); |
| 647 | 2 | return std::unexpected(ResolveError::EmptyCandidates); | |
| 648 | } | ||
| 649 | |||
| 650 | 11 | bool all_parse_failed = true; | |
| 651 |
1/2✓ Branch 10 → 11 taken 11 times.
✗ Branch 10 → 16 not taken.
|
11 | const auto attempt = scan_candidates(candidates, all_parse_failed, kind); |
| 652 |
1/2✓ Branch 11 → 12 taken 11 times.
✗ Branch 11 → 17 not taken.
|
11 | return finalize_cascade(attempt, all_parse_failed, candidates, label); |
| 653 | } | ||
| 654 | |||
| 655 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 656 | 146 | DetourModKit::Scanner::resolve_cascade_in_module(std::span<const AddrCandidate> candidates, std::string_view label, | |
| 657 | Memory::ModuleRange range) | ||
| 658 | { | ||
| 659 |
1/2✓ Branch 2 → 3 taken 146 times.
✗ Branch 2 → 24 not taken.
|
146 | auto &logger = Logger::get_instance(); |
| 660 | |||
| 661 |
2/2✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 9 taken 143 times.
|
146 | if (candidates.empty()) |
| 662 | { | ||
| 663 |
1/2✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 21 not taken.
|
3 | logger.warning("Scanner: resolve_cascade_in_module for '{}' called with no candidates.", label); |
| 664 | 3 | return std::unexpected(ResolveError::EmptyCandidates); | |
| 665 | } | ||
| 666 |
2/2✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 15 taken 140 times.
|
143 | if (!range.valid()) |
| 667 | { | ||
| 668 |
1/2✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 22 not taken.
|
3 | logger.warning("Scanner: resolve_cascade_in_module for '{}' called with an invalid module range.", label); |
| 669 | 3 | return std::unexpected(ResolveError::InvalidRange); | |
| 670 | } | ||
| 671 | |||
| 672 | 140 | bool all_parse_failed = true; | |
| 673 | // The ScannerKind argument is unused once a range is supplied (see | ||
| 674 | // scan_candidates): one module-scoped scan covers .text and .rdata together. | ||
| 675 |
1/2✓ Branch 16 → 17 taken 140 times.
✗ Branch 16 → 23 not taken.
|
140 | const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable, range); |
| 676 |
1/2✓ Branch 17 → 18 taken 140 times.
✗ Branch 17 → 24 not taken.
|
140 | return finalize_cascade(attempt, all_parse_failed, candidates, label); |
| 677 | } | ||
| 678 | |||
| 679 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 680 | 6 | DetourModKit::Scanner::resolve_cascade_with_prologue_fallback(std::span<const AddrCandidate> candidates, | |
| 681 | std::string_view label) | ||
| 682 | { | ||
| 683 |
1/2✓ Branch 2 → 3 taken 6 times.
✗ Branch 2 → 23 not taken.
|
6 | auto &logger = Logger::get_instance(); |
| 684 | |||
| 685 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 9 taken 6 times.
|
6 | if (candidates.empty()) |
| 686 | { | ||
| 687 | ✗ | logger.warning("Scanner: resolve_cascade_with_prologue_fallback for '{}' called with no candidates.", | |
| 688 | label); | ||
| 689 | ✗ | return std::unexpected(ResolveError::EmptyCandidates); | |
| 690 | } | ||
| 691 | |||
| 692 | // Prologue recovery is a code-shape heuristic (it rebuilds a hooked | ||
| 693 | // near-JMP prologue), so this resolver is executable-only by construction; | ||
| 694 | // the readable sweep is meaningless for it. | ||
| 695 | 6 | bool all_parse_failed = true; | |
| 696 |
1/2✓ Branch 10 → 11 taken 6 times.
✗ Branch 10 → 20 not taken.
|
6 | const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable); |
| 697 | // The prologue-recovery pass is expensive (it rescans the process), so run it only when the direct pass found | ||
| 698 | // nothing. | ||
| 699 | 6 | PrologueFallbackResult hooked; | |
| 700 |
1/2✓ Branch 11 → 12 taken 6 times.
✗ Branch 11 → 15 not taken.
|
6 | if (!attempt.success) |
| 701 | { | ||
| 702 |
1/2✓ Branch 13 → 14 taken 6 times.
✗ Branch 13 → 21 not taken.
|
6 | hooked = scan_candidates_hooked_prologue(candidates); |
| 703 | } | ||
| 704 |
1/2✓ Branch 15 → 16 taken 6 times.
✗ Branch 15 → 23 not taken.
|
6 | return finalize_cascade_with_fallback(attempt, all_parse_failed, hooked, candidates, label); |
| 705 | } | ||
| 706 | |||
| 707 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 708 | 17 | DetourModKit::Scanner::resolve_cascade_in_module_with_prologue_fallback(std::span<const AddrCandidate> candidates, | |
| 709 | std::string_view label, | ||
| 710 | Memory::ModuleRange range) | ||
| 711 | { | ||
| 712 |
1/2✓ Branch 2 → 3 taken 17 times.
✗ Branch 2 → 30 not taken.
|
17 | auto &logger = Logger::get_instance(); |
| 713 | |||
| 714 |
2/2✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 16 times.
|
17 | if (candidates.empty()) |
| 715 | { | ||
| 716 |
1/2✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 25 not taken.
|
1 | logger.warning( |
| 717 | "Scanner: resolve_cascade_in_module_with_prologue_fallback for '{}' called with no candidates.", label); | ||
| 718 | 1 | return std::unexpected(ResolveError::EmptyCandidates); | |
| 719 | } | ||
| 720 |
1/2✗ Branch 10 → 11 not taken.
✓ Branch 10 → 15 taken 16 times.
|
16 | if (!range.valid()) |
| 721 | { | ||
| 722 | ✗ | logger.warning("Scanner: resolve_cascade_in_module_with_prologue_fallback for '{}' called with an invalid " | |
| 723 | "module range.", | ||
| 724 | label); | ||
| 725 | ✗ | return std::unexpected(ResolveError::InvalidRange); | |
| 726 | } | ||
| 727 | |||
| 728 | // Both the direct pass and the prologue-recovery pass confine their scans to the module image: the match site | ||
| 729 | // must be in-range, while a rebuilt near-JMP may still jump to a trampoline outside it (see | ||
| 730 | // scan_candidates_hooked_prologue). | ||
| 731 | 16 | bool all_parse_failed = true; | |
| 732 |
1/2✓ Branch 16 → 17 taken 16 times.
✗ Branch 16 → 27 not taken.
|
16 | const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable, range); |
| 733 | 16 | PrologueFallbackResult hooked; | |
| 734 |
2/2✓ Branch 17 → 18 taken 13 times.
✓ Branch 17 → 21 taken 3 times.
|
16 | if (!attempt.success) |
| 735 | { | ||
| 736 |
1/2✓ Branch 19 → 20 taken 13 times.
✗ Branch 19 → 28 not taken.
|
13 | hooked = scan_candidates_hooked_prologue(candidates, range); |
| 737 | } | ||
| 738 |
1/2✓ Branch 21 → 22 taken 16 times.
✗ Branch 21 → 30 not taken.
|
16 | return finalize_cascade_with_fallback(attempt, all_parse_failed, hooked, candidates, label); |
| 739 | } | ||
| 740 | |||
| 741 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 742 | 3 | DetourModKit::Scanner::resolve_cascade_in_host_module(std::span<const AddrCandidate> candidates, | |
| 743 | std::string_view label) | ||
| 744 | { | ||
| 745 | // host_module_range() returns an invalid range (base == end == 0) when the main module cannot be resolved; | ||
| 746 | // forward the typed failure rather than letting the underlying resolver scan an empty span and report NoMatch. | ||
| 747 | 3 | const auto range = Memory::host_module_range(); | |
| 748 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 10 taken 3 times.
|
3 | if (!range.valid()) |
| 749 | { | ||
| 750 | ✗ | Logger::get_instance().warning( | |
| 751 | "Scanner: resolve_cascade_in_host_module for '{}' could not determine the host module range.", label); | ||
| 752 | ✗ | return std::unexpected(ResolveError::InvalidRange); | |
| 753 | } | ||
| 754 |
1/2✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 15 not taken.
|
3 | return resolve_cascade_in_module(candidates, label, range); |
| 755 | } | ||
| 756 | |||
| 757 | std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError> | ||
| 758 | 2 | DetourModKit::Scanner::resolve_cascade_in_host_module_with_prologue_fallback( | |
| 759 | std::span<const AddrCandidate> candidates, std::string_view label) | ||
| 760 | { | ||
| 761 | 2 | const auto range = Memory::host_module_range(); | |
| 762 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 10 taken 2 times.
|
2 | if (!range.valid()) |
| 763 | { | ||
| 764 | ✗ | Logger::get_instance().warning( | |
| 765 | "Scanner: resolve_cascade_in_host_module_with_prologue_fallback for '{}' could not " | ||
| 766 | "determine the host module range.", | ||
| 767 | label); | ||
| 768 | ✗ | return std::unexpected(ResolveError::InvalidRange); | |
| 769 | } | ||
| 770 |
1/2✓ Branch 10 → 11 taken 2 times.
✗ Branch 10 → 15 not taken.
|
2 | return resolve_cascade_in_module_with_prologue_fallback(candidates, label, range); |
| 771 | } | ||
| 772 | } // namespace DetourModKit | ||
| 773 |