include/DetourModKit/scan.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_SCAN_HPP | ||
| 2 | #define DETOURMODKIT_SCAN_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file scan.hpp | ||
| 6 | * @brief The public scan surface: pattern matching, candidate-ladder resolution, and the | ||
| 7 | * standalone RIP-relative, string-xref, code-constant, and export resolvers. | ||
| 8 | * @details `Pattern` compiles an AOB mini-DSL string and holds its bytes and mask inline. | ||
| 9 | * @ref scan locates one `Pattern` in a `Region`. @ref resolve runs an ordered | ||
| 10 | * @ref Candidate ladder until one strategy produces a confident address. The free | ||
| 11 | * resolvers expose a single backend for a caller that holds one piece of evidence. | ||
| 12 | * The resolution note in docs/design/resolution.md owns the mechanism. | ||
| 13 | * @warning `[B-100]` Under the loader lock, call only Callback-safe entry points and supply each required Region from | ||
| 14 | * setup. Ladder construction and resolution can allocate, scan memory, or create threads. | ||
| 15 | */ | ||
| 16 | |||
| 17 | #include "DetourModKit/address.hpp" | ||
| 18 | #include "DetourModKit/defines.hpp" | ||
| 19 | #include "DetourModKit/detail/pattern_core.hpp" | ||
| 20 | #include "DetourModKit/error.hpp" | ||
| 21 | #include "DetourModKit/region.hpp" | ||
| 22 | |||
| 23 | #include <array> | ||
| 24 | #include <cstddef> | ||
| 25 | #include <cstdint> | ||
| 26 | #include <span> | ||
| 27 | #include <stdexcept> | ||
| 28 | #include <string> | ||
| 29 | #include <string_view> | ||
| 30 | #include <variant> | ||
| 31 | #include <vector> | ||
| 32 | |||
| 33 | namespace DetourModKit::scan | ||
| 34 | { | ||
| 35 | class Pattern; | ||
| 36 | } // namespace DetourModKit::scan | ||
| 37 | |||
| 38 | namespace DetourModKit::detail | ||
| 39 | { | ||
| 40 | /// Returns @p pattern's immutable compiled buffer for internal scan adapters. | ||
| 41 | [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept; | ||
| 42 | } // namespace DetourModKit::detail | ||
| 43 | |||
| 44 | namespace DetourModKit::scan | ||
| 45 | { | ||
| 46 | /** | ||
| 47 | * @class Pattern | ||
| 48 | * @brief A value-semantic compiled AOB pattern that owns its bytes and mask inline. | ||
| 49 | * @details Construct with compile() (runtime, returns Result) or literal() (compile-time, returns by value). The | ||
| 50 | * compiled form exposes its bytes, mask, result offset, and the cached compile-time anchor so the scan | ||
| 51 | * engine can prefilter and verify without re-parsing, and matches_at() applies the same masked compare | ||
| 52 | * the engine uses for a single position. Copyable and trivially comparable in cost to its inline arrays. | ||
| 53 | * @note The compile() / literal() factories are setup/control-plane; size(), the byte/mask/anchor accessors, and | ||
| 54 | * matches_at() are callback-safe (pure value reads with no allocation, I/O, or locking). | ||
| 55 | */ | ||
| 56 | class Pattern | ||
| 57 | { | ||
| 58 | public: | ||
| 59 | /** | ||
| 60 | * @brief Compiles a runtime AOB DSL string. | ||
| 61 | * @param dsl The whitespace-separated pattern, e.g. "48 8B 05 ?? ?? ?? ??". | ||
| 62 | * @return A Pattern on success, or Error{ErrorCode::BadPattern} when the string is malformed/empty/over-cap. | ||
| 63 | * @details Never undefined behaviour on bad input: a parse failure becomes a recoverable Error, with the | ||
| 64 | * specific parse status in the Error's extra slot. | ||
| 65 | * @note Setup/control-plane only: compile patterns at init, not inside a hot callback. | ||
| 66 | */ | ||
| 67 | 399 | [[nodiscard]] static Result<Pattern> compile(std::string_view dsl) | |
| 68 | { | ||
| 69 | 399 | const detail::PatternParse parsed = detail::parse_pattern(dsl); | |
| 70 |
2/2✓ Branch 3 → 4 taken 22 times.
✓ Branch 3 → 7 taken 377 times.
|
399 | if (parsed.status != detail::PatternStatus::Ok) |
| 71 | { | ||
| 72 | 22 | return std::unexpected( | |
| 73 | 22 | Error{ErrorCode::BadPattern, "scan::compile", 0, static_cast<std::uint32_t>(parsed.status)} | |
| 74 | 22 | ); | |
| 75 | } | ||
| 76 | 377 | return Pattern{parsed.buffer}; | |
| 77 | } | ||
| 78 | |||
| 79 | /** | ||
| 80 | * @brief Compiles an in-source AOB DSL literal at compile time. | ||
| 81 | * @param dsl A constant-expression pattern string. | ||
| 82 | * @return The compiled Pattern by value. | ||
| 83 | * @details consteval, so a malformed literal causes a compile error at that literal. The throw below becomes a | ||
| 84 | * non-constant expression during constant evaluation, instead of a runtime Result to deref. | ||
| 85 | * @note Compile-time only: consteval, so it runs during compilation and has no runtime call site to classify. | ||
| 86 | */ | ||
| 87 | [[nodiscard]] static consteval Pattern literal(std::string_view dsl) | ||
| 88 | { | ||
| 89 | const detail::PatternParse parsed = detail::parse_pattern(dsl); | ||
| 90 | if (parsed.status != detail::PatternStatus::Ok) | ||
| 91 | { | ||
| 92 | throw "DetourModKit: scan::Pattern::literal() received a malformed AOB pattern"; | ||
| 93 | } | ||
| 94 | return Pattern{parsed.buffer}; | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Number of bytes in the compiled pattern. | ||
| 98 | 536 | [[nodiscard]] constexpr std::size_t size() const noexcept { return m_data.length; } | |
| 99 | |||
| 100 | /** | ||
| 101 | * @brief The `|` result offset as an index into the fixed byte stream (0 when there is no offset marker). | ||
| 102 | * @details For a pattern with bounded jumps the resolver adds the actual gap bytes at match time, so the | ||
| 103 | * returned address still points at the intended run; this reports only the fixed-byte portion. | ||
| 104 | */ | ||
| 105 | 804 | [[nodiscard]] constexpr std::size_t offset() const noexcept { return m_data.offset; } | |
| 106 | |||
| 107 | /** | ||
| 108 | * @brief View over the compiled fixed pattern bytes, all segments concatenated (length == size()). | ||
| 109 | * @details Gap bytes are not stored, so for a jump-bearing pattern this is the fixed bytes only, not the span. | ||
| 110 | */ | ||
| 111 | 1230 | [[nodiscard]] constexpr std::span<const std::byte> bytes() const noexcept | |
| 112 | { | ||
| 113 | 1230 | return std::span<const std::byte>(m_data.bytes.data(), m_data.length); | |
| 114 | } | ||
| 115 | |||
| 116 | /// View over the per-byte match mask paralleling bytes() (length == size()). | ||
| 117 | 1239 | [[nodiscard]] constexpr std::span<const std::byte> mask() const noexcept | |
| 118 | { | ||
| 119 | 1239 | return std::span<const std::byte>(m_data.mask.data(), m_data.length); | |
| 120 | } | ||
| 121 | |||
| 122 | /// True when the pattern carries at least one bounded jump (and therefore more than one segment). | ||
| 123 | 72 | [[nodiscard]] constexpr bool has_jumps() const noexcept { return m_data.jump_count > 0; } | |
| 124 | |||
| 125 | /// Number of fixed segments the pattern splits into (1 for a plain pattern; one more than the jump count). | ||
| 126 | 5 | [[nodiscard]] constexpr std::size_t segment_count() const noexcept { return m_data.jump_count + 1; } | |
| 127 | |||
| 128 | /// Fewest bytes any match can occupy: the fixed byte count plus every gap's minimum skip. | ||
| 129 | 5 | [[nodiscard]] constexpr std::size_t min_match_length() const noexcept | |
| 130 | { | ||
| 131 | 5 | return detail::min_match_length(m_data); | |
| 132 | } | ||
| 133 | |||
| 134 | /// Most bytes any match can occupy: the fixed byte count plus every gap's maximum skip. | ||
| 135 | 4 | [[nodiscard]] constexpr std::size_t max_match_length() const noexcept | |
| 136 | { | ||
| 137 | 4 | return detail::max_match_length(m_data); | |
| 138 | } | ||
| 139 | |||
| 140 | /// True when the pattern has at least one fully-known byte the prefilter can anchor on. | ||
| 141 | 226 | [[nodiscard]] constexpr bool has_anchor() const noexcept { return m_data.anchor < m_data.length; } | |
| 142 | |||
| 143 | /// Index of the rarest fully-known byte; only meaningful when has_anchor() is true. | ||
| 144 | 207 | [[nodiscard]] constexpr std::size_t anchor_index() const noexcept { return m_data.anchor; } | |
| 145 | |||
| 146 | /// The anchor byte value, or a zero byte when has_anchor() is false. | ||
| 147 | 5 | [[nodiscard]] constexpr std::byte anchor_byte() const noexcept | |
| 148 | { | ||
| 149 |
2/2✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 6 taken 1 time.
|
5 | return has_anchor() ? m_data.bytes[m_data.anchor] : std::byte{0x00}; |
| 150 | } | ||
| 151 | |||
| 152 | /** | ||
| 153 | * @brief Tests whether the pattern matches at the start of @p window and honors each bounded jump. | ||
| 154 | * @param window The candidate byte window. A match requires at least min_match_length() bytes. | ||
| 155 | * @return True only when the bounded search finds a complete placement at the window start. | ||
| 156 | * It returns false when the search finds no placement or exhausts its per-position budget before it | ||
| 157 | * checks every candidate placement. A false result is not proof of absence. | ||
| 158 | * @details A byte agrees when (memory ^ pattern) & mask is zero. A mask of 0x00 accepts every byte. | ||
| 159 | * Masks 0xF0 and 0x0F compare only the fixed nibble. The search tries gaps from smallest to largest. | ||
| 160 | * @note Callback-safe: the bounded search allocates no memory, performs no I/O, and takes no lock. | ||
| 161 | */ | ||
| 162 | 26 | [[nodiscard]] constexpr bool matches_at(std::span<const std::byte> window) const noexcept | |
| 163 | { | ||
| 164 | 26 | return detail::matches_buffer_at(m_data, window); | |
| 165 | } | ||
| 166 | |||
| 167 | private: | ||
| 168 | friend constexpr const detail::PatternBuffer &detail::pattern_buffer(const Pattern &pattern) noexcept; | ||
| 169 | |||
| 170 | // Private so the only ways to obtain a Pattern are the validating factories; a default-constructed or | ||
| 171 | // arbitrary-buffer Pattern can never exist. | ||
| 172 | 377 | constexpr explicit Pattern(const detail::PatternBuffer &data) noexcept : m_data{data} {} | |
| 173 | |||
| 174 | detail::PatternBuffer m_data{}; | ||
| 175 | }; | ||
| 176 | |||
| 177 | } // namespace DetourModKit::scan | ||
| 178 | |||
| 179 | namespace DetourModKit::detail | ||
| 180 | { | ||
| 181 | /// Returns @p pattern's immutable compiled buffer for internal scan adapters. | ||
| 182 | 1218 | [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept | |
| 183 | { | ||
| 184 | 1218 | return pattern.m_data; | |
| 185 | } | ||
| 186 | } // namespace DetourModKit::detail | ||
| 187 | |||
| 188 | namespace DetourModKit::scan | ||
| 189 | { | ||
| 190 | |||
| 191 | /** | ||
| 192 | * @enum Pages | ||
| 193 | * @brief Which page-protection class a page-gated scan accepts. | ||
| 194 | * @details Readable accepts every committed readable page, so one pass covers both code and data candidates. | ||
| 195 | * Executable narrows to committed execute-readable code pages only, the lower-false-positive choice when | ||
| 196 | * a signature must land on code. | ||
| 197 | */ | ||
| 198 | enum class Pages : std::uint8_t | ||
| 199 | { | ||
| 200 | /// Every committed readable page (a superset of Executable); the default for a data-capable sweep. | ||
| 201 | Readable, | ||
| 202 | /// Committed execute-readable code pages only. | ||
| 203 | Executable | ||
| 204 | }; | ||
| 205 | |||
| 206 | /** | ||
| 207 | * @brief Largest architectural x86-64 instruction length, in bytes. | ||
| 208 | * @details x86-64 instructions are at most 15 bytes long. RIP-relative helpers validate their disp32 layout against | ||
| 209 | * this bound before using the instruction length as the next-instruction base. | ||
| 210 | */ | ||
| 211 | inline constexpr std::size_t MAX_X86_INSTRUCTION_LENGTH = 15; | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @brief Tests whether a disp32 field fits within an x86-64 instruction of the given length. | ||
| 215 | * @param displacement_offset Byte offset of the signed 4-byte displacement field. | ||
| 216 | * @param instruction_length Total instruction length in bytes. | ||
| 217 | * @return True when the field lies entirely within a non-empty instruction no longer than | ||
| 218 | * @ref MAX_X86_INSTRUCTION_LENGTH. | ||
| 219 | * @details This checks structural bounds only; the caller remains responsible for supplying an opcode whose operand | ||
| 220 | * is actually RIP-relative. Subtraction after the offset comparison avoids unsigned-overflow arithmetic. | ||
| 221 | * @note Callback-safe: pure constexpr arithmetic. | ||
| 222 | */ | ||
| 223 | [[nodiscard]] constexpr bool | ||
| 224 | 130 | is_valid_rip_relative_layout(std::size_t displacement_offset, std::size_t instruction_length) noexcept | |
| 225 | { | ||
| 226 |
3/4✓ Branch 2 → 3 taken 126 times.
✓ Branch 2 → 6 taken 4 times.
✓ Branch 3 → 4 taken 126 times.
✗ Branch 3 → 6 not taken.
|
256 | return instruction_length <= MAX_X86_INSTRUCTION_LENGTH && displacement_offset <= instruction_length && |
| 227 |
2/2✓ Branch 4 → 5 taken 116 times.
✓ Branch 4 → 6 taken 10 times.
|
256 | instruction_length - displacement_offset >= sizeof(std::int32_t); |
| 228 | } | ||
| 229 | |||
| 230 | /** | ||
| 231 | * @enum SimdLevel | ||
| 232 | * @brief The highest SIMD verification tier the engine selects at runtime. | ||
| 233 | */ | ||
| 234 | enum class SimdLevel : std::uint8_t | ||
| 235 | { | ||
| 236 | /// Byte-by-byte verification (no SIMD). | ||
| 237 | Scalar, | ||
| 238 | /// SSE2 (16 bytes per iteration). | ||
| 239 | Sse2, | ||
| 240 | /// AVX2 (32 bytes per iteration, with an SSE2 + scalar tail). | ||
| 241 | Avx2, | ||
| 242 | /// AVX-512F + AVX-512BW (64 bytes per iteration). Opt-in: a DMK_ENABLE_AVX512 build on an AVX-512 host. | ||
| 243 | Avx512 | ||
| 244 | }; | ||
| 245 | |||
| 246 | /** | ||
| 247 | * @brief Returns the enumerator name for a SimdLevel. | ||
| 248 | * @param level The tier. | ||
| 249 | * @return A static string view; "Unknown" for an out-of-range value. | ||
| 250 | * @note Callback-safe: a pure constexpr value map. | ||
| 251 | */ | ||
| 252 | [[nodiscard]] constexpr std::string_view to_string(SimdLevel level) noexcept | ||
| 253 | { | ||
| 254 | switch (level) | ||
| 255 | { | ||
| 256 | case SimdLevel::Scalar: | ||
| 257 | return "Scalar"; | ||
| 258 | case SimdLevel::Sse2: | ||
| 259 | return "Sse2"; | ||
| 260 | case SimdLevel::Avx2: | ||
| 261 | return "Avx2"; | ||
| 262 | case SimdLevel::Avx512: | ||
| 263 | return "Avx512"; | ||
| 264 | } | ||
| 265 | return "Unknown"; | ||
| 266 | } | ||
| 267 | |||
| 268 | /** | ||
| 269 | * @brief Reports the SIMD tier find-pattern matching uses at runtime. | ||
| 270 | * @details Reflects both compile-time support (which intrinsics were built) and runtime CPU detection (CPUID plus | ||
| 271 | * OS XGETBV). Reports Avx512 only when the library was built with the opt-in DMK_ENABLE_AVX512 option and | ||
| 272 | * the host has AVX-512F + AVX-512BW; otherwise it reports the highest available lower tier. | ||
| 273 | * @note Callback-safe: pure CPU-feature read, no allocation or locking. | ||
| 274 | */ | ||
| 275 | [[nodiscard]] SimdLevel active_simd_level() noexcept; | ||
| 276 | |||
| 277 | /** | ||
| 278 | * @enum StringEncoding | ||
| 279 | * @brief Byte encoding of an anchor string as it is stored in the image. | ||
| 280 | */ | ||
| 281 | enum class StringEncoding : std::uint8_t | ||
| 282 | { | ||
| 283 | /** | ||
| 284 | * @brief The literal is stored as the query's bytes verbatim (char / std::string literals). | ||
| 285 | * @details Byte-transparent: the query text is searched for exactly as given, so a caller may anchor on a byte | ||
| 286 | * sequence that is not well-formed UTF-8. | ||
| 287 | */ | ||
| 288 | Utf8, | ||
| 289 | /** | ||
| 290 | * @brief The literal is stored as UTF-16LE (wchar_t / L"" on Windows). | ||
| 291 | * @details The query text is UTF-8 and is transcoded, so it must be well-formed: a supplementary code point | ||
| 292 | * becomes a surrogate pair, and ill-formed input returns @ref ErrorCode::MalformedQueryText rather | ||
| 293 | * than searching for something else. | ||
| 294 | */ | ||
| 295 | Utf16le | ||
| 296 | }; | ||
| 297 | |||
| 298 | /** | ||
| 299 | * @enum XrefReturn | ||
| 300 | * @brief What a resolved string cross-reference returns. | ||
| 301 | */ | ||
| 302 | enum class XrefReturn : std::uint8_t | ||
| 303 | { | ||
| 304 | /// Exact address of the instruction that loads the string. | ||
| 305 | ReferencingInstruction, | ||
| 306 | /** | ||
| 307 | * @brief Enclosing-function entry of the referencing instruction. | ||
| 308 | * @details Authoritative x64 `.pdata` bounds via RtlLookupFunctionEntry (following chained fragments to the | ||
| 309 | * primary function), with a bounded RET/INT3 prologue back-scan as the fallback for leaf functions and | ||
| 310 | * code regions with no registered exception table. | ||
| 311 | */ | ||
| 312 | EnclosingFunction, | ||
| 313 | /** | ||
| 314 | * @brief Address of the global data slot a `mov [rip+slot], reg` stores the loaded string pointer into. | ||
| 315 | * @details Applies when the unique reference is a `lea reg, [rip+string]` shortly followed by that store; it | ||
| 316 | * resolves a cached global string pointer rather than the load site. Reports | ||
| 317 | * ErrorCode::StoreNotFound when no such store follows the reference. | ||
| 318 | */ | ||
| 319 | StringPointerSlot | ||
| 320 | }; | ||
| 321 | |||
| 322 | /** | ||
| 323 | * @struct StringRefQuery | ||
| 324 | * @brief A string-reference anchor query, with the string text borrowed for the duration of the call. | ||
| 325 | * @details Anchors a target on an immutable string literal in the image's read-only data, then resolves the unique | ||
| 326 | * RIP-relative reference to it. @ref text is a non-owning view into caller storage. This query is for the | ||
| 327 | * immediate find_string_xref() call. A stored string-xref Candidate owns its literal independently (see | ||
| 328 | * Candidate::string_xref). | ||
| 329 | */ | ||
| 330 | struct StringRefQuery | ||
| 331 | { | ||
| 332 | /** | ||
| 333 | * @brief Literal content (no quotes); borrowed for the call. | ||
| 334 | * @details Must not contain an embedded NUL on either encoding: it would contradict @ref require_terminator and | ||
| 335 | * cannot occur in the C string literals these anchors name, so it returns | ||
| 336 | * @ref ErrorCode::MalformedQueryText. | ||
| 337 | */ | ||
| 338 | std::string_view text; | ||
| 339 | /// How it is stored in the image, and therefore how @ref text is interpreted (see @ref StringEncoding). | ||
| 340 | StringEncoding encoding = StringEncoding::Utf8; | ||
| 341 | /** | ||
| 342 | * @brief Match a trailing NUL so a prefix of a longer literal is not matched (e.g. "Player" inside | ||
| 343 | * "PlayerController"). | ||
| 344 | */ | ||
| 345 | bool require_terminator = true; | ||
| 346 | /// Selects the exact instruction site, the enclosing-function heuristic, or the cached global pointer slot. | ||
| 347 | XrefReturn return_mode = XrefReturn::ReferencingInstruction; | ||
| 348 | /** | ||
| 349 | * @brief Selects the phase-2 reference scan breadth. | ||
| 350 | * @details false (default) runs the fast, desync-immune all-offset shape scan that recognizes the REX.W | ||
| 351 | * `lea`/`mov reg, [rip+disp32]` forms. true keeps that scan and also runs a Zydis-verified linear | ||
| 352 | * sweep that recognizes the rarer RIP-relative shapes (`cmp [rip+d], imm`, `push [rip+d]`, a no-REX | ||
| 353 | * `lea`/`mov`, ...), at the cost of a full decode per instruction. Derived return modes may still run | ||
| 354 | * that broad sweep as a confirmation pass when this flag is false, so a shape-local narrow hit is not | ||
| 355 | * certified while a rarer second reference exists. | ||
| 356 | */ | ||
| 357 | bool broad_match = false; | ||
| 358 | }; | ||
| 359 | |||
| 360 | /** | ||
| 361 | * @brief Resolves a string-reference anchor inside one mapped image. | ||
| 362 | * @param query The string and how to interpret its reference. | ||
| 363 | * @param scope Module image to search; defaults to the host executable. A scope confined to neither one mapped | ||
| 364 | * image nor one reserved allocation returns @ref ErrorCode::NotAuthoritative. This entry point takes no | ||
| 365 | * exclusion span and no page selector, so a confined scope is the only remedy. | ||
| 366 | * @return The referencing-instruction (or enclosing-function, or pointer-slot) address, or an Error. | ||
| 367 | * @details Two fail-closed phases. Phase 1 locates the single occurrence of @p query.text in the scope's readable | ||
| 368 | * pages. Zero returns @ref ErrorCode::StringNotFound, and more than one returns | ||
| 369 | * @ref ErrorCode::StringAmbiguous. Phase 2 finds the single RIP-relative reference whose resolved target | ||
| 370 | * equals that string address. Zero returns @ref ErrorCode::NoReference, and more than one returns | ||
| 371 | * @ref ErrorCode::AmbiguousReference. An observed second copy or second reference stays ambiguous even | ||
| 372 | * when the sweep was also truncated. A truncated sweep that observed no multiplicity certifies neither | ||
| 373 | * absence nor uniqueness and returns @ref ErrorCode::IncompleteScan. Text that cannot be encoded as | ||
| 374 | * asked returns | ||
| 375 | * @ref ErrorCode::MalformedQueryText. An out-of-range @ref StringRefQuery::encoding or | ||
| 376 | * @ref StringRefQuery::return_mode returns @ref ErrorCode::InvalidArg before phase 1 starts. The result | ||
| 377 | * is ASLR-correct because the reference is RIP-relative. | ||
| 378 | * @note Phase 1 excludes @p query.text's own storage when that storage lies inside @p scope. If the caller's | ||
| 379 | * buffer is the only copy in scope, the result is @ref ErrorCode::StringNotFound. Anchor on a literal that | ||
| 380 | * the scanned image owns. | ||
| 381 | * @note With @ref StringRefQuery::broad_match false, @ref XrefReturn::ReferencingInstruction reports uniqueness | ||
| 382 | * among the fast REX.W `lea`/`mov reg, [rip+disp32]` shapes only. A derived return | ||
| 383 | * (@ref XrefReturn::EnclosingFunction or @ref XrefReturn::StringPointerSlot) runs a broad confirmation | ||
| 384 | * sweep after a single narrow hit, so a rarer second reference fails closed as | ||
| 385 | * @ref ErrorCode::AmbiguousReference. That sweep does not promote a broad-only reference into a hit. Set | ||
| 386 | * @ref StringRefQuery::broad_match to accept the rarer shapes. @ref anchor::Anchor::xref_broad_match and | ||
| 387 | * @ref anchor::ScanProfile::default_broad_string_xref expose the same knob. | ||
| 388 | * @note Not noexcept: the broad-match phase may allocate during its decode. Setup/control-plane only. | ||
| 389 | */ | ||
| 390 | [[nodiscard]] Result<Address> find_string_xref(const StringRefQuery &query, Region scope = Region::host()); | ||
| 391 | |||
| 392 | /** | ||
| 393 | * @brief Resolves a named export to its address through one module's PE Export Address Table. | ||
| 394 | * @param export_name The exact export symbol, for example "Sleep". PE export names are case-sensitive. | ||
| 395 | * @param module The mapped image whose export directory to search; defaults to the host executable. An export | ||
| 396 | * usually lives in a different module from the code a mod scans, so pass the export's own module, | ||
| 397 | * for example @ref Region::module_named("kernel32.dll"). | ||
| 398 | * @return The absolute address of the exported symbol, or an Error. | ||
| 399 | * @details The walk parses the mapped image's own IMAGE_EXPORT_DIRECTORY. It never calls GetProcAddress, so it | ||
| 400 | * never enters the loader and never runs a DllMain. Every RVA is bound-checked against the image and | ||
| 401 | * every read is guarded, so a truncated or hostile export section returns an Error, never a host fault. | ||
| 402 | * A missing export directory, an absent name, an ordinal-only export, an out-of-image RVA, and an empty | ||
| 403 | * @p export_name all return @ref ErrorCode::ExportNotFound. A null or invalid module image returns | ||
| 404 | * @ref ErrorCode::InvalidRange. A forwarded export returns @ref ErrorCode::ExportForwarded instead of | ||
| 405 | * the address of the forwarder string, because only the loader can follow a forwarder. | ||
| 406 | * @note Setup/control-plane only by convention, because it queries a module image, not a per-frame quantity. | ||
| 407 | * Allocation-free and noexcept: the name compare runs against a fixed-length window. | ||
| 408 | */ | ||
| 409 | [[nodiscard]] Result<Address> resolve_export(std::string_view export_name, Region module = Region::host()) noexcept; | ||
| 410 | |||
| 411 | /** | ||
| 412 | * @enum OperandKind | ||
| 413 | * @brief Which operand field @ref read_code_constant extracts. | ||
| 414 | */ | ||
| 415 | enum class OperandKind : std::uint8_t | ||
| 416 | { | ||
| 417 | /// An immediate operand (e.g. the imm of `add reg, imm`). | ||
| 418 | Immediate, | ||
| 419 | /// A memory operand's displacement (e.g. the disp of `[reg + disp]`). | ||
| 420 | MemoryDisplacement | ||
| 421 | }; | ||
| 422 | |||
| 423 | /** | ||
| 424 | * @enum Mode | ||
| 425 | * @brief The resolution strategy a Candidate uses to turn a signature into an address. | ||
| 426 | * @details The mode is data on the Candidate (the active std::variant alternative), so a ladder can interleave | ||
| 427 | * the tiers freely. The two byte tiers (Direct, RipRelative) scan a compiled Pattern. The two text tiers | ||
| 428 | * (RttiVtable, StringXref) resolve a name or literal through a dedicated backend. They are unique-only by | ||
| 429 | * construction and fail closed on ambiguity regardless of the request's require_unique. | ||
| 430 | */ | ||
| 431 | enum class Mode : std::uint8_t | ||
| 432 | { | ||
| 433 | /// Scan for the Pattern, then add a fixed signed walk-back to the hit (the address IS at the match site). | ||
| 434 | Direct, | ||
| 435 | /// Scan for the Pattern, then read the RIP-relative disp32 it spans and compute the absolute target. | ||
| 436 | RipRelative, | ||
| 437 | /// Resolve the primary vtable of an MSVC-mangled type name through the reverse-RTTI walk. | ||
| 438 | RttiVtable, | ||
| 439 | /// Anchor on an immutable string literal and resolve the unique RIP-relative reference to it. | ||
| 440 | StringXref | ||
| 441 | }; | ||
| 442 | |||
| 443 | /** | ||
| 444 | * @enum CandidateOrder | ||
| 445 | * @brief How a ScanRequest's ladder is ordered before the resolver tries it. | ||
| 446 | * @details AsDeclared preserves the caller's array order. UniqueFirst promotes the unique-only text tiers, then | ||
| 447 | * anchored byte patterns, then the other byte patterns. @ref resolve returns the first candidate that | ||
| 448 | * resolves successfully. The changed order can alter the returned @ref Hit when valid candidates resolve | ||
| 449 | * to different addresses. Every candidate retains the same verification and validity rules. | ||
| 450 | */ | ||
| 451 | enum class CandidateOrder : std::uint8_t | ||
| 452 | { | ||
| 453 | /// Try candidates in the order the caller wrote them. | ||
| 454 | AsDeclared, | ||
| 455 | /// Try unique-only text tiers, then anchored byte patterns, then the rest; declared order kept within a group. | ||
| 456 | UniqueFirst | ||
| 457 | }; | ||
| 458 | |||
| 459 | /** | ||
| 460 | * @brief Returns the enumerator name for a CandidateOrder. | ||
| 461 | * @param order The ordering policy. | ||
| 462 | * @return A static string view; "Unknown" for an out-of-range value. | ||
| 463 | * @note Callback-safe: a pure constexpr value map with no allocation, I/O, or locking. | ||
| 464 | */ | ||
| 465 | 5 | [[nodiscard]] constexpr std::string_view candidate_order_to_string(CandidateOrder order) noexcept | |
| 466 | { | ||
| 467 |
3/3✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 2 → 5 taken 1 time.
|
5 | switch (order) |
| 468 | { | ||
| 469 | 2 | case CandidateOrder::AsDeclared: | |
| 470 | 2 | return "AsDeclared"; | |
| 471 | 2 | case CandidateOrder::UniqueFirst: | |
| 472 | 2 | return "UniqueFirst"; | |
| 473 | } | ||
| 474 | 1 | return "Unknown"; | |
| 475 | } | ||
| 476 | |||
| 477 | /** | ||
| 478 | * @struct DirectPattern | ||
| 479 | * @brief The Direct-tier payload: a compiled Pattern plus the signed walk-back applied to the match. | ||
| 480 | */ | ||
| 481 | struct DirectPattern | ||
| 482 | { | ||
| 483 | /// The compiled signature to scan for. | ||
| 484 | Pattern pattern; | ||
| 485 | /// Signed byte delta added to the match (negative walks backward); 0 returns the match itself. | ||
| 486 | std::ptrdiff_t walk_back{0}; | ||
| 487 | }; | ||
| 488 | |||
| 489 | /** | ||
| 490 | * @struct RipRelativePattern | ||
| 491 | * @brief The RipRelative-tier payload: a compiled Pattern plus the disp32 location and instruction length. | ||
| 492 | * @details The resolved target is `(match + instruction_length) + sign_extend(disp32 @ (match + displacement_at))`, | ||
| 493 | * read under a fault guard, so a corrupt displacement is a miss rather than a host fault. | ||
| 494 | */ | ||
| 495 | struct RipRelativePattern | ||
| 496 | { | ||
| 497 | /// The compiled signature to scan for. | ||
| 498 | Pattern pattern; | ||
| 499 | /// Byte offset from the match to the signed 4-byte displacement field. | ||
| 500 | std::ptrdiff_t displacement_at{0}; | ||
| 501 | /// Total length of the referencing instruction (the next-IP base for the disp). | ||
| 502 | std::size_t instruction_length{0}; | ||
| 503 | }; | ||
| 504 | |||
| 505 | /** | ||
| 506 | * @struct RttiVtable | ||
| 507 | * @brief The RttiVtable-tier payload: the MSVC-mangled type name to resolve through the reverse-RTTI walk. Owned. | ||
| 508 | * @details Unique-only: an ambiguous name (two primaries) fails closed and the ladder falls through. | ||
| 509 | */ | ||
| 510 | struct RttiVtable | ||
| 511 | { | ||
| 512 | /// The MSVC decorated type name, e.g. ".?AVCameraManager@@". Owned. | ||
| 513 | std::string mangled; | ||
| 514 | }; | ||
| 515 | |||
| 516 | /** | ||
| 517 | * @struct StringXref | ||
| 518 | * @brief The StringXref-tier payload: an OWNED string literal plus the reference-resolution facets. | ||
| 519 | * @details The literal is held as an owned std::string (not the borrowed std::string_view of StringRefQuery) | ||
| 520 | * because a Candidate is stored and resolved later, long after the expression that built it; the resolver | ||
| 521 | * rebuilds a StringRefQuery view over this owned text at resolve time. Unique-only: a pooled literal or a | ||
| 522 | * second reference fails closed. | ||
| 523 | */ | ||
| 524 | struct StringXref | ||
| 525 | { | ||
| 526 | /// The exact string content to anchor on (no quotes). Owned. | ||
| 527 | std::string text; | ||
| 528 | /// How the literal is stored in the image. | ||
| 529 | StringEncoding encoding{StringEncoding::Utf8}; | ||
| 530 | /// Match a trailing NUL so a prefix of a longer literal is not matched. | ||
| 531 | bool require_terminator{true}; | ||
| 532 | /// Instruction site, enclosing function, or cached global pointer slot. | ||
| 533 | XrefReturn return_mode{XrefReturn::ReferencingInstruction}; | ||
| 534 | /// Keep the lea/mov shape scan and add the Zydis broad sweep for rarer reference shapes. | ||
| 535 | bool broad_match{false}; | ||
| 536 | }; | ||
| 537 | |||
| 538 | /** | ||
| 539 | * @class Candidate | ||
| 540 | * @brief One resilience tier in a resolution ladder: a strategy plus the signature it resolves, owning its strings. | ||
| 541 | * @details The payload is a std::variant over the four typed tiers, so the (mode, payload) pairing is coherent by | ||
| 542 | * construction. The four factories are the only way to build one, so a Candidate can only exist with a | ||
| 543 | * valid alternative. The candidate copies every owned string: the name, the RttiVtable mangled name, and | ||
| 544 | * the StringXref literal. A returned Hit or a stored ladder therefore never aliases caller storage. | ||
| 545 | */ | ||
| 546 | class Candidate | ||
| 547 | { | ||
| 548 | public: | ||
| 549 | /// The active variant payload type. The alternative order matches the Mode enumerator order. | ||
| 550 | using Payload = std::variant<DirectPattern, RipRelativePattern, RttiVtable, StringXref>; | ||
| 551 | |||
| 552 | /** | ||
| 553 | * @brief A Direct byte-scan candidate: the resolved address is at the match site plus a fixed walk-back. | ||
| 554 | * @note Setup/control-plane only: builds an owned Candidate (string + Pattern copy); assemble ladders at init. | ||
| 555 | */ | ||
| 556 | 335 | [[nodiscard]] static Candidate direct(std::string name, Pattern pattern, std::ptrdiff_t walk_back = 0) | |
| 557 | { | ||
| 558 | 670 | return Candidate{std::move(name), DirectPattern{std::move(pattern), walk_back}}; | |
| 559 | } | ||
| 560 | |||
| 561 | /** | ||
| 562 | * @brief A RIP-relative byte-scan candidate: the resolved address is read from a disp32 the match spans. | ||
| 563 | * @param displacement_at Byte offset from the match to the signed 4-byte displacement field; must be >= 0. | ||
| 564 | * @param instruction_length Total length of the referencing instruction; must be no more than 15 bytes and | ||
| 565 | * contain the disp32 field. | ||
| 566 | * @throws std::invalid_argument when the declared layout is invalid or the matched suffix does not span the | ||
| 567 | * complete disp32 field. | ||
| 568 | * @details Setup/control-plane only: it builds an owned Candidate. Assemble each ladder at init. The matched | ||
| 569 | * evidence must cover the disp32 it authorizes. The resolver computes | ||
| 570 | * match + instruction_length + disp from one immutable sweep snapshot, never from a post-sweep | ||
| 571 | * reread. | ||
| 572 | * @note The manifest loader validates the same bound through @ref is_valid_rip_relative_layout, so a bad | ||
| 573 | * manifest fails closed with an error value instead of a throw. | ||
| 574 | */ | ||
| 575 | [[nodiscard]] static Candidate | ||
| 576 | 36 | rip_relative(std::string name, Pattern pattern, std::ptrdiff_t displacement_at, std::size_t instruction_length) | |
| 577 | { | ||
| 578 | 71 | if (displacement_at < 0 || | |
| 579 |
6/6✓ Branch 2 → 3 taken 35 times.
✓ Branch 2 → 8 taken 1 time.
✓ Branch 4 → 5 taken 32 times.
✓ Branch 4 → 8 taken 3 times.
✓ Branch 10 → 11 taken 8 times.
✓ Branch 10 → 14 taken 28 times.
|
68 | !is_valid_rip_relative_layout(static_cast<std::size_t>(displacement_at), instruction_length) || |
| 580 | 32 | detail::min_match_suffix_length(detail::pattern_buffer(pattern)) < | |
| 581 |
2/2✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 9 taken 28 times.
|
32 | static_cast<std::size_t>(displacement_at) + sizeof(std::int32_t)) |
| 582 | { | ||
| 583 | throw std::invalid_argument( | ||
| 584 | "scan::Candidate::rip_relative: the matched suffix must span a valid x86-64 RIP disp32 " | ||
| 585 | "(0 <= displacement_at, displacement_at + 4 <= instruction_length <= 15, and the pattern's " | ||
| 586 | "shortest suffix from the result marker covers displacement_at + 4 bytes)" | ||
| 587 |
1/2✓ Branch 12 → 13 taken 8 times.
✗ Branch 12 → 25 not taken.
|
8 | ); |
| 588 | } | ||
| 589 | return Candidate{ | ||
| 590 | 28 | std::move(name), | |
| 591 | 56 | RipRelativePattern{std::move(pattern), displacement_at, instruction_length} | |
| 592 | 84 | }; | |
| 593 | } | ||
| 594 | |||
| 595 | /** | ||
| 596 | * @brief An RTTI-vtable candidate: resolves the primary vtable of an MSVC-mangled type name. | ||
| 597 | * @note Setup/control-plane only: copies the name and mangled query strings. | ||
| 598 | */ | ||
| 599 | 7 | [[nodiscard]] static Candidate rtti_vtable(std::string name, std::string mangled) | |
| 600 | { | ||
| 601 | 14 | return Candidate{std::move(name), RttiVtable{std::move(mangled)}}; | |
| 602 | } | ||
| 603 | |||
| 604 | /** | ||
| 605 | * @brief A string-xref candidate with default facets (UTF-8, referencing-instruction, terminator-required). | ||
| 606 | * @note Setup/control-plane only: copies the name and literal query strings. | ||
| 607 | */ | ||
| 608 | 10 | [[nodiscard]] static Candidate string_xref(std::string name, std::string literal) | |
| 609 | { | ||
| 610 |
1/2✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 10 times.
|
20 | return Candidate{std::move(name), StringXref{std::move(literal)}}; |
| 611 | } | ||
| 612 | |||
| 613 | /** | ||
| 614 | * @brief A string-xref candidate carrying explicit facets (encoding, return mode, terminator, broad match). | ||
| 615 | * @details The query's borrowed text is copied into the Candidate's owned StringXref payload, so the Candidate | ||
| 616 | * outlives the StringRefQuery and its backing storage. The remaining facets are taken verbatim. | ||
| 617 | * @note Setup/control-plane only: copies the name and the query literal. | ||
| 618 | */ | ||
| 619 | 11 | [[nodiscard]] static Candidate string_xref(std::string name, StringRefQuery query) | |
| 620 | { | ||
| 621 | return Candidate{ | ||
| 622 | 11 | std::move(name), | |
| 623 |
2/4✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 18 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 11 times.
|
22 | StringXref{ |
| 624 | ✗ | std::string{query.text}, | |
| 625 | 11 | query.encoding, | |
| 626 | 11 | query.require_terminator, | |
| 627 | 11 | query.return_mode, | |
| 628 | 11 | query.broad_match | |
| 629 | } | ||
| 630 | 33 | }; | |
| 631 | } | ||
| 632 | |||
| 633 | /// Human-readable label; carried verbatim into the winning Hit. | ||
| 634 | 348 | [[nodiscard]] const std::string &name() const noexcept { return m_name; } | |
| 635 | |||
| 636 | /// The resolution strategy this tier uses (a cast of the active variant alternative index). | ||
| 637 | 636 | [[nodiscard]] Mode mode() const noexcept { return static_cast<Mode>(m_payload.index()); } | |
| 638 | |||
| 639 | /// The full variant payload, for the resolver's std::visit dispatch. | ||
| 640 | [[nodiscard]] const Payload &payload() const noexcept { return m_payload; } | ||
| 641 | |||
| 642 | /// Returns the Direct payload, or nullptr when this is not a Direct candidate. | ||
| 643 | 1402 | [[nodiscard]] const DirectPattern *as_direct() const noexcept { return std::get_if<DirectPattern>(&m_payload); } | |
| 644 | |||
| 645 | /// Returns the RipRelative payload, or nullptr when this is not a RipRelative candidate. | ||
| 646 | 450 | [[nodiscard]] const RipRelativePattern *as_rip_relative() const noexcept | |
| 647 | { | ||
| 648 | 450 | return std::get_if<RipRelativePattern>(&m_payload); | |
| 649 | } | ||
| 650 | |||
| 651 | /// Returns the RttiVtable payload, or nullptr when this is not an RTTI-vtable candidate. | ||
| 652 | 806 | [[nodiscard]] const RttiVtable *as_rtti_vtable() const noexcept { return std::get_if<RttiVtable>(&m_payload); } | |
| 653 | |||
| 654 | /// Returns the StringXref payload, or nullptr when this is not a string-xref candidate. | ||
| 655 | 1226 | [[nodiscard]] const StringXref *as_string_xref() const noexcept { return std::get_if<StringXref>(&m_payload); } | |
| 656 | |||
| 657 | private: | ||
| 658 | // Private so the four validating factories are the only construction path; a stray `Candidate{...}` does not | ||
| 659 | // compile, and the (name, payload) coherence is established at the factory. | ||
| 660 | 1173 | Candidate(std::string name, Payload payload) : m_name{std::move(name)}, m_payload{std::move(payload)} {} | |
| 661 | |||
| 662 | std::string m_name; | ||
| 663 | Payload m_payload; | ||
| 664 | }; | ||
| 665 | |||
| 666 | // The resolver derives Mode from the active variant index, so the alternative order MUST track the Mode order. Pin | ||
| 667 | // it here: a future reorder of either list that breaks the mapping fails the build rather than silently misrouting | ||
| 668 | // a candidate to the wrong backend. | ||
| 669 | static_assert(std::is_same_v< | ||
| 670 | std::variant_alternative_t<static_cast<std::size_t>(Mode::Direct), Candidate::Payload>, | ||
| 671 | DirectPattern>); | ||
| 672 | static_assert(std::is_same_v< | ||
| 673 | std::variant_alternative_t<static_cast<std::size_t>(Mode::RipRelative), Candidate::Payload>, | ||
| 674 | RipRelativePattern>); | ||
| 675 | static_assert(std::is_same_v< | ||
| 676 | std::variant_alternative_t<static_cast<std::size_t>(Mode::RttiVtable), Candidate::Payload>, | ||
| 677 | RttiVtable>); | ||
| 678 | static_assert(std::is_same_v< | ||
| 679 | std::variant_alternative_t<static_cast<std::size_t>(Mode::StringXref), Candidate::Payload>, | ||
| 680 | StringXref>); | ||
| 681 | |||
| 682 | /** | ||
| 683 | * @struct CodeConstant | ||
| 684 | * @brief Declares a constant encoded in the engine's machine code so DMK can re-derive it after a patch. | ||
| 685 | * @details The code-side twin of the RTTI self-heal: where a struct stride or field displacement is an immediate or | ||
| 686 | * `[reg + disp]` in a dispatch loop, declare the candidate ladder that lands ON the instruction plus which | ||
| 687 | * operand to read, and read_code_constant() decodes the live instruction and returns the current value, so | ||
| 688 | * a consumer stops hand-reading the immediate every patch. | ||
| 689 | */ | ||
| 690 | struct CodeConstant | ||
| 691 | { | ||
| 692 | /// Candidate ladder that resolves to an execute-readable instruction site. Borrowed. | ||
| 693 | std::span<const Candidate> site; | ||
| 694 | /// Which operand field to read: an immediate or a memory displacement. | ||
| 695 | OperandKind kind = OperandKind::Immediate; | ||
| 696 | /// Index into the instruction's VISIBLE operands, as counted in a disassembler. | ||
| 697 | std::uint8_t operand_index = 0; | ||
| 698 | /// 0 preserves the decoded value; 1 through 8 narrows a non-RIP constant to low bytes and sign-extends. | ||
| 699 | std::uint8_t byte_width = 0; | ||
| 700 | /// Last-known value, for telemetry/baseline ONLY; never returned in place of a live decode. | ||
| 701 | std::int64_t nominal = 0; | ||
| 702 | /// Set true to make @ref nominal meaningful (do not overload nominal == 0 as "unset"). | ||
| 703 | bool has_nominal = false; | ||
| 704 | }; | ||
| 705 | |||
| 706 | /** | ||
| 707 | * @brief Resolves @p code_constant.site, decodes the instruction there, and returns the requested operand's value. | ||
| 708 | * @param code_constant The code-constant declaration. | ||
| 709 | * @param scope Module image to resolve the site in; defaults to the host executable. | ||
| 710 | * @return The decoded value (sign-extended), or an Error. | ||
| 711 | * @details Always decodes and returns the LIVE operand; @c nominal is never a short-circuit, so a same-shape | ||
| 712 | * different-value drift (e.g. a stride 232 -> 240) is reported as the new value, which is the point. | ||
| 713 | * Fail-closed: a candidate whose final site is not execute-readable is skipped so a later ladder rung can | ||
| 714 | * resolve; if the selected site loses executable protection before decoding, or its decoded instruction | ||
| 715 | * crosses into a non-executable page, it returns DecodeFailed. A site that no longer decodes | ||
| 716 | * (DecodeFailed), whose operand is the wrong kind (UnexpectedShape), or whose operand index is out of | ||
| 717 | * range (OperandOutOfRange) also returns a typed error rather than a guess. An out-of-range | ||
| 718 | * @ref CodeConstant::kind or @ref CodeConstant::byte_width returns @ref ErrorCode::InvalidArg before site | ||
| 719 | * resolution. A RIP-relative memory operand is resolved to its absolute target without narrowing. | ||
| 720 | * The value decodes from a fresh snapshot after site resolution. | ||
| 721 | * A byte rung must still match its physical span and resolve the decoded site at that epoch (`[B-75]`). | ||
| 722 | * Otherwise, the function returns @ref ErrorCode::EvidenceMismatch. | ||
| 723 | * A wildcarded operand byte at the selected site may drift. The function returns its current value. | ||
| 724 | * @note Not noexcept: resolving the site allocates. Setup/control-plane only. | ||
| 725 | */ | ||
| 726 | [[nodiscard]] Result<std::int64_t> | ||
| 727 | read_code_constant(const CodeConstant &code_constant, Region scope = Region::host()); | ||
| 728 | |||
| 729 | /** | ||
| 730 | * @brief Ceiling on the winning-span bytes a @ref WinningEvidence can carry. | ||
| 731 | * @details A mutation baseline is compared byte for byte, so it must hold the WHOLE winning span or it would | ||
| 732 | * authorize a write on partial evidence. Evidence longer than this is reported truncated: still valid for | ||
| 733 | * read-only resolution, never usable to authorize a mutation or to seed a strict baseline. | ||
| 734 | */ | ||
| 735 | inline constexpr std::size_t MAX_MUTATION_WITNESS_BYTES = 256; | ||
| 736 | |||
| 737 | /** | ||
| 738 | * @struct WinningEvidence | ||
| 739 | * @brief The literal bytes present at the winning match span, captured during the match that produced them. | ||
| 740 | * @details Content evidence, as opposed to the layout evidence in @ref ImageIdentity: it records what the target | ||
| 741 | * actually contained, including the concrete values that matched wildcard positions and the bytes a | ||
| 742 | * variable-length gap skipped over. That is what lets a caller distinguish a same-layout image whose code | ||
| 743 | * was changed under it from one that is genuinely unchanged, which no PE-header identity can do. | ||
| 744 | * | ||
| 745 | * Captured from the same traversal that matched, never a re-read, so it witnesses the span the resolver | ||
| 746 | * actually accepted rather than whatever occupies that address later. | ||
| 747 | */ | ||
| 748 | struct WinningEvidence | ||
| 749 | { | ||
| 750 | /// The captured span, valid over the first @ref length elements; trailing elements are zero. | ||
| 751 | std::array<std::byte, MAX_MUTATION_WITNESS_BYTES> bytes{}; | ||
| 752 | /// How many leading elements of @ref bytes are meaningful; 0 when nothing was captured. | ||
| 753 | std::uint16_t length = 0; | ||
| 754 | /** | ||
| 755 | * @brief True when the winning span exceeded @ref MAX_MUTATION_WITNESS_BYTES and was not captured. | ||
| 756 | * @details Set with @ref length 0: a partial prefix would compare equal against a prefix baseline and silently | ||
| 757 | * weaken the gate, so an over-long span carries no evidence at all rather than misleading evidence. | ||
| 758 | */ | ||
| 759 | bool truncated = false; | ||
| 760 | |||
| 761 | /// True when a complete, internally valid winning span was captured. | ||
| 762 | 263 | [[nodiscard]] constexpr bool present() const noexcept | |
| 763 | { | ||
| 764 |
6/6✓ Branch 2 → 3 taken 62 times.
✓ Branch 2 → 6 taken 201 times.
✓ Branch 3 → 4 taken 60 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 4 → 5 taken 58 times.
✓ Branch 4 → 6 taken 2 times.
|
263 | return length != 0 && length <= MAX_MUTATION_WITNESS_BYTES && !truncated; |
| 765 | } | ||
| 766 | |||
| 767 | /// The captured bytes as a span; empty unless @ref present. | ||
| 768 | 6 | [[nodiscard]] constexpr std::span<const std::byte> span() const noexcept | |
| 769 | { | ||
| 770 |
2/2✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 4 times.
|
6 | if (!present()) |
| 771 | { | ||
| 772 | 2 | return {}; | |
| 773 | } | ||
| 774 | 4 | return std::span<const std::byte>{bytes.data(), length}; | |
| 775 | } | ||
| 776 | |||
| 777 | /** | ||
| 778 | * @brief Value equality over the captured prefix and the truncation flag. | ||
| 779 | * @details Fails closed on a malformed value rather than walking it: @ref length is public and is not | ||
| 780 | * clamped on assignment, so a hand-built evidence whose length exceeds | ||
| 781 | * @ref MAX_MUTATION_WITNESS_BYTES, or which claims bytes while @ref truncated, would otherwise read | ||
| 782 | * past @ref bytes. Such a value compares equal to nothing, including a copy of itself, so it can | ||
| 783 | * never satisfy a baseline comparison. | ||
| 784 | */ | ||
| 785 | 13 | [[nodiscard]] constexpr bool operator==(const WinningEvidence &other) const noexcept | |
| 786 | { | ||
| 787 |
4/6✓ Branch 2 → 3 taken 13 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 12 times.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 6 not taken.
|
13 | const bool malformed = length > MAX_MUTATION_WITNESS_BYTES || (truncated && length != 0); |
| 788 | 13 | const bool other_malformed = | |
| 789 |
4/6✓ Branch 7 → 8 taken 13 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 11 taken 12 times.
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 11 not taken.
|
13 | other.length > MAX_MUTATION_WITNESS_BYTES || (other.truncated && other.length != 0); |
| 790 |
3/4✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 14 taken 1 time.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 12 times.
|
13 | if (malformed || other_malformed) |
| 791 | { | ||
| 792 | 1 | return false; | |
| 793 | } | ||
| 794 |
3/4✓ Branch 15 → 16 taken 11 times.
✓ Branch 15 → 17 taken 1 time.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 11 times.
|
12 | if (length != other.length || truncated != other.truncated) |
| 795 | { | ||
| 796 | 1 | return false; | |
| 797 | } | ||
| 798 |
2/2✓ Branch 24 → 19 taken 1030 times.
✓ Branch 24 → 25 taken 9 times.
|
1039 | for (std::size_t i = 0; i < length; ++i) |
| 799 | { | ||
| 800 |
2/2✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 1028 times.
|
1030 | if (bytes[i] != other.bytes[i]) |
| 801 | { | ||
| 802 | 2 | return false; | |
| 803 | } | ||
| 804 | } | ||
| 805 | 9 | return true; | |
| 806 | } | ||
| 807 | }; | ||
| 808 | |||
| 809 | /** | ||
| 810 | * @struct Hit | ||
| 811 | * @brief A resolved address paired with the owning name and mode of the candidate that produced it. | ||
| 812 | * @details @ref winning_name remains valid for the lifetime of the Hit. | ||
| 813 | */ | ||
| 814 | struct Hit | ||
| 815 | { | ||
| 816 | /// The resolved absolute address. | ||
| 817 | Address address; | ||
| 818 | /// A copy of the winning candidate's name. | ||
| 819 | std::string winning_name; | ||
| 820 | /// The resolution mode of the winning candidate. | ||
| 821 | Mode winning_mode = Mode::Direct; | ||
| 822 | /** | ||
| 823 | * @brief The literal bytes at the span this candidate matched; absent for a backend that matches no span. | ||
| 824 | * @details Only a byte-pattern tier witnesses a span. An RTTI, export, or string-xref rung resolves through a | ||
| 825 | * structure rather than a literal run, so it leaves this absent and cannot seed a mutation baseline. | ||
| 826 | */ | ||
| 827 | WinningEvidence evidence{}; | ||
| 828 | }; | ||
| 829 | |||
| 830 | /** | ||
| 831 | * @enum FallbackPolicy | ||
| 832 | * @brief How strictly hooked-prologue recovery confirms the identity of a recovered target. | ||
| 833 | * @details `[B-53]` Hooked-prologue recovery rebuilds a Direct candidate's prologue as an inline-hook jump shape | ||
| 834 | * and resolves the single site that uniquely matches. That structural gate is strong but address-blind: a | ||
| 835 | * game reshape can leave an inline-hooked near-twin with a coincidental match. Pair a | ||
| 836 | * @ref FallbackWitness with RequireIdentity to fail closed on an unconfirmed site. | ||
| 837 | */ | ||
| 838 | enum class FallbackPolicy : std::uint8_t | ||
| 839 | { | ||
| 840 | /// Recovery disabled: a full direct miss stays a miss. | ||
| 841 | Off, | ||
| 842 | /// Recover structurally; log a rejecting @ref FallbackWitness but still return the address. | ||
| 843 | WarnOnly, | ||
| 844 | /// Recover structurally, then require a @ref FallbackWitness to confirm the recovered address. | ||
| 845 | RequireIdentity, | ||
| 846 | }; | ||
| 847 | |||
| 848 | /** | ||
| 849 | * @brief A post-recovery identity check for hooked-prologue recovery. | ||
| 850 | * @details Signature-compatible with @ref anchor::AnchorValidator. The recovered absolute address is passed as | ||
| 851 | * @p value. Return false to reject it as a coincidental near-twin. | ||
| 852 | * @param value The recovered absolute address, as a signed integer (a hook target is a code address). | ||
| 853 | * @param context The opaque @ref FallbackWitness::context pointer, forwarded verbatim (nullptr if unused). | ||
| 854 | */ | ||
| 855 | using FallbackValidator = bool (*)(std::int64_t value, const void *context) noexcept; | ||
| 856 | |||
| 857 | /** | ||
| 858 | * @struct FallbackWitness | ||
| 859 | * @brief The witness a @ref FallbackPolicy runs against a recovered prologue-fallback target. | ||
| 860 | * @details A null @ref predicate means "no witness": WarnOnly then behaves as a plain structural recovery, and | ||
| 861 | * RequireIdentity fails closed (it has nothing to confirm the site with). A typical witness corroborates | ||
| 862 | * the recovered address against an independently resolved landmark, or reads a distinguishing byte past | ||
| 863 | * the overwritten prologue. | ||
| 864 | */ | ||
| 865 | struct FallbackWitness | ||
| 866 | { | ||
| 867 | /// Predicate run on the recovered address; nullptr for no identity check. | ||
| 868 | FallbackValidator predicate = nullptr; | ||
| 869 | /// Opaque pointer forwarded verbatim to @ref predicate. | ||
| 870 | const void *context = nullptr; | ||
| 871 | }; | ||
| 872 | |||
| 873 | /** | ||
| 874 | * @struct ScanRequest | ||
| 875 | * @brief A non-owning resolution request: a candidate ladder plus the scope and policy to resolve it under. | ||
| 876 | * @details ladder, label, and exclusions are NON-owning views, so a ScanRequest is for a request built and consumed | ||
| 877 | * in one expression (a temporary handed straight to resolve() outlives the call). To store or pass a | ||
| 878 | * request around, use OwnedScanRequest, or build a borrowed one through borrow() so the lifetime-bound | ||
| 879 | * diagnostic can ride its parameters (the attribute cannot annotate a data member). | ||
| 880 | */ | ||
| 881 | struct ScanRequest | ||
| 882 | { | ||
| 883 | /// Candidates tried in order (after applying @ref order); the first that resolves uniquely wins. | ||
| 884 | std::span<const Candidate> ladder; | ||
| 885 | /// Optional label for diagnostics; non-owning. | ||
| 886 | std::string_view label{}; | ||
| 887 | /// The memory range to resolve within; defaults to the host process image. | ||
| 888 | Region scope = Region::host(); | ||
| 889 | /// Hooked-prologue recovery mode for a full direct miss (see @ref FallbackPolicy). | ||
| 890 | FallbackPolicy fallback_policy = FallbackPolicy::Off; | ||
| 891 | /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off. | ||
| 892 | FallbackWitness fallback_witness{}; | ||
| 893 | /// Fail closed on an ambiguous byte match (a second occurrence in scope) rather than taking the first. | ||
| 894 | bool require_unique = true; | ||
| 895 | /// How the ladder is ordered before it is tried. | ||
| 896 | CandidateOrder order = CandidateOrder::AsDeclared; | ||
| 897 | /// Page-protection class the Direct / RipRelative byte tiers scan. | ||
| 898 | Pages pages = Pages::Readable; | ||
| 899 | /** | ||
| 900 | * @brief Rejects a candidate whose final resolved address is not on a committed execute-readable page. | ||
| 901 | * @details Applies after each byte, RTTI, string-xref, or prologue-recovery backend resolves its final address. | ||
| 902 | * Use it for a hook target that must be executable even when a byte candidate matches code then | ||
| 903 | * transforms its match into a data address. Defaults false because a RipGlobal may intentionally | ||
| 904 | * resolve a data global from an executable instruction reference. | ||
| 905 | */ | ||
| 906 | bool require_executable_result = false; | ||
| 907 | /** | ||
| 908 | * @brief Caller-owned copies of the ladder's query bytes a match may not come from. | ||
| 909 | * @details Only needed for a @ref Pages::Readable scope confined to neither one mapped image nor one reserved | ||
| 910 | * allocation, where a match could otherwise be the query finding its own storage; such a scope | ||
| 911 | * resolves to @ref ErrorCode::NotAuthoritative while this is empty. DMK's own representations (the | ||
| 912 | * ladder, its Patterns, and the compiled forms) are excluded regardless. Non-owning, like @ref ladder. | ||
| 913 | */ | ||
| 914 | std::span<const Region> exclusions{}; | ||
| 915 | }; | ||
| 916 | |||
| 917 | /** | ||
| 918 | * @brief Builds a borrowed ScanRequest whose lifetime-bound diagnostic rides its view parameters. | ||
| 919 | * @details DMK_LIFETIMEBOUND on the borrowed parameters lets Clang/MSVC warn when a temporary ladder/label is | ||
| 920 | * passed. MinGW GCC has no such attribute, so the build there relies on the owning/borrowed split plus | ||
| 921 | * `-Wdangling-reference`. For a stored or deferred request, prefer OwnedScanRequest. | ||
| 922 | * @note Callback-safe with an explicit Region: packs the borrowed views into a ScanRequest; noexcept, no | ||
| 923 | * allocation. The default scope query is setup/control-plane only. | ||
| 924 | */ | ||
| 925 | [[nodiscard]] ScanRequest borrow( | ||
| 926 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 927 | std::string_view label DMK_LIFETIMEBOUND = {}, | ||
| 928 | Region scope = Region::host(), | ||
| 929 | FallbackPolicy fallback_policy = FallbackPolicy::Off, | ||
| 930 | FallbackWitness fallback_witness = {}, | ||
| 931 | bool require_unique = true, | ||
| 932 | CandidateOrder order = CandidateOrder::AsDeclared, | ||
| 933 | Pages pages = Pages::Readable | ||
| 934 | ) noexcept; | ||
| 935 | |||
| 936 | /** | ||
| 937 | * @brief Builds a borrowed ScanRequest preset for resolving a CODE (hook) target. | ||
| 938 | * @param ladder Candidates tried in order; borrowed for the call. | ||
| 939 | * @param label Optional diagnostic label; borrowed. | ||
| 940 | * @param scope Module image to resolve within; defaults to the host process image. | ||
| 941 | * @param fallback_policy Hooked-prologue recovery strictness (see @ref FallbackPolicy). Defaults to WarnOnly: | ||
| 942 | * recover a target another mod inline-hooked, structurally. Pass RequireIdentity with @p fallback_witness to | ||
| 943 | * fail closed on a recovered site the witness cannot confirm. | ||
| 944 | * @param fallback_witness The identity witness the fallback runs on a recovered site (see @ref FallbackWitness). | ||
| 945 | * @return A ScanRequest carrying the code-target resolution policy. | ||
| 946 | * @details A hook target must land on an instruction, so this preset differs from the default data-capable | ||
| 947 | * request in four ways: `Pages::Executable`, `require_executable_result`, | ||
| 948 | * @ref CandidateOrder::UniqueFirst, and an enabled @p fallback_policy. `require_unique` stays true. | ||
| 949 | * `Pages::Executable` narrows the Direct and RipRelative byte scans only. The final-result gate also | ||
| 950 | * rejects a byte tier that resolves code bytes to a data address, and any RTTI or string-xref result | ||
| 951 | * that is not executable. For a data, RTTI, or string target, use the default ScanRequest or | ||
| 952 | * @ref borrow. | ||
| 953 | * @note Callback-safe with an explicit Region: packs the borrowed views into a ScanRequest; noexcept, no | ||
| 954 | * allocation. The default scope query is setup/control-plane only. For a stored or deferred request, copy | ||
| 955 | * the same fields onto an OwnedScanRequest so the ladder is owned. | ||
| 956 | */ | ||
| 957 | [[nodiscard]] ScanRequest borrow_code_target( | ||
| 958 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 959 | std::string_view label DMK_LIFETIMEBOUND = {}, | ||
| 960 | Region scope = Region::host(), | ||
| 961 | FallbackPolicy fallback_policy = FallbackPolicy::WarnOnly, | ||
| 962 | FallbackWitness fallback_witness = {} | ||
| 963 | ) noexcept; | ||
| 964 | |||
| 965 | /** | ||
| 966 | * @brief Builds a borrowed ScanRequest preset for a CODE (hook) target that fails closed on unconfirmed recovery. | ||
| 967 | * @param ladder Candidates tried in order; borrowed for the call. | ||
| 968 | * @param label Optional diagnostic label; borrowed. Required positionally because the witness that follows has no | ||
| 969 | * default. Pass {} for none. | ||
| 970 | * @param fallback_witness The identity witness a recovered hooked-prologue site must satisfy. It has no default. | ||
| 971 | * @param scope Module image to resolve within; defaults to the host process image. | ||
| 972 | * @return A ScanRequest carrying the code-target policy under @ref FallbackPolicy::RequireIdentity. | ||
| 973 | * @details The strict counterpart to @ref borrow_code_target. Every field is identical except the fallback | ||
| 974 | * strictness. Recovery runs under @ref FallbackPolicy::RequireIdentity, so a Direct candidate recovered | ||
| 975 | * from an already inline-hooked target resolves only when @p fallback_witness confirms it. A | ||
| 976 | * coincidental near-twin fails closed instead. The witness has no default because RequireIdentity | ||
| 977 | * without a witness fails closed on every recovery, which is a silent always-miss. | ||
| 978 | * @note Callback-safe with an explicit Region: packs the borrowed views into a ScanRequest; noexcept, no | ||
| 979 | * allocation. The default scope query is setup/control-plane only. | ||
| 980 | */ | ||
| 981 | [[nodiscard]] ScanRequest borrow_code_target_strict( | ||
| 982 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 983 | std::string_view label DMK_LIFETIMEBOUND, | ||
| 984 | FallbackWitness fallback_witness, | ||
| 985 | Region scope = Region::host() | ||
| 986 | ) noexcept; | ||
| 987 | |||
| 988 | /** | ||
| 989 | * @struct OwnedScanRequest | ||
| 990 | * @brief An owning resolution request for stored or deferred resolution. | ||
| 991 | * @details Owns its ladder, label, and exclusions, so it is the safe shape to keep inside a registration or any | ||
| 992 | * structure that outlives the expression that built it. The structural guarantee that stored entry points | ||
| 993 | * take OwnedScanRequest (never a borrowed ScanRequest) is the primary defense against dangling views; | ||
| 994 | * @ref view rebuilds a borrowed ScanRequest over this object's storage on demand. | ||
| 995 | */ | ||
| 996 | struct OwnedScanRequest | ||
| 997 | { | ||
| 998 | /// Owned candidate ladder. | ||
| 999 | std::vector<Candidate> ladder; | ||
| 1000 | /// Owned diagnostic label. | ||
| 1001 | std::string label; | ||
| 1002 | /// The resolution scope; defaults to the host image. | ||
| 1003 | Region scope = Region::host(); | ||
| 1004 | /// Hooked-prologue recovery strictness on a full direct miss (see @ref FallbackPolicy). | ||
| 1005 | FallbackPolicy fallback_policy = FallbackPolicy::Off; | ||
| 1006 | /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off. | ||
| 1007 | FallbackWitness fallback_witness{}; | ||
| 1008 | /// Fail closed on an ambiguous byte match. | ||
| 1009 | bool require_unique = true; | ||
| 1010 | /// Ladder ordering policy. | ||
| 1011 | CandidateOrder order = CandidateOrder::AsDeclared; | ||
| 1012 | /// Page-protection class the byte tiers scan (see @ref ScanRequest::pages). | ||
| 1013 | Pages pages = Pages::Readable; | ||
| 1014 | /// Whether the final resolved address must be execute-readable. | ||
| 1015 | bool require_executable_result = false; | ||
| 1016 | /// Owned copies of the caller-declared query exclusions (see @ref ScanRequest::exclusions). | ||
| 1017 | std::vector<Region> exclusions; | ||
| 1018 | |||
| 1019 | /** | ||
| 1020 | * @brief Returns a borrowed ScanRequest viewing this object's owned storage. | ||
| 1021 | * @return A ScanRequest whose ladder/label/exclusions alias *this; valid only while this object lives. | ||
| 1022 | */ | ||
| 1023 | 12 | [[nodiscard]] ScanRequest view() const noexcept DMK_LIFETIMEBOUND | |
| 1024 | { | ||
| 1025 | return ScanRequest{ | ||
| 1026 | 12 | .ladder = ladder, | |
| 1027 | 12 | .label = label, | |
| 1028 | .scope = scope, | ||
| 1029 | 12 | .fallback_policy = fallback_policy, | |
| 1030 | .fallback_witness = fallback_witness, | ||
| 1031 | 12 | .require_unique = require_unique, | |
| 1032 | 12 | .order = order, | |
| 1033 | 12 | .pages = pages, | |
| 1034 | 12 | .require_executable_result = require_executable_result, | |
| 1035 | 12 | .exclusions = exclusions, | |
| 1036 | 24 | }; | |
| 1037 | } | ||
| 1038 | }; | ||
| 1039 | |||
| 1040 | /** | ||
| 1041 | * @brief Writes the index permutation @p order implies for @p ladder into @p out. | ||
| 1042 | * @param order The ordering policy. | ||
| 1043 | * @param ladder The candidate ladder to order. | ||
| 1044 | * @param out Destination for the permutation; receives up to min(ladder.size(), out.size()) indices. | ||
| 1045 | * @return The number of indices written. | ||
| 1046 | * @details Pure index math, no allocation. UniqueFirst is a stable three-pass partition (unique-only text tiers, | ||
| 1047 | * then anchored byte patterns, then the rest), declared order preserved within each group. Every other | ||
| 1048 | * value, including an out-of-range one, yields the identity permutation, so a mis-declared order can never | ||
| 1049 | * select the UniqueFirst promotion; the fallible @ref resolve boundary rejects it with | ||
| 1050 | * @ref ErrorCode::InvalidArg. | ||
| 1051 | * @note Callback-safe: pure index math, noexcept, no allocation. | ||
| 1052 | */ | ||
| 1053 | [[nodiscard]] std::size_t | ||
| 1054 | order_candidates(CandidateOrder order, std::span<const Candidate> ladder, std::span<std::size_t> out) noexcept; | ||
| 1055 | |||
| 1056 | /** | ||
| 1057 | * @brief Resolves a candidate ladder to a single address, trying each tier until one resolves uniquely. | ||
| 1058 | * @param request The ladder, scope, and policy to resolve. | ||
| 1059 | * @return The resolved Hit, or an Error describing why no candidate resolved. | ||
| 1060 | * @details The whole resolver surface in one call. Candidates are tried in @ref ScanRequest::order order; the first | ||
| 1061 | * that (for a byte tier) matches in scope, passes the uniqueness gate when required, and resolves to an | ||
| 1062 | * in-scope plausible address, or (for a text tier) resolves through its unique-only backend, wins. When | ||
| 1063 | * @ref ScanRequest::require_executable_result is true, every final address must also be execute-readable. | ||
| 1064 | * On a full direct miss with a non-Off fallback_policy, each Direct candidate's prologue is rebuilt as a | ||
| 1065 | * near/far JMP and retried to recover a target another mod already inline-hooked, subject to the policy's | ||
| 1066 | * identity witness. A byte tier whose own sweep was truncated (@ref ErrorCode::BudgetExceeded or | ||
| 1067 | * @ref ErrorCode::IncompleteScan) preempts that recovery, because "the direct candidates fully missed" is | ||
| 1068 | * the premise recovery rests on and a partly-read scope does not establish it; a recovery sweep that | ||
| 1069 | * itself skips a faulted region reports @ref ErrorCode::IncompleteScan rather than a miss. A text tier's | ||
| 1070 | * failure (@ref ErrorCode::MalformedQueryText, or @ref ErrorCode::NotAuthoritative or | ||
| 1071 | * @ref ErrorCode::IncompleteScan from its own readable sweep) does not preempt recovery, but is reported | ||
| 1072 | * in place of the generic miss when nothing resolves. An unconfined Pages::Readable scope that declares | ||
| 1073 | * no @ref ScanRequest::exclusions refuses the whole request with @ref ErrorCode::NotAuthoritative before | ||
| 1074 | * any candidate is graded. An out-of-range @ref ScanRequest::pages, @ref ScanRequest::order, | ||
| 1075 | * @ref ScanRequest::fallback_policy, or StringXref candidate encoding/return mode fails closed with | ||
| 1076 | * @ref ErrorCode::InvalidArg rather than selecting a permissive default. May allocate, so it is NOT | ||
| 1077 | * noexcept; the only throwing path is allocation failure. | ||
| 1078 | * @note Setup/control-plane only: a cascade resolve walks the image and is a startup-time operation. | ||
| 1079 | */ | ||
| 1080 | [[nodiscard]] Result<Hit> resolve(const ScanRequest &request); | ||
| 1081 | |||
| 1082 | /** | ||
| 1083 | * @brief Resolves a batch of requests concurrently, returning one Result per request in input order. | ||
| 1084 | * @param requests The requests to resolve. | ||
| 1085 | * @param max_workers Upper bound on worker threads (0 = auto-select from hardware concurrency). | ||
| 1086 | * @return On success, the inner vector holds one @ref Hit-or-Error per input request, in order. On a WHOLE-BATCH | ||
| 1087 | * failure (the per-request result container itself could not be allocated under true out-of-memory) the | ||
| 1088 | * OUTER Result carries Error{OutOfMemory} and there is no inner vector. | ||
| 1089 | * @details noexcept by contract, and the two failure layers are distinct so no failure is ever silent. A | ||
| 1090 | * PER-REQUEST allocation failure is reported as that slot's Error{OutOfMemory}, and any other per-request | ||
| 1091 | * exception leaves that slot at the seeded Error{NoMatch}, so one failing request never sinks the batch. | ||
| 1092 | * A WHOLE-BATCH allocation failure, when even the seeded result vector cannot be built, is reported on | ||
| 1093 | * the outer Result instead of an easily-ignored empty vector, so a caller must unwrap the outer Result | ||
| 1094 | * before indexing and cannot silently proceed on a truncated batch. This mirrors @ref hook::install_all, | ||
| 1095 | * whose outer Result is likewise the whole-batch signal. | ||
| 1096 | * @note Setup/control-plane only: spawns a worker pool and allocates; a startup-time batch, not a per-frame call. | ||
| 1097 | */ | ||
| 1098 | [[nodiscard]] Result<std::vector<Result<Hit>>> | ||
| 1099 | resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers = 0) noexcept; | ||
| 1100 | |||
| 1101 | /** | ||
| 1102 | * @brief Scans one Pattern over a known scope and returns the Nth match address. | ||
| 1103 | * @param pattern The compiled signature. | ||
| 1104 | * @param scope The memory range to search. | ||
| 1105 | * @param occurrence Which match to return (1-based). 1 = first match. 0 yields NoMatch. | ||
| 1106 | * @param pages Which page-protection class to accept (Readable superset by default, or Executable code-only). | ||
| 1107 | * @return The address of the Nth match (adjusted by the Pattern's `|` offset), or an Error. | ||
| 1108 | * @details Page-gated. The sweep walks @p scope through the OS page map and reads only committed pages of the | ||
| 1109 | * requested class under a fault guard, so an unmapped or guard page inside the scope is skipped instead | ||
| 1110 | * of a host fault. A match that straddles two adjacent accepted regions is still found. For the raw | ||
| 1111 | * primitive where the caller guarantees readability, use @ref unchecked::find_pattern. | ||
| 1112 | * | ||
| 1113 | * A miss is typed, because "not found" and "not searched" are different answers. | ||
| 1114 | * @ref ErrorCode::NoMatch means the sweep traversed the whole scope and the pattern is absent. | ||
| 1115 | * @ref ErrorCode::IncompleteScan means a region faulted mid-scan and was skipped, so the pattern can | ||
| 1116 | * live in bytes that the sweep never read. @ref ErrorCode::BudgetExceeded means a bounded-jump pattern | ||
| 1117 | * spent its backtracking budget before the traversal was exhaustive. Neither truncation is a miss. An | ||
| 1118 | * out-of-range @p pages value returns @ref ErrorCode::InvalidArg before the sweep starts. | ||
| 1119 | * | ||
| 1120 | * A @ref Pages::Readable scan returns @ref ErrorCode::NotAuthoritative when its scope is confined to | ||
| 1121 | * neither one mapped image nor one reserved allocation and declares no exclusions. Such a scope covers | ||
| 1122 | * the caller's own copies of the pattern bytes, which DMK cannot enumerate, so a match can be the query | ||
| 1123 | * that finds itself. Confine the scope, scan @ref Pages::Executable, or use the exclusion-taking | ||
| 1124 | * overload. DMK always excludes its own query representations, on every scope. | ||
| 1125 | * @note Setup/control-plane only: walks the scope through the OS page map; a startup-time scan, not a per-frame | ||
| 1126 | * call. noexcept; an allocation failure while preparing the scan surfaces as Error{OutOfMemory}. | ||
| 1127 | */ | ||
| 1128 | [[nodiscard]] Result<Address> | ||
| 1129 | scan(const Pattern &pattern, Region scope, std::size_t occurrence = 1, Pages pages = Pages::Readable) noexcept; | ||
| 1130 | |||
| 1131 | /** | ||
| 1132 | * @brief Scans one Pattern over a known scope while excluding caller-owned copies of the query bytes. | ||
| 1133 | * @param pattern The compiled signature. | ||
| 1134 | * @param scope The memory range to search. | ||
| 1135 | * @param exclusions Spans holding the caller's own copies of the query material; a match intersecting one is not | ||
| 1136 | * counted. Passing a non-empty span is what makes an otherwise unprovable readable scope authoritative, so | ||
| 1137 | * it must actually name every live copy the caller holds. | ||
| 1138 | * @param occurrence Which match to return (1-based). 1 = first match. 0 yields NoMatch. | ||
| 1139 | * @param pages Which page-protection class to accept. | ||
| 1140 | * @return The address of the Nth non-excluded match, or an Error. | ||
| 1141 | * @details Identical to the four-argument overload except that @p exclusions is added to the set DMK already | ||
| 1142 | * excludes for its own query storage. The combined set has 32 slots after merging touching spans; if it | ||
| 1143 | * cannot hold every span, the scan fails closed with @ref ErrorCode::NotAuthoritative. | ||
| 1144 | * @note Setup/control-plane only, same constraints as the four-argument overload. | ||
| 1145 | */ | ||
| 1146 | [[nodiscard]] Result<Address> scan( | ||
| 1147 | const Pattern &pattern, | ||
| 1148 | Region scope, | ||
| 1149 | std::span<const Region> exclusions, | ||
| 1150 | std::size_t occurrence = 1, | ||
| 1151 | Pages pages = Pages::Readable | ||
| 1152 | ) noexcept; | ||
| 1153 | |||
| 1154 | /// Common x86-64 RIP-relative opcode prefixes (the bytes preceding the disp32 field), for find_and_resolve. | ||
| 1155 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RAX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x05}}; | ||
| 1156 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RCX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x0D}}; | ||
| 1157 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RDX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x15}}; | ||
| 1158 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RBX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x1D}}; | ||
| 1159 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RAX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x05}}; | ||
| 1160 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RCX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x0D}}; | ||
| 1161 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RDX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x15}}; | ||
| 1162 | inline constexpr std::array<std::byte, 1> PREFIX_CALL_REL32 = {std::byte{0xE8}}; | ||
| 1163 | inline constexpr std::array<std::byte, 1> PREFIX_JMP_REL32 = {std::byte{0xE9}}; | ||
| 1164 | |||
| 1165 | /** | ||
| 1166 | * @brief Resolves an absolute address from an x86-64 RIP-relative instruction at a known address. | ||
| 1167 | * @param instruction Address of the first byte of the instruction. | ||
| 1168 | * @param displacement_offset Byte offset from @p instruction to the disp32 field. | ||
| 1169 | * @param instruction_length Total length of the instruction in bytes; must be at most 15 and contain the disp32. | ||
| 1170 | * @return The resolved absolute address (`instruction + instruction_length + disp32`), or an Error. | ||
| 1171 | * @details The displacement is read under an SEH fault guard. A resolved address that is not a plausible user-mode | ||
| 1172 | * pointer is rejected with ErrorCode::ImplausibleTarget rather than returned. For `FF 15`/`FF 25` forms | ||
| 1173 | * the resolved value is the pointer slot, itself an in-image address. A malformed field layout returns | ||
| 1174 | * ErrorCode::InvalidArg before any read. | ||
| 1175 | * @note Callback-safe: a guarded read plus pointer arithmetic, no allocation. | ||
| 1176 | */ | ||
| 1177 | [[nodiscard]] Result<Address> | ||
| 1178 | resolve_rip_relative(Address instruction, std::size_t displacement_offset, std::size_t instruction_length) noexcept; | ||
| 1179 | |||
| 1180 | /** | ||
| 1181 | * @brief Scans forward in @p search for an opcode prefix, then resolves the RIP-relative target that follows it. | ||
| 1182 | * @param search The region to scan; the disp32 is assumed to immediately follow the matched prefix. | ||
| 1183 | * @param opcode_prefix The opcode byte sequence to search for. | ||
| 1184 | * @param instruction_length Total length of the instruction in bytes; must be at most 15 and contain the disp32 | ||
| 1185 | * that follows @p opcode_prefix. | ||
| 1186 | * @return The resolved absolute address, or an Error. | ||
| 1187 | * @details The first resolvable prefix wins. A matched occurrence is a coincidental decoy when its disp32 | ||
| 1188 | * resolves to an implausible target, or when the first byte of the resolved target is not readable at | ||
| 1189 | * scan time. The scan skips each decoy and continues. The scan fails only after it exhausts the region, | ||
| 1190 | * and then reports the last concrete failure, for example @ref ErrorCode::ImplausibleTarget or | ||
| 1191 | * @ref ErrorCode::UnreadableTarget for a plausible target on an unreadable page. The prefix search | ||
| 1192 | * reads @p search directly with no page filter, so the caller must guarantee that the region is | ||
| 1193 | * committed and readable. | ||
| 1194 | * To resolve one instruction whose address is uncertain, use @ref resolve_rip_relative, whose | ||
| 1195 | * displacement read is guarded. For the `FF 15` and `FF 25` indirect forms the returned address is the | ||
| 1196 | * pointer slot, not the final target. The same ImplausibleTarget gate applies. For an ambiguous | ||
| 1197 | * signature, anchor through @ref resolve, which enforces per-candidate uniqueness. A malformed field | ||
| 1198 | * layout returns @ref ErrorCode::InvalidArg before the sweep starts. | ||
| 1199 | * @note The prefix scan reads @p search unguarded (caller-guaranteed readable); the displacement read is guarded. | ||
| 1200 | * No allocation. | ||
| 1201 | * @note Setup/control-plane only: the sweep cost scales with @p search, so resolve at init, not per frame. | ||
| 1202 | */ | ||
| 1203 | [[nodiscard]] Result<Address> find_and_resolve_rip_relative( | ||
| 1204 | Region search, | ||
| 1205 | std::span<const std::byte> opcode_prefix, | ||
| 1206 | std::size_t instruction_length | ||
| 1207 | ) noexcept; | ||
| 1208 | |||
| 1209 | /** | ||
| 1210 | * @brief Cheap heuristic: does @p addr look like the first byte of a real function body? | ||
| 1211 | * @param addr Absolute address to probe. A null @p addr returns false without reading memory. | ||
| 1212 | * @return true if the byte at @p addr is readable and not on the poison list; false otherwise. | ||
| 1213 | * @details Reads exactly one byte from @p addr under an SEH fault guard and rejects a small blacklist of bytes that | ||
| 1214 | * are never the first opcode of a callable x86-64 function: 0x00 (zero-fill / NULL page), 0xCC (int3 pad), | ||
| 1215 | * and 0xC2 / 0xC3 (bare RET stub). It returns true for 0xE9 / 0xEB / the 0xFF 0x25 prefix of an indirect | ||
| 1216 | * JMP, so a target whose prologue is already overwritten by another inline hook still passes, which is | ||
| 1217 | * required for nested-hook scenarios. This is the negative complement to the resolve() prologue-recovery | ||
| 1218 | * fallback: use it to filter scan poison (a zero page or an alignment pad) after a resolve. | ||
| 1219 | * @note Callback-safe: a single guarded byte read, no allocation. | ||
| 1220 | */ | ||
| 1221 | [[nodiscard]] bool is_likely_function_prologue(Address addr) noexcept; | ||
| 1222 | |||
| 1223 | /** | ||
| 1224 | * @struct ImageIdentity | ||
| 1225 | * @brief An ASLR-insensitive fingerprint of a loaded module's PE build identity. | ||
| 1226 | * @details Folds the PE timestamp, image size, and section-table layout. The module base is excluded; a malformed | ||
| 1227 | * or incomplete header read yields an absent identity. | ||
| 1228 | * @warning Layout identity, NOT content identity. Every input is an | ||
| 1229 | * @c IMAGE_FILE_HEADER / @c IMAGE_OPTIONAL_HEADER / @c IMAGE_SECTION_HEADER field; no section body is | ||
| 1230 | * ever read. Executable content patched in place, leaving the timestamp, @c SizeOfImage, and the section | ||
| 1231 | * table equal, produces a bit-identical identity. Use @ref WinningEvidence to witness content. | ||
| 1232 | */ | ||
| 1233 | struct ImageIdentity | ||
| 1234 | { | ||
| 1235 | /// PE @c IMAGE_FILE_HEADER::TimeDateStamp of the resolved image (0 when the read failed). | ||
| 1236 | 470 | std::uint32_t timestamp = 0; | |
| 1237 | /// PE @c IMAGE_OPTIONAL_HEADER::SizeOfImage of the resolved image (0 when the read failed). | ||
| 1238 | 468 | std::uint32_t size_of_image = 0; | |
| 1239 | /// A fold of every section header's name, RVA, virtual size, and characteristics. | ||
| 1240 | 468 | std::uint64_t section_digest = 0; | |
| 1241 | |||
| 1242 | /// True when a live image was read (@ref size_of_image is non-zero); a default value is absent. | ||
| 1243 | 493 | [[nodiscard]] constexpr bool present() const noexcept { return size_of_image != 0; } | |
| 1244 | |||
| 1245 | /// A single 64-bit token folding all three fields, for a fingerprint or an equality key. | ||
| 1246 | 15 | [[nodiscard]] constexpr std::uint64_t token() const noexcept | |
| 1247 | { | ||
| 1248 | 15 | std::uint64_t seed = section_digest; | |
| 1249 | 15 | seed ^= static_cast<std::uint64_t>(timestamp) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2); | |
| 1250 | 15 | seed ^= static_cast<std::uint64_t>(size_of_image) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2); | |
| 1251 | 15 | return seed; | |
| 1252 | } | ||
| 1253 | |||
| 1254 | /// Value equality across all three fields. | ||
| 1255 |
5/6✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 468 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 468 times.
✓ Branch 6 → 7 taken 12 times.
✓ Branch 6 → 8 taken 456 times.
|
470 | [[nodiscard]] constexpr bool operator==(const ImageIdentity &other) const noexcept = default; |
| 1256 | }; | ||
| 1257 | |||
| 1258 | /** | ||
| 1259 | * @brief Reads the ASLR-insensitive @ref ImageIdentity of the module mapped at @p range's base. | ||
| 1260 | * @param range The module image to identify; defaults to the host executable. Only @p range.base is used, since | ||
| 1261 | * the live PE headers there carry the authoritative SizeOfImage and section table. | ||
| 1262 | * @return The identity, or an absent value when @p range is empty or its PE headers do not validate completely. | ||
| 1263 | * @details Uses guarded reads and consults no loader. | ||
| 1264 | * @note Callback-safe with an explicit Region: bounded guarded reads of the PE headers, no allocation or loader | ||
| 1265 | * call. The default scope query is setup/control-plane only. | ||
| 1266 | */ | ||
| 1267 | [[nodiscard]] ImageIdentity image_identity(Region range = Region::host()) noexcept; | ||
| 1268 | |||
| 1269 | /** | ||
| 1270 | * @brief Flattens a resolve Result to its address, or a null Address on failure. | ||
| 1271 | * @details A convenience adapter, not the primary contract: new code resolves through Result and handles the | ||
| 1272 | * Error. | ||
| 1273 | * @note Callback-safe: a pure noexcept Result read with no allocation, I/O, or locking. | ||
| 1274 | */ | ||
| 1275 | 2 | [[nodiscard]] inline Address or_null(const Result<Hit> &result) noexcept | |
| 1276 | { | ||
| 1277 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 1 time.
|
2 | return result ? result->address : Address{}; |
| 1278 | } | ||
| 1279 | |||
| 1280 | /** | ||
| 1281 | * @brief Flattens a resolve Result to its address, or a caller-chosen fallback on failure. | ||
| 1282 | * @details The general, non-default form of or_null (`or_null(r)` is `address_or(r, Address{})`). Same | ||
| 1283 | * convenience-adapter status: prefer handling the Error in new code. | ||
| 1284 | * @note Callback-safe: a pure noexcept Result read with no allocation, I/O, or locking. | ||
| 1285 | */ | ||
| 1286 | 3 | [[nodiscard]] inline Address address_or(const Result<Hit> &result, Address fallback = Address{}) noexcept | |
| 1287 | { | ||
| 1288 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 2 times.
|
3 | return result ? result->address : fallback; |
| 1289 | } | ||
| 1290 | |||
| 1291 | namespace unchecked | ||
| 1292 | { | ||
| 1293 | /** | ||
| 1294 | * @brief Raw single-pattern scan over a region the caller guarantees is fully readable. | ||
| 1295 | * @param region The byte range to scan; every byte MUST be committed and readable. | ||
| 1296 | * @param pattern The compiled signature. | ||
| 1297 | * @param occurrence Which match to return (1-based). 1 = first match. 0 returns nullptr. | ||
| 1298 | * @return A pointer to the Nth match (adjusted by the Pattern's `|` offset), or nullptr if not found. | ||
| 1299 | * @details The unsafe twin of scan(): it performs no page filtering and uses raw SIMD/memchr loads, so an | ||
| 1300 | * unreadable byte in @p region faults the host. The return is a raw pointer, not a Result, because | ||
| 1301 | * there is no recoverable error to report. noexcept. A pattern allocation failure returns nullptr. | ||
| 1302 | * @note Setup/control-plane only: prepares the engine pattern and performs a raw, page-unfiltered scan. | ||
| 1303 | */ | ||
| 1304 | [[nodiscard]] const std::byte * | ||
| 1305 | find_pattern(Region region, const Pattern &pattern, std::size_t occurrence = 1) noexcept; | ||
| 1306 | } // namespace unchecked | ||
| 1307 | |||
| 1308 | } // namespace DetourModKit::scan | ||
| 1309 | |||
| 1310 | #endif // DETOURMODKIT_SCAN_HPP | ||
| 1311 |