[compiler-rt] [LSan] Use exit caller SP for explicit exit leak checks (PR #195238)
via llvm-commits
llvm-commits at lists.llvm.org
Fri May 1 01:48:38 PDT 2026
llvmorg-github-actions[bot] wrote:
<!--LLVM PR SUMMARY COMMENT-->
@llvm/pr-subscribers-compiler-rt-sanitizer
Author: Kunqiu Chen (Camsyn)
<details>
<summary>Changes</summary>
## Summary
This patch reduces a LeakSanitizer false negative in explicit `exit()` paths.
LSan scans stacks conservatively. When the leak check is reached through the
process termination path, stack frames created by libc exit handling and LSan
itself can expose stale user stack bytes as live roots. If those bytes still
contain a leaked heap pointer, the chunk can be marked reachable and the leak is
not reported.
This continues the strategy from
[`39db491957dc`](https://github.com/llvm/llvm-project/commit/39db491957dcf095936d81bed89c2b4edae2a1e7),
which captured `caller_sp` early in `CheckForLeaks()` to avoid later LSan stack
growth affecting the root set. This patch captures the boundary earlier, at
`exit()` interceptor entry, and reuses the existing `CheckForLeaksParam::caller_sp`
path when that exit-time boundary is available.
Related issue: https://github.com/llvm/llvm-project/issues/42932
## Example
The regression test writes a leaked pointer into a stack slot in a noinline
helper frame, lets that frame return, and then calls `exit(0)`:
```c++
__attribute__((noinline)) void leak_to_dead_frame() {
void *p = malloc(100);
// Test assembly stores p into a known dead-frame stack hole.
}
int main() {
leak_to_dead_frame();
exit(0);
}
```
Without an exit-entry boundary, the later atexit leak check can scan the dead
helper frame and treat the stale pointer as a live root. With this patch, LSan
uses the stack boundary captured at `exit()` entry and reports the leak.
## Implementation
- Add an `exit()` interceptor for standalone LSan.
- Add `CAN_SANITIZE_LEAKS`-guarded `exit()` interceptors in ASan and HWASan that call into LSan common code.
- Store the captured stack pointer in LSan common state, not in ASan/HWASan thread objects.
- Keep `GetThreadRangesLocked()` unchanged.
- Reuse the existing `caller_sp` flow by replacing `param.caller_sp` when an exit-time boundary exists.
- Add a TODO near the interceptors for other program-killing functions that behave like `exit()`.
## Scope
This intentionally does **not** intercept `__libc_start_main`. That path is
libc/linker sensitive and is not stable enough for this patch. Therefore this
patch improves explicit `exit()` paths and does not claim coverage for libc
startup paths where `main` returns and libc internally bypasses the interposable
`exit` symbol.
## Testing
- `ninja -C temp/null/20260430_2344/compiler-rt-build-rel check-lsan`
- `/home/camsyn/build/llvm/bin/llvm-lit -sv --filter='exit_atexit_stale_stack' temp/null/20260430_2344/compiler-rt-build-rel/test/lsan/X86_64LsanConfig temp/null/20260430_2344/compiler-rt-build-rel/test/lsan/X86_64AsanConfig`
HWASan lit execution was unsupported in the local x86_64 HWASan configuration;
the HWASan runtime compile/link path was still covered by the `check-lsan`
rebuild.
---
Full diff: https://github.com/llvm/llvm-project/pull/195238.diff
6 Files Affected:
- (modified) compiler-rt/lib/asan/asan_interceptors.cpp (+17)
- (modified) compiler-rt/lib/hwasan/hwasan_interceptors.cpp (+17)
- (modified) compiler-rt/lib/lsan/lsan_common.cpp (+15-1)
- (modified) compiler-rt/lib/lsan/lsan_common.h (+1)
- (modified) compiler-rt/lib/lsan/lsan_interceptors.cpp (+11)
- (added) compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp (+66)
``````````diff
diff --git a/compiler-rt/lib/asan/asan_interceptors.cpp b/compiler-rt/lib/asan/asan_interceptors.cpp
index 6d024e58b27cf..a80d2fe79660d 100644
--- a/compiler-rt/lib/asan/asan_interceptors.cpp
+++ b/compiler-rt/lib/asan/asan_interceptors.cpp
@@ -873,6 +873,19 @@ INTERCEPTOR(int, atexit, void (*func)()) {
}
# endif
+# if CAN_SANITIZE_LEAKS
+// TODO: Intercept more program-killing functions that behave like exit().
+INTERCEPTOR(void, exit, int status) {
+ if (AsanInited() && common_flags()->detect_leaks &&
+ common_flags()->leak_check_at_exit) {
+ // Capture the boundary before libc runs atexit handlers.
+ __lsan::RecordExitCallerSP(GET_CURRENT_FRAME());
+ }
+ REAL(exit)(status);
+}
+
+# endif
+
# if ASAN_INTERCEPT_PTHREAD_ATFORK
extern "C" {
extern int _pthread_atfork(void (*prepare)(), void (*parent)(),
@@ -1003,6 +1016,10 @@ void InitializeAsanInterceptors() {
ASAN_INTERCEPT_FUNC(atexit);
# endif
+# if CAN_SANITIZE_LEAKS
+ ASAN_INTERCEPT_FUNC(exit);
+# endif
+
# if ASAN_INTERCEPT_PTHREAD_ATFORK
ASAN_INTERCEPT_FUNC(pthread_atfork);
# endif
diff --git a/compiler-rt/lib/hwasan/hwasan_interceptors.cpp b/compiler-rt/lib/hwasan/hwasan_interceptors.cpp
index c10b5c158548e..69f27336e5bb1 100644
--- a/compiler-rt/lib/hwasan/hwasan_interceptors.cpp
+++ b/compiler-rt/lib/hwasan/hwasan_interceptors.cpp
@@ -24,6 +24,7 @@
#include "hwasan_thread.h"
#include "hwasan_thread_list.h"
#include "interception/interception.h"
+#include "lsan/lsan_common.h"
#include "sanitizer_common/sanitizer_errno.h"
#include "sanitizer_common/sanitizer_linux.h"
#include "sanitizer_common/sanitizer_stackdepot.h"
@@ -313,6 +314,19 @@ INTERCEPTOR(void, pthread_exit, void *retval) {
REAL(pthread_exit)(retval);
}
+# if CAN_SANITIZE_LEAKS
+// TODO: Intercept more program-killing functions that behave like exit().
+INTERCEPTOR(void, exit, int status) {
+ if (hwasan_inited && common_flags()->detect_leaks &&
+ common_flags()->leak_check_at_exit) {
+ // Capture the boundary before libc runs atexit handlers.
+ __lsan::RecordExitCallerSP(GET_CURRENT_FRAME());
+ }
+ REAL(exit)(status);
+}
+
+# endif
+
# if SANITIZER_GLIBC
INTERCEPTOR(int, pthread_tryjoin_np, void *thread, void **ret) {
int result;
@@ -536,6 +550,9 @@ void InitializeInterceptors() {
INTERCEPT_FUNCTION(pthread_join);
INTERCEPT_FUNCTION(pthread_detach);
INTERCEPT_FUNCTION(pthread_exit);
+# if CAN_SANITIZE_LEAKS
+ INTERCEPT_FUNCTION(exit);
+# endif
# if SANITIZER_GLIBC
INTERCEPT_FUNCTION(pthread_tryjoin_np);
INTERCEPT_FUNCTION(pthread_timedjoin_np);
diff --git a/compiler-rt/lib/lsan/lsan_common.cpp b/compiler-rt/lib/lsan/lsan_common.cpp
index 39537b67b681d..e339a774e4af2 100644
--- a/compiler-rt/lib/lsan/lsan_common.cpp
+++ b/compiler-rt/lib/lsan/lsan_common.cpp
@@ -13,6 +13,7 @@
#include "lsan_common.h"
+#include "sanitizer_common/sanitizer_atomic.h"
#include "sanitizer_common/sanitizer_common.h"
#include "sanitizer_common/sanitizer_flag_parser.h"
#include "sanitizer_common/sanitizer_flags.h"
@@ -41,10 +42,19 @@ namespace __lsan {
// This mutex is used to prevent races between DoLeakCheck and IgnoreObject, and
// also to protect the global list of root regions.
static Mutex global_mutex;
+static atomic_uintptr_t exit_caller_sp;
void LockGlobal() SANITIZER_ACQUIRE(global_mutex) { global_mutex.Lock(); }
void UnlockGlobal() SANITIZER_RELEASE(global_mutex) { global_mutex.Unlock(); }
+void RecordExitCallerSP(uptr sp) {
+ atomic_store(&exit_caller_sp, sp, memory_order_relaxed);
+}
+
+static uptr GetExitCallerSP() {
+ return atomic_load(&exit_caller_sp, memory_order_relaxed);
+}
+
Flags lsan_flags;
void DisableCounterUnderflow() {
@@ -880,7 +890,11 @@ static bool CheckForLeaksOnce() {
// CheckForLeaks which does not use bytes with pointers before the
// threads are suspended and stack pointers captured.
param.caller_tid = GetTid();
- param.caller_sp = reinterpret_cast<uptr>(__builtin_frame_address(0));
+ param.caller_sp = GetExitCallerSP();
+ // If this leak check is not triggerred by exit.
+ if (!param.caller_sp)
+ caller_sp = reinterpret_cast<uptr>(__builtin_frame_address(0));
+
LockStuffAndStopTheWorld(CheckForLeaksCallback, ¶m);
if (!param.success) {
Report("LeakSanitizer has encountered a fatal error.\n");
diff --git a/compiler-rt/lib/lsan/lsan_common.h b/compiler-rt/lib/lsan/lsan_common.h
index b399a242c4bcd..f7b9d48e0cb50 100644
--- a/compiler-rt/lib/lsan/lsan_common.h
+++ b/compiler-rt/lib/lsan/lsan_common.h
@@ -274,6 +274,7 @@ void ScanExtraStackRanges(const InternalMmapVector<Range> &ranges,
// Functions called from the parent tool.
const char *MaybeCallLsanDefaultOptions();
void InitCommonLsan();
+void RecordExitCallerSP(uptr sp);
void DoLeakCheck();
void DoRecoverableLeakCheckVoid();
void DisableCounterUnderflow();
diff --git a/compiler-rt/lib/lsan/lsan_interceptors.cpp b/compiler-rt/lib/lsan/lsan_interceptors.cpp
index 5340c6ffba607..3440e4e3ff444 100644
--- a/compiler-rt/lib/lsan/lsan_interceptors.cpp
+++ b/compiler-rt/lib/lsan/lsan_interceptors.cpp
@@ -563,6 +563,16 @@ INTERCEPTOR(void, _exit, int status) {
REAL(_exit)(status);
}
+// TODO: Intercept more program-killing functions that behave like exit().
+INTERCEPTOR(void, exit, int status) {
+ if (lsan_inited && common_flags()->detect_leaks &&
+ common_flags()->leak_check_at_exit) {
+ // Capture the boundary before libc runs atexit handlers.
+ RecordExitCallerSP(GET_CURRENT_FRAME());
+ }
+ REAL(exit)(status);
+}
+
#define COMMON_INTERCEPT_FUNCTION(name) INTERCEPT_FUNCTION(name)
#define SIGNAL_INTERCEPTOR_ENTER() ENSURE_LSAN_INITED
#include "sanitizer_common/sanitizer_signal_interceptors.inc"
@@ -600,6 +610,7 @@ void InitializeInterceptors() {
LSAN_MAYBE_INTERCEPT_TIMEDJOIN;
LSAN_MAYBE_INTERCEPT_TRYJOIN;
INTERCEPT_FUNCTION(_exit);
+ INTERCEPT_FUNCTION(exit);
LSAN_MAYBE_INTERCEPT__LWP_EXIT;
LSAN_MAYBE_INTERCEPT_THR_EXIT;
diff --git a/compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp b/compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp
new file mode 100644
index 0000000000000..c6c9dfbc1f819
--- /dev/null
+++ b/compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp
@@ -0,0 +1,66 @@
+// Test that LSan's atexit leak check does not scan stale pointers left in a
+// dead user stack frame after exit() has been called.
+//
+// This is intentionally x86_64/glibc-specific. The inline assembly writes the
+// leaked pointer into a known stack slot in a noinline helper frame. That frame
+// is dead before main calls exit(0). Without the exit-entry stack boundary, the
+// later DoLeakCheck stack pointer can make LSan scan the stale helper frame and
+// incorrectly mark the allocation reachable.
+//
+// RUN: %clangxx_lsan -O0 -fno-omit-frame-pointer -DEXPLIT_EXIT %s -o %t
+// RUN: %env_lsan_opts="use_registers=0:use_stacks=1" not %run %t 2>&1 | FileCheck %s
+//
+// RUN: %clangxx_lsan -O0 -fno-omit-frame-pointer %s -o %t
+// RUN: %env_lsan_opts="use_registers=0:use_stacks=1" not %run %t 2>&1 | FileCheck %s
+//
+// REQUIRES: x86_64-target-arch, glibc
+// UNSUPPORTED: hwasan
+
+#include <stdlib.h>
+
+// This test detects special FN
+// OFF must point to a stack-frame hole that survives until LSan's atexit leak
+// check. The exact holes depend on the libc version and generated exit-handler
+// stack layout. On glibc 2.39 (Ubuntu 24.04), known working values include 64,
+// 80, 88, 160, 384, 400, 408, and 416.
+#ifndef OFF
+# define OFF 64
+#endif
+
+#ifdef EXPLICT_EXIT
+__attribute__((noinline)) void leak_to_dead_frame(void) {
+ __asm__ __volatile__("movl $100, %%edi\n\t"
+ "callq malloc at PLT\n\t"
+ "movq %%rax, -%c0(%%rbp)\n\t"
+ "xorq %%rax, %%rax\n\t"
+ :
+ : "i"(OFF)
+ : "rax", "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10",
+ "r11", "memory");
+}
+
+int main(void) {
+ leak_to_dead_frame();
+ // Explit exit called by user.
+ exit(0);
+}
+
+#else
+
+int main(void) {
+ __asm__ __volatile__("movl $100, %%edi\n\t"
+ "callq malloc at PLT\n\t"
+ "movq %%rax, -%c0(%%rbp)\n\t"
+ "xorq %%rax, %%rax\n\t"
+ :
+ : "i"(OFF)
+ : "rax", "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10",
+ "r11", "memory");
+ // Implicit exit called after main by libc.
+ return 0;
+}
+#endif
+
+// CHECK: LeakSanitizer: detected memory leaks
+// CHECK: Direct leak of 100 byte(s) in 1 object(s)
+// CHECK: SUMMARY: {{.*}}Sanitizer: 100 byte(s) leaked in 1 allocation(s)
``````````
</details>
https://github.com/llvm/llvm-project/pull/195238
More information about the llvm-commits
mailing list