src/internal/scan_prologue_recovery.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file internal/scan_prologue_recovery.cpp | ||
| 3 | * @brief Hooked-prologue recovery: rebuild a Direct candidate's prologue as a recognised inline-hook jump shape and | ||
| 4 | * recover the single redirected site. | ||
| 5 | * @details Acts only on Direct candidates, dispatched through Candidate::as_direct(). Each rebuilt prologue runs | ||
| 6 | * through the shared constexpr DSL core so the jump-prefix encoding has one source of truth, scans over the | ||
| 7 | * image's executable pages, and gates the decoded jump destination as a plausible, executable address before | ||
| 8 | * acceptance. | ||
| 9 | */ | ||
| 10 | |||
| 11 | #include "internal/scan_prologue_recovery.hpp" | ||
| 12 | |||
| 13 | #include "internal/memory_guarded.hpp" | ||
| 14 | #include "internal/scan_engine.hpp" | ||
| 15 | #include "internal/scan_pages.hpp" | ||
| 16 | #include "internal/scan_shared.hpp" | ||
| 17 | |||
| 18 | #include "DetourModKit/detail/pattern_core.hpp" | ||
| 19 | |||
| 20 | #include "x86_decode.hpp" | ||
| 21 | |||
| 22 | #include <Zydis/Zydis.h> | ||
| 23 | |||
| 24 | #include <array> | ||
| 25 | #include <cstddef> | ||
| 26 | #include <cstdint> | ||
| 27 | #include <optional> | ||
| 28 | #include <span> | ||
| 29 | #include <string_view> | ||
| 30 | |||
| 31 | namespace DetourModKit | ||
| 32 | { | ||
| 33 | namespace | ||
| 34 | { | ||
| 35 | // One inline-hook prologue shape the fallback can rebuild and recover. patch_minimum is the jump's own length, | ||
| 36 | // the minimum the hook overwrites, which the rebuild rounds up to a whole instruction. jump_prefix is the | ||
| 37 | // AOB fragment that replaces them; decode recovers the absolute target the rebuilt jump redirects to, so a | ||
| 38 | // match can be confirmed as a real hook rather than a coincidental opcode collision. | ||
| 39 | struct PrologueShape | ||
| 40 | { | ||
| 41 | std::size_t patch_minimum; | ||
| 42 | std::string_view jump_prefix; | ||
| 43 | std::optional<std::uintptr_t> (*decode)(std::uintptr_t) noexcept; | ||
| 44 | }; | ||
| 45 | |||
| 46 | // Minimum fully-known tail bytes a rebuilt pattern must keep after the jump prefix. Ten literal bytes is | ||
| 47 | // roughly two to four real instructions of context, which drops the false-positive rate of the generic near-JMP | ||
| 48 | // shape to near zero on a multi-megabyte .text section. | ||
| 49 | constexpr std::size_t PROLOGUE_MIN_TAIL_LITERALS = 10; | ||
| 50 | |||
| 51 | // Inline-hook prologue shapes the fallback tries, in order. E9 is the five-byte near jump for a trampoline | ||
| 52 | // within rel32 reach; the rest are the far-jump shapes emitted when the trampoline is beyond rel32 reach (an | ||
| 53 | // FF 25 RIP-relative indirect jump through a pointer slot, the fourteen-byte FF 25 absolute form whose disp32 | ||
| 54 | // is zero so the 8-byte target is inlined after the instruction, and the twelve-byte `mov rax, imm64; jmp | ||
| 55 | // rax`). The opcode groups differ and the two FF 25 forms differ only by overwrite length, so the shapes are | ||
| 56 | // mutually exclusive at a real hook site and the try order only affects which is attempted first, never | ||
| 57 | // correctness; E9 leads because it is by far the common case. | ||
| 58 | constexpr std::array<PrologueShape, 4> PROLOGUE_SHAPES = {{ | ||
| 59 | {5, std::string_view{"E9 ?? ?? ?? ??"}, &detail::decode_e9_rel32}, | ||
| 60 | {6, std::string_view{"FF 25 ?? ?? ?? ??"}, &detail::decode_ff25_indirect}, | ||
| 61 | {14, std::string_view{"FF 25 00 00 00 00 ?? ?? ?? ?? ?? ?? ?? ??"}, &detail::decode_ff25_indirect}, | ||
| 62 | {12, std::string_view{"48 B8 ?? ?? ?? ?? ?? ?? ?? ?? FF E0"}, &detail::decode_mov_rax_imm64_jmp_rax}, | ||
| 63 | }}; | ||
| 64 | |||
| 65 | // Rebuild a Direct candidate's signature as one prologue shape: drop the instruction-rounded stolen span (the | ||
| 66 | // jump patch minimum rounded up to a whole instruction), prepend the shape's jump prefix, fill any rounded | ||
| 67 | // excess with don't-care bytes, and keep the surviving tail token-exact. The jump prefix is parsed through the | ||
| 68 | // shared constexpr DSL core so the prefix encoding has one source of truth. Returns nullopt when the pattern is | ||
| 69 | // shorter than the patch, its leading span is undecodable, or its literal tail is below the floor (the shape is | ||
| 70 | // "not applicable" then). The rebuilt pattern carries offset 0: replacing the prologue dropped the original `|` | ||
| 71 | // anchor, which the caller re-applies after the scan so a `|`-anchored candidate recovered here lands on the | ||
| 72 | // same byte the direct scan would have. | ||
| 73 | std::optional<detail::EnginePattern> | ||
| 74 | 70 | build_rebuilt_prologue(const scan::Pattern &original, const PrologueShape &shape) | |
| 75 | { | ||
| 76 | // A bounded-jump pattern cannot be rebuilt by a flat byte-and-mask concatenation: this rebuild drops the | ||
| 77 | // instruction-rounded stolen span and prepends the jump prefix, but it copies only bytes/mask and never | ||
| 78 | // carries the original's `jumps` across (nor rebases their positions past the shifted prologue, nor | ||
| 79 | // re-splits a gap that straddled the patched bytes). Carrying no jumps would collapse every variable gap | ||
| 80 | // into a fixed run, so the rebuilt pattern would match a wrong, gap-collapsed shape. Fail closed on any | ||
| 81 | // jump-bearing pattern instead: the jump-bearing tail still resolves through the normal (non-fallback) | ||
| 82 | // scan path, so this only forgoes prologue RECOVERY for such a signature, never a correct direct match. | ||
| 83 |
2/2✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 66 times.
|
70 | if (original.has_jumps()) |
| 84 | { | ||
| 85 | 4 | return std::nullopt; | |
| 86 | } | ||
| 87 | 66 | const std::size_t size = original.size(); | |
| 88 |
2/2✓ Branch 6 → 7 taken 3 times.
✓ Branch 6 → 8 taken 63 times.
|
66 | if (size < shape.patch_minimum) |
| 89 | { | ||
| 90 | 3 | return std::nullopt; | |
| 91 | } | ||
| 92 | 63 | const std::span<const std::byte> original_bytes = original.bytes(); | |
| 93 | 63 | const std::span<const std::byte> original_mask = original.mask(); | |
| 94 | |||
| 95 | // Round the stolen span up to a whole-instruction boundary. A jump patch overwrites shape.patch_minimum | ||
| 96 | // bytes, but if that splits an instruction the installer must steal the whole straddling instruction, so | ||
| 97 | // the true stolen span is the first instruction boundary at or past the patch minimum. Decode the original | ||
| 98 | // leading instructions until the cumulative length reaches it. Every byte handed to the decoder must be | ||
| 99 | // fully known: a wildcard within a required leading instruction makes the span untrustworthy. | ||
| 100 | ZydisDecoder decoder; | ||
| 101 |
2/4✓ Branch 10 → 11 taken 63 times.
✗ Branch 10 → 79 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 63 times.
|
63 | if (!ZYAN_SUCCESS(ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64))) |
| 102 | { | ||
| 103 | ✗ | return std::nullopt; | |
| 104 | } | ||
| 105 | 63 | std::size_t stolen_span = 0; | |
| 106 |
2/2✓ Branch 36 → 14 taken 279 times.
✓ Branch 36 → 37 taken 59 times.
|
338 | while (stolen_span < shape.patch_minimum) |
| 107 | { | ||
| 108 | 279 | std::size_t literal_run = 0; | |
| 109 |
6/6✓ Branch 16 → 17 taken 3629 times.
✓ Branch 16 → 21 taken 141 times.
✓ Branch 17 → 18 taken 3499 times.
✓ Branch 17 → 21 taken 130 times.
✓ Branch 22 → 15 taken 3491 times.
✓ Branch 22 → 23 taken 279 times.
|
7269 | while (stolen_span + literal_run < size && literal_run < ZYDIS_MAX_INSTRUCTION_LENGTH && |
| 110 |
2/2✓ Branch 19 → 20 taken 3491 times.
✓ Branch 19 → 21 taken 8 times.
|
3499 | original_mask[stolen_span + literal_run] == std::byte{0xFF}) |
| 111 | { | ||
| 112 | 3491 | ++literal_run; | |
| 113 | } | ||
| 114 |
1/2✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 279 times.
|
279 | if (literal_run == 0) |
| 115 | { | ||
| 116 | 4 | return std::nullopt; | |
| 117 | } | ||
| 118 | std::uint8_t window[ZYDIS_MAX_INSTRUCTION_LENGTH]; | ||
| 119 |
2/2✓ Branch 30 → 26 taken 3491 times.
✓ Branch 30 → 31 taken 279 times.
|
3770 | for (std::size_t i = 0; i < literal_run; ++i) |
| 120 | { | ||
| 121 | 6982 | window[i] = std::to_integer<std::uint8_t>(original_bytes[stolen_span + i]); | |
| 122 | } | ||
| 123 | ZydisDecodedInstruction instruction; | ||
| 124 |
3/4✓ Branch 31 → 32 taken 279 times.
✗ Branch 31 → 74 not taken.
✓ Branch 32 → 33 taken 4 times.
✓ Branch 32 → 34 taken 275 times.
|
279 | if (!ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, nullptr, window, literal_run, &instruction))) |
| 125 | { | ||
| 126 | // The bytes do not decode as a whole instruction within the known-literal run (a wildcard truncates | ||
| 127 | // it, or the bytes are not valid code): undecodable, so not applicable. | ||
| 128 | 4 | return std::nullopt; | |
| 129 | } | ||
| 130 | 275 | stolen_span += instruction.length; | |
| 131 | } | ||
| 132 | |||
| 133 | // The excess beyond the jump patch minimum (what an installer NOP-pads or leaves as the straddled | ||
| 134 | // instruction's orphaned tail) is matched don't-care, so recovery succeeds whether that gap is | ||
| 135 | // zero-filled, NOP-padded, or left as the original bytes. Only the surviving literal tail past the stolen | ||
| 136 | // span must match exactly, and enough of it must remain to keep the generic jump shape selective. | ||
| 137 | 59 | const std::size_t padding_bytes = stolen_span - shape.patch_minimum; | |
| 138 | 59 | std::size_t literal_tail = 0; | |
| 139 |
2/2✓ Branch 42 → 38 taken 552 times.
✓ Branch 42 → 43 taken 59 times.
|
611 | for (std::size_t i = stolen_span; i < size; ++i) |
| 140 | { | ||
| 141 |
1/2✓ Branch 39 → 40 taken 552 times.
✗ Branch 39 → 41 not taken.
|
552 | if (original_mask[i] == std::byte{0xFF}) |
| 142 | { | ||
| 143 | 552 | ++literal_tail; | |
| 144 | } | ||
| 145 | } | ||
| 146 |
2/2✓ Branch 43 → 44 taken 23 times.
✓ Branch 43 → 45 taken 36 times.
|
59 | if (literal_tail < PROLOGUE_MIN_TAIL_LITERALS) |
| 147 | { | ||
| 148 | 23 | return std::nullopt; | |
| 149 | } | ||
| 150 | 36 | const detail::PatternParse prefix = detail::parse_pattern(shape.jump_prefix); | |
| 151 |
1/2✗ Branch 46 → 47 not taken.
✓ Branch 46 → 48 taken 36 times.
|
36 | if (prefix.status != detail::PatternStatus::Ok) |
| 152 | { | ||
| 153 | // The shape prefixes are constant and well-formed; a parse failure would be a library defect, not user | ||
| 154 | // input, so fail this shape closed rather than scan a malformed pattern. | ||
| 155 | ✗ | return std::nullopt; | |
| 156 | } | ||
| 157 | 36 | detail::EnginePattern rebuilt; | |
| 158 |
1/2✓ Branch 48 → 49 taken 36 times.
✗ Branch 48 → 77 not taken.
|
36 | rebuilt.bytes.reserve(prefix.buffer.length + padding_bytes + (size - stolen_span)); |
| 159 |
1/2✓ Branch 49 → 50 taken 36 times.
✗ Branch 49 → 77 not taken.
|
36 | rebuilt.mask.reserve(prefix.buffer.length + padding_bytes + (size - stolen_span)); |
| 160 |
2/2✓ Branch 56 → 51 taken 226 times.
✓ Branch 56 → 57 taken 36 times.
|
262 | for (std::size_t i = 0; i < prefix.buffer.length; ++i) |
| 161 | { | ||
| 162 |
1/2✓ Branch 52 → 53 taken 226 times.
✗ Branch 52 → 77 not taken.
|
226 | rebuilt.bytes.push_back(prefix.buffer.bytes[i]); |
| 163 |
1/2✓ Branch 54 → 55 taken 226 times.
✗ Branch 54 → 77 not taken.
|
226 | rebuilt.mask.push_back(prefix.buffer.mask[i]); |
| 164 | } | ||
| 165 |
2/2✓ Branch 61 → 58 taken 20 times.
✓ Branch 61 → 62 taken 36 times.
|
56 | for (std::size_t i = 0; i < padding_bytes; ++i) |
| 166 | { | ||
| 167 |
1/2✓ Branch 58 → 59 taken 20 times.
✗ Branch 58 → 75 not taken.
|
20 | rebuilt.bytes.push_back(std::byte{0}); |
| 168 |
1/2✓ Branch 59 → 60 taken 20 times.
✗ Branch 59 → 76 not taken.
|
20 | rebuilt.mask.push_back(std::byte{0}); |
| 169 | } | ||
| 170 |
2/2✓ Branch 68 → 63 taken 469 times.
✓ Branch 68 → 69 taken 36 times.
|
505 | for (std::size_t i = stolen_span; i < size; ++i) |
| 171 | { | ||
| 172 |
1/2✓ Branch 64 → 65 taken 469 times.
✗ Branch 64 → 77 not taken.
|
469 | rebuilt.bytes.push_back(original_bytes[i]); |
| 173 |
1/2✓ Branch 66 → 67 taken 469 times.
✗ Branch 66 → 77 not taken.
|
469 | rebuilt.mask.push_back(original_mask[i]); |
| 174 | } | ||
| 175 | 36 | rebuilt.offset = 0; | |
| 176 | 36 | rebuilt.compile_anchor(); | |
| 177 | 36 | return rebuilt; | |
| 178 | 36 | } | |
| 179 | |||
| 180 | // Try one prologue shape for one Direct candidate: rebuild, require the rebuilt pattern to match exactly once | ||
| 181 | // in the scope's executable pages (a hooked prologue is code), decode the jump to confirm a real redirect, and | ||
| 182 | // resolve the anchored match. applicable becomes true once the rebuilt pattern is usable, independent of | ||
| 183 | // whether it then matches, so the caller can tell "no shape applied" from "applied but missed". | ||
| 184 | 70 | std::optional<std::uintptr_t> try_prologue_shape( | |
| 185 | const scan::DirectPattern &direct, | ||
| 186 | const PrologueShape &shape, | ||
| 187 | detail::ModuleSpan range, | ||
| 188 | bool &applicable, | ||
| 189 | detail::FallbackOutcome &outcome | ||
| 190 | ) | ||
| 191 | { | ||
| 192 | 70 | const scan::Pattern &pattern = direct.pattern; | |
| 193 |
1/2✓ Branch 2 → 3 taken 70 times.
✗ Branch 2 → 50 not taken.
|
70 | const std::optional<detail::EnginePattern> rebuilt = build_rebuilt_prologue(pattern, shape); |
| 194 |
2/2✓ Branch 4 → 5 taken 34 times.
✓ Branch 4 → 6 taken 36 times.
|
70 | if (!rebuilt) |
| 195 | { | ||
| 196 | 34 | return std::nullopt; | |
| 197 | } | ||
| 198 | 36 | applicable = true; | |
| 199 | |||
| 200 | // Count zero, one, or two-or-more occurrences over the executable pages in ONE traversal, so the hit and | ||
| 201 | // the ambiguity verdict describe the same view of memory. A truncated sweep (a faulted region skipped, or | ||
| 202 | // bounded-jump backtracking spent) makes the count a lower bound, so a single hit does not prove | ||
| 203 | // uniqueness; fail closed. More than one hit makes the rebuilt jump ambiguous; fail closed. | ||
| 204 | 36 | const detail::MatchResult found = detail::scan_module_executable( | |
| 205 | *rebuilt, | ||
| 206 | range, | ||
| 207 | 36 | detail::ScanQuery{ | |
| 208 | .occurrence = 1, | ||
| 209 | .count_beyond = true, | ||
| 210 | .exclusions = nullptr, | ||
| 211 | } | ||
| 212 | ); | ||
| 213 | // Truncation is recorded even when this shape would have been rejected anyway: it says the executable | ||
| 214 | // pages were not fully read, so the caller must not report the whole recovery pass as a proven absence. | ||
| 215 | // build_rebuilt_prologue refuses jump-bearing patterns, so a skipped faulted region is the only channel. | ||
| 216 |
2/4✓ Branch 8 → 9 taken 36 times.
✗ Branch 8 → 11 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 36 times.
|
36 | outcome.incomplete = outcome.incomplete || found.truncated(); |
| 217 |
2/2✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 35 times.
|
36 | if (found.count > 1) |
| 218 | { | ||
| 219 | // The rebuilt shape collides at two or more executable sites (a count that a truncated sweep only | ||
| 220 | // raises), so no single redirect can be trusted. Record the ambiguity so the resolver reports it | ||
| 221 | // distinctly from a plain miss, and still fail closed. | ||
| 222 | 1 | outcome.ambiguous = true; | |
| 223 | 1 | return std::nullopt; | |
| 224 | } | ||
| 225 |
5/6✓ Branch 15 → 16 taken 19 times.
✓ Branch 15 → 18 taken 16 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 19 times.
✓ Branch 20 → 21 taken 16 times.
✓ Branch 20 → 22 taken 19 times.
|
35 | if (found.match == nullptr || found.truncated()) |
| 226 | { | ||
| 227 | 16 | return std::nullopt; | |
| 228 | } | ||
| 229 | |||
| 230 | 19 | const std::uintptr_t match = reinterpret_cast<std::uintptr_t>(found.match); | |
| 231 | 19 | const std::optional<std::uintptr_t> jump_target = shape.decode(match); | |
| 232 |
6/8✓ Branch 24 → 25 taken 19 times.
✗ Branch 24 → 31 not taken.
✓ Branch 27 → 28 taken 19 times.
✗ Branch 27 → 31 not taken.
✓ Branch 30 → 31 taken 1 time.
✓ Branch 30 → 32 taken 18 times.
✓ Branch 33 → 34 taken 1 time.
✓ Branch 33 → 35 taken 18 times.
|
19 | if (!jump_target || !detail::is_plausible_ptr(*jump_target) || !detail::is_executable_address(*jump_target)) |
| 233 | { | ||
| 234 | // The matched bytes do not redirect to executable code, so this is a coincidental opcode collision, not | ||
| 235 | // a hooked prologue. The jump destination itself is intentionally NOT range-constrained: a sibling | ||
| 236 | // mod's trampoline is allocated outside every loaded module. | ||
| 237 | 1 | return std::nullopt; | |
| 238 | } | ||
| 239 | |||
| 240 | 18 | const std::uintptr_t anchored = match + static_cast<std::uintptr_t>(pattern.offset()); | |
| 241 | 18 | const std::optional<std::uintptr_t> resolved = detail::resolve_direct(anchored, direct); | |
| 242 |
3/6✓ Branch 38 → 39 taken 18 times.
✗ Branch 38 → 42 not taken.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 18 times.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 18 times.
|
18 | if (!resolved || !range.contains(*resolved)) |
| 243 | { | ||
| 244 | // Bound the recovered address to the requested scope, matching the normal byte path: a Direct walk-back | ||
| 245 | // must not resolve outside the range even when it is reached through prologue recovery. | ||
| 246 | ✗ | return std::nullopt; | |
| 247 | } | ||
| 248 | 18 | outcome.physical_source = found.physical_span; | |
| 249 | 18 | return resolved; | |
| 250 | 70 | } | |
| 251 | } // anonymous namespace | ||
| 252 | |||
| 253 | 25 | detail::FallbackOutcome detail::resolve_prologue_fallback( | |
| 254 | const scan::ScanRequest &request, | ||
| 255 | std::span<const std::size_t> order, | ||
| 256 | ModuleSpan range | ||
| 257 | ) | ||
| 258 | { | ||
| 259 | 25 | FallbackOutcome outcome; | |
| 260 | 25 | const scan::FallbackPolicy policy = request.fallback_policy; | |
| 261 | 25 | const scan::FallbackWitness witness = request.fallback_witness; | |
| 262 |
2/2✓ Branch 53 → 4 taken 26 times.
✓ Branch 53 → 54 taken 12 times.
|
63 | for (const std::size_t index : order) |
| 263 | { | ||
| 264 | 26 | const scan::Candidate &candidate = request.ladder[index]; | |
| 265 | 26 | const scan::DirectPattern *direct = candidate.as_direct(); | |
| 266 |
2/2✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 25 times.
|
26 | if (direct == nullptr) |
| 267 | { | ||
| 268 | 1 | continue; | |
| 269 | } | ||
| 270 | 25 | outcome.had_direct = true; | |
| 271 |
2/2✓ Branch 43 → 11 taken 70 times.
✓ Branch 43 → 44 taken 12 times.
|
82 | for (const PrologueShape &shape : PROLOGUE_SHAPES) |
| 272 | { | ||
| 273 | 70 | bool applicable = false; | |
| 274 | const std::optional<std::uintptr_t> recovered = | ||
| 275 |
1/2✓ Branch 11 → 12 taken 70 times.
✗ Branch 11 → 58 not taken.
|
70 | try_prologue_shape(*direct, shape, range, applicable, outcome); |
| 276 |
2/2✓ Branch 12 → 13 taken 36 times.
✓ Branch 12 → 14 taken 34 times.
|
70 | if (applicable) |
| 277 | { | ||
| 278 | 36 | outcome.not_applicable = false; | |
| 279 | } | ||
| 280 |
2/2✓ Branch 15 → 16 taken 52 times.
✓ Branch 15 → 17 taken 18 times.
|
70 | if (!recovered) |
| 281 | { | ||
| 282 | 57 | continue; | |
| 283 | } | ||
| 284 | |||
| 285 | // Structural recovery succeeded (unique rebuilt match, decoded redirect into executable memory, | ||
| 286 | // in-scope walk-back). Apply the caller's identity gate before trusting the address: the structural | ||
| 287 | // gate is address-blind, so a reshaped near-twin whose surviving tail matches and which is itself | ||
| 288 | // hooked would resolve here uniformly. The witness is the only thing that can tell the intended | ||
| 289 | // function from a coincidental twin. | ||
| 290 | 18 | const std::int64_t recovered_value = static_cast<std::int64_t>(*recovered); | |
| 291 |
2/2✓ Branch 18 → 19 taken 8 times.
✓ Branch 18 → 26 taken 10 times.
|
18 | if (policy == scan::FallbackPolicy::RequireIdentity) |
| 292 | { | ||
| 293 | // Fail closed on an unconfirmed site: a missing witness cannot confirm identity, and a witness that | ||
| 294 | // rejects the site marks it a twin. Record the rejection so the resolver reports it distinctly, and | ||
| 295 | // keep trying other shapes / candidates for one that does pass identity rather than stopping here. | ||
| 296 |
6/6✓ Branch 19 → 20 taken 7 times.
✓ Branch 19 → 22 taken 1 time.
✓ Branch 21 → 22 taken 4 times.
✓ Branch 21 → 23 taken 3 times.
✓ Branch 24 → 25 taken 5 times.
✓ Branch 24 → 33 taken 3 times.
|
8 | if (witness.predicate == nullptr || !witness.predicate(recovered_value, witness.context)) |
| 297 | { | ||
| 298 | 5 | outcome.identity_rejected = true; | |
| 299 | 5 | continue; | |
| 300 | } | ||
| 301 | } | ||
| 302 |
5/6✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 30 taken 9 times.
✓ Branch 28 → 29 taken 1 time.
✗ Branch 28 → 30 not taken.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 9 times.
|
10 | else if (witness.predicate != nullptr && !witness.predicate(recovered_value, witness.context)) |
| 303 | { | ||
| 304 | // WarnOnly: a supplied witness that disagrees does not veto the recovery, but it is surfaced so the | ||
| 305 | // resolver logs the disagreement. A consumer can observe near-twin drift in this mode before | ||
| 306 | // switching to RequireIdentity and failing closed on it. | ||
| 307 | 1 | outcome.identity_warned = true; | |
| 308 | } | ||
| 309 | |||
| 310 |
2/4✓ Branch 36 → 37 taken 13 times.
✗ Branch 36 → 57 not taken.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 13 times.
|
13 | outcome.hit = scan::Hit{Address{*recovered}, candidate.name(), scan::Mode::Direct}; |
| 311 | 13 | return outcome; | |
| 312 | } | ||
| 313 | } | ||
| 314 | 12 | return outcome; | |
| 315 | ✗ | } | |
| 316 | } // namespace DetourModKit | ||
| 317 |