src/internal/hook_ledger.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file internal/hook_ledger.hpp | ||
| 6 | * @brief Per-linked-instance safety ledger for the free-function hook surface; not a public registry. | ||
| 7 | * @details Hooks are owned by the caller's handle rather than a central registry, but two safety properties still need | ||
| 8 | * shared state: exact same-kit duplicate detection (does this kit already patch address X?) and layer | ||
| 9 | * ordering for hooks stacked on one target address. This ledger is that state and only that state. It keys on | ||
| 10 | * the raw target address (inline/mid) and the cloned-vptr base (VMT clones), holds no names, exposes no | ||
| 11 | * enumeration, and is never installed. | ||
| 12 | * | ||
| 13 | * SCOPE: one ledger per linked DMK instance, NOT per process. DetourModKit is a static archive | ||
| 14 | * (CMakeLists.txt: add_library(DetourModKit STATIC)), so two DLLs that each link it get two independent | ||
| 15 | * ledgers with two duplicate-detection and two layer-ordering domains. Cross-instance layering is therefore | ||
| 16 | * invisible here and is not defended against; a real process-shared registry would be required for that. | ||
| 17 | * | ||
| 18 | * Layer ordering is not enforced by reordering destructors. The RAII model deliberately hands lifetime to | ||
| 19 | * the caller. Instead an operation that would alter a target's bytes claims that target's serialization slot | ||
| 20 | * and measures how many NEWER live hooks sit on the same address. A non-zero answer means the caller is | ||
| 21 | * toggling or unwinding a hook that is no longer on top, so the operation is refused (toggle) or the backend | ||
| 22 | * is intentionally leaked (teardown) rather than writing bytes a newer trampoline still depends on. | ||
| 23 | */ | ||
| 24 | |||
| 25 | #include <atomic> | ||
| 26 | #include <condition_variable> | ||
| 27 | #include <cstddef> | ||
| 28 | #include <cstdint> | ||
| 29 | #include <mutex> | ||
| 30 | #include <optional> | ||
| 31 | #include <unordered_map> | ||
| 32 | #include <vector> | ||
| 33 | |||
| 34 | namespace DetourModKit | ||
| 35 | { | ||
| 36 | namespace detail | ||
| 37 | { | ||
| 38 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 39 | /** | ||
| 40 | * @brief Fired immediately before every HookLedger state-lock acquisition. | ||
| 41 | * @details A throwing probe drives the synchronization-failure branch of each noexcept ledger boundary, which a | ||
| 42 | * real std::mutex on Windows does not produce on demand. Intentionally NOT noexcept: the throw is the | ||
| 43 | * injected failure. Null in production builds, where this seam does not compile at all. | ||
| 44 | */ | ||
| 45 | extern void (*g_hook_ledger_lock_probe)(); | ||
| 46 | #endif | ||
| 47 | |||
| 48 | /** | ||
| 49 | * @class HookLedger | ||
| 50 | * @brief Tracker of live and in-progress DMK hooks for one linked instance, by target address and VMT base. | ||
| 51 | * @details Never destroyed: see @ref instance. | ||
| 52 | */ | ||
| 53 | class HookLedger | ||
| 54 | { | ||
| 55 | public: | ||
| 56 | /** | ||
| 57 | * @brief Returns this linked instance's ledger, constructing it once on first use. | ||
| 58 | * @details Constructed into function-local static storage and NEVER destroyed, mirroring | ||
| 59 | * StringPool::instance() and Profiler::instance(). A Meyers singleton (`static HookLedger l;`) | ||
| 60 | * would register a static destructor, and a Hook/VmtHook whose own destructor runs later would | ||
| 61 | * then lock a destroyed mutex and search a destroyed map. That is a use-after-free, reachable | ||
| 62 | * whenever a namespace-scope owner (e.g. an empty `static HookStack` populated after startup) | ||
| 63 | * registers its destructor BEFORE the first hook is created and therefore before this ledger | ||
| 64 | * registers its own. Leaking the state for the process lifetime is the cost of making that | ||
| 65 | * ordering irrelevant. | ||
| 66 | */ | ||
| 67 | [[nodiscard]] static HookLedger &instance() noexcept; | ||
| 68 | |||
| 69 | /// Outcome of @ref try_reserve_hook. | ||
| 70 | enum class ReserveStatus : std::uint8_t | ||
| 71 | { | ||
| 72 | /// The slot is reserved and must be committed or released. | ||
| 73 | Reserved, | ||
| 74 | /// This instance already patches the target, so nothing was reserved. | ||
| 75 | AlreadyHooked, | ||
| 76 | /// Bookkeeping allocation or lock acquisition failed, so nothing was reserved. | ||
| 77 | OutOfMemory | ||
| 78 | }; | ||
| 79 | |||
| 80 | /** | ||
| 81 | * @struct Reservation | ||
| 82 | * @brief The result of an atomic check-and-reserve on the target ledger. | ||
| 83 | * @details On @ref ReserveStatus::Reserved, @ref id MUST be committed (@ref commit_hook) or rolled back | ||
| 84 | * (@ref release_hook) by the caller. @ref preexisting reports whether this kit already tracked the | ||
| 85 | * target BEFORE this reservation, distinguishing a fresh install from a layered one. | ||
| 86 | */ | ||
| 87 | struct Reservation | ||
| 88 | { | ||
| 89 | ReserveStatus status{ReserveStatus::OutOfMemory}; | ||
| 90 | std::uint64_t id{0}; | ||
| 91 | bool preexisting{false}; | ||
| 92 | }; | ||
| 93 | |||
| 94 | // Inline / mid hooks: keyed by the patched target address. Inline and mid hooks at one address share the | ||
| 95 | // same prologue bytes, so they share one key space, one pending queue, and one layering order. | ||
| 96 | |||
| 97 | /** | ||
| 98 | * @brief Atomically checks the target and reserves a ledger id under a single lock acquisition. | ||
| 99 | * @param target The patched target address. | ||
| 100 | * @param refuse_if_hooked Refuse (reserving nothing) if this kit already patches @p target; this is the | ||
| 101 | * @ref hook::Options::fail_if_already_hooked exact same-kit gate. | ||
| 102 | * @return A @ref Reservation. On @ref ReserveStatus::Reserved the id is appended newest-last and is next in | ||
| 103 | * the per-target queue. | ||
| 104 | * @details Folding the check and the record into one locked step closes the time-of-check/time-of-use gap a | ||
| 105 | * separate is_target_hooked + record left open. Reservations also wait their turn in creation | ||
| 106 | * order, so two permissive same-target installs cannot patch concurrently or invert the layering | ||
| 107 | * order. Allocation or lock failure returns OutOfMemory so the caller fails the install closed | ||
| 108 | * rather than installing a live-but-unledgered hook that duplicate detection and layer tracking | ||
| 109 | * cannot see. | ||
| 110 | * @warning Setup/control-plane only, and NOT reentrant for a single target on one thread: a thread MUST | ||
| 111 | * commit or roll back its reservation before reserving the SAME @p target again, or the second | ||
| 112 | * call blocks forever waiting for the first (which the same thread still holds) to leave the | ||
| 113 | * queue. DMK's install paths honor this and never install reentrantly from a detour, so the | ||
| 114 | * deadlock is unreachable in supported use; it is a caller error, not a defect. | ||
| 115 | */ | ||
| 116 | [[nodiscard]] Reservation try_reserve_hook(std::uintptr_t target, bool refuse_if_hooked) noexcept; | ||
| 117 | |||
| 118 | /** | ||
| 119 | * @brief Completes a reservation after backend creation and wakes the next same-target installer. | ||
| 120 | * @return True when the reservation was committed. False on lock failure or an invalid reservation, so the | ||
| 121 | * installer can fail before publishing a handle. | ||
| 122 | * @details The id stays in the target's creation order for duplicate detection and layer tracking; only the | ||
| 123 | * pending reservation is removed. | ||
| 124 | */ | ||
| 125 | [[nodiscard]] bool commit_hook(std::uintptr_t target, std::uint64_t id) noexcept; | ||
| 126 | |||
| 127 | /** | ||
| 128 | * @brief Removes hook @p id from @p target and returns how many newer hooks remain recorded on it. | ||
| 129 | * @return The count of ids created AFTER @p id still in @p target's order. Zero means @p id is the newest | ||
| 130 | * (or sole) record, so a byte restore is in the safe newest-first order. Any positive value means | ||
| 131 | * newer layers sit on top and the caller must not restore. Lock failure returns a positive count | ||
| 132 | * so the caller fails closed to a leak. | ||
| 133 | * @note The count is keyed on the record, not on whether a handle still owns it: an id counts until its | ||
| 134 | * owner calls this method, so it includes ids left behind by a pin (a teardown that took @ref | ||
| 135 | * release_target_slot, and the release verbs, which abandon the backend without a ledger call at | ||
| 136 | * all). Excluding the ownerless ones would authorize an older layer to write over a prologue a | ||
| 137 | * retained trampoline can still resume through. | ||
| 138 | */ | ||
| 139 | [[nodiscard]] std::size_t release_hook(std::uintptr_t target, std::uint64_t id) noexcept; | ||
| 140 | |||
| 141 | /** | ||
| 142 | * @brief Claims @p target's serialization slot for an operation that may alter its bytes, returning the | ||
| 143 | * newer-live count measured at the instant the slot is held. | ||
| 144 | * @return The count of ids created AFTER @p id still recorded on @p target (see @ref release_hook for what | ||
| 145 | * that includes). Zero authorizes the caller to write target bytes (enable, disable, or restore); | ||
| 146 | * any positive value means @p id is not the top layer (or one raced in during the claim) and | ||
| 147 | * the caller MUST refuse (toggle) or leak (teardown). An absent target/id, a bookkeeping | ||
| 148 | * allocation failure, or a lock failure all return a positive count so the caller fails closed. | ||
| 149 | * @details The write-side counterpart to @ref try_reserve_hook, used by enable, disable, and teardown. A | ||
| 150 | * bare newer-count peek followed by a byte write is not atomic against a concurrent same-target | ||
| 151 | * install: an install reserved after the peek reads the caller's prologue as its resume, and the | ||
| 152 | * caller's write then clobbers it and the trampoline the new layer chains through - a | ||
| 153 | * use-after-free. This method closes that window by waiting its turn in the SAME per-target queue | ||
| 154 | * an install waits in, then blocking every new reserver behind this id until the slot is released. | ||
| 155 | * While the slot is held no install can read or write @p target's prologue, so {decide, write} is | ||
| 156 | * effectively atomic against installs. | ||
| 157 | * @warning The caller MUST release the slot exactly once, through @ref release_hook (which also drops the | ||
| 158 | * id from the creation order) or @ref release_target_slot (which keeps it). Otherwise later | ||
| 159 | * same-target installs block forever behind the unreleased queue sentinel. Only the sentinel can | ||
| 160 | * block. The creation-order entry never does. Release the slot before running user code or | ||
| 161 | * taking the loader lock: holding it across either invites a deadlock against an install that is | ||
| 162 | * itself under the loader lock. | ||
| 163 | * @note A release path that cannot retake the state lock leaves this id at the front of the pending queue, | ||
| 164 | * which in isolation reads like a stranded sentinel that would park every later same-target reserver | ||
| 165 | * forever. It cannot, and the release paths deliberately do NOT try to repair it: the queue is plain | ||
| 166 | * vector state that only the mutex makes safe to touch, so erasing the sentinel without the lock | ||
| 167 | * would be a data race, trading a stall for undefined behaviour. The stall is unreachable instead. | ||
| 168 | * @ref lock_state fails only when std::mutex::lock throws, which is a permanent property of that | ||
| 169 | * mutex rather than a transient one, so a later reserver fails its OWN acquisition and returns | ||
| 170 | * OutOfMemory before it can reach the wait. Only the test seam can produce the selective one-shot | ||
| 171 | * failure the stall would need, and it is compiled out of shipping builds. | ||
| 172 | * HookLedgerFaultProof.AbandonedSlotCannotParkALaterReserver pins that ordering. | ||
| 173 | */ | ||
| 174 | [[nodiscard]] std::size_t acquire_target_slot(std::uintptr_t target, std::uint64_t id) noexcept; | ||
| 175 | |||
| 176 | /** | ||
| 177 | * @brief Releases a slot claimed by @ref acquire_target_slot WITHOUT removing @p id from the creation | ||
| 178 | * order. | ||
| 179 | * @details For callers whose hook remains live or remains physically patched: a refused toggle, or a | ||
| 180 | * teardown that leaked its backend. Only the queue sentinel is removed, waking the next | ||
| 181 | * same-target installer; the order entry stays so @ref is_target_hooked keeps reporting the | ||
| 182 | * target hooked. The restoring teardown path instead calls @ref release_hook, which drops the id | ||
| 183 | * from both. | ||
| 184 | * @note The order entry this method keeps goes on counting as a newer layer even once no handle owns it, | ||
| 185 | * which is the contract rather than an oversight: the teardown caller here kept an installed | ||
| 186 | * backend, so an older layer underneath must stay refused. Because ids are appended newest-last, a | ||
| 187 | * kept id never outranks a layer installed after it, so a layer installed over a pinned target is | ||
| 188 | * still the newest and still restores its own bytes on teardown. The pinned backend underneath it | ||
| 189 | * is never restored, and the target stays reported hooked for the process lifetime. | ||
| 190 | */ | ||
| 191 | void release_target_slot(std::uintptr_t target, std::uint64_t id) noexcept; | ||
| 192 | |||
| 193 | /// True when this kit currently has at least one live or reserved hook for @p target; true on lock failure. | ||
| 194 | [[nodiscard]] bool is_target_hooked(std::uintptr_t target) const noexcept; | ||
| 195 | |||
| 196 | // VMT clones: keyed by the cloned-vptr base SafetyHook installs. One base per VmtHook (every object the | ||
| 197 | // clone is applied to shares that base), so a clone is recorded once at create. | ||
| 198 | |||
| 199 | /** | ||
| 200 | * @brief Records a live VMT clone (installed vptr base @p cloned_base); returns its unique ledger id. | ||
| 201 | * @return The new ledger id, or std::nullopt if the bookkeeping allocation or the lock failed. | ||
| 202 | * @details A nullopt result tells the caller to fail the clone closed (unwinding the backend to restore the | ||
| 203 | * object's vptr) rather than leave a live-but-untracked clone @ref is_vmt_clone_base cannot | ||
| 204 | * recognise. That is the VMT analogue of the inline/mid fail-closed reservation. | ||
| 205 | */ | ||
| 206 | [[nodiscard]] std::optional<std::uint64_t> try_record_vmt(std::uintptr_t cloned_base) noexcept; | ||
| 207 | |||
| 208 | /// Removes the VMT clone @p id from the ledger; best-effort on lock failure. | ||
| 209 | void release_vmt(std::uint64_t id) noexcept; | ||
| 210 | |||
| 211 | /** | ||
| 212 | * @brief True when @p vptr is the installed base of any live VMT clone this kit owns. | ||
| 213 | * @return True on lock failure, so a caller refuses or warns rather than silently double-hooking an | ||
| 214 | * unrecognised clone. | ||
| 215 | */ | ||
| 216 | [[nodiscard]] bool is_vmt_clone_base(std::uintptr_t vptr) const noexcept; | ||
| 217 | |||
| 218 | private: | ||
| 219 | 378 | HookLedger() noexcept = default; | |
| 220 | |||
| 221 | /** | ||
| 222 | * @brief Acquires the state lock, containing a synchronization failure instead of crossing a noexcept edge. | ||
| 223 | * @return An owning lock, or an unowned lock when acquisition failed. Every caller is noexcept and MUST | ||
| 224 | * check owns_lock() and fail closed; std::mutex::lock can throw std::system_error, which would | ||
| 225 | * otherwise reach a noexcept boundary and terminate the host. | ||
| 226 | */ | ||
| 227 | [[nodiscard]] std::unique_lock<std::mutex> lock_state() const noexcept; | ||
| 228 | |||
| 229 | struct VmtEntry | ||
| 230 | { | ||
| 231 | std::uint64_t id; | ||
| 232 | std::uintptr_t base; | ||
| 233 | }; | ||
| 234 | |||
| 235 | struct TargetEntry | ||
| 236 | { | ||
| 237 | std::vector<std::uint64_t> order; | ||
| 238 | std::vector<std::uint64_t> pending; | ||
| 239 | }; | ||
| 240 | |||
| 241 | mutable std::mutex m_mutex; | ||
| 242 | std::condition_variable m_install_cv; | ||
| 243 | // target address -> live/reserved hook ids in creation order (back = newest). | ||
| 244 | std::unordered_map<std::uintptr_t, TargetEntry> m_by_target; | ||
| 245 | // Live VMT clones (small; a linear scan is cheaper than a map at this size). | ||
| 246 | std::vector<VmtEntry> m_vmt; | ||
| 247 | std::atomic<std::uint64_t> m_next_id{1}; | ||
| 248 | }; | ||
| 249 | } // namespace detail | ||
| 250 | } // namespace DetourModKit | ||
| 251 | |||
| 252 | #endif // DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP | ||
| 253 |