Skip to content

Commit 3f36059

Browse files
authored
fix(memory,scanner): guard region reads against TOCTOU faults (#113)
1 parent 8eef23c commit 3f36059

11 files changed

Lines changed: 631 additions & 131 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ Do not add `Memory::is_readable()` or `Memory::is_writable()` before every field
301301
302302
### Scanning process memory
303303
304-
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.
304+
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`.
305305
306306
## Testing
307307
@@ -380,7 +380,7 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc
380380
- `Memory::is_readable_nonblocking()` -- try_lock_shared + cache lookup (returns Unknown on contention)
381381
- `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), cache-accelerated with VirtualQuery fallback (MinGW)
382382
- `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
383-
- `Memory::seh_read<T>()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, VirtualQuery loop across regions on MinGW. Used by `Rtti` for chained RTTI walks
383+
- `Memory::seh_read<T>()` / `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
384384
- `Memory::seh_resolve_chain()` / `seh_read_chain<T>()` -- 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
385385
- `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)
386386
- `Memory::contains(range, p)` -- constexpr point-in-range test for module bounds checks

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
4141
- `|` offset markers for targeting a specific instruction within a wider pattern (e.g., `"48 8B 88 B8 00 00 00 | 48 89 4C 24 68"` sets the offset to byte 7)
4242
- Nth-occurrence matching (1-based) for patterns that hit multiple locations
4343
- RIP-relative instruction resolution for extracting absolute addresses from x86-64 code (returns `std::expected` with typed `RipResolveError` for actionable diagnostics)
44-
- `scan_executable_regions()` for scanning all committed executable pages in the process - useful for games with packed or protected binaries that unpack code into anonymous memory outside any loaded module (pure-execute pages without a read bit are skipped to avoid access violations)
44+
- `scan_executable_regions()` for scanning all committed executable pages in the process - useful for games with packed or protected binaries that unpack code into anonymous memory outside any loaded module (pure-execute pages without a read bit are skipped to avoid access violations; a region decommitted or reprotected concurrently mid-sweep is skipped rather than faulting the host on MSVC, where each region read runs inside a structured-exception guard)
4545
- `scan_readable_regions()` -- the data-section sibling of `scan_executable_regions()` -- sweeps every committed readable page (`.rdata` / `.data`, read-only heaps) to reach C++ vtables, RTTI type descriptors, and read-only metadata the executable-only sweep cannot see (guard / no-access / uncommitted pages are skipped); opt a cascade into it with `resolve_cascade(..., ScannerKind::Readable)`
4646
- `resolve_cascade()` and the module-scoped `resolve_cascade_in_module()` -- ordered multi-candidate resolution (try signatures in priority order, return the first that resolves), with an optional hooked-prologue recovery pass; the `_in_module` variants confine the scan to one mapped image `[base, end)` and reject out-of-module resolutions, so a generic signature that also matches inside another injected module (a graphics overlay, a sibling mod) cannot shadow the correct in-module target. By default each candidate must match uniquely in the scanned scope: an ambiguous signature (more than one match) falls through to the next candidate instead of silently committing to an arbitrary match, so a too-loose pattern surfaces as a clean failure to fix rather than a wrong hook. Set `require_unique = false` per candidate only to opt out a deliberately non-unique, separately-verified candidate
4747
- `resolve_cascade_in_host_module()` / `resolve_cascade_in_host_module_with_prologue_fallback()` -- one-line convenience overloads that scope a cascade to the host EXE (`host_module_range()`), removing the boilerplate of building the range at every call site. They return `ResolveError::InvalidRange` if the host range cannot be determined. Use them only when the target lives in the host EXE; for a game whose logic is in a separate module (an engine DLL), resolve that module's range and call `resolve_cascade_in_module()` instead

include/DetourModKit/memory.hpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,12 @@ namespace DetourModKit
342342

343343
/**
344344
* @brief SEH-guarded raw memory copy from @p addr into @p out.
345-
* @details On MSVC, the copy runs inside a __try / __except
346-
* (EXCEPTION_ACCESS_VIOLATION | STATUS_GUARD_PAGE_VIOLATION)
347-
* frame, so any access violation in the middle of the copy unwinds cleanly and the function returns
348-
* false. On MinGW the implementation falls back to VirtualQuery-based validation of every region the
349-
* read spans before issuing the memcpy; this is race-prone against concurrent
350-
* VirtualProtect but is the best a non-SEH toolchain can do.
345+
* @details On MSVC, the copy runs inside a __try / __except frame whose filter accepts the foreign-read fault
346+
* set (EXCEPTION_ACCESS_VIOLATION, STATUS_GUARD_PAGE_VIOLATION, and EXCEPTION_IN_PAGE_ERROR for a
347+
* file-backed page that fails to page in), so any such fault in the middle of the copy unwinds cleanly
348+
* and the function returns false. On MinGW the implementation falls back to VirtualQuery-based
349+
* validation of every region the read spans before issuing the memcpy; this is race-prone against
350+
* concurrent VirtualProtect but is the best a non-SEH toolchain can do.
351351
*
352352
* The function is the underlying primitive for the typed @ref seh_read template and is exposed
353353
* directly for callers that need to read a contiguous buffer of bytes (for example NUL-terminated

src/memory.cpp

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include "DetourModKit/logger.hpp"
1616
#include "DetourModKit/srw_shared_mutex.hpp"
1717
#include "platform.hpp"
18+
#include "memory_internal.hpp"
1819

1920
#include <windows.h>
2021
#if defined(_MSC_VER) && defined(__SANITIZE_ADDRESS__)
@@ -668,8 +669,10 @@ namespace
668669

669670
constexpr size_t MAX_INVALIDATION_RETRIES = 3;
670671

672+
size_t skipped_shards = 0;
671673
for (size_t shard_idx = 0; shard_idx < shard_count; ++shard_idx)
672674
{
675+
bool invalidated = false;
673676
for (size_t retry = 0; retry < MAX_INVALIDATION_RETRIES; ++retry)
674677
{
675678
std::unique_lock<SrwSharedMutex> lock(*s_shard_mutexes[shard_idx], std::try_to_lock);
@@ -684,8 +687,25 @@ namespace
684687
}
685688

686689
evict_overlapping_entries_in_shard(s_cache_shards[shard_idx], address, end_address);
690+
invalidated = true;
687691
break;
688692
}
693+
if (!invalidated)
694+
{
695+
++skipped_shards;
696+
}
697+
}
698+
699+
if (skipped_shards > 0)
700+
{
701+
// A shard that stayed contended across every retry keeps its entries in place, so they answer from the
702+
// pre-write protection state until the configured expiry sweeps them. This matters chiefly when the caller
703+
// could not restore protection (write_bytes restores on success), so surface it for diagnosis instead of
704+
// skipping silently. try_log keeps this noexcept path honest when the level is enabled.
705+
(void)Logger::get_instance().try_log(LogLevel::Debug,
706+
"MemoryCache: invalidate_range left {} contended shard(s) unswept; "
707+
"stale entries persist until expiry.",
708+
skipped_shards);
689709
}
690710
}
691711

@@ -1114,13 +1134,24 @@ namespace
11141134
if (!address || size == 0)
11151135
return false;
11161136

1117-
// Construct reader guard BEFORE checking s_cache_initialized to prevent shutdown_cache from destroying data
1137+
// Construct reader guard BEFORE loading s_cache_initialized to prevent shutdown_cache from destroying data
11181138
// structures between the check and access.
11191139
ActiveReaderGuard reader_guard;
11201140

1121-
if (!s_cache_initialized.load(std::memory_order_seq_cst))
1141+
// Snapshot cache readiness once, under the guard. The guard's seq_cst increment is ordered before this seq_cst
1142+
// load of s_cache_initialized (the Dekker protocol that lets shutdown_cache drain readers safely). shard_count
1143+
// is loaded only when initialized, so the cache path below operates on a single consistent value.
1144+
const bool cache_initialized = s_cache_initialized.load(std::memory_order_seq_cst);
1145+
const size_t shard_count = cache_initialized ? s_shard_count.load(std::memory_order_acquire) : 0;
1146+
1147+
// Fall back to a direct VirtualQuery whenever the cache is unavailable: never initialized, observed in the
1148+
// brief init publication window where s_cache_initialized is already true but s_shard_count is still 0
1149+
// (init_cache publishes the flag before perform_cache_initialization stores the count), or a concurrent
1150+
// shutdown that has already zeroed the count. Treating shard_count == 0 as "use the direct query" -- matching
1151+
// read_ptr_unsafe and invalidate_range -- avoids wrongly reporting a readable region as non-readable during
1152+
// that window.
1153+
if (shard_count == 0)
11221154
{
1123-
// Cache not initialized -- fall back to direct VirtualQuery
11241155
MEMORY_BASIC_INFORMATION mbi;
11251156
if (!VirtualQuery(address, &mbi, sizeof(mbi)))
11261157
return false;
@@ -1136,12 +1167,7 @@ namespace
11361167
return query_addr_val >= region_start && query_end <= region_start + mbi.RegionSize;
11371168
}
11381169

1139-
// Reader guard already active -- safe to access cache data structures
1140-
1141-
const size_t shard_count = s_shard_count.load(std::memory_order_acquire);
1142-
if (shard_count == 0)
1143-
return false;
1144-
1170+
// Cache is live and shard_count is non-zero -- the reader guard keeps the shard vectors alive for the access.
11451171
const uintptr_t query_addr_val = reinterpret_cast<uintptr_t>(address);
11461172
const size_t shard_idx = compute_shard_index(query_addr_val, shard_count);
11471173
const uint64_t now_ns = current_time_ns();
@@ -1325,11 +1351,15 @@ uintptr_t DetourModKit::Memory::read_ptr_unsafe(uintptr_t base, ptrdiff_t offset
13251351
#ifdef _MSC_VER
13261352
__try
13271353
{
1328-
return *reinterpret_cast<const uintptr_t *>(base + offset);
1354+
// memcpy, not a *reinterpret_cast deref: the foreign address may be misaligned, and a cast-deref of a
1355+
// misaligned pointer is formal undefined behavior. memcpy has identical codegen here (a single mov on x86-64)
1356+
// and matches the idiom seh_read_bytes already uses for the same reason.
1357+
uintptr_t value;
1358+
std::memcpy(&value, reinterpret_cast<const void *>(base + offset), sizeof(value));
1359+
return value;
13291360
}
1330-
__except ((GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION || GetExceptionCode() == STATUS_GUARD_PAGE_VIOLATION)
1331-
? EXCEPTION_EXECUTE_HANDLER
1332-
: EXCEPTION_CONTINUE_SEARCH)
1361+
__except (Memory::detail::is_guarded_read_fault(GetExceptionCode()) ? EXCEPTION_EXECUTE_HANDLER
1362+
: EXCEPTION_CONTINUE_SEARCH)
13331363
{
13341364
return 0;
13351365
}
@@ -1358,7 +1388,12 @@ uintptr_t DetourModKit::Memory::read_ptr_unsafe(uintptr_t base, ptrdiff_t offset
13581388
if (cached)
13591389
{
13601390
if (check_read_permission(cached->protection))
1361-
return *reinterpret_cast<const uintptr_t *>(src);
1391+
{
1392+
// memcpy for the same misalignment reason as the MSVC path above.
1393+
uintptr_t value;
1394+
std::memcpy(&value, reinterpret_cast<const void *>(src), sizeof(value));
1395+
return value;
1396+
}
13621397
return 0;
13631398
}
13641399
}
@@ -1381,7 +1416,10 @@ uintptr_t DetourModKit::Memory::read_ptr_unsafe(uintptr_t base, ptrdiff_t offset
13811416
const uintptr_t read_end = src + sizeof(uintptr_t);
13821417
if (read_end < src || src < region_start || read_end > region_end)
13831418
return 0;
1384-
return *reinterpret_cast<const uintptr_t *>(src);
1419+
// memcpy for the same misalignment reason as the MSVC path above.
1420+
uintptr_t value;
1421+
std::memcpy(&value, reinterpret_cast<const void *>(src), sizeof(value));
1422+
return value;
13851423
#endif
13861424
}
13871425

@@ -1418,9 +1456,8 @@ bool DetourModKit::Memory::seh_read_bytes(uintptr_t addr, void *out, size_t byte
14181456
#endif
14191457
return true;
14201458
}
1421-
__except ((GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION || GetExceptionCode() == STATUS_GUARD_PAGE_VIOLATION)
1422-
? EXCEPTION_EXECUTE_HANDLER
1423-
: EXCEPTION_CONTINUE_SEARCH)
1459+
__except (Memory::detail::is_guarded_read_fault(GetExceptionCode()) ? EXCEPTION_EXECUTE_HANDLER
1460+
: EXCEPTION_CONTINUE_SEARCH)
14241461
{
14251462
return false;
14261463
}
@@ -1482,10 +1519,8 @@ namespace
14821519
out_addr = (count == 0) ? cur : cur + static_cast<uintptr_t>(offsets[count - 1]);
14831520
return true;
14841521
}
1485-
__except (
1486-
(GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION || GetExceptionCode() == STATUS_GUARD_PAGE_VIOLATION)
1487-
? EXCEPTION_EXECUTE_HANDLER
1488-
: EXCEPTION_CONTINUE_SEARCH)
1522+
__except (Memory::detail::is_guarded_read_fault(GetExceptionCode()) ? EXCEPTION_EXECUTE_HANDLER
1523+
: EXCEPTION_CONTINUE_SEARCH)
14891524
{
14901525
return false;
14911526
}
@@ -1550,9 +1585,8 @@ bool DetourModKit::Memory::seh_read_chain_bytes(uintptr_t base, std::span<const
15501585
std::memcpy(out, reinterpret_cast<const void *>(final_addr), bytes);
15511586
return true;
15521587
}
1553-
__except ((GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION || GetExceptionCode() == STATUS_GUARD_PAGE_VIOLATION)
1554-
? EXCEPTION_EXECUTE_HANDLER
1555-
: EXCEPTION_CONTINUE_SEARCH)
1588+
__except (Memory::detail::is_guarded_read_fault(GetExceptionCode()) ? EXCEPTION_EXECUTE_HANDLER
1589+
: EXCEPTION_CONTINUE_SEARCH)
15561590
{
15571591
return false;
15581592
}

0 commit comments

Comments
 (0)