AddressSanitizer, UBSan and Friends: A Practical Guide
How ASan, UBSan, MSan, TSan and HWASan work, which flags and runtime options to use, how to read an ASan report line by line, and how to run sanitizers in CI.
Sanitizers are compiler instrumentation plus a runtime library that check, as the program runs, for errors that C and C++ otherwise leave silent. They are the most effective single tool for finding memory-safety bugs before release: a heap overflow that would corrupt memory quietly in a normal build becomes an immediate, precise report with stack traces. This guide covers the main sanitizers in GCC and Clang, how to enable them, how to read their output, and how to make them part of everyday testing. Pair it with coverage-guided fuzzing, which feeds sanitized builds the inputs that trigger bugs.
The sanitizer family
| Sanitizer | Flag | Finds | Typical overhead | Compiler |
|---|---|---|---|---|
| AddressSanitizer (ASan) | -fsanitize=address | Out-of-bounds on heap, stack and globals; use-after-free; double free; use-after-return (optional) | ~2x CPU, 2–3x memory | GCC, Clang, MSVC |
| LeakSanitizer (LSan) | on with ASan, or -fsanitize=leak | Memory leaks at exit | Low | GCC, Clang |
| UndefinedBehaviorSanitizer (UBSan) | -fsanitize=undefined | Signed overflow, invalid shifts, misaligned or null pointer use, bad casts, out-of-bounds array indices (some) | Low to moderate | GCC, Clang |
| MemorySanitizer (MSan) | -fsanitize=memory | Reads of uninitialised memory | ~3x CPU | Clang only |
| ThreadSanitizer (TSan) | -fsanitize=thread | Data races | 5–15x CPU, 5–10x memory | GCC, Clang |
| HWASan | -fsanitize=hwaddress | Like ASan, using pointer tags | Lower memory than ASan | Clang, AArch64 (x86-64 experimental) |
ASan and UBSan belong in every C and C++ project's test matrix. MSan and TSan are specialised: add them when uninitialised reads or concurrency bugs are a realistic risk.
How AddressSanitizer works
ASan maps every 8 bytes of application memory to one byte of shadow memory that records how many of those 8 bytes are addressable. The compiler inserts a check before each load and store: compute the shadow address, read the shadow byte, and report if the access touches poisoned memory.
application memory shadow byte meaning
[ 8 bytes ] -> 00 all 8 bytes addressable
[ 8 bytes ] -> 04 first 4 bytes addressable
[ 8 bytes ] -> fa heap redzone
[ 8 bytes ] -> fd freed heap memory
[ 8 bytes ] -> f1 f2 f3 stack redzones (left, mid, right)
Three runtime features make it catch so much:
- Redzones. Every heap allocation, stack array and global gets poisoned padding around it, so overflows hit poison immediately.
- Quarantine. Freed memory is poisoned and held back from reuse for a while (256 MB by default on 64-bit Linux), so a use-after-free hits poison rather than a new object.
- Interceptors. Functions such as
memcpy,strcpyandfreeare replaced with checking versions that validate their whole range.
Building with sanitizers
# Test build: ASan + UBSan, good stack traces
clang -O1 -g -fno-omit-frame-pointer \
-fsanitize=address,undefined \
-fno-sanitize-recover=undefined \
-o parser_test parser.c parser_test.c
-O1keeps the build fast enough while leaving code recognisable;-O0also works but is slower under ASan.-g -fno-omit-frame-pointerproduces accurate, symbolised stack traces.-fno-sanitize-recover=undefinedmakes UBSan abort on the first error instead of printing and continuing, so tests fail.- Pass the same
-fsanitize=flags to the link step. With CMake, add them to bothCMAKE_C_FLAGSandCMAKE_EXE_LINKER_FLAGS, or useadd_compile_optionsandadd_link_options.
Runtime options
Sanitizers read options from environment variables. A sensible default for CI:
export ASAN_OPTIONS="abort_on_error=1:detect_leaks=1:strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
| Option | Effect |
|---|---|
abort_on_error=1 | Abort (and dump core if enabled) instead of _exit, useful for debuggers and crash collectors |
detect_leaks=1 | Run LeakSanitizer at exit |
detect_stack_use_after_return=1 | Catch pointers to locals used after the function returned (costs memory) |
strict_string_checks=1 | Check that string arguments are properly terminated |
quarantine_size_mb=N | Larger quarantine catches use-after-free with longer delays |
symbolize=1 | Symbolise stack traces (needs llvm-symbolizer in PATH) |
Reading an ASan report
Consider a toy bug: a function that copies a record name into a heap buffer sized one byte too small.
char *dup_name(const char *src, size_t n) {
char *out = malloc(n); /* forgot room for the terminator */
memcpy(out, src, n);
out[n] = '\0'; /* writes one byte past the allocation */
return out;
}
The report, annotated:
==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000015 at pc 0x55f1c2a3b4e7 bp 0x7ffe... sp 0x7ffe...
WRITE of size 1 at 0x602000000015 thread T0 <- (1) kind, direction and size
#0 0x55f1c2a3b4e6 in dup_name records.c:4 <- (2) where the bad access happened
#1 0x55f1c2a3b61a in parse_record records.c:17
#2 0x55f1c2a3b7d0 in main records.c:31
0x602000000015 is located 0 bytes after 5-byte region [0x602000000010,0x602000000015)
<- (3) position relative to the object
allocated by thread T0 here: <- (4) where the object came from
#0 0x7f1a4c8b1c57 in malloc
#1 0x55f1c2a3b4c1 in dup_name records.c:2
#2 0x55f1c2a3b61a in parse_record records.c:17
SUMMARY: AddressSanitizer: heap-buffer-overflow records.c:4 in dup_name
Shadow bytes around the buggy address:
0x0c047fff8000: fa fa 00 05 fa fa ... <- (5) 05 = 5 addressable bytes, then redzone
How to read it:
- Kind, direction and size.
heap-buffer-overflow, aWRITEof 1 byte. Writes are generally more serious than reads, and small overflows are often off-by-one errors. - Access stack. Frame
#0in your code is where to start. Frames inside interceptors (memcpy,strcpy) point to the caller one frame up. - Relative position. "0 bytes after 5-byte region" is the classic off-by-one signature. "Located 4096 bytes to the right" means a much wilder access, often an integer bug in the size or index.
- Allocation stack. Shows which object was involved. For use-after-free reports there is also a "freed by thread T0 here" stack, usually the fastest route to the root cause.
- Shadow bytes. Confirm the object's size and the redzone type (
faheap redzone,fdfreed memory,f1/f2/f3stack redzones).
The common report kinds map directly onto the bug classes described in heap corruption and use-after-free and stack buffer overflows:
| Report | Usual root cause |
|---|---|
heap-buffer-overflow | Missing or wrong bound, off-by-one, size arithmetic bug |
stack-buffer-overflow | Local array overrun |
global-buffer-overflow | Overrun of a static or global array |
heap-use-after-free | Lifetime or ownership bug |
attempting double-free | Two owners, error path frees twice |
stack-use-after-return | Pointer to a local escapes its function |
SEGV on unknown address | Wild or NULL pointer; look at the address to tell which |
UndefinedBehaviorSanitizer
UBSan checks for undefined behaviour that often precedes memory corruption, particularly integer bugs (see integer overflows and format strings). Its messages are one line with file, line and column:
decode.c:58:19: runtime error: signed integer overflow: 2147483600 + 100 cannot be represented in type 'int'
decode.c:73:9: runtime error: load of misaligned address 0x55d3e1c0a2b3 for type 'uint32_t', which requires 4 byte alignment
-fsanitize=undefined enables a default group. Useful additions in Clang: -fsanitize=integer (includes unsigned overflow and implicit conversions, which are not UB but often bugs) and -fsanitize=bounds (array index checks where the size is known). UBSan can also run in minimal or trap mode (-fsanitize-trap=undefined) with no runtime library, which some projects use in hardened production builds for a handful of cheap checks.
Sanitizers in CI
- Build a sanitizer variant of your test suite and run it on every pull request. Treat any report as a test failure.
- Keep suppressions minimal and reviewed. LSan suppressions for known third-party leaks are acceptable; suppressing ASan errors in your own code is not.
- Instrument dependencies where it matters. ASan tolerates uninstrumented libraries (it intercepts common functions), but bugs inside those libraries are only caught if they are instrumented too. MSan requires full instrumentation.
- Run fuzzers against sanitized builds. Sanitizers need inputs that exercise the bug; fuzzers are the most efficient way to produce them.
- Record the toolchain version. Report formats and default options change between releases.
What sanitizers do not do
Sanitizers only find bugs on code paths that tests actually execute, and only for the bug types they instrument. ASan does not detect reads of uninitialised memory (MSan does), data races (TSan does) or intra-object overflows within a struct. None of them replace mitigations in production. For production-time detection, look at GWP-ASan, a sampling allocator used in Chrome, Android and elsewhere that places a small random fraction of allocations on guarded pages at negligible cost, and at hardware memory tagging (Arm MTE) where available.
Checklist
- Add an ASan+UBSan build with
-O1 -g -fno-omit-frame-pointerto CI and fail on any report. - Set
ASAN_OPTIONSandUBSAN_OPTIONSexplicitly so behaviour does not depend on defaults. - Read reports from the top: kind and size, access stack, relative position, allocation and free stacks.
- Add MSan and TSan builds where uninitialised memory or concurrency are real risks.
- Feed the sanitized build with a fuzzer, and use crash triage techniques to prioritise the results.