8. Undefined Behavior & Sanitizers¶
What & why¶
The C++ standard lists operations whose behavior is undefined: the standard places no requirements at all on what happens. Not "crashes", not "returns garbage" — anything, including appearing to work perfectly until a compiler upgrade. UB is the root of most serious C++ bugs and security vulnerabilities. This lesson covers what it is, why compilers exploit it, the common forms, and the tools that catch it.
Undefined, unspecified, implementation-defined¶
| Kind | Meaning | Example |
|---|---|---|
| Implementation-defined | implementation chooses and documents | sizeof(int), whether char is signed |
| Unspecified | one of several valid outcomes, not documented | order of evaluation of function arguments |
| Undefined | no requirements whatsoever | signed overflow, out-of-bounds access, data races |
Why compilers "exploit" UB¶
The optimizer assumes your program has no UB, and derives facts from that assumption:
bool will_overflow(int x) {
return x + 1 < x; // signed overflow is UB, so the compiler assumes it can't happen
} // → compiles to `return false;`
void process(int* p) {
int value = *p; // dereference: so p can't be null (that'd be UB)...
if (p == nullptr) { // ...so this check is dead code and gets deleted
return;
}
use(value);
}
int table[4];
bool exists_in_table(int v) {
for (int i = 0; i <= 4; i++) { // reads table[4]: UB
if (table[i] == v) return true;
}
return false;
}
// Some compilers compile this to `return true;` — the only way to exit the loop
// without UB is to find v in the first four iterations.
UB doesn't just affect the faulty line; it can time-travel, changing the meaning of code before and after it. That's why "it works when I add a print statement" is a classic UB symptom.
The common forms of UB¶
Memory¶
- Out-of-bounds reads/writes (
v[v.size()], off-by-one C arrays,std::string_viewpast its end) - Use after free / after scope (dangling pointers, references, iterators,
string_views) - Double free, freeing with the wrong function (
deletevsdelete[]vsfree) - Null pointer dereference
- Reading uninitialized variables
- Iterator invalidation (using iterators after
push_back/erase)
Arithmetic¶
- Signed integer overflow (
INT_MAX + 1) - Division or modulo by zero;
INT_MIN / -1 - Shifting by a negative amount or ≥ the bit width (
1 << 32for 32-bitint) - Converting an out-of-range floating-point value to an integer (
static_cast<int>(1e20))
Types and objects¶
- Strict aliasing violations: accessing an object through a pointer of an unrelated type (
*reinterpret_cast<float*>(&some_int)). Usestd::bit_castorstd::memcpy. - Calling a member function on an object whose lifetime has ended
- Misaligned access (
*reinterpret_cast<int*>(bytes + 1)) - Modifying a
constobject (viaconst_cast) - Deleting a derived object through a base pointer without a virtual destructor
Concurrency¶
- Data races (unsynchronized concurrent access with at least one write)
Library preconditions¶
std::sortwith a comparator that isn't a strict weak orderingfront()/back()/pop_back()on an empty container*opton an emptystd::optional- Passing overlapping ranges to
std::memcpy - Returning from a non-
voidfunction without areturn(falling off the end) - Infinite loops without side effects (the compiler may assume they terminate)
Sanitizers¶
Sanitizers instrument your program at compile time to detect bugs at runtime, with precise reports. They are the single most effective bug-finding tool in C++.
| Sanitizer | Flag | Catches | Slowdown |
|---|---|---|---|
| AddressSanitizer (ASan) | -fsanitize=address |
out-of-bounds, use-after-free/scope/return, double free, leaks (LSan) | ~2× |
| UndefinedBehaviorSanitizer (UBSan) | -fsanitize=undefined |
signed overflow, bad shifts, null deref, misalignment, invalid enum/bool, bad casts | small |
| ThreadSanitizer (TSan) | -fsanitize=thread |
data races, some deadlocks | 5–15× |
| MemorySanitizer (MSan, Clang/Linux) | -fsanitize=memory |
reads of uninitialized memory | ~3× |
ASan and UBSan combine; TSan and MSan must each be used alone.
An ASan report:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
#0 0x4011a6 in main main.cpp:9
freed by thread T0 here:
#0 0x7f... in operator delete(void*)
#1 0x401190 in std::vector<int>::_M_realloc_insert ...
#2 0x401172 in main main.cpp:8
previously allocated by thread T0 here:
#1 0x401150 in main main.cpp:6
It tells you what (use after free), where it happened, where the memory was freed, and where it was allocated.
Useful options:
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 ./main
ASAN_OPTIONS=detect_stack_use_after_return=1 ./main
Add -fno-sanitize-recover=all so UBSan aborts on the first error (important in CI, where otherwise it only prints).
Standard library debug modes¶
- libstdc++ (GCC):
-D_GLIBCXX_DEBUG(full checked iterators; ABI-changing) or-D_GLIBCXX_ASSERTIONS(cheap bounds checks onoperator[],front(), etc. — reasonable even in production). - libc++ (Clang):
-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST(or_EXTENSIVE/_DEBUG). - MSVC:
_ITERATOR_DEBUG_LEVEL=2in Debug builds by default.
Compiler warnings and static analysis¶
Before running anything:
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wnull-dereference
-Wold-style-cast -Wuseless-cast -Wdouble-promotion -Wimplicit-fallthrough
Then static analyzers:
- clang-tidy — hundreds of checks (
bugprone-*,cppcoreguidelines-*,modernize-*,performance-*). Runs oncompile_commands.json. - Clang Static Analyzer (
scan-build), GCC-fanalyzer— path-sensitive analysis. - cppcheck, PVS-Studio, Coverity, SonarQube.
# .clang-tidy
Checks: >
bugprone-*,
cppcoreguidelines-*,
-cppcoreguidelines-avoid-magic-numbers,
modernize-*,
performance-*,
readability-identifier-naming
WarningsAsErrors: 'bugprone-*'
Fuzzing¶
Sanitizers only catch bugs on inputs you run. Fuzzers generate millions of inputs automatically, guided by code coverage:
// fuzz_parser.cpp — libFuzzer (Clang)
#include <cstdint>
#include <string_view>
#include "linkcheck/links.hpp"
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
std::string_view text{reinterpret_cast<const char*>(data), size};
(void)linkcheck::extract_links(text);
return 0;
}
clang++ -std=c++20 -g -O1 -fsanitize=fuzzer,address,undefined fuzz_parser.cpp links.cpp -o fuzz
./fuzz -max_total_time=60
Every parser that handles untrusted input should be fuzzed.
Writing code that avoids UB¶
- Prefer safe abstractions:
std::vector,std::span,std::string_view(carefully), smart pointers, range-for, algorithms. - Use checked access at trust boundaries:
.at(),std::optional::value(). - Initialize everything.
int x{}; - Use unsigned/checked arithmetic deliberately; for overflow-sensitive code, use
__builtin_add_overflow(GCC/Clang) or C++26's checked-arithmetic facilities. std::bit_castfor type punning, neverreinterpret_castbetween unrelated types.- Avoid raw
new,delete,const_cast,reinterpret_cast, C-style casts, and C arrays in application code. - Run tests under ASan+UBSan and TSan in CI, and fuzz parsers.
A CI matrix¶
# .github/workflows/ci.yml (sketch)
strategy:
matrix:
config:
- { name: asan-ubsan, flags: "-fsanitize=address,undefined -fno-sanitize-recover=all" }
- { name: tsan, flags: "-fsanitize=thread" }
- { name: release, flags: "-O2 -D_GLIBCXX_ASSERTIONS" }
steps:
- run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="${{ matrix.config.flags }}"
- run: cmake --build build -j
- run: ctest --test-dir build --output-on-failure
Common mistakes¶
- "It works on my machine" as evidence of correctness.
- Only running sanitizers locally, occasionally — put them in CI.
- Ignoring warnings — many UB bugs are flagged at compile time.
- Adding
volatileor prints to "fix" a UB symptom. reinterpret_castfor serialization — usestd::memcpy/std::bit_cast.- Assuming signed overflow wraps — it doesn't (use unsigned types if you need wrapping).
Exercises¶
- Write one small program for each: out-of-bounds write, use-after-free, use-after-return, double free, signed overflow, shift overflow, null deref, uninitialized read, data race. Run each under the appropriate sanitizer and read the report.
- Compile
bool will_overflow(int x) { return x + 1 < x; }on Compiler Explorer at-O0and-O2. Then changeinttounsigned. Explain the difference. - Replace a
reinterpret_cast<float*>(&u32)pun withstd::bit_cast<float>(u32)and verify the output is the same with-O2 -fstrict-aliasing. - Add a
.clang-tidyfile to your link-checker capstone, runclang-tidy -p build src/**/*.cpp, and fix or justify every finding. - Fuzz
extract_linksfrom the link checker for 5 minutes with libFuzzer. Did it find anything? Ifstd::regexis slow, how does that show up? - Set up a GitHub Actions workflow running your tests under ASan+UBSan and TSan.
Recap¶
- UB has no requirements; optimizers assume it never happens, with far-reaching effects.
- Know the common forms: memory errors, signed overflow, bad shifts, aliasing, races, library precondition violations.
- Sanitizers (ASan, UBSan, TSan, MSan) catch UB at runtime with precise reports — use them in CI.
- Warnings, clang-tidy, and static analyzers catch it at compile time; fuzzers find the inputs that trigger it.
- Prefer safe abstractions and checked access at trust boundaries.