AddressSanitizer (ASan) In Depth
Deep dive into AddressSanitizer — use-after-free, heap buffer overflow, stack overflow, global overflow, use-after-return, leak sanitizer, and reading reports.
Quick start
# Compile with ASan
clang++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o myapp myapp.cpp
# or GCC:
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o myapp myapp.cpp
# Run — crash on first error
./myapp
# With leak detection
ASAN_OPTIONS=detect_leaks=1 ./myappWhat ASan detects
| Bug type | Detection |
|---|---|
| Heap buffer overflow | ✅ |
| Stack buffer overflow | ✅ |
| Global buffer overflow | ✅ |
| Use-after-free | ✅ |
| Use-after-return | ✅ (with ASAN_OPTIONS=detect_stack_use_after_return=1) |
| Use-after-scope | ✅ |
| Memory leaks | ✅ (via LeakSanitizer, default on Linux) |
| Double-free | ✅ |
| Invalid free (unallocated ptr) | ✅ |
| Uninitialized reads | ❌ (use MemorySanitizer instead) |
| Data races | ❌ (use ThreadSanitizer) |
Reading ASan reports
Heap buffer overflow
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000054
READ of size 4 at 0x602000000054 thread T0
#0 0x401234 in main (/tmp/example+0x401234)
(/tmp/example.cpp:10:18)
0x602000000054 is located 4 bytes to the right of 16-byte region [0x602000000040,0x602000000050)
allocated by thread T0 here:
#0 0x7f... in operator new[](unsigned long) ...
#1 0x401200 in main (/tmp/example.cpp:8:16)Reading the report:
- Error type —
heap-buffer-overflow,heap-use-after-free, etc. - Access —
READorWRITE, size, address - Stack trace — where the bad access happened
- Allocation info — where the memory was allocated, and often freed
Use-after-free
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
#0 0x401310 in read_value (example.cpp:15)
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
#0 0x7f... in operator delete(void*)...
#1 0x401270 in main (example.cpp:12)
previously allocated by thread T0 here:
#0 0x7f... in operator new(unsigned long)...
#1 0x401240 in main (example.cpp:9)Common bugs ASan catches
Heap buffer overflow
int* arr = new int[5];
arr[5] = 42; // → heap-buffer-overflow: 4 bytes after [0..4]
delete[] arr;Use-after-free
int* p = new int(42);
delete p;
int x = *p; // → heap-use-after-freeStack buffer overflow
int buf[10];
buf[10] = 0; // → stack-buffer-overflowUse-after-scope
int* p;
{
int x = 42;
p = &x;
}
*p = 100; // → stack-use-after-scope (with -fsanitize-address-use-after-scope)Double-free
int* p = new int(1);
delete p;
delete p; // → attempting double-freeASAN_OPTIONS tuning
# Full list of options
ASAN_OPTIONS=help=1 ./myapp
# Common options
ASAN_OPTIONS=detect_leaks=1 # enable LeakSanitizer (default on Linux)
ASAN_OPTIONS=detect_leaks=0 # disable leak detection
ASAN_OPTIONS=abort_on_error=1 # call abort() instead of _exit()
ASAN_OPTIONS=halt_on_error=0 # continue after error (not recommended)
ASAN_OPTIONS=print_stats=1 # print shadow memory stats on exit
ASAN_OPTIONS=detect_stack_use_after_return=1 # more expensive, finds UAR bugs
ASAN_OPTIONS=check_initialization_order=1 # detect init order fiasco
# Multiple options (colon-separated)
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./myappCMake integration
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
if(ENABLE_ASAN)
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
add_link_options(-fsanitize=address)
endif()cmake -DENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug -B build
cmake --build build
ASAN_OPTIONS=detect_leaks=1 ./build/myappCI integration
# .github/workflows/asan.yml
name: AddressSanitizer
on: [push, pull_request]
jobs:
asan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build with ASan
run: |
cmake -DENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo -B build
cmake --build build -j$(nproc)
- name: Run tests
run: |
cd build && ctest --output-on-failure
env:
ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
LSAN_OPTIONS: suppressions=${{ github.workspace }}/lsan.suppLeakSanitizer suppressions
Not all leaks are bugs (e.g., one-time allocations, third-party library internals). Suppress known ones:
# lsan.supp
leak:libssl.so
leak:some_external_library
leak:one_time_init_functionLSAN_OPTIONS=suppressions=lsan.supp ./myappCombining with other sanitizers
# ASan + UBSan (common combination)
clang++ -fsanitize=address,undefined -g -O1 -o myapp myapp.cpp
# ASan + LeakSan (automatic on Linux)
ASAN_OPTIONS=detect_leaks=1 ./myapp
# NOTE: ASan and TSan (ThreadSanitizer) are INCOMPATIBLE — can't combine
# NOTE: ASan and MSan (MemorySanitizer) are INCOMPATIBLEPerformance overhead
- Memory: 2× typical (shadow memory for every byte)
- CPU: 2–3× typical
- Compile time: slight increase (instrumentation)
Not suitable for production — use in development, CI, and fuzzing. For release, remove -fsanitize=address.
When ASan misses bugs
ASan uses shadow memory to track allocations — it can miss:
- Bugs within allocated objects (intra-object overflow) — use
-fsanitize-address-field-padding - Wild pointer reads that happen to land in mapped memory
- Logic errors that don't cause memory violations
- Uninitialized reads — use MemorySanitizer instead
- Data races — use ThreadSanitizer instead
Reading ASan Output — Annotated Heap-Use-After-Free Report
Every field in an ASan report tells you exactly where to look. The report has three stacked traces: the access site, the free site, and the allocation site. Together they give you a complete timeline of the object's life.
Below is a full, realistic heap-use-after-free report with each part labelled:
==38921==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000110 thread T0
WRITE of size 4 at 0x602000000110 thread T0 ← (A) what happened
#0 0x40173c in Widget::update() widget.cpp:47 ← (B) access site
#1 0x401891 in EventLoop::tick() loop.cpp:112
#2 0x401234 in main main.cpp:23
0x602000000110 is located 16 bytes inside of 32-byte region ← (C) object geometry
[0x602000000100,0x602000000120)
freed by thread T0 here: ← (D) free site
#0 0x7f3a1b in operator delete(void*) (libasan.so)
#1 0x4016c0 in EventLoop::remove() loop.cpp:88
previously allocated by thread T0 here: ← (E) allocation site
#0 0x7f3a2c in operator new(unsigned long) (libasan.so)
#1 0x401601 in EventLoop::add() loop.cpp:55
SUMMARY: AddressSanitizer: heap-use-after-free widget.cpp:47 in Widget::update()Navigating to the bug in three steps:
-
Find the access site (B).
widget.cpp:47is where the corrupted memory was touched — open that line first. This is almost always inside a function that received a pointer it no longer owns. -
Find the free site (D).
loop.cpp:88freed the object. Cross-reference with step 1 to understand ownership: who calledremove()beforeupdate()still held a reference? -
Find the allocation site (E).
loop.cpp:55created the object. Check what stores the returned pointer and who is responsible for its lifetime. The bug is usually a missing ownership transfer or an incorrect assumption about lifetime.
The object geometry line (C) is also useful: 16 bytes inside of 32-byte region tells you this is not a one-byte past-the-end access — the pointer points into the middle of the old object, confirming a stale (dangling) pointer rather than an arithmetic error.
ASan Runtime Options (ASAN_OPTIONS)
ASAN_OPTIONS is a colon-separated string of key=value pairs that controls runtime behaviour without recompilation. Set it as an environment variable before running your binary.
| Option | Default | Effect |
|---|---|---|
abort_on_error=1 | 0 | Call abort() on first error — triggers core dumps and debuggers |
halt_on_error=1 | 1 | Stop after the first error (set to 0 to see all errors, use with care) |
detect_leaks=1 | 1 on Linux | Enable LeakSanitizer at process exit |
log_path=/tmp/asan.log | stderr | Write reports to /tmp/asan.log.<pid> instead of stderr |
print_stats=1 | 0 | Print shadow-memory statistics on exit — useful for understanding overhead |
detect_odr_violation=2 | 2 | Detect One Definition Rule violations (1=weak, 2=strict) |
redzone=64 | 16 | Shadow redzone size in bytes — enlarge to 64 or 128 when hunting subtle array overflows |
check_initialization_order=1 | 0 | Detect static-initialization-order fiasco |
detect_stack_use_after_return=1 | 0 | Detect stack-use-after-return (expensive; see dedicated section) |
strict_string_checks=1 | 0 | Validate C-string function arguments |
# Crash with a core dump, log to file, strict redzone
ASAN_OPTIONS=abort_on_error=1:log_path=/tmp/asan.log:redzone=64 ./myapp
# Collect all errors instead of stopping at the first
ASAN_OPTIONS=halt_on_error=0:log_path=/tmp/asan.log ./myapp
# See shadow memory usage after a long run
ASAN_OPTIONS=print_stats=1 ./myappredzone=64 is particularly useful for C-style arrays. The default 16-byte redzone may be smaller than a single cache line, so an off-by-one that stays within the same cache line can slip through; a 64-byte redzone catches such accesses reliably.
Leak Detection (LSan)
On Linux, LeakSanitizer is automatically activated when you compile with -fsanitize=address. It runs at process exit and reports all heap memory that was never freed, grouped by allocation stack trace.
# Linux default: leaks already reported; macOS needs the explicit flag
ASAN_OPTIONS=detect_leaks=1 ./myappA typical LSan report looks like this:
=================================================================
==38921==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 256 byte(s) in 1 object(s) allocated from:
#0 0x7f... in operator new(unsigned long) (libasan.so)
#1 0x4015a3 in Config::load() config.cpp:34
#2 0x401234 in main main.cpp:10
SUMMARY: AddressSanitizer: 256 byte(s) leaked in 1 allocation(s).Intentional leaks — singleton registries, one-shot caches, and some third-party library internals — should be suppressed rather than worked around with ugly static hacks:
#include <sanitizer/lsan_interface.h>
void register_global_cache() {
__lsan_disable(); // stop tracking allocations
g_cache = new LargeCache; // intentional, program-lifetime object
__lsan_enable(); // resume tracking
}For third-party code you cannot modify, use a suppressions file:
# lsan.supp — one pattern per line, matched against the full symbol or library name
leak:libssl
leak:libdbus
leak:GlobalRegistrar::init
leak:one_time_setupLSAN_OPTIONS=suppressions=/path/to/lsan.supp ./myappLSan suppression patterns are substring-matched against the function name or shared-library path appearing in the allocation stack trace — no regex syntax needed.
Stack-Use-After-Return
Returning a pointer or reference to a local variable is undefined behaviour that ASan can catch, but only when you opt in. The feature is off by default because it requires every function call to allocate its locals on the heap, adding roughly 10–40 % overhead on top of baseline ASan cost.
ASAN_OPTIONS=detect_stack_use_after_return=1 ./myappA minimal reproducer:
int* dangerous() {
int local = 42;
return &local; // local dies when dangerous() returns
}
int main() {
int* p = dangerous();
return *p; // → stack-use-after-return with the option enabled
}The report names the error stack-use-after-return and shows both the access site in main and the return site inside dangerous, making the ownership mistake obvious.
When to enable it:
- When a code review or fuzzer hints at a potential dangling-stack-pointer bug but the default run does not trigger
- As a one-off pass during development of an API that returns raw pointers or iterators into local containers
- During fuzzing campaigns where thorough coverage matters more than throughput
Do not leave it on permanently in CI unless your test suite is small — the throughput impact is significant enough to break wall-clock time budgets on large test suites.
ASan with CMake — Preset Pattern
Both compile flags and link flags are required; omitting one or the other produces a binary that either fails to link or silently skips instrumentation. The cleanest approach is a CMake preset so that a single --preset switch toggles the entire toolchain.
# CMakeLists.txt
option(ENABLE_ASAN "Build with AddressSanitizer" OFF)
if(ENABLE_ASAN)
# Apply to every target defined after this point
add_compile_options(
-fsanitize=address
-fno-omit-frame-pointer # required for readable stack traces
-fno-optimize-sibling-calls # prevents tail-call elision hiding frames
)
add_link_options(-fsanitize=address)
endif()Or, to limit the flags to a specific target rather than the whole project:
target_compile_options(mylib PRIVATE
$<$<BOOL:${ENABLE_ASAN}>:
-fsanitize=address
-fno-omit-frame-pointer
-fno-optimize-sibling-calls
>
)
target_link_options(mylib PRIVATE
$<$<BOOL:${ENABLE_ASAN}>:-fsanitize=address>
)Declare a preset in CMakePresets.json so developers and CI share identical flags:
{
"version": 6,
"configurePresets": [
{
"name": "asan-debug",
"displayName": "Debug + ASan",
"binaryDir": "${sourceDir}/build/asan",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"ENABLE_ASAN": "ON"
}
},
{
"name": "asan-relwithdebinfo",
"displayName": "RelWithDebInfo + ASan",
"binaryDir": "${sourceDir}/build/asan-rel",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"ENABLE_ASAN": "ON"
}
}
]
}cmake --preset asan-debug
cmake --build build/asan
ASAN_OPTIONS=detect_leaks=1 ./build/asan/myappRelWithDebInfo is the recommended build type for ASan in CI. It retains debug symbols (-g) and adds moderate optimisation (-O2), which exercises more realistic code paths than a Debug build while still producing readable stack traces. Pure Debug (-O0) misses bugs that optimisation introduces and runs substantially slower.
ASan + UBSan Combined
Running ASan and UBSan together catches a wider class of bugs in a single pass with no compatibility conflicts. They instrument different aspects of the program and their shadow-memory schemes do not interfere.
clang++ -fsanitize=address,undefined \
-fno-sanitize-recover=all \
-fno-omit-frame-pointer \
-g -O1 \
-o myapp myapp.cpp myapp-fno-sanitize-recover=all is critical: without it, UBSan prints a warning and continues execution after undefined behaviour, which can mask subsequent memory errors. With =all, the first UB is fatal and the error is unambiguous.
Enable UBSan stack traces at runtime:
ASAN_OPTIONS=detect_leaks=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
./myappWhat each sanitizer adds:
| Check | ASan | UBSan |
|---|---|---|
| Heap/stack/global buffer overflow | ✅ | ❌ |
| Use-after-free / use-after-scope | ✅ | ❌ |
| Signed integer overflow | ❌ | ✅ |
| Null pointer dereference | partial | ✅ (-fsanitize=null) |
| Misaligned pointer access | ❌ | ✅ (-fsanitize=alignment) |
| Invalid bool / enum values | ❌ | ✅ |
| Divide by zero | ❌ | ✅ |
| VLA bounds | ❌ | ✅ |
ASan and UBSan are additive — every check from both lists fires. The only sanitizers incompatible with ASan are ThreadSanitizer and MemorySanitizer, which use conflicting shadow-memory layouts.
# GCC supports the same combination
g++ -fsanitize=address,undefined -fno-sanitize-recover=all \
-fno-omit-frame-pointer -g -O1 -o myapp myapp.cppPlatform Notes
Linux
Linux is ASan's primary platform and has the fewest caveats. LeakSanitizer is enabled by default, symbolisation works out of the box when the binary has debug symbols, and ASAN_OPTIONS controls everything you need.
# Ensure llvm-symbolizer is on PATH for human-readable traces
export ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer)macOS
Since Xcode 12 (Apple Clang 12), you no longer need DYLD_INSERT_LIBRARIES — ASan is linked statically and activates automatically when the binary is compiled with -fsanitize=address.
If you see spurious leak reports from system frameworks on macOS, set:
MallocNanoZone=0 ASAN_OPTIONS=detect_leaks=1 ./myappMallocNanoZone=0 disables Apple's nano allocator, which can interfere with ASan's shadow memory and produce false positives for leaks inside system libraries.
LeakSanitizer is disabled by default on macOS; you must opt in explicitly with detect_leaks=1. Not all LSan features (e.g., __lsan_disable) are supported on older Apple toolchains.
Windows (MSVC)
MSVC supports ASan via the /fsanitize=address flag starting with Visual Studio 2019 version 16.9, but the implementation differs from Clang/GCC in several ways:
# Windows-specific CMake integration
if(MSVC)
target_compile_options(myapp PRIVATE /fsanitize=address)
# No separate link flag required — MSVC links the runtime automatically
endif()Key differences from Clang ASan on MSVC:
- No LeakSanitizer support (LSan is Linux/macOS only)
ASAN_OPTIONSenvironment variable is honoured, but fewer options are implemented/fsanitize=addresscannot be combined with/RTC(runtime checks) — they are mutually exclusive- Edit-and-continue (
/ZI) is incompatible; use/Ziinstead - UBSan is not available in MSVC; use Clang-cl for combined ASan + UBSan on Windows
Docker / Containers
Containers often drop the SYS_PTRACE capability, which ASan needs to unwind stack frames. Add it explicitly when running sanitized binaries in Docker:
docker run --cap-add SYS_PTRACE myimage ./myappOr in a docker-compose.yml:
services:
test:
image: myimage
cap_add:
- SYS_PTRACE
environment:
ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
LSAN_OPTIONS: suppressions=/app/lsan.supp
command: ./build/myappWithout SYS_PTRACE, stack traces in ASan reports will be empty or show only hex addresses, making it impossible to navigate to the bug.