src/scan_resolution.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file scan_resolution.cpp | ||
| 3 | * @brief The candidate-ladder resolver: resolve() and resolve_batch(). | ||
| 4 | * @details Dispatches each Candidate on its active variant payload (Direct / RipRelative byte tiers, RttiVtable / | ||
| 5 | * StringXref text tiers) and returns the first that resolves uniquely to an in-scope, plausible address. One | ||
| 6 | * resolve(ScanRequest) carries scope, ordering, uniqueness, prologue fallback, and the ladder as fields. The | ||
| 7 | * byte tiers reuse the page-gated SIMD engine with the bounded | ||
| 8 | * haystack-frequency anchor override (sampled lazily on the first byte candidate, shared across the ladder); | ||
| 9 | * the text tiers resolve through their unique-only backends. On a full direct miss with a non-Off | ||
| 10 | * fallback_policy, hooked-prologue recovery is attempted under that policy's identity gate. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "DetourModKit/scan.hpp" | ||
| 14 | |||
| 15 | #include "internal/memory_guarded.hpp" | ||
| 16 | #include "internal/rtti_shared.hpp" | ||
| 17 | #include "internal/scan_engine.hpp" | ||
| 18 | #include "internal/scan_exclusions.hpp" | ||
| 19 | #include "internal/scan_pages.hpp" | ||
| 20 | #include "internal/scan_prologue_recovery.hpp" | ||
| 21 | #include "internal/scan_shared.hpp" | ||
| 22 | |||
| 23 | #include "DetourModKit/format.hpp" | ||
| 24 | #include "DetourModKit/logger.hpp" | ||
| 25 | #include "DetourModKit/memory.hpp" | ||
| 26 | #include "DetourModKit/rtti.hpp" | ||
| 27 | |||
| 28 | #include "fork_join.hpp" | ||
| 29 | |||
| 30 | #include <cstddef> | ||
| 31 | #include <cstdint> | ||
| 32 | #include <new> | ||
| 33 | #include <optional> | ||
| 34 | #include <span> | ||
| 35 | #include <string> | ||
| 36 | #include <string_view> | ||
| 37 | #include <utility> | ||
| 38 | #include <variant> | ||
| 39 | #include <vector> | ||
| 40 | |||
| 41 | namespace DetourModKit | ||
| 42 | { | ||
| 43 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 44 | namespace detail | ||
| 45 | { | ||
| 46 | void (*g_scan_after_byte_sweep_test_hook)() noexcept = nullptr; | ||
| 47 | } // namespace detail | ||
| 48 | #endif | ||
| 49 | |||
| 50 | namespace scan | ||
| 51 | { | ||
| 52 | namespace | ||
| 53 | { | ||
| 54 | 363 | [[nodiscard]] bool accepts_resolved_address(const ScanRequest &request, Address address) noexcept | |
| 55 | { | ||
| 56 | 363 | return !request.require_executable_result || detail::is_executable_address(address.raw()); | |
| 57 | } | ||
| 58 | |||
| 59 | 458 | [[nodiscard]] constexpr bool valid_candidate_order(CandidateOrder order) noexcept | |
| 60 | { | ||
| 61 | 458 | return order == CandidateOrder::AsDeclared || order == CandidateOrder::UniqueFirst; | |
| 62 | } | ||
| 63 | |||
| 64 | 454 | [[nodiscard]] constexpr bool valid_fallback_policy(FallbackPolicy policy) noexcept | |
| 65 | { | ||
| 66 | 454 | switch (policy) | |
| 67 | { | ||
| 68 | 453 | case FallbackPolicy::Off: | |
| 69 | case FallbackPolicy::WarnOnly: | ||
| 70 | case FallbackPolicy::RequireIdentity: | ||
| 71 | 453 | return true; | |
| 72 | } | ||
| 73 | 1 | return false; | |
| 74 | } | ||
| 75 | |||
| 76 | 461 | [[nodiscard]] bool valid_candidate_enums(const Candidate &candidate) noexcept | |
| 77 | { | ||
| 78 | 461 | const StringXref *xref = candidate.as_string_xref(); | |
| 79 | 561 | return xref == nullptr || | |
| 80 | 561 | (detail::valid_string_encoding(xref->encoding) && detail::valid_xref_return(xref->return_mode)); | |
| 81 | } | ||
| 82 | |||
| 83 | // Resolve a byte-tier candidate's match to its final address: a Direct walk-back, or a RipRelative disp32 | ||
| 84 | // read. Both screen the result through the plausible-userspace floor (in the shared helpers), so a faulted | ||
| 85 | // read or a crafted displacement is a miss, never a hit at a near-null or kernel-range address. | ||
| 86 | 341 | std::optional<std::uintptr_t> resolve_byte_candidate( | |
| 87 | std::uintptr_t match, | ||
| 88 | const Candidate &candidate, | ||
| 89 | std::span<const std::byte> instruction | ||
| 90 | ) noexcept | ||
| 91 | { | ||
| 92 | 341 | if (const DirectPattern *direct = candidate.as_direct()) | |
| 93 | { | ||
| 94 | 315 | return detail::resolve_direct(match, *direct); | |
| 95 | } | ||
| 96 | 26 | if (const RipRelativePattern *rip = candidate.as_rip_relative()) | |
| 97 | { | ||
| 98 | 26 | return detail::resolve_rip_relative_candidate(match, *rip, instruction); | |
| 99 | } | ||
| 100 | ✗ | return std::nullopt; | |
| 101 | } | ||
| 102 | |||
| 103 | // Resolver observability: one line names the winning candidate on success, a distinct line marks | ||
| 104 | // hooked-prologue recovery, and a miss records how many ladder rows were tried. These helpers do not alter | ||
| 105 | // the address or outcome: formatting/allocation failures are swallowed so a diagnostic cannot turn a | ||
| 106 | // genuine hit into a failure. The request label is echoed so callers can correlate logs with the site they | ||
| 107 | // asked to resolve. | ||
| 108 | 360 | void log_resolved(const ScanRequest &request, const Hit &hit, bool via_prologue_recovery) noexcept | |
| 109 | { | ||
| 110 | try | ||
| 111 | { | ||
| 112 | 360 | const std::string where = format::format_address(hit.address.raw()); | |
| 113 | 358 | if (via_prologue_recovery) | |
| 114 | { | ||
| 115 | 26 | (void)DetourModKit::log().try_log( | |
| 116 | LogLevel::Debug, | ||
| 117 | "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction of candidate '{}'.", | ||
| 118 | 13 | request.label, | |
| 119 | where, | ||
| 120 | 13 | hit.winning_name | |
| 121 | ); | ||
| 122 | } | ||
| 123 | else | ||
| 124 | { | ||
| 125 | 690 | (void)DetourModKit::log().try_log( | |
| 126 | LogLevel::Debug, | ||
| 127 | "scan::resolve: '{}' resolved {} via candidate '{}'.", | ||
| 128 | 345 | request.label, | |
| 129 | where, | ||
| 130 | 345 | hit.winning_name | |
| 131 | ); | ||
| 132 | } | ||
| 133 | 358 | } | |
| 134 | 2 | catch (...) | |
| 135 | { | ||
| 136 | // Best-effort diagnostic only: never let a logging allocation perturb the resolve() result. | ||
| 137 | 2 | } | |
| 138 | 360 | } | |
| 139 | |||
| 140 | 47 | void log_unresolved(const ScanRequest &request, std::string_view reason) noexcept | |
| 141 | { | ||
| 142 | try | ||
| 143 | { | ||
| 144 | 47 | (void)DetourModKit::log().try_log( | |
| 145 | LogLevel::Warning, | ||
| 146 | "scan::resolve: '{}' matched no candidate across {} tried ({}).", | ||
| 147 | 47 | request.label, | |
| 148 | 47 | request.ladder.size(), | |
| 149 | reason | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | catch (...) | ||
| 153 | { | ||
| 154 | } | ||
| 155 | 47 | } | |
| 156 | |||
| 157 | // A WarnOnly recovery whose identity witness disagreed: surface the possible near-twin at Warning level | ||
| 158 | // while still returning the address. Formatting/allocation failures are swallowed like the other | ||
| 159 | // diagnostics so a log cannot turn an accepted recovery into a failure. | ||
| 160 | 1 | void log_identity_warning(const ScanRequest &request, const Hit &hit) noexcept | |
| 161 | { | ||
| 162 | try | ||
| 163 | { | ||
| 164 | 1 | (void)DetourModKit::log().try_log( | |
| 165 | LogLevel::Warning, | ||
| 166 | "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction, but its identity witness " | ||
| 167 | "disagreed (WarnOnly); the site may be a near-twin.", | ||
| 168 | 1 | request.label, | |
| 169 | 2 | format::format_address(hit.address.raw()) | |
| 170 | ); | ||
| 171 | } | ||
| 172 | ✗ | catch (...) | |
| 173 | { | ||
| 174 | ✗ | } | |
| 175 | 1 | } | |
| 176 | } // namespace | ||
| 177 | |||
| 178 | namespace | ||
| 179 | { | ||
| 180 | /** | ||
| 181 | * @brief Collects the query storage of the ladder and the caller exclusions into @p ladder_exclusions. | ||
| 182 | * @details The query storage is the Candidate array and each owned text literal that the text tiers search | ||
| 183 | * for verbatim. The Candidate array holds each inline Pattern buffer. The restriction to the | ||
| 184 | * scanned range comes first. It drops every span that the sweep does not read, so a large ladder | ||
| 185 | * still costs one or two slots. | ||
| 186 | */ | ||
| 187 | 442 | void collect_ladder_exclusions( | |
| 188 | const ScanRequest &request, | ||
| 189 | const detail::ModuleSpan &range, | ||
| 190 | detail::ScanExclusions &ladder_exclusions | ||
| 191 | ) noexcept | ||
| 192 | { | ||
| 193 | 442 | ladder_exclusions.restrict_to(range.base, range.end); | |
| 194 | 441 | ladder_exclusions.add_object_span(request.ladder); | |
| 195 | 1333 | for (const Candidate &entry : request.ladder) | |
| 196 | { | ||
| 197 | 451 | if (const RttiVtable *rtti_payload = entry.as_rtti_vtable()) | |
| 198 | { | ||
| 199 | 5 | ladder_exclusions.add_text(rtti_payload->mangled); | |
| 200 | } | ||
| 201 | 446 | else if (const StringXref *xref_payload = entry.as_string_xref()) | |
| 202 | { | ||
| 203 | 48 | ladder_exclusions.add_text(xref_payload->text); | |
| 204 | } | ||
| 205 | } | ||
| 206 | 441 | detail::add_regions(ladder_exclusions, request.exclusions); | |
| 207 | 442 | } | |
| 208 | |||
| 209 | /// Resolves @p xref and carries the ladder and caller exclusions through phase 1. | ||
| 210 | 46 | [[nodiscard]] Result<Address> find_string_xref_site( | |
| 211 | const StringXref &xref, | ||
| 212 | const ScanRequest &request, | ||
| 213 | const detail::ScanExclusions &ladder_exclusions, | ||
| 214 | Region &reference_span | ||
| 215 | ) | ||
| 216 | { | ||
| 217 | const StringRefQuery query{ | ||
| 218 | 46 | .text = xref.text, | |
| 219 | 46 | .encoding = xref.encoding, | |
| 220 | 46 | .require_terminator = xref.require_terminator, | |
| 221 | 46 | .return_mode = xref.return_mode, | |
| 222 | 46 | .broad_match = xref.broad_match, | |
| 223 | 46 | }; | |
| 224 | 46 | return detail::find_string_xref_with_exclusions( | |
| 225 | query, | ||
| 226 | request.scope, | ||
| 227 | &ladder_exclusions, | ||
| 228 | request.exclusions, | ||
| 229 | &reference_span | ||
| 230 | 36 | ); | |
| 231 | } | ||
| 232 | |||
| 233 | /** | ||
| 234 | * @brief Sweeps @p range in one traversal for the first match of @p compiled and, when the request requires | ||
| 235 | * uniqueness, a second match. | ||
| 236 | * @details The single traversal means that a concurrent write cannot produce a hit and a uniqueness verdict | ||
| 237 | * that no single view of memory had. The inline Pattern of @p candidate needs no exclusion of its | ||
| 238 | * own. It is a subobject of the ladder array, and collect_ladder_exclusions() excludes the whole | ||
| 239 | * array. | ||
| 240 | */ | ||
| 241 | 392 | [[nodiscard]] detail::MatchResult sweep_byte_candidate( | |
| 242 | const ScanRequest &request, | ||
| 243 | const detail::ModuleSpan &range, | ||
| 244 | const Candidate &candidate, | ||
| 245 | const detail::EnginePattern &compiled, | ||
| 246 | const detail::ScanExclusions &ladder_exclusions | ||
| 247 | ) noexcept | ||
| 248 | { | ||
| 249 | 392 | const RipRelativePattern *rip = candidate.as_rip_relative(); | |
| 250 | 365 | const std::uint8_t instruction_snapshot_length = | |
| 251 | 392 | rip != nullptr ? static_cast<std::uint8_t>(rip->instruction_length) : std::uint8_t{0}; | |
| 252 | 392 | return detail::scan_module_pages( | |
| 253 | compiled, | ||
| 254 | range, | ||
| 255 | 392 | request.pages, | |
| 256 | 392 | detail::ScanQuery{ | |
| 257 | .occurrence = 1, | ||
| 258 | 392 | .count_beyond = request.require_unique, | |
| 259 | .exclusions = &ladder_exclusions, | ||
| 260 | .capture_evidence = true, | ||
| 261 | .instruction_snapshot_length = instruction_snapshot_length, | ||
| 262 | } | ||
| 263 | 392 | ); | |
| 264 | } | ||
| 265 | |||
| 266 | /** | ||
| 267 | * @brief Accepts a complete byte match that the sweep counted once, whose resolved address is in scope and | ||
| 268 | * passes accepts_resolved_address(), and publishes its provenance. | ||
| 269 | * @return The hit, or std::nullopt so that the ladder falls through to the next candidate. | ||
| 270 | */ | ||
| 271 | 392 | [[nodiscard]] std::optional<Hit> accept_byte_match( | |
| 272 | const ScanRequest &request, | ||
| 273 | const detail::ModuleSpan &range, | ||
| 274 | const Candidate &candidate, | ||
| 275 | const detail::MatchResult &found, | ||
| 276 | std::size_t winning_index, | ||
| 277 | detail::ResolvedScanHit *provenance | ||
| 278 | ) | ||
| 279 | { | ||
| 280 | 392 | if (found.match == nullptr) | |
| 281 | { | ||
| 282 | 48 | return std::nullopt; | |
| 283 | } | ||
| 284 | 344 | if (found.truncated()) | |
| 285 | { | ||
| 286 | // A skipped faulted region or a bounded-jump budget truncation makes the occurrence count a | ||
| 287 | // lower bound. Unscanned bytes can hide an earlier match or a duplicate, so a hit from an | ||
| 288 | // incomplete sweep can name a wrong address. | ||
| 289 | ✗ | return std::nullopt; | |
| 290 | } | ||
| 291 | 344 | if (found.count > 1) | |
| 292 | { | ||
| 293 | // The lowest-address match of an ambiguous sweep is not provably the intended target. | ||
| 294 | 3 | return std::nullopt; | |
| 295 | } | ||
| 296 | 341 | const std::optional<std::uintptr_t> resolved = resolve_byte_candidate( | |
| 297 | 341 | reinterpret_cast<std::uintptr_t>(found.match), | |
| 298 | candidate, | ||
| 299 | found.instruction.span() | ||
| 300 | ); | ||
| 301 | 341 | if (!resolved || !range.contains(*resolved) || !accepts_resolved_address(request, Address{*resolved})) | |
| 302 | { | ||
| 303 | // A RipRelative displacement can resolve outside the scanned scope, for example to an import | ||
| 304 | // thunk in another module. | ||
| 305 | 11 | return std::nullopt; | |
| 306 | } | ||
| 307 | // The evidence comes from the sweep that produced this match. The address can be a RIP-relative | ||
| 308 | // target elsewhere, but the witnessed bytes are always the matched span of the pattern. | ||
| 309 | 330 | Hit hit{Address{*resolved}, candidate.name(), candidate.mode(), found.evidence}; | |
| 310 | 330 | if (provenance != nullptr) | |
| 311 | { | ||
| 312 | // The authored match span alone understates the evidence of a RIP winner. The target came from | ||
| 313 | // the whole instruction that the snapshot decode consumed, and a signature can omit the trailing | ||
| 314 | // immediates that it authorizes. The published span therefore extends to the decoded end. | ||
| 315 | // Otherwise a second selector that matches those immediates abuts this span rather than overlaps | ||
| 316 | // it, and one instruction votes twice. The offset-applied match point sits inside the match span, | ||
| 317 | // so the union stays one contiguous extent. | ||
| 318 | 217 | Region source = found.physical_span; | |
| 319 | 217 | if (found.instruction.length != 0) | |
| 320 | { | ||
| 321 | 10 | const std::uintptr_t decoded_end = | |
| 322 | 10 | reinterpret_cast<std::uintptr_t>(found.match) + found.instruction.length; | |
| 323 | 10 | if (decoded_end > source.end().raw()) | |
| 324 | { | ||
| 325 | 1 | source.size = static_cast<std::size_t>(decoded_end - source.base.raw()); | |
| 326 | } | ||
| 327 | } | ||
| 328 | 217 | provenance->physical_source = source; | |
| 329 | 217 | provenance->winning_index = winning_index; | |
| 330 | 217 | provenance->match_span = found.physical_span; | |
| 331 | } | ||
| 332 | 330 | log_resolved(request, hit, false); | |
| 333 | 330 | return hit; | |
| 334 | 330 | } | |
| 335 | |||
| 336 | /** | ||
| 337 | * @brief Tries each candidate in @p order and returns the first hit, or std::nullopt when every candidate | ||
| 338 | * misses. | ||
| 339 | * @details The first byte-sweep coverage failure latches into @p coverage_error, and the first text-tier | ||
| 340 | * failure latches into @p text_error. resolve_impl() states what each latch proves. | ||
| 341 | */ | ||
| 342 | 435 | [[nodiscard]] std::optional<Hit> resolve_ladder_rungs( | |
| 343 | const ScanRequest &request, | ||
| 344 | const detail::ModuleSpan &range, | ||
| 345 | const detail::ScanExclusions &ladder_exclusions, | ||
| 346 | const std::vector<std::size_t> &order, | ||
| 347 | std::size_t ordered_count, | ||
| 348 | std::optional<ErrorCode> &coverage_error, | ||
| 349 | std::optional<ErrorCode> &text_error, | ||
| 350 | detail::ResolvedScanHit *provenance | ||
| 351 | ) | ||
| 352 | { | ||
| 353 | // The first byte candidate samples the haystack histogram. Every later byte candidate reuses it, | ||
| 354 | // because all of them scan the same scope. | ||
| 355 | 435 | std::optional<detail::HaystackHistogram> histogram; | |
| 356 | 2 | const auto remember_coverage_error = [&coverage_error](ErrorCode code) noexcept -> void | |
| 357 | { | ||
| 358 | 2 | if (!coverage_error) | |
| 359 | { | ||
| 360 | 2 | coverage_error = code; | |
| 361 | } | ||
| 362 | 437 | }; | |
| 363 | 1 | const auto remember_text_error = [&text_error](ErrorCode code) noexcept -> void | |
| 364 | { | ||
| 365 | 1 | if (!text_error) | |
| 366 | { | ||
| 367 | 1 | text_error = code; | |
| 368 | } | ||
| 369 | 436 | }; | |
| 370 | |||
| 371 | 503 | for (std::size_t k = 0; k < ordered_count; ++k) | |
| 372 | { | ||
| 373 | 443 | const Candidate &candidate = request.ladder[order[k]]; | |
| 374 | |||
| 375 | 443 | if (const RttiVtable *rtti = candidate.as_rtti_vtable()) | |
| 376 | { | ||
| 377 | // Fully qualify the namespace: the local `rtti` pointer would otherwise shadow the `rtti` | ||
| 378 | // module namespace and make `rtti::detail` name the variable instead. | ||
| 379 | const DetourModKit::rtti::detail::PrimaryVtable primary = | ||
| 380 | 5 | DetourModKit::rtti::detail::primary_vtable_checked(rtti->mangled, request.scope); | |
| 381 | 5 | const std::optional<Address> &vtable = primary.vtable; | |
| 382 | 5 | if (vtable && range.contains(vtable->raw()) && accepts_resolved_address(request, *vtable)) | |
| 383 | { | ||
| 384 | 1 | Hit hit{*vtable, candidate.name(), Mode::RttiVtable}; | |
| 385 | 1 | if (provenance != nullptr) | |
| 386 | { | ||
| 387 | ✗ | provenance->winning_index = order[k]; | |
| 388 | } | ||
| 389 | 1 | log_resolved(request, hit, false); | |
| 390 | 1 | return hit; | |
| 391 | 1 | } | |
| 392 | 4 | if (primary.completeness != DetourModKit::rtti::Traversal::Complete) | |
| 393 | { | ||
| 394 | 1 | remember_text_error(ErrorCode::IncompleteScan); | |
| 395 | } | ||
| 396 | 4 | continue; | |
| 397 | 4 | } | |
| 398 | 438 | if (const StringXref *xref = candidate.as_string_xref()) | |
| 399 | { | ||
| 400 | 46 | Region reference_span{}; | |
| 401 | const Result<Address> site = | ||
| 402 | 46 | find_string_xref_site(*xref, request, ladder_exclusions, reference_span); | |
| 403 | 18 | if (site && range.contains(site->raw()) && accepts_resolved_address(request, *site)) | |
| 404 | { | ||
| 405 | 16 | Hit hit{*site, candidate.name(), Mode::StringXref}; | |
| 406 | 16 | if (provenance != nullptr) | |
| 407 | { | ||
| 408 | 2 | provenance->physical_source = reference_span; | |
| 409 | 2 | provenance->winning_index = order[k]; | |
| 410 | } | ||
| 411 | 16 | log_resolved(request, hit, false); | |
| 412 | 16 | return hit; | |
| 413 | 15 | } | |
| 414 | 4 | if (!site && (site.error().code == ErrorCode::IncompleteScan || | |
| 415 | 2 | site.error().code == ErrorCode::NotAuthoritative || | |
| 416 | 2 | site.error().code == ErrorCode::MalformedQueryText)) | |
| 417 | { | ||
| 418 | ✗ | remember_text_error(site.error().code); | |
| 419 | } | ||
| 420 | 2 | continue; | |
| 421 | 2 | } | |
| 422 | |||
| 423 | // Byte tiers (Direct / RipRelative). | ||
| 424 | 392 | const Pattern *pattern = detail::byte_pattern_of(candidate); | |
| 425 | 392 | if (pattern == nullptr) | |
| 426 | { | ||
| 427 | // Unreachable through the factories (every alternative is handled above); skip defensively. | ||
| 428 | ✗ | continue; | |
| 429 | } | ||
| 430 | 392 | if (!histogram) | |
| 431 | { | ||
| 432 | 386 | histogram = detail::sample_haystack(request.scope); | |
| 433 | } | ||
| 434 | 392 | const detail::EnginePattern compiled = detail::to_engine_pattern(*pattern, *histogram); | |
| 435 | const detail::MatchResult found = | ||
| 436 | 392 | sweep_byte_candidate(request, range, candidate, compiled, ladder_exclusions); | |
| 437 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 438 | 392 | if (auto *const hook = detail::g_scan_after_byte_sweep_test_hook) | |
| 439 | { | ||
| 440 | 12 | hook(); | |
| 441 | } | ||
| 442 | #endif | ||
| 443 | 392 | if (found.budget_exhausted) | |
| 444 | { | ||
| 445 | 1 | remember_coverage_error(ErrorCode::BudgetExceeded); | |
| 446 | } | ||
| 447 | 391 | else if (found.incomplete) | |
| 448 | { | ||
| 449 | 1 | remember_coverage_error(ErrorCode::IncompleteScan); | |
| 450 | } | ||
| 451 | 391 | if (std::optional<Hit> hit = | |
| 452 | 392 | accept_byte_match(request, range, candidate, found, order[k], provenance)) | |
| 453 | { | ||
| 454 | 329 | return hit; | |
| 455 | 392 | } | |
| 456 | 392 | } | |
| 457 | 60 | return std::nullopt; | |
| 458 | } | ||
| 459 | |||
| 460 | /** | ||
| 461 | * @brief Runs hooked-prologue recovery after a full direct miss. | ||
| 462 | * @return The recovered hit, else the latched @p text_error, else the first recovery diagnostic, or | ||
| 463 | * std::nullopt when none applies. | ||
| 464 | */ | ||
| 465 | 25 | [[nodiscard]] std::optional<Result<Hit>> try_prologue_fallback( | |
| 466 | const ScanRequest &request, | ||
| 467 | const std::vector<std::size_t> &order, | ||
| 468 | std::size_t ordered_count, | ||
| 469 | const detail::ModuleSpan &range, | ||
| 470 | const std::optional<ErrorCode> &text_error, | ||
| 471 | detail::ResolvedScanHit *provenance | ||
| 472 | ) | ||
| 473 | { | ||
| 474 | const detail::FallbackOutcome fallback = detail::resolve_prologue_fallback( | ||
| 475 | request, | ||
| 476 | 25 | std::span<const std::size_t>{order.data(), ordered_count}, | |
| 477 | range | ||
| 478 | 25 | ); | |
| 479 | 25 | if (fallback.hit && accepts_resolved_address(request, fallback.hit->address)) | |
| 480 | { | ||
| 481 | 13 | if (provenance != nullptr) | |
| 482 | { | ||
| 483 | ✗ | provenance->physical_source = fallback.physical_source; | |
| 484 | } | ||
| 485 | 13 | if (fallback.identity_warned) | |
| 486 | { | ||
| 487 | 1 | log_identity_warning(request, *fallback.hit); | |
| 488 | } | ||
| 489 | 13 | log_resolved(request, *fallback.hit, true); | |
| 490 | 13 | return *fallback.hit; | |
| 491 | } | ||
| 492 | 12 | if (text_error) | |
| 493 | { | ||
| 494 | // The text-tier code precedes the prologue diagnostics. An unencodable literal or an unconfined | ||
| 495 | // text scope is a defect in the request. An identity rejection or a missing rebuildable Direct | ||
| 496 | // candidate is a property of the recovery attempt. The caller must act on the request-level code. | ||
| 497 | ✗ | log_unresolved(request, DetourModKit::to_string(*text_error)); | |
| 498 | ✗ | return std::unexpected(Error{*text_error, "scan::resolve"}); | |
| 499 | } | ||
| 500 | 12 | if (fallback.identity_rejected) | |
| 501 | { | ||
| 502 | // RequireIdentity refused every structurally recovered site. The rebuilt prologue matched | ||
| 503 | // uniquely, but no recovered address passed the witness. Unlike a plain miss, this code tells the | ||
| 504 | // caller that a hooked near-twin exists. The signature then needs a sharper witness or a | ||
| 505 | // corroborating landmark. | ||
| 506 | 4 | log_unresolved(request, "prologue recovery rejected by identity gate"); | |
| 507 | 4 | return std::unexpected(Error{ErrorCode::PrologueIdentityRejected, "scan::resolve"}); | |
| 508 | } | ||
| 509 | 8 | if (fallback.ambiguous) | |
| 510 | { | ||
| 511 | // A rebuilt hook shape matched more than one executable site, so recovery cannot name a single | ||
| 512 | // redirect. This code follows the identity gate, which judges a uniquely found site. It precedes | ||
| 513 | // the incomplete and applicability diagnostics, because a proven multiplicity is more specific | ||
| 514 | // than a truncated sweep or a too-short tail. Unlike a plain miss, this code tells the caller that | ||
| 515 | // the surviving tail of the signature is not unique. | ||
| 516 | 1 | log_unresolved(request, DetourModKit::to_string(ErrorCode::PrologueFallbackAmbiguous)); | |
| 517 | 1 | return std::unexpected(Error{ErrorCode::PrologueFallbackAmbiguous, "scan::resolve"}); | |
| 518 | } | ||
| 519 | 7 | if (fallback.incomplete) | |
| 520 | { | ||
| 521 | // The recovery sweep over the executable pages went short, so "no rebuildable shape matched" is | ||
| 522 | // not a proven absence. This code follows the identity gate, which requires a recovered | ||
| 523 | // site. It precedes the applicability diagnostics, which read as a proven miss. | ||
| 524 | ✗ | log_unresolved(request, DetourModKit::to_string(ErrorCode::IncompleteScan)); | |
| 525 | ✗ | return std::unexpected(Error{ErrorCode::IncompleteScan, "scan::resolve"}); | |
| 526 | } | ||
| 527 | 7 | if (fallback.had_direct && fallback.not_applicable) | |
| 528 | { | ||
| 529 | // At least one Direct candidate existed, and no shape rebuilt a usable pattern from any of them. | ||
| 530 | // This code differs from a plain miss, where the ladder carries no Direct candidate to rebuild. | ||
| 531 | 5 | log_unresolved(request, "prologue recovery had no rebuildable Direct candidate"); | |
| 532 | 5 | return std::unexpected(Error{ErrorCode::PrologueFallbackNotApplicable, "scan::resolve"}); | |
| 533 | } | ||
| 534 | 2 | return std::nullopt; | |
| 535 | 25 | } | |
| 536 | |||
| 537 | 459 | Result<Hit> resolve_impl(const ScanRequest &request, detail::ResolvedScanHit *provenance) | |
| 538 | { | ||
| 539 | 459 | if (provenance != nullptr) | |
| 540 | { | ||
| 541 | 235 | provenance->physical_source = Region{}; | |
| 542 | 235 | provenance->winning_index = static_cast<std::size_t>(-1); | |
| 543 | 235 | provenance->match_span = Region{}; | |
| 544 | } | ||
| 545 | 459 | if (request.pages != Pages::Readable && request.pages != Pages::Executable) | |
| 546 | { | ||
| 547 | 1 | return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"}); | |
| 548 | } | ||
| 549 | 458 | if (!valid_candidate_order(request.order) || !valid_fallback_policy(request.fallback_policy)) | |
| 550 | { | ||
| 551 | 5 | return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"}); | |
| 552 | } | ||
| 553 | 1364 | for (const Candidate &candidate : request.ladder) | |
| 554 | { | ||
| 555 | 461 | if (!valid_candidate_enums(candidate)) | |
| 556 | { | ||
| 557 | 2 | return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"}); | |
| 558 | } | ||
| 559 | } | ||
| 560 | 451 | if (request.ladder.empty()) | |
| 561 | { | ||
| 562 | 3 | return std::unexpected(Error{ErrorCode::EmptyCandidates, "scan::resolve"}); | |
| 563 | } | ||
| 564 | 447 | const detail::ModuleSpan range = detail::module_span(request.scope); | |
| 565 | 447 | if (!range.valid()) | |
| 566 | { | ||
| 567 | 3 | return std::unexpected(Error{ErrorCode::InvalidRange, "scan::resolve"}); | |
| 568 | } | ||
| 569 | 444 | if (!detail::readable_scan_is_authoritative(range, request.pages, request.exclusions)) | |
| 570 | { | ||
| 571 | // The Readable authority rule of scan::Pages refuses the request before any tier is graded. | ||
| 572 | 3 | return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"}); | |
| 573 | } | ||
| 574 | |||
| 575 | 442 | detail::ScanExclusions ladder_exclusions; | |
| 576 | 442 | collect_ladder_exclusions(request, range, ladder_exclusions); | |
| 577 | 442 | if (ladder_exclusions.overflowed()) | |
| 578 | { | ||
| 579 | 1 | return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"}); | |
| 580 | } | ||
| 581 | |||
| 582 | 447 | std::vector<std::size_t> order(request.ladder.size()); | |
| 583 | 435 | const std::size_t ordered_count = order_candidates(request.order, request.ladder, order); | |
| 584 | // The two latches split a candidate failure by what it proves, not by its code. A byte candidate whose | ||
| 585 | // sweep went short leaves the module-executable pages that hooked-prologue recovery searches only | ||
| 586 | // partly read. A full direct miss is then unproven, so recovery must not run. A text candidate fails on | ||
| 587 | // an unencodable literal, or on an unconfined or truncated readable phase-1 sweep. That failure says | ||
| 588 | // nothing about executable-page coverage, and recovery acts only on Direct candidates. The text-tier | ||
| 589 | // code therefore replaces the generic miss and does not suppress recovery. | ||
| 590 | 435 | std::optional<ErrorCode> coverage_error; | |
| 591 | 435 | std::optional<ErrorCode> text_error; | |
| 592 | std::optional<Hit> hit = resolve_ladder_rungs( | ||
| 593 | request, | ||
| 594 | range, | ||
| 595 | ladder_exclusions, | ||
| 596 | order, | ||
| 597 | ordered_count, | ||
| 598 | coverage_error, | ||
| 599 | text_error, | ||
| 600 | provenance | ||
| 601 | 435 | ); | |
| 602 | 407 | if (hit) | |
| 603 | { | ||
| 604 | 693 | return std::move(*hit); | |
| 605 | } | ||
| 606 | |||
| 607 | 60 | if (coverage_error) | |
| 608 | { | ||
| 609 | // A byte rung was budget-bound or truncated, so the executable pages recovery would search were not | ||
| 610 | // fully covered and a rebuilt-prologue hit could not be read as "the direct scan missed". Fail | ||
| 611 | // closed before the fallback, and ahead of every other verdict so a coverage code can never become | ||
| 612 | // NoMatch. | ||
| 613 | 2 | log_unresolved(request, DetourModKit::to_string(*coverage_error)); | |
| 614 | 2 | return std::unexpected(Error{*coverage_error, "scan::resolve"}); | |
| 615 | } | ||
| 616 | |||
| 617 | 58 | if (request.fallback_policy != FallbackPolicy::Off) | |
| 618 | { | ||
| 619 | std::optional<Result<Hit>> verdict = | ||
| 620 | 25 | try_prologue_fallback(request, order, ordered_count, range, text_error, provenance); | |
| 621 | 25 | if (verdict) | |
| 622 | { | ||
| 623 | 46 | return std::move(*verdict); | |
| 624 | } | ||
| 625 | 25 | } | |
| 626 | |||
| 627 | 35 | if (text_error) | |
| 628 | { | ||
| 629 | // Reached when the fallback is Off or produced no verdict of its own. The typed text-tier code is | ||
| 630 | // more actionable than a generic miss, and a truncated text sweep must never read as a proven | ||
| 631 | // absence. | ||
| 632 | 1 | log_unresolved(request, DetourModKit::to_string(*text_error)); | |
| 633 | 1 | return std::unexpected(Error{*text_error, "scan::resolve"}); | |
| 634 | } | ||
| 635 | |||
| 636 | 34 | log_unresolved(request, "no ladder candidate resolved uniquely in scope"); | |
| 637 | 34 | return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve"}); | |
| 638 | 434 | } | |
| 639 | } // namespace | ||
| 640 | |||
| 641 | 224 | Result<Hit> resolve(const ScanRequest &request) | |
| 642 | { | ||
| 643 | 224 | return resolve_impl(request, nullptr); | |
| 644 | } | ||
| 645 | |||
| 646 | Result<std::vector<Result<Hit>>> | ||
| 647 | 30 | resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers) noexcept | |
| 648 | { | ||
| 649 | try | ||
| 650 | { | ||
| 651 | 58 | return detail::run_fork_join<ScanRequest, Result<Hit>>( | |
| 652 | requests, | ||
| 653 | max_workers, | ||
| 654 | 100 | [](const ScanRequest &request) -> Result<Hit> | |
| 655 | { | ||
| 656 | try | ||
| 657 | { | ||
| 658 | 100 | return resolve(request); | |
| 659 | } | ||
| 660 | 19 | catch (const std::bad_alloc &) | |
| 661 | { | ||
| 662 | 19 | return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"}); | |
| 663 | 19 | } | |
| 664 | }, | ||
| 665 | 100 | [](const ScanRequest &) noexcept -> Result<Hit> | |
| 666 | 100 | { return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve_batch"}); } | |
| 667 | 28 | ); | |
| 668 | } | ||
| 669 | 2 | catch (const std::bad_alloc &) | |
| 670 | { | ||
| 671 | // The resolve_batch docblock in scan.hpp owns this outer-Result arm. Error holds a const char*, so | ||
| 672 | // this path allocates nothing. | ||
| 673 | 2 | return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"}); | |
| 674 | 2 | } | |
| 675 | ✗ | catch (...) | |
| 676 | { | ||
| 677 | ✗ | return std::unexpected(Error{ErrorCode::Unknown, "scan::resolve_batch"}); | |
| 678 | ✗ | } | |
| 679 | } | ||
| 680 | } // namespace scan | ||
| 681 | |||
| 682 | 235 | Result<detail::ResolvedScanHit> detail::resolve_scan_with_provenance(const scan::ScanRequest &request) | |
| 683 | { | ||
| 684 | 235 | ResolvedScanHit resolved; | |
| 685 | 235 | Result<scan::Hit> hit = scan::resolve_impl(request, &resolved); | |
| 686 | 235 | if (!hit) | |
| 687 | { | ||
| 688 | 16 | return std::unexpected(hit.error()); | |
| 689 | } | ||
| 690 | 438 | resolved.hit = std::move(*hit); | |
| 691 | 219 | return resolved; | |
| 692 | 235 | } | |
| 693 | } // namespace DetourModKit | ||
| 694 |