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