Undefined Behavior
C++ undefined behavior — what it is, how compilers exploit it, the most dangerous forms, and how to detect and eliminate it.
Undefined Behaviorsince C++98A program that executes an operation the C++ standard designates as undefined behavior gives up all guarantees — the compiler may generate any code it chooses, including code that silently corrupts data, eliminates safety checks, or introduces security vulnerabilities.
Overview
Undefined behavior is not a runtime failure mode — it is a compile-time contract violation the optimizer exploits. The standard deliberately leaves certain operations undefined so compilers can optimize without disproving impossibility. When you write x + 1 where x is a signed int, the compiler is permitted to assume that expression never overflows, and it will restructure your code around that assumption.
The core danger: UB does not produce "garbage output." It produces code whose behavior the optimizer derived from a false premise. The result can appear correct in debug builds, fail silently under -O2, and change behavior across compiler versions — without any diagnostic.
The standard defines four tiers below "defined":
| Term | Meaning |
|---|---|
| Undefined behavior | No requirements — anything may happen |
| Unspecified behavior | One of several valid outcomes; standard doesn't say which |
| Implementation-defined | Platform must pick one outcome and document it |
| Erroneous behavior (C++26) | UB with mandatory diagnostic support in hardened implementations |
C++26 introduces erroneous behavior for operations like reading an uninitialized bool — conforming implementations must either produce the indeterminate value deterministically or trap, closing some of the practical UB gap without full defined-behavior overhead.
How compilers exploit UB
The optimizer operates under the as-if rule: it may rewrite any code that preserves observable behavior for a valid program. Because UB cannot occur in a valid program by definition, any path reachable only via UB is provably dead and may be eliminated.
// Overflow check the optimizer deletes entirely
bool will_overflow(int x) {
return x + 1 <= x; // signed overflow is UB — so x+1 > x always
}
// -O2 output (GCC 14, Clang 18): return false;// Null check after dereference — optimizer proves it's dead code
void write(int* p) {
*p = 42; // dereference: establishes p != null
if (p == nullptr) // provably false — eliminated
log_error();
}// Signed loop counter — wrap assumed never to occur; termination check dropped
for (int i = 0; i >= 0; ++i) { // UB: wraps to INT_MIN
process(i);
}
// GCC -O3: may generate an infinite loop — the "i >= 0" check is always trueThese are not compiler bugs. They are correct transformations of a program that has already violated the standard.
Examples
Signed integer overflow
Signed arithmetic is not modular in C++. Two's complement wrapping that C programmers sometimes rely on is undefined behavior — and C++ only mandated two's complement representation in C++20.
int x = INT_MAX;
int y = x + 1; // UB: signed overflow (pre-C++20 and C++20+)
// Safe alternatives:
if (x > INT_MAX - 1) throw std::overflow_error("overflow");
long long y = static_cast<long long>(x) + 1; // promote before adding
int y = std::add_sat(x, 1); // C++26: saturating arithmeticUnsigned integers wrap by definition — that is fully specified behavior:
unsigned u = UINT_MAX;
unsigned v = u + 1; // 0 — defined, guaranteed modular wrapOut-of-bounds access
int arr[5];
int x = arr[5]; // UB: one past end
arr[-1] = 0; // UB: before start
std::vector<int> v{1, 2, 3};
v[10]; // UB: operator[] is unchecked
v.at(10); // C++98: throws std::out_of_range — use at trust boundariesDangling references and use-after-free
int& bad() {
int x = 42;
return x; // UB: x destroyed at return
}
// Iterator invalidation — common and silent
std::vector<int> v{1, 2, 3};
auto it = v.begin();
v.push_back(4); // may reallocate — it is now dangling
*it = 99; // UB
// Fix: hold indices instead of iterators across mutations
// Or: v.reserve(N) before inserting, when final size is knownUninitialized reads
int x; // indeterminate value — not zero-initialized
if (x > 0) { ... } // UB: reading indeterminate value
// C++26 erroneous behavior: reading uninitialized trivial types becomes
// erroneous (not UB) — implementations may produce 0 or trapStrict aliasing violations
The strict aliasing rule permits the compiler to assume pointers of different types do not alias, enabling load/store reordering that breaks programs that violate it. The only types that may alias any object are char*, unsigned char*, and (C++17) std::byte*.
float f = 3.14f;
int* p = reinterpret_cast<int*>(&f);
*p = 0x3f800000; // UB: accessing float storage through int*
// Correct type punning:
int bits;
std::memcpy(&bits, &f, sizeof(float)); // always defined
int bits = std::bit_cast<int>(f); // C++20: preferred, constexpr-capablestd::bit_cast (C++20) is the canonical solution — it is constexpr, communicates intent, and generates identical code to memcpy on optimized builds.
Shift overflow
int x = 1;
x << 31; // UB pre-C++20: shifts into sign bit for signed int
x << 32; // UB all versions: shift amount >= bit width
// C++20 change: left-shift of signed integers is defined as
// truncation (two's complement) — shifting into sign bit is no longer UB
// C++20: (1 << 31) is defined for 32-bit int as INT_MIN
uint32_t u = 1u << 31; // Always defined: 0x80000000uData races
int counter = 0;
std::thread t1([&]{ ++counter; }); // C++11: std::thread introduced
std::thread t2([&]{ ++counter; });
t1.join(); t2.join();
// UB: concurrent non-atomic writes — C++11 memory model defines this as UB
// Fix: std::atomic<int> counter{0}; // C++11Detection
| Tool | Catches | Flag |
|---|---|---|
| UBSan | Signed overflow, null deref, invalid enum, OOB | -fsanitize=undefined |
| ASan | OOB access, use-after-free, stack use-after-scope | -fsanitize=address |
| MSan | Uninitialized reads | -fsanitize=memory |
| TSan | Data races | -fsanitize=thread |
| Valgrind | OOB, use-after-free, leaks | No recompile needed |
| clang-tidy | Static: uninitialized vars, aliasing patterns | Compile-time |
# Full sanitizer build — use this for CI on test suites
clang++ -fsanitize=address,undefined -fno-sanitize-recover=all \
-g -O1 -o myapp main.cpp
# -fno-sanitize-recover=all: first UB hit aborts with stack trace
# -O1: enough optimization to trigger UB, cheap enough for CI-fsanitize-trap=undefined (Clang) emits a hardware trap instead of a runtime call — useful for embedded targets that cannot link the sanitizer runtime.
Best Practices
- Enable ASan + UBSan in CI. Run every test suite under
-fsanitize=address,undefined -fno-sanitize-recover=all. Most dynamic UB is caught here at near-zero maintenance cost. - Test at
-O2, not just debug. Many UB manifestations only appear when the optimizer runs. Debug builds mask the problem. - Use
std::bit_castfor type punning (C++20). It'sconstexpr, expresses intent, and compiles to zero overhead. - Prefer
.at()over[]at trust boundaries — where input size comes from external sources. The bounds check is a single branch. - Use
std::atomicfor any shared variable (C++11). Even reads of non-atomic data are UB when another thread writes. - Initialize all variables at declaration. Modern optimizers eliminate redundant stores; the bug risk from skipping initialization is not worth it.
Common Pitfalls
Signed/unsigned comparison. int i = -1; if (i < v.size()) — size() returns size_t (unsigned); -1 promotes to SIZE_MAX. Not UB, but almost always a bug. Compile with -Wsign-compare.
std::optional access without checking (C++17):
std::optional<int> opt; // C++17
int x = *opt; // UB: accessing disengaged optional
int x = opt.value(); // throws std::bad_optional_access — safer at trust boundariesPlacement new without std::launder (C++17). Reusing storage via placement new and then accessing through the original pointer is UB:
alignas(int) char buf[sizeof(int)];
new (buf) int{42};
int* p = reinterpret_cast<int*>(buf); // UB: stale pointer
int* p = std::launder(reinterpret_cast<int*>(buf)); // C++17: definedAssuming sanitizers catch everything. MSan misses some heap uninitialized reads; UBSan typically misses strict aliasing violations (-fsanitize=strict-aliasing is rarely enabled); neither tool catches logic errors. Sanitizers are a floor, not a ceiling.
Integer overflow in security-sensitive length arithmetic.
// Classic heap overflow via wrapping length
uint32_t len = attacker_controlled;
char* out = new char[len + 1]; // len = UINT_MAX → +1 wraps to 0
memcpy(out, src, len); // copies UINT_MAX bytes — heap corruption
// Fix:
if (len > MAX_SAFE_SIZE) return error;
char* out = new char[len + 1];Strict Aliasing Rule and Type Punning
Claim. Accessing an object through a pointer of an incompatible type is undefined behavior — the compiler is allowed to assume pointers of different types never alias the same memory, enabling aggressive load/store reordering.
The only pointer types that may legally alias any other object are char*, unsigned char*, and std::byte* (C++17). Everything else is subject to the strict aliasing rule.
// Classic (but UB) float→int bit-cast via pointer reinterpret
float f = 3.14f;
int bits = *reinterpret_cast<int*>(&f); // UB: strict aliasing violationmemcpy-based punning is the portable pre-C++20 solution — it is always defined because copying through char* is explicitly permitted:
// Pre-C++20: memcpy punning — always defined
float f = 1.0f;
int bits;
std::memcpy(&bits, &f, sizeof(bits)); // defined behavior, compiles to a register move// C++20
#include <bit>
float f = 1.0f;
int bits = std::bit_cast<int>(f); // C++20: constexpr-capable, zero overheadstd::bit_cast is the canonical modern solution. It is constexpr, communicates intent at the call site, and generates identical machine code to memcpy on optimized builds. Passing -fno-strict-aliasing to GCC/Clang disables the aliasing optimization model entirely and makes the reinterpret_cast form safe — but it also forfeits significant optimization opportunities across the whole translation unit.
Signed Integer Overflow and Shift UB
Claim. Signed integer arithmetic is not modular in C++ — the compiler is permitted to assume signed overflow never occurs, and it derives loop and branch transformations from that assumption.
// INT_MAX + 1 is UB; the optimizer assumes it never happens
int x = INT_MAX;
int y = x + 1; // UB: the compiler may assume this path is unreachable
// Loop vectorization enabled by overflow UB:
// Compiler assumes i*stride never wraps, so it can vectorize freely
for (int i = 0; i < n; i++) a[i] = i * 2;Shift operations carry their own UB categories: left-shifting a signed value into or past the sign bit was UB before C++20, and shifting by an amount greater than or equal to the bit width is UB in all versions.
int x = 1;
x << 32; // UB: shift amount >= bit width (all versions)
x << 31; // UB pre-C++20: shifts into sign bit for 32-bit signed int
// C++20: defined as two's complement truncation
uint32_t u = 1u;
u << 31; // always defined: 0x80000000uSafe alternatives for checked and saturating arithmetic:
// GNU extension / GCC + Clang builtin
int result;
if (__builtin_add_overflow(a, b, &result)) { /* handle */ }
// C++26
#include <numeric>
int sat = std::add_sat(INT_MAX, 1); // C++26: returns INT_MAX, no UB
// Runtime trap on overflow (debug/CI builds)
// Compile with: clang++ -ftrapv ... (aborts on signed overflow)
// clang++ -fsanitize=undefined ... (UBSan reports + aborts)-ftrapv instruments every signed arithmetic operation to abort on overflow; it is too slow for production but invaluable in test suites. UBSan (-fsanitize=signed-integer-overflow) gives the same safety with richer diagnostics.
UB Enabling Compiler Optimizations (Both Good and Bad)
Claim. Undefined behavior is not purely a safety hazard — it is a deliberate contract that enables optimizations that defined-behavior languages cannot perform, but the same mechanism can silently remove code you intended to execute.
A useful optimization: signed overflow UB lets the compiler treat loop induction variables as unbounded, enabling auto-vectorization and strength reduction:
// Compiler can vectorize: assumes i never wraps, so SIMD widening is valid
void scale(int* a, int n) {
for (int i = 0; i < n; i++) a[i] = i * 2;
}
// With unsigned i, the wrap assumption is gone — some vectorizers emit wider codeA dangerous optimization: dereferencing a pointer before a null check lets the compiler eliminate the check entirely.
void write(int* ptr, int val) {
*ptr = val; // dereference establishes: ptr != null
if (ptr == nullptr) // provably false — dead code, eliminated at -O2
log_error();
}
// The null check is gone in the binary. If ptr is null, the store crashes silently.Branch-prediction hints interact with this: [[likely]] and [[unlikely]] (C++20) annotate probabilities the optimizer uses for layout and speculative execution, but they cannot override UB-derived dead-code elimination — if UB has already made the branch unreachable, the hint has no effect.
// C++20
void process(int* p) {
if (p == nullptr) [[unlikely]] return; // C++20: hint, not a guarantee
*p = 42;
}
// Safe: null check precedes the dereference — no UB, hint is respectedDetecting UB at Compile Time and Runtime
Claim. The best UB is the kind caught before the binary ships — and C++ provides escalating layers of detection from constexpr evaluation at compile time through sanitizers at runtime.
constexpr evaluation is a zero-cost UB detector: many forms of UB that are silent at runtime become hard compile errors inside a constexpr context:
// C++20
constexpr int divide(int a, int b) { return a / b; }
constexpr int x = divide(1, 0); // C++20: compile error — division by zero in constexprstd::is_constant_evaluated() lets you write one function that is strict at compile time and fast at runtime:
// C++20
#include <type_traits>
constexpr int safe_div(int a, int b) {
if (std::is_constant_evaluated()) {
// division by zero here is a compile error
}
return a / b;
}UBSan runtime detection covers the most common dynamic UB categories. Compile with -fsanitize=undefined -fno-sanitize-recover=all to abort with a stack trace on first hit:
# Example UBSan output
runtime error: signed integer overflow: 2147483647 + 1 cannot be
represented in type 'int'
#0 0x401234 in main main.cpp:5The full Clang -fsanitize=undefined checklist includes: signed-integer-overflow, shift, null, alignment, bool, enum, vptr, bounds, object-size, and pointer-overflow. Enable them individually if the full suite is too slow for your test suite. Compile-time attributes [[nodiscard]] and [[deprecated]] are UB-adjacent — they don't flag UB directly, but they catch ignored return values and stale APIs that commonly lead to it.
Implementation-Defined and Unspecified Behavior
Claim. Not all non-portable behavior is undefined — the standard defines a strict hierarchy, and confusing these tiers leads either to unnecessary portability paranoia or to real UB disguised as "it works on my machine."
The three tiers below fully defined behavior:
| Tier | Guarantee | Example |
|---|---|---|
| Implementation-defined | Compiler picks one outcome and documents it | sizeof(int), sign of % for negative operands |
| Unspecified | One of several valid outcomes; compiler need not document which | Order of function argument evaluation, value of uninitialized storage |
| Undefined | No requirement — optimizer may assume it never occurs | Signed overflow, data race, null dereference |
sizeof(int) is implementation-defined: on LP64 Linux it is 4, on ILP64 it may be 8, but the platform must document the choice. Relying on it is non-portable but not UB:
// Implementation-defined: 4 on most 64-bit platforms, not UB
static_assert(sizeof(int) >= 2); // minimum guaranteed by standardFunction argument evaluation order was unspecified in C++14 — each call to f(a(), b()) could evaluate a() or b() first, giving different results with side effects. C++17 tightened sequencing rules for postfix expressions but left general argument order unspecified:
// Unspecified in C++14, still unspecified in C++17
int i = 0;
f(i++, i++); // unspecified which increment happens first
// C++17 sequencing improvement: chained calls are now left-to-right
a.b().c().d(); // C++17: a.b() is fully evaluated before .c()The C++17 sequencing changes also fixed the classic std::string expression evaluation problem — s.replace(…).replace(…) now has a guaranteed left-to-right order for member-function chains.
Mitigating UB: Sanitizers, Static Analysis, and Safe Types
Claim. No single tool catches all UB — a defense-in-depth workflow combining compile-time constraints, CI sanitizers, and narrow production checks is the industry standard.
The core debug/CI build flags:
# Recommended CI build (Clang or GCC)
clang++ -fsanitize=address,undefined \
-fno-sanitize-recover=all \
-g -O1 \
-o myapp main.cpp
# address: OOB, use-after-free, stack UAF
# undefined: signed overflow, null deref, bad shift, invalid enum
# -fno-sanitize-recover=all: abort on first violation (no silent continuation)
# -O1: enough optimization to trigger UB, cheap enough for CIStatic analysis catches structural patterns sanitizers miss:
clang-tidy myapp.cpp -- -std=c++20 # checks: cppcoreguidelines-*, bugprone-*
cppcheck --enable=all --std=c++20 myapp.cppSafe type wrappers eliminate whole categories of UB at their source:
// C++20
#include <gsl/narrow>
#include <span>
// gsl::narrow<T>: checked narrowing cast — throws on value change
int big = 300;
uint8_t small = gsl::narrow<uint8_t>(big); // throws: 300 doesn't fit in uint8_t
// std::span: bounds-checked view over contiguous data (C++20)
void process(std::span<int> data) {
data[0]; // bounds-checked in hardened mode; no raw pointer arithmetic
}
// std::array over raw arrays: size always known, .at() available
std::array<int, 4> arr{1, 2, 3, 4};
arr.at(5); // throws std::out_of_range — no silent OOBDefense-in-depth matrix:
| Layer | Mechanism | Cost |
|---|---|---|
| Compile-time | concepts, static_assert, constexpr eval | Zero runtime cost |
| CI / Debug | ASan + UBSan, -fno-sanitize-recover=all | ~2× slower, CI only |
| Code review | clang-tidy, cppcheck, core guidelines | Developer time |
| Production boundaries | gsl::narrow, .at(), explicit range checks | Single branch per call |
| Production interior | Trust validated data; no sanitizer overhead | Zero extra cost |
Sanitizers are a floor, not a ceiling. UBSan misses strict aliasing violations by default; MSan misses some heap patterns; neither tool catches logic errors. The goal is to make UB loud in development and structurally impossible in production-boundary code.