GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.4% 245 / 0 / 271
Functions: 100.0% 16 / 0 / 16
Branches: 78.0% 192 / 0 / 246

src/rtti.cpp
Line Branch Exec Source
1 /**
2 * @file rtti.cpp
3 * @brief Implementation of MSVC RTTI introspection primitives.
4 *
5 * Walks RTTICompleteObjectLocator -> TypeDescriptor -> mangled name through
6 * SEH-guarded reads. The COL layout is read in a single batch to minimise the number of guarded-read transitions; on
7 * MSVC each __try frame is essentially free, on MinGW each VirtualQuery is microseconds-class so batching matters.
8 *
9 * Every address derived from a COL field is bound-checked against the vtable's owning module range before being
10 * dereferenced. This guarantees that a forged or corrupted COL cannot redirect the walker to read from another loaded
11 * module or from an unrelated mapped region.
12 *
13 * The verified prelude (resolve_col_site) and the page-bounded name copy (read_name_seh) live in the internal
14 * rtti_internal.hpp header so the reverse dissector in rtti_dissect.cpp reuses them byte-for-byte rather than
15 * duplicating the walk.
16 */
17
18 #include "DetourModKit/rtti.hpp"
19 #include "DetourModKit/memory.hpp"
20
21 #include "rtti_internal.hpp"
22
23 #include <windows.h>
24
25 #include <cstring>
26
27 namespace DetourModKit
28 {
29 230787 bool Rtti::detail::resolve_col_site(std::uintptr_t vtable, ColSite &out) noexcept
30 {
31
2/2
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 4 taken 230782 times.
230787 if (vtable < MIN_VALID_PTR)
32 5 return false;
33
34 // Anchor every subsequent bound check against the vtable's owning module. Heap pointers and addresses inside
35 // unmapped ranges fail here without ever touching memory.
36 230782 const auto mod_range_opt = Memory::module_range_for(reinterpret_cast<const void *>(vtable));
37
2/2
✓ Branch 6 → 7 taken 11 times.
✓ Branch 6 → 8 taken 230771 times.
230782 if (!mod_range_opt)
38 11 return false;
39 230771 const auto mod_range = *mod_range_opt;
40
41 // vtable[-1] holds a pointer to the RTTICompleteObjectLocator. The COL must live in the same module as the
42 // vtable; a COL pointer escaping the module range is the signature of a forged or relocated structure and
43 // aborts the walk.
44 const auto col_ptr_opt =
45 230771 Memory::seh_read<std::uintptr_t>(static_cast<std::uintptr_t>(vtable + COL_OFFSET_FROM_VTABLE));
46
3/6
✓ Branch 11 → 12 taken 230771 times.
✗ Branch 11 → 15 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 230771 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 230771 times.
230771 if (!col_ptr_opt || !Memory::contains(mod_range, *col_ptr_opt))
47 return false;
48 230771 const std::uintptr_t col_addr = *col_ptr_opt;
49
50 // contains() above proved col_addr is in [base, end), but the seh_read<ColHead> below pulls sizeof(ColHead)
51 // bytes, which a COL sitting within that many bytes of the module end would straddle past. The SEH guard
52 // faults cleanly on an unmapped straddle, but reject the whole-span overrun up front so the walk never reads
53 // COL fields out of an adjacent mapped image. (mod_range.end - col_addr cannot underflow: col_addr < end.)
54
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 230771 times.
230771 if (mod_range.end - col_addr < sizeof(ColHead))
55 return false;
56
57 // Batched read: pulling all six COL fields in one SEH frame matters on
58 // MinGW where every guarded read translates into a VirtualQuery.
59 230771 const auto head_opt = Memory::seh_read<ColHead>(col_addr);
60
5/6
✓ Branch 24 → 25 taken 230771 times.
✗ Branch 24 → 27 not taken.
✓ Branch 26 → 27 taken 66256 times.
✓ Branch 26 → 28 taken 164515 times.
✓ Branch 29 → 30 taken 66256 times.
✓ Branch 29 → 31 taken 164515 times.
230771 if (!head_opt || head_opt->p_type_descriptor == 0)
61 66256 return false;
62
63 // Scope is x64 MSVC, where the COL signature is always 1 and carries pSelf, an RVA back to the COL itself.
64 // Reject any other signature
65 // outright: a non-x64 or corrupt signature has no pSelf to cross-check,
66 // so accepting it would skip the forgery guard below and fall through to the loader base unverified. The
67 // canonical IDA/Ghidra technique computes the image base as col_addr - p_self; cross-check that recovered base
68 // against the loader-reported module base so a forged p_self cannot bend the walk to another module.
69
2/2
✓ Branch 32 → 33 taken 164246 times.
✓ Branch 32 → 34 taken 269 times.
164515 if (head_opt->signature != COL_SIGNATURE_X64)
70 164246 return false;
71
3/6
✓ Branch 35 → 36 taken 269 times.
✗ Branch 35 → 38 not taken.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 269 times.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 269 times.
269 if (head_opt->p_self == 0 || col_addr < head_opt->p_self)
72 return false;
73
2/2
✓ Branch 43 → 44 taken 3 times.
✓ Branch 43 → 45 taken 266 times.
269 if (col_addr - head_opt->p_self != mod_range.base)
74 3 return false;
75
76 // Compute the TypeDescriptor and name-buffer addresses from the module base plus the type-descriptor RVA. A
77 // bogus RVA that places the name buffer outside the module range (or wraps the address space) is rejected by
78 // Memory::contains, which also requires the range to be valid. The name address is the strictly-higher of the
79 // two (td + 0x10), so bound-checking it implies td_addr is in range as well.
80 266 const std::uintptr_t td_addr = mod_range.base + head_opt->p_type_descriptor;
81 266 const std::uintptr_t name_addr = td_addr + TD_NAME_OFFSET;
82
2/2
✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 49 taken 262 times.
266 if (!Memory::contains(mod_range, name_addr))
83 4 return false;
84
85 262 out.col_addr = col_addr;
86 262 out.td_addr = td_addr;
87 262 out.name_addr = name_addr;
88 262 out.col_offset = head_opt->offset;
89 262 return true;
90 }
91
92 94 std::size_t Rtti::detail::read_name_seh(std::uintptr_t addr, char *out, std::size_t out_len) noexcept
93 {
94
2/4
✓ Branch 2 → 3 taken 94 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 94 times.
94 if (!out || out_len == 0)
95 return 0;
96 94 out[0] = '\0';
97
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 94 times.
94 if (addr < MIN_VALID_PTR)
98 return 0;
99
100 94 const std::size_t max_chars = out_len - 1;
101
102 // The temporary is capped at Rtti::MAX_TYPE_NAME_LEN, which is the documented hard upper bound for any single
103 // name read. Names produced by MSVC RTTI in practice never approach this bound, so the cap costs nothing.
104 char tmp[MAX_TYPE_NAME_LEN];
105 94 const std::size_t accum_cap = (max_chars < sizeof(tmp)) ? max_chars : sizeof(tmp);
106
107 94 std::size_t written = 0;
108 94 bool found_nul = false;
109 94 bool read_failed = false;
110
111
2/2
✓ Branch 22 → 8 taken 94 times.
✓ Branch 22 → 23 taken 2 times.
96 while (written < accum_cap)
112 {
113 94 const std::uintptr_t cur = addr + written;
114 94 const std::uintptr_t page_end = (cur | PAGE_MASK) + 1;
115 94 const std::size_t to_page_end = static_cast<std::size_t>(page_end - cur);
116 94 const std::size_t remaining = accum_cap - written;
117
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 94 times.
94 const std::size_t chunk = (to_page_end < remaining) ? to_page_end : remaining;
118
119 char buf[256];
120 94 const std::size_t this_read = (chunk > sizeof(buf)) ? sizeof(buf) : chunk;
121
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 94 times.
94 if (!Memory::seh_read_bytes(cur, buf, this_read))
122 {
123 read_failed = true;
124 92 break;
125 }
126
127 94 const auto *nul = static_cast<const char *>(std::memchr(buf, 0, this_read));
128
2/2
✓ Branch 15 → 16 taken 92 times.
✓ Branch 15 → 17 taken 2 times.
94 const std::size_t copy_len = nul ? static_cast<std::size_t>(nul - buf) : this_read;
129 94 std::memcpy(tmp + written, buf, copy_len);
130 94 written += copy_len;
131
2/2
✓ Branch 18 → 19 taken 92 times.
✓ Branch 18 → 20 taken 2 times.
94 if (nul)
132 {
133 92 found_nul = true;
134 92 break;
135 }
136 }
137
138 // Commit only when either a NUL was found or the buffer was filled to accum_cap without faulting. A read fault
139 // before either condition is reported as a clean failure: the caller-visible buffer stays empty and the return
140 // is zero.
141
1/4
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 26 taken 94 times.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 26 not taken.
94 if (read_failed && !found_nul)
142 {
143 out[0] = '\0';
144 return 0;
145 }
146
147 94 std::memcpy(out, tmp, written);
148 94 out[written] = '\0';
149 94 return written;
150 }
151
152 namespace
153 {
154 /**
155 * @brief Resolves the address of the mangled-name buffer for @p vtable.
156 * @details Thin wrapper over Rtti::detail::resolve_col_site that keeps the forward walker's "name address or
157 * zero" contract. Every failure mode of the prelude collapses to a 0 return.
158 * @return Address of the first byte of the NUL-terminated name, or 0 on any failure.
159 */
160 33 std::uintptr_t resolve_name_site(std::uintptr_t vtable) noexcept
161 {
162 33 Rtti::detail::ColSite site;
163
2/2
✓ Branch 3 → 4 taken 21 times.
✓ Branch 3 → 5 taken 12 times.
33 return Rtti::detail::resolve_col_site(vtable, site) ? site.name_addr : 0;
164 }
165 } // anonymous namespace
166
167 10 std::optional<std::string> Rtti::type_name_of(std::uintptr_t vtable, std::size_t max_len) noexcept
168 {
169 10 const std::uintptr_t name_addr = resolve_name_site(vtable);
170
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 5 taken 5 times.
10 if (name_addr == 0)
171 5 return std::nullopt;
172
173
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 4 times.
5 if (max_len == 0)
174 1 max_len = DEFAULT_TYPE_NAME_MAX;
175
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 5 times.
5 if (max_len > MAX_TYPE_NAME_LEN)
176 max_len = MAX_TYPE_NAME_LEN;
177
178 5 std::string out;
179 try
180 {
181
1/2
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 21 not taken.
5 out.resize(max_len + 1);
182 }
183 catch (...)
184 {
185 return std::nullopt;
186 }
187 5 const std::size_t len = detail::read_name_seh(name_addr, out.data(), out.size());
188
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 4 times.
5 if (len == 0)
189 1 return std::nullopt;
190 4 out.resize(len);
191 4 return out;
192 5 }
193
194 6 std::size_t Rtti::type_name_into(std::uintptr_t vtable, char *out, std::size_t out_len) noexcept
195 {
196
4/4
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
6 if (!out || out_len == 0)
197 2 return 0;
198 4 out[0] = '\0';
199 4 const std::uintptr_t name_addr = resolve_name_site(vtable);
200
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 2 times.
4 if (name_addr == 0)
201 2 return 0;
202 2 return detail::read_name_seh(name_addr, out, out_len);
203 }
204
205 21 bool Rtti::vtable_is_type(std::uintptr_t vtable, std::string_view expected) noexcept
206 {
207
6/6
✓ Branch 3 → 4 taken 20 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 19 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 19 times.
21 if (expected.empty() || expected.size() >= MAX_TYPE_NAME_LEN)
208 2 return false;
209
210 19 const std::uintptr_t name_addr = resolve_name_site(vtable);
211
2/2
✓ Branch 11 → 12 taken 5 times.
✓ Branch 11 → 13 taken 14 times.
19 if (name_addr == 0)
212 5 return false;
213
214 // Read expected.size() + 1 bytes to capture the terminating NUL. A name that lacks the NUL at expected.size()
215 // is either longer (a superstring) or unreadable past that point; both are rejected.
216 char buf[MAX_TYPE_NAME_LEN + 1];
217 14 const std::size_t need = expected.size() + 1;
218
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 14 times.
14 if (!Memory::seh_read_bytes(name_addr, buf, need))
219 return false;
220
2/2
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 20 taken 12 times.
14 if (buf[expected.size()] != '\0')
221 2 return false;
222 12 return std::memcmp(buf, expected.data(), expected.size()) == 0;
223 }
224
225 13 std::optional<std::uintptr_t> Rtti::find_in_pointer_table(std::uintptr_t table, std::size_t slot_count,
226 std::string_view expected,
227 std::atomic<std::uintptr_t> *vtable_cache,
228 std::size_t stride) noexcept
229 {
230
8/8
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 10 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 10 times.
13 if (table < detail::MIN_VALID_PTR || slot_count == 0 || expected.empty())
231 3 return std::nullopt;
232
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 9 times.
10 if (stride == 0)
233 1 stride = sizeof(std::uintptr_t);
234
235 // Overflow guard on the addressable span. Two failure modes are
236 // possible:
237 // 1. (slot_count * stride) overflows std::size_t.
238 // 2. (table + span) overflows std::uintptr_t.
239 // The first check rejects (1) directly; the second catches (2) by comparing the wrapped sum against the base. A
240 // malformed (table, stride, slot_count) tuple is treated as an empty table.
241
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 10 times.
10 if (slot_count > SIZE_MAX / stride)
242 return std::nullopt;
243 10 const std::uintptr_t span = static_cast<std::uintptr_t>(slot_count * stride);
244
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 9 times.
10 if (table + span < table)
245 1 return std::nullopt;
246
247 9 std::uintptr_t cached_vt = 0;
248
2/2
✓ Branch 16 → 17 taken 3 times.
✓ Branch 16 → 25 taken 6 times.
9 if (vtable_cache)
249 6 cached_vt = vtable_cache->load(std::memory_order_relaxed);
250
251
2/2
✓ Branch 67 → 26 taken 16 times.
✓ Branch 67 → 68 taken 2 times.
18 for (std::size_t i = 0; i < slot_count; ++i)
252 {
253 16 const std::uintptr_t slot_addr = table + i * stride;
254
255 16 const auto obj_opt = Memory::seh_read<std::uintptr_t>(slot_addr);
256
5/6
✓ Branch 28 → 29 taken 16 times.
✗ Branch 28 → 31 not taken.
✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 32 taken 12 times.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 35 taken 12 times.
16 if (!obj_opt || *obj_opt < detail::MIN_VALID_PTR)
257 6 continue;
258 12 const std::uintptr_t obj = *obj_opt;
259
260 12 const auto vt_opt = Memory::seh_read<std::uintptr_t>(obj);
261
3/6
✓ Branch 38 → 39 taken 12 times.
✗ Branch 38 → 41 not taken.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 12 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 12 times.
12 if (!vt_opt || *vt_opt < detail::MIN_VALID_PTR)
262 continue;
263 12 const std::uintptr_t vt = *vt_opt;
264
265
2/2
✓ Branch 46 → 47 taken 3 times.
✓ Branch 46 → 50 taken 9 times.
12 if (cached_vt != 0)
266 {
267
2/2
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 49 taken 2 times.
3 if (vt == cached_vt)
268 7 return obj;
269 2 continue;
270 }
271
272
2/2
✓ Branch 51 → 52 taken 6 times.
✓ Branch 51 → 63 taken 3 times.
9 if (vtable_is_type(vt, expected))
273 {
274
2/2
✓ Branch 52 → 53 taken 1 time.
✓ Branch 52 → 62 taken 5 times.
6 if (vtable_cache)
275 1 vtable_cache->store(vt, std::memory_order_relaxed);
276 6 return obj;
277 }
278 }
279 2 return std::nullopt;
280 }
281
282 namespace
283 {
284 // Upper bound on distinct sub-object vtables collected for one mangled name. Multiple/virtual inheritance
285 // produces a handful at most; the cap keeps the reverse scan allocation-free (matches live in a stack array)
286 // and bounds a pathological duplicate-type image.
287 inline constexpr std::size_t MAX_REVERSE_MATCHES = 64;
288
289 // One readable, non-executable image-resident scan window.
290 struct ScanRange
291 {
292 std::uintptr_t begin = 0;
293 std::uintptr_t end = 0;
294 };
295
296 // A validated reverse-RTTI hit: the vtable and the COL.offset of the sub-object it belongs to (0 ==
297 // primary/most-derived).
298 struct VtMatch
299 {
300 std::uintptr_t vtable = 0;
301 std::uint32_t col_offset = 0;
302 };
303
304 /**
305 * @brief Enumerates the module's readable, non-executable, non-discardable sections -- where MSVC keeps vtables
306 * and their
307 * RTTI meta-pointers (.rdata for a normal /GR image, .data for a packed or section-merged one).
308 * @details .text is skipped on purpose: a vtable's [-1] COL meta-slot never lives in executable code, and
309 * sweeping code pages qword-by-qword would multiply the one-time cost for nothing. Every header field
310 * is bound- and signature-checked so a malformed or hostile image fails closed instead of being read
311 * through.
312 * @return The number of ranges written into @p out; 0 means the PE headers could not be parsed and the caller
313 * falls back to the whole module image.
314 */
315 23 std::size_t collect_rtti_scan_ranges(Memory::ModuleRange mod, ScanRange *out, std::size_t cap) noexcept
316 {
317 23 const auto dos = Memory::seh_read<IMAGE_DOS_HEADER>(mod.base);
318
5/6
✓ Branch 4 → 5 taken 23 times.
✗ Branch 4 → 7 not taken.
✓ Branch 6 → 7 taken 22 times.
✓ Branch 6 → 8 taken 1 time.
✓ Branch 9 → 10 taken 22 times.
✓ Branch 9 → 11 taken 1 time.
23 if (!dos || dos->e_magic != IMAGE_DOS_SIGNATURE)
319 22 return 0;
320
321 const std::uintptr_t nt_addr =
322 1 mod.base + static_cast<std::uintptr_t>(static_cast<std::uint32_t>(dos->e_lfanew));
323 // The NT headers must lie inside the image; a wild e_lfanew is the signature of a forged or truncated
324 // header.
325
3/6
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 16 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 1 time.
1 if (!Memory::contains(mod, nt_addr) || !Memory::contains(mod, nt_addr + sizeof(IMAGE_NT_HEADERS64)))
326 return 0;
327
328 1 const auto nt = Memory::seh_read<IMAGE_NT_HEADERS64>(nt_addr);
329
4/8
✓ Branch 22 → 23 taken 1 time.
✗ Branch 22 → 27 not taken.
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 27 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 1 time.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 1 time.
1 if (!nt || nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
330 return 0;
331
332 // The Windows loader caps a PE at 96 sections; a larger count is corrupt and would otherwise let the loop
333 // below run away.
334 1 const std::uint32_t num_sections = nt->FileHeader.NumberOfSections;
335
2/4
✓ Branch 32 → 33 taken 1 time.
✗ Branch 32 → 34 not taken.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 1 time.
1 if (num_sections == 0 || num_sections > 96)
336 return 0;
337
338 // IMAGE_FIRST_SECTION: the section table starts immediately after the
339 // optional header, whose length is SizeOfOptionalHeader. Using sizeof(IMAGE_NT_HEADERS64) instead would
340 // misplace the table whenever the optional-header size differs from the compile-time struct size.
341 const std::uintptr_t sec_table =
342 1 nt_addr + offsetof(IMAGE_NT_HEADERS64, OptionalHeader) + nt->FileHeader.SizeOfOptionalHeader;
343
344 1 std::size_t count = 0;
345
3/4
✓ Branch 63 → 64 taken 18 times.
✓ Branch 63 → 65 taken 1 time.
✓ Branch 64 → 37 taken 18 times.
✗ Branch 64 → 65 not taken.
19 for (std::uint32_t i = 0; i < num_sections && count < cap; ++i)
346 {
347 18 const std::uintptr_t hdr_addr =
348 18 sec_table + static_cast<std::uintptr_t>(i) * sizeof(IMAGE_SECTION_HEADER);
349
1/2
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 18 times.
18 if (!Memory::contains(mod, hdr_addr + sizeof(IMAGE_SECTION_HEADER)))
350 break;
351
352 18 const auto sec = Memory::seh_read<IMAGE_SECTION_HEADER>(hdr_addr);
353
1/2
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 18 times.
18 if (!sec)
354 break;
355
356 18 const std::uint32_t ch = sec->Characteristics;
357 18 const bool readable = (ch & IMAGE_SCN_MEM_READ) != 0;
358 18 const bool executable = (ch & IMAGE_SCN_MEM_EXECUTE) != 0;
359 18 const bool discardable = (ch & IMAGE_SCN_MEM_DISCARDABLE) != 0;
360
5/6
✓ Branch 45 → 46 taken 18 times.
✗ Branch 45 → 48 not taken.
✓ Branch 46 → 47 taken 17 times.
✓ Branch 46 → 48 taken 1 time.
✓ Branch 47 → 48 taken 10 times.
✓ Branch 47 → 49 taken 7 times.
18 if (!readable || executable || discardable)
361 11 continue;
362
363 // Use the in-memory extent (VirtualAddress + VirtualSize), never the on-disk
364 // PointerToRawData/SizeOfRawData: those are file offsets and do not survive section alignment once
365 // mapped.
366 7 const std::uintptr_t begin = mod.base + sec->VirtualAddress;
367 7 const std::uintptr_t end = begin + sec->Misc.VirtualSize;
368
4/8
✓ Branch 51 → 52 taken 7 times.
✗ Branch 51 → 55 not taken.
✓ Branch 53 → 54 taken 7 times.
✗ Branch 53 → 55 not taken.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 56 taken 7 times.
✗ Branch 57 → 58 not taken.
✓ Branch 57 → 59 taken 7 times.
7 if (end <= begin || !Memory::contains(mod, begin) || end > mod.end)
369 continue;
370
371 7 out[count].begin = begin;
372 7 out[count].end = end;
373 7 ++count;
374 }
375 1 return count;
376 }
377
378 /**
379 * @brief Byte-exact comparison of the NUL-terminated name at @p name_addr with @p mangled, including the
380 * terminator so a superstring does not match.
381 * @details Mirrors vtable_is_type's name check; the caller guarantees mangled.size() < MAX_TYPE_NAME_LEN.
382 */
383 154 [[nodiscard]] bool name_equals(std::uintptr_t name_addr, std::string_view mangled) noexcept
384 {
385 char buf[Rtti::MAX_TYPE_NAME_LEN + 1];
386 154 const std::size_t need = mangled.size() + 1;
387
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 154 times.
154 if (!Memory::seh_read_bytes(name_addr, buf, need))
388 return false;
389
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 152 times.
154 if (buf[mangled.size()] != '\0')
390 2 return false;
391 152 return std::memcmp(buf, mangled.data(), mangled.size()) == 0;
392 }
393
394 /**
395 * @brief Sweeps one [begin,end) window for vtables whose RTTI name equals @p mangled, appending validated,
396 * deduped matches to @p out (capped at @p cap).
397 * @details The window is read in page-bounded chunks (one guarded read per page) and scanned in-process, so the
398 * guarded-read count is per-page rather than per-qword -- the difference between a few hundred and a
399 * few hundred thousand guarded transitions over a multi-megabyte section. A meta-slot is a qword that
400 * points to an in-module COL; the vtable that owns it is slot + 8, validated by the same COL prelude
401 * the forward walker uses, so the in-range pre-filter only spares a deeper read and never decides
402 * correctness.
403 */
404 29 void sweep_range_for_name(Memory::ModuleRange mod, std::uintptr_t begin, std::uintptr_t end,
405 std::string_view mangled, VtMatch *out, std::size_t cap, std::size_t &count) noexcept
406 {
407 // Image-resident pointer storage is 8-byte aligned, so only qword-aligned slots can hold a vtable
408 // meta-pointer.
409 29 std::uintptr_t addr = (begin + 7) & ~static_cast<std::uintptr_t>(7);
410
411
3/4
✓ Branch 34 → 35 taken 3364 times.
✓ Branch 34 → 36 taken 29 times.
✓ Branch 35 → 3 taken 3364 times.
✗ Branch 35 → 36 not taken.
3393 while (addr + sizeof(std::uintptr_t) <= end && count < cap)
412 {
413 // Never let one guarded read cross a page boundary: an unmapped page then fails only its own chunk, not
414 // the whole window.
415 3364 const std::uintptr_t page_end = (addr | Rtti::detail::PAGE_MASK) + 1;
416
2/2
✓ Branch 3 → 4 taken 29 times.
✓ Branch 3 → 5 taken 3335 times.
3364 const std::uintptr_t chunk_end = (page_end < end) ? page_end : end;
417 3364 const std::size_t qwords = static_cast<std::size_t>(chunk_end - addr) / sizeof(std::uintptr_t);
418
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 3364 times.
3364 if (qwords == 0)
419 {
420 addr = chunk_end;
421 continue;
422 }
423
424 // a 4 KiB page holds at most 512 qwords
425 std::uintptr_t buf[512];
426 3364 const std::size_t want = (qwords < 512) ? qwords : 512;
427
1/2
✓ Branch 9 → 10 taken 3364 times.
✗ Branch 9 → 32 not taken.
3364 if (Memory::seh_read_bytes(addr, buf, want * sizeof(std::uintptr_t)))
428 {
429
4/4
✓ Branch 30 → 31 taken 1710523 times.
✓ Branch 30 → 32 taken 3362 times.
✓ Branch 31 → 11 taken 1710521 times.
✓ Branch 31 → 32 taken 2 times.
1713885 for (std::size_t j = 0; j < want && count < cap; ++j)
430 {
431 // Pre-filter: a meta-slot holds a pointer to a COL inside
432 // this module. Most qwords fail here without a second read.
433
2/2
✓ Branch 12 → 13 taken 1479865 times.
✓ Branch 12 → 14 taken 230656 times.
1710521 if (!Memory::contains(mod, buf[j]))
434 1710370 continue;
435
436 230656 const std::uintptr_t slot_addr = addr + j * sizeof(std::uintptr_t);
437 230656 const std::uintptr_t candidate_vtable = slot_addr + sizeof(std::uintptr_t);
438
439 230656 Rtti::detail::ColSite site;
440
2/2
✓ Branch 15 → 16 taken 230502 times.
✓ Branch 15 → 17 taken 154 times.
230656 if (!Rtti::detail::resolve_col_site(candidate_vtable, site))
441 230502 continue;
442
2/2
✓ Branch 18 → 19 taken 3 times.
✓ Branch 18 → 20 taken 151 times.
154 if (!name_equals(site.name_addr, mangled))
443 3 continue;
444
445 151 bool seen = false;
446
2/2
✓ Branch 24 → 21 taken 4042 times.
✓ Branch 24 → 25 taken 151 times.
4193 for (std::size_t k = 0; k < count; ++k)
447 {
448
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 4042 times.
4042 if (out[k].vtable == candidate_vtable)
449 {
450 seen = true;
451 break;
452 }
453 }
454
1/2
✓ Branch 25 → 26 taken 151 times.
✗ Branch 25 → 27 not taken.
151 if (!seen)
455 {
456 151 out[count].vtable = candidate_vtable;
457 151 out[count].col_offset = site.col_offset;
458 151 ++count;
459 }
460 }
461 }
462
463 3364 addr += want * sizeof(std::uintptr_t);
464 }
465 29 }
466
467 /**
468 * @brief Finds vtables for @p mangled across the module's RTTI-bearing sections, returning the count written
469 * into @p out (deduped, capped at @p cap).
470 * @details Sweeps the readable non-executable sections; if the PE headers cannot be parsed, falls back to the
471 * whole module image rather than reporting a confident-but-wrong "not found" for a packed or merged
472 * binary.
473 */
474 27 std::size_t scan_vtables_for_name(Memory::ModuleRange mod, std::string_view mangled, VtMatch *out,
475 std::size_t cap) noexcept
476 {
477
6/8
✓ Branch 3 → 4 taken 23 times.
✓ Branch 3 → 8 taken 4 times.
✓ Branch 5 → 6 taken 23 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 23 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 12 taken 23 times.
27 if (!mod.valid() || mangled.empty() || mangled.size() >= Rtti::MAX_TYPE_NAME_LEN)
478 4 return 0;
479
480 23 ScanRange ranges[32];
481 23 const std::size_t range_count = collect_rtti_scan_ranges(mod, ranges, 32);
482
483 23 std::size_t count = 0;
484
2/2
✓ Branch 13 → 14 taken 22 times.
✓ Branch 13 → 16 taken 1 time.
23 if (range_count == 0)
485 {
486 // No usable scan window (the PE headers could not be parsed, or no readable non-executable section
487 // qualified): sweep the whole image rather than report a confident-but-wrong "not found" for a packed
488 // or section-merged binary. The whole-image sweep is a strict superset, and resolve_col_site still
489 // validates every candidate.
490 22 sweep_range_for_name(mod, mod.base, mod.end, mangled, out, cap, count);
491 22 return count;
492 }
493
494
3/4
✓ Branch 19 → 20 taken 7 times.
✓ Branch 19 → 21 taken 1 time.
✓ Branch 20 → 17 taken 7 times.
✗ Branch 20 → 21 not taken.
8 for (std::size_t i = 0; i < range_count && count < cap; ++i)
495 {
496 7 sweep_range_for_name(mod, ranges[i].begin, ranges[i].end, mangled, out, cap, count);
497 }
498 1 return count;
499 }
500 } // anonymous namespace
501
502 18 std::optional<std::uintptr_t> Rtti::vtable_for_type(std::string_view mangled, Memory::ModuleRange range) noexcept
503 {
504 18 VtMatch matches[MAX_REVERSE_MATCHES];
505 18 const std::size_t match_count = scan_vtables_for_name(range, mangled, matches, MAX_REVERSE_MATCHES);
506
507 // The primary vtable is the COL.offset == 0 sub-object: the value an object pointer's first qword holds for a
508 // most-derived instance, i.e. the faithful inverse of vtable_is_type. More than one distinct primary for the
509 // same name (a type linked into the image twice) is ambiguous; fail closed rather than return an arbitrary one.
510 18 std::optional<std::uintptr_t> primary;
511
2/2
✓ Branch 17 → 4 taken 75 times.
✓ Branch 17 → 18 taken 16 times.
91 for (std::size_t i = 0; i < match_count; ++i)
512 {
513
2/2
✓ Branch 4 → 5 taken 64 times.
✓ Branch 4 → 6 taken 11 times.
75 if (matches[i].col_offset != 0)
514 64 continue;
515
5/6
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 11 taken 9 times.
✓ Branch 9 → 10 taken 2 times.
✗ Branch 9 → 11 not taken.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 14 taken 9 times.
11 if (primary && *primary != matches[i].vtable)
516 2 return std::nullopt;
517 9 primary = matches[i].vtable;
518 }
519
520 // scan_vtables_for_name saturated its match buffer (match_count reached the cap), so vtables for this name may
521 // exist beyond MAX_REVERSE_MATCHES that the loop above never inspected -- including a second distinct primary
522 // that would make the result ambiguous. The uniqueness guarantee cannot hold across a truncated scan, so fail
523 // closed rather than hand back a primary that might not be the only one. A name with this many sub-object
524 // vtables is already a pathological / ODR-duplicated image; the documented happy case is a handful.
525
5/6
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 22 taken 15 times.
✓ Branch 20 → 21 taken 1 time.
✗ Branch 20 → 22 not taken.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 25 taken 15 times.
16 if (match_count == MAX_REVERSE_MATCHES && primary)
526 1 return std::nullopt;
527
528 15 return primary;
529 }
530
531 9 std::size_t Rtti::vtables_for_type(std::string_view mangled, std::uintptr_t *out, std::size_t out_cap,
532 Memory::ModuleRange range) noexcept
533 {
534
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 8 times.
9 if (!out)
535 1 out_cap = 0;
536
537 9 VtMatch matches[MAX_REVERSE_MATCHES];
538 9 const std::size_t match_count = scan_vtables_for_name(range, mangled, matches, MAX_REVERSE_MATCHES);
539
540 // Ascending COL.offset so the primary (offset 0) sorts first. The match count is tiny (one per base
541 // sub-object), so an in-place insertion sort is both adequate and allocation-free.
542
2/2
✓ Branch 11 → 6 taken 69 times.
✓ Branch 11 → 12 taken 9 times.
78 for (std::size_t i = 1; i < match_count; ++i)
543 {
544 69 const VtMatch key = matches[i];
545 69 std::size_t sorted = i;
546
4/4
✓ Branch 8 → 9 taken 70 times.
✓ Branch 8 → 10 taken 1 time.
✓ Branch 9 → 7 taken 2 times.
✓ Branch 9 → 10 taken 68 times.
71 while (sorted > 0 && matches[sorted - 1].col_offset > key.col_offset)
547 {
548 2 matches[sorted] = matches[sorted - 1];
549 2 --sorted;
550 }
551 69 matches[sorted] = key;
552 }
553
554
2/2
✓ Branch 12 → 13 taken 3 times.
✓ Branch 12 → 14 taken 6 times.
9 const std::size_t to_write = (match_count < out_cap) ? match_count : out_cap;
555
2/2
✓ Branch 17 → 16 taken 73 times.
✓ Branch 17 → 18 taken 9 times.
82 for (std::size_t i = 0; i < to_write; ++i)
556 73 out[i] = matches[i].vtable;
557 9 return match_count;
558 }
559
560 3 Rtti::TypeIdentity::TypeIdentity(std::string_view mangled, Memory::ModuleRange range) noexcept
561 3 : m_mangled(mangled), m_range(range)
562 {
563 3 }
564
565 9 std::optional<std::uintptr_t> Rtti::TypeIdentity::vtable() const noexcept
566 {
567 // Fast path: a completed resolve stored m_cached before publishing m_resolved with release, so an acquire-load
568 // that sees m_resolved also sees the cached value.
569
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 16 taken 5 times.
9 if (m_resolved.load(std::memory_order_acquire))
570 {
571 4 const std::uintptr_t cached = m_cached.load(std::memory_order_relaxed);
572
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 13 not taken.
4 return cached != 0 ? std::optional<std::uintptr_t>(cached) : std::nullopt;
573 }
574
575 // First use (or a retry after an earlier miss): resolve and cache. Concurrent first-callers converge on the
576 // same vtable, so a benign double-resolve needs no lock.
577 5 const auto resolved = vtable_for_type(m_mangled, m_range);
578
579 // Latch only a SUCCESSFUL resolve as permanent. A failed resolve is not cached: the vtable may simply not be
580 // mapped yet (the owning module loads later, or a game patch is mid-relocation), so leaving m_resolved false
581 // lets a subsequent call retry once the type becomes resolvable rather than wedging on a stale miss forever.
582 // The store of m_cached is published with release before m_resolved, so the fast path's acquire-load that sees
583 // m_resolved == true also sees the cached value.
584
2/2
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 29 taken 3 times.
5 if (resolved)
585 {
586 2 m_cached.store(*resolved, std::memory_order_release);
587 2 m_resolved.store(true, std::memory_order_release);
588 }
589 5 return resolved;
590 }
591
592 3 bool Rtti::TypeIdentity::matches(std::uintptr_t vtable) const noexcept
593 {
594 3 const auto resolved = TypeIdentity::vtable();
595
4/4
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 8 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 1 time.
3 return resolved.has_value() && *resolved == vtable;
596 }
597 } // namespace DetourModKit
598