[compiler-rt] [LSan] Use exit caller SP for exit-triggerring leak checks (PR #195238)

Kunqiu Chen via llvm-commits llvm-commits at lists.llvm.org
Fri May 1 02:40:03 PDT 2026


https://github.com/Camsyn updated https://github.com/llvm/llvm-project/pull/195238

>From 594d6f49ef6ae8f0dbc4b573eddd6b27b265765e Mon Sep 17 00:00:00 2001
From: Camsyn <camsyn at foxmail.com>
Date: Fri, 1 May 2026 16:28:12 +0800
Subject: [PATCH 1/2] Reduce LSan false negatives after explicit exit

LeakSanitizer scans thread stacks conservatively. When a leak check runs from process termination, frames created after exit() can expose stale user stack contents as live roots. If those bytes contain leaked heap pointers, LSan can mark the chunks reachable and miss the leak.

This follows the root-set narrowing strategy from 39db491957dc, which captured caller_sp early in CheckForLeaks to keep later LSan stack growth out of the live stack range. This patch records the caller stack pointer earlier, at exit() entry, and reuses the existing CheckForLeaksParam::caller_sp path when an exit-time boundary is available.

ASan and HWASan record the same boundary through a small CAN_SANITIZE_LEAKS-gated call into LSan common code. The boundary is not stored in sanitizer thread objects, and GetThreadRangesLocked() remains unchanged.

Constraint: Avoid intercepting __libc_start_main because that path is libc/linker sensitive

Rejected: Store exit_caller_sp in ASan/HWASan thread objects | exit() starts process termination, so per-thread lifetime is unnecessary

Rejected: Clear dead stack frames | too broad for LSan's narrow exit-time leak-checking need

Confidence: high

Scope-risk: narrow

Directive: Do not claim coverage for libc-internal main-return termination paths unless they are proven to call the interposable exit symbol

Related: https://github.com/llvm/llvm-project/issues/42932

Related: 39db491957dcf095936d81bed89c2b4edae2a1e7

Tested: git diff --check

Not-tested: Local lit in this detached worktree; LLVM CI will run the updated PR branch

Co-authored-by: OmX <omx at oh-my-codex.dev>
---
 compiler-rt/lib/asan/asan_interceptors.cpp    | 17 +++++
 .../lib/hwasan/hwasan_interceptors.cpp        | 17 +++++
 compiler-rt/lib/lsan/lsan_common.cpp          | 15 +++++
 compiler-rt/lib/lsan/lsan_common.h            |  1 +
 compiler-rt/lib/lsan/lsan_interceptors.cpp    | 11 ++++
 .../Linux/exit_atexit_stale_stack.cpp         | 66 +++++++++++++++++++
 6 files changed, 127 insertions(+)
 create mode 100644 compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp

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..1a92fdc067fa4 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() {
@@ -881,6 +891,11 @@ static bool CheckForLeaksOnce() {
     // threads are suspended and stack pointers captured.
     param.caller_tid = GetTid();
     param.caller_sp = reinterpret_cast<uptr>(__builtin_frame_address(0));
+    // If exit() has started, leak checking runs from the termination/atexit
+    // path. Use the stack boundary captured at exit() entry so frames created
+    // by libc exit handlers and LSan itself do not expand the live stack root.
+    if (uptr exit_sp = GetExitCallerSP())
+      param.caller_sp = exit_sp;
     LockStuffAndStopTheWorld(CheckForLeaksCallback, &param);
     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..1d71864633fec
--- /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 -DEXPLICIT_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 a false negative caused by stale stack roots after exit().
+// 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 EXPLICIT_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();
+  // Explicit exit called by user code.
+  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 by libc after main returns.
+  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)

>From a93fa44cbf478a5f8e98ff51949f14c3ef3fb6d9 Mon Sep 17 00:00:00 2001
From: Camsyn <camsyn at foxmail.com>
Date: Fri, 1 May 2026 17:39:25 +0800
Subject: [PATCH 2/2] Remove implicit-exit RUN from LSan regression

---
 .../test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp      | 3 ---
 1 file changed, 3 deletions(-)

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
index 1d71864633fec..d1782642b5091 100644
--- a/compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp
+++ b/compiler-rt/test/lsan/TestCases/Linux/exit_atexit_stale_stack.cpp
@@ -10,9 +10,6 @@
 // RUN: %clangxx_lsan -O0 -fno-omit-frame-pointer -DEXPLICIT_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
 



More information about the llvm-commits mailing list