./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
252 / 268
94.0%
Branches:
179 / 237
75.5%
Functions:
22 / 22
100.0%

src/internal/scan_pages.cpp
Line Branch Exec Source
1 /**
2 * @file internal/scan_pages.cpp
3 * @brief Page-gated AOB scanning: the VirtualQuery region walk, the per-region TOCTOU fault guard, the committed-window
4 * collector, and the executable-address / executable-range predicates.
5 * @details Wraps the raw matcher in the OS page map so a scan over arbitrary process memory reads only committed pages
6 * of the requested protection class. Incomplete-scan state rides on the MatchResult return value rather than a
7 * thread-local side channel, so concurrent scans cannot clobber each other's fault state. The Windows
8 * page-protection masks stay private to this TU.
9 */
10
11 #include "internal/scan_pages.hpp"
12
13 #include "DetourModKit/diagnostics.hpp"
14 #include "DetourModKit/logger.hpp"
15 #include "DetourModKit/memory.hpp"
16
17 #include "internal/memory_fault.hpp"
18 #include "internal/scan_fault_seam.hpp"
19
20 #include <windows.h>
21 #if defined(_MSC_VER)
22 #include <intrin.h> // __movsb: forward, ASan-safe foreign-memory copy
23 #endif
24
25 #include <cassert>
26 #include <cstddef>
27 #include <cstdint>
28 #include <cstring>
29 #include <limits>
30 #include <vector>
31
32 namespace DetourModKit
33 {
34 namespace
35 {
36 // The two exclusion sets a match is tested against: the engine's unconditional guarantee that a scan never
37 // returns the compiled pattern's own buffers, and whatever query storage the caller declared. Keeping them
38 // separate lets the floor guarantee hold even for a caller that passes no exclusions at all.
39 struct ExclusionSet
40 {
41 const detail::ScanExclusions &engine;
42 const detail::ScanExclusions *caller;
43
44 1228 [[nodiscard]] bool excludes(std::uintptr_t lo, std::uintptr_t hi) const noexcept
45 {
46
6/6
✓ Branch 3 → 4 taken 1218 times.
✓ Branch 3 → 7 taken 10 times.
✓ Branch 4 → 5 taken 1096 times.
✓ Branch 4 → 8 taken 122 times.
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 8 taken 1086 times.
1228 return engine.overlaps(lo, hi) || (caller != nullptr && caller->overlaps(lo, hi));
47 }
48 };
49
50 // Running occurrence state for one page walk. Shared across regions so the Nth match and the (N+1)th detection
51 // come from a single traversal.
52 struct ScanTally
53 {
54 std::size_t seen = 0;
55 const std::byte *nth_point = nullptr;
56 Region nth_span{};
57 scan::WinningEvidence nth_evidence{};
58 detail::InstructionSnapshot nth_instruction{};
59 };
60
61 // MSVC ASan intercepts libc copies from this process's foreign/poisoned memory. Keep the copy inline so a valid
62 // scan of instrumented storage does not become a false overflow report.
63 369 void copy_foreign_bytes(std::byte *destination, const std::byte *source, std::size_t size) noexcept
64 {
65 #if defined(_MSC_VER) && defined(__SANITIZE_ADDRESS__)
66 __movsb(
67 reinterpret_cast<unsigned char *>(destination),
68 reinterpret_cast<const unsigned char *>(source),
69 size
70 );
71 #else
72 369 std::memcpy(destination, source, size);
73 #endif
74 369 }
75
76 // Copy the literal bytes of [start, end) out of the live image. Called only from inside the TOCTOU fault guard,
77 // while the region is still proven readable: the match pointers must not outlive that window, so the bytes are
78 // taken as a value here rather than re-read later from a span that a concurrent unmap may have invalidated.
79 // An over-long span yields truncated evidence with no bytes at all, because a captured prefix would compare
80 // equal against a prefix baseline and quietly authorize a mutation on partial evidence.
81 [[nodiscard]] scan::WinningEvidence
82 345 capture_winning_evidence(const std::byte *start, const std::byte *end) noexcept
83 {
84 345 scan::WinningEvidence evidence{};
85
4/6
✓ Branch 2 → 3 taken 344 times.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 3 → 4 taken 344 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 344 times.
345 if (start == nullptr || end == nullptr || end <= start)
86 {
87 1 return evidence;
88 }
89 344 const auto span_length = static_cast<std::size_t>(end - start);
90
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 340 times.
344 if (span_length > scan::MAX_MUTATION_WITNESS_BYTES)
91 {
92 4 evidence.truncated = true;
93 4 return evidence;
94 }
95 340 copy_foreign_bytes(evidence.bytes.data(), start, span_length);
96 340 evidence.length = static_cast<std::uint16_t>(span_length);
97 340 return evidence;
98 }
99
100 27 [[nodiscard]] detail::InstructionSnapshot capture_instruction_snapshot(
101 const std::byte *start,
102 const std::byte *end,
103 const std::byte *point,
104 std::uintptr_t capture_limit,
105 std::uint8_t requested_length,
106 const scan::WinningEvidence &evidence
107 ) noexcept
108 {
109 27 detail::InstructionSnapshot snapshot{};
110
4/8
✓ Branch 2 → 3 taken 27 times.
✗ Branch 2 → 9 not taken.
✓ Branch 3 → 4 taken 27 times.
✗ Branch 3 → 9 not taken.
✓ Branch 4 → 5 taken 27 times.
✗ Branch 4 → 9 not taken.
✓ Branch 5 → 6 taken 27 times.
✗ Branch 5 → 9 not taken.
27 if (requested_length == 0 || requested_length > scan::MAX_X86_INSTRUCTION_LENGTH || start == nullptr ||
111
3/6
✓ Branch 6 → 7 taken 27 times.
✗ Branch 6 → 9 not taken.
✓ Branch 7 → 8 taken 27 times.
✗ Branch 7 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 27 times.
27 end == nullptr || point == nullptr || point < start || point > end)
112 {
113 ✗ return snapshot;
114 }
115 27 const auto point_offset = static_cast<std::size_t>(point - start);
116 27 const auto span_length = static_cast<std::size_t>(end - start);
117 27 const std::uintptr_t point_address = reinterpret_cast<std::uintptr_t>(point);
118
2/4
✓ Branch 10 → 11 taken 27 times.
✗ Branch 10 → 13 not taken.
✓ Branch 11 → 12 taken 27 times.
✗ Branch 11 → 13 not taken.
27 if (point_offset > span_length || point_address >= capture_limit ||
119
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 26 times.
27 requested_length > capture_limit - point_address)
120 {
121 1 return snapshot;
122 }
123
124
2/2
✓ Branch 15 → 16 taken 25 times.
✓ Branch 15 → 24 taken 1 time.
26 if (evidence.present())
125 {
126 25 const std::size_t evidenced_length = span_length - point_offset;
127 25 const std::size_t copy_length =
128 25 requested_length < evidenced_length ? requested_length : evidenced_length;
129 50 copy_foreign_bytes(snapshot.bytes.data(), evidence.bytes.data() + point_offset, copy_length);
130
2/2
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 27 taken 22 times.
25 if (copy_length < requested_length)
131 {
132 6 copy_foreign_bytes(
133 3 snapshot.bytes.data() + copy_length,
134 point + copy_length,
135 3 requested_length - copy_length
136 );
137 }
138 }
139 else
140 {
141 2 copy_foreign_bytes(snapshot.bytes.data(), point, requested_length);
142 }
143 25 snapshot.length = requested_length;
144 25 return snapshot;
145 }
146
147 // Scan one protection-gated region, tallying every counted, non-excluded match. Returns true once the tally
148 // reaches @p cap, which tells the caller to stop walking. This is the body the TOCTOU fault guard wraps (see
149 // scan_region_guarded): it performs the unguarded find_pattern_raw reads across [region_start, +scan_size).
150 //
151 // count_floor is the address below which matches were already tallied by an earlier region in this contiguous
152 // accepted run. When a region is back-extended over a protection split, it re-reads the tail of the previous
153 // region to catch a match straddling the boundary; a match that ended inside that tail (end <= count_floor) was
154 // already counted there, so it is skipped here. Comparing the match's true end (RawMatch::end), not a fixed
155 // pattern length, is what keeps this correct for a variable-length bounded-jump match, where a fixed-length
156 // overlap would double-count a short match near the boundary.
157 28634 bool scan_region_for_match(
158 const std::byte *region_start,
159 std::size_t scan_size,
160 const detail::EnginePattern &pattern,
161 const ExclusionSet &exclusions,
162 std::uintptr_t count_floor,
163 std::size_t target,
164 std::size_t cap,
165 bool capture,
166 std::uintptr_t snapshot_limit,
167 std::uint8_t instruction_snapshot_length,
168 ScanTally &tally,
169 bool &out_budget_exhausted
170 ) noexcept
171 {
172 #if defined(DMK_ENABLE_TEST_SEAMS)
173 // Inside the guard's frame, so an armed address that the declared span does not cover proves the guard
174 // screens the faulting address rather than only the exception class.
175 28634 detail::fire_scan_fault_seam_for_test(detail::g_scan_region_fault_for_test);
176 #endif
177 // One SegmentedScanBudget spans every suffix call of this region. RawMatch owns the truncation contract.
178 28634 detail::SegmentedScanBudget segmented_budget{};
179 28634 detail::RawMatch match = detail::find_pattern_raw(region_start, scan_size, pattern, &segmented_budget);
180 23680 out_budget_exhausted = match.budget_exhausted;
181
2/2
✓ Branch 25 → 5 taken 1228 times.
✓ Branch 25 → 26 taken 23627 times.
24855 while (match.start != nullptr)
182 {
183 1228 const auto match_addr = reinterpret_cast<std::uintptr_t>(match.start);
184 1228 const auto match_end = reinterpret_cast<std::uintptr_t>(match.end);
185 // The match spans [match_addr, match_end); using the true end (not a fixed pattern length) keeps both
186 // the exclusion test and the boundary de-duplication exact for a bounded-jump match.
187 1228 const bool excluded = exclusions.excludes(match_addr, match_end);
188 1228 const bool already_counted = match_end <= count_floor;
189
3/4
✓ Branch 6 → 7 taken 1208 times.
✓ Branch 6 → 17 taken 20 times.
✓ Branch 7 → 8 taken 1208 times.
✗ Branch 7 → 17 not taken.
1228 if (!excluded && !already_counted)
190 {
191 1208 ++tally.seen;
192
2/2
✓ Branch 8 → 9 taken 1133 times.
✓ Branch 8 → 15 taken 75 times.
1208 if (tally.seen == target)
193 {
194 1133 tally.nth_point = match.point;
195 1133 const std::uintptr_t span_start = reinterpret_cast<std::uintptr_t>(match.start);
196 1133 const std::uintptr_t span_end = reinterpret_cast<std::uintptr_t>(match.end);
197 1133 tally.nth_span = Region{Address{span_start}, static_cast<std::size_t>(span_end - span_start)};
198
2/2
✓ Branch 10 → 11 taken 344 times.
✓ Branch 10 → 13 taken 788 times.
1132 if (capture)
199 {
200 344 tally.nth_evidence = capture_winning_evidence(match.start, match.end);
201 }
202
2/2
✓ Branch 13 → 14 taken 27 times.
✓ Branch 13 → 15 taken 1105 times.
1132 if (instruction_snapshot_length != 0)
203 {
204 27 tally.nth_instruction = capture_instruction_snapshot(
205 match.start,
206 match.end,
207 match.point,
208 snapshot_limit,
209 instruction_snapshot_length,
210 27 tally.nth_evidence
211 );
212 }
213 }
214
2/2
✓ Branch 15 → 16 taken 52 times.
✓ Branch 15 → 17 taken 1155 times.
1207 if (tally.seen >= cap)
215 {
216 52 return true;
217 }
218 }
219
220 // Continue scanning past the current match START (not its variable end).
221 1175 const std::size_t consumed = static_cast<std::size_t>(match.start - region_start) + 1;
222
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 1175 times.
1175 if (consumed >= scan_size)
223 {
224 ✗ break;
225 }
226 1175 match = detail::find_pattern_raw(match.start + 1, scan_size - consumed, pattern, &segmented_budget);
227
2/4
✓ Branch 20 → 21 taken 1175 times.
✗ Branch 20 → 22 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 1175 times.
1175 out_budget_exhausted = out_budget_exhausted || match.budget_exhausted;
228 }
229 23627 return false;
230 }
231
232 // Region-granular TOCTOU fault guard around scan_region_for_match. The caller's per-region VirtualQuery only
233 // proves the region was committed and readable at gate time; a concurrent decommit / reprotect before these
234 // unguarded reads complete would otherwise fault the host. Both arms claim a fault only inside the exact span
235 // this sweep is permitted to read, [span_lo, capture_limit): on MSVC through
236 // detail::guarded_range_fault_filter, on MinGW x64 through the same process-wide vectored read guard the
237 // guarded_read paths use, armed over that span. An access-class fault OUTSIDE it is an unrelated defect rather
238 // than the concurrent unmap this guard exists to absorb, so it reaches the host's handlers instead of being
239 // recorded as a faulted region. A 32-bit build is rejected outright by the architecture gate in defines.hpp,
240 // so only these two x64 arms exist.
241 28634 bool scan_region_guarded(
242 const std::byte *region_start,
243 std::size_t scan_size,
244 const detail::EnginePattern &pattern,
245 const ExclusionSet &exclusions,
246 std::uintptr_t count_floor,
247 std::size_t target,
248 std::size_t cap,
249 bool capture,
250 std::uintptr_t snapshot_limit,
251 std::uint8_t instruction_snapshot_length,
252 ScanTally &tally,
253 bool &out_faulted,
254 bool &out_budget_exhausted
255 ) noexcept
256 {
257 28634 out_faulted = false;
258 28634 const std::uintptr_t span_lo = reinterpret_cast<std::uintptr_t>(region_start);
259 28634 const std::uintptr_t scan_hi = span_lo + scan_size;
260 28634 std::uintptr_t capture_limit = scan_hi;
261
4/4
✓ Branch 2 → 3 taken 27 times.
✓ Branch 2 → 5 taken 28607 times.
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 21 times.
28634 if (instruction_snapshot_length != 0 && capture_limit < snapshot_limit)
262 {
263 6 const std::uintptr_t available = snapshot_limit - capture_limit;
264 6 const std::uintptr_t extension =
265 6 instruction_snapshot_length < available ? instruction_snapshot_length : available;
266 6 capture_limit += extension;
267 }
268 // A faulted region is treated as skipped, not partially scanned: matches observed before the fault cannot
269 // be trusted for occurrence accounting because unreadable tail bytes may hide additional matches. The skip
270 // already forces the scan incomplete, so any partial budget-exhaustion state from the aborted sweep is
271 // moot and is cleared so it is not double-counted.
272 28634 const ScanTally original_tally = tally;
273 #ifdef _MSC_VER
274 __try
275 {
276 return scan_region_for_match(
277 region_start,
278 scan_size,
279 pattern,
280 exclusions,
281 count_floor,
282 target,
283 cap,
284 capture,
285 capture_limit,
286 instruction_snapshot_length,
287 tally,
288 out_budget_exhausted
289 );
290 }
291 __except (detail::guarded_range_fault_filter(GetExceptionInformation(), span_lo, capture_limit))
292 {
293 tally = original_tally;
294 out_faulted = true;
295 out_budget_exhausted = false;
296 return false;
297 }
298 #elif defined(_WIN64)
299 // MinGW x64: route the sweep through the same vectored fault guard as the foreign-read primitives. The
300 // armed range includes the pattern window plus the bounded, scope-clamped instruction-snapshot tail.
301 struct ScanContext
302 {
303 const std::byte *region_start;
304 std::size_t scan_size;
305 const detail::EnginePattern *pattern;
306 const ExclusionSet *exclusions;
307 std::uintptr_t count_floor;
308 std::size_t target;
309 std::size_t cap;
310 bool capture;
311 std::uintptr_t snapshot_limit;
312 std::uint8_t instruction_snapshot_length;
313 ScanTally *tally;
314 bool *budget_exhausted;
315 bool cap_reached;
316 28634 } scan_ctx{
317 region_start,
318 scan_size,
319 &pattern,
320 &exclusions,
321 count_floor,
322 target,
323 cap,
324 capture,
325 capture_limit,
326 instruction_snapshot_length,
327 &tally,
328 &out_budget_exhausted,
329 false
330 28634 };
331
332 28634 const auto run_scan = [](void *opaque) noexcept -> void
333 {
334 28634 auto *context = static_cast<ScanContext *>(opaque);
335 52313 context->cap_reached = scan_region_for_match(
336 context->region_start,
337 context->scan_size,
338 28634 *context->pattern,
339 28634 *context->exclusions,
340 context->count_floor,
341 context->target,
342 context->cap,
343 28634 context->capture,
344 context->snapshot_limit,
345 28634 context->instruction_snapshot_length,
346 28634 *context->tally,
347 28634 *context->budget_exhausted
348 );
349 23679 };
350
351
2/2
✓ Branch 7 → 8 taken 23679 times.
✓ Branch 7 → 9 taken 4955 times.
28634 if (detail::run_guarded_region(span_lo, capture_limit, run_scan, &scan_ctx))
352 {
353 23679 return scan_ctx.cap_reached;
354 }
355 4955 tally = original_tally;
356 4955 out_faulted = true;
357 4955 out_budget_exhausted = false;
358 4955 return false;
359 #endif
360 }
361
362 /**
363 * @brief Adds to @p engine_owned the engine buffers that a sweep must never match.
364 * @details The compiled pattern keeps its byte and mask buffers in readable heap memory. Without these
365 * exclusions, a readable sweep matches the pattern against its own buffers and can return the query
366 * storage instead of the intended target. This floor guarantee holds whatever the caller declares.
367 * Caller-owned copies of the query belong in query.exclusions on top of it.
368 *
369 * When the query captures evidence or an instruction snapshot, the corresponding @p tally buffer
370 * joins the floor for the same reason. Each buffer holds a verbatim copy of match bytes, so after one
371 * capture the sweep can count that copy as another occurrence. The exclusion ranges record buffer
372 * addresses, so @p tally must keep its address for the whole sweep.
373 */
374 6226 void add_engine_owned_exclusions(
375 detail::ScanExclusions &engine_owned,
376 const detail::EnginePattern &pattern,
377 const detail::ScanQuery &query,
378 const ScanTally &tally
379 ) noexcept
380 {
381 6226 detail::add_engine_pattern_storage(engine_owned, pattern);
382
2/2
✓ Branch 3 → 4 taken 392 times.
✓ Branch 3 → 8 taken 5834 times.
6226 if (query.capture_evidence)
383 {
384 784 engine_owned.add(
385 392 reinterpret_cast<std::uintptr_t>(tally.nth_evidence.bytes.data()),
386 tally.nth_evidence.bytes.size()
387 );
388 }
389
2/2
✓ Branch 8 → 9 taken 27 times.
✓ Branch 8 → 13 taken 6199 times.
6226 if (query.instruction_snapshot_length != 0)
390 {
391 54 engine_owned.add(
392 27 reinterpret_cast<std::uintptr_t>(tally.nth_instruction.bytes.data()),
393 tally.nth_instruction.bytes.size()
394 );
395 }
396 6226 }
397
398 void
399 4955 report_faulted_regions(std::size_t faulted_regions, std::uintptr_t window_lo, std::uintptr_t window_hi) noexcept
400 {
401 try
402 {
403 4955 (void)log().try_log(
404 LogLevel::Debug,
405 "Scanner: skipped {} region(s) that faulted mid-scan (concurrent decommit/reprotect).",
406 faulted_regions
407 );
408 }
409 catch (...)
410 {
411 }
412
413 // The dispatcher is lazy and can allocate on first use. A failed allocation must never change the sweep
414 // result.
415 try
416 {
417
1/2
✓ Branch 4 → 5 taken 4955 times.
✗ Branch 4 → 8 not taken.
4955 diagnostics::scanner_faults().emit_safe(
418 4955 diagnostics::ScannerFaultEvent{
419 .faulted_regions = faulted_regions,
420 .window_low = window_lo,
421 .window_high = window_hi,
422 }
423 );
424 }
425 ✗ catch (...)
426 {
427 ✗ }
428 4955 }
429
430 [[nodiscard]] detail::MatchResult
431 6226 build_sweep_result(const ScanTally &tally, std::size_t faulted_regions, bool budget_exhausted_total) noexcept
432 {
433 return detail::MatchResult{
434 6226 tally.nth_point,
435 tally.nth_span,
436 tally.nth_evidence,
437 tally.nth_instruction,
438 6226 tally.seen,
439 6226 faulted_regions > 0,
440 budget_exhausted_total
441 6226 };
442 }
443
444 // Region-walking AOB scan shared by the whole-process and module-scoped entry points. Walks the committed
445 // regions of [window_lo, window_hi) via VirtualQuery and runs the per-region scan (behind the fault guard)
446 // against every region whose base protection is present in accept_mask. The whole-process scanners pass
447 // [0, UINTPTR_MAX); the module-scoped scan passes the image's [base, end).
448 //
449 // Guard, no-access, and uncommitted regions are always skipped: PAGE_GUARD raises STATUS_GUARD_PAGE_VIOLATION
450 // on the first touch and PAGE_NOACCESS faults even for reads, so neither is safe to dereference. The Windows
451 // base protections are mutually exclusive single bits, so a bitwise-AND against a mask of the acceptable bases
452 // is a sound membership test. PAGE_GUARD is a modifier bit OR-ed onto a base value, so it must be excluded
453 // separately or it would satisfy the mask and be scanned.
454 //
455 // A signature can straddle a protection split: two adjacent accepted regions VirtualQuery reports separately
456 // because their base protections differ (a sibling VirtualProtect carving part of .text into
457 // PAGE_EXECUTE_READWRITE is the canonical case). To catch such a match, each accepted region's scan is
458 // extended back by up to max_match_length() - 1 bytes into the contiguous run of already-accepted regions it
459 // abuts, bounded by the run start so it never reads past the bytes the per-region gate proved readable. A
460 // match wholly inside the previous region is not re-counted: the region's true start is passed as a count
461 // floor, and only a match whose end reaches past it is counted. The floor, not the carry width, is what
462 // prevents a double count, which is why a variable-length bounded-jump match stays correctly counted across
463 // the split.
464 6226 detail::MatchResult scan_regions_filtered(
465 const detail::EnginePattern &pattern,
466 const detail::ScanQuery &query,
467 DWORD accept_mask,
468 std::uintptr_t window_lo,
469 std::uintptr_t window_hi
470 ) noexcept
471 {
472 6226 ScanTally tally;
473
474 6226 detail::ScanExclusions engine_owned;
475 6226 add_engine_owned_exclusions(engine_owned, pattern, query, tally);
476 6226 const ExclusionSet exclusions{engine_owned, query.exclusions};
477
478 6226 const std::size_t target = query.occurrence;
479 const std::size_t cap =
480
4/4
✓ Branch 3 → 4 taken 1158 times.
✓ Branch 3 → 7 taken 5068 times.
✓ Branch 5 → 6 taken 1157 times.
✓ Branch 5 → 7 taken 1 time.
6226 query.count_beyond && target != std::numeric_limits<std::size_t>::max() ? target + 1 : target;
481
482 6226 std::size_t faulted_regions = 0;
483 6226 bool budget_exhausted_total = false;
484 6226 bool cap_reached = false;
485 6226 MEMORY_BASIC_INFORMATION mbi{};
486 6226 std::uintptr_t addr = window_lo;
487
488 // Contiguous-accepted-run tracking for the cross-boundary overlap (see the function comment).
489 // prev_accept_hi is the end of the previous accepted region; run_lo is the start of the run of contiguous
490 // accepted regions the current region belongs to. A gap (a skipped, guarded, or non-readable region) breaks
491 // the run because the bytes across it are not proven readable.
492 6226 bool prev_accepted = false;
493 6226 std::uintptr_t prev_accept_hi = 0;
494 6226 std::uintptr_t run_lo = 0;
495
496
8/8
✓ Branch 47 → 48 taken 46391 times.
✓ Branch 47 → 52 taken 52 times.
✓ Branch 48 → 49 taken 40233 times.
✓ Branch 48 → 52 taken 6158 times.
✓ Branch 50 → 51 taken 40217 times.
✓ Branch 50 → 52 taken 16 times.
✓ Branch 53 → 9 taken 40217 times.
✓ Branch 53 → 54 taken 6226 times.
46443 while (!cap_reached && addr < window_hi && VirtualQuery(reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi)))
497 {
498 40217 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
499 40217 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
500 40217 const std::uintptr_t region_end = region_base + mbi.RegionSize;
501
502 // Clamp the region to the requested window so a region that straddles window_lo / window_hi is
503 // inspected only where it intersects. For a whole-process sweep the clamp is a no-op; for a
504 // module-scoped sweep this is what keeps the scan inside [base, end) even when a VirtualQuery region
505 // extends past it.
506
2/2
✓ Branch 9 → 10 taken 55 times.
✓ Branch 9 → 11 taken 40162 times.
40217 const std::uintptr_t scan_lo = region_base < window_lo ? window_lo : region_base;
507
2/2
✓ Branch 12 → 13 taken 56 times.
✓ Branch 12 → 14 taken 40161 times.
40217 const std::uintptr_t scan_hi = region_end > window_hi ? window_hi : region_end;
508
509
7/8
✓ Branch 15 → 16 taken 36530 times.
✓ Branch 15 → 40 taken 3687 times.
✓ Branch 16 → 17 taken 28744 times.
✓ Branch 16 → 40 taken 7786 times.
✓ Branch 17 → 18 taken 28634 times.
✓ Branch 17 → 40 taken 110 times.
✓ Branch 18 → 19 taken 28634 times.
✗ Branch 18 → 40 not taken.
40217 if (mbi.State == MEM_COMMIT && (mbi.Protect & accept_mask) != 0 && !protection_unsafe &&
510 scan_hi > scan_lo)
511 {
512 // Continue the accepted run only when this region begins exactly where the previous accepted one
513 // ended; otherwise restart it here. Done before computing the overlap so run_lo reflects the run
514 // scan_lo joins.
515
3/4
✓ Branch 19 → 20 taken 20715 times.
✓ Branch 19 → 21 taken 7919 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 20715 times.
28634 if (!prev_accepted || prev_accept_hi != scan_lo)
516 {
517 7919 run_lo = scan_lo;
518 }
519
520 28634 std::uintptr_t effective_scan_lo = scan_lo;
521 28634 const std::size_t match_span = pattern.max_match_length();
522
3/4
✓ Branch 23 → 24 taken 28634 times.
✗ Branch 23 → 29 not taken.
✓ Branch 24 → 25 taken 20715 times.
✓ Branch 24 → 29 taken 7919 times.
28634 if (match_span > 1 && scan_lo > run_lo)
523 {
524 20715 const std::uintptr_t max_overlap = static_cast<std::uintptr_t>(match_span - 1);
525 20715 const std::uintptr_t available = scan_lo - run_lo;
526
1/2
✓ Branch 25 → 26 taken 20715 times.
✗ Branch 25 → 27 not taken.
20715 effective_scan_lo = scan_lo - ((max_overlap < available) ? max_overlap : available);
527 }
528
529 28634 const std::size_t scan_size = static_cast<std::size_t>(scan_hi - effective_scan_lo);
530 28634 bool region_faulted = false;
531
1/2
✓ Branch 30 → 31 taken 28634 times.
✗ Branch 30 → 39 not taken.
28634 if (scan_size >= pattern.size())
532 {
533 28634 const auto *region_start = reinterpret_cast<const std::byte *>(effective_scan_lo);
534
535 // The protection gate above proved the region readable at gate time; scan_region_guarded
536 // backstops a concurrent decommit / reprotect that could fault the read after the gate. scan_lo
537 // is the count floor: matches that ended before it were already tallied by the previous region.
538 28634 bool region_budget_exhausted = false;
539 28634 cap_reached = scan_region_guarded(
540 region_start,
541 scan_size,
542 pattern,
543 exclusions,
544 scan_lo,
545 target,
546 cap,
547 28634 query.capture_evidence,
548 window_hi,
549 28634 query.instruction_snapshot_length,
550 tally,
551 region_faulted,
552 region_budget_exhausted
553 );
554 // A spent bounded-jump backtracking budget makes any occurrence count a lower bound, exactly
555 // like a skipped faulted region, so it feeds the same incomplete signal.
556
3/4
✓ Branch 32 → 33 taken 28634 times.
✗ Branch 32 → 34 not taken.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 35 taken 28630 times.
28634 budget_exhausted_total = budget_exhausted_total || region_budget_exhausted;
557
2/2
✓ Branch 36 → 37 taken 4955 times.
✓ Branch 36 → 38 taken 23679 times.
28634 if (region_faulted)
558 {
559 4955 ++faulted_regions;
560 }
561 }
562
563 // A faulted region ends the run as surely as a gap does. Its bytes were abandoned mid-read, so the
564 // next region must not back-extend into them: that overlap would fault as well and cost a second,
565 // fully readable region its entire sweep.
566 28634 prev_accepted = !region_faulted;
567 28634 prev_accept_hi = scan_hi;
568 28634 }
569 else
570 {
571 11583 prev_accepted = false;
572 }
573
574
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 40217 times.
40217 assert(region_end > addr && "VirtualQuery returned a non-advancing region");
575
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 40217 times.
40217 if (region_end <= addr)
576 {
577 ✗ break; // Overflow guard.
578 }
579 40217 addr = region_end;
580 }
581
582
2/2
✓ Branch 54 → 55 taken 4955 times.
✓ Branch 54 → 56 taken 1271 times.
6226 if (faulted_regions != 0)
583 {
584 // This report is best-effort diagnosis only. The sweep already skipped each faulted region and
585 // continued. The incomplete flag that build_sweep_result sets from faulted_regions makes the caller
586 // fail closed on the skipped bytes.
587 4955 report_faulted_regions(faulted_regions, window_lo, window_hi);
588 }
589 6226 return build_sweep_result(tally, faulted_regions, budget_exhausted_total);
590 }
591
592 // Base protections accepted by the executable-only sweeps: the three page variants that grant execute *and*
593 // read. Bare PAGE_EXECUTE (execute without a read bit) is excluded because dereferencing it raises an access
594 // violation; PAGE_GUARD / PAGE_NOACCESS are filtered separately inside scan_regions_filtered.
595 constexpr DWORD EXECUTABLE_PAGE_FLAGS = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
596
597 // Base protections accepted by the readable sweep: the executable-readable set plus the non-executable
598 // readable pages (.rdata / .data and read-only heaps). This reaches C++ vtables, RTTI type descriptors, and
599 // other read-only metadata the executable-only sweep cannot see.
600 constexpr DWORD READABLE_PAGE_FLAGS = EXECUTABLE_PAGE_FLAGS | PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY;
601
602 // Shared precondition for every page-gated entry point.
603 [[nodiscard]] bool
604 6230 query_is_scannable(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
605 {
606
4/4
✓ Branch 3 → 4 taken 6228 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 4 → 5 taken 6226 times.
✓ Branch 4 → 6 taken 2 times.
6230 return !pattern.empty() && query.occurrence != 0;
607 }
608
609 // Region walks a scope may cross while still counting as one caller-named allocation. A scope that needs more
610 // than this is not something the caller enumerated; it is a sweep of whatever happens to be mapped.
611 constexpr std::size_t MAX_CONFINED_REGIONS = 64;
612
613 // True when [range.base, range.end) lies inside a single reserved allocation. VirtualAlloc hands out one
614 // AllocationBase per reservation and VirtualQuery splits it into regions as protections diverge, so a constant
615 // AllocationBase across the walk is exactly "the caller named one buffer". A whole-process window fails on the
616 // first region boundary that changes it.
617 1010 [[nodiscard]] bool span_is_single_allocation(detail::ModuleSpan range) noexcept
618 {
619 1010 MEMORY_BASIC_INFORMATION mbi{};
620
3/4
✓ Branch 3 → 4 taken 1010 times.
✗ Branch 3 → 5 not taken.
✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 1005 times.
2020 if (VirtualQuery(reinterpret_cast<LPCVOID>(range.base), &mbi, sizeof(mbi)) == 0 ||
621
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 6 taken 1004 times.
1010 mbi.AllocationBase == nullptr)
622 {
623 5 return false;
624 }
625 1005 const LPVOID allocation_base = mbi.AllocationBase;
626
627 1005 std::uintptr_t cursor = range.base;
628
1/2
✓ Branch 25 → 10 taken 2328 times.
✗ Branch 25 → 26 not taken.
2327 for (std::size_t visited = 0; visited < MAX_CONFINED_REGIONS; ++visited)
629 {
630
2/4
✓ Branch 11 → 12 taken 2328 times.
✗ Branch 11 → 13 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 2328 times.
4656 if (VirtualQuery(reinterpret_cast<LPCVOID>(cursor), &mbi, sizeof(mbi)) == 0 ||
631
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 2328 times.
2328 mbi.AllocationBase != allocation_base)
632 {
633 ✗ return false;
634 }
635 2328 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
636
3/4
✓ Branch 17 → 18 taken 2328 times.
✗ Branch 17 → 19 not taken.
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 2327 times.
2328 if (mbi.RegionSize == 0 || region_base > UINTPTR_MAX - mbi.RegionSize)
637 {
638 1 return false;
639 }
640 2327 const std::uintptr_t region_end = region_base + mbi.RegionSize;
641
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 2327 times.
2327 if (region_end <= cursor)
642 {
643 ✗ return false;
644 }
645
2/2
✓ Branch 22 → 23 taken 1005 times.
✓ Branch 22 → 24 taken 1322 times.
2327 if (region_end >= range.end)
646 {
647 1005 return true;
648 }
649 1322 cursor = region_end;
650 }
651 ✗ return false;
652 }
653 } // anonymous namespace
654
655 1246 bool detail::readable_scan_is_authoritative(
656 detail::ModuleSpan range,
657 scan::Pages pages,
658 std::span<const Region> exclusions
659 ) noexcept
660 {
661
6/6
✓ Branch 2 → 3 taken 1062 times.
✓ Branch 2 → 5 taken 184 times.
✓ Branch 4 → 5 taken 4 times.
✓ Branch 4 → 6 taken 1058 times.
✓ Branch 7 → 8 taken 188 times.
✓ Branch 7 → 9 taken 1058 times.
1246 if (pages != scan::Pages::Readable || !exclusions.empty())
662 {
663 188 return true;
664 }
665 1058 const ModuleSpan image = module_span(memory::module_of(Address{range.base}));
666
6/8
✓ Branch 13 → 14 taken 48 times.
✓ Branch 13 → 17 taken 1010 times.
✓ Branch 14 → 15 taken 48 times.
✗ Branch 14 → 17 not taken.
✓ Branch 15 → 16 taken 48 times.
✗ Branch 15 → 17 not taken.
✓ Branch 18 → 19 taken 48 times.
✓ Branch 18 → 20 taken 1010 times.
1058 if (image.valid() && range.base >= image.base && range.end <= image.end)
667 {
668 48 return true;
669 }
670 1010 return span_is_single_allocation(range);
671 }
672
673 220 detail::MatchResult detail::scan_module_executable(
674 const detail::EnginePattern &pattern,
675 detail::ModuleSpan range,
676 const detail::ScanQuery &query
677 ) noexcept
678 {
679 // EXECUTABLE_PAGE_FLAGS confines the match to code, so a data-page hit cannot pose as an instruction site.
680
3/6
✓ Branch 3 → 4 taken 220 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 220 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 220 times.
220 if (!query_is_scannable(pattern, query) || !range.valid())
681 {
682 ✗ return MatchResult{};
683 }
684 220 return scan_regions_filtered(pattern, query, EXECUTABLE_PAGE_FLAGS, range.base, range.end);
685 }
686
687 5957 detail::MatchResult detail::scan_module_readable(
688 const detail::EnginePattern &pattern,
689 detail::ModuleSpan range,
690 const detail::ScanQuery &query
691 ) noexcept
692 {
693 // READABLE_PAGE_FLAGS lets one pass cover both .text and .rdata / .data candidates.
694
3/6
✓ Branch 3 → 4 taken 5957 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 5957 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 5957 times.
5957 if (!query_is_scannable(pattern, query) || !range.valid())
695 {
696 ✗ return MatchResult{};
697 }
698 5957 return scan_regions_filtered(pattern, query, READABLE_PAGE_FLAGS, range.base, range.end);
699 }
700
701 detail::MatchResult
702 19 detail::scan_executable_regions(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
703 {
704
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 17 times.
19 if (!query_is_scannable(pattern, query))
705 {
706 2 return MatchResult{};
707 }
708 // The window spans the entire user address space, so the clamp in scan_regions_filtered is a no-op and the walk
709 // stops only when VirtualQuery runs off the end of the address space.
710 17 return scan_regions_filtered(pattern, query, EXECUTABLE_PAGE_FLAGS, 0, UINTPTR_MAX);
711 }
712
713 detail::MatchResult
714 34 detail::scan_readable_regions(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
715 {
716
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 32 times.
34 if (!query_is_scannable(pattern, query))
717 {
718 2 return MatchResult{};
719 }
720 32 return scan_regions_filtered(pattern, query, READABLE_PAGE_FLAGS, 0, UINTPTR_MAX);
721 }
722
723 405 detail::MatchResult detail::scan_module_pages(
724 const detail::EnginePattern &pattern,
725 detail::ModuleSpan range,
726 scan::Pages pages,
727 const detail::ScanQuery &query
728 ) noexcept
729 {
730 // An out-of-range enum value must not silently widen to readable pages, so reject it as an empty result.
731
2/3
✓ Branch 2 → 3 taken 221 times.
✓ Branch 2 → 4 taken 184 times.
✗ Branch 2 → 5 not taken.
405 switch (pages)
732 {
733 221 case scan::Pages::Readable:
734 221 return scan_module_readable(pattern, range, query);
735 184 case scan::Pages::Executable:
736 184 return scan_module_executable(pattern, range, query);
737 }
738 ✗ return MatchResult{};
739 }
740
741 // Centralizes the executable-page protection gate for out-of-TU callers (the string-xref backend): one VirtualQuery
742 // walk over [range.base, range.end) that returns each committed, execute-readable region clamped to the range,
743 // using the identical mask scan_module_executable applies. The per-region gate guarantees the window is readable at
744 // gate time; the caller still wraps its reads in a fault guard so a concurrent decommit / reprotect between gate
745 // and read cannot fault the host.
746 456 std::vector<detail::ExecutableWindow> detail::collect_executable_windows(detail::ModuleSpan range)
747 {
748 456 std::vector<ExecutableWindow> windows;
749
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 456 times.
456 if (!range.valid())
750 {
751 ✗ return windows;
752 }
753
754 456 MEMORY_BASIC_INFORMATION mbi{};
755 456 std::uintptr_t addr = range.base;
756
6/8
✓ Branch 21 → 22 taken 1071 times.
✓ Branch 21 → 25 taken 453 times.
✓ Branch 22 → 23 taken 1071 times.
✗ Branch 22 → 31 not taken.
✓ Branch 23 → 24 taken 1071 times.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 6 taken 1071 times.
✓ Branch 26 → 27 taken 453 times.
1524 while (addr < range.end && VirtualQuery(reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi)))
757 {
758 1071 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
759 1071 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
760 1071 const std::uintptr_t region_end = region_base + mbi.RegionSize;
761
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1071 times.
1071 const std::uintptr_t scan_lo = region_base < range.base ? range.base : region_base;
762
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1071 times.
1071 const std::uintptr_t scan_hi = region_end > range.end ? range.end : region_end;
763
764
6/8
✓ Branch 12 → 13 taken 590 times.
✓ Branch 12 → 18 taken 481 times.
✓ Branch 13 → 14 taken 550 times.
✓ Branch 13 → 18 taken 40 times.
✓ Branch 14 → 15 taken 550 times.
✗ Branch 14 → 18 not taken.
✓ Branch 15 → 16 taken 550 times.
✗ Branch 15 → 18 not taken.
1071 if (mbi.State == MEM_COMMIT && (mbi.Protect & EXECUTABLE_PAGE_FLAGS) != 0 && !protection_unsafe &&
765 scan_hi > scan_lo)
766 {
767
2/2
✓ Branch 16 → 17 taken 547 times.
✓ Branch 16 → 30 taken 3 times.
550 windows.push_back(ExecutableWindow{scan_lo, static_cast<std::size_t>(scan_hi - scan_lo)});
768 }
769
770
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 1068 times.
1068 if (region_end <= addr)
771 {
772 ✗ break; // Overflow guard, mirroring scan_regions_filtered.
773 }
774 1068 addr = region_end;
775 }
776 453 return windows;
777 3 }
778
779 // Single-address sibling of the executable-page gate scan_regions_filtered applies per region, so the
780 // prologue-recovery fallback can vet a decoded jump destination without re-deriving the Windows page masks or
781 // constraining it to a loaded module (a sibling mod's trampoline is VirtualAlloc'd outside every image).
782 5460 bool detail::is_executable_address(std::uintptr_t address) noexcept
783 {
784 5460 MEMORY_BASIC_INFORMATION mbi{};
785
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 5460 times.
5460 if (VirtualQuery(reinterpret_cast<LPCVOID>(address), &mbi, sizeof(mbi)) == 0)
786 {
787 ✗ return false;
788 }
789 5460 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
790
5/6
✓ Branch 5 → 6 taken 5338 times.
✓ Branch 5 → 9 taken 122 times.
✓ Branch 6 → 7 taken 5327 times.
✓ Branch 6 → 9 taken 11 times.
✓ Branch 7 → 8 taken 5327 times.
✗ Branch 7 → 9 not taken.
5460 return mbi.State == MEM_COMMIT && (mbi.Protect & EXECUTABLE_PAGE_FLAGS) != 0 && !protection_unsafe;
791 }
792
793 17378 bool detail::is_executable_range(std::uintptr_t address, std::size_t size) noexcept
794 {
795
3/6
✓ Branch 2 → 3 taken 17378 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 17378 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 17378 times.
17378 if (address == 0 || size == 0 || size > UINTPTR_MAX - address)
796 {
797 ✗ return false;
798 }
799
800 17378 const std::uintptr_t end = address + size;
801 17378 std::uintptr_t cursor = address;
802
2/2
✓ Branch 24 → 7 taken 17381 times.
✓ Branch 24 → 25 taken 17059 times.
34440 while (cursor < end)
803 {
804 17381 MEMORY_BASIC_INFORMATION mbi{};
805
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 17381 times.
17381 if (VirtualQuery(reinterpret_cast<LPCVOID>(cursor), &mbi, sizeof(mbi)) == 0)
806 {
807 319 return false;
808 }
809
810 17381 const std::uintptr_t region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
811
2/4
✓ Branch 10 → 11 taken 17381 times.
✗ Branch 10 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 17381 times.
17381 if (mbi.RegionSize == 0 || region_base > UINTPTR_MAX - mbi.RegionSize)
812 {
813 ✗ return false;
814 }
815 17381 const std::uintptr_t region_end = region_base + mbi.RegionSize;
816 17381 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
817
7/8
✓ Branch 13 → 14 taken 17381 times.
✗ Branch 13 → 18 not taken.
✓ Branch 14 → 15 taken 17378 times.
✓ Branch 14 → 18 taken 3 times.
✓ Branch 15 → 16 taken 17064 times.
✓ Branch 15 → 18 taken 314 times.
✓ Branch 16 → 17 taken 17062 times.
✓ Branch 16 → 18 taken 2 times.
17381 if (cursor < region_base || mbi.State != MEM_COMMIT || (mbi.Protect & EXECUTABLE_PAGE_FLAGS) == 0 ||
818
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 17062 times.
17062 protection_unsafe || region_end <= cursor)
819 {
820 319 return false;
821 }
822
823
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 17059 times.
17062 cursor = region_end < end ? region_end : end;
824 }
825 17059 return true;
826 }
827 } // namespace DetourModKit
828