GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 96.1% 98 / 0 / 102
Functions: 100.0% 3 / 0 / 3
Branches: 76.7% 89 / 0 / 116

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