|
| 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>`. |
0 commit comments