./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
18 / 22
81.8%
Branches:
11 / 16
68.8%
Functions:
3 / 3
100.0%

src/internal/rtti_shared.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_RTTI_SHARED_HPP
2 #define DETOURMODKIT_INTERNAL_RTTI_SHARED_HPP
3
4 /**
5 * @file internal/rtti_shared.hpp
6 * @brief Shared MSVC RTTI prelude for the rtti.cpp and rtti_dissect.cpp TUs.
7 *
8 * Houses the verified COL -> TypeDescriptor walk (@ref resolve_col_site) and the page-bounded name copy (@ref
9 * read_name_seh) that both the forward walker (type_name_of / vtable_is_type) and the reverse dissector
10 * (identify_pointee_type / heal_landmark) consume. The scan resolver's RTTI rung consumes @ref primary_vtable_checked.
11 * The structures and helpers live in DetourModKit::rtti::detail and are NOT part of the installed public surface.
12 * ColHead in particular encodes a raw ABI layout that must never leak into a consumer-visible header.
13 */
14
15 #include "DetourModKit/rtti.hpp"
16
17 #include <cstddef>
18 #include <cstdint>
19 #include <limits>
20 #include <optional>
21 #include <string_view>
22 #include <type_traits>
23
24 namespace DetourModKit
25 {
26 // Forward declaration so the ModuleSpan overload of resolve_col_site can be declared here without pulling the
27 // guarded-memory engine header into this internal header. Both TUs that define or call the overload (rtti.cpp,
28 // rtti_dissect.cpp) already include the full definition via internal/memory_guarded.hpp.
29 namespace detail
30 {
31 struct ModuleSpan;
32 }
33
34 namespace rtti
35 {
36 namespace detail
37 {
38 // MSVC x64 RTTI ABI layout constants shared by the forward and reverse walkers.
39 inline constexpr std::ptrdiff_t COL_OFFSET_FROM_VTABLE = -8;
40 inline constexpr std::ptrdiff_t TD_NAME_OFFSET = 0x10;
41 inline constexpr std::uint32_t COL_SIGNATURE_X64 = 1;
42 inline constexpr std::uintptr_t MIN_VALID_PTR = 0x10000;
43
44 // Subtraction from PAGE_SIZE keeps the final page bound valid at UINTPTR_MAX. Proof:
45 // RttiReverseWrapTest.VtableForTypeAtMaxMinusFifteenTerminates and
46 // RttiReverseWrapTest.RegionRttiPresenceAtMaxMinusFifteenTerminates.
47 inline constexpr std::uintptr_t PAGE_MASK = 0xFFF;
48 inline constexpr std::uintptr_t PAGE_SIZE = PAGE_MASK + 1;
49
50 /**
51 * @brief Two's-complement magnitude of a signed offset, defined for every value including PTRDIFF_MIN.
52 * @details The naive `(v < 0) ? -v : v` is undefined behavior at PTRDIFF_MIN, whose magnitude is not
53 * representable in std::ptrdiff_t. Computing the negation in the unsigned domain (2^64 - v) yields
54 * the exact magnitude for every input, so a drift/delta comparison never trips signed-overflow UB.
55 */
56 4 [[nodiscard]] constexpr std::uint64_t ptrdiff_magnitude(std::ptrdiff_t value) noexcept
57 {
58 4 const std::uint64_t bits = static_cast<std::uint64_t>(value);
59
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 2 times.
4 return (value < 0) ? (~bits + 1U) : bits;
60 }
61
62 /**
63 * @brief Subtracts two offsets and saturates when their mathematical difference is not representable.
64 * @details Saturation preserves the direction and severity of an extreme drift; wrapping could turn
65 * PTRDIFF_MAX - PTRDIFF_MIN into -1 and suppress a configured drift warning.
66 */
67 11 [[nodiscard]] constexpr std::ptrdiff_t saturating_sub(std::ptrdiff_t a, std::ptrdiff_t b) noexcept
68 {
69 11 constexpr std::ptrdiff_t MIN = std::numeric_limits<std::ptrdiff_t>::min();
70 11 constexpr std::ptrdiff_t MAX = std::numeric_limits<std::ptrdiff_t>::max();
71
4/4
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 9 times.
11 if (b > 0 && a < MIN + b)
72 1 return MIN;
73
3/4
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 8 taken 9 times.
✓ Branch 6 → 7 taken 1 time.
✗ Branch 6 → 8 not taken.
10 if (b < 0 && a > MAX + b)
74 1 return MAX;
75 9 return a - b;
76 }
77
78 /** @brief Returns @p address - @p base as a signed offset, saturating when it is not representable. */
79 34 [[nodiscard]] constexpr std::ptrdiff_t address_offset(std::uintptr_t address, std::uintptr_t base) noexcept
80 {
81 34 constexpr auto MIN = std::numeric_limits<std::ptrdiff_t>::min();
82 34 constexpr auto MAX = std::numeric_limits<std::ptrdiff_t>::max();
83 34 constexpr std::uint64_t MIN_MAGNITUDE = static_cast<std::uint64_t>(MAX) + 1U;
84
1/2
✓ Branch 2 → 3 taken 34 times.
✗ Branch 2 → 7 not taken.
34 if (address >= base)
85 {
86 34 const std::uint64_t difference = address - base;
87
1/2
✓ Branch 3 → 4 taken 34 times.
✗ Branch 3 → 5 not taken.
34 return difference > static_cast<std::uint64_t>(MAX) ? MAX : static_cast<std::ptrdiff_t>(difference);
88 }
89 ✗ const std::uint64_t difference = base - address;
90
0/2
✗ Branch 7 → 8 not taken.
✗ Branch 7 → 9 not taken.
✗ if (difference >= MIN_MAGNITUDE)
91 ✗ return MIN;
92 ✗ return -static_cast<std::ptrdiff_t>(difference);
93 }
94
95 /**
96 * @struct ColHead
97 * @brief First 24 bytes of an MSVC x64 RTTICompleteObjectLocator.
98 * @details The MSVC ABI fixes the field order. A packed POD lets resolve_col_site() pull every field in one
99 * guarded read instead of six. The struct is trivially copyable so guarded_read<ColHead>
100 * instantiates cleanly.
101 */
102 struct ColHead
103 {
104 std::uint32_t signature;
105 std::uint32_t offset;
106 std::uint32_t cd_offset;
107 std::uint32_t p_type_descriptor;
108 std::uint32_t p_class_descriptor;
109 std::uint32_t p_self;
110 };
111 static_assert(sizeof(ColHead) == 24, "ColHead must match MSVC x64 ABI layout");
112 static_assert(std::is_trivially_copyable_v<ColHead>);
113
114 /**
115 * @struct ColSite
116 * @brief Fully resolved COL/TypeDescriptor coordinates for a vtable.
117 * @details Output of @ref resolve_col_site. Carries every address the verified prelude recovers so a caller
118 * can read the name (@ref name_addr) or recover the complete object from @ref col_offset without
119 * re-walking the COL.
120 */
121 struct ColSite
122 {
123 /// Address of the COL the vtable points back to.
124 std::uintptr_t col_addr = 0;
125 /// TypeDescriptor base (image base + COL.pTypeDescriptor RVA).
126 std::uintptr_t td_addr = 0;
127 /// Mangled-name buffer (td_addr + TD_NAME_OFFSET).
128 std::uintptr_t name_addr = 0;
129 /// Exclusive end [base, end) of the owning module; bounds the name read (see @ref read_name_seh).
130 std::uintptr_t module_end = 0;
131 /// COL.offset (+0x04): this vtable's offset in the complete object.
132 std::uint32_t col_offset = 0;
133 };
134
135 /**
136 * @brief Runs the verified COL prelude for @p vtable and reports every recovered coordinate in @p out.
137 * @details Every intermediate address (the COL pointer, the pSelf-recovered image base, and the final name
138 * buffer) is required to lie inside the vtable's owning module range. This forces a poisoned COL
139 * to fail closed: a forged pTypeDescriptor that would otherwise redirect a read to another loaded
140 * module or to unmapped memory is rejected here instead of read-through. @p out is left untouched
141 * on any failure.
142 * @param vtable Runtime vtable pointer (first qword of the object).
143 * @param out Receives the resolved coordinates on success only.
144 * @return true on a fully validated walk, false for every failure mode (vtable not in a loaded module,
145 * unreadable COL, COL pointer escaping the module range, pSelf disagreeing with the loader-reported
146 * base, zero TypeDescriptor RVA, name address escaping the module range).
147 */
148 [[nodiscard]] bool resolve_col_site(std::uintptr_t vtable, ColSite &out) noexcept;
149
150 /**
151 * @brief resolve_col_site variant that reuses a caller-held owning-module span.
152 * @details Identical validation to the single-argument overload, but the caller supplies @p mod_range (the
153 * span of the module that owns @p vtable) instead of this function resolving it via
154 * memory::module_of. The reverse sweep already holds the module span for every candidate it tests,
155 * so routing through this overload removes a per-candidate GetModuleHandleExW loader lookup from
156 * the hot RTTI scan - the per-frame cliff a TypeIdentity polled for an absent type hits. The
157 * overload requires @p vtable to lie inside @p mod_range (the same invariant module_of grants
158 * implicitly: the loader only resolves a module that contains the address), so a candidate one
159 * past the module end still fails closed.
160 * @param vtable Runtime vtable pointer (first qword of the object).
161 * @param mod_range Pre-resolved span of the module that owns @p vtable.
162 * @param out Receives the resolved coordinates on success only.
163 * @return true on a fully validated walk; false for every failure mode the single-argument overload
164 * rejects, plus a @p vtable outside @p mod_range.
165 */
166 [[nodiscard]] bool resolve_col_site(
167 std::uintptr_t vtable,
168 const DetourModKit::detail::ModuleSpan &mod_range,
169 ColSite &out
170 ) noexcept;
171
172 /**
173 * @brief Page-bounded, module-bounded NUL-terminated copy from @p addr into @p out.
174 * @details Reads in chunks that never cross a 4 KiB page boundary so a string that ends just before an
175 * unmapped page still terminates cleanly. To honour the all-or-nothing failure contract, bytes are
176 * first accumulated in a local temporary; the final commit into @p out only happens when either
177 * the terminating NUL is observed or the allowed length is filled in full. A read fault before
178 * either condition leaves @p out as the empty NUL-terminated string and returns 0. The read is
179 * additionally clamped to @p module_end so a mangled name that lacks a NUL before the end of its
180 * owning module (a forged or edge-of-module TypeDescriptor) is truncated at the module boundary
181 * rather than read-through into an adjacent mapped image, which would surface another module's
182 * bytes as a confident type name (an information leak).
183 * @param addr Address of the first name byte. Values below
184 * MIN_VALID_PTR are rejected without a read.
185 * @param out Destination buffer. nullptr or zero length returns 0.
186 * @param out_len Capacity of @p out including the NUL terminator.
187 * @param module_end Exclusive end of the owning module range; the read never advances to or past it. Zero
188 * means "no module bound known" and only the length caps apply (@ref resolve_col_site supplies
189 * a real end, so a caller threading a @ref ColSite gets the bound automatically).
190 * @return Number of bytes written excluding the NUL terminator, or
191 * 0 on any partial-read failure.
192 */
193 [[nodiscard]] std::size_t
194 read_name_seh(std::uintptr_t addr, char *out, std::size_t out_len, std::uintptr_t module_end) noexcept;
195
196 /**
197 * @struct PrimaryVtable
198 * @brief Result of @ref primary_vtable_checked: the primary vtable plus the sweep completeness.
199 */
200 struct PrimaryVtable
201 {
202 /// The unique primary vtable, or std::nullopt on absence, ambiguity, or a sweep other than Complete.
203 std::optional<Address> vtable;
204 /// Completeness of the one sweep that produced @ref vtable.
205 Traversal completeness = Traversal::Complete;
206 };
207
208 /**
209 * @brief @ref rtti::vtable_for_type plus the completeness of its sweep.
210 * @details One sweep answers both, so a caller that tells an incomplete sweep from a miss reads one view
211 * of the image.
212 */
213 [[nodiscard]] PrimaryVtable primary_vtable_checked(std::string_view mangled, Region range) noexcept;
214 } // namespace detail
215 } // namespace rtti
216 } // namespace DetourModKit
217
218 #endif // DETOURMODKIT_INTERNAL_RTTI_SHARED_HPP
219