# Undefined Behavior: The Compiler Is Not Your Friend Here

> UB doesn't mean 'returns garbage' — it means the optimizer may assume it never happens and reason backwards from that. How signed overflow checks get deleted, why time-travel is real, and the sanitizer workflow that catches it.

URL: https://harshith.in/blog/undefined-behavior-is-not-a-value/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: systems, compilers, security

Every C programmer eventually writes this overflow check, and the compiler
eventually deletes it:

```c
int next = len + delta;
if (next < len) abort();      /* overflow check — gcc -O2: gone */
```

Signed overflow is undefined behavior; therefore, reasons the optimizer,
`len + delta` did not overflow; therefore `next < len` is impossible for
positive delta; therefore the branch is dead code. The check written to catch
the bug is removed *because of* the bug it checks for. Understanding why this
is legal — and why compilers genuinely can't just "be reasonable" — is the
difference between writing C and gambling in it.

## UB Is a Contract Term, Not an Error Mode

The C standard defines behavior for valid programs. For certain operations —
signed overflow, out-of-bounds access, null dereference, data races, aliasing
violations, use-after-free — it says: *if this occurs, the standard places no
requirements on the implementation.* Not "returns an unspecified value"
(that's a different, tamer category) — no requirements. On the whole
execution, including the parts that already happened; a later UB can make an
earlier `printf` not appear, because the optimizer reordered on the
assumption the UB path is unreachable. People call it time travel; it's just
dataflow analysis over a premise you handed the compiler.

The premise is the point. Every optimization is a theorem of the form "this
transformation preserves behavior *of valid programs*." `for (i = 0;
i <= n; i++) a[i]` vectorizes cleanly only if `i` can't wrap; pointer loads
can be hoisted only if they can't trap; `p->x; if (!p)` lets the null check
fold away because a null `p` would have already been UB. Demanding the
compiler "do the obvious thing" on UB means disabling the assumption
machinery that makes -O2 worth having — the same inference that deletes your
overflow check is the one deleting bounds checks it *proved* redundant.

## Where It Actually Bites

The UB list is long; the production-incident list is short and worth knowing
cold:

- **Signed overflow** — checks written *after* the operation get folded, loop
  bounds get assumed. Write checks before the fact (`delta > INT_MAX - len`)
  or use `__builtin_add_overflow`.
- **Dereference-before-null-check** — the deref licenses check deletion. The
  famous 2009 Linux TUN/TAP exploit was exactly this shape (plus mmap at
  page 0); the kernel has compiled with `-fno-delete-null-pointer-checks`
  since.
- **Strict aliasing** — `*(float *)&i` tells the optimizer int-writes and
  float-reads can't alias, so it may reorder them. Use `memcpy` (compiles to
  the same instruction, blesses the type pun) or `-fno-strict-aliasing`,
  which the kernel also does.
- **Out-of-bounds / use-after-free** — not "reads garbage": the optimizer may
  cache, reorder, or miscompile surrounding code based on the impossible
  access. All security-relevant.

Note what the kernel flags concede: real codebases *negotiate* the contract,
buying back specific UB categories with flags (`-fwrapv` for wrapping signed
arithmetic exists too). That's a legitimate engineering position — but it's a
per-project treaty, not the language default, and code that silently assumes
it doesn't travel.

## The Workflow That Wins

Arguing with the optimizer loses; instrumenting wins. The sanitizers changed
this game completely:

```bash
gcc -O2 -fsanitize=undefined,address -g -fno-sanitize-recover=all ...
```

**UBSan** traps signed overflow, misaligned access, bad shifts, null deref at
the moment they execute, with file:line. **ASan** catches out-of-bounds and
use-after-free with shadow memory (~2× slowdown — cheap for CI). Run the test
suite under both continuously, and fuzz the parsers under them; the
combination converts "impossible" field crashes into same-day fixes. For the
paranoid tier: `-ftrapv`-style hardening in production via
`-fsanitize=signed-integer-overflow -fsanitize-minimal-runtime`, and
`-D_FORTIFY_SOURCE=3` for libc bounds. None of this is optional-extra
tooling anymore; it's the standard cost of writing in a language whose
optimizer holds you to a contract you can't see.

And when you *want* the compiler to assume something, say it out loud —
`assert` in debug plus `__builtin_unreachable()` in release is you supplying
the premise deliberately, which is the only polite direction for that
relationship to run.

## Takeaways

- UB isn't a value or a crash — it's a premise the optimizer may reason from,
  forwards and backwards.
- The canonical deletions: post-hoc overflow checks, null checks after
  derefs, aliasing-violating reorders. Check before the operation, always.
- Real projects buy back UB with flags (-fwrapv, -fno-strict-aliasing,
  -fno-delete-null-pointer-checks) — a treaty you must document.
- UBSan+ASan in CI and under fuzzing is the workflow; -O2 without sanitizer
  coverage is unaudited risk.
- memcpy is the blessed type-pun; __builtin_*_overflow are the blessed
  checks; unreachable() is you stating premises on purpose.
