valgrind, Sanitizers, and Miri
bugfarm seeds five defects — leak, use-after-free, an uninitialised read, a heap overflow, and a data race — behind one `app
Chapter 28 read a crash after it happened. This chapter catches five bugs
before they get the chance to crash anything — or, in one case, watches a
tool fail to catch one at all. bugfarm is a single CLI,
app <leak|uaf|uninit|overflow|race>, seeding one specific memory or
concurrency defect per subcommand, built three times over. C++ has no
compile-time protection against any of the five, so it is the only language
that can express all of them; Go’s garbage collector and runtime checks rule
three out by construction; Rust’s borrow checker rules out four. What is
left, in every language, is watched by a different kind of tool — a runtime
binary translator, a compile-time instrumentation pass, or a symbolic
interpreter — and this chapter runs all of them, for real, against the same
five bugs, so the catch/miss matrix below is built entirely from commands
executed this session, not from what each tool’s documentation claims it does.
The code is in examples/29-valgrind-sanitizers-miri/. ./demo.sh build
builds the plain binaries; each language’s README.md-documented CMake
presets (asan, tsan) and go build -race build the instrumented ones
directly, since instrumented builds are outside every chapter’s normal
demo.sh contract.
Tools used —
valgrind3.27.1 (--tool=memcheck,--tool=helgrind,--tool=massif),cmake/g++16.1.1 with theasan/tsanCMake presets (-fsanitize=address,undefined/-fsanitize=thread),go build -race,cargo +nightly miri(all host).valgrindand the sanitizer runtimes (libasan,libtsan) are hard requirements inscripts/check-host.sh;miriis not — it tracks Rust nightly, and this chapter documents exactly what happens whether or not a nightly-plus-miri toolchain is installed.
Two ways to watch a program: translate the binary, or instrument the source
valgrind needs no rebuild and no source cooperation at all: point it at
any binary, plain RelWithDebInfo included, and its core (a virtual CPU
built on an IR called VEX) re-translates every basic block before it runs,
inserting extra instructions around every memory access. memcheck, the
default tool, gives every byte of memory a shadow bit tracking
addressability (has this byte been allocated?) and a shadow byte tracking
definedness (has this byte been written to?), checked in software on every
load and store — no hardware assist, because ordinary CPUs have no such
instruction. That software check, repeated for every single memory access of
the whole program, is the entire reason for the well-known 10-50x slowdown:
this chapter’s own uaf subcommand took 0.002s plain, 0.040s under
ASan, and 0.97-0.99s under valgrind memcheck — valgrind ran roughly
25x slower than the sanitizer build for the identical bug, on a binary so
small that most of that second is JIT-translation and process startup, not
steady-state emulation. helgrind swaps memcheck’s shadow bytes for a
lockset-plus-happens-before model over the same binary-translation core (the
race subcommand measured 1.22s under it, slightly heavier than
memcheck’s 1.02s on the same input); massif swaps in a heap-usage
sampler, taking periodic snapshots of live allocation size instead of
checking every access — running it against leak produced a real,
plottable curve peaking at 80.02 KB before the process exits with the
leaked block still counted.
Because valgrind operates on the compiled binary with no notion of what
produced it, it also has no notion of runtimes that manage their own memory
outside libc — and Go is exactly that. Pointing valgrind at the Go leak
binary produces 630 errors from 411 contexts, every single one a false
“Conditional jump or move depends on uninitialised value(s)” or “Use of
uninitialised value” deep inside runtime.lock2, runtime.gopark, and
runtime.mcall — the goroutine scheduler’s own stack-switching and lock-free
bookkeeping look like impossible, undefined states to an instruction-level
model built around one C-style stack per OS thread. Worse, the HEAP
SUMMARY reports 0 allocs, 0 frees, 0 bytes allocated for the entire run,
even though the program printed a real leaked=1 on stdout — Go’s allocator
talks to the kernel directly via mmap, bypassing the malloc/free calls
valgrind intercepts, so its leak-tracking machinery has nothing to attach to
at all. Neither the 630 false positives nor the invisible real leak is a
configuration mistake; valgrind and the Go runtime are built on
incompatible assumptions about what a stack and a heap look like, and this
chapter’s own transcript is the proof.
Sanitizers take the opposite trade: instrument the source, at compile
time, and ship a differently-linked binary (libasan.so, libtsan.so)
whose every load and store already carries its own inline check — no
separate CPU, no re-translation, just a few extra machine instructions the
compiler emitted. ASan’s mechanism is shadow memory: one shadow byte
covers eight bytes of application memory (shadow_addr = (app_addr >> 3) +
offset), and specific byte values mean specific things — 00 fully
addressable, fa a heap redzone the allocator placed around every block to
catch overflow, fd a freed block’s bytes repainted so any further access
trips instantly.
Running the uaf binary under ASan pulls exactly the fd byte at
0x7b29be3e0010 and reports heap-use-after-free with the freed-at and
allocated-at stacks attached; running overflow reports
heap-buffer-overflow the instant the write lands one byte into the fa
redzone past a 40-byte block. LSan is bundled into the ASan runtime by
default on Linux and runs its own reachability walk at process exit — the
same leak binary rebuilt under the asan preset reports detected memory
leaks with no separate leak-checking flag needed. UBSan rides along in
the same build (-fsanitize=address,undefined) but none of this chapter’s
five bugs happen to trip one of its specific checks (signed overflow,
invalid enum values, and the like are its domain, not heap safety), so it
stays silent here — worth naming so its absence in these transcripts doesn’t
read as a miss. TSan needs its own separate build (ASan and TSan cannot
be linked into the same binary) and uses yet another shadow model — a vector
clock per thread recording happens-before relationships — to catch a race
while it actually executes, meaning it can only report interleavings the
run actually hits. The overhead lands close to native either way: race
measured 0.03-0.046s under TSan versus 0.002s plain, roughly the
constant-factor cost of a few inlined instructions per access, not
valgrind’s order-of-magnitude software CPU.
Go’s -race is the same idea under a different name: go build -race
links the ThreadSanitizer runtime directly into the Go binary, which is why
its WARNING: DATA RACE output — two conflicting accesses, each with a full
goroutine stack, plus the creation site of both goroutines — looks so much
like TSan’s C++ report. It is TSan, wearing Go’s tooling.
miri is neither of the above: it never runs your compiled machine code
at all. It interprets the program’s MIR (Rust’s mid-level IR) inside an
abstract machine that tracks every allocation’s provenance and validity, so
it can flag undefined behavior the borrow checker never got a chance to rule
out — specifically, UB reachable only through unsafe. This host’s pinned
toolchain (rust-toolchain.toml, 1.97.1) has no miri component (miri
tracks nightly, not stable), but a nightly-x86_64-unknown-linux-gnu
toolchain with miri is installed here, so this chapter ran it for real
rather than only documenting it. Against the actual bugfarm leak
binary, cargo +nightly miri run -- leak reports error: memory leaked:
alloc1026 (Rust heap, size: 24, align: 8) naming the exact Box::leak call
site — the same finding valgrind reached by a completely different route.
To see miri catch a defect only unsafe can express, a five-line
standalone snippet frees a Box through a raw pointer and then writes
through it anyway:
let raw: *mut i32 = unsafe { Box::into_raw(Box::new(42i32)) };
unsafe {
drop(Box::from_raw(raw)); // frees the allocation
*raw = 7; // UB: write through a dangling pointer
}
cargo +nightly run on this prints read back: 7 and exits 0 — the
write “worked,” which is exactly the danger UB represents. cargo +nightly
miri run on the identical source refuses:
error: Undefined Behavior: memory access failed: alloc236 has been freed, so this pointer is dangling
--> src/main.rs:12:9
help: alloc236 was allocated here: src/main.rs:7:21
help: alloc236 was deallocated here: src/main.rs:11:9
That is the same use-after-free class ASan catches in C++ (right down to
naming the allocation and deallocation sites), reached through Rust’s
unsafe escape hatch instead of a language with no such checks at all —
which is exactly the point: a type system cannot forbid what it never sees,
and unsafe is where Rust admits that plainly.
How the code works
Each language’s main.cpp / main.go / main.rs shares one shape: a
Bug enum with exactly five variants (enum class Bug in C++, a plain
string switch in Go, enum Bug in Rust), a parse_bug function returning
that enum or an error through each language’s Chapter 5 error-handling
idiom (std::expected, (string, error), Result<Bug, String>), and one
do_* function per variant holding the seeded defect. The enum, not a raw
string comparison scattered through main, is what makes “five bugs, three
languages, one dispatch shape” a closed, exhaustive match instead of an
open-ended set of string checks — C++’s switch over Bug even gets
std::unreachable() after it, telling the compiler (and a reader) that
every variant is handled.
The C++ do_leak, do_uaf, do_uninit, and do_overflow functions each
route their pointer through a touch() helper —
asm volatile("" : : "r,m"(value) : "memory"), an empty inline-asm block
that tells the compiler “something outside this translation unit might read
this value,” the same trick benchmark libraries call DoNotOptimize. It
matters more than it looks: rebuilding do_leak with touch(buf) removed
but its per-element buf[i] = i loop left in place still reports the
leak, because GCC’s escape analysis treats a write into heap memory as an
observable side effect it won’t remove without proof nothing depends on it.
Strip the loop too — an allocation nothing ever writes to or reads — and
HEAP SUMMARY drops straight to 0 allocs, 0 frees, 0 bytes allocated:
GCC proved the entire new[] unobservable and deleted it, malloc and all,
confirmed directly on this host. touch() is what closes that gap; without
it (or without a loop that keeps writing), there is no bug left to catch,
which is itself the lesson — an unobserved bug is dead code, and the
compiler is entitled to remove dead code. Rust’s leak() uses
std::hint::black_box(leaked.as_ptr()) for the identical reason: LLVM can
see nothing observable depends on the buffer’s contents (only its
statically-known .len() gets printed), so without the barrier the whole
64 KiB vec![0xAAu8; 65536] — and its Box::leak — can vanish too.
The bugs themselves are small and literal. do_uaf calls delete p
then writes *p = 7 through the now-dangling pointer; do_uninit
allocates new int with no initializer and reads it; do_overflow
writes arr[10] into a 10-element array; do_race spawns two
std::jthreads that each increment a shared, unguarded int counter in a
tight loop, then joins both (via jthread’s own RAII) before printing the
result. Go’s leak() spawns a goroutine blocked forever on an unbuffered
channel receive nobody will ever send to, then compares
runtime.NumGoroutine() before and after — there is no external tool
watching the Go heap for this the way valgrind watches malloc, so the
program has to notice its own leak. Rust’s leak() is the one bug safe
Rust can still express: Box::leak is a real, safe standard-library API
whose entire purpose is opting an allocation out of Drop-based
reclamation, which is why it is the only row in the matrix where Rust has
anything for valgrind or miri to find at all.
Errors, three ways
Bad usage is identical across all three: no subcommand, or an unrecognized
one, prints usage: app <leak|uaf|uninit|overflow|race> alone (for no
argument) or app: unknown bug: <name> above that same usage line (for an
unrecognized one), and exits 2 — the same shape as every other
chapter’s app. The interesting three-way split is what each language does
with a bug it cannot express: Go prints go: prevented by the runtime —
see chapter for uaf/uninit/overflow, Rust prints
rust: prevented at compile time — see chapter for
uaf/uninit/overflow/race, and both exit 64 (EX_USAGE) rather
than attempting anything. That fixed message and exit code is the
finding for those cells — not a placeholder, but the same “no reproducible
bug here” result a real investigation would reach chasing the equivalent
C++ defect in that language, arrived at by a runtime check or a rejected
compilation instead of a crash.
Concurrency lens: TSan vs. helgrind vs. -race
The race is one unsynchronized int counter incremented from two threads
with no lock — the canonical lost-update bug — and this chapter ran all
three of that bug’s detectors against it directly. ThreadSanitizer
(the C++ tsan preset) named the exact colliding line,
main.cpp:67 (++counter;), printing both threads’ stacks and a summary
that exits 66; a plain, uninstrumented three-run sample of the same
binary showed the race is not guaranteed to manifest every time —
counter=400000 (the mathematically correct total for the workload) came
back in one plain sample, while the TSan-instrumented run’s altered timing
produced counter=200000, half the expected total, alongside its report.
helgrind, valgrind’s other thread tool, found the identical line
through a completely different mechanism — a lockset-and-happens-before
model instead of TSan’s vector clocks — reporting “Possible data race…
at (anonymous namespace)::bump() (main.cpp:67)” between the same two
threads, both created with “Locks held: none.” go -race found Go’s
version of the same shape at main.go:70, printing WARNING: DATA RACE
for the conflicting pair and Found 2 data race(s) before exiting 66;
a plain Go run in this same session came back counter=100302 instead of
the expected 200000 — a visibly corrupted count, unlike the C++ plain
run’s lucky correct total, which is itself a reminder that “it printed the
right number” is not evidence a race is absent. Rust needs none of this:
the race subcommand exits 64 with prevented at compile time, because
sharing &mut i32 across std::thread::spawn boundaries without
synchronization is rejected by the Send/Sync rules before there is a
binary to run a detector against at all.
int counter = 0; // race: shared, unsynchronized on purpose
void bump() {
for (int i = 0; i < 200'000; ++i) {
++counter;
}
}
var counter int // race: shared, unsynchronized on purpose
func bump(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 100_000; i++ {
counter++
}
}
match bug {
Bug::Leak => {
leak();
Ok(())
}
Bug::Uaf | Bug::Uninit | Bug::Overflow | Bug::Race => {
eprintln!("{PREVENTED}");
Err(64)
}
}
The C++ and Go excerpts are the bug itself, written by hand in both
languages; the Rust excerpt is the same Bug::Race variant reaching the
match arm that refuses to try — there is no racy Rust function to show,
because the compiler never let one exist.
Build, run, observe
[host]$ cd examples/29-valgrind-sanitizers-miri && ./demo.sh build
[host]$ valgrind --leak-check=full --error-exitcode=1 ./cpp/build/release/app leak
bugfarm: leak: allocated 4096 bytes on the heap, never freed (intentional)
==...== 4,096 bytes in 1 blocks are definitely lost in loss record 1 of 1
Build the instrumented variants directly — they sit outside demo.sh’s
build/run contract on purpose, since this chapter (unlike most) is about
the tool, not a happy-path run:
[host]$ cd cpp && cmake --preset asan && cmake --build --preset asan
[host]$ cmake --preset tsan && cmake --build --preset tsan
[host]$ ./build/asan/app uaf
==...==ERROR: AddressSanitizer: heap-use-after-free on address ...
[host]$ ./build/tsan/app race
WARNING: ThreadSanitizer: data race (pid=...)
[host]$ go build -race -o bin/app-race . && ./bin/app-race race
WARNING: DATA RACE
python3 scripts/test-all-examples.py --only 29-valgrind-sanitizers-miri
reports PASS PASS PASS: the plain build and bad-usage/prevented-message
contract per language, then the C++ suite (memcheck on leak/uninit,
ASan on uaf/overflow, TSan on race), Go’s self-detected leak plus
-race’s race, and Rust’s valgrind-on-leak plus its informational
miri probe.
Cross-check: the same use-after-free, two tools, two speeds
The clearest single proof that these are two different techniques, not two
brands of the same one, is running the identical bug under both: the C++
uaf binary, unmodified, first under valgrind, then rebuilt under ASan.
[host]$ valgrind --error-exitcode=1 ./cpp/build/release/app uaf
==...== Invalid write of size 4
==...== at 0x400A3B: do_uaf (main.cpp:87)
==...== Address 0x4e7f080 is 0 bytes inside a block of size 4 free'd
==...== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
[host]$ ./cpp/build/asan/app uaf
==...==ERROR: AddressSanitizer: heap-use-after-free on address 0x7b29be3e0010
WRITE of size 4 at 0x7b29be3e0010 thread T0
#0 do_uaf .../main.cpp:87
freed by thread T0 here: ... #1 do_uaf .../main.cpp:85
previously allocated by thread T0 here: ... #1 do_uaf .../main.cpp:84
Both name line 87 as the bad write and lines 84/85 as the allocation and
free — the same defect, independently confirmed. What differs is how much
work got them there: valgrind’s memcheck took 0.97-0.99s (repeated
twice for stability) against the plain binary with no rebuild required;
the ASan build, needing cmake --preset asan first, then ran in
0.040s — about 25x faster — and its report additionally carries a
full shadow-byte dump (fa fa fa fa[fd]fa fa ...) that pinpoints exactly
which byte tripped the check, something memcheck’s report describes in
words but ASan shows you directly. Neither number should be read as “the”
overhead for real programs — this binary does almost nothing, so both
times are dominated by fixed startup cost — but the ratio, valgrind an
order of magnitude slower than a rebuild-and-relink sanitizer for
identical output, is exactly the trade-off the tool-selection question in
this chapter’s opening comes down to: rebuild if you can, translate the
binary if you cannot.
What you learned
- valgrind instruments the binary at runtime (no rebuild, works on
anything) at the cost of a software-emulated CPU — measured at roughly
25x the ASan build’s time for an identical
uafreport — and that same binary-translation model has no concept of a runtime that manages its own stacks and heap outside libc, which is why pointing it at a Go binary produced 630 false-positive errors and a heap summary blind to a real, on-stdout goroutine leak. - Sanitizers instrument the source at compile time via shadow memory (one
byte per eight application bytes,
>>3 + offset), trading a required rebuild for overhead close to native (TSan’s race run at 0.03-0.046s versus plain’s 0.002s) — and LSan/UBSan ride along in the same ASan build rather than needing separate invocations. - miri interprets MIR symbolically instead of running compiled machine
code at all, which lets it catch undefined behavior reachable only
through
unsafe— demonstrated live on this host’s nightly toolchain against both the realBox::leakbugfarm binary and a standalone use-after-free snippet, naming the exact allocation and deallocation sites the same way ASan does for C++. - The same race bug, checked three ways (TSan, helgrind,
go -race), gets three different detection mechanisms (vector clocks, lockset/happens-before, and TSan borrowed wholesale) converging on the identical source line — while Rust’sSend/Syncrules mean there is no racy binary for any of them to point at in the first place.
Next, the eBPF observation toolkit: bcc-tools and bpftrace watch these same kinds of programs from outside entirely, with no instrumentation and no rebuild, on the lab VM this chapter’s tools never needed.
verified — every transcript,
number, and diagnostic above was produced on the Fedora 44 reference host
(kernel 7.1.3-200.fc44, valgrind-3.27.1, gcc 16.1.1, go1.26.5, rustc/cargo
1.97.1, miri 0.1.0 nightly-2026-07-18) this session. Confirmed live:
valgrind memcheck reported definitely lost for leak
and uninitialised value(s) for uninit, and also
caught uaf (Invalid write ... freed) and
overflow (Invalid write ... 0 bytes after a block)
on the plain binary; the ASan build reported heap-use-after-free
and heap-buffer-overflow with real shadow-byte dumps, and its
bundled LSan reported detected memory leaks for leak;
the TSan build reported data race at main.cpp:67
(exit 66) and helgrind independently reported Possible data race
at the same line; go build -race reported
WARNING: DATA RACE at main.go:70,
Found 2 data race(s) (exit 66), and a plain Go run showed a
corrupted counter=100302 against the expected 200000; valgrind
against the Go leak binary produced 630 errors from 411
contexts (all inside the Go runtime) and a heap summary of
0 allocs, 0 frees, 0 bytes allocated; valgrind against the Rust
leak binary reported 65,536 bytes ... definitely lost,
and cargo +nightly miri run -- leak independently reported
memory leaked: alloc1026; a standalone unsafe snippet ran clean
under cargo +nightly run (read back: 7) and was
rejected by cargo +nightly miri run with
Undefined Behavior: memory access failed: alloc236 has been freed;
massif against leak produced a real peak of
80.02 KB; timing the same uaf binary gave
0.002s plain, 0.040s under ASan, and
0.97-0.99s under valgrind memcheck (repeated twice); and
removing every observable use of do_leak's buffer (not just its
touch() barrier, but its per-element writes too) reproducibly
collapsed valgrind's report to 0 allocs, 0 frees, 0 bytes allocated
on this host's GCC 16.1.1 at -O2.