GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 75.9% 44 / 0 / 58
Functions: 100.0% 4 / 0 / 4
Branches: 65.1% 28 / 0 / 43

src/internal/hook_fault_boundary.cpp
Line Branch Exec Source
1 #include "internal/hook_fault_boundary.hpp"
2
3 #include "internal/memory_guarded.hpp"
4 #include "internal/scan_pages.hpp"
5
6 #include <windows.h>
7
8 #include <array>
9 #include <cstdint>
10 #include <optional>
11 #include <string_view>
12
13 namespace DetourModKit
14 {
15 namespace
16 {
17 /// A function's half-open extent as declared by its unwind metadata.
18 struct FunctionBound
19 {
20 std::uintptr_t lo{0};
21 std::uintptr_t hi{0};
22 };
23
24 // Resolves the unwind-declared extent of the function containing target, or nullopt when the target declares
25 // none. Absence is the normal case for a leaf function or JIT-emitted code and is never treated as a refusal.
26 //
27 // The RUNTIME_FUNCTION the loader hands back points into the image's exception directory, which is ordinary
28 // process memory that a concurrent unload can withdraw; it is copied under the fault guard rather than
29 // dereferenced. A record whose extent is inverted or empty is discarded as untrustworthy.
30 //
31 // Only the directly registered entry is honoured. A chained entry names the primary fragment of a
32 // hot/cold-split function, which is a different, non-contiguous span that does not contain target and is
33 // therefore not a containment bound for it.
34 1078 std::optional<FunctionBound> unwind_bound(std::uintptr_t target) noexcept
35 {
36 1078 DWORD64 image_base = 0;
37 1078 const PRUNTIME_FUNCTION entry = RtlLookupFunctionEntry(target, &image_base, nullptr);
38
3/4
✓ Branch 3 → 4 taken 938 times.
✓ Branch 3 → 5 taken 140 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 938 times.
1078 if (entry == nullptr || image_base == 0)
39 {
40 140 return std::nullopt;
41 }
42
43 938 RUNTIME_FUNCTION record{};
44
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 938 times.
938 if (!detail::guarded_read_bytes(reinterpret_cast<std::uintptr_t>(entry), &record, sizeof(record)))
45 {
46 return std::nullopt;
47 }
48
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 938 times.
938 if (record.EndAddress <= record.BeginAddress)
49 {
50 return std::nullopt;
51 }
52 938 const auto base = static_cast<std::uintptr_t>(image_base);
53
2/4
✓ Branch 11 → 12 taken 938 times.
✗ Branch 11 → 13 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 938 times.
938 if (record.BeginAddress > UINTPTR_MAX - base || record.EndAddress > UINTPTR_MAX - base)
54 {
55 return std::nullopt;
56 }
57 938 const FunctionBound bound{base + record.BeginAddress, base + record.EndAddress};
58
2/4
✓ Branch 14 → 15 taken 938 times.
✗ Branch 14 → 16 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 938 times.
938 if (target < bound.lo || target >= bound.hi)
59 {
60 return std::nullopt;
61 }
62 938 return bound;
63 }
64 } // namespace
65
66 1089 detail::TargetWindowResult detail::validate_backend_steal_window(std::uintptr_t target) noexcept
67 {
68 // Executable and committed across the whole window. Stricter than testing the first byte: the backend decodes
69 // forward without a region bound, so a prologue whose tail runs off the end of its executable region would let
70 // the decoder consume an adjacent data page (or unmapped space) as instruction bytes.
71
2/2
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 5 taken 1078 times.
1089 if (!is_executable_range(target, BACKEND_MAX_STEAL_WINDOW))
72 {
73 11 return TargetWindowResult{TargetWindowVerdict::NotExecutable, target};
74 }
75
76 // Executable-and-committed is not readable. VirtualQuery reports the protection the OS recorded; it cannot
77 // report that a guard page will trap the first touch, or that a section's backing store will fail to fault in.
78 // Touching the bytes under the guard is the only way to learn that, and it consults no protection cache, so it
79 // cannot answer from a stale snapshot.
80 1078 std::array<std::uint8_t, BACKEND_MAX_STEAL_WINDOW> window{};
81 1078 volatile std::uintptr_t fault_address = target;
82
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1078 times.
2156 if (!guarded_read_bytes(target, window.data(), window.size(), &fault_address))
83 {
84 return TargetWindowResult{TargetWindowVerdict::Unreadable, fault_address};
85 }
86
87 // Which patch form runs is decided after this check. The fallback can overwrite more bytes than the near jump,
88 // so an unwind-bounded target must accommodate the fallback minimum even when the near form might be selected.
89 1078 const std::optional<FunctionBound> bound = unwind_bound(target);
90
6/6
✓ Branch 14 → 15 taken 938 times.
✓ Branch 14 → 18 taken 140 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 936 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 22 taken 1076 times.
1078 if (bound && target + BACKEND_FALLBACK_MIN_PATCH > bound->hi)
91 {
92 2 return TargetWindowResult{TargetWindowVerdict::BoundOverrun, bound->hi};
93 }
94
95 1076 return TargetWindowResult{TargetWindowVerdict::Ok, target};
96 }
97
98 12 std::string_view detail::target_window_description(TargetWindowVerdict verdict) noexcept
99 {
100
2/5
✓ Branch 2 → 3 taken 11 times.
✗ Branch 2 → 4 not taken.
✓ Branch 2 → 5 taken 1 time.
✗ Branch 2 → 6 not taken.
✗ Branch 2 → 7 not taken.
12 switch (verdict)
101 {
102 11 case TargetWindowVerdict::NotExecutable:
103 11 return "the bytes the backend decodes are not all executable committed memory";
104 case TargetWindowVerdict::Unreadable:
105 return "the bytes the backend decodes are not all readable";
106 1 case TargetWindowVerdict::BoundOverrun:
107 1 return "the target function is shorter than the largest patch minimum the backend may select";
108 case TargetWindowVerdict::Ok:
109 return "the target is hookable";
110 }
111 return "the target is hookable";
112 }
113
114 289 detail::ObjectWordResult detail::validate_vmt_object_word(std::uintptr_t object) noexcept
115 {
116 // Readability first, and by touching rather than querying: VirtualQuery cannot report that a guard page will
117 // trap the first access.
118 289 volatile std::uintptr_t fault_address = object;
119 289 std::uintptr_t vptr = 0;
120
2/2
✓ Branch 3 → 4 taken 24 times.
✓ Branch 3 → 5 taken 265 times.
289 if (!guarded_read_bytes(object, &vptr, sizeof(vptr), &fault_address))
121 {
122 24 return ObjectWordResult{ObjectWordVerdict::Unreadable, fault_address, 0};
123 }
124
125 // Readable does not imply writable. There is no non-mutating write probe, so ask the OS and let the later
126 // fault-contained publication compare-exchange close a displacement/protection/unmap race.
127 265 MEMORY_BASIC_INFORMATION info{};
128
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 265 times.
265 if (VirtualQuery(reinterpret_cast<LPCVOID>(object), &info, sizeof(info)) != sizeof(info))
129 {
130 return ObjectWordResult{ObjectWordVerdict::NotWritable, object, vptr};
131 }
132
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 265 times.
265 if (info.State != MEM_COMMIT)
133 {
134 return ObjectWordResult{ObjectWordVerdict::NotWritable, object, vptr};
135 }
136 // A guard armed before the read already failed it closed above. This catches one armed since: PAGE_GUARD traps
137 // the first access to a page whose protection otherwise reads as writable, so the word is not publishable even
138 // though its protection bits say it is.
139
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 265 times.
265 if ((info.Protect & PAGE_GUARD) != 0)
140 {
141 return ObjectWordResult{ObjectWordVerdict::NotWritable, object, vptr};
142 }
143 265 constexpr DWORD WRITABLE_PROTECTIONS =
144 PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
145
2/2
✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 14 taken 253 times.
265 if ((info.Protect & WRITABLE_PROTECTIONS) == 0)
146 {
147 12 return ObjectWordResult{ObjectWordVerdict::NotWritable, object, vptr};
148 }
149
150 // The word must not straddle two regions with different protections: VirtualQuery reports the region containing
151 // the first byte, so a pointer-sized word ending in a read-only neighbour would pass on its first byte alone.
152 // VirtualQuery places @p object inside the region it reports, so measuring the remainder as an offset from the
153 // region base keeps this arithmetic wrap-free without trusting the reported bounds not to overflow.
154 253 const std::uintptr_t offset_in_region = object - reinterpret_cast<std::uintptr_t>(info.BaseAddress);
155
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 253 times.
253 if (static_cast<std::uintptr_t>(info.RegionSize) - offset_in_region < sizeof(std::uintptr_t))
156 {
157 return ObjectWordResult{ObjectWordVerdict::NotWritable, object, vptr};
158 }
159
160 253 return ObjectWordResult{ObjectWordVerdict::Ok, object, vptr};
161 }
162 } // namespace DetourModKit
163