GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.1% 242 / 0 / 260
Functions: 100.0% 19 / 0 / 19
Branches: 75.9% 180 / 0 / 237

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 1236 [[nodiscard]] bool excludes(std::uintptr_t lo, std::uintptr_t hi) const noexcept
45 {
46
6/6
✓ Branch 3 → 4 taken 1228 times.
✓ Branch 3 → 7 taken 8 times.
✓ Branch 4 → 5 taken 1071 times.
✓ Branch 4 → 8 taken 157 times.
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 8 taken 1061 times.
1236 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 359 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 359 std::memcpy(destination, source, size);
73 #endif
74 359 }
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 337 capture_winning_evidence(const std::byte *start, const std::byte *end) noexcept
83 {
84 337 scan::WinningEvidence evidence{};
85
3/6
✓ Branch 2 → 3 taken 337 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 337 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 337 times.
337 if (start == nullptr || end == nullptr || end <= start)
86 {
87 return evidence;
88 }
89 337 const auto span_length = static_cast<std::size_t>(end - start);
90
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 333 times.
337 if (span_length > scan::MAX_MUTATION_WITNESS_BYTES)
91 {
92 4 evidence.truncated = true;
93 4 return evidence;
94 }
95 333 copy_foreign_bytes(evidence.bytes.data(), start, span_length);
96 333 evidence.length = static_cast<std::uint16_t>(span_length);
97 333 return evidence;
98 }
99
100 24 [[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 24 detail::InstructionSnapshot snapshot{};
110
4/8
✓ Branch 2 → 3 taken 24 times.
✗ Branch 2 → 9 not taken.
✓ Branch 3 → 4 taken 24 times.
✗ Branch 3 → 9 not taken.
✓ Branch 4 → 5 taken 24 times.
✗ Branch 4 → 9 not taken.
✓ Branch 5 → 6 taken 24 times.
✗ Branch 5 → 9 not taken.
24 if (requested_length == 0 || requested_length > scan::MAX_X86_INSTRUCTION_LENGTH || start == nullptr ||
111
3/6
✓ Branch 6 → 7 taken 24 times.
✗ Branch 6 → 9 not taken.
✓ Branch 7 → 8 taken 24 times.
✗ Branch 7 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 24 times.
24 end == nullptr || point == nullptr || point < start || point > end)
112 {
113 return snapshot;
114 }
115 24 const auto point_offset = static_cast<std::size_t>(point - start);
116 24 const auto span_length = static_cast<std::size_t>(end - start);
117 24 const std::uintptr_t point_address = reinterpret_cast<std::uintptr_t>(point);
118
2/4
✓ Branch 10 → 11 taken 24 times.
✗ Branch 10 → 13 not taken.
✓ Branch 11 → 12 taken 24 times.
✗ Branch 11 → 13 not taken.
24 if (point_offset > span_length || point_address >= capture_limit ||
119
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 23 times.
24 requested_length > capture_limit - point_address)
120 {
121 1 return snapshot;
122 }
123
124
2/2
✓ Branch 15 → 16 taken 22 times.
✓ Branch 15 → 24 taken 1 time.
23 if (evidence.present())
125 {
126 22 const std::size_t evidenced_length = span_length - point_offset;
127 22 const std::size_t copy_length =
128 22 requested_length < evidenced_length ? requested_length : evidenced_length;
129 44 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 19 times.
22 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 22 snapshot.length = requested_length;
144 22 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 25934 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 25934 detail::fire_scan_fault_seam_for_test(detail::g_scan_region_fault_for_test);
176 #endif
177 // One SegmentedScanBudget stays live across every find_pattern_raw suffix call below. A bounded-jump sweep
178 // whose per-position or region-wide backtracking budget was spent leaves the occurrence count a lower
179 // bound, exactly like a faulted-region skip. The flag is meaningful even when no match is found: a
180 // truncated no-match is not a proven absence.
181 25934 detail::SegmentedScanBudget segmented_budget{};
182 25934 detail::RawMatch match = detail::find_pattern_raw(region_start, scan_size, pattern, &segmented_budget);
183 22242 out_budget_exhausted = match.budget_exhausted;
184
2/2
✓ Branch 25 → 5 taken 1236 times.
✓ Branch 25 → 26 taken 22183 times.
23419 while (match.start != nullptr)
185 {
186 1236 const auto match_addr = reinterpret_cast<std::uintptr_t>(match.start);
187 1236 const auto match_end = reinterpret_cast<std::uintptr_t>(match.end);
188 // The match spans [match_addr, match_end); using the true end (not a fixed pattern length) keeps both
189 // the exclusion test and the boundary de-duplication exact for a bounded-jump match.
190 1236 const bool excluded = exclusions.excludes(match_addr, match_end);
191 1236 const bool already_counted = match_end <= count_floor;
192
3/4
✓ Branch 6 → 7 taken 1218 times.
✓ Branch 6 → 17 taken 18 times.
✓ Branch 7 → 8 taken 1218 times.
✗ Branch 7 → 17 not taken.
1236 if (!excluded && !already_counted)
193 {
194 1218 ++tally.seen;
195
2/2
✓ Branch 8 → 9 taken 1114 times.
✓ Branch 8 → 15 taken 104 times.
1218 if (tally.seen == target)
196 {
197 1114 tally.nth_point = match.point;
198 1114 const std::uintptr_t span_start = reinterpret_cast<std::uintptr_t>(match.start);
199 1114 const std::uintptr_t span_end = reinterpret_cast<std::uintptr_t>(match.end);
200 1114 tally.nth_span = Region{Address{span_start}, static_cast<std::size_t>(span_end - span_start)};
201
2/2
✓ Branch 10 → 11 taken 337 times.
✓ Branch 10 → 13 taken 777 times.
1114 if (capture)
202 {
203 337 tally.nth_evidence = capture_winning_evidence(match.start, match.end);
204 }
205
2/2
✓ Branch 13 → 14 taken 24 times.
✓ Branch 13 → 15 taken 1090 times.
1114 if (instruction_snapshot_length != 0)
206 {
207 24 tally.nth_instruction = capture_instruction_snapshot(
208 match.start,
209 match.end,
210 match.point,
211 snapshot_limit,
212 instruction_snapshot_length,
213 24 tally.nth_evidence
214 );
215 }
216 }
217
2/2
✓ Branch 15 → 16 taken 58 times.
✓ Branch 15 → 17 taken 1159 times.
1217 if (tally.seen >= cap)
218 {
219 58 return true;
220 }
221 }
222
223 // Continue scanning past the current match START (not its variable end).
224 1177 const std::size_t consumed = static_cast<std::size_t>(match.start - region_start) + 1;
225
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 1177 times.
1177 if (consumed >= scan_size)
226 {
227 break;
228 }
229 1177 match = detail::find_pattern_raw(match.start + 1, scan_size - consumed, pattern, &segmented_budget);
230
2/4
✓ Branch 20 → 21 taken 1177 times.
✗ Branch 20 → 22 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 1177 times.
1177 out_budget_exhausted = out_budget_exhausted || match.budget_exhausted;
231 }
232 22183 return false;
233 }
234
235 // Region-granular TOCTOU fault guard around scan_region_for_match. The caller's per-region VirtualQuery only
236 // proves the region was committed and readable at gate time; a concurrent decommit / reprotect before these
237 // unguarded reads complete would otherwise fault the host. Both arms claim a fault only inside the exact span
238 // this sweep is permitted to read, [span_lo, capture_limit): on MSVC through
239 // detail::guarded_range_fault_filter, on MinGW x64 through the same process-wide vectored read guard the
240 // guarded_read paths use, armed over that span. An access-class fault OUTSIDE it is an unrelated defect rather
241 // than the concurrent unmap this guard exists to absorb, so it reaches the host's handlers instead of being
242 // recorded as a faulted region. A 32-bit build is rejected outright by the architecture gate in defines.hpp,
243 // so only these two x64 arms exist.
244 25933 bool scan_region_guarded(
245 const std::byte *region_start,
246 std::size_t scan_size,
247 const detail::EnginePattern &pattern,
248 const ExclusionSet &exclusions,
249 std::uintptr_t count_floor,
250 std::size_t target,
251 std::size_t cap,
252 bool capture,
253 std::uintptr_t snapshot_limit,
254 std::uint8_t instruction_snapshot_length,
255 ScanTally &tally,
256 bool &out_faulted,
257 bool &out_budget_exhausted
258 ) noexcept
259 {
260 25933 out_faulted = false;
261 25933 const std::uintptr_t span_lo = reinterpret_cast<std::uintptr_t>(region_start);
262 25933 const std::uintptr_t scan_hi = span_lo + scan_size;
263 25933 std::uintptr_t capture_limit = scan_hi;
264
4/4
✓ Branch 2 → 3 taken 24 times.
✓ Branch 2 → 5 taken 25909 times.
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 18 times.
25933 if (instruction_snapshot_length != 0 && capture_limit < snapshot_limit)
265 {
266 6 const std::uintptr_t available = snapshot_limit - capture_limit;
267 6 const std::uintptr_t extension =
268 6 instruction_snapshot_length < available ? instruction_snapshot_length : available;
269 6 capture_limit += extension;
270 }
271 // A faulted region is treated as skipped, not partially scanned: matches observed before the fault cannot
272 // be trusted for occurrence accounting because unreadable tail bytes may hide additional matches. The skip
273 // already forces the scan incomplete, so any partial budget-exhaustion state from the aborted sweep is
274 // moot and is cleared so it is not double-counted.
275 25933 const ScanTally original_tally = tally;
276 #ifdef _MSC_VER
277 __try
278 {
279 return scan_region_for_match(
280 region_start,
281 scan_size,
282 pattern,
283 exclusions,
284 count_floor,
285 target,
286 cap,
287 capture,
288 capture_limit,
289 instruction_snapshot_length,
290 tally,
291 out_budget_exhausted
292 );
293 }
294 __except (detail::guarded_range_fault_filter(GetExceptionInformation(), span_lo, capture_limit))
295 {
296 tally = original_tally;
297 out_faulted = true;
298 out_budget_exhausted = false;
299 return false;
300 }
301 #elif defined(_WIN64)
302 // MinGW x64: route the sweep through the same vectored fault guard as the foreign-read primitives. The
303 // armed range includes the pattern window plus the bounded, scope-clamped instruction-snapshot tail.
304 struct ScanContext
305 {
306 const std::byte *region_start;
307 std::size_t scan_size;
308 const detail::EnginePattern *pattern;
309 const ExclusionSet *exclusions;
310 std::uintptr_t count_floor;
311 std::size_t target;
312 std::size_t cap;
313 bool capture;
314 std::uintptr_t snapshot_limit;
315 std::uint8_t instruction_snapshot_length;
316 ScanTally *tally;
317 bool *budget_exhausted;
318 bool cap_reached;
319 25933 } scan_ctx{
320 region_start,
321 scan_size,
322 &pattern,
323 &exclusions,
324 count_floor,
325 target,
326 cap,
327 capture,
328 capture_limit,
329 instruction_snapshot_length,
330 &tally,
331 &out_budget_exhausted,
332 false
333 25933 };
334
335 25934 const auto run_scan = [](void *opaque) noexcept -> void
336 {
337 25934 auto *context = static_cast<ScanContext *>(opaque);
338 48175 context->cap_reached = scan_region_for_match(
339 context->region_start,
340 context->scan_size,
341 25934 *context->pattern,
342 25934 *context->exclusions,
343 context->count_floor,
344 context->target,
345 context->cap,
346 25934 context->capture,
347 context->snapshot_limit,
348 25934 context->instruction_snapshot_length,
349 25934 *context->tally,
350 25934 *context->budget_exhausted
351 );
352 22241 };
353
354
2/2
✓ Branch 7 → 8 taken 22242 times.
✓ Branch 7 → 9 taken 3692 times.
25933 if (detail::run_guarded_region(span_lo, capture_limit, run_scan, &scan_ctx))
355 {
356 22242 return scan_ctx.cap_reached;
357 }
358 3692 tally = original_tally;
359 3692 out_faulted = true;
360 3692 out_budget_exhausted = false;
361 3692 return false;
362 #endif
363 }
364
365 // Region-walking AOB scan shared by the whole-process and module-scoped entry points. Walks the committed
366 // regions of [window_lo, window_hi) via VirtualQuery and runs the per-region scan (behind the fault guard)
367 // against every region whose base protection is present in accept_mask. The whole-process scanners pass
368 // [0, UINTPTR_MAX); the module-scoped scan passes the image's [base, end).
369 //
370 // Guard, no-access, and uncommitted regions are always skipped: PAGE_GUARD raises STATUS_GUARD_PAGE_VIOLATION
371 // on the first touch and PAGE_NOACCESS faults even for reads, so neither is safe to dereference. The Windows
372 // base protections are mutually exclusive single bits, so a bitwise-AND against a mask of the acceptable bases
373 // is a sound membership test. PAGE_GUARD is a modifier bit OR-ed onto a base value, so it must be excluded
374 // separately or it would satisfy the mask and be scanned.
375 //
376 // A signature can straddle a protection split: two adjacent accepted regions VirtualQuery reports separately
377 // because their base protections differ (a sibling VirtualProtect carving part of .text into
378 // PAGE_EXECUTE_READWRITE is the canonical case). To catch such a match, each accepted region's scan is
379 // extended back by up to max_match_length() - 1 bytes into the contiguous run of already-accepted regions it
380 // abuts, bounded by the run start so it never reads past the bytes the per-region gate proved readable. A
381 // match wholly inside the previous region is not re-counted: the region's true start is passed as a count
382 // floor, and only a match whose end reaches past it is counted. The floor, not the carry width, is what
383 // prevents a double count, which is why a variable-length bounded-jump match stays correctly counted across
384 // the split.
385 6207 detail::MatchResult scan_regions_filtered(
386 const detail::EnginePattern &pattern,
387 const detail::ScanQuery &query,
388 DWORD accept_mask,
389 std::uintptr_t window_lo,
390 std::uintptr_t window_hi
391 ) noexcept
392 {
393 6207 ScanTally tally;
394
395 // The compiled pattern's own bytes and mask buffers live in readable heap memory, so a readable sweep would
396 // otherwise match the needle against itself and could return the query's storage instead of the intended
397 // target. This floor guarantee holds regardless of what the caller declared; caller-owned copies of the
398 // query ride query.exclusions on top of it.
399 //
400 // When evidence capture is on, the tally buffer joins that floor for the same reason and is strictly worse
401 // if left out: it holds a verbatim copy of a MATCHED span, so once one match is captured the sweep would
402 // count that copy as a further occurrence.
403 6207 detail::ScanExclusions engine_owned;
404 6207 detail::add_engine_pattern_storage(engine_owned, pattern);
405
2/2
✓ Branch 3 → 4 taken 384 times.
✓ Branch 3 → 9 taken 5822 times.
6206 if (query.capture_evidence)
406 {
407 384 engine_owned.add(
408 384 reinterpret_cast<std::uintptr_t>(tally.nth_evidence.bytes.data()),
409 tally.nth_evidence.bytes.size()
410 );
411 }
412
2/2
✓ Branch 9 → 10 taken 24 times.
✓ Branch 9 → 15 taken 6181 times.
6205 if (query.instruction_snapshot_length != 0)
413 {
414 24 engine_owned.add(
415 24 reinterpret_cast<std::uintptr_t>(tally.nth_instruction.bytes.data()),
416 tally.nth_instruction.bytes.size()
417 );
418 }
419 6206 const ExclusionSet exclusions{engine_owned, query.exclusions};
420
421 6206 const std::size_t target = query.occurrence;
422 const std::size_t cap =
423
4/4
✓ Branch 15 → 16 taken 1132 times.
✓ Branch 15 → 19 taken 5074 times.
✓ Branch 17 → 18 taken 1130 times.
✓ Branch 17 → 19 taken 1 time.
6206 query.count_beyond && target != std::numeric_limits<std::size_t>::max() ? target + 1 : target;
424
425 6205 std::size_t faulted_regions = 0;
426 6205 bool budget_exhausted_total = false;
427 6205 bool cap_reached = false;
428 6205 MEMORY_BASIC_INFORMATION mbi{};
429 6205 std::uintptr_t addr = window_lo;
430
431 // Contiguous-accepted-run tracking for the cross-boundary overlap (see the function comment).
432 // prev_accept_hi is the end of the previous accepted region; run_lo is the start of the run of contiguous
433 // accepted regions the current region belongs to. A gap (a skipped, guarded, or non-readable region) breaks
434 // the run because the bytes across it are not proven readable.
435 6205 bool prev_accepted = false;
436 6205 std::uintptr_t prev_accept_hi = 0;
437 6205 std::uintptr_t run_lo = 0;
438
439
8/8
✓ Branch 59 → 60 taken 44050 times.
✓ Branch 59 → 64 taken 58 times.
✓ Branch 60 → 61 taken 37918 times.
✓ Branch 60 → 64 taken 6132 times.
✓ Branch 62 → 63 taken 37903 times.
✓ Branch 62 → 64 taken 16 times.
✓ Branch 65 → 21 taken 37902 times.
✓ Branch 65 → 66 taken 6207 times.
44108 while (!cap_reached && addr < window_hi && VirtualQuery(reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi)))
440 {
441 37902 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
442 37902 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
443 37902 const std::uintptr_t region_end = region_base + mbi.RegionSize;
444
445 // Clamp the region to the requested window so a region that straddles window_lo / window_hi is
446 // inspected only where it intersects. For a whole-process sweep the clamp is a no-op; for a
447 // module-scoped sweep this is what keeps the scan inside [base, end) even when a VirtualQuery region
448 // extends past it.
449
2/2
✓ Branch 21 → 22 taken 55 times.
✓ Branch 21 → 23 taken 37847 times.
37902 const std::uintptr_t scan_lo = region_base < window_lo ? window_lo : region_base;
450
2/2
✓ Branch 24 → 25 taken 56 times.
✓ Branch 24 → 26 taken 37846 times.
37902 const std::uintptr_t scan_hi = region_end > window_hi ? window_hi : region_end;
451
452
8/8
✓ Branch 27 → 28 taken 32788 times.
✓ Branch 27 → 52 taken 5114 times.
✓ Branch 28 → 29 taken 26049 times.
✓ Branch 28 → 52 taken 6739 times.
✓ Branch 29 → 30 taken 25933 times.
✓ Branch 29 → 52 taken 116 times.
✓ Branch 30 → 31 taken 25932 times.
✓ Branch 30 → 52 taken 1 time.
37902 if (mbi.State == MEM_COMMIT && (mbi.Protect & accept_mask) != 0 && !protection_unsafe &&
453 scan_hi > scan_lo)
454 {
455 // Continue the accepted run only when this region begins exactly where the previous accepted one
456 // ended; otherwise restart it here. Done before computing the overlap so run_lo reflects the run
457 // scan_lo joins.
458
4/4
✓ Branch 31 → 32 taken 16539 times.
✓ Branch 31 → 33 taken 9393 times.
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 16538 times.
25932 if (!prev_accepted || prev_accept_hi != scan_lo)
459 {
460 9394 run_lo = scan_lo;
461 }
462
463 25932 std::uintptr_t effective_scan_lo = scan_lo;
464 25932 const std::size_t match_span = pattern.max_match_length();
465
3/4
✓ Branch 35 → 36 taken 25932 times.
✗ Branch 35 → 41 not taken.
✓ Branch 36 → 37 taken 16539 times.
✓ Branch 36 → 41 taken 9393 times.
25932 if (match_span > 1 && scan_lo > run_lo)
466 {
467 16539 const std::uintptr_t max_overlap = static_cast<std::uintptr_t>(match_span - 1);
468 16539 const std::uintptr_t available = scan_lo - run_lo;
469
1/2
✓ Branch 37 → 38 taken 16539 times.
✗ Branch 37 → 39 not taken.
16539 effective_scan_lo = scan_lo - ((max_overlap < available) ? max_overlap : available);
470 }
471
472 25932 const std::size_t scan_size = static_cast<std::size_t>(scan_hi - effective_scan_lo);
473 25932 bool region_faulted = false;
474
1/2
✓ Branch 42 → 43 taken 25934 times.
✗ Branch 42 → 51 not taken.
25932 if (scan_size >= pattern.size())
475 {
476 25934 const auto *region_start = reinterpret_cast<const std::byte *>(effective_scan_lo);
477
478 // The protection gate above proved the region readable at gate time; scan_region_guarded
479 // backstops a concurrent decommit / reprotect that could fault the read after the gate. scan_lo
480 // is the count floor: matches that ended before it were already tallied by the previous region.
481 25934 bool region_budget_exhausted = false;
482 25934 cap_reached = scan_region_guarded(
483 region_start,
484 scan_size,
485 pattern,
486 exclusions,
487 scan_lo,
488 target,
489 cap,
490 25934 query.capture_evidence,
491 window_hi,
492 25934 query.instruction_snapshot_length,
493 tally,
494 region_faulted,
495 region_budget_exhausted
496 );
497 // A spent bounded-jump backtracking budget makes any occurrence count a lower bound, exactly
498 // like a skipped faulted region, so it feeds the same incomplete signal.
499
4/4
✓ Branch 44 → 45 taken 25933 times.
✓ Branch 44 → 46 taken 1 time.
✓ Branch 45 → 46 taken 3 times.
✓ Branch 45 → 47 taken 25930 times.
25934 budget_exhausted_total = budget_exhausted_total || region_budget_exhausted;
500
2/2
✓ Branch 48 → 49 taken 3692 times.
✓ Branch 48 → 50 taken 22242 times.
25934 if (region_faulted)
501 {
502 3692 ++faulted_regions;
503 }
504 }
505
506 // A faulted region ends the run as surely as a gap does. Its bytes were abandoned mid-read, so the
507 // next region must not back-extend into them: that overlap would fault as well and cost a second,
508 // fully readable region its entire sweep.
509 25933 prev_accepted = !region_faulted;
510 25933 prev_accept_hi = scan_hi;
511 25933 }
512 else
513 {
514 11970 prev_accepted = false;
515 }
516
517
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 55 taken 37903 times.
37903 assert(region_end > addr && "VirtualQuery returned a non-advancing region");
518
1/2
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 58 taken 37903 times.
37903 if (region_end <= addr)
519 {
520 break; // Overflow guard.
521 }
522 37903 addr = region_end;
523 }
524
525
2/2
✓ Branch 66 → 67 taken 3692 times.
✓ Branch 66 → 72 taken 2515 times.
6207 if (faulted_regions != 0)
526 {
527 // Best-effort diagnosis only; the sweep already skipped each faulted region and continued, and the
528 // skipped bytes are what the incomplete flag below makes the caller fail closed on.
529 try
530 {
531 3692 (void)log().try_log(
532 LogLevel::Debug,
533 "Scanner: skipped {} region(s) that faulted mid-scan (concurrent decommit/reprotect).",
534 faulted_regions
535 );
536 }
537 catch (...)
538 {
539 }
540
541 // The dispatcher is lazy and can allocate on first use, so diagnostics must never change the result.
542 try
543 {
544
1/2
✓ Branch 69 → 70 taken 3692 times.
✗ Branch 69 → 74 not taken.
3692 diagnostics::scanner_faults().emit_safe(
545 3692 diagnostics::ScannerFaultEvent{
546 .faulted_regions = faulted_regions,
547 .window_low = window_lo,
548 .window_high = window_hi,
549 }
550 );
551 }
552 catch (...)
553 {
554 }
555 }
556 return detail::MatchResult{
557 6207 tally.nth_point,
558 tally.nth_span,
559 tally.nth_evidence,
560 tally.nth_instruction,
561 6207 tally.seen,
562 6207 faulted_regions > 0,
563 budget_exhausted_total
564 6207 };
565 }
566
567 // Base protections accepted by the executable-only sweeps: the three page variants that grant execute *and*
568 // read. Bare PAGE_EXECUTE (execute without a read bit) is excluded because dereferencing it raises an access
569 // violation; PAGE_GUARD / PAGE_NOACCESS are filtered separately inside scan_regions_filtered.
570 constexpr DWORD EXECUTABLE_PAGE_FLAGS = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
571
572 // Base protections accepted by the readable sweep: the executable-readable set plus the non-executable
573 // readable pages (.rdata / .data and read-only heaps). This reaches C++ vtables, RTTI type descriptors, and
574 // other read-only metadata the executable-only sweep cannot see.
575 constexpr DWORD READABLE_PAGE_FLAGS = EXECUTABLE_PAGE_FLAGS | PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY;
576
577 // Shared precondition for every page-gated entry point.
578 [[nodiscard]] bool
579 6211 query_is_scannable(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
580 {
581
4/4
✓ Branch 3 → 4 taken 6209 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 4 → 5 taken 6207 times.
✓ Branch 4 → 6 taken 2 times.
6211 return !pattern.empty() && query.occurrence != 0;
582 }
583
584 // Region walks a scope may cross while still counting as one caller-named allocation. A scope that needs more
585 // than this is not something the caller enumerated; it is a sweep of whatever happens to be mapped.
586 constexpr std::size_t MAX_CONFINED_REGIONS = 64;
587
588 // True when [range.base, range.end) lies inside a single reserved allocation. VirtualAlloc hands out one
589 // AllocationBase per reservation and VirtualQuery splits it into regions as protections diverge, so a constant
590 // AllocationBase across the walk is exactly "the caller named one buffer". A whole-process window fails on the
591 // first region boundary that changes it.
592 906 [[nodiscard]] bool span_is_single_allocation(detail::ModuleSpan range) noexcept
593 {
594 906 MEMORY_BASIC_INFORMATION mbi{};
595
3/4
✓ Branch 3 → 4 taken 908 times.
✗ Branch 3 → 5 not taken.
✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 902 times.
1814 if (VirtualQuery(reinterpret_cast<LPCVOID>(range.base), &mbi, sizeof(mbi)) == 0 ||
596
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 6 taken 902 times.
908 mbi.AllocationBase == nullptr)
597 {
598 5 return false;
599 }
600 902 const LPVOID allocation_base = mbi.AllocationBase;
601
602 902 std::uintptr_t cursor = range.base;
603
2/2
✓ Branch 25 → 10 taken 2346 times.
✓ Branch 25 → 26 taken 1 time.
2347 for (std::size_t visited = 0; visited < MAX_CONFINED_REGIONS; ++visited)
604 {
605
2/4
✓ Branch 11 → 12 taken 2348 times.
✗ Branch 11 → 13 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 2348 times.
4694 if (VirtualQuery(reinterpret_cast<LPCVOID>(cursor), &mbi, sizeof(mbi)) == 0 ||
606
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 2348 times.
2348 mbi.AllocationBase != allocation_base)
607 {
608 return false;
609 }
610 2348 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
611
2/4
✓ Branch 17 → 18 taken 2348 times.
✗ Branch 17 → 19 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 2348 times.
2348 if (mbi.RegionSize == 0 || region_base > UINTPTR_MAX - mbi.RegionSize)
612 {
613 return false;
614 }
615 2348 const std::uintptr_t region_end = region_base + mbi.RegionSize;
616
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 2348 times.
2348 if (region_end <= cursor)
617 {
618 return false;
619 }
620
2/2
✓ Branch 22 → 23 taken 903 times.
✓ Branch 22 → 24 taken 1445 times.
2348 if (region_end >= range.end)
621 {
622 903 return true;
623 }
624 1445 cursor = region_end;
625 }
626 1 return false;
627 }
628 } // anonymous namespace
629
630 1142 bool detail::readable_scan_is_authoritative(
631 detail::ModuleSpan range,
632 scan::Pages pages,
633 std::span<const Region> exclusions
634 ) noexcept
635 {
636
6/6
✓ Branch 2 → 3 taken 959 times.
✓ Branch 2 → 5 taken 183 times.
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 951 times.
✓ Branch 7 → 8 taken 188 times.
✓ Branch 7 → 9 taken 951 times.
1142 if (pages != scan::Pages::Readable || !exclusions.empty())
637 {
638 188 return true;
639 }
640 951 const ModuleSpan image = module_span(memory::module_of(Address{range.base}));
641
6/8
✓ Branch 13 → 14 taken 46 times.
✓ Branch 13 → 17 taken 905 times.
✓ Branch 14 → 15 taken 46 times.
✗ Branch 14 → 17 not taken.
✓ Branch 15 → 16 taken 46 times.
✗ Branch 15 → 17 not taken.
✓ Branch 18 → 19 taken 46 times.
✓ Branch 18 → 20 taken 905 times.
953 if (image.valid() && range.base >= image.base && range.end <= image.end)
642 {
643 46 return true;
644 }
645 905 return span_is_single_allocation(range);
646 }
647
648 220 detail::MatchResult detail::scan_module_executable(
649 const detail::EnginePattern &pattern,
650 detail::ModuleSpan range,
651 const detail::ScanQuery &query
652 ) noexcept
653 {
654 // EXECUTABLE_PAGE_FLAGS confines the match to code, so a data-page hit cannot pose as an instruction site.
655
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())
656 {
657 return MatchResult{};
658 }
659 220 return scan_regions_filtered(pattern, query, EXECUTABLE_PAGE_FLAGS, range.base, range.end);
660 }
661
662 5932 detail::MatchResult detail::scan_module_readable(
663 const detail::EnginePattern &pattern,
664 detail::ModuleSpan range,
665 const detail::ScanQuery &query
666 ) noexcept
667 {
668 // READABLE_PAGE_FLAGS lets one pass cover both .text and .rdata / .data candidates.
669
3/6
✓ Branch 3 → 4 taken 5931 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 5930 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 5930 times.
5932 if (!query_is_scannable(pattern, query) || !range.valid())
670 {
671 return MatchResult{};
672 }
673 5930 return scan_regions_filtered(pattern, query, READABLE_PAGE_FLAGS, range.base, range.end);
674 }
675
676 detail::MatchResult
677 19 detail::scan_executable_regions(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
678 {
679
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 17 times.
19 if (!query_is_scannable(pattern, query))
680 {
681 2 return MatchResult{};
682 }
683 // The window spans the entire user address space, so the clamp in scan_regions_filtered is a no-op and the walk
684 // stops only when VirtualQuery runs off the end of the address space.
685 17 return scan_regions_filtered(pattern, query, EXECUTABLE_PAGE_FLAGS, 0, UINTPTR_MAX);
686 }
687
688 detail::MatchResult
689 40 detail::scan_readable_regions(const detail::EnginePattern &pattern, const detail::ScanQuery &query) noexcept
690 {
691
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 38 times.
40 if (!query_is_scannable(pattern, query))
692 {
693 2 return MatchResult{};
694 }
695 38 return scan_regions_filtered(pattern, query, READABLE_PAGE_FLAGS, 0, UINTPTR_MAX);
696 }
697
698 397 detail::MatchResult detail::scan_module_pages(
699 const detail::EnginePattern &pattern,
700 detail::ModuleSpan range,
701 scan::Pages pages,
702 const detail::ScanQuery &query
703 ) noexcept
704 {
705 // An out-of-range enum value must not silently widen to readable pages, so reject it as an empty result.
706
2/3
✓ Branch 2 → 3 taken 213 times.
✓ Branch 2 → 4 taken 184 times.
✗ Branch 2 → 5 not taken.
397 switch (pages)
707 {
708 213 case scan::Pages::Readable:
709 213 return scan_module_readable(pattern, range, query);
710 184 case scan::Pages::Executable:
711 184 return scan_module_executable(pattern, range, query);
712 }
713 return MatchResult{};
714 }
715
716 // Centralizes the executable-page protection gate for out-of-TU callers (the string-xref backend): one VirtualQuery
717 // walk over [range.base, range.end) that returns each committed, execute-readable region clamped to the range,
718 // using the identical mask scan_module_executable applies. The per-region gate guarantees the window is readable at
719 // gate time; the caller still wraps its reads in a fault guard so a concurrent decommit / reprotect between gate
720 // and read cannot fault the host.
721 335 std::vector<detail::ExecutableWindow> detail::collect_executable_windows(detail::ModuleSpan range)
722 {
723 335 std::vector<ExecutableWindow> windows;
724
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 335 times.
335 if (!range.valid())
725 {
726 return windows;
727 }
728
729 335 MEMORY_BASIC_INFORMATION mbi{};
730 335 std::uintptr_t addr = range.base;
731
6/8
✓ Branch 21 → 22 taken 769 times.
✓ Branch 21 → 25 taken 334 times.
✓ Branch 22 → 23 taken 769 times.
✗ Branch 22 → 31 not taken.
✓ Branch 23 → 24 taken 769 times.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 6 taken 769 times.
✓ Branch 26 → 27 taken 334 times.
1103 while (addr < range.end && VirtualQuery(reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi)))
732 {
733 769 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
734 769 const auto region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
735 769 const std::uintptr_t region_end = region_base + mbi.RegionSize;
736
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 769 times.
769 const std::uintptr_t scan_lo = region_base < range.base ? range.base : region_base;
737
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 769 times.
769 const std::uintptr_t scan_hi = region_end > range.end ? range.end : region_end;
738
739
6/8
✓ Branch 12 → 13 taken 446 times.
✓ Branch 12 → 18 taken 323 times.
✓ Branch 13 → 14 taken 408 times.
✓ Branch 13 → 18 taken 38 times.
✓ Branch 14 → 15 taken 408 times.
✗ Branch 14 → 18 not taken.
✓ Branch 15 → 16 taken 408 times.
✗ Branch 15 → 18 not taken.
769 if (mbi.State == MEM_COMMIT && (mbi.Protect & EXECUTABLE_PAGE_FLAGS) != 0 && !protection_unsafe &&
740 scan_hi > scan_lo)
741 {
742
1/2
✓ Branch 16 → 17 taken 407 times.
✗ Branch 16 → 30 not taken.
408 windows.push_back(ExecutableWindow{scan_lo, static_cast<std::size_t>(scan_hi - scan_lo)});
743 }
744
745
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 768 times.
768 if (region_end <= addr)
746 {
747 break; // Overflow guard, mirroring scan_regions_filtered.
748 }
749 768 addr = region_end;
750 }
751 334 return windows;
752 }
753
754 // Single-address sibling of the executable-page gate scan_regions_filtered applies per region, so the
755 // prologue-recovery fallback can vet a decoded jump destination without re-deriving the Windows page masks or
756 // constraining it to a loaded module (a sibling mod's trampoline is VirtualAlloc'd outside every image).
757 5443 bool detail::is_executable_address(std::uintptr_t address) noexcept
758 {
759 5443 MEMORY_BASIC_INFORMATION mbi{};
760
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 5443 times.
5443 if (VirtualQuery(reinterpret_cast<LPCVOID>(address), &mbi, sizeof(mbi)) == 0)
761 {
762 return false;
763 }
764 5443 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
765
5/6
✓ Branch 5 → 6 taken 5324 times.
✓ Branch 5 → 9 taken 119 times.
✓ Branch 6 → 7 taken 5313 times.
✓ Branch 6 → 9 taken 11 times.
✓ Branch 7 → 8 taken 5313 times.
✗ Branch 7 → 9 not taken.
5443 return mbi.State == MEM_COMMIT && (mbi.Protect & EXECUTABLE_PAGE_FLAGS) != 0 && !protection_unsafe;
766 }
767
768 1231 bool detail::is_executable_range(std::uintptr_t address, std::size_t size) noexcept
769 {
770
3/6
✓ Branch 2 → 3 taken 1231 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 1231 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1231 times.
1231 if (address == 0 || size == 0 || size > UINTPTR_MAX - address)
771 {
772 return false;
773 }
774
775 1231 const std::uintptr_t end = address + size;
776 1231 std::uintptr_t cursor = address;
777
2/2
✓ Branch 24 → 7 taken 1234 times.
✓ Branch 24 → 25 taken 1219 times.
2453 while (cursor < end)
778 {
779 1234 MEMORY_BASIC_INFORMATION mbi{};
780
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1234 times.
1234 if (VirtualQuery(reinterpret_cast<LPCVOID>(cursor), &mbi, sizeof(mbi)) == 0)
781 {
782 12 return false;
783 }
784
785 1234 const std::uintptr_t region_base = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
786
2/4
✓ Branch 10 → 11 taken 1234 times.
✗ Branch 10 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 1234 times.
1234 if (mbi.RegionSize == 0 || region_base > UINTPTR_MAX - mbi.RegionSize)
787 {
788 return false;
789 }
790 1234 const std::uintptr_t region_end = region_base + mbi.RegionSize;
791 1234 const bool protection_unsafe = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;
792
7/8
✓ Branch 13 → 14 taken 1234 times.
✗ Branch 13 → 18 not taken.
✓ Branch 14 → 15 taken 1230 times.
✓ Branch 14 → 18 taken 4 times.
✓ Branch 15 → 16 taken 1223 times.
✓ Branch 15 → 18 taken 7 times.
✓ Branch 16 → 17 taken 1222 times.
✓ Branch 16 → 18 taken 1 time.
1234 if (cursor < region_base || mbi.State != MEM_COMMIT || (mbi.Protect & EXECUTABLE_PAGE_FLAGS) == 0 ||
793
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 1222 times.
1222 protection_unsafe || region_end <= cursor)
794 {
795 12 return false;
796 }
797
798
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 1219 times.
1222 cursor = region_end < end ? region_end : end;
799 }
800 1219 return true;
801 }
802 } // namespace DetourModKit
803