Skip to content

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_view past its end)
  • Use after free / after scope (dangling pointers, references, iterators, string_views)
  • Double free, freeing with the wrong function (delete vs delete[] vs free)
  • 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 << 32 for 32-bit int)
  • 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)). Use std::bit_cast or std::memcpy.
  • Calling a member function on an object whose lifetime has ended
  • Misaligned access (*reinterpret_cast<int*>(bytes + 1))
  • Modifying a const object (via const_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::sort with a comparator that isn't a strict weak ordering
  • front()/back()/pop_back() on an empty container
  • *opt on an empty std::optional
  • Passing overlapping ranges to std::memcpy
  • Returning from a non-void function without a return (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.

g++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined main.cpp -o main
./main

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 on operator[], front(), etc. — reasonable even in production).
  • libc++ (Clang): -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST (or _EXTENSIVE / _DEBUG).
  • MSVC: _ITERATOR_DEBUG_LEVEL=2 in 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 on compile_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_cast for type punning, never reinterpret_cast between 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 volatile or prints to "fix" a UB symptom.
  • reinterpret_cast for serialization — use std::memcpy / std::bit_cast.
  • Assuming signed overflow wraps — it doesn't (use unsigned types if you need wrapping).

Exercises

  1. 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.
  2. Compile bool will_overflow(int x) { return x + 1 < x; } on Compiler Explorer at -O0 and -O2. Then change int to unsigned. Explain the difference.
  3. Replace a reinterpret_cast<float*>(&u32) pun with std::bit_cast<float>(u32) and verify the output is the same with -O2 -fstrict-aliasing.
  4. Add a .clang-tidy file to your link-checker capstone, run clang-tidy -p build src/**/*.cpp, and fix or justify every finding.
  5. Fuzz extract_links from the link checker for 5 minutes with libFuzzer. Did it find anything? If std::regex is slow, how does that show up?
  6. 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.

Next: Design patterns, the C++ way →