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 Pattern matching, candidate-ladder resolution, and the RIP-relative, string-xref, code-constant, and export | ||
| 7 | * resolvers. docs/design/resolution.md owns the mechanism. | ||
| 8 | * @warning `[B-100]` Under the loader lock, call only Callback-safe entry points, with any Region argument built | ||
| 9 | * during setup. Ladder construction and resolution can allocate, scan memory, or create threads. | ||
| 10 | */ | ||
| 11 | |||
| 12 | #include "DetourModKit/address.hpp" | ||
| 13 | #include "DetourModKit/defines.hpp" | ||
| 14 | #include "DetourModKit/detail/pattern_core.hpp" | ||
| 15 | #include "DetourModKit/error.hpp" | ||
| 16 | #include "DetourModKit/region.hpp" | ||
| 17 | |||
| 18 | #include <array> | ||
| 19 | #include <cstddef> | ||
| 20 | #include <cstdint> | ||
| 21 | #include <span> | ||
| 22 | #include <stdexcept> | ||
| 23 | #include <string> | ||
| 24 | #include <string_view> | ||
| 25 | #include <variant> | ||
| 26 | #include <vector> | ||
| 27 | |||
| 28 | namespace DetourModKit::scan | ||
| 29 | { | ||
| 30 | class Pattern; | ||
| 31 | } // namespace DetourModKit::scan | ||
| 32 | |||
| 33 | namespace DetourModKit::detail | ||
| 34 | { | ||
| 35 | /// Returns @p pattern's immutable compiled buffer for internal scan adapters. | ||
| 36 | [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept; | ||
| 37 | } // namespace DetourModKit::detail | ||
| 38 | |||
| 39 | namespace DetourModKit::scan | ||
| 40 | { | ||
| 41 | /** | ||
| 42 | * @brief A value-semantic compiled AOB pattern that owns its bytes and mask inline. | ||
| 43 | * @note Setup/control-plane only: compile(). Callback-safe: size(), the accessors, and matches_at(). | ||
| 44 | */ | ||
| 45 | class Pattern | ||
| 46 | { | ||
| 47 | public: | ||
| 48 | /** | ||
| 49 | * @brief Compiles a runtime AOB DSL string, for example "48 8B 05 ?? ?? ?? ??". | ||
| 50 | * @return The Pattern, or BadPattern (parse status in `extra`) for a malformed, empty, or oversized @p dsl. | ||
| 51 | * @note Setup/control-plane only. | ||
| 52 | */ | ||
| 53 | 20621 | [[nodiscard]] static Result<Pattern> compile(std::string_view dsl) | |
| 54 | { | ||
| 55 | 20621 | const detail::PatternParse parsed = detail::parse_pattern(dsl); | |
| 56 | 20621 | if (parsed.status != detail::PatternStatus::Ok) | |
| 57 | { | ||
| 58 | 34 | return std::unexpected( | |
| 59 | 34 | Error{ErrorCode::BadPattern, "scan::compile", 0, static_cast<std::uint32_t>(parsed.status)} | |
| 60 | 34 | ); | |
| 61 | } | ||
| 62 | 20587 | return Pattern{parsed.buffer}; | |
| 63 | } | ||
| 64 | |||
| 65 | /** @brief Compiles an in-source AOB DSL literal at compile time. A malformed literal is a compile error. */ | ||
| 66 | [[nodiscard]] static consteval Pattern literal(std::string_view dsl) | ||
| 67 | { | ||
| 68 | const detail::PatternParse parsed = detail::parse_pattern(dsl); | ||
| 69 | if (parsed.status != detail::PatternStatus::Ok) | ||
| 70 | { | ||
| 71 | throw "DetourModKit: scan::Pattern::literal() received a malformed AOB pattern"; | ||
| 72 | } | ||
| 73 | return Pattern{parsed.buffer}; | ||
| 74 | } | ||
| 75 | |||
| 76 | /// The fixed byte count, without gap bytes. | ||
| 77 | 4195575 | [[nodiscard]] constexpr std::size_t size() const noexcept { return m_data.length; } | |
| 78 | |||
| 79 | /** @brief The `|` offset in fixed bytes, or 0 without a marker. A match result also adds skipped gap bytes. */ | ||
| 80 | 1087 | [[nodiscard]] constexpr std::size_t offset() const noexcept { return m_data.offset; } | |
| 81 | |||
| 82 | /// The fixed bytes of all segments, concatenated without gap bytes (length == size()). | ||
| 83 | 2099420 | [[nodiscard]] constexpr std::span<const std::byte> bytes() const noexcept | |
| 84 | { | ||
| 85 | 2099420 | return std::span<const std::byte>(m_data.bytes.data(), m_data.length); | |
| 86 | } | ||
| 87 | |||
| 88 | /// The per-byte match mask, parallel to bytes() (length == size()). | ||
| 89 | 1531 | [[nodiscard]] constexpr std::span<const std::byte> mask() const noexcept | |
| 90 | { | ||
| 91 | 1531 | return std::span<const std::byte>(m_data.mask.data(), m_data.length); | |
| 92 | } | ||
| 93 | |||
| 94 | /// True when the pattern carries at least one bounded jump. | ||
| 95 | 72 | [[nodiscard]] constexpr bool has_jumps() const noexcept { return m_data.jump_count > 0; } | |
| 96 | |||
| 97 | /// Number of fixed segments. | ||
| 98 | 5 | [[nodiscard]] constexpr std::size_t segment_count() const noexcept { return m_data.jump_count + 1; } | |
| 99 | |||
| 100 | /// Fewest bytes any match can occupy: the fixed byte count plus every gap's minimum skip. | ||
| 101 | 5 | [[nodiscard]] constexpr std::size_t min_match_length() const noexcept | |
| 102 | { | ||
| 103 | 5 | return detail::min_match_length(m_data); | |
| 104 | } | ||
| 105 | |||
| 106 | /// Most bytes any match can occupy: the fixed byte count plus every gap's maximum skip. | ||
| 107 | 4 | [[nodiscard]] constexpr std::size_t max_match_length() const noexcept | |
| 108 | { | ||
| 109 | 4 | return detail::max_match_length(m_data); | |
| 110 | } | ||
| 111 | |||
| 112 | /// True when the run before the first bounded jump has a fully-known byte for the prefilter anchor. | ||
| 113 | 489 | [[nodiscard]] constexpr bool has_anchor() const noexcept { return m_data.anchor < m_data.length; } | |
| 114 | |||
| 115 | /// Index of the rarest fully-known byte before the first bounded jump, valid only when has_anchor() is true. | ||
| 116 | 469 | [[nodiscard]] constexpr std::size_t anchor_index() const noexcept { return m_data.anchor; } | |
| 117 | |||
| 118 | /// The anchor byte value, or a zero byte when has_anchor() is false. | ||
| 119 | 5 | [[nodiscard]] constexpr std::byte anchor_byte() const noexcept | |
| 120 | { | ||
| 121 | 5 | return has_anchor() ? m_data.bytes[m_data.anchor] : std::byte{0x00}; | |
| 122 | } | ||
| 123 | |||
| 124 | /** | ||
| 125 | * @brief Tests for a match at the start of @p window. A byte agrees when (memory ^ pattern) & mask is zero. | ||
| 126 | * @return True when the bounded search, shortest gaps first, places every segment. False also covers an | ||
| 127 | * exhausted per-position budget, so it is not proof of absence. A match needs min_match_length() bytes. | ||
| 128 | * @note Callback-safe: no allocation, I/O, or lock. | ||
| 129 | */ | ||
| 130 | 26 | [[nodiscard]] constexpr bool matches_at(std::span<const std::byte> window) const noexcept | |
| 131 | { | ||
| 132 | 26 | return detail::matches_buffer_at(m_data, window); | |
| 133 | } | ||
| 134 | |||
| 135 | private: | ||
| 136 | friend constexpr const detail::PatternBuffer &detail::pattern_buffer(const Pattern &pattern) noexcept; | ||
| 137 | |||
| 138 | // Private: every Pattern comes from a successful compile() or literal() parse. | ||
| 139 | 20587 | constexpr explicit Pattern(const detail::PatternBuffer &data) noexcept : m_data{data} {} | |
| 140 | |||
| 141 | detail::PatternBuffer m_data{}; | ||
| 142 | }; | ||
| 143 | |||
| 144 | } // namespace DetourModKit::scan | ||
| 145 | |||
| 146 | namespace DetourModKit::detail | ||
| 147 | { | ||
| 148 | /// Returns @p pattern's immutable compiled buffer for internal scan adapters. | ||
| 149 | 41584 | [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept | |
| 150 | { | ||
| 151 | 41584 | return pattern.m_data; | |
| 152 | } | ||
| 153 | } // namespace DetourModKit::detail | ||
| 154 | |||
| 155 | namespace DetourModKit::scan | ||
| 156 | { | ||
| 157 | |||
| 158 | /** | ||
| 159 | * @brief Which page-protection class a page-gated scan accepts. | ||
| 160 | * @details Readable authority rule: a Readable scan returns @ref ErrorCode::NotAuthoritative when its scope | ||
| 161 | * declares no exclusions and lies inside neither one mapped image nor one reserved allocation. Outside a | ||
| 162 | * mapped image, a scope in one allocation that crosses more than 64 `VirtualQuery` regions is unconfined. | ||
| 163 | * Such a scope also covers caller copies of the query bytes that DMK cannot enumerate, so a match can be | ||
| 164 | * the query storage itself. DMK always excludes the query storage that it owns. | ||
| 165 | * | ||
| 166 | * The remedies are a confined scope, an Executable scan, or exclusions that name every live caller copy. | ||
| 167 | * Proof: `ScannerTrustProof.WholeProcessReadableScanCannotAuthorizeQueryOwnedMatch`. | ||
| 168 | */ | ||
| 169 | enum class Pages : std::uint8_t | ||
| 170 | { | ||
| 171 | /// Every committed readable page, a superset of Executable. | ||
| 172 | Readable, | ||
| 173 | /// Committed execute-readable pages only, for a pattern that must land on code. | ||
| 174 | Executable | ||
| 175 | }; | ||
| 176 | |||
| 177 | /** @brief Largest x86-64 instruction length, in bytes. The RIP-relative helpers reject a longer instruction. */ | ||
| 178 | inline constexpr std::size_t MAX_X86_INSTRUCTION_LENGTH = 15; | ||
| 179 | |||
| 180 | /** | ||
| 181 | * @brief Tests whether a disp32 at @p displacement_offset lies within @p instruction_length. A length above | ||
| 182 | * @ref MAX_X86_INSTRUCTION_LENGTH returns false. It checks bounds only, not the RIP-relative operand. | ||
| 183 | * @note Callback-safe: pure constexpr arithmetic. | ||
| 184 | */ | ||
| 185 | [[nodiscard]] constexpr bool | ||
| 186 | 152 | is_valid_rip_relative_layout(std::size_t displacement_offset, std::size_t instruction_length) noexcept | |
| 187 | { | ||
| 188 | 300 | return instruction_length <= MAX_X86_INSTRUCTION_LENGTH && displacement_offset <= instruction_length && | |
| 189 | 300 | instruction_length - displacement_offset >= sizeof(std::int32_t); | |
| 190 | } | ||
| 191 | |||
| 192 | /** @brief The highest SIMD verification tier the engine selects at runtime. */ | ||
| 193 | enum class SimdLevel : std::uint8_t | ||
| 194 | { | ||
| 195 | /// Byte-by-byte verification (no SIMD). | ||
| 196 | Scalar, | ||
| 197 | /// SSE2. | ||
| 198 | Sse2, | ||
| 199 | /// AVX2. | ||
| 200 | Avx2, | ||
| 201 | /// AVX-512F and AVX-512BW, only in a DMK_ENABLE_AVX512 build on an AVX-512 host. | ||
| 202 | Avx512 | ||
| 203 | }; | ||
| 204 | |||
| 205 | /** | ||
| 206 | * @brief Returns the static enumerator name for a SimdLevel, or "Unknown" for an out-of-range value. | ||
| 207 | * @note Callback-safe: a pure constexpr value map. | ||
| 208 | */ | ||
| 209 | [[nodiscard]] constexpr std::string_view to_string(SimdLevel level) noexcept | ||
| 210 | { | ||
| 211 | switch (level) | ||
| 212 | { | ||
| 213 | case SimdLevel::Scalar: | ||
| 214 | return "Scalar"; | ||
| 215 | case SimdLevel::Sse2: | ||
| 216 | return "Sse2"; | ||
| 217 | case SimdLevel::Avx2: | ||
| 218 | return "Avx2"; | ||
| 219 | case SimdLevel::Avx512: | ||
| 220 | return "Avx512"; | ||
| 221 | } | ||
| 222 | return "Unknown"; | ||
| 223 | } | ||
| 224 | |||
| 225 | /** | ||
| 226 | * @brief Reports the SIMD tier that pattern matching uses, the highest that both the build and the host support. | ||
| 227 | * @note Callback-safe: a CPU-feature read with no allocation or lock. | ||
| 228 | */ | ||
| 229 | [[nodiscard]] SimdLevel active_simd_level() noexcept; | ||
| 230 | |||
| 231 | /** @brief Byte encoding of an anchor string as it is stored in the image. */ | ||
| 232 | enum class StringEncoding : std::uint8_t | ||
| 233 | { | ||
| 234 | /** @brief The query bytes verbatim, as a char literal stores them. Ill-formed UTF-8 is searched as given. */ | ||
| 235 | Utf8, | ||
| 236 | /** @brief UTF-16LE (wchar_t), transcoded from the UTF-8 query. Ill-formed UTF-8 returns MalformedQueryText. */ | ||
| 237 | Utf16le | ||
| 238 | }; | ||
| 239 | |||
| 240 | /** @brief What a resolved string cross-reference returns. */ | ||
| 241 | enum class XrefReturn : std::uint8_t | ||
| 242 | { | ||
| 243 | /// Exact address of the instruction that loads the string. | ||
| 244 | ReferencingInstruction, | ||
| 245 | /** @brief Enclosing-function entry from `.pdata` (chains to the primary), else a bounded RET/INT3 back-scan. */ | ||
| 246 | EnclosingFunction, | ||
| 247 | /** | ||
| 248 | * @brief The global slot that a `mov [rip+slot], reg` fills shortly after the unique `lea reg, [rip+string]`. | ||
| 249 | * A `mov` load, a rarer shape that broad_match finds, or no such store returns StoreNotFound. | ||
| 250 | */ | ||
| 251 | StringPointerSlot | ||
| 252 | }; | ||
| 253 | |||
| 254 | /** @brief A string-reference query that borrows @ref text for one call. A Candidate::string_xref owns its copy. */ | ||
| 255 | struct StringRefQuery | ||
| 256 | { | ||
| 257 | /** @brief Literal content without quotes. An embedded NUL returns @ref ErrorCode::MalformedQueryText. */ | ||
| 258 | std::string_view text; | ||
| 259 | /// How the image stores the literal, and therefore how @ref text is read (see @ref StringEncoding). | ||
| 260 | StringEncoding encoding = StringEncoding::Utf8; | ||
| 261 | /// Match a trailing NUL, so "Player" does not match the prefix of "PlayerController". | ||
| 262 | bool require_terminator = true; | ||
| 263 | /// Selects the exact instruction site, the enclosing-function heuristic, or the cached global pointer slot. | ||
| 264 | XrefReturn return_mode = XrefReturn::ReferencingInstruction; | ||
| 265 | /** | ||
| 266 | * @brief With false, phase 2 finds only the REX.W `lea`/`mov reg, [rip+disp32]` shapes. With true, a Zydis | ||
| 267 | * sweep also finds rarer shapes such as `cmp [rip+d], imm`. That sweep decodes only for a displacement | ||
| 268 | * field whose arithmetic can reach the string. See find_string_xref() for the broad confirmation sweep. | ||
| 269 | */ | ||
| 270 | bool broad_match = false; | ||
| 271 | }; | ||
| 272 | |||
| 273 | /** | ||
| 274 | * @brief Resolves a string-reference anchor inside one mapped image. | ||
| 275 | * @param scope Module image to search, which must pass the Readable authority rule of @ref Pages. | ||
| 276 | * @return The address that @ref StringRefQuery::return_mode selects, or an Error. | ||
| 277 | * @details Phase 1 finds the one copy of @p query.text in readable pages, or returns @ref ErrorCode::StringNotFound | ||
| 278 | * or @ref ErrorCode::StringAmbiguous. Phase 2 finds the one RIP-relative reference to that copy, or | ||
| 279 | * returns @ref ErrorCode::NoReference or @ref ErrorCode::AmbiguousReference. An observed second copy or | ||
| 280 | * reference stays ambiguous in a truncated sweep. Any other truncated sweep returns | ||
| 281 | * @ref ErrorCode::IncompleteScan. Unencodable text returns @ref ErrorCode::MalformedQueryText, and an | ||
| 282 | * out-of-range encoding or return mode returns @ref ErrorCode::InvalidArg before phase 1 starts. | ||
| 283 | * @note Phase 1 excludes the storage of @p query.text. If that buffer is the only copy in scope, the result is | ||
| 284 | * @ref ErrorCode::StringNotFound. Anchor on a literal that the scanned image owns. | ||
| 285 | * @note With broad_match false, an EnclosingFunction or StringPointerSlot return confirms a single narrow hit with | ||
| 286 | * a broad sweep. A rarer second reference then returns @ref ErrorCode::AmbiguousReference, and a broad-only | ||
| 287 | * reference never becomes a hit. | ||
| 288 | * @note Setup/control-plane only. An allocation failure in either phase throws `std::bad_alloc`, never a miss. | ||
| 289 | */ | ||
| 290 | [[nodiscard]] Result<Address> find_string_xref(const StringRefQuery &query, Region scope = Region::host()); | ||
| 291 | |||
| 292 | /** | ||
| 293 | * @brief Resolves a named export to its address through one module's PE Export Address Table. | ||
| 294 | * @param export_name The exact, case-sensitive export name, for example "Sleep". | ||
| 295 | * @param module The image that exports the name, for example @ref Region::module_named("kernel32.dll"). | ||
| 296 | * @details The walk parses the mapped IMAGE_EXPORT_DIRECTORY and never calls GetProcAddress, so it never enters the | ||
| 297 | * loader or runs a DllMain. A truncated or hostile export section returns an Error, unless the guard-page | ||
| 298 | * re-arm in `[B-20]` fails. A missing export directory, an absent or duplicate name, an ordinal-only | ||
| 299 | * export, an out-of-image RVA, or an empty @p export_name returns ExportNotFound. A null or invalid module | ||
| 300 | * image returns InvalidRange. A forwarded export returns ExportForwarded. | ||
| 301 | * @note Setup/control-plane only by convention. It does not allocate. | ||
| 302 | */ | ||
| 303 | [[nodiscard]] Result<Address> resolve_export(std::string_view export_name, Region module = Region::host()) noexcept; | ||
| 304 | |||
| 305 | /** @brief Which operand field @ref read_code_constant extracts. */ | ||
| 306 | enum class OperandKind : std::uint8_t | ||
| 307 | { | ||
| 308 | /// An immediate operand, for example the imm of `add reg, imm`. | ||
| 309 | Immediate, | ||
| 310 | /// A memory operand's displacement, for example the disp of `[reg + disp]`. | ||
| 311 | MemoryDisplacement | ||
| 312 | }; | ||
| 313 | |||
| 314 | /** @brief The resolution strategy of a Candidate. RttiVtable and StringXref are unique-only text tiers. */ | ||
| 315 | enum class Mode : std::uint8_t | ||
| 316 | { | ||
| 317 | /// Scan for the Pattern, then add a fixed signed walk-back to the match. | ||
| 318 | Direct, | ||
| 319 | /// Scan for the Pattern, then read the RIP-relative disp32 it spans and compute the absolute target. | ||
| 320 | RipRelative, | ||
| 321 | /// Resolve the primary vtable of an MSVC-mangled type name through the reverse-RTTI walk. | ||
| 322 | RttiVtable, | ||
| 323 | /// Anchor on an immutable string literal and resolve the unique RIP-relative reference to it. | ||
| 324 | StringXref | ||
| 325 | }; | ||
| 326 | |||
| 327 | /** @brief The ladder order before resolution. It can change which @ref Hit wins, never the verification rules. */ | ||
| 328 | enum class CandidateOrder : std::uint8_t | ||
| 329 | { | ||
| 330 | /// Try candidates in the order the caller wrote them. | ||
| 331 | AsDeclared, | ||
| 332 | /// Try unique-only text tiers, then anchored byte patterns, then the rest, each group in declared order. | ||
| 333 | UniqueFirst | ||
| 334 | }; | ||
| 335 | |||
| 336 | /** | ||
| 337 | * @brief Returns the static enumerator name for a CandidateOrder, or "Unknown" for an out-of-range value. | ||
| 338 | * @note Callback-safe: a pure constexpr value map. | ||
| 339 | */ | ||
| 340 | 5 | [[nodiscard]] constexpr std::string_view candidate_order_to_string(CandidateOrder order) noexcept | |
| 341 | { | ||
| 342 | 5 | switch (order) | |
| 343 | { | ||
| 344 | 2 | case CandidateOrder::AsDeclared: | |
| 345 | 2 | return "AsDeclared"; | |
| 346 | 2 | case CandidateOrder::UniqueFirst: | |
| 347 | 2 | return "UniqueFirst"; | |
| 348 | } | ||
| 349 | 1 | return "Unknown"; | |
| 350 | } | ||
| 351 | |||
| 352 | /** @brief The Direct-tier payload: a compiled Pattern plus the signed walk-back applied to the match. */ | ||
| 353 | struct DirectPattern | ||
| 354 | { | ||
| 355 | /// The compiled pattern to scan for. | ||
| 356 | Pattern pattern; | ||
| 357 | /// Signed byte delta added to the match, where a negative value walks backward. | ||
| 358 | std::ptrdiff_t walk_back{0}; | ||
| 359 | }; | ||
| 360 | |||
| 361 | /** @brief The RipRelative-tier payload. It targets `match + instruction_length + disp32`. A bad disp32 misses. */ | ||
| 362 | struct RipRelativePattern | ||
| 363 | { | ||
| 364 | /// The compiled pattern to scan for. | ||
| 365 | Pattern pattern; | ||
| 366 | /// Byte offset from the match to the signed 4-byte displacement field. | ||
| 367 | std::ptrdiff_t displacement_at{0}; | ||
| 368 | /// Total length of the referencing instruction (the next-IP base for the disp). | ||
| 369 | std::size_t instruction_length{0}; | ||
| 370 | }; | ||
| 371 | |||
| 372 | /** @brief The RttiVtable-tier payload: an owned mangled type name. An ambiguous name fails closed. */ | ||
| 373 | struct RttiVtable | ||
| 374 | { | ||
| 375 | /// The MSVC decorated type name, for example ".?AVCameraManager@@". | ||
| 376 | std::string mangled; | ||
| 377 | }; | ||
| 378 | |||
| 379 | /** @brief The StringXref-tier payload: owned literal and facets. A second copy or reference fails closed. */ | ||
| 380 | struct StringXref | ||
| 381 | { | ||
| 382 | /// The exact string content to anchor on, without quotes. | ||
| 383 | std::string text; | ||
| 384 | /// How the literal is stored in the image. | ||
| 385 | StringEncoding encoding{StringEncoding::Utf8}; | ||
| 386 | /// Match a trailing NUL so a prefix of a longer literal is not matched. | ||
| 387 | bool require_terminator{true}; | ||
| 388 | /// What the resolution returns (see @ref XrefReturn). | ||
| 389 | XrefReturn return_mode{XrefReturn::ReferencingInstruction}; | ||
| 390 | /// Add the Zydis broad sweep (see @ref StringRefQuery::broad_match). | ||
| 391 | bool broad_match{false}; | ||
| 392 | }; | ||
| 393 | |||
| 394 | /** @brief One ladder tier, built only by the factories. It copies its strings, so it never aliases the caller. */ | ||
| 395 | class Candidate | ||
| 396 | { | ||
| 397 | public: | ||
| 398 | /// The variant payload, whose alternative order matches the Mode enumerator order. | ||
| 399 | using Payload = std::variant<DirectPattern, RipRelativePattern, RttiVtable, StringXref>; | ||
| 400 | |||
| 401 | /** | ||
| 402 | * @brief A Direct byte-scan candidate: the resolved address is the match plus a fixed walk-back. | ||
| 403 | * @note Setup/control-plane only. | ||
| 404 | */ | ||
| 405 | 20539 | [[nodiscard]] static Candidate direct(std::string name, Pattern pattern, std::ptrdiff_t walk_back = 0) | |
| 406 | { | ||
| 407 | 41078 | return Candidate{std::move(name), DirectPattern{std::move(pattern), walk_back}}; | |
| 408 | } | ||
| 409 | |||
| 410 | /** | ||
| 411 | * @brief A RIP-relative byte-scan candidate: the address is the target of the disp32 that the match spans. | ||
| 412 | * @throws std::invalid_argument for an invalid layout: a negative @p displacement_at, a disp32 that ends past | ||
| 413 | * @p instruction_length, or a length above 15. It also throws for a pattern suffix from `|` that does | ||
| 414 | * not span the disp32. | ||
| 415 | * @details The matched instruction must decode to @p instruction_length bytes, with a RIP-relative memory | ||
| 416 | * disp32 at @p displacement_at, or the candidate never resolves. A rel32 branch (`E8` call, `E9` jmp) | ||
| 417 | * has none. For a branch target, use scan() plus resolve_rip_relative(). The resolver computes the | ||
| 418 | * target from one immutable sweep snapshot, never from a reread after the sweep. | ||
| 419 | * @note Setup/control-plane only. The manifest loader checks the same layout and returns an error, not a throw. | ||
| 420 | */ | ||
| 421 | [[nodiscard]] static Candidate | ||
| 422 | 47 | rip_relative(std::string name, Pattern pattern, std::ptrdiff_t displacement_at, std::size_t instruction_length) | |
| 423 | { | ||
| 424 | 93 | if (displacement_at < 0 || | |
| 425 | 90 | !is_valid_rip_relative_layout(static_cast<std::size_t>(displacement_at), instruction_length) || | |
| 426 | 43 | detail::min_match_suffix_length(detail::pattern_buffer(pattern)) < | |
| 427 | 43 | static_cast<std::size_t>(displacement_at) + sizeof(std::int32_t)) | |
| 428 | { | ||
| 429 | throw std::invalid_argument( | ||
| 430 | "scan::Candidate::rip_relative: the matched suffix must span a valid x86-64 RIP disp32 " | ||
| 431 | "(0 <= displacement_at, displacement_at + 4 <= instruction_length <= 15, and the pattern's " | ||
| 432 | "shortest suffix from the result marker covers displacement_at + 4 bytes)" | ||
| 433 | 8 | ); | |
| 434 | } | ||
| 435 | return Candidate{ | ||
| 436 | 39 | std::move(name), | |
| 437 | 78 | RipRelativePattern{std::move(pattern), displacement_at, instruction_length} | |
| 438 | 117 | }; | |
| 439 | } | ||
| 440 | |||
| 441 | /** | ||
| 442 | * @brief An RTTI-vtable candidate: resolves the primary vtable of an MSVC-mangled type name. | ||
| 443 | * @note Setup/control-plane only. | ||
| 444 | */ | ||
| 445 | 15 | [[nodiscard]] static Candidate rtti_vtable(std::string name, std::string mangled) | |
| 446 | { | ||
| 447 | 30 | return Candidate{std::move(name), RttiVtable{std::move(mangled)}}; | |
| 448 | } | ||
| 449 | |||
| 450 | /** | ||
| 451 | * @brief A string-xref candidate with the default @ref StringXref facets. | ||
| 452 | * @note Setup/control-plane only. | ||
| 453 | */ | ||
| 454 | 12 | [[nodiscard]] static Candidate string_xref(std::string name, std::string literal) | |
| 455 | { | ||
| 456 | 24 | return Candidate{std::move(name), StringXref{std::move(literal)}}; | |
| 457 | } | ||
| 458 | |||
| 459 | /** | ||
| 460 | * @brief A string-xref candidate with the facets of @p query. It owns a copy of @p query.text. | ||
| 461 | * @note Setup/control-plane only. | ||
| 462 | */ | ||
| 463 | 19 | [[nodiscard]] static Candidate string_xref(std::string name, StringRefQuery query) | |
| 464 | { | ||
| 465 | return Candidate{ | ||
| 466 | 19 | std::move(name), | |
| 467 | 38 | StringXref{ | |
| 468 | ✗ | std::string{query.text}, | |
| 469 | 19 | query.encoding, | |
| 470 | 19 | query.require_terminator, | |
| 471 | 19 | query.return_mode, | |
| 472 | 19 | query.broad_match | |
| 473 | } | ||
| 474 | 57 | }; | |
| 475 | } | ||
| 476 | |||
| 477 | /// Human-readable label, copied into the winning Hit. | ||
| 478 | 20408 | [[nodiscard]] const std::string &name() const noexcept { return m_name; } | |
| 479 | |||
| 480 | /// The resolution strategy of this tier. | ||
| 481 | 20729 | [[nodiscard]] Mode mode() const noexcept { return static_cast<Mode>(m_payload.index()); } | |
| 482 | |||
| 483 | /// The full variant payload. | ||
| 484 | [[nodiscard]] const Payload &payload() const noexcept { return m_payload; } | ||
| 485 | |||
| 486 | /// Returns the Direct payload, or nullptr when this is not a Direct candidate. | ||
| 487 | 21473 | [[nodiscard]] const DirectPattern *as_direct() const noexcept { return std::get_if<DirectPattern>(&m_payload); } | |
| 488 | |||
| 489 | /// Returns the RipRelative payload, or nullptr when this is not a RipRelative candidate. | ||
| 490 | 478 | [[nodiscard]] const RipRelativePattern *as_rip_relative() const noexcept | |
| 491 | { | ||
| 492 | 478 | return std::get_if<RipRelativePattern>(&m_payload); | |
| 493 | } | ||
| 494 | |||
| 495 | /// Returns the RttiVtable payload, or nullptr when this is not an RTTI-vtable candidate. | ||
| 496 | 911 | [[nodiscard]] const RttiVtable *as_rtti_vtable() const noexcept { return std::get_if<RttiVtable>(&m_payload); } | |
| 497 | |||
| 498 | /// Returns the StringXref payload, or nullptr when this is not a string-xref candidate. | ||
| 499 | 21413 | [[nodiscard]] const StringXref *as_string_xref() const noexcept { return std::get_if<StringXref>(&m_payload); } | |
| 500 | |||
| 501 | private: | ||
| 502 | // Private: every Candidate comes from a factory, so its name and payload stay coherent. | ||
| 503 | 61872 | Candidate(std::string name, Payload payload) : m_name{std::move(name)}, m_payload{std::move(payload)} {} | |
| 504 | |||
| 505 | std::string m_name; | ||
| 506 | Payload m_payload; | ||
| 507 | }; | ||
| 508 | |||
| 509 | // mode() casts the variant index, so the alternative order must match the Mode order. | ||
| 510 | static_assert(std::is_same_v< | ||
| 511 | std::variant_alternative_t<static_cast<std::size_t>(Mode::Direct), Candidate::Payload>, | ||
| 512 | DirectPattern>); | ||
| 513 | static_assert(std::is_same_v< | ||
| 514 | std::variant_alternative_t<static_cast<std::size_t>(Mode::RipRelative), Candidate::Payload>, | ||
| 515 | RipRelativePattern>); | ||
| 516 | static_assert(std::is_same_v< | ||
| 517 | std::variant_alternative_t<static_cast<std::size_t>(Mode::RttiVtable), Candidate::Payload>, | ||
| 518 | RttiVtable>); | ||
| 519 | static_assert(std::is_same_v< | ||
| 520 | std::variant_alternative_t<static_cast<std::size_t>(Mode::StringXref), Candidate::Payload>, | ||
| 521 | StringXref>); | ||
| 522 | |||
| 523 | /** @brief Declares a constant encoded in machine code, so read_code_constant() can re-derive it after a patch. */ | ||
| 524 | struct CodeConstant | ||
| 525 | { | ||
| 526 | /// Borrowed candidate ladder that resolves to an execute-readable instruction site. | ||
| 527 | std::span<const Candidate> site; | ||
| 528 | /// Which operand field to read: an immediate or a memory displacement. | ||
| 529 | OperandKind kind = OperandKind::Immediate; | ||
| 530 | /// Index into the instruction's visible operands, as a disassembler counts them. | ||
| 531 | std::uint8_t operand_index = 0; | ||
| 532 | /// 0 keeps the decoded value, and 1 through 8 narrows a non-RIP constant to its low bytes and sign-extends it. | ||
| 533 | std::uint8_t byte_width = 0; | ||
| 534 | /// Last-known value for telemetry or a baseline, never returned in place of a live decode. | ||
| 535 | std::int64_t nominal = 0; | ||
| 536 | /// True when @ref nominal holds a value, so nominal == 0 does not mean "unset". | ||
| 537 | bool has_nominal = false; | ||
| 538 | }; | ||
| 539 | |||
| 540 | /** | ||
| 541 | * @brief Resolves @p code_constant.site, decodes the instruction there, and returns the requested operand's value. | ||
| 542 | * @return The sign-extended live value, or an Error. A RIP-relative memory operand returns its absolute target. | ||
| 543 | * @details A candidate whose final site is not execute-readable is skipped, and the next candidate runs. A selected | ||
| 544 | * site that loses execute protection, crosses into a non-executable page, or no longer decodes returns | ||
| 545 | * DecodeFailed. A wrong operand kind returns UnexpectedShape, and an operand index out of range returns | ||
| 546 | * OperandOutOfRange. An out-of-range kind or byte_width returns @ref ErrorCode::InvalidArg first. | ||
| 547 | * @note `[B-75]` The value decodes from a fresh snapshot after site resolution. A byte tier must still match its | ||
| 548 | * physical span and resolve the decoded site at that epoch, or the result is EvidenceMismatch. A wildcarded | ||
| 549 | * operand byte can drift, and the result is its current value. | ||
| 550 | * @note Setup/control-plane only. Site resolution allocates. | ||
| 551 | */ | ||
| 552 | [[nodiscard]] Result<std::int64_t> | ||
| 553 | read_code_constant(const CodeConstant &code_constant, Region scope = Region::host()); | ||
| 554 | |||
| 555 | /** | ||
| 556 | * @brief Ceiling on the bytes a @ref WinningEvidence carries. A longer span still resolves, but its truncated | ||
| 557 | * evidence never authorizes a mutation or seeds a strict baseline. | ||
| 558 | */ | ||
| 559 | inline constexpr std::size_t MAX_MUTATION_WITNESS_BYTES = 256; | ||
| 560 | |||
| 561 | /** | ||
| 562 | * @brief The literal bytes at the winning match span, captured by the match and never re-read. They include the | ||
| 563 | * wildcard and gap bytes, so they detect a same-layout code change that @ref ImageIdentity cannot see. | ||
| 564 | */ | ||
| 565 | struct WinningEvidence | ||
| 566 | { | ||
| 567 | /// The captured span in the first @ref length elements, with zero in the trailing elements. | ||
| 568 | std::array<std::byte, MAX_MUTATION_WITNESS_BYTES> bytes{}; | ||
| 569 | /// The count of valid leading elements in @ref bytes, or 0 when nothing was captured. | ||
| 570 | std::uint16_t length = 0; | ||
| 571 | /** @brief True when the winning span exceeded @ref MAX_MUTATION_WITNESS_BYTES. @ref length is then 0. */ | ||
| 572 | bool truncated = false; | ||
| 573 | |||
| 574 | /// True when a complete, internally valid winning span was captured. | ||
| 575 | 516 | [[nodiscard]] constexpr bool present() const noexcept | |
| 576 | { | ||
| 577 | 516 | return length != 0 && length <= MAX_MUTATION_WITNESS_BYTES && !truncated; | |
| 578 | } | ||
| 579 | |||
| 580 | /// The captured bytes as a span, empty unless @ref present. | ||
| 581 | 7 | [[nodiscard]] constexpr std::span<const std::byte> span() const noexcept | |
| 582 | { | ||
| 583 | 7 | if (!present()) | |
| 584 | { | ||
| 585 | 2 | return {}; | |
| 586 | } | ||
| 587 | 5 | return std::span<const std::byte>{bytes.data(), length}; | |
| 588 | } | ||
| 589 | |||
| 590 | /** @brief Value equality. A malformed value (bad @ref length, or bytes while @ref truncated) equals nothing. */ | ||
| 591 | 15 | [[nodiscard]] constexpr bool operator==(const WinningEvidence &other) const noexcept | |
| 592 | { | ||
| 593 | 15 | const bool malformed = length > MAX_MUTATION_WITNESS_BYTES || (truncated && length != 0); | |
| 594 | 15 | const bool other_malformed = | |
| 595 | 15 | other.length > MAX_MUTATION_WITNESS_BYTES || (other.truncated && other.length != 0); | |
| 596 | 15 | if (malformed || other_malformed) | |
| 597 | { | ||
| 598 | 1 | return false; | |
| 599 | } | ||
| 600 | 14 | if (length != other.length || truncated != other.truncated) | |
| 601 | { | ||
| 602 | 1 | return false; | |
| 603 | } | ||
| 604 | 1089 | for (std::size_t i = 0; i < length; ++i) | |
| 605 | { | ||
| 606 | 1078 | if (bytes[i] != other.bytes[i]) | |
| 607 | { | ||
| 608 | 2 | return false; | |
| 609 | } | ||
| 610 | } | ||
| 611 | 11 | return true; | |
| 612 | } | ||
| 613 | }; | ||
| 614 | |||
| 615 | /** @brief A resolved address with the owned name and the mode of the candidate that produced it. */ | ||
| 616 | struct Hit | ||
| 617 | { | ||
| 618 | /// The resolved absolute address. | ||
| 619 | Address address; | ||
| 620 | /// A copy of the winning candidate's name. | ||
| 621 | std::string winning_name; | ||
| 622 | /// The resolution mode of the winning candidate. | ||
| 623 | Mode winning_mode = Mode::Direct; | ||
| 624 | /** @brief Matched-span bytes. Only a byte-pattern tier fills them, so no other tier can seed a baseline. */ | ||
| 625 | WinningEvidence evidence{}; | ||
| 626 | }; | ||
| 627 | |||
| 628 | /** @brief `[B-53]` How strictly hooked-prologue recovery, which is address-blind, confirms a recovered target. */ | ||
| 629 | enum class FallbackPolicy : std::uint8_t | ||
| 630 | { | ||
| 631 | /// Recovery disabled: a full direct miss stays a miss. | ||
| 632 | Off, | ||
| 633 | /// Recover structurally and return the address. A @ref FallbackWitness rejection only logs a warning. | ||
| 634 | WarnOnly, | ||
| 635 | /// Recover structurally, then require a @ref FallbackWitness to confirm the recovered address. | ||
| 636 | RequireIdentity, | ||
| 637 | }; | ||
| 638 | |||
| 639 | /** @brief Returns false to reject a recovered site. It has the same type as @ref anchor::AnchorValidator. */ | ||
| 640 | using FallbackValidator = bool (*)(std::int64_t value, const void *context) noexcept; | ||
| 641 | |||
| 642 | /** @brief The FallbackPolicy witness. With a null predicate, WarnOnly recovers and RequireIdentity fails closed. */ | ||
| 643 | struct FallbackWitness | ||
| 644 | { | ||
| 645 | /// Predicate run on the recovered address, or nullptr for no identity check. | ||
| 646 | FallbackValidator predicate = nullptr; | ||
| 647 | /// Opaque pointer forwarded verbatim to @ref predicate. | ||
| 648 | const void *context = nullptr; | ||
| 649 | }; | ||
| 650 | |||
| 651 | /** @brief A non-owning resolution request for use within one expression. To store one, use OwnedScanRequest. */ | ||
| 652 | struct ScanRequest | ||
| 653 | { | ||
| 654 | /// Candidates tried in @ref order, where the first one that resolves wins. | ||
| 655 | std::span<const Candidate> ladder; | ||
| 656 | /// Optional non-owning label for diagnostics. | ||
| 657 | std::string_view label{}; | ||
| 658 | /// The memory range to resolve within. | ||
| 659 | Region scope = Region::host(); | ||
| 660 | /// Hooked-prologue recovery strictness on a full direct miss (see @ref FallbackPolicy). | ||
| 661 | FallbackPolicy fallback_policy = FallbackPolicy::Off; | ||
| 662 | /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off. | ||
| 663 | FallbackWitness fallback_witness{}; | ||
| 664 | /// Fail closed on an ambiguous byte match (a second occurrence in scope). False takes the first match. | ||
| 665 | bool require_unique = true; | ||
| 666 | /// How the ladder is ordered before it is tried. | ||
| 667 | CandidateOrder order = CandidateOrder::AsDeclared; | ||
| 668 | /// Page-protection class the Direct / RipRelative byte tiers scan. | ||
| 669 | Pages pages = Pages::Readable; | ||
| 670 | /** | ||
| 671 | * @brief Rejects each candidate or recovered site whose final address is off an execute-readable page. A | ||
| 672 | * rejected candidate is skipped, not fatal. A RIP-relative data global needs false. | ||
| 673 | */ | ||
| 674 | bool require_executable_result = false; | ||
| 675 | /** | ||
| 676 | * @brief Every live caller copy of the query bytes. The byte and string-xref tiers do not count a match that | ||
| 677 | * intersects one. A non-empty span satisfies the Readable authority rule. | ||
| 678 | */ | ||
| 679 | std::span<const Region> exclusions{}; | ||
| 680 | }; | ||
| 681 | |||
| 682 | /** | ||
| 683 | * @brief Builds a borrowed ScanRequest. Clang and MSVC code analysis can flag a temporary ladder or label. | ||
| 684 | * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only. | ||
| 685 | */ | ||
| 686 | [[nodiscard]] ScanRequest borrow( | ||
| 687 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 688 | std::string_view label DMK_LIFETIMEBOUND = {}, | ||
| 689 | Region scope = Region::host(), | ||
| 690 | FallbackPolicy fallback_policy = FallbackPolicy::Off, | ||
| 691 | FallbackWitness fallback_witness = {}, | ||
| 692 | bool require_unique = true, | ||
| 693 | CandidateOrder order = CandidateOrder::AsDeclared, | ||
| 694 | Pages pages = Pages::Readable | ||
| 695 | ) noexcept; | ||
| 696 | |||
| 697 | /** | ||
| 698 | * @brief Builds a borrowed ScanRequest preset for a code (hook) target. | ||
| 699 | * @details The preset sets `Pages::Executable`, `require_executable_result`, and @ref CandidateOrder::UniqueFirst. | ||
| 700 | * For a data, RTTI, or string target, use @ref borrow. | ||
| 701 | * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only. | ||
| 702 | */ | ||
| 703 | [[nodiscard]] ScanRequest borrow_code_target( | ||
| 704 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 705 | std::string_view label DMK_LIFETIMEBOUND = {}, | ||
| 706 | Region scope = Region::host(), | ||
| 707 | FallbackPolicy fallback_policy = FallbackPolicy::WarnOnly, | ||
| 708 | FallbackWitness fallback_witness = {} | ||
| 709 | ) noexcept; | ||
| 710 | |||
| 711 | /** | ||
| 712 | * @brief The @ref borrow_code_target preset under @ref FallbackPolicy::RequireIdentity. For no label, pass {}. | ||
| 713 | * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only. | ||
| 714 | */ | ||
| 715 | [[nodiscard]] ScanRequest borrow_code_target_strict( | ||
| 716 | std::span<const Candidate> ladder DMK_LIFETIMEBOUND, | ||
| 717 | std::string_view label DMK_LIFETIMEBOUND, | ||
| 718 | FallbackWitness fallback_witness, | ||
| 719 | Region scope = Region::host() | ||
| 720 | ) noexcept; | ||
| 721 | |||
| 722 | /** @brief An owning resolution request for stored or deferred resolution. Stored entry points take this type. */ | ||
| 723 | struct OwnedScanRequest | ||
| 724 | { | ||
| 725 | /// Owned candidate ladder. | ||
| 726 | std::vector<Candidate> ladder; | ||
| 727 | /// Owned diagnostic label. | ||
| 728 | std::string label; | ||
| 729 | /// The resolution scope. | ||
| 730 | Region scope = Region::host(); | ||
| 731 | /// Hooked-prologue recovery strictness on a full direct miss (see @ref FallbackPolicy). | ||
| 732 | FallbackPolicy fallback_policy = FallbackPolicy::Off; | ||
| 733 | /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off. | ||
| 734 | FallbackWitness fallback_witness{}; | ||
| 735 | /// Fail closed on an ambiguous byte match. | ||
| 736 | bool require_unique = true; | ||
| 737 | /// Ladder ordering policy. | ||
| 738 | CandidateOrder order = CandidateOrder::AsDeclared; | ||
| 739 | /// Page-protection class the byte tiers scan (see @ref ScanRequest::pages). | ||
| 740 | Pages pages = Pages::Readable; | ||
| 741 | /// Whether the final resolved address must be execute-readable. | ||
| 742 | bool require_executable_result = false; | ||
| 743 | /// Owned copies of the caller-declared query exclusions (see @ref ScanRequest::exclusions). | ||
| 744 | std::vector<Region> exclusions; | ||
| 745 | |||
| 746 | /** @brief Returns a ScanRequest that views this object's storage, valid only while this object lives. */ | ||
| 747 | 12 | [[nodiscard]] ScanRequest view() const noexcept DMK_LIFETIMEBOUND | |
| 748 | { | ||
| 749 | return ScanRequest{ | ||
| 750 | 12 | .ladder = ladder, | |
| 751 | 12 | .label = label, | |
| 752 | .scope = scope, | ||
| 753 | 12 | .fallback_policy = fallback_policy, | |
| 754 | .fallback_witness = fallback_witness, | ||
| 755 | 12 | .require_unique = require_unique, | |
| 756 | 12 | .order = order, | |
| 757 | 12 | .pages = pages, | |
| 758 | 12 | .require_executable_result = require_executable_result, | |
| 759 | 12 | .exclusions = exclusions, | |
| 760 | 24 | }; | |
| 761 | } | ||
| 762 | }; | ||
| 763 | |||
| 764 | /** | ||
| 765 | * @brief Writes the index permutation that @p order implies for @p ladder into @p out. | ||
| 766 | * @return The number of indices written, at most min(ladder.size(), out.size()). | ||
| 767 | * @details AsDeclared and any out-of-range value yield the identity permutation. | ||
| 768 | * @note Callback-safe: pure index math with no allocation. | ||
| 769 | */ | ||
| 770 | [[nodiscard]] std::size_t | ||
| 771 | order_candidates(CandidateOrder order, std::span<const Candidate> ladder, std::span<std::size_t> out) noexcept; | ||
| 772 | |||
| 773 | /** | ||
| 774 | * @brief Returns the Hit of the first candidate, in @ref ScanRequest::order, that resolves, or an Error. | ||
| 775 | * @details A byte tier resolves when it matches in scope, passes the uniqueness gate when required, and yields an | ||
| 776 | * in-scope plausible address. A text tier resolves through its unique-only backend. After a full direct | ||
| 777 | * miss with a non-Off fallback_policy, the resolver rebuilds each Direct prologue as a near or far JMP. | ||
| 778 | * Only a unique match of the rebuilt shape resolves, regardless of require_unique. A byte tier whose own | ||
| 779 | * sweep was truncated (BudgetExceeded or IncompleteScan) preempts that recovery. A recovery sweep that | ||
| 780 | * skips a faulted region reports IncompleteScan, not a miss. | ||
| 781 | * | ||
| 782 | * A text tier failure does not preempt recovery. That failure is MalformedQueryText, or NotAuthoritative | ||
| 783 | * or IncompleteScan from its own readable sweep. If nothing resolves, the result reports it in place of | ||
| 784 | * the generic miss. A scope that fails the Readable authority rule of @ref Pages refuses the request | ||
| 785 | * before the resolver grades any candidate. An out-of-range pages, order, fallback_policy, or StringXref | ||
| 786 | * encoding or return mode returns @ref ErrorCode::InvalidArg. Only an allocation failure throws. | ||
| 787 | * @note Setup/control-plane only. | ||
| 788 | */ | ||
| 789 | [[nodiscard]] Result<Hit> resolve(const ScanRequest &request); | ||
| 790 | |||
| 791 | /** | ||
| 792 | * @brief Resolves a batch of requests concurrently and returns one Result per request in input order. | ||
| 793 | * @param max_workers Upper bound on worker threads, or 0 to select from the hardware concurrency. | ||
| 794 | * @return The outer Result fails with OutOfMemory if the result vector cannot be allocated, and with Unknown on any | ||
| 795 | * other whole-batch exception. A slot holds OutOfMemory after its own allocation failure, and NoMatch after | ||
| 796 | * any other exception. | ||
| 797 | * @note Setup/control-plane only: it spawns a worker pool and allocates. | ||
| 798 | */ | ||
| 799 | [[nodiscard]] Result<std::vector<Result<Hit>>> | ||
| 800 | resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers = 0) noexcept; | ||
| 801 | |||
| 802 | /** | ||
| 803 | * @brief Scans one Pattern over a known scope and returns the Nth match address. | ||
| 804 | * @return The @p occurrence-th (1-based) match, adjusted by the `|` offset, or an Error. 0 returns NoMatch. | ||
| 805 | * @details The sweep reads only committed pages of the @p pages class. It skips an unmapped or guard page without a | ||
| 806 | * host fault and still finds a match that straddles two adjacent accepted regions. Each call repeats its | ||
| 807 | * setup, which can query the loader for the authority check. For a cursor walk over a range that the | ||
| 808 | * caller proves readable, use @ref unchecked::find_pattern. | ||
| 809 | * | ||
| 810 | * @ref ErrorCode::NoMatch means the sweep read the whole scope and found fewer than @p occurrence matches. | ||
| 811 | * @ref ErrorCode::IncompleteScan means the sweep skipped a faulted region, so the pattern can live in | ||
| 812 | * unread bytes. @ref ErrorCode::BudgetExceeded means a bounded-jump pattern spent its backtracking budget. | ||
| 813 | * Neither truncation is a miss. An out-of-range @p pages returns @ref ErrorCode::InvalidArg before the | ||
| 814 | * sweep starts. A Readable scan follows the Readable authority rule of @ref Pages. | ||
| 815 | * @note Setup/control-plane only. An allocation failure while the scan is prepared returns OutOfMemory. | ||
| 816 | */ | ||
| 817 | [[nodiscard]] Result<Address> | ||
| 818 | scan(const Pattern &pattern, Region scope, std::size_t occurrence = 1, Pages pages = Pages::Readable) noexcept; | ||
| 819 | |||
| 820 | /** | ||
| 821 | * @brief The four-argument scan() that does not count a match that intersects one of @p exclusions. | ||
| 822 | * @param exclusions The live caller copies of the query bytes, as @ref ScanRequest::exclusions describes. | ||
| 823 | * @details DMK adds @p exclusions to its own query-storage exclusions. The combined set has 32 slots after touching | ||
| 824 | * spans merge. If the set cannot hold every span, the result is @ref ErrorCode::NotAuthoritative. | ||
| 825 | * @note Setup/control-plane only. | ||
| 826 | */ | ||
| 827 | [[nodiscard]] Result<Address> scan( | ||
| 828 | const Pattern &pattern, | ||
| 829 | Region scope, | ||
| 830 | std::span<const Region> exclusions, | ||
| 831 | std::size_t occurrence = 1, | ||
| 832 | Pages pages = Pages::Readable | ||
| 833 | ) noexcept; | ||
| 834 | |||
| 835 | /// Common x86-64 opcode prefixes, the bytes before the disp32 field, for find_and_resolve_rip_relative(). | ||
| 836 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RAX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x05}}; | ||
| 837 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RCX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x0D}}; | ||
| 838 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RDX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x15}}; | ||
| 839 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RBX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x1D}}; | ||
| 840 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RAX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x05}}; | ||
| 841 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RCX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x0D}}; | ||
| 842 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RDX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x15}}; | ||
| 843 | inline constexpr std::array<std::byte, 1> PREFIX_CALL_REL32 = {std::byte{0xE8}}; | ||
| 844 | inline constexpr std::array<std::byte, 1> PREFIX_JMP_REL32 = {std::byte{0xE9}}; | ||
| 845 | |||
| 846 | /** | ||
| 847 | * @brief Resolves `instruction + instruction_length + disp32` for an x86-64 RIP-relative instruction. | ||
| 848 | * @details An invalid layout returns InvalidArg before a read. A target that is not a plausible user-mode address | ||
| 849 | * returns ImplausibleTarget. The `FF 15` and `FF 25` shapes return the pointer slot. | ||
| 850 | * @note Callback-safe: a guarded read and pointer arithmetic, with no allocation. | ||
| 851 | */ | ||
| 852 | [[nodiscard]] Result<Address> | ||
| 853 | resolve_rip_relative(Address instruction, std::size_t displacement_offset, std::size_t instruction_length) noexcept; | ||
| 854 | |||
| 855 | /** | ||
| 856 | * @brief Scans @p search for @p opcode_prefix and resolves the disp32 after the first resolvable occurrence. | ||
| 857 | * @param instruction_length Total instruction length, at most 15. The disp32 immediately follows @p opcode_prefix. | ||
| 858 | * @details An occurrence is a decoy when its target is implausible or the first target byte is unreadable at scan | ||
| 859 | * time. The scan skips each decoy. After it exhausts the region, it reports the last concrete failure, | ||
| 860 | * for example @ref ErrorCode::ImplausibleTarget or @ref ErrorCode::UnreadableTarget. The `FF 15` and | ||
| 861 | * `FF 25` shapes return the pointer slot. An invalid layout returns @ref ErrorCode::InvalidArg before the | ||
| 862 | * sweep starts. | ||
| 863 | * @note The prefix search reads @p search unguarded, so it must be readable. While the memory cache runs, the | ||
| 864 | * one-byte @ref memory::is_readable target check can allocate a cache entry. A failed allocation leaves the | ||
| 865 | * result unchanged. | ||
| 866 | * @note Setup/control-plane only: the sweep cost scales with @p search. | ||
| 867 | */ | ||
| 868 | [[nodiscard]] Result<Address> find_and_resolve_rip_relative( | ||
| 869 | Region search, | ||
| 870 | std::span<const std::byte> opcode_prefix, | ||
| 871 | std::size_t instruction_length | ||
| 872 | ) noexcept; | ||
| 873 | |||
| 874 | /** | ||
| 875 | * @brief Cheap heuristic: tests whether @p addr looks like the first byte of a real function body. | ||
| 876 | * @return True when @p addr is non-null and its guarded byte is readable and not 0x00, 0xCC, 0xC2, or 0xC3. | ||
| 877 | * @details A jump opcode (0xE9, 0xEB, or the 0xFF of `FF 25`) passes, so a prologue that a hook rewrote passes. | ||
| 878 | * @note Callback-safe: a single guarded byte read, no allocation. | ||
| 879 | */ | ||
| 880 | [[nodiscard]] bool is_likely_function_prologue(Address addr) noexcept; | ||
| 881 | |||
| 882 | /** | ||
| 883 | * @brief An ASLR-insensitive fingerprint of a loaded module's PE timestamp, image size, and section-table layout. | ||
| 884 | * @warning This is layout identity, not content identity. It reads no section body, so code patched in place under | ||
| 885 | * equal headers keeps a bit-identical identity. Use @ref WinningEvidence to witness content. | ||
| 886 | */ | ||
| 887 | struct ImageIdentity | ||
| 888 | { | ||
| 889 | /// PE @c IMAGE_FILE_HEADER::TimeDateStamp of the resolved image (0 when the read failed). | ||
| 890 | 482 | std::uint32_t timestamp = 0; | |
| 891 | /// PE @c IMAGE_OPTIONAL_HEADER::SizeOfImage of the resolved image (0 when the read failed). | ||
| 892 | 480 | std::uint32_t size_of_image = 0; | |
| 893 | /// A fold of every section header's name, RVA, virtual size, and characteristics. | ||
| 894 | 480 | std::uint64_t section_digest = 0; | |
| 895 | |||
| 896 | /// True when a live image was read. | ||
| 897 | 749 | [[nodiscard]] constexpr bool present() const noexcept { return size_of_image != 0; } | |
| 898 | |||
| 899 | /// A 64-bit token that folds all three fields, for a fingerprint or an equality key. | ||
| 900 | 15 | [[nodiscard]] constexpr std::uint64_t token() const noexcept | |
| 901 | { | ||
| 902 | 15 | std::uint64_t seed = section_digest; | |
| 903 | 15 | seed ^= static_cast<std::uint64_t>(timestamp) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2); | |
| 904 | 15 | seed ^= static_cast<std::uint64_t>(size_of_image) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2); | |
| 905 | 15 | return seed; | |
| 906 | } | ||
| 907 | |||
| 908 | /// Value equality across all three fields. | ||
| 909 | 482 | [[nodiscard]] constexpr bool operator==(const ImageIdentity &other) const noexcept = default; | |
| 910 | }; | ||
| 911 | |||
| 912 | /** | ||
| 913 | * @brief Reads the @ref ImageIdentity of the module mapped at @p range.base. The extent comes from its PE headers. | ||
| 914 | * @return The identity, or an absent value when @p range is empty or its PE headers do not validate completely. | ||
| 915 | * @note Callback-safe with an explicit Region: guarded PE header reads, with no allocation or loader call. The | ||
| 916 | * default scope query is setup/control-plane only. | ||
| 917 | */ | ||
| 918 | [[nodiscard]] ImageIdentity image_identity(Region range = Region::host()) noexcept; | ||
| 919 | |||
| 920 | /** | ||
| 921 | * @brief Flattens a resolve Result to its address, or a null Address on failure. | ||
| 922 | * @note Callback-safe: a pure Result read. | ||
| 923 | */ | ||
| 924 | 2 | [[nodiscard]] inline Address or_null(const Result<Hit> &result) noexcept | |
| 925 | { | ||
| 926 | 2 | return result ? result->address : Address{}; | |
| 927 | } | ||
| 928 | |||
| 929 | /** | ||
| 930 | * @brief Flattens a resolve Result to its address, or @p fallback on failure. | ||
| 931 | * @note Callback-safe: a pure Result read. | ||
| 932 | */ | ||
| 933 | 3 | [[nodiscard]] inline Address address_or(const Result<Hit> &result, Address fallback = Address{}) noexcept | |
| 934 | { | ||
| 935 | 3 | return result ? result->address : fallback; | |
| 936 | } | ||
| 937 | |||
| 938 | namespace unchecked | ||
| 939 | { | ||
| 940 | /** | ||
| 941 | * @brief Raw single-pattern scan with no page filter. An unreadable byte in @p region faults the host. | ||
| 942 | * @return The @p occurrence-th (1-based) match, adjusted by the `|` offset. 0, a miss, and a pattern allocation | ||
| 943 | * failure return nullptr. | ||
| 944 | * @note Setup/control-plane only: each call copies @p pattern into two heap buffers, or three with bounded | ||
| 945 | * jumps, and restarts from `region.base`. For a pattern whose `offset()` is zero, a cursor walk passes | ||
| 946 | * occurrence 1 and advances `region.base` past each hit. For a nonzero offset, raise only @p occurrence. | ||
| 947 | * Proof: `ScannerUncheckedAllocationTest.CursorWalkFindsEveryNeedleAtAFixedPerCallCost`. | ||
| 948 | */ | ||
| 949 | [[nodiscard]] const std::byte * | ||
| 950 | find_pattern(Region region, const Pattern &pattern, std::size_t occurrence = 1) noexcept; | ||
| 951 | } // namespace unchecked | ||
| 952 | |||
| 953 | } // namespace DetourModKit::scan | ||
| 954 | |||
| 955 | #endif // DETOURMODKIT_SCAN_HPP | ||
| 956 |