How to Use Address Sanitizer (ASan) with Rust

Enable Address Sanitizer in Rust by setting RUSTFLAGS to -Zsanitizer=address and building with a nightly toolchain.

When the borrow checker isn't enough

You spent three hours debugging a crash that only happens on the third run. The stack trace points to Vec::push, but you didn't even touch a vector. The data looks fine in the debugger, then suddenly it's garbage. This isn't a logic error. This is memory corruption. Your program wrote past the end of a buffer, stomped on a neighbor's stack frame, and the house of cards collapsed.

Rust's borrow checker catches most of these at compile time. It prevents use-after-free, double-free, and buffer overflows for safe code. But unsafe blocks, FFI calls, and complex lifetime hacks leave gaps. The compiler trusts you inside unsafe. If you lie, the compiler can't stop you. Address Sanitizer (ASan) fills those gaps. It's a runtime detector that catches memory bugs the compiler missed. It instruments your code to check every memory access and aborts immediately when you cross a line.

The borrow checker is your first line of defense. ASan is the backup that catches what slips through.

How ASan catches what the compiler misses

ASan works by injecting extra code around every memory access. It also maintains a shadow memory map. For every 8 bytes of real memory, ASan uses 1 byte of shadow memory. The shadow byte stores metadata about the real memory: is it allocated? Is it freed? Is it a red zone?

When your program reads or writes memory, the injected code checks the corresponding shadow byte. If the shadow byte says the memory is freed, ASan stops the program and prints a report. If you write past the end of a buffer, you hit a red zone. The shadow byte marks that zone as invalid. ASan catches the write instantly.

This approach has a cost. The extra checks slow down the program. Memory usage increases because of the shadow map. ASan is a development tool, not a production tool. You use it to find bugs, then you fix the bugs and ship a normal build.

Speed is the price of certainty. Pay it during development.

Minimal example

Here is a simple program with a buffer overflow. The code uses unsafe to write past the end of an array. Without ASan, this might crash, or it might silently corrupt data. With ASan, it fails fast with a clear message.

fn main() {
    // Create a small array on the stack.
    let mut data = [1u8, 2, 3];

    // SAFETY: This is intentional undefined behavior for demonstration.
    // We access index 5, which is out of bounds for an array of length 3.
    unsafe {
        let ptr = data.as_mut_ptr();
        // Writing to index 5 overflows the buffer.
        *ptr.add(5) = 42;
    }

    // This line might not run if ASan aborts the process.
    println!("{:?}", data);
}

To run this with ASan, you need the nightly toolchain and a specific compiler flag. The flag is unstable, which is why nightly is required.

# Set the flag to enable address sanitizer.
export RUSTFLAGS="-Zsanitizer=address"

# Build and run with nightly.
cargo +nightly run

The output looks like this:

==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fff...
WRITE of size 1 at 0x7fff... thread T0
    #0 0x... in main at src/main.rs:8

ASan identifies the exact line, the type of error, and the size of the access. It also prints the stack trace. You don't have to guess where the bug is.

Convention aside: The community prefers cargo +nightly over switching your default toolchain to nightly. Switching defaults risks breaking stable builds. Keep the override local to the command.

Realistic scenario

Real bugs often hide in custom memory management or FFI wrappers. Here is a struct that manages a buffer manually. It allocates memory, writes to it, and frees it. The write method has a bug: it doesn't check bounds.

use std::alloc::{alloc, dealloc, Layout};

/// A manual buffer that manages its own memory.
struct ManualBuffer {
    ptr: *mut u8,
    len: usize,
}

impl ManualBuffer {
    /// Creates a new buffer with the given length.
    fn new(len: usize) -> Self {
        // SAFETY: Layout::from_size_align is safe because alignment is 1 for u8.
        // We handle the error if the layout is invalid.
        let layout = Layout::from_size_align(len, 1).unwrap();

        // SAFETY: alloc returns a valid pointer if it doesn't panic.
        // We check for null to handle allocation failure.
        let ptr = unsafe { alloc(layout) };
        if ptr.is_null() {
            panic!("Allocation failed");
        }

        ManualBuffer { ptr, len }
    }

    /// Writes a value at the given index.
    /// Bug: No bounds check. Writing past `len` is undefined behavior.
    fn write(&mut self, index: usize, value: u8) {
        unsafe {
            // This dereference is only safe if index < len.
            // The caller must guarantee this invariant.
            *self.ptr.add(index) = value;
        }
    }
}

impl Drop for ManualBuffer {
    fn drop(&mut self) {
        // SAFETY: ptr was allocated with this layout.
        // We must deallocate with the same layout to avoid UB.
        let layout = Layout::from_size_align(self.len, 1).unwrap();
        unsafe {
            dealloc(self.ptr, layout);
        }
    }
}

fn main() {
    let mut buf = ManualBuffer::new(4);
    buf.write(0, 10);
    buf.write(3, 30);

    // Bug: Index 4 is out of bounds for a buffer of length 4.
    // Valid indices are 0, 1, 2, 3.
    buf.write(4, 40);

    println!("Done");
}

Run this with ASan. The sanitizer catches the overflow in write(4, 40). The report shows a heap-buffer-overflow because the buffer is on the heap. It points to the write call and the dereference inside.

Convention aside: RUSTFLAGS persists in your shell session. If you forget to unset it, your next cargo build will fail on stable. Use a subshell to contain the flag: (RUSTFLAGS="-Zsanitizer=address" cargo +nightly run). This prevents accidental pollution of your environment.

Write the bounds check. ASan proves you need it.

Pitfalls and nuances

ASan is powerful, but it has quirks. Understanding these quirks saves time.

Performance hit. ASan slows down the program. The slowdown is usually 2x to 3x. Memory usage increases by about 50% due to the shadow map. Don't use ASan for performance benchmarks. Use it for correctness testing.

Leak detection. ASan detects memory leaks by default. If you allocate memory and never free it, ASan reports a leak at the end of the program. You can control this with environment variables. Set ASAN_OPTIONS=detect_leaks=0 to disable leak detection if you have intentional leaks. Set ASAN_OPTIONS=halt_on_error=1 to make ASan abort on the first error instead of continuing.

False positives. ASan can produce false positives in rare cases. FFI code that uses complex memory layouts might trigger ASan incorrectly. If you see a false positive, you can suppress it by adding a suppression file. The community discourages suppressing errors unless you are sure they are false positives. Fix the code first.

Miri vs ASan. Miri is another tool for detecting undefined behavior. Miri is an interpreter that runs your code in a virtual machine. It catches more types of UB, like uninitialized reads and invalid references. ASan only catches memory access errors. Miri is slower and cannot run code with threads or FFI. Use Miri for pure Rust code. Use ASan for code with unsafe and FFI.

Debug vs Release. ASan works in both debug and release builds. However, optimizations in release mode can hide bugs or change behavior. It's best to run ASan in debug mode. If you need to test release builds, use RUSTFLAGS="-Zsanitizer=address -C opt-level=1". This gives some optimization without hiding too much.

ASan is a development tool. Strip it before you ship.

Decision matrix

Use ASan when you have unsafe code and need to verify memory safety at runtime. Use ASan when you call FFI and suspect the C library is corrupting memory. Use ASan when you are debugging a crash that looks like memory corruption but the stack trace is confusing.

Use Miri when you need to detect undefined behavior that ASan misses, like uninitialized reads or invalid references. Use Miri for pure Rust code without threads or FFI.

Use Valgrind when you are on a platform where ASan isn't supported or need leak detection with more detail. Valgrind is slower than ASan but works on more systems.

Reach for the borrow checker first. ASan is a net, not a replacement for static guarantees. Trust the compiler. Use ASan to verify the exceptions.

Where to go next