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 unlimited in 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