Memory corruption, undefined behaviour and data races in C and C++ code often run silently in tests and only surface as crashes or exploitable bugs in production. Compiler sanitisers are runtime instrumentation that makes these bugs fail loudly, with a stack trace, at the moment they happen. This guide explains what AddressSanitizer (ASan), UndefinedBehaviorSanitizer (UBSan), MemorySanitizer (MSan) and ThreadSanitizer (TSan) each detect, what they cost, which can be combined, how to run them in CI and with fuzzing, how they apply to unsafe Rust, and how to triage what they find. It is written for C, C++ and Rust developers, build engineers and the application security teams who set test requirements for native code.
Key takeaways
- Each sanitiser finds a different bug class. ASan for out-of-bounds and use-after-free, UBSan for undefined behaviour such as signed overflow and bad shifts, MSan for reads of uninitialised memory, TSan for data races.
- They are test tools, not hardening. Clang states that ASan and TSan are not meant for production executables; for UBSan in security-sensitive production builds it recommends the minimal runtime or trap mode instead of the full runtime.
- Not every combination is allowed. GCC documents that ASan cannot be combined with TSan or HWASan, and TSan cannot be combined with ASan or LeakSanitizer, so plan separate build variants.
- Sanitisers only see executed code. Their value depends on test and fuzzing coverage, which is why OSS-Fuzz builds fuzz targets with ASan and UBSan by default.
- MSan and TSan need everything instrumented. Uninstrumented libraries cause false reports or missed races, so budget for rebuilding dependencies.
What sanitisers are and where they fit
A sanitiser is a compiler pass plus a runtime library. The compiler inserts checks around memory accesses, arithmetic or synchronisation, and the runtime keeps shadow state (for example which bytes are addressable or initialised) and prints a report when a check fails. Because detection happens at the exact faulting instruction, a sanitiser report points at the bug rather than at a crash many calls later.
Sanitisers sit between static analysis and production hardening. Static analysis can flag possible flaws without running code but produces false positives; sanitisers have very few false positives for the bugs they cover but only see the paths that tests exercise. Production hardening such as _FORTIFY_SOURCE, stack protectors and control flow integrity runs in shipped binaries and reduces exploitability. See the Compiler and Toolchain Hardening Guide for those options and the C and C++ Secure Coding Guide for the coding practices that prevent these bugs in the first place.
The four main sanitisers compared
| Sanitiser | Flag (Clang, GCC) | Detects | Documented cost (Clang) | Key constraint |
|---|---|---|---|---|
| AddressSanitizer | -fsanitize=address | Out-of-bounds access to heap, stack and globals; use-after-free; use-after-return; use-after-scope; double and invalid free; leaks (via LeakSanitizer) | Typical slowdown 2x; more real memory, stack use up to 3x; maps 16+ TB of virtual address space on 64-bit | No static linking of executables; not for production |
| UndefinedBehaviorSanitizer | -fsanitize=undefined plus named checks | Signed integer overflow, invalid shifts, null use, out-of-bounds indexing with static bounds, misalignment, wrong dynamic type (vptr), wrong function pointer type | Clang describes the checks as small runtime cost with no impact on address space layout or ABI | Full runtime is for testing; use minimal runtime or trap mode in production |
| MemorySanitizer | -fsanitize=memory (Clang) | Use of uninitialised memory in branches, pointer use and across calls; use after destruction | Typical slowdown 3x; 2x memory, 3x with origin tracking; origin tracking adds 1.5x to 2x slowdown | All code must be instrumented, including dependencies; Linux, NetBSD and FreeBSD |
| ThreadSanitizer | -fsanitize=thread | Data races; also lock inversion, thread leaks and mutex misuse | Typical slowdown about 5x to 15x; memory about 5x to 10x | 64-bit only in practice; implies PIE; no static libc or libstdc++; Clang marks it beta |
Related tools fill gaps. HWAddressSanitizer (-fsanitize=hwaddress) uses pointer tag bits to detect memory errors with lower overhead and GCC documents it mainly for AArch64. LeakSanitizer is built into ASan on Linux and can run standalone. On Windows, MSVC supports AddressSanitizer with /fsanitize=address from Visual Studio 2019 version 16.9 for x86 and x64 on Windows 10 and later; Microsoft lists thread, leak, memory, undefined and hwaddress sanitisers only as possible future additions, and states that ASan should not be used in production.
What each sanitiser maps to
Mapping sanitisers to weakness classes helps decide which variants a project needs. Memory-unsafe code that parses untrusted input needs ASan and UBSan at minimum; multi-threaded services need TSan; code that handles secrets or builds network messages from structs benefits from MSan, because uninitialised bytes can leak memory contents.
| Weakness class (CWE) | Primary sanitiser | Notes |
|---|---|---|
| Out-of-bounds write and read (CWE-787, CWE-125) | ASan | Heap, stack and global redzones; UBSan bounds covers statically known array bounds |
| Use after free (CWE-416), double free (CWE-415) | ASan | Freed memory is quarantined so later access is caught |
| Integer overflow or wraparound (CWE-190) | UBSan | signed-integer-overflow is in the undefined group; unsigned wraparound needs -fsanitize=unsigned-integer-overflow or the integer group and is often intended behaviour |
| NULL pointer dereference (CWE-476) | UBSan null; ASan | UBSan also flags creating a null reference |
| Use of uninitialised variable (CWE-457) | MSan | Reports only when the value affects behaviour, not on every copy |
| Race condition (CWE-362) | TSan | Only races on executed interleavings; TSan has an optional adaptive delay mode to expose more |
| Memory leak (CWE-401) | LeakSanitizer | On by default with ASan on Linux; matters for long-running services and denial of service |
Build variants and combinations
Because some sanitisers cannot share a build, most projects run a small matrix of build variants. GCC states that -fsanitize=address cannot be combined with -fsanitize=thread or -fsanitize=hwaddress, and that -fsanitize=thread cannot be combined with -fsanitize=address or -fsanitize=leak. MSan is a Clang feature and needs its own fully instrumented build. ASan and UBSan combine well and are the usual default pair.
| Variant | Flags | When to run |
|---|---|---|
| ASan plus UBSan | -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 | Every pull request: unit and integration tests, and fuzzing |
| TSan | -fsanitize=thread -g -O1 | Nightly or on changes to concurrent code; long-running stress tests |
| MSan | -fsanitize=memory -fsanitize-memory-track-origins -g -O1 with instrumented dependencies | Nightly or weekly once dependencies are rebuilt; fuzzing of parsers |
| Production UBSan subset | -fsanitize=<chosen checks> -fsanitize-trap=all or -fsanitize-minimal-runtime | Shipped builds where a trap on undefined behaviour is preferable to continuing |
- Optimisation level: Clang recommends
-O1or higher for reasonable performance,-fno-omit-frame-pointerfor better stack traces with ASan, and-gfor file and line information. - Link with the compiler driver: Clang notes that the final link step must use
clang, notld, so the runtime is linked. - Fail fast in CI: by default UBSan reports and continues. Use
-fno-sanitize-recover=...for the checks you gate on, so a report fails the test run. GCC documents that the runtime defaulthalt_on_erroris 1 for ASan and 0 for TSan and UBSan. - Check the feature in code:
__has_feature(address_sanitizer)lets test code adapt, for example to skip a test that deliberately exhausts memory. - Windows specifics: MSVC ASan is not compatible with edit-and-continue (
/ZI), incremental linking or/RTC, and Microsoft notes/ZIis enabled by default for new CMake projects.
Runtime options and reports
Each runtime reads an environment variable: ASAN_OPTIONS, UBSAN_OPTIONS, MSAN_OPTIONS, TSAN_OPTIONS and LSAN_OPTIONS. Setting help=1 lists the available flags. Options worth standardising in CI:
- ASan:
detect_leaks=1(default on Linux, opt-in on macOS),detect_stack_use_after_return=1(already enabled on Linux per Clang), andcheck_initialization_order=1for C++ static initialisation order bugs (not supported on macOS). - UBSan:
print_stacktrace=1for symbolised traces, which Clang notes needs-g,-fno-sanitize-merge,-fno-omit-frame-pointerandllvm-symbolizeron the path;log_pathto write reports to files your CI collects. - TSan:
halt_on_error(default false),exitcode(default 66),history_sizeto keep more access history for clearer reports, anddetect_deadlocks(default true). - Symbolisation: keep
llvm-symbolizeravailable in the test image; unsymbolised reports slow triage badly.
Sanitisers and fuzzing
Fuzzing generates the inputs; sanitisers turn silent corruption into a crash the fuzzer can record. OSS-Fuzz, Google's continuous fuzzing service for open source, lets a project list address, memory and undefined in its project.yaml, and uses ASan and UBSan by default. Its documentation recommends MSan but does not enable it by default because uninstrumented system dependencies are likely to cause false positives; projects that opt in must build all their libraries with MemorySanitizer flags, and can mark a sanitiser as experimental while they fix build problems.
- Write fuzz targets for every parser of untrusted input: file formats, network protocols, configuration and deserialisation code.
- Build each target with ASan plus UBSan first, then add MSan once dependencies are instrumented.
- Keep a regression corpus: every crashing input becomes a unit test run under the same sanitiser in CI.
- Deduplicate by stack: group crashes by the top frames of the sanitiser report before filing bugs.
See the Fuzzing Guide for harness design, coverage and corpus management.
Sanitisers in Rust
Safe Rust prevents most memory errors at compile time, but unsafe blocks, FFI calls into C and C++ and some concurrency primitives can still introduce them. The Rust Unstable Book documents sanitiser support behind the unstable -Zsanitizer flag, so it needs a nightly toolchain. It separates sanitisers intended for testing or fuzzing (AddressSanitizer, HWAddressSanitizer, LeakSanitizer, MemorySanitizer, RealtimeSanitizer, ThreadSanitizer) from those that may also be used in production (ControlFlowIntegrity, KernelControlFlowIntegrity, MemTagSanitizer, SafeStack, ShadowCallStack and DataFlowSanitizer). Its examples build with RUSTFLAGS=-Zsanitizer=address and cargo build -Zbuild-std --target x86_64-unknown-linux-gnu, and each sanitiser lists its supported targets. MemorySanitizer in Rust has the same rule as in C: C and C++ dependencies must be rebuilt with -fsanitize=memory or reports will be false positives. See the Rust Security Guide and the Memory-Safe Languages Guide.
Triage, suppressions and ignore lists
A sanitiser report should be treated as a real bug until shown otherwise. False reports exist but mostly come from configuration: uninstrumented code under MSan or TSan, custom allocators ASan does not understand, or synchronisation TSan cannot see. Each runtime provides scoped ways to silence what is genuinely not a bug.
- Runtime suppression files: ASan supports suppressions only for external libraries (types such as
interceptor_via_libandodr_violation); UBSan suppressions name a check and a location and only apply to recoverable checks; TSan supports types such asrace,race_top,mutex,thread,deadlockandcalled_from_lib; leak suppressions go inLSAN_OPTIONS. - Function attributes:
__attribute__((no_sanitize("address")))or a specific check such asno_sanitize("signed-integer-overflow")removes instrumentation from one function. - Ignore lists: the sanitiser special case list can exclude source files and functions at compile time. Clang warns that for TSan, ignored code is not instrumented at all, which can itself cause false positives and missing stack frames, unlike
no_sanitize("thread"), which keeps some instrumentation. - Govern suppressions like code: every entry needs a comment with the reason and an owner, and the suppression files should be reviewed when dependencies change. A suppression added to make CI green is a recorded, unfixed bug.
When a report is real, classify it by the sanitiser's bug type and attacker reachability, then set severity using your bug bar: a heap overflow reachable from network input is a different class from a signed overflow in a test utility. See the Vulnerability Severity and Bug Bar Guide for Development Teams.
Rolling sanitisers out
- Pilot on one component: pick a library that parses untrusted input, add the ASan plus UBSan variant and run the existing tests. Expect a first wave of real bugs; fix them before widening.
- Report only, then gate: run the variant as a non-blocking CI job for two or three weeks, triage everything it finds, then make it blocking for new reports.
- Widen by component: add the variant to each native component in turn, starting with internet-facing and privileged code.
- Add TSan and MSan on a schedule: nightly jobs first, with an owner for each, because their reports take longer to triage.
- Roll back safely: if a variant blocks releases because of a toolchain problem, switch that job back to non-blocking with a dated ticket rather than deleting it.
Measuring sanitiser coverage
- Variant coverage: share of native components with ASan plus UBSan in pull request CI, and with TSan and MSan on a schedule.
- Executed code coverage under sanitisers: line or edge coverage of the sanitised test and fuzz runs, because unexecuted code is unchecked code.
- Open sanitiser findings by type and age: heap-buffer-overflow and use-after-free reports should be fixed within the bug bar deadline for memory corruption.
- Suppression count and age: a growing suppression file is a growing backlog.
- Escapes: memory safety bugs found in production or by external researchers in code paths that sanitised tests already covered point to gaps in inputs or options.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| NIST SP 800-53 Rev. 5 | SA-11(8) Dynamic Code Analysis | Sanitised test and fuzz runs are dynamic analysis of the developer's code |
| NIST SP 800-53 Rev. 5 | SA-11 Developer Testing and Evaluation; SA-15 Development Process, Standards, and Tools | Sanitiser build variants and CI gates as defined test tooling |
| NIST SP 800-53 Rev. 5 | SI-16 Memory Protection | Production hardening complements, but is not replaced by, sanitiser testing |
| NIST CSF 2.0 | PR.PS-06 | Secure software development practices integrated and monitored |
| MITRE CWE | CWE-787, CWE-125, CWE-416, CWE-190, CWE-457, CWE-362 | Weakness classes sanitisers detect at run time |
Common failure patterns
- Shipping sanitiser builds: ASan and TSan runtimes are not designed for security-sensitive production use, and Microsoft and Clang both say so. Keep sanitised builds in test.
- Running sanitisers on thin tests: a clean ASan run over unit tests that never touch the parser proves little. Pair sanitisers with fuzzing of untrusted input paths.
- Letting UBSan recover silently: reports scroll past in logs while the job passes. Gate with
-fno-sanitize-recoveron the checks that matter. - MSan without instrumented dependencies: teams see false reports from libraries, conclude MSan is noisy and drop it. Rebuild dependencies first or leave MSan out.
- Blanket ignore lists: excluding whole directories to quieten TSan or ASan hides real bugs in exactly the hardest code.
- One variant only: running ASan alone misses races and uninitialised reads; the cheap fix is a nightly TSan job.
Practitioner checklist
- Add an ASan plus UBSan build variant to pull request CI for all C, C++ and unsafe Rust components.
- Add scheduled TSan runs for concurrent code and MSan runs once dependencies are instrumented.
- Build with
-O1 -g -fno-omit-frame-pointerand keepllvm-symbolizerin test images. - Make sanitiser reports fail the build with
-fno-sanitize-recoverandhalt_on_errorwhere appropriate. - Fuzz every parser of untrusted input under sanitisers and keep crashing inputs as regression tests.
- Keep sanitiser runtimes out of release builds; use UBSan trap mode or the minimal runtime only for chosen checks.
- Require a reason and owner for every suppression and ignore list entry, and review them on dependency updates.
- Track sanitised coverage, open findings by type and age, and suppression growth.
Standards and references
- LLVM Clang: AddressSanitizer
- LLVM Clang: UndefinedBehaviorSanitizer
- LLVM Clang: MemorySanitizer
- LLVM Clang: ThreadSanitizer
- GCC: Program Instrumentation Options
- Microsoft Learn: AddressSanitizer for MSVC
- Rust: The Unstable Book, sanitizer
- Google OSS-Fuzz: Setting up a new project
Related NHI Mgmt Group resources: Fuzzing Guide · C and C++ Secure Coding Guide · Compiler and Toolchain Hardening Guide · Memory-Safe Languages Guide