Memory Debugging: AddressSanitizer, Valgrind, and Practical Triage
This article covers Memory Debugging: AddressSanitizer, Valgrind, and Practical Triage. A practical guide to debugging memory issues: use-after-free, buffer overflows, leaks, and data races. Learn what tools catch, what they miss, and ho...
Memory bugs are some of the most expensive bugs:
- they may not crash immediately
- they can corrupt unrelated state
- they can appear/disappear with minor changes
The goal of this post is to give you a workflow.
1) The common failure modes
- buffer overflow (stack/heap)
- use-after-free
- double free
- uninitialized reads
- memory leak
- data race (not strictly “memory” but shows up as memory corruption)
2) AddressSanitizer (ASan)
ASan is a compiler-based tool (Clang/GCC) that adds redzones and shadow memory.
It is great at catching:
- heap/stack buffer overflows
- use-after-free
Typical usage:
- build with
-fsanitize=address -fno-omit-frame-pointer -g - run program normally
3) Valgrind
Valgrind instruments at runtime and can detect:
- leaks (Memcheck)
- invalid reads/writes
It’s slower than ASan but often easier to use when you can’t recompile.
4) C: a tiny bug and how tools catch it
#include <stdlib.h>
#include <string.h>
int main(void) {
char *p = (char*)malloc(8);
strcpy(p, "this string is too long"); // overflow
free(p);
return 0;
}
ASan output typically points to:
- the allocation site
- the overflow site
- stack traces
5) Zig: safety modes and debug allocators
Zig can help catch memory errors in debug builds.
- bounds checks
- optional safety checks
- debug allocators can detect leaks
Example idea:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const leaked = gpa.deinit();
if (leaked) std.debug.print("leak detected\n", .{});
}
const a = gpa.allocator();
var buf = try a.alloc(u8, 8);
defer a.free(buf);
// OOB write will trap in safe builds.
buf[100] = 1;
}
6) Rust: Miri and sanitizers
Rust prevents many memory bugs, but unsafe code can still break.
Tools:
- Miri: an interpreter that detects UB in unsafe code
- sanitizers: Rust supports ASan/TSan in nightly and some setups
Example (unsafe UB):
fn main() {
let mut v = vec![1u8, 2, 3];
let p = v.as_mut_ptr();
drop(v);
unsafe {
// use-after-free
*p = 9;
}
}
7) A practical triage workflow
- Make the crash reproducible.
- Minimize input.
- Enable sanitizers.
- Get stack traces.
- If data race suspected, use TSan.
8) Make crashes actionable: symbols and stack traces
Most "I have a crash" situations become solvable once you have:
- debug symbols
- line numbers
- a reliable stack trace
Practical tips:
- compile with
-g(and avoid stripping symbols in debug builds) - keep binaries and shared libraries from the same build together
- if you ship stripped binaries, keep a separate symbol package for production debugging
Without symbols, your crash may look like "segfault at 0x0". With symbols, it becomes "use-after-free at foo.c:123".
9) Core dumps: capture the full process state
If a crash is hard to reproduce, a core dump is often the best artifact.
At a high level:
- enable core dumps (
ulimit -c unlimitedin a shell) - run the program until it crashes
- load the core in a debugger to inspect memory and threads
Core dumps are especially valuable for:
- heisenbugs that disappear under instrumentation
- production incidents
- multi-threaded crashes where timing matters
10) Sanitizer selection guide
Sanitizers are not interchangeable.
- ASan: great for buffer overflows and use-after-free
- UBSan: undefined behavior like signed overflow, invalid shifts, misaligned access
- TSan: data races (often the root cause behind "random" memory corruption)
Workflow:
- start with ASan
- if you suspect UB, add UBSan
- if you suspect concurrency issues, switch to TSan (expect more slowdown)
11) Minimizing: the fastest path to a fix
When a bug needs a 200MB input file, it is hard to reason about.
Aim for:
- smallest input that still triggers the issue
- smallest set of threads / features enabled
- smallest code path
This is why fuzzers are effective: they produce tiny inputs that reliably trigger crashes.
References
- ASan docs (Clang): https://clang.llvm.org/docs/AddressSanitizer.html
- Valgrind Memcheck: https://valgrind.org/docs/manual/mc-manual.html
- Zig allocator docs: https://ziglang.org/documentation/master/std/#std.heap.GeneralPurposeAllocator
- Rust Miri: https://github.com/rust-lang/miri