Skip to content

Commit 8f76e99

Browse files
authored
fix(scanner): keep the AOB scanner and SEH probe ASan-clean (#103)
1 parent 597b0a6 commit 8f76e99

4 files changed

Lines changed: 210 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ via gcov. A non-blocking CI probe in `.github/workflows/quality.yml` builds and
119119
runs the MSVC ASan preset (alongside an advisory clang-format check) so
120120
regressions in that wiring surface without gating PRs.
121121

122+
The AOB scanner and the SEH-guarded probe read deliberately read arbitrary mapped
123+
process memory, which ASan reports as false-positive overflows when it scans this
124+
process's own poisoned shadow. They are excluded from ASan entirely under
125+
`#if defined(__SANITIZE_ADDRESS__)`, so release builds are byte-for-byte
126+
unchanged; see [docs/misc/asan-memory-scanner.md](docs/misc/asan-memory-scanner.md)
127+
for the mechanism and the pattern for any new foreign-memory primitive.
128+
122129
### Makefile wrapper
123130

124131
```bash

docs/misc/asan-memory-scanner.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# AddressSanitizer and the memory scanner
2+
3+
## Summary
4+
5+
DetourModKit's AOB scanner and SEH-guarded probe read deliberately read arbitrary
6+
mapped process memory. Under MSVC AddressSanitizer (the `msvc-debug-asan` preset)
7+
those reads land on memory ASan has poisoned for its own bookkeeping and are
8+
reported as buffer overflows -- even though every read is in bounds of a
9+
committed, readable page and never faults in a release build. They are false
10+
positives intrinsic to running a whole-process memory scanner inside an
11+
ASan-instrumented process.
12+
13+
The fix excludes only the deliberate foreign-memory readers from ASan, entirely
14+
under `#if defined(__SANITIZE_ADDRESS__)`, so release and non-ASan builds are
15+
byte-for-byte unchanged and the full test suite still runs -- and passes -- under
16+
ASan with the scanner exercised.
17+
18+
## What ASan reports
19+
20+
Building the suite under `msvc-debug-asan` without the fix produces reports like:
21+
22+
- `stack-buffer-underflow` / `global-buffer-overflow` in `find_pattern_raw`
23+
(`src/scanner.cpp`), reached via `scan_readable_regions` ->
24+
`scan_regions_filtered`. ASan attributes the address to `find_pattern_raw`'s
25+
own stack frame, or to an instrumented global.
26+
- `global-buffer-overflow` in `seh_read_bytes` (`src/memory.cpp`), reached via
27+
the RTTI host-module section walk.
28+
29+
## Root cause
30+
31+
`scan_readable_regions` enumerates every committed, readable region in the
32+
current process with `VirtualQuery` and scans each one for the pattern. Among
33+
those regions are the running thread's own stack and the module's data segments
34+
-- both of which, under ASan, carry poisoned shadow, because ASan surrounds stack
35+
locals and instrumented globals with redzones.
36+
37+
The scanner's reads stay strictly in bounds of the regions `VirtualQuery`
38+
reported as readable: the arithmetic in `find_pattern_raw` keeps every access
39+
inside `[start, start + region_size)`, and the SIMD verify never reads past
40+
`pattern_start + pattern.size()`. So the reads never fault in a release build.
41+
They only "fail" because ASan's shadow marks sub-ranges of that mapped, readable
42+
memory as off-limits to ordinary code. `seh_read_bytes` is the same: its
43+
`__try`-guarded copy reads a mapped data section that happens to contain an
44+
instrumented global's redzone.
45+
46+
This is the well-known conflict between AddressSanitizer and code that
47+
intentionally reads memory it does not own (memory scanners, conservative garbage
48+
collectors, stack walkers). ASan cannot model a process reading its own shadow.
49+
It never arises in production: DetourModKit scans a separate target process that
50+
is not built with ASan.
51+
52+
## Two ASan mechanisms, two fixes
53+
54+
A function that reads foreign memory trips ASan two different ways, and each needs
55+
its own treatment:
56+
57+
1. **Compiler load instrumentation.** ASan rewrites the function's own loads to
58+
check the shadow first. `__declspec(no_sanitize_address)` removes that
59+
instrumentation. On MSVC the attribute is compile-time only and must sit on the
60+
function's first declaration.
61+
2. **libc interceptors.** ASan hot-patches `memchr`, `memcpy`, `memmove`, etc. at
62+
runtime, so a call to one checks its range against the shadow regardless of the
63+
caller's attributes. `no_sanitize_address` does **not** disable this. The only
64+
portable way to avoid it is to not call the intercepted function on foreign
65+
memory.
66+
67+
## The fix
68+
69+
Every change is guarded by `#if defined(__SANITIZE_ADDRESS__)`; release and
70+
non-ASan builds keep the original `memchr`/`memcpy` and emit identical code.
71+
72+
`src/scanner.cpp`:
73+
74+
- A translation-unit-local `DMK_NO_SANITIZE_ADDRESS` macro (empty off ASan).
75+
- `no_sanitize_address` on `find_pattern_raw`, `verify_pattern_avx2`, and the
76+
`scan_for_byte` helper -- the functions whose instrumented SIMD/scalar loads
77+
read the scanned region.
78+
- `scan_for_byte` replaces the inner-loop `memchr`. Under ASan it scans inline (no
79+
interceptor); otherwise it forwards to `memchr`, so release keeps the optimized
80+
path.
81+
82+
`src/memory.cpp`:
83+
84+
- `seh_read_bytes` copies with the `__movsb` (`rep movsb`) intrinsic under ASan --
85+
it emits the copy inline with no interceptable call -- and with `std::memcpy`
86+
otherwise. No `no_sanitize_address` is applied here: the copy is the function's
87+
only foreign read, and `__movsb` is neither instrumented nor intercepted, so the
88+
attribute would suppress nothing and would be dead.
89+
90+
What this costs: ASan no longer validates the scanner's own reads of arbitrary
91+
process memory. That is unavoidable -- those reads are the false-positive source
92+
-- and acceptable: the scanner's bounds logic is still exercised under ASan by the
93+
tests that scan small, heap-allocated (ASan-tracked) buffers, where a genuine
94+
over-read would still be caught, and by the full non-ASan suite.
95+
96+
## Alternatives considered and rejected
97+
98+
- **Skip the offending tests under ASan.** Removes coverage and is widely
99+
considered an anti-pattern; the convention is to exclude the *function* that
100+
legitimately bypasses ASan, not the test.
101+
- **`no_sanitize_address` alone.** Insufficient: it does not stop the
102+
`memchr`/`memcpy` interceptors (see above).
103+
- **Sanitizer ignorelist / special-case-list file.** Unsupported by MSVC
104+
AddressSanitizer.
105+
- **`__asan_unpoison_memory_region`.** Intended only for memory ASan owns;
106+
unpoisoning another subsystem's redzones would corrupt ASan's bookkeeping and
107+
mask real bugs.
108+
- **Disabling the intrinsic interceptors globally** (an `ASAN_OPTIONS` knob).
109+
Weakens `memcpy`/`memchr` overflow detection across the entire suite.
110+
111+
## Adding a new foreign-memory primitive
112+
113+
If a new function deliberately reads memory the process does not own:
114+
115+
1. Mark it `DMK_NO_SANITIZE_ADDRESS`, with the attribute on its first declaration
116+
(on MSVC, the header prototype for an out-of-line function).
117+
2. Route any `memchr`/`memcpy`/`memmove`/`memset` it performs on that memory
118+
around the interceptor under `#if defined(__SANITIZE_ADDRESS__)` -- an inline
119+
loop, or `__movsb`/`__stosb` for bulk copies/fills.
120+
3. Keep the release path on the original libc call so shipped code is unchanged.
121+
122+
## References
123+
124+
- [MSVC AddressSanitizer language, build, and debugging reference](https://learn.microsoft.com/en-us/cpp/sanitizers/asan-building?view=msvc-170)
125+
-- the `__SANITIZE_ADDRESS__` macro, and that `__declspec(no_sanitize_address)`
126+
"affects compiler behavior, not runtime behavior" (so it cannot disable the
127+
runtime interceptors).
128+
- [MSVC AddressSanitizer known issues and limitations](https://learn.microsoft.com/en-us/cpp/sanitizers/asan-known-issues?view=msvc-170)
129+
-- special-case-list (ignorelist) files are unsupported on MSVC.
130+
- [Clang AddressSanitizer](https://clang.llvm.org/docs/AddressSanitizer.html)
131+
-- `__attribute__((no_sanitize("address")))`, and the stronger
132+
`disable_sanitizer_instrumentation`, for excluding a function that legitimately
133+
bypasses ASan.
134+
- [MaskRay -- All about sanitizer interceptors](https://maskray.me/blog/2023-01-08-all-about-sanitizer-interceptors)
135+
-- on Windows, ASan installs its `memchr`/`memcpy`/... interceptors by
136+
hot-patching the function entry at run time, which is why a compile-time
137+
attribute on the caller cannot suppress them.
138+
- [google/sanitizers -- AddressSanitizerManualPoisoning](https://github.com/google/sanitizers/wiki/AddressSanitizerManualPoisoning)
139+
-- `__asan_poison_memory_region` / `__asan_unpoison_memory_region` apply only to
140+
memory ASan owns; they are not a tool for reads of foreign memory.
141+
- [`__movsb` intrinsic](https://learn.microsoft.com/en-us/cpp/intrinsics/movsb?view=msvc-170)
142+
-- emits `rep movsb` inline with no interceptable call; declared in `<intrin.h>`.

src/memory.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
#include "platform.hpp"
1919

2020
#include <windows.h>
21+
#if defined(_MSC_VER) && defined(__SANITIZE_ADDRESS__)
22+
#include <intrin.h> // __movsb -- ASan-safe copy in the SEH probe read
23+
#endif
2124
#include <shared_mutex>
2225
#include <unordered_map>
2326
#include <map>
@@ -1443,7 +1446,17 @@ bool DetourModKit::Memory::seh_read_bytes(uintptr_t addr, void *out, size_t byte
14431446
#ifdef _MSC_VER
14441447
__try
14451448
{
1449+
#if defined(__SANITIZE_ADDRESS__)
1450+
// Copy via __movsb (rep movsb) under ASan: MSVC routes std::memcpy through
1451+
// the ASan interceptor, which inspects the source against ASan's shadow and
1452+
// false-positives on the foreign mapped memory this probe legitimately
1453+
// reads (e.g. a module's data section during the RTTI walk). __movsb emits
1454+
// the copy inline with no interceptable call. Release keeps std::memcpy.
1455+
__movsb(static_cast<unsigned char *>(out),
1456+
reinterpret_cast<const unsigned char *>(addr), bytes);
1457+
#else
14461458
std::memcpy(out, reinterpret_cast<const void *>(addr), bytes);
1459+
#endif
14471460
return true;
14481461
}
14491462
__except ((GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ||

src/scanner.cpp

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@
4343
#define DMK_AVX2_TARGET
4444
#endif
4545

46+
// AddressSanitizer poisons the shadow of this process's own committed, readable
47+
// memory -- the redzones around stack locals and instrumented globals. The AOB
48+
// scanner deliberately reads across whole readable regions, so under ASan its
49+
// in-bounds, never-faulting reads land on poisoned shadow and are reported as
50+
// overflows. DMK_NO_SANITIZE_ADDRESS removes the compiler's load instrumentation
51+
// from such a function, so the read runs exactly as a release build does. It does
52+
// NOT stop ASan's libc interceptors (memchr/memcpy are hot-patched at runtime),
53+
// so those calls are routed around separately under __SANITIZE_ADDRESS__ (see
54+
// scan_for_byte). ASan links only under MSVC here (mingw-w64 ships no sanitizer
55+
// runtime), so the attribute is the MSVC __declspec form; the macro is empty in
56+
// every other build, leaving release codegen unchanged.
57+
#if defined(_MSC_VER) && defined(__SANITIZE_ADDRESS__)
58+
#define DMK_NO_SANITIZE_ADDRESS __declspec(no_sanitize_address)
59+
#else
60+
#define DMK_NO_SANITIZE_ADDRESS
61+
#endif
62+
4663
using namespace DetourModKit;
4764

4865
namespace
@@ -102,6 +119,7 @@ namespace
102119
* GCC/Clang. On MSVC, intrinsics are always available.
103120
*/
104121
DMK_AVX2_TARGET
122+
DMK_NO_SANITIZE_ADDRESS
105123
std::optional<size_t> verify_pattern_avx2(const std::byte *pattern_start,
106124
const Scanner::CompiledPattern &pattern,
107125
size_t start_offset) noexcept
@@ -333,6 +351,7 @@ namespace
333351
// pattern.offset. The public find_pattern wrappers apply the offset
334352
// exactly once on top of this result; scan_executable_regions also calls
335353
// this directly so its own final offset-application remains correct.
354+
DMK_NO_SANITIZE_ADDRESS
336355
const std::byte *find_pattern_raw(const std::byte *start_address, size_t region_size,
337356
const Scanner::CompiledPattern &pattern) noexcept;
338357

@@ -395,6 +414,32 @@ const std::byte *DetourModKit::Scanner::find_pattern(const std::byte *start_addr
395414

396415
namespace
397416
{
417+
// memchr over [begin, end] for the anchor byte. Under ASan the libc memchr
418+
// interceptor inspects the whole range against ASan's shadow and reports a
419+
// false overflow when the scanner walks this process's own poisoned memory;
420+
// no_sanitize_address does not suppress that runtime interceptor. Scanning
421+
// inline in a no_sanitize_address function reads the bytes directly, exactly
422+
// as the release memchr does. Release builds keep the optimized memchr.
423+
DMK_NO_SANITIZE_ADDRESS
424+
const std::byte *scan_for_byte(const std::byte *begin, const std::byte *end,
425+
unsigned char target) noexcept
426+
{
427+
#if defined(__SANITIZE_ADDRESS__)
428+
for (const std::byte *p = begin; p <= end; ++p)
429+
{
430+
if (static_cast<unsigned char>(*p) == target)
431+
{
432+
return p;
433+
}
434+
}
435+
return nullptr;
436+
#else
437+
return static_cast<const std::byte *>(
438+
memchr(begin, target, static_cast<size_t>(end - begin + 1)));
439+
#endif
440+
}
441+
442+
DMK_NO_SANITIZE_ADDRESS
398443
const std::byte *find_pattern_raw(const std::byte *start_address, size_t region_size,
399444
const Scanner::CompiledPattern &pattern) noexcept
400445
{
@@ -438,15 +483,13 @@ namespace
438483

439484
while (search_start <= search_end)
440485
{
441-
const void *found = memchr(search_start, static_cast<int>(target_val),
442-
static_cast<size_t>(search_end - search_start + 1));
486+
const std::byte *current_scan_ptr =
487+
scan_for_byte(search_start, search_end, target_val);
443488

444-
if (!found)
489+
if (!current_scan_ptr)
445490
{
446491
break;
447492
}
448-
449-
const std::byte *current_scan_ptr = static_cast<const std::byte *>(found);
450493
const std::byte *pattern_start = current_scan_ptr - best_anchor;
451494

452495
// Verify the full pattern at this position.

0 commit comments

Comments
 (0)