[compiler-rt] Reland: [ASan][Windows] Fix memmove/memcpy interception on x64 (#192060) (PR #193633)
Alexandre Ganea via llvm-commits
llvm-commits at lists.llvm.org
Wed Apr 29 07:00:28 PDT 2026
https://github.com/aganea updated https://github.com/llvm/llvm-project/pull/193633
>From 6083696e8d6578545422ca9fd076f330c810be2c Mon Sep 17 00:00:00 2001
From: Alexandre Ganea <aganea at havenstudios.com>
Date: Wed, 22 Apr 2026 20:19:59 -0400
Subject: [PATCH 1/3] [ASan][Windows] Fix memmove/memcpy interception on x64
(#192060)
On Windows x64, memcpy and memmove used to share an implementation in
`vcruntime140.dll`, but newer versions of the CRT may provide them as
separate functions. Detect this at runtime and intercept accordingly,
rather than unconditionally assuming they alias each other.
Should fix https://github.com/llvm/llvm-project/issues/126077
---
compiler-rt/lib/asan/asan_allocator.cpp | 28 ++++++++++++++++
.../asan/asan_win_static_runtime_thunk.cpp | 6 ++--
...izer_common_interceptors_memintrinsics.inc | 33 +++++++++++++++++++
3 files changed, 64 insertions(+), 3 deletions(-)
diff --git a/compiler-rt/lib/asan/asan_allocator.cpp b/compiler-rt/lib/asan/asan_allocator.cpp
index 46ba7e16da9b2..f9d3c5d01e0e3 100644
--- a/compiler-rt/lib/asan/asan_allocator.cpp
+++ b/compiler-rt/lib/asan/asan_allocator.cpp
@@ -34,6 +34,14 @@
#include "sanitizer_common/sanitizer_quarantine.h"
#include "sanitizer_common/sanitizer_stackdepot.h"
+#if SANITIZER_WINDOWS64
+namespace __sanitizer {
+// Set by InitializeMemintrinsicInterceptors(). True when the CRT exposes
+// memcpy and memmove as separate functions (newer vcruntime140.dll).
+extern bool win64_memcpy_memmove_are_disjoint;
+} // namespace __sanitizer
+#endif
+
namespace __asan {
// Valid redzone sizes are 16, 32, 64, ... 2048, so we encode them in 3 bits.
@@ -807,7 +815,27 @@ struct Allocator {
uptr memcpy_size = Min(new_size, m->UsedSize());
// If realloc() races with free(), we may start copying freed memory.
// However, we will report racy double-free later anyway.
+#if SANITIZER_WINDOWS64
+ // On Win64, REAL(memcpy) only points at a distinct non-interceptor
+ // function when memcpy and memmove are separate CRT entry points. When
+ // they are aliased we keep REAL(memcpy) == REAL(memmove), and both may
+ // dispatch back through the intercepted libc routine, which performs
+ // shadow-checked reads. That is a problem for chunks upgraded from
+ // malloc(0) / HeapReAlloc(..., 0): the single live byte is still
+ // shadow-poisoned (from_zero_alloc / asan_mark_zero_allocation), so a
+ // shadow-checked copy of Min(new_size, UsedSize()) bytes reports a
+ // spurious heap-buffer-overflow (see ReallocTest,
+ // heaprealloc_alloc_zero). Fall back to internal_memcpy (which bypasses
+ // the interceptor) for the poisoned or aliased cases, and only use
+ // REAL(memcpy) on the fast path.
+ if (memcpy_size && __sanitizer::win64_memcpy_memmove_are_disjoint &&
+ !m->from_zero_alloc)
+ REAL(memcpy)(new_ptr, old_ptr, memcpy_size);
+ else
+ internal_memcpy(new_ptr, old_ptr, memcpy_size);
+#else
REAL(memcpy)(new_ptr, old_ptr, memcpy_size);
+#endif
Deallocate(old_ptr, 0, 0, stack, FROM_MALLOC);
}
return new_ptr;
diff --git a/compiler-rt/lib/asan/asan_win_static_runtime_thunk.cpp b/compiler-rt/lib/asan/asan_win_static_runtime_thunk.cpp
index 46e0e90738f24..17f98d51fd8e6 100644
--- a/compiler-rt/lib/asan/asan_win_static_runtime_thunk.cpp
+++ b/compiler-rt/lib/asan/asan_win_static_runtime_thunk.cpp
@@ -42,10 +42,10 @@ INTERCEPT_LIBRARY_FUNCTION_ASAN(memchr);
# endif
INTERCEPT_LIBRARY_FUNCTION_ASAN(memcmp);
INTERCEPT_LIBRARY_FUNCTION_ASAN(memcpy);
-# ifndef _WIN64
-// memmove and memcpy share an implementation on amd64
+// Older Win64 CRTs aliased memmove and memcpy, so hooking memcpy was enough.
+// Newer vcruntime140.dll ships them as distinct functions, so we always have
+// to intercept memmove explicitly.
INTERCEPT_LIBRARY_FUNCTION_ASAN(memmove);
-# endif
INTERCEPT_LIBRARY_FUNCTION_ASAN(memset);
INTERCEPT_LIBRARY_FUNCTION_ASAN(strcat);
INTERCEPT_LIBRARY_FUNCTION_ASAN(strchr);
diff --git a/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc b/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
index 0b6731c89950b..36f5e4972c4f9 100644
--- a/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
+++ b/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
@@ -234,11 +234,44 @@ INTERCEPTOR(void *, bzero, void *block, usize size) {
#endif // SANITIZER_INTERCEPT_BZERO
namespace __sanitizer {
+
+#if SANITIZER_WINDOWS64
+// On Windows x64, memcpy and memmove used to share a single implementation in
+// the CRT (vcruntime140.dll), but newer CRTs ship them as distinct functions.
+// Detect this at interceptor init time so the rest of the runtime can pick the
+// right code path (e.g. asan_allocator's Reallocate). See
+// InitializeMemintrinsicInterceptors below.
+bool win64_memcpy_memmove_are_disjoint;
+#endif
+
// This does not need to be called if InitializeCommonInterceptors() is called.
void InitializeMemintrinsicInterceptors() {
INIT_MEMSET;
+#if SANITIZER_WINDOWS64
+ // Use volatile to keep the compiler from folding &memcpy != &memmove at
+ // compile time (the two declarations are distinct symbols even if the
+ // runtime resolves them to the same address).
+ volatile uptr memcpy_addr = (uptr)&memcpy;
+ volatile uptr memmove_addr = (uptr)&memmove;
+ win64_memcpy_memmove_are_disjoint = (memcpy_addr != memmove_addr);
+ INIT_MEMMOVE;
+ if (win64_memcpy_memmove_are_disjoint) {
+ COMMON_INTERCEPT_FUNCTION(memcpy);
+ } else {
+ // Legacy CRT: memcpy and memmove are the same function; just share REAL.
+ ASSIGN_REAL(memcpy, memmove);
+ }
+ // INTERCEPT_FUNCTION(memcpy) can fail for some binaries (e.g. certain ASan
+ // unit tests linked against the CRT) while REAL(memmove) is valid. Using
+ // the real memmove implementation for REAL(memcpy) is always safe for
+ // interceptor forwarding (memmove is a semantic superset of memcpy).
+ if (!REAL(memcpy))
+ ASSIGN_REAL(memcpy, memmove);
+ CHECK(REAL(memcpy));
+#else
INIT_MEMMOVE;
INIT_MEMCPY;
+#endif
INIT_AEABI_MEM;
INIT___BZERO;
INIT_BZERO;
>From efc4a27790bdd7e6722682c4e05b2c495c925b63 Mon Sep 17 00:00:00 2001
From: Alexandre Ganea <alex_toresh at yahoo.fr>
Date: Wed, 29 Apr 2026 08:47:53 -0400
Subject: [PATCH 2/3] Address review comments
---
compiler-rt/lib/asan/asan_allocator.cpp | 61 ++++++++++---------
...izer_common_interceptors_memintrinsics.inc | 39 +++++-------
.../TestCases/Windows/realloc_zero_size.cpp | 43 +++++++++++++
3 files changed, 91 insertions(+), 52 deletions(-)
create mode 100644 compiler-rt/test/asan/TestCases/Windows/realloc_zero_size.cpp
diff --git a/compiler-rt/lib/asan/asan_allocator.cpp b/compiler-rt/lib/asan/asan_allocator.cpp
index f9d3c5d01e0e3..9552e4400f4e1 100644
--- a/compiler-rt/lib/asan/asan_allocator.cpp
+++ b/compiler-rt/lib/asan/asan_allocator.cpp
@@ -34,14 +34,6 @@
#include "sanitizer_common/sanitizer_quarantine.h"
#include "sanitizer_common/sanitizer_stackdepot.h"
-#if SANITIZER_WINDOWS64
-namespace __sanitizer {
-// Set by InitializeMemintrinsicInterceptors(). True when the CRT exposes
-// memcpy and memmove as separate functions (newer vcruntime140.dll).
-extern bool win64_memcpy_memmove_are_disjoint;
-} // namespace __sanitizer
-#endif
-
namespace __asan {
// Valid redzone sizes are 16, 32, 64, ... 2048, so we encode them in 3 bits.
@@ -361,6 +353,37 @@ void AllocatorOptions::CopyTo(Flags *f, CommonFlags *cf) {
cf->allocator_release_to_os_interval_ms = release_to_os_interval_ms;
}
+// Copy the contents of the chunk being reallocated into the freshly allocated
+// destination. `m` describes the source chunk so we can honor any
+// platform-specific constraints attached to it (e.g. zero-size allocations
+// upgraded to 1 byte on Windows).
+static void ReallocCopyContents(void *new_ptr, void *old_ptr, uptr size,
+ AsanChunk *m) {
+#if SANITIZER_WINDOWS64
+ if (!size)
+ return;
+ // On Windows, malloc(0) (and HeapAlloc(.., 0) under
+ // windows_hook_rtl_allocators) is upgraded to a 1-byte allocation whose
+ // single byte is intentionally shadow-poisoned (asan_mark_zero_allocation,
+ // see b3452d9). The user requested 0 bytes, so there is nothing to copy.
+ if (m->from_zero_alloc)
+ return;
+ // After InitializeMemintrinsicInterceptors, REAL(memcpy) != REAL(memmove)
+ // means we successfully intercepted memcpy as a distinct function and can
+ // safely call its trampoline. When the CRT aliases them (or interception
+ // fell back to ASSIGN_REAL(memcpy, memmove)), both REAL pointers refer to
+ // the same memmove trampoline; calling it for memcpy can re-enter the
+ // intercepted libc routine and perform shadow-checked reads. Use
+ // internal_memcpy in that case.
+ if (REAL(memcpy) != REAL(memmove))
+ REAL(memcpy)(new_ptr, old_ptr, size);
+ else
+ internal_memcpy(new_ptr, old_ptr, size);
+#else
+ REAL(memcpy)(new_ptr, old_ptr, size);
+#endif
+}
+
struct Allocator {
static const uptr kMaxAllowedMallocSize =
FIRST_32_SECOND_64(3UL << 30, 1ULL << 40);
@@ -815,27 +838,7 @@ struct Allocator {
uptr memcpy_size = Min(new_size, m->UsedSize());
// If realloc() races with free(), we may start copying freed memory.
// However, we will report racy double-free later anyway.
-#if SANITIZER_WINDOWS64
- // On Win64, REAL(memcpy) only points at a distinct non-interceptor
- // function when memcpy and memmove are separate CRT entry points. When
- // they are aliased we keep REAL(memcpy) == REAL(memmove), and both may
- // dispatch back through the intercepted libc routine, which performs
- // shadow-checked reads. That is a problem for chunks upgraded from
- // malloc(0) / HeapReAlloc(..., 0): the single live byte is still
- // shadow-poisoned (from_zero_alloc / asan_mark_zero_allocation), so a
- // shadow-checked copy of Min(new_size, UsedSize()) bytes reports a
- // spurious heap-buffer-overflow (see ReallocTest,
- // heaprealloc_alloc_zero). Fall back to internal_memcpy (which bypasses
- // the interceptor) for the poisoned or aliased cases, and only use
- // REAL(memcpy) on the fast path.
- if (memcpy_size && __sanitizer::win64_memcpy_memmove_are_disjoint &&
- !m->from_zero_alloc)
- REAL(memcpy)(new_ptr, old_ptr, memcpy_size);
- else
- internal_memcpy(new_ptr, old_ptr, memcpy_size);
-#else
- REAL(memcpy)(new_ptr, old_ptr, memcpy_size);
-#endif
+ ReallocCopyContents(new_ptr, old_ptr, memcpy_size, m);
Deallocate(old_ptr, 0, 0, stack, FROM_MALLOC);
}
return new_ptr;
diff --git a/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc b/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
index 36f5e4972c4f9..cead1b39d2897 100644
--- a/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
+++ b/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc
@@ -235,36 +235,29 @@ INTERCEPTOR(void *, bzero, void *block, usize size) {
namespace __sanitizer {
-#if SANITIZER_WINDOWS64
-// On Windows x64, memcpy and memmove used to share a single implementation in
-// the CRT (vcruntime140.dll), but newer CRTs ship them as distinct functions.
-// Detect this at interceptor init time so the rest of the runtime can pick the
-// right code path (e.g. asan_allocator's Reallocate). See
-// InitializeMemintrinsicInterceptors below.
-bool win64_memcpy_memmove_are_disjoint;
-#endif
-
// This does not need to be called if InitializeCommonInterceptors() is called.
void InitializeMemintrinsicInterceptors() {
INIT_MEMSET;
#if SANITIZER_WINDOWS64
- // Use volatile to keep the compiler from folding &memcpy != &memmove at
- // compile time (the two declarations are distinct symbols even if the
- // runtime resolves them to the same address).
+ INIT_MEMMOVE;
+ // On Windows x64, memcpy and memmove used to resolve to the same function in
+ // the CRT (e.g. vcruntime140.dll on older toolsets, ucrtbase.dll today), but
+ // newer CRTs (recent vcruntime140.dll) ship them as distinct functions. We
+ // can't know which shape the loaded CRT has at compile time, so check it at
+ // runtime. Use volatile to keep the compiler from assuming distinct
+ // declarations imply distinct addresses and folding the comparison to true.
volatile uptr memcpy_addr = (uptr)&memcpy;
volatile uptr memmove_addr = (uptr)&memmove;
- win64_memcpy_memmove_are_disjoint = (memcpy_addr != memmove_addr);
- INIT_MEMMOVE;
- if (win64_memcpy_memmove_are_disjoint) {
+ if (memcpy_addr != memmove_addr)
COMMON_INTERCEPT_FUNCTION(memcpy);
- } else {
- // Legacy CRT: memcpy and memmove are the same function; just share REAL.
- ASSIGN_REAL(memcpy, memmove);
- }
- // INTERCEPT_FUNCTION(memcpy) can fail for some binaries (e.g. certain ASan
- // unit tests linked against the CRT) while REAL(memmove) is valid. Using
- // the real memmove implementation for REAL(memcpy) is always safe for
- // interceptor forwarding (memmove is a semantic superset of memcpy).
+ // INTERCEPT_FUNCTION(memcpy) can also fail for some binaries (e.g. certain
+ // ASan unit tests linked against a CRT we can't trampoline), leaving
+ // REAL(memcpy) null while REAL(memmove) is valid. Using the real memmove for
+ // REAL(memcpy) is always safe (memmove is a semantic superset of memcpy).
+ // After this fallback, REAL(memcpy) == REAL(memmove) is the runtime signal
+ // that memcpy is *not* a distinct function we can call directly; consumers
+ // (e.g. asan_allocator's Reallocate) check that predicate to pick the right
+ // code path.
if (!REAL(memcpy))
ASSIGN_REAL(memcpy, memmove);
CHECK(REAL(memcpy));
diff --git a/compiler-rt/test/asan/TestCases/Windows/realloc_zero_size.cpp b/compiler-rt/test/asan/TestCases/Windows/realloc_zero_size.cpp
new file mode 100644
index 0000000000000..c9f49d4622aa0
--- /dev/null
+++ b/compiler-rt/test/asan/TestCases/Windows/realloc_zero_size.cpp
@@ -0,0 +1,43 @@
+// Regression test for the Win64 path in asan_allocator's Reallocate where
+// realloc()-ing a chunk that was originally created by malloc(0) (and
+// internally upgraded to a 1-byte allocation whose single byte is shadow-
+// poisoned by asan_mark_zero_allocation) used to spuriously report a
+// heap-buffer-overflow when the CRT aliased memcpy and memmove. See
+// https://github.com/llvm/llvm-project/pull/193633 and
+// https://github.com/llvm/llvm-project/issues/126077.
+//
+// RUN: %clang_cl_asan %Od %s %Fe%t
+// RUN: %run %t 2>&1 | FileCheck %s
+//
+// Also exercise the static CRT linkage which is the configuration that
+// originally surfaced the issue on the buildbots.
+// RUN: %clang_cl_asan %Od %MT %s %Fe%t
+// RUN: %run %t 2>&1 | FileCheck %s
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+int main() {
+ // malloc(0) returns a non-null pointer to a 1-byte chunk whose single
+ // user-visible byte is intentionally poisoned. realloc()-ing it must not
+ // attempt to copy that byte through the shadow-checked path.
+ void *p = malloc(0);
+ if (!p)
+ return 1;
+
+ void *q = realloc(p, 32);
+ if (!q)
+ return 2;
+
+ // Use the new allocation to make sure it is a real, writable region.
+ memset(q, 'a', 32);
+
+ free(q);
+ fprintf(stderr, "OK\n");
+ return 0;
+}
+
+// CHECK-NOT: AddressSanitizer
+// CHECK-NOT: heap-buffer-overflow
+// CHECK: OK
>From dea3bd914637cd0ff118f8c9df4b4d55badadb3c Mon Sep 17 00:00:00 2001
From: Alexandre Ganea <alex_toresh at yahoo.fr>
Date: Wed, 29 Apr 2026 10:00:02 -0400
Subject: [PATCH 3/3] clang-format
---
compiler-rt/lib/asan/asan_allocator.cpp | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/compiler-rt/lib/asan/asan_allocator.cpp b/compiler-rt/lib/asan/asan_allocator.cpp
index 9552e4400f4e1..64542f79a3cc3 100644
--- a/compiler-rt/lib/asan/asan_allocator.cpp
+++ b/compiler-rt/lib/asan/asan_allocator.cpp
@@ -357,8 +357,8 @@ void AllocatorOptions::CopyTo(Flags *f, CommonFlags *cf) {
// destination. `m` describes the source chunk so we can honor any
// platform-specific constraints attached to it (e.g. zero-size allocations
// upgraded to 1 byte on Windows).
-static void ReallocCopyContents(void *new_ptr, void *old_ptr, uptr size,
- AsanChunk *m) {
+static void ReallocCopyContents(void* new_ptr, void* old_ptr, uptr size,
+ AsanChunk* m) {
#if SANITIZER_WINDOWS64
if (!size)
return;
More information about the llvm-commits
mailing list