From 3c5ac00b9168408acf7c80d584cfee00d6b226f3 Mon Sep 17 00:00:00 2001 From: Quang Trinh Date: Sat, 13 Jun 2026 03:58:34 +0700 Subject: [PATCH 1/2] perf(memory): VEH fault guard for MinGW seh_read Guard MinGW seh_read_bytes/read_ptr_unsafe with a process-wide vectored exception handler instead of a per-call VirtualQuery: zero-syscall success path recovered via non-unwinding __builtin_setjmp/longjmp, also closing the stale-cache unguarded dereference. Per-thread guard via an allocation-free Win32 TLS slot, drained before handler removal, with a VirtualQuery fallback for install failure and 32-bit builds. MSVC __try path unchanged. --- AGENTS.md | 8 +- README.md | 2 +- docs/analysis/memory_veh_bench_v3.x/README.md | 60 +++ docs/misc/hot-path-memory.md | 6 +- docs/misc/rtti-walker.md | 4 +- include/DetourModKit/memory.hpp | 25 +- src/memory.cpp | 383 +++++++++++++----- tests/bench_memory.cpp | 2 +- tests/test_memory.cpp | 267 +++++++++++- tests/test_scanner.cpp | 4 +- 10 files changed, 639 insertions(+), 122 deletions(-) create mode 100644 docs/analysis/memory_veh_bench_v3.x/README.md diff --git a/AGENTS.md b/AGENTS.md index 0638c2d..03371fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,7 +301,7 @@ Do not add `Memory::is_readable()` or `Memory::is_writable()` before every field ### Scanning process memory -The raw `Scanner::find_pattern(start_address, region_size, pattern)` overloads do no page filtering: they read the whole span with `memchr`/SIMD, so the caller must guarantee `[start_address, start_address + region_size)` is committed and readable, or the host faults. Use them only on byte buffers or module sections whose readability is already known. To scan arbitrary process or module memory, prefer the page-filtered helpers (`scan_executable_regions`, `scan_readable_regions`), which walk `VirtualQuery` and skip guard, no-access, and non-readable pages. The per-region `VirtualQuery` gate proves readability only at gate time; on MSVC each region read additionally runs inside a structured-exception guard, so a region decommitted or reprotected concurrently between the gate and the read is skipped (and counted at `Debug`) rather than faulting the host. On MinGW the gate is the only guard, so that narrow TOCTOU window can still fault until the VEH-based handler lands. The same guard wraps the per-window reads behind `find_string_xref`. +The raw `Scanner::find_pattern(start_address, region_size, pattern)` overloads do no page filtering: they read the whole span with `memchr`/SIMD, so the caller must guarantee `[start_address, start_address + region_size)` is committed and readable, or the host faults. Use them only on byte buffers or module sections whose readability is already known. To scan arbitrary process or module memory, prefer the page-filtered helpers (`scan_executable_regions`, `scan_readable_regions`), which walk `VirtualQuery` and skip guard, no-access, and non-readable pages. The per-region `VirtualQuery` gate proves readability only at gate time; on MSVC each region read additionally runs inside a structured-exception guard, so a region decommitted or reprotected concurrently between the gate and the read is skipped (and counted at `Debug`) rather than faulting the host. On MinGW the bulk `find_pattern_raw` reads are not covered by the `seh_read` vectored fault guard (that guard wraps the copy primitives, not the SIMD sweep), so the per-region gate is the only guard there and that narrow TOCTOU window can still fault. The same guard wraps the per-window reads behind `find_string_xref`. ## Testing @@ -378,10 +378,10 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc - `Logger::log()` async enqueue -- atomic shared_ptr load + lock-free queue push - `Memory::is_readable()` -- sharded SRWLOCK reader + cache lookup - `Memory::is_readable_nonblocking()` -- try_lock_shared + cache lookup (returns Unknown on contention) -- `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), cache-accelerated with VirtualQuery fallback (MinGW) +- `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), vectored-handler-guarded read on MinGW (no per-call VirtualQuery; the fault guard also closes the stale-cache dereference) - `Memory::read_ptr_unchecked()` -- inline pointer dereference with source and result user-mode range guards (a low-address floor plus a `USERSPACE_PTR_MAX` ceiling that rejects kernel-range and non-canonical values, which also subsumes pointer-arithmetic wraparound), no SEH (caller must guarantee structural pointer validity); debug builds add an `is_readable` assert that catches a stale or unmapped source pointer, compiled out in release so the hot path stays a single guarded memcpy -- `Memory::seh_read()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, VirtualQuery loop across regions on MinGW. The MSVC `__except` filter swallows the foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- and lets any other code continue the handler search; the predicate (`Memory::detail::is_guarded_read_fault`) is shared by every guarded read so the set stays uniform. Used by `Rtti` for chained RTTI walks -- `Memory::seh_resolve_chain()` / `seh_read_chain()` -- resolves or reads a whole multi-level pointer chain under one fault guard: one out-of-line call instead of N separate `seh_read` calls, with each intermediate link kept in a register and pre-screened by `plausible_userspace_ptr` (a faulting or implausible link aborts the walk and returns nullopt/false). VirtualQuery-guarded per link on MinGW +- `Memory::seh_read()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, and on MinGW a single `rep movsb` copy under a process-wide vectored exception handler (installed lazily / by `init_cache`, removed by `shutdown_cache`) that recovers via a non-unwinding `__builtin_setjmp`/`__builtin_longjmp`, so the success path runs no syscall. Both toolchains swallow the same foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- via the shared predicate `Memory::detail::is_guarded_read_fault`, and let any other fault continue the handler search; the MinGW handler additionally claims only faults whose address lies in the foreign range being read. A guarded read uses a single per-thread guard, so it must not nest on one thread (DMK reads are synchronous, so it does not); if `AddVectoredExceptionHandler` ever fails the reads fall back to VirtualQuery validation. Used by `Rtti` for chained RTTI walks +- `Memory::seh_resolve_chain()` / `seh_read_chain()` -- resolves or reads a whole multi-level pointer chain under one fault guard: one out-of-line call instead of N separate `seh_read` calls, with each intermediate link kept in a register and pre-screened by `plausible_userspace_ptr` (a faulting or implausible link aborts the walk and returns nullopt/false). On MinGW each link read is guarded by the vectored handler - `Memory::plausible_userspace_ptr(p)` -- `inline constexpr` user-mode pointer plausibility test; pure arithmetic with no syscall and no memory access (early-rejects stale/sentinel/torn pointers before an SEH-guarded read) - `Memory::contains(range, p)` -- constexpr point-in-range test for module bounds checks - `Memory::own_module_range()` / `host_module_range()` -- magic-static cached, single atomic load on the fast path diff --git a/README.md b/README.md index 9a3b24a..cfd58ca 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre - Functions for checking memory readability/writability and writing bytes to memory - Optional memory region cache with sharded SRWLOCK concurrency, LRU eviction, and stampede coalescing - `is_readable_nonblocking()` - tri-state (readable/not-readable/unknown) for latency-sensitive threads -- `read_ptr_unsafe()` - safe pointer reads in hot paths (SEH-protected on MSVC, cache-accelerated with VirtualQuery fallback on MinGW) +- `read_ptr_unsafe()` - safe pointer reads in hot paths (SEH-protected on MSVC, guarded by a process-wide vectored exception handler on MinGW, so the success path issues no per-call VirtualQuery) - `read_ptr_unchecked()` - inline header-only variant with a configurable lower-bound guard plus a `USERSPACE_PTR_MAX` ceiling for pointer chain traversal without per-call SEH overhead (caller must guarantee structural pointer validity) - `seh_read()` / `seh_read_bytes()` - typed SEH-guarded reads for arbitrary trivially copyable T (and contiguous byte ranges), used to walk torn pointer chains and parse PE headers without per-site `__try` boilerplate. Returns `std::optional` / `bool` so callers can distinguish "read faulted" from "read returned zero" - `module_range_for(addr)` / `own_module_range()` / `host_module_range()` - PE image range queries (base + SizeOfImage) for sanity-checking that a resolved vtable or return address lives inside the game image vs the heap or an injected DLL. Per-HMODULE cache for `module_range_for`; magic-static cache for the own and host variants diff --git a/docs/analysis/memory_veh_bench_v3.x/README.md b/docs/analysis/memory_veh_bench_v3.x/README.md new file mode 100644 index 0000000..194f406 --- /dev/null +++ b/docs/analysis/memory_veh_bench_v3.x/README.md @@ -0,0 +1,60 @@ +# Memory microbenchmark: MinGW vectored-handler fault guard + +This directory captures a run of `tests/bench_memory.cpp` against the `Memory` read primitives on **MinGW**, where the `seh_read` family is guarded by a process-wide vectored exception handler (VEH) rather than a per-call `VirtualQuery`. It is the MinGW companion to the MSVC numbers in [../memory_bench_v3.x](../memory_bench_v3.x): the same harness, run once per toolchain, so the two columns below are directly comparable. + +The question is "does the VEH actually buy anything on MinGW?" The VirtualQuery-validated baseline issues a `VirtualQuery` syscall on every terminal read (and one per link on a chain walk) because GCC has no zero-cost frame-based SEH. The VEH path removes that syscall entirely: the success path is a single `rep movsb` under a thread-local guard, and a read fault is recovered with a non-unwinding `__builtin_setjmp` / `__builtin_longjmp` (the GCC equivalent of Frida's `_setjmp(env, NULL)`). The per-thread guard is published through a Win32 TLS slot read with `TlsGetValue`, which is allocation-free and safe to touch from the exception-dispatch context (a `thread_local` / `__thread` would lower to `__emutls_get_address`, which allocates and locks on first access -- forbidden in a handler). + +The benchmark measures the per-call cost of each read primitive. The two numbers that matter are: + +- **`seh_read`** -- a single terminal guarded read. The MinGW VirtualQuery baseline pays one query (the `raw VirtualQuery` row) plus a copy; the VEH path pays neither. +- **`seh_resolve_chain` / `seh_read_chain`** -- a six-link pointer-chain walk. The VirtualQuery-validated baseline pays one query per link. + +## Hardware / configuration + +- Build: `cmake --preset mingw-release -DDMK_BUILD_BENCHMARKS=ON` (MinGW column); MSVC Release `/O2` with `-DDMK_BUILD_BENCHMARKS=ON` (MSVC column) +- Toolchains: GCC 15.1 (MSYS2 MinGW-w64), `-O3` + LTO, `seh_*` use the vectored-handler fault guard; MSVC 2022, `seh_*` use real `__try` / `__except` (table-driven on x64, free on the no-fault path) +- Iterations: 200,000 per sample (20,000 for `write_bytes`); 15 samples; median reported +- Pointer chain: six links, warm cache +- `DEFAULT_CACHE_EXPIRY_MS = 50` + +## Results + +```text +primitive MinGW (VEH) MSVC (__try) MinGW VirtualQuery baseline +raw VirtualQuery 224.28 ns 213.76 ns -- (this row is the per-read baseline) +direct volatile load 6.47 ns ~4 ns -- +read_ptr_unchecked 191.82 ns 5.34 ns -- (range-guarded, no fault guard; debug-assert build) +seh_read 62.34 ns 7.26 ns ~224 ns (a VirtualQuery + copy) +seh_resolve_chain (6) 250.15 ns 9.92 ns ~1345 ns (6 x VirtualQuery) +seh_read_chain (6) 299.26 ns 8.94 ns ~1345 ns (6 x VirtualQuery + terminal read) +``` + +The "MinGW VirtualQuery baseline" column is derived, not separately benchmarked: `virtualquery_validated_copy` is the install-failure fallback, so its cost is the `raw VirtualQuery` row (224 ns) times the number of regions a read spans -- one for a terminal read, one per link for a chain. + +## How to read this + +| Primitive | MinGW VEH | vs VirtualQuery baseline | vs MSVC `__try` | +|-----------|-----------|-------------------|-----------------| +| `seh_read` | 62 ns | **~3.6x faster** (no VirtualQuery) | ~8.6x slower | +| `seh_resolve_chain` (6 links) | 250 ns | **~5.4x faster** (no per-link syscall) | ~25x slower | +| `seh_read_chain` (6 links) | 299 ns | **~4.5x faster** | ~33x slower | + +A few takeaways: + +1. **The VEH eliminates the syscall, which is the whole point.** A guarded terminal read dropped from a `VirtualQuery` (224 ns) to 62 ns, and a six-link chain from roughly 1.3 microseconds of syscalls to 250 ns. The remaining MinGW cost is the `__builtin_setjmp` snapshot, two `TlsSetValue` writes, the drain-epoch atomics, and a `rep movsb` -- all userspace, no kernel transition. The 62 ns being far below a single `raw VirtualQuery` is also the proof that the VEH is actually active rather than silently falling back. +2. **The MinGW-to-MSVC gap is inherent and not closable.** MSVC `__try` is table-driven SEH described by `.pdata`/`.xdata` emitted at compile time, so the no-fault path costs nothing beyond the read itself (7-10 ns). GCC on Windows has no equivalent; any runtime fault guard -- VEH, signal handler, or otherwise -- must do per-read setup. So the realistic goal was "stop paying a syscall per read," which is met, not "match MSVC." +3. **The hardened TLS path keeps the handler allocation-free.** Publishing the guard through a Win32 TLS slot (`TlsSetValue` / `TlsGetValue`, a TEB-array access) avoids the `__emutls_get_address` call a mingw `thread_local` lowers to, and that matters because allocation and locking are forbidden in the exception-dispatch context. +4. **`read_ptr_unchecked` stays the floor for known-live pointers.** It applies only range guards and no fault guard; in this debug-assert benchmark build its MinGW number is inflated by the `is_readable` assert, but in a release consumer it is a single guarded `memcpy`. Use it only when the caller can prove the pointer is live this frame; otherwise `seh_read` / `seh_read_chain` is the safe choice and, on MinGW, now cheap enough to use freely. + +## Reproducing locally + +```bash +# MinGW (vectored-handler path) +PATH="/c/msys64/mingw64/bin:$PATH" \ + cmake -S . -B build/mingw-release -DDMK_BUILD_BENCHMARKS=ON +PATH="/c/msys64/mingw64/bin:$PATH" \ + cmake --build build/mingw-release --target DetourModKit_bench_memory -j 3 +./build/mingw-release/tests/DetourModKit_bench_memory.exe +``` + +The program prints the human-readable tables shown above plus a `#TSV` block on stdout for scripted comparison. The toolchain banner on the first line confirms which fault-guard path is in effect (`vectored-handler fault guard` on MinGW, `__try/__except` on MSVC). diff --git a/docs/misc/hot-path-memory.md b/docs/misc/hot-path-memory.md index 7cb1e8b..629f23b 100644 --- a/docs/misc/hot-path-memory.md +++ b/docs/misc/hot-path-memory.md @@ -40,8 +40,8 @@ bool probe_object(uintptr_t obj, ObjectFields &out) noexcept } // 2. Read every field under a single SEH-guarded chain walk. On MSVC this - // is one __try frame; on MinGW it is VirtualQuery-guarded. Either way a - // fault anywhere in the walk returns nullopt instead of crashing. + // is one __try frame; on MinGW it uses the vectored fault guard. Either + // way a fault anywhere in the walk returns nullopt instead of crashing. const auto vtable = Mem::seh_read(obj); if (!vtable || !Mem::contains(g_host, *vtable)) { @@ -116,7 +116,7 @@ if (value) ## Toolchain note -The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no SEH) they fall back to a `VirtualQuery`-guarded read, which is correct but pays a syscall; prefer `read_ptr_unchecked` on MinGW hot paths where you can guarantee structural validity. Shipping mod builds target MSVC, so the zero-cost path is the normal case. +The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no frame-based SEH) they use a process-wide vectored exception handler: the success path is a guarded copy with no `VirtualQuery`, and a read fault returns `std::nullopt` / `false` instead of crashing. `read_ptr_unchecked` is still the fastest choice when you can prove the pointer is live for the current frame; otherwise prefer `seh_read` / `seh_read_chain` for stale or unmapped pointers. Shipping mod builds target MSVC, so the zero-cost path is the normal case. ## Anti-patterns to remove diff --git a/docs/misc/rtti-walker.md b/docs/misc/rtti-walker.md index 4a8bd13..4245c9f 100644 --- a/docs/misc/rtti-walker.md +++ b/docs/misc/rtti-walker.md @@ -135,7 +135,7 @@ Scoping to one `ModuleRange` (the default is the host EXE) is load-bearing for c ## Performance notes -- The walker issues two SEH-guarded reads per call on the cold path: one for the COL pointer at `vtable - 8`, one batched read of the 24-byte `ColHead`. On MSVC each `__try` frame is essentially free on the success path. On MinGW each read goes through `VirtualQuery`, which is microseconds-class; the batched ColHead read keeps the MinGW cost down to two syscalls instead of four. +- The walker issues two SEH-guarded reads per call on the cold path: one for the COL pointer at `vtable - 8`, one batched read of the 24-byte `ColHead`. On MSVC each `__try` frame is essentially free on the success path. On MinGW each read uses the vectored fault guard, so the success path avoids the per-read `VirtualQuery` syscall; the batched ColHead read still matters because it keeps the walker to two guarded calls instead of four. - `vtable_is_type` reads `expected.size() + 1` name bytes in a single SEH frame and compares with `memcmp`. There is no heap allocation, no string construction, and no demangle pass. - `type_name_of` allocates one `std::string` per call. Prefer `type_name_into` or `vtable_is_type` on hot paths. - `find_in_pointer_table` on a cold cache scans every non-null slot with the full walker. With a warm cache it touches each slot exactly once with a qword compare. For a sparse table of 256 slots and a unique target, the warm-path cost is dominated by the slot dereference, not the RTTI machinery. @@ -154,6 +154,6 @@ None of these raise an exception; the caller can treat all failure modes uniform ## MinGW support -The walker works correctly on both MSVC and MinGW builds of DetourModKit when targeting MSVC-compiled binaries (the typical use case for game mods). The underlying `Memory::seh_read_bytes` primitive uses `__try` / `__except` on MSVC and a `VirtualQuery`-based region validation loop on MinGW. The MinGW path is race-prone against concurrent `VirtualProtect` and slower (one syscall per region), but produces identical results for stable game state. +The walker works correctly on both MSVC and MinGW builds of DetourModKit when targeting MSVC-compiled binaries (the typical use case for game mods). The underlying `Memory::seh_read_bytes` primitive uses `__try` / `__except` on MSVC and the process-wide vectored fault guard on MinGW. Both toolchains fail closed on unreadable RTTI pages and produce identical results for stable game state; MSVC remains faster because its x64 SEH tables add no success-path setup. Note: the walker reads the MSVC RTTI ABI. If the target object was compiled by GCC or Clang for the Itanium C++ ABI, the layout at `vtable - 8` is different and the walker will fail. This is by design; DetourModKit consumers building mods for MSVC-compiled games (every major Windows game engine since 2010) will not encounter Itanium RTTI in their target processes. diff --git a/include/DetourModKit/memory.hpp b/include/DetourModKit/memory.hpp index b679501..505e407 100644 --- a/include/DetourModKit/memory.hpp +++ b/include/DetourModKit/memory.hpp @@ -155,12 +155,13 @@ namespace DetourModKit /** * @brief Reads a pointer-sized value at (base + offset), returning 0 on fault. * @details On MSVC, uses SEH (__try/__except) to catch access violations with zero overhead on the success - * path. On MinGW, falls back to a single - * VirtualQuery guard before dereferencing (no cache interaction). Suitable for hot paths that already - * manage their own error recovery. - * @note On MinGW the implementation additionally probes the cache via a non-blocking try_lock_shared before - * falling back to VirtualQuery, so cache hits avoid a syscall. This is invisible to callers; the function - * still exposes no caller-observable cache state. + * path. On MinGW, the read runs under a process-wide vectored exception handler (installed lazily), so + * the success path is a guarded copy with no syscall and any fault -- unmapped, PAGE_NOACCESS/guard, or + * a page reprotected out from under a stale cache entry -- is swallowed and returned as 0. Suitable for + * hot paths that already manage their own error recovery. + * @note The MinGW path consults no cache; the fault guard makes a cache probe unnecessary. If the vectored + * handler cannot be installed it falls back to VirtualQuery-validated reads. Either way the function + * exposes no caller-observable cache state. * @param base The base address to read from. * @param offset Byte offset added to base before dereferencing. * @return The pointer-sized value at the address, or 0 if the read faults. @@ -201,7 +202,7 @@ namespace DetourModKit * * This function does NOT provide fault protection against unmapped or freed memory above min_valid. If * the pointer chain may reference deallocated heap memory or unmapped regions, use read_ptr_unsafe() - * instead (SEH-protected on MSVC, VirtualQuery-guarded on MinGW). + * instead (SEH-protected on MSVC, vectored-handler-guarded on MinGW). * * The "unchecked" in the name refers to the absence of OS-level memory validation in release builds * (no SEH, no VirtualQuery, no cache lookup). Only user-mode range guards are applied. Intended for @@ -345,9 +346,10 @@ namespace DetourModKit * @details On MSVC, the copy runs inside a __try / __except frame whose filter accepts the foreign-read fault * set (EXCEPTION_ACCESS_VIOLATION, STATUS_GUARD_PAGE_VIOLATION, and EXCEPTION_IN_PAGE_ERROR for a * file-backed page that fails to page in), so any such fault in the middle of the copy unwinds cleanly - * and the function returns false. On MinGW the implementation falls back to VirtualQuery-based - * validation of every region the read spans before issuing the memcpy; this is race-prone against - * concurrent VirtualProtect but is the best a non-SEH toolchain can do. + * and the function returns false. On MinGW the copy runs under a process-wide vectored exception + * handler that claims the same fault set (no per-call VirtualQuery on the success path); a fault + * anywhere in the span is swallowed and the function returns false. If the handler cannot be installed + * it falls back to VirtualQuery-based validation of every region the read spans. * * The function is the underlying primitive for the typed @ref seh_read template and is exposed * directly for callers that need to read a contiguous buffer of bytes (for example NUL-terminated @@ -399,7 +401,8 @@ namespace DetourModKit * it is one out-of-line call instead of N, each intermediate link kept in a register instead of * round-tripped through std::optional, and a single argument validation. On MinGW (no SEH) the saving * is concrete: one guarded helper call instead of N, each intermediate link read through @ref - * read_ptr_unsafe (VirtualQuery-guarded) and the final address computed without a read. + * read_ptr_unsafe (guarded by the process-wide vectored handler) and the final address computed + * without a read. * * Each intermediate link is screened with @ref plausible_userspace_ptr; a link that faults or yields * an implausible pointer aborts the walk and returns std::nullopt. The returned address is not diff --git a/src/memory.cpp b/src/memory.cpp index e43eee3..cfbd658 100644 --- a/src/memory.cpp +++ b/src/memory.cpp @@ -843,6 +843,254 @@ namespace } // anonymous namespace +// Lower bound on a valid usermode pointer on x64 Windows. The null page plus the standard NoAccess guard region cover +// [0, 0x10000); rejecting addresses in this range without a memory access keeps stale or sentinel pointers from raising +// first-chance exceptions in callers' debuggers. Shared by seh_read_bytes and the MinGW guarded-read entry point. +namespace +{ + inline constexpr uintptr_t SEH_READ_MIN_VALID_ADDR = 0x10000; +} // anonymous namespace + +#ifndef _MSC_VER +// MinGW/GCC has no __try / __except, so the foreign-read probes in this file cannot wrap their reads in frame-based +// SEH the way the MSVC paths do. A single process-wide vectored exception handler provides the equivalent fault guard: +// each guarded read marks the foreign range it is about to touch in a thread-local slot, and a read fault inside that +// range is intercepted and turned into a clean failure instead of terminating the host. The guarded path avoids a +// per-call VirtualQuery on successful terminal reads and keeps stale cache entries from authorizing unguarded +// dereferences after a page is reprotected. +namespace +{ + // VirtualQuery-validated copy. On x64 it is the fallback used only when the vectored handler could not be installed + // (it carries a query-to-copy TOCTOU window the handler path does not, so it is never the normal path there); on a + // 32-bit MinGW build, where the handler's x64 register redirect is unavailable, it is the only guard. Validates + // every region the read spans before copying. + bool virtualquery_validated_copy(uintptr_t addr, void *out, size_t bytes) noexcept + { + size_t copied = 0; + while (copied < bytes) + { + const uintptr_t cur = addr + copied; + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(reinterpret_cast(cur), &mbi, sizeof(mbi))) + return false; + if (mbi.State != MEM_COMMIT) + return false; + if ((mbi.Protect & CachePermissions::READ_PERMISSION_FLAGS) == 0 || + (mbi.Protect & CachePermissions::NOACCESS_GUARD_FLAGS) != 0) + return false; + + const uintptr_t region_start = reinterpret_cast(mbi.BaseAddress); + const uintptr_t region_end = region_start + mbi.RegionSize; + if (region_end < region_start) + return false; + if (cur < region_start || cur >= region_end) + return false; + + const size_t available = static_cast(region_end - cur); + const size_t remaining = bytes - copied; + const size_t to_copy = (remaining < available) ? remaining : available; + std::memcpy(static_cast(out) + copied, reinterpret_cast(cur), to_copy); + copied += to_copy; + } + return true; + } + +#if defined(_WIN64) + // Per-read record describing the foreign range and the recovery snapshot. It lives on the guarded read's own stack + // (one per nested-free synchronous read) and is published to the thread's Win32 TLS slot for the duration of the + // read; the handler reads that slot. A Win32 TLS slot is used rather than a thread_local / __thread because mingw + // lowers thread-locals to __emutls_get_address, which allocates and locks on a thread's first access -- forbidden + // in the exception-dispatch context the handler runs in. TlsGetValue is documented to be callable there: it reads + // the thread's TLS array with no allocation and no lock, and returns null on any thread that has not armed a read. + struct VehReadGuard + { + void *env[5]; // __builtin_setjmp buffer; the recovery stub longjmps through it (5 words is the GCC ABI) + uintptr_t guard_lo; // first byte of the foreign source range being read + uintptr_t guard_hi; // one past the last byte of that range + }; + + std::mutex s_veh_mutex; + std::atomic s_veh_handle{nullptr}; + // Process-lifetime TLS index, allocated once and reused across install/remove cycles (never freed so a removal can + // never invalidate an index a concurrent read still holds). The handler reads it with an acquire load. + std::atomic s_veh_tls_index{TLS_OUT_OF_INDEXES}; + // Count of reads currently on the guarded path. shutdown_cache drains this to zero before unregistering the handler + // so a fault can never arrive after the handler is gone. + std::atomic s_veh_in_flight{0}; + + // Recovery stub the handler redirects a faulting thread into. __builtin_longjmp restores the stack pointer, frame + // pointer and program counter from the snapshot the matching __builtin_setjmp captured before the read, so recovery + // is correct no matter which frame the fault occurred in and without invoking SEH unwinding (which can abort when + // unwound from a vectored-handler-resumed context). The handler passes the buffer in the first-argument register so + // the stub touches no thread-local itself. noinline gives it a stable address for the handler to target. + [[noreturn]] __attribute__((noinline)) void veh_perform_longjmp(void *env) noexcept + { + __builtin_longjmp(env, 1); + } + + // Vectored exception handler, installed at the front of the list. It claims a fault only when the current thread is + // inside a guarded read (the TLS slot is non-null), the code is one a guarded probe owns (is_guarded_read_fault -- + // the same set the MSVC __except filters use), the record carries a faulting address, and that address falls inside + // the foreign range being read. Every other fault is passed straight through, so a fault from the destination + // write, a host software exception reusing one of these codes, or any code running outside a guarded read still + // reaches the host's own handlers unchanged. On a claimed fault it redirects the thread into veh_perform_longjmp, + // which reports the read as failed. + LONG NTAPI dmk_veh_read_handler(PEXCEPTION_POINTERS info) noexcept + { + const DWORD slot = s_veh_tls_index.load(std::memory_order_acquire); + if (slot == TLS_OUT_OF_INDEXES) + return EXCEPTION_CONTINUE_SEARCH; + + auto *const guard = static_cast(TlsGetValue(slot)); + if (guard == nullptr) + return EXCEPTION_CONTINUE_SEARCH; + + const EXCEPTION_RECORD *const record = info->ExceptionRecord; + if (!Memory::detail::is_guarded_read_fault(record->ExceptionCode)) + return EXCEPTION_CONTINUE_SEARCH; + + // A guarded foreign read can only fault with a hardware access-violation, guard-page or in-page-error, all of + // which carry the faulting data address in ExceptionInformation[1]. Refuse to claim a record without it: that + // rules out a host RaiseException reusing one of these NTSTATUS codes with no address from being hijacked out + // of the host's control flow while a guarded read happens to be in flight on this thread. + if (record->NumberParameters < 2) + return EXCEPTION_CONTINUE_SEARCH; + + // Confine the claim to the foreign source range, so a fault from the destination write (a caller bug, not a + // foreign-read fault) reaches the host's handlers instead of being silently swallowed. + const uintptr_t fault_address = static_cast(record->ExceptionInformation[1]); + if (fault_address < guard->guard_lo || fault_address >= guard->guard_hi) + return EXCEPTION_CONTINUE_SEARCH; + + // Disarm before resuming so a fault inside the longjmp stub would pass through rather than recurse. + TlsSetValue(slot, nullptr); + + // Resume the faulting thread in veh_perform_longjmp(env): instruction pointer to the stub, setjmp buffer in the + // Win64 first-argument register (RCX). The stub is entered by an injected RIP change, not a CALL, so the + // fault-point RSP is not the ABI-required call alignment; pre-align it (the stub reloads RSP from the snapshot + // anyway, so this only protects the stub's own prologue) to keep the resume robust against future codegen that + // might touch an aligned stack slot before the reload. + CONTEXT *const ctx = info->ContextRecord; + ctx->Rsp = (ctx->Rsp & ~static_cast(15)) - 8; + ctx->Rcx = reinterpret_cast(&guard->env); + ctx->Rip = reinterpret_cast(&veh_perform_longjmp); + return EXCEPTION_CONTINUE_EXECUTION; + } + + // Install the handler once, lazily. Re-installable across an init/shutdown cycle: shutdown_cache removes it and + // clears the handle, so a later guarded read or re-init installs a fresh one. Best-effort: if either TlsAlloc + // or AddVectoredExceptionHandler fails (realistic only under exhaustion) the handle stays null and the guarded read + // paths fall back to VirtualQuery validation. + void ensure_veh_installed() noexcept + { + if (s_veh_handle.load(std::memory_order_acquire) != nullptr) + return; + std::lock_guard lock(s_veh_mutex); + if (s_veh_handle.load(std::memory_order_relaxed) != nullptr) + return; + if (s_veh_tls_index.load(std::memory_order_relaxed) == TLS_OUT_OF_INDEXES) + { + const DWORD slot = TlsAlloc(); + if (slot == TLS_OUT_OF_INDEXES) + return; // cannot guard; the read paths fall back to VirtualQuery validation + s_veh_tls_index.store(slot, std::memory_order_release); + } + // First in the list (FirstHandler = 1): a guarded read always resolves through this handler before any consumer + // VEH or frame-based SEH. Every fault that is not this thread's own in-flight guarded read is passed through + // with EXCEPTION_CONTINUE_SEARCH, so being first never starves the host's handlers. + void *const handle = AddVectoredExceptionHandler(1, dmk_veh_read_handler); + s_veh_handle.store(handle, std::memory_order_release); + } + + void remove_veh_handler() noexcept + { + std::lock_guard lock(s_veh_mutex); + void *const handle = s_veh_handle.load(std::memory_order_relaxed); + if (handle == nullptr) + return; + // Stop new guarded reads from taking the handler path, then wait for any read already committed to it to finish + // before unregistering, so a fault cannot arrive after the handler is gone. The seq_cst store pairs with the + // seq_cst fetch_add / handle-load in veh_read_bytes (the Dekker protocol the cache reader epoch uses): a read + // that observed a live handle is necessarily counted in s_veh_in_flight before this store is observed. + s_veh_handle.store(nullptr, std::memory_order_seq_cst); + int spins = 0; + while (s_veh_in_flight.load(std::memory_order_seq_cst) > 0) + { + if (spins < 4096) + std::this_thread::yield(); + else + std::this_thread::sleep_for(std::chrono::microseconds(100)); + ++spins; + } + RemoveVectoredExceptionHandler(handle); + } + + // Copy [src, src + len) into out under the vectored handler. The copy is a single rep movsb emitted as raw inline + // assembly: inline asm is invisible to AddressSanitizer, which instruments only compiler-emitted loads, so this + // deliberate cross-region read cannot raise an ASan false positive (the same reason the MSVC probe copies via the + // __movsb intrinsic). __builtin_setjmp records the recovery point; the guard is then published to this thread's TLS + // slot so a read fault is claimable, and the handler longjmps back here so the setjmp expression returns non-zero + // and the function reports failure. noinline keeps the read and its setjmp anchor in one self-contained frame. + __attribute__((noinline)) bool veh_guarded_copy(void *out, const void *src, size_t len) noexcept + { + const DWORD slot = s_veh_tls_index.load(std::memory_order_acquire); + VehReadGuard guard; + guard.guard_lo = reinterpret_cast(src); + guard.guard_hi = guard.guard_lo + len; + + if (__builtin_setjmp(guard.env) != 0) + { + // Reached only when the handler longjmped here after swallowing a read fault; the handler already cleared + // the TLS slot. Report the failure. + return false; + } + + // Arm after the setjmp captures env and before the read, so a fault in the rep movsb below is claimable while a + // fault before the buffer is valid is not. TlsSetValue writes the thread's TLS array with no allocation. + TlsSetValue(slot, &guard); + + void *dst = out; + const void *cur = src; + size_t n = len; + __asm__ __volatile__("rep movsb" : "+D"(dst), "+S"(cur), "+c"(n) : : "memory"); + + TlsSetValue(slot, nullptr); + return true; + } + + // Single entry point the MinGW read paths share. Rejects a wrapping or low source range first (a wrapped + // addr + bytes would invert the handler's [guard_lo, guard_hi) check and let a real fault escape the guard); + // mirrors seh_read_bytes' own precheck so read_ptr_unsafe, which has no precheck of its own, is covered too. Counts + // the read in the drain epoch around the path decision so a read on the guarded path is always visible to + // remove_veh_handler's drain. Falls back to VirtualQuery validation when the handler is unavailable. + bool veh_read_bytes(uintptr_t addr, void *out, size_t bytes) noexcept + { + if (addr < SEH_READ_MIN_VALID_ADDR || addr + bytes < addr) + return false; + + ensure_veh_installed(); + + s_veh_in_flight.fetch_add(1, std::memory_order_seq_cst); + const bool armed = s_veh_handle.load(std::memory_order_seq_cst) != nullptr; + const bool ok = armed ? veh_guarded_copy(out, reinterpret_cast(addr), bytes) + : virtualquery_validated_copy(addr, out, bytes); + s_veh_in_flight.fetch_sub(1, std::memory_order_release); + return ok; + } +#else // !_WIN64 + // 32-bit MinGW: the handler's recovery redirect rewrites x64 CONTEXT registers (Rcx/Rip) and the longjmp buffer is + // x64-sized, so the vectored guard is x64-only. A guarded read here validates every region with VirtualQuery before + // copying instead. + bool veh_read_bytes(uintptr_t addr, void *out, size_t bytes) noexcept + { + if (addr < SEH_READ_MIN_VALID_ADDR || addr + bytes < addr) + return false; + return virtualquery_validated_copy(addr, out, bytes); + } +#endif // _WIN64 +} // anonymous namespace +#endif // !_MSC_VER + bool DetourModKit::Memory::init_cache(size_t cache_size, unsigned int expiry_ms, size_t shard_count) { // Hold state mutex to prevent concurrent clear_cache or shutdown_cache @@ -863,6 +1111,13 @@ bool DetourModKit::Memory::init_cache(size_t cache_size, unsigned int expiry_ms, return false; } +#if !defined(_MSC_VER) && defined(_WIN64) + // MinGW has no frame-based SEH; install the process-wide vectored fault handler the seh_read paths rely on so a + // guarded read never has to fall back to a per-call VirtualQuery. Best-effort and independent of cache success: + // a failed install only costs the guarded reads their VirtualQuery fallback. + ensure_veh_installed(); +#endif + // Try to start background cleanup thread (may fail silently on MinGW) s_cleanup_thread_running.store(true, std::memory_order_release); try @@ -890,6 +1145,13 @@ bool DetourModKit::Memory::init_cache(size_t cache_size, unsigned int expiry_ms, { if (is_loader_lock_held()) { +#if !defined(_MSC_VER) && defined(_WIN64) + // Remove the vectored fault handler before the module can be unloaded: a list removal is + // safe under loader lock, and leaving the handler registered against soon-to-be-freed code + // is worse than the pinned-thread leak below (it would actively claim faults on a defunct + // subsystem). shutdown_cache is not reached on this branch, so do it here. + remove_veh_handler(); +#endif // Under loader lock (FreeLibrary path): pin the module so code pages remain valid for the // detached thread, then signal it to stop and detach. s_cleanup_thread_running.store(false, std::memory_order_release); @@ -1039,6 +1301,15 @@ void DetourModKit::Memory::shutdown_cache() s_max_entries_per_shard.store(0, std::memory_order_relaxed); s_cleanup_requested.store(false, std::memory_order_relaxed); +#if !defined(_MSC_VER) && defined(_WIN64) + // Remove the vectored fault handler installed for the MinGW seh_read paths so it cannot dangle into freed code if + // the DMK module is unloaded after teardown. remove_veh_handler drains guarded reads still on the handler path + // before unregistering, so an in-flight read cannot fault into a missing handler. Idempotent: a no-op when the + // handler was never installed (cache used without any guarded read) or already removed. A later guarded read + // re-installs it. + remove_veh_handler(); +#endif + Logger::get_instance().debug("MemoryCache: Shutdown complete."); } @@ -1364,73 +1635,20 @@ uintptr_t DetourModKit::Memory::read_ptr_unsafe(uintptr_t base, ptrdiff_t offset return 0; } #else - // MinGW/GCC lacks __try/__except. Probe the cache with a trylock to avoid a VirtualQuery syscall when the region is - // already cached. Falls back to VirtualQuery on cache miss or when cache is off. ActiveReaderGuard is required to - // prevent shutdown_cache() from destroying shard vectors between our check and access. - const auto src = base + static_cast(offset); - - { - ActiveReaderGuard reader_guard; - - if (s_cache_initialized.load(std::memory_order_seq_cst)) - { - const size_t shard_count = s_shard_count.load(std::memory_order_acquire); - if (shard_count != 0) - { - const size_t shard_idx = compute_shard_index(src, shard_count); - std::shared_lock lock(*s_shard_mutexes[shard_idx], std::try_to_lock); - if (lock.owns_lock()) - { - const uint64_t now_ns = current_time_ns(); - const uint64_t expiry_ns = configured_expiry_ns(); - CachedMemoryRegionInfo *cached = - find_in_shard(s_cache_shards[shard_idx], src, sizeof(uintptr_t), now_ns, expiry_ns); - if (cached) - { - if (check_read_permission(cached->protection)) - { - // memcpy for the same misalignment reason as the MSVC path above. - uintptr_t value; - std::memcpy(&value, reinterpret_cast(src), sizeof(value)); - return value; - } - return 0; - } - } - } - } - } - - // Cache miss, lock contention, or cache not initialized - MEMORY_BASIC_INFORMATION mbi; - if (!VirtualQuery(reinterpret_cast(src), &mbi, sizeof(mbi))) + // MinGW/GCC lacks __try/__except. Read the pointer-sized value through the process-wide vectored fault guard (see + // veh_read_bytes): a fault -- unmapped, PAGE_NOACCESS/guard, or a page reprotected out from under a stale cache + // entry -- is swallowed and reported as failure. This matches the MSVC path's "0 on fault" contract without a + // per-call VirtualQuery and without trusting cached protection data for an unguarded dereference. memcpy semantics + // are preserved (the guarded copy is byte-wise), so a misaligned foreign pointer is read without the undefined + // behavior of dereferencing it. + const uintptr_t src = base + static_cast(offset); + uintptr_t value = 0; + if (!veh_read_bytes(src, &value, sizeof(value))) return 0; - if (mbi.State != MEM_COMMIT) - return 0; - if ((mbi.Protect & CachePermissions::READ_PERMISSION_FLAGS) == 0 || - (mbi.Protect & CachePermissions::NOACCESS_GUARD_FLAGS) != 0) - return 0; - // Verify the full read fits within the committed region (overflow-safe) - const uintptr_t region_start = reinterpret_cast(mbi.BaseAddress); - const uintptr_t region_end = region_start + mbi.RegionSize; - const uintptr_t read_end = src + sizeof(uintptr_t); - if (read_end < src || src < region_start || read_end > region_end) - return 0; - // memcpy for the same misalignment reason as the MSVC path above. - uintptr_t value; - std::memcpy(&value, reinterpret_cast(src), sizeof(value)); return value; #endif } -// Lower bound on a valid usermode pointer on x64 Windows. The null page plus the standard NoAccess guard region cover -// [0, 0x10000); rejecting addresses in this range without a memory access keeps stale or sentinel pointers from raising -// first-chance exceptions in callers' debuggers. -namespace -{ - inline constexpr uintptr_t SEH_READ_MIN_VALID_ADDR = 0x10000; -} // anonymous namespace - bool DetourModKit::Memory::seh_read_bytes(uintptr_t addr, void *out, size_t bytes) noexcept { if (bytes == 0) @@ -1462,35 +1680,10 @@ bool DetourModKit::Memory::seh_read_bytes(uintptr_t addr, void *out, size_t byte return false; } #else - // MinGW lacks __try/__except. Validate every region the read spans with - // VirtualQuery before issuing the memcpy. The loop chains across adjacent regions so multi-region reads (which - // happen for any buffer that crosses an allocation boundary) succeed when every covered page is committed and - // readable. - size_t copied = 0; - while (copied < bytes) - { - const uintptr_t cur = addr + copied; - MEMORY_BASIC_INFORMATION mbi; - if (!VirtualQuery(reinterpret_cast(cur), &mbi, sizeof(mbi))) - return false; - if (mbi.State != MEM_COMMIT) - return false; - if ((mbi.Protect & CachePermissions::READ_PERMISSION_FLAGS) == 0 || - (mbi.Protect & CachePermissions::NOACCESS_GUARD_FLAGS) != 0) - return false; - - const uintptr_t region_start = reinterpret_cast(mbi.BaseAddress); - const uintptr_t region_end = region_start + mbi.RegionSize; - if (cur < region_start || cur >= region_end) - return false; - - const size_t available = static_cast(region_end - cur); - const size_t remaining = bytes - copied; - const size_t to_copy = (remaining < available) ? remaining : available; - std::memcpy(static_cast(out) + copied, reinterpret_cast(cur), to_copy); - copied += to_copy; - } - return true; + // MinGW lacks __try/__except. Read through the process-wide vectored fault guard (see veh_read_bytes): the success + // path is a single rep movsb with no syscall, and any read fault across the span -- including a multi-region read + // that crosses into unmapped or protected memory -- is swallowed and reported as failure. + return veh_read_bytes(addr, out, bytes); #endif } @@ -1525,8 +1718,8 @@ namespace return false; } #else - // MinGW lacks __try/__except. Each intermediate link is read through read_ptr_unsafe (VirtualQuery-guarded, - // returns 0 on fault); the plausibility screen also rejects that 0. + // MinGW lacks __try/__except. Each intermediate link is read through read_ptr_unsafe, which returns 0 on + // fault; the plausibility screen also rejects that 0. uintptr_t cur = base; for (size_t i = 0; i + 1 < count; ++i) { @@ -1591,8 +1784,8 @@ bool DetourModKit::Memory::seh_read_chain_bytes(uintptr_t base, std::span(DetourModKit::DEFAULT_CACHE_EXPIRY_MS)); diff --git a/tests/test_memory.cpp b/tests/test_memory.cpp index 580ef6e..285f4ba 100644 --- a/tests/test_memory.cpp +++ b/tests/test_memory.cpp @@ -1498,7 +1498,8 @@ TEST_F(MemoryTest, ReadPtrUnsafe_GuardPage) VirtualProtect(mem, 4096, PAGE_READWRITE | PAGE_GUARD, &old_protect); uintptr_t result = Memory::read_ptr_unsafe(reinterpret_cast(mem), 0); - // MSVC SEH catches the guard page exception and returns 0. MinGW VirtualQuery detects PAGE_GUARD and returns 0. + // MSVC SEH catches the guard-page exception and returns 0. MinGW's vectored fault handler swallows the same + // STATUS_GUARD_PAGE_VIOLATION and returns 0. EXPECT_EQ(result, 0u); VirtualFree(mem, 0, MEM_RELEASE); @@ -1802,15 +1803,15 @@ TEST_F(MemoryTest, IsReadableNonblocking_LargeValidRegion) VirtualFree(mem, 0, MEM_RELEASE); } -TEST_F(MemoryTest, ReadPtrUnsafe_CacheHitPath) +TEST_F(MemoryTest, ReadPtrUnsafe_AfterCachePrime) { uintptr_t value = 0xDEADBEEF; auto addr = reinterpret_cast(&value); - // Prime the cache with a readable check + // Prime the cache with a readable check. read_ptr_unsafe consults no cache (the fault guard makes a probe + // unnecessary), so this only confirms a primed region still reads back its value through the guarded path. EXPECT_TRUE(Memory::is_readable(&value, sizeof(uintptr_t))); - // read_ptr_unsafe should use the cached entry uintptr_t result = Memory::read_ptr_unsafe(addr, 0); EXPECT_EQ(result, value); } @@ -2183,3 +2184,261 @@ TEST(MemoryUninitializedCache, PermissionChecksFallBackToDirectQuery) VirtualProtect(region, 4096, old_protect, &old_protect); VirtualFree(region, 0, MEM_RELEASE); } + +// A reserved-but-uncommitted page is backed by no physical storage, so any read of it faults. The guarded read must +// fail closed rather than terminate the host. On MinGW the vectored handler swallows the access violation; on MSVC the +// __except filter does. +TEST_F(MemoryTest, SehReadBytes_ReservedUncommittedReturnsFalse) +{ + void *reserved = VirtualAlloc(nullptr, 4096, MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(reserved, nullptr); + + uint64_t out = 0; + EXPECT_FALSE(Memory::seh_read_bytes(reinterpret_cast(reserved), &out, sizeof(out))); + + VirtualFree(reserved, 0, MEM_RELEASE); +} + +// With the cache reporting a page readable, an external reprotect to PAGE_NOACCESS must not crash a subsequent +// read_ptr_unsafe. The guarded read must ignore stale protection data and fail closed through the same "0 on fault" +// contract on both toolchains. +TEST_F(MemoryTest, ReadPtrUnsafeSurvivesStaleCacheReprotect) +{ + void *region = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(region, nullptr); + const uintptr_t addr = reinterpret_cast(region); + *reinterpret_cast(region) = 0xABCDEF0123456789ULL; + + // Prime the cache so the region is recorded as readable. + EXPECT_TRUE(Memory::is_readable(region, sizeof(uintptr_t))); + EXPECT_EQ(Memory::read_ptr_unsafe(addr, 0), 0xABCDEF0123456789ULL); + + // Reprotect out from under the still-readable cache entry. The cache is not invalidated, so a cache-trusting read + // would dereference a now-inaccessible page. + DWORD old_protect = 0; + ASSERT_NE(VirtualProtect(region, 4096, PAGE_NOACCESS, &old_protect), 0); + + // The fault is swallowed and reported as the pointer-read failure value. + EXPECT_EQ(Memory::read_ptr_unsafe(addr, 0), 0u); + + VirtualProtect(region, 4096, old_protect, &old_protect); + VirtualFree(region, 0, MEM_RELEASE); +} + +namespace +{ + // A consumer-style vectored handler that never claims a fault, standing in for an unrelated VEH a host might + // register without interfering with the guarded read. + LONG CALLBACK consumer_passthrough_veh(PEXCEPTION_POINTERS) + { + return EXCEPTION_CONTINUE_SEARCH; + } +} // namespace + +// The MinGW guarded read installs a process-wide vectored exception handler, which must coexist with a handler the host +// registered. Exercise both list orderings (consumer registered before and after DMK's handler exists) and assert the +// guarded reads still fail closed on bad memory and succeed on live memory. On MSVC there is no DMK VEH; the consumer +// handler simply passes the first-chance fault through to the frame-based __except, so the test is meaningful on both. +TEST_F(MemoryTest, VectoredHandlerCoexistsWithConsumerHandler) +{ + void *mem = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(mem, nullptr); + const uintptr_t bad_addr = reinterpret_cast(mem); + // Now unmapped; any read of bad_addr faults. + VirtualFree(mem, 0, MEM_RELEASE); + + const uint64_t live = 0x1122334455667788ULL; + + auto exercise = [&]() + { + uint64_t out = 0; + EXPECT_FALSE(Memory::seh_read_bytes(bad_addr, &out, sizeof(out))); + EXPECT_EQ(Memory::read_ptr_unsafe(bad_addr, 0), 0u); + out = 0; + EXPECT_TRUE(Memory::seh_read_bytes(reinterpret_cast(&live), &out, sizeof(out))); + EXPECT_EQ(out, live); + }; + + // Ordering 1: consumer handler registered before DMK performs (and lazily installs) its own guarded read. + void *consumer = AddVectoredExceptionHandler(1, consumer_passthrough_veh); + ASSERT_NE(consumer, nullptr); + exercise(); + RemoveVectoredExceptionHandler(consumer); + + // Ordering 2: DMK's handler already exists; register the consumer in front of it and repeat. + consumer = AddVectoredExceptionHandler(1, consumer_passthrough_veh); + ASSERT_NE(consumer, nullptr); + exercise(); + RemoveVectoredExceptionHandler(consumer); +} + +// A wrapping or low source range must fail closed to 0 rather than crash. On MinGW the wrap matters specifically: a +// source within 8 bytes of UINTPTR_MAX would make the guarded read's [lo, hi) range wrap so the fault-claim check +// inverts and a real read fault escapes the guard; the shared entry point rejects the wrap before reading. On MSVC the +// __try simply catches the fault. Both return 0. +TEST_F(MemoryTest, ReadPtrUnsafeWraparoundAndLowAddressReturnZero) +{ + // An 8-byte read would wrap past the top of the address space. + EXPECT_EQ(Memory::read_ptr_unsafe(UINTPTR_MAX - 3, 0), 0u); + + // Below the user-mode floor. + EXPECT_EQ(Memory::read_ptr_unsafe(0x100, 0), 0u); + + // A low base plus a large negative offset underflows the source to a high, unmapped address; still 0, no crash. + EXPECT_EQ(Memory::read_ptr_unsafe(0x20000, -static_cast(0x30000)), 0u); +} + +// Each guarded read arms its own per-read guard published to a thread-local slot, so concurrent reads of mixed +// good/bad memory across many threads must each get the right answer with no cross-thread guard corruption and no +// crash. This pins the per-thread isolation of the fault guard. +TEST_F(MemoryTest, GuardedReadsAreThreadIsolatedUnderConcurrency) +{ + void *good = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(good, nullptr); + constexpr uintptr_t kSentinel = 0x5151515151515151ULL; + *reinterpret_cast(good) = kSentinel; + + void *freed = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(freed, nullptr); + + // Now unmapped; reads of it must fault through the guard to 0. + VirtualFree(freed, 0, MEM_RELEASE); + + std::atomic all_correct{true}; + std::vector threads; + for (int t = 0; t < 8; ++t) + { + threads.emplace_back( + [&, t]() + { + for (int i = 0; i < 1000; ++i) + { + if (t % 2 == 0) + { + if (Memory::read_ptr_unsafe(reinterpret_cast(good), 0) != kSentinel) + all_correct.store(false, std::memory_order_relaxed); + } + else if (Memory::read_ptr_unsafe(reinterpret_cast(freed), 0) != 0u) + { + all_correct.store(false, std::memory_order_relaxed); + } + } + }); + } + for (auto &th : threads) + th.join(); + + EXPECT_TRUE(all_correct.load()); + VirtualFree(good, 0, MEM_RELEASE); +} + +// shutdown_cache removes the MinGW vectored handler; it must drain reads still on the handler path first, so a fault +// cannot arrive after the handler is gone. Race continuous guarded reads (some faulting) against repeated +// shutdown/re-init and assert survival, then that the subsystem still answers. On MSVC there is no handler to remove, +// so this exercises read_ptr_unsafe / cache-shutdown concurrency. +TEST_F(MemoryTest, GuardedReadsSurviveConcurrentShutdown) +{ + void *good = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(good, nullptr); + constexpr uintptr_t kSentinel = 0xA5A5A5A5A5A5A5A5ULL; + *reinterpret_cast(good) = kSentinel; + + void *freed = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + ASSERT_NE(freed, nullptr); + VirtualFree(freed, 0, MEM_RELEASE); + + std::atomic stop{false}; + std::vector readers; + for (int t = 0; t < 3; ++t) + { + readers.emplace_back( + [&, t]() + { + while (!stop.load(std::memory_order_acquire)) + Memory::read_ptr_unsafe(reinterpret_cast((t % 2) ? freed : good), 0); + }); + } + + for (int round = 0; round < 12; ++round) + { + Memory::shutdown_cache(); + (void)Memory::init_cache(); + } + + stop.store(true, std::memory_order_release); + for (auto &th : readers) + th.join(); + + EXPECT_EQ(Memory::read_ptr_unsafe(reinterpret_cast(good), 0), kSentinel); + VirtualFree(good, 0, MEM_RELEASE); +} + +namespace +{ + std::atomic g_recover_page{nullptr}; + std::atomic g_recover_hits{0}; + + // A consumer handler that recovers an access violation on a registered sentinel page by making it readable and + // re-executing the faulting instruction. Used to prove DMK's handler passed an unrelated fault through. + LONG CALLBACK recover_noaccess_veh(PEXCEPTION_POINTERS info) + { + void *const page = g_recover_page.load(std::memory_order_acquire); + const EXCEPTION_RECORD *const rec = info->ExceptionRecord; + if (page != nullptr && rec->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && rec->NumberParameters >= 2) + { + const uintptr_t fault = static_cast(rec->ExceptionInformation[1]); + const uintptr_t base = reinterpret_cast(page); + if (fault >= base && fault < base + 4096) + { + DWORD old_protect = 0; + VirtualProtect(page, 4096, PAGE_READWRITE, &old_protect); + g_recover_hits.fetch_add(1, std::memory_order_relaxed); + return EXCEPTION_CONTINUE_EXECUTION; + } + } + return EXCEPTION_CONTINUE_SEARCH; + } +} // namespace + +// DMK's process-wide handler runs on every thread for every fault, including threads that never armed a guarded read. +// Such a fault must be passed straight through (the handler reads its per-thread slot with an allocation-free +// TlsGetValue and sees null), never claimed or hijacked. Here a worker thread that has never issued a DMK guarded read +// faults on a no-access page while DMK's handler is installed; a consumer handler recovers it. A spurious DMK claim +// (longjmp into a frame that never armed) or a crash would fail this. On MSVC there is no DMK handler, so the consumer +// simply recovers the fault directly. +TEST_F(MemoryTest, UnarmedThreadFaultIsPassedThroughNotClaimed) +{ + // Arm and disarm DMK's handler on this thread so it is installed for the process. + uint64_t probe_src = 0xC3C3C3C3C3C3C3C3ULL; + uint64_t probe_out = 0; + ASSERT_TRUE(Memory::seh_read_bytes(reinterpret_cast(&probe_src), &probe_out, sizeof(probe_out))); + + void *page = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_NOACCESS); + ASSERT_NE(page, nullptr); + g_recover_page.store(page, std::memory_order_release); + g_recover_hits.store(0, std::memory_order_release); + + // Last in the list, so DMK's first-in-list handler sees the fault first and must decline it. + void *consumer = AddVectoredExceptionHandler(0, recover_noaccess_veh); + ASSERT_NE(consumer, nullptr); + + std::atomic observed{0xFFFFFFFFu}; + std::thread worker( + [&]() + { + volatile uint32_t *const p = reinterpret_cast(page); + // Faults once; the consumer handler makes the page readable and retries the instruction. + observed.store(*p, std::memory_order_release); + }); + worker.join(); + + RemoveVectoredExceptionHandler(consumer); + g_recover_page.store(nullptr, std::memory_order_release); + + // The unarmed-thread fault reached the consumer, proving DMK passed it through. + EXPECT_GE(g_recover_hits.load(), 1); + + // The recommitted page reads as zero. + EXPECT_EQ(observed.load(), 0u); + VirtualFree(page, 0, MEM_RELEASE); +} diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index 56a0fbb..2e5a406 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -3382,7 +3382,9 @@ TEST(ScannerHostModuleCascade, EmptyCandidatesReturnsEmptyCandidates) // reports a false match for an absent needle. A run where the decommit never lands inside the read window is a valid // pass for the fault path; the __except / skip-the-region mechanism is pinned deterministically by // MemoryGuardedReadFault and the seh_read_bytes NoAccess / GuardPage tests in test_memory.cpp. MSVC-only: MinGW has no -// structured exception handling, so the race can still fault there until the VEH-based handler lands. +// structured exception handling, and the vectored handler that guards the seh_read primitives does not extend to this +// sweep's bulk find_pattern_raw reads, so the per-region VirtualQuery gate is the only guard there and the race can +// still fault. TEST(ScannerRegionGuard, SurvivesConcurrentDecommitMidSweep) { SYSTEM_INFO si{}; From ddfa275917240c552092d99f8f3e14494beaace1 Mon Sep 17 00:00:00 2001 From: Quang Trinh Date: Sat, 13 Jun 2026 04:18:27 +0700 Subject: [PATCH 2/2] test(memory): make the VEH shutdown-drain race deterministic Wait for a live good-path and faulting-path guarded read before the shutdown/init loop so it exercises the in-flight drain rather than possibly racing ahead of any guarded read. Also clarify in hot-path-memory.md that MinGW seh_* takes the VEH guarded-copy path only when the handler installed, and falls back to a VirtualQuery-validated copy on install failure or 32-bit builds. --- docs/misc/hot-path-memory.md | 2 +- tests/test_memory.cpp | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/misc/hot-path-memory.md b/docs/misc/hot-path-memory.md index 629f23b..596a2dc 100644 --- a/docs/misc/hot-path-memory.md +++ b/docs/misc/hot-path-memory.md @@ -116,7 +116,7 @@ if (value) ## Toolchain note -The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no frame-based SEH) they use a process-wide vectored exception handler: the success path is a guarded copy with no `VirtualQuery`, and a read fault returns `std::nullopt` / `false` instead of crashing. `read_ptr_unchecked` is still the fastest choice when you can prove the pointer is live for the current frame; otherwise prefer `seh_read` / `seh_read_chain` for stale or unmapped pointers. Shipping mod builds target MSVC, so the zero-cost path is the normal case. +The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no frame-based SEH) a 64-bit build installs a process-wide vectored exception handler once and runs the read as a guarded copy with no `VirtualQuery` on the success path, recovering a fault as `std::nullopt` / `false` instead of crashing. This is best-effort: `veh_read_bytes` takes the VEH guarded-copy path only when handler installation succeeded (`s_veh_handle` is non-null); if `ensure_veh_installed()` failed, or on a 32-bit MinGW build (`!_WIN64`), it falls back to `virtualquery_validated_copy`, a `VirtualQuery`-validated copy that pays a syscall per region. So MinGW `seh_*` reads are fault-safe either way, using the VEH when available and the VirtualQuery fallback otherwise. `read_ptr_unchecked` is still the fastest choice when you can prove the pointer is live for the current frame; otherwise prefer `seh_read` / `seh_read_chain` for stale or unmapped pointers. Shipping mod builds target MSVC, so the zero-cost path is the normal case. ## Anti-patterns to remove diff --git a/tests/test_memory.cpp b/tests/test_memory.cpp index 285f4ba..f91fbb9 100644 --- a/tests/test_memory.cpp +++ b/tests/test_memory.cpp @@ -2348,17 +2348,37 @@ TEST_F(MemoryTest, GuardedReadsSurviveConcurrentShutdown) VirtualFree(freed, 0, MEM_RELEASE); std::atomic stop{false}; + std::atomic seen_good{0}; + std::atomic seen_fault{0}; std::vector readers; for (int t = 0; t < 3; ++t) { readers.emplace_back( [&, t]() { + void *const target = (t % 2) ? freed : good; while (!stop.load(std::memory_order_acquire)) - Memory::read_ptr_unsafe(reinterpret_cast((t % 2) ? freed : good), 0); + { + const uintptr_t v = Memory::read_ptr_unsafe(reinterpret_cast(target), 0); + if (target == good) + { + if (v == kSentinel) + seen_good.fetch_add(1, std::memory_order_relaxed); + } + else if (v == 0u) + { + seen_fault.fetch_add(1, std::memory_order_relaxed); + } + } }); } + // Wait until both guarded-read paths are actually live (one good-path read, one faulting-path read) before racing + // teardown, so the shutdown loop deterministically exercises the in-flight-read drain rather than possibly running + // before any guarded read happened. + while (seen_good.load(std::memory_order_acquire) == 0 || seen_fault.load(std::memory_order_acquire) == 0) + std::this_thread::yield(); + for (int round = 0; round < 12; ++round) { Memory::shutdown_cache();