A fuzzer hands you a crash and a crashing input. The real work starts when you open the sanitizer report. AddressSanitizer, UndefinedBehaviorSanitizer, and MemorySanitizer each produce structured output, but the format is dense and easy to misread under pressure. This guide walks every field from first line to last, covering what each byte in the shadow memory dump encodes and where each sanitizer commonly lies.
All three sanitizers are LLVM-based and ship with Clang. GCC has partial support for ASan and UBSan but MSan is Clang-only. The examples below were produced with Clang 17 on x86-64 Linux; output is essentially identical across Clang versions since the sanitizer ABI has been stable for years.
AddressSanitizer Reports
ASan instruments every memory access at compile time and maintains a shadow memory region that tracks the poisoned/addressable state of every eight application bytes. When you access a poisoned region the instrumented load or store traps, ASan reads the shadow to classify the error type, and prints a report before aborting.
Here is a complete heap-buffer-overflow report from a real XML parser fuzz campaign:
==18432==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000050
READ of size 1 at 0x602000000050 thread T0
#0 0x55f3a1c2e89b in parse_tag /src/xml_parser.c:214:18
#1 0x55f3a1c2d403 in parse_element /src/xml_parser.c:178:12
#2 0x55f3a1c2b8f0 in xml_parse /src/xml_parser.c:91:5
#3 0x55f3a1c1a200 in LLVMFuzzerTestOneInput /fuzz/fuzz_xml.c:12:3
#4 0x55f3a1b00110 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long)
#5 0x55f3a1aff830 in fuzzer::Fuzzer::RunOne(unsigned char const*, unsigned long, bool, ...)
0x602000000050 is located 0 bytes after a 16-byte region [0x602000000040,0x602000000050)
allocated by thread T0 here:
#0 0x55f3a1e4b0d0 in malloc (/usr/lib/x86_64-linux-gnu/libasan.so.6+0xb50d0)
#1 0x55f3a1c2c112 in tag_alloc /src/xml_parser.c:45:14
#2 0x55f3a1c2e751 in parse_tag /src/xml_parser.c:209:9
SUMMARY: AddressSanitizer: heap-buffer-overflow /src/xml_parser.c:214:18 in parse_tag
Shadow bytes around the buggy address:
0x0c047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c047fff7fd0: fa fa 00 00[fa]fa 00 00 fa fa 00 00 fa fa 00 00
0x0c047fff7fe0: fa fa 00 00 fa fa 00 00 fa fa 00 00 fa fa 00 00
0x0c047fff7ff0: fa fa 00 00 fa fa 00 00 fa fa 00 00 fa fa 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloc red zone: ca
Right alloc red zone: cb
Shadow gap: cc
==18432==ABORTINGLine-by-line: the header
The first line ==18432==ERROR: AddressSanitizer: heap-buffer-overflow packs three things: the process PID (18432), the sanitizer name, and the error type. The error types you will encounter most often in fuzzing campaigns:
- heap-buffer-overflow — read or write past a heap allocation boundary.
- heap-use-after-free — access to memory that was already freed.
- stack-buffer-overflow — same as heap-buffer-overflow but for stack allocations.
- stack-use-after-return — pointer to a stack frame used after that frame was popped. Requires
ASAN_OPTIONS=detect_stack_use_after_return=1. - global-buffer-overflow — out-of-bounds access on a global array.
- use-after-poison — manual poisoning via
ASAN_POISON_MEMORY_REGIONhit a code path you marked invalid.
The second line tells you the access direction and size: READ of size 1. A one-byte out-of-bounds read in a parser is almost always the end of a length field being trusted without validation. A large write is more likely exploitable — note the size; it informs severity triage.
The stack trace
Frames are numbered from the point of fault outward. Frame #0 is the exact access; frame #1 is the call site, and so on up to LLVMFuzzerTestOneInput (for libFuzzer targets) or the main loop (for AFL++ targets). The path and line number in frame #0 is where you start reading source.
If you see addresses without source lines (e.g., 0x55f3... in ??) the binary was stripped or built without -g. Recompile with -g -O1 — -O0 is safe but slow; -O2 often inlines frames and makes the trace harder to follow.
The allocation site
The block starting with 0x602000000050 is located 0 bytes after a 16-byte region tells you the exact allocation that was overflowed. The range [0x602000000040, 0x602000000050) is the allocated object; the access was at 0x602000000050, which is the first byte past the end — a classic off-by-one. The allocation site stack trace immediately below names the call chain that allocated the memory. In this case tag_alloc at xml_parser.c:45 allocated 16 bytes butparse_tag indexed 17 bytes into it.
Shadow memory — decoding the hex dump
The shadow memory section is the most intimidating part of the report and the most informative. Each shadow byte encodes the state of 8 consecutive application bytes. The arrow (=>) marks the row containing the faulting address; the brackets mark the specific byte.
The byte values map to the legend printed at the bottom of every report:
00— fully addressable (all 8 application bytes are valid).01–07— partially addressable; the value is the number of valid bytes at the low end of the 8-byte group. Common at the tail of a malloc'd region that isn't a multiple of 8 bytes.fa— heap redzone. ASan places redzones around every allocation; reads or writes here are heap-buffer-overflows.fd— freed heap. The region was freed and is now quarantined. Accesses here are use-after-free.f1/f3— stack left/right redzone. Stack-buffer-overflow.f5— stack frame after return. Stack-use-after-return.f8— stack use-after-scope. Variable went out of scope but the pointer is still live.f9— global redzone. Global-buffer-overflow.
In the example above the row reads fa fa 00 00 [fa] fa 00 00 fa fa .... The accessed byte maps to fa — a heap redzone — which confirms the overflow landed immediately past the allocated region, consistent with the off-by-one identified from the allocation site.
Reading the shadow dump also tells you whether there is adjacent live memory after the redzone. If you see fa fa 00 00 the next object starts two 64-byte groups away; if you see fa fa fd fd the next region is freed heap. The latter scenario is often more exploitable because an overflow into freed memory can corrupt tcmalloc or ptmalloc metadata.
Use-after-free reports
Here is a use-after-free from a tree-walking fuzzer:
==19105==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000000030
READ of size 8 at 0x603000000030 thread T0
#0 0x55ab2c3d1042 in node_get_child /src/tree.c:88:12
#1 0x55ab2c3cf8b0 in tree_walk /src/tree.c:120:5
#2 0x55ab2c3c0200 in LLVMFuzzerTestOneInput /fuzz/fuzz_tree.c:19:3
0x603000000030 is located 16 bytes inside a 48-byte region [0x603000000020,0x603000000050)
freed by thread T0 here:
#0 0x55ab2c5e30c0 in free (/usr/lib/x86_64-linux-gnu/libasan.so.6+0x330c0)
#1 0x55ab2c3cd4a1 in node_free /src/tree.c:62:3
previously allocated by thread T0 here:
#0 0x55ab2c5e30d0 in malloc (/usr/lib/x86_64-linux-gnu/libasan.so.6+0xb50d0)
#1 0x55ab2c3cc890 in node_alloc /src/tree.c:38:14
SUMMARY: AddressSanitizer: heap-use-after-free /src/tree.c:88:12 in node_get_childUse-after-free reports carry three stack traces instead of two: the access site, the free site, and the original allocation site. All three matter for understanding the ownership bug. In this example node_free freed the node at line 62 but tree_walk still held a pointer and dereferenced it at line 120 vianode_get_child. The allocation trace at line 38 tells you the object's lifetime started in node_alloc. Mapping those three sites gives you the complete lifetime arc of the dangling pointer.
UndefinedBehaviorSanitizer Reports
UBSan catches a different class of bug: code that invokes C/C++ undefined behavior without necessarily accessing invalid memory. The report format is dramatically simpler — a single line printed directly to stderr with no stack trace:
/src/decoder.c:312:18: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
/src/decoder.c:419:9: runtime error: load of misaligned address 0x7ffd3b2c8005 for type 'uint32_t', which requires 4 byte alignment
/src/decoder.c:501:22: runtime error: null pointer passed as argument 1, which is declared to never be nullEach line is <file>:<line>:<col>: runtime error: <description>. That's it. There is no allocation site, no shadow dump, no process PID. The description is always in plain English, which makes UBSan reports the easiest sanitizer output to read — but the lack of a stack trace means you need to correlate the file and line number with the crashing input yourself.
The checks UBSan most commonly trips in fuzzing campaigns:
- signed integer overflow — arithmetic that wraps past
INT_MAXorINT_MIN. Extremely common in length calculations. Enable with-fsanitize=signed-integer-overflow. - shift exponent too large — shifting by an amount ≥ the type width. Common in bit manipulation code, especially in codecs.
- null pointer dereference — distinct from ASan's null-dereference detection; UBSan catches it before the access when the pointer is passed to a
__attribute__((nonnull))function. - misaligned address — loading a type from an address that doesn't satisfy the type's alignment requirement. Silent on x86-64 but a hard fault on ARM and RISC-V.
- load of value that is not a valid enum value — an integer cast to an enum type holds a value outside the declared enumerators. Leads to table lookup bugs.
- invalid bool value — a byte that should be 0 or 1 holds something else, often from uninitialized memory or a bad cast.
A practical note: UBSan continues execution by default after printing the error. This is useful for collecting all violations in a single run, but it means your fuzzer won't record a crash — the process exits 0. To make UBSan abort on the first violation (so the fuzzer records it as a crash), compile with -fsanitize-undefined-trap-on-error or set UBSAN_OPTIONS=halt_on_error=1.
For maximum coverage in a single binary, combine ASan and UBSan: -fsanitize=address,undefined. The two sanitizers are compatible and complement each other: ASan catches memory safety bugs; UBSan catches integer and type safety bugs that ASan ignores.
MemorySanitizer Reports
MSan detects reads of uninitialized memory — a class of bug ASan cannot catch because the memory is technically addressable; it just hasn't been written. These bugs are particularly dangerous in cryptography (key material derived from uninitialized entropy), parsers (length fields that might be 0, or might be garbage), and any code that branches on uninitialized values to make security decisions.
==21800==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x561f4a2b3190 in compare_fields /src/compare.c:74:9
#1 0x561f4a2b1040 in record_equal /src/compare.c:102:14
#2 0x561f4a2a0200 in LLVMFuzzerTestOneInput /fuzz/fuzz_record.c:22:3
Uninitialized value was created by an allocation of 'rec' in the stack frame of function 'parse_record'
#0 0x561f4a2b0510 in parse_record /src/compare.c:55:17
SUMMARY: MemorySanitizer: use-of-uninitialized-value /src/compare.c:74:9 in compare_fieldsMSan reports are structurally similar to ASan use-after-free reports: the access stack trace first, then the origin stack trace explaining where the uninitialized memory came from. The origin trace is the harder one to interpret because uninitialized data often flows through many function calls before it's used in a branch or comparison. Follow the origin frame to the allocation, then trace forward through the code to find where a write was omitted.
MSan has a higher instrumentation overhead than ASan (roughly 2–3× slowdown versus 1.5–2× for ASan) and is significantly harder to use correctly because it requires all code in the process — including every linked library — to be compiled with MSan. A single uninstrumented library function can generate false positives because MSan cannot track writes performed by code it hasn't instrumented. For most fuzzing campaigns the practical workflow is: run ASan first to clear memory safety bugs, then run a separate MSan build to catch uninitialized reads.
False Positives and Suppressions
Each sanitizer has known false-positive scenarios worth understanding before you spend time chasing a non-bug.
ASan false positives
- Custom allocators. If your code implements a pool allocator or arena allocator, ASan's shadow memory is unaware of your internal structure and will fire on accesses within the pool that your allocator considers valid. Fix: annotate pool boundaries with
ASAN_POISON_MEMORY_REGION/ASAN_UNPOISON_MEMORY_REGION. - Interceptors and preloaded libraries. ASan ships with interceptors that wrap libc functions. If your target uses a non-standard libc or overrides
mallocviaLD_PRELOAD, the interceptor chain can mismatch and produce spurious reports. Run withASAN_OPTIONS=replace_str=0:replace_intrin=0to disable the interceptors and confirm whether the report disappears. - Fork-mode with AFL++. AFL++'s fork server forks after the target is initialized. If the target allocates memory before
__AFL_INIT(), ASan's heap bookkeeping is duplicated in every forked child, and some allocations may appear use-after-free because the child's allocator state inherited the parent's free lists. Solution: move initialization to after__AFL_INIT()or useAFL_PRELOADwith a clean allocator.
UBSan false positives
- Intentional type punning. Code that uses unions or
memcpy-based type punning may trigger UBSan's alignment checks on platforms where the punned type has stricter alignment than the source. Annotate intentional punning with__attribute__((packed))or use-fno-sanitize=alignmentfor that translation unit. - Signed overflow in deliberate wrapping arithmetic. Some code intentionally relies on two's complement wrapping. Mark the variable
__attribute__((no_sanitize("signed-integer-overflow")))or use__builtin_add_overflowfor the checked version.
MSan false positives
- Uninstrumented libraries. As noted above, any call into code not compiled with MSan loses tracking. The standard remedy is to build the entire dependency chain with MSan, including libc — the LLVM project provides an MSan-instrumented libc build for this purpose.
- SIMD intrinsics. Compiler-emitted or hand-written SSE/AVX intrinsics can move data in ways the MSan pass doesn't fully track, producing spurious uninitialized warnings. Use
-fno-sanitize=memoryon affected files and re-enable for the rest of the codebase.
Sanitizer Options Across Fuzzers
AFL++ and libFuzzer set different default ASAN_OPTIONS. Understanding the difference matters when you reproduce a crash from one fuzzer using another's build.
# AFL++ — set before the fuzzer binary
export ASAN_OPTIONS="abort_on_error=1:halt_on_error=1:symbolize=1"
export ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer)
afl-fuzz -i seeds/ -o findings/ -- ./my_parser @@
# libFuzzer — pass on the command line
./fuzz_target corpus/ -timeout=10 \
-asan_options=abort_on_error=1:symbolize=1The most important option is abort_on_error=1. Without it, ASan prints the report and returns control to the process, which may continue running — useful for coverage collection but wrong for crash recording. Both AFL++ and libFuzzer set this by default in their managed environments; if you're reproducing manually, set it explicitly.
symbolize=1 enables inline symbolization via llvm-symbolizer, which converts raw addresses to file:line references in the stack trace. Without it you get raw hex addresses. On systems where llvm-symbolizer is not in PATH, set ASAN_SYMBOLIZER_PATH to its absolute path.
Crash deduplication in AFL++ uses the edge hash from the coverage bitmap as a first-pass filter, not the sanitizer output. Two crashes that hit the same edge are considered duplicates even if they trigger different ASan error types. LibFuzzer uses the top few frames of the stack trace. Neither approach is perfect: AFL++ can over-deduplicate distinct bugs reachable via the same edge; libFuzzer can under-deduplicate the same bug triggered through different call paths. Manual triage of the ASan output is still necessary to confirm whether two crashes are the same root cause.
Reading Reports Efficiently: A Workflow
- Note the error type (first line). Heap-use-after-free and stack-use-after-return are higher severity than heap-buffer-overflow by one byte.
- Go to frame
#0of the access trace. Open the source file at that line. Does the access make sense given the surrounding code? - For ASan: read the allocation site. How large was the allocation? How far past the end did the access land? A zero-byte overflow is a boundary condition bug; a large overflow may be an integer truncation feeding a length.
- For UAF: compare the free site and the access site. Are they in different functions? Different threads? Different callbacks? The answer shapes the fix.
- Reproduce the crash locally with
ASAN_OPTIONS=abort_on_error=1and the crashing input from the fuzzer. Confirm the same report appears before attempting a fix. - Write a regression test. Add the minimized crashing input to your seed corpus so the fuzzer doesn't re-discover the same bug after you patch it.