Not every crash the fuzzer reports is a bug. A fuzzing campaign against a real target will produce false positives — inputs that cause the process to terminate for reasons that have nothing to do with a defect in the target code. Treating every crash report as a genuine bug wastes engineering time and erodes trust in the fuzzing pipeline. Understanding the common sources of false positives and how to distinguish them from real bugs is a core triage skill.
This post covers the common false positive categories, the diagnostic workflow for each, and when to file versus when to discard.
What Counts as a False Positive
A false positive is a crash that the fuzzer records as a potential bug but that does not represent a defect in the target code. The categories:
- Allocator behavior under memory pressure. ASan's allocator reserves guard regions around every allocation. When system memory is exhausted or the allocator quarantine fills, ASan can abort with an OOM-style error that looks like a crash but is not a bug in the target.
- Sanitizer init failures. If the target binary is launched with conflicting
ASAN_OPTIONSor on a system where the shadow memory region overlaps an existing mapping, ASan can fail to initialize and abort immediately. This produces a crash that is nothing but a configuration problem. - Instrumentation interaction bugs. ASan's interceptors for
malloc,free,memcpy, and similar libc functions can conflict with custom allocators,LD_PRELOADhooks, or non-standard libc implementations. The interceptor fires on a valid access and reports a false positive. - Harness memory leaks. Memory allocated in the harness (not the target) that persists for the lifetime of the process rather than being freed per iteration. In persistent mode, this accumulation eventually triggers an OOM abort.
- Persistent-mode state leaks. As described in the persistent mode guide: mutable global state from iteration N corrupts the execution of iteration N+1, producing a use-after-free or out-of-bounds report that does not reproduce when the crashing input is run in isolation.
ASan's Allocator Under Near-OOM Conditions
ASan's allocator is more conservative than the system allocator. It places redzones around every allocation, maintains a quarantine of freed regions, and tracks allocation metadata. All of this consumes more memory than a non-instrumented build. Under tight memory constraints — a fuzzer worker with a small memory limit, or a persistent-mode harness with a memory leak in the target — the ASan allocator can exhaust memory and abort.
The abort message looks like:
==12345==ERROR: AddressSanitizer failed to allocate 0x100000 (1048576) bytes of LargeMmapAllocator (error code: 12)
ABORTINGError code 12 is ENOMEM. This is not a bug in the target; it is memory exhaustion in the fuzzing environment — or, sometimes, an intentional very-large allocation in the target that ASan's instrumented allocator refuses (by default ASan caps individual allocations at around 10 GB via max_allocation_size_mb). Diagnose by:
- Running the same input in a non-ASan build and checking if the target exits cleanly.
- Increasing the available memory for the fuzzer worker, or removing the AFL++ memory limit with
afl-fuzz -m none(ASan builds routinely need this). - Reducing
ASAN_OPTIONS=quarantine_size_mbto free quarantine memory. - Setting
ASAN_OPTIONS=allocator_may_return_null=1if the target is designed to handlemalloc()returningNULL— this turns a fatal ASan abort into a normal allocation failure that the target can respond to. - Auditing the harness for leaks — memory that should be freed per iteration but isn't.
The Difference Between a Real OOB and an Interceptor False Positive
ASan ships with interceptors for common libc functions (memcpy, strcpy, strlen, etc.) that perform shadow memory checks before the real call. If your target uses a non-standard libc or overrides these functions via LD_PRELOAD, the interceptor can fire on a valid call and report a false positive.
The distinguishing characteristic: a real out-of-bounds access has a frame #0 in target code (your parser, your handler, your library). An interceptor false positive has frame #0 in a libc wrapper or the ASan interceptor itself, with no corresponding target code in the trace. If the stack trace shows an access in __interceptor_memcpy or similar without a target code frame, run with ASAN_OPTIONS=replace_str=0:replace_intrin=0 to disable interceptors and confirm whether the crash disappears.
Sanitizer Init Failures Masquerading as Crashes
ASan requires a large contiguous virtual address range for its shadow memory (1/8th of the total virtual address space). On systems with unusual address-space layouts — containers with restricted memory, systems with non-standard kernel parameters, or builds that explicitly map large regions before main() — ASan's shadow memory initialization can fail.
Init failures produce an abort in ASan's startup code, not in target code. The crash report shows a very short stack trace that goes from main or the dynamic linker directly into ASan internals without passing through any parser code. Confirm by running the binary with ASAN_OPTIONS=verbosity=1 — a successful init prints a startup message; a failed init will show the error before any target code runs.
Harness Memory Leaks
A common harness pattern is to allocate a buffer for each iteration and pass it to the target. If the allocation happens inside the __AFL_LOOP body but the free does not, the harness leaks memory — not the target. This is a harness bug, not a target bug, but it looks like a target memory error in the sanitizer report because the allocation happens inside the harness and is flagged by ASan when the process aborts on OOM.
Audit every allocation in your harness that happens inside the fuzzing loop and confirm it is freed before the loop body exits. The simplest harness pattern avoids this entirely: read into a stack buffer or a pre-allocated static buffer that is reused across iterations.
Persistent-Mode State Leaks Misreported as Use-After-Free
This is the most common and most confusing persistent-mode false positive. The scenario:
- Iteration N: the target allocates a node, processes it, and frees it.
- ASan quarantines the freed region (does not immediately reuse it).
- Iteration N+1: a different allocation happens to land at the same address (once the quarantine evicts the old entry).
- ASan reports a use-after-free because the address was previously freed, even though the current access is to a valid allocation.
// Classic persistent-mode false positive: use-after-free that isn't.
//
// Scenario: the parser allocates a node in iteration N and frees it.
// In iteration N+1, a completely different allocation happens to land
// at the same address. ASan's quarantine catches that the address was
// previously freed and reports use-after-free — but the two uses are
// from different allocations in different iterations.
//
// Diagnosis: run with __AFL_LOOP(1) to disable persistent mode.
// If the crash disappears, it is a persistent-mode state leak, not a real UAF.
// In your harness:
while (__AFL_LOOP(1)) { // <-- change N to 1 for diagnosis
reset_parser_state(); // ensure this actually resets ALL state
parse_input(buf, len);
}
// If the crash goes away with N=1, your reset_parser_state() is incomplete.
// Add logging inside reset_parser_state() to enumerate what it resets,
// then audit the parser source for global/static variables it misses.The diagnostic is deterministic: if the crash disappears when you set __AFL_LOOP(1) (one iteration per process lifetime, equivalent to fork mode), it is a state leak between iterations. The resolution is to find and fix the incomplete state reset, not to file a bug against the target.
The Triage Workflow
A disciplined triage workflow handles false positives without burning time on dead ends:
# Step 1: minimise the crashing input with libFuzzer's minimiser
# This reduces the input to the smallest form that still triggers the same crash.
./fuzz_target -minimize_crash=1 -runs=10000 crash_input.bin
# With AFL++
afl-tmin -i crash_input.bin -o crash_minimised.bin -- ./my_parser @@
# Step 2: reproduce the minimised crash
./my_parser crash_minimised.bin
# Step 3: reproduce under gdb to see the exact fault
gdb -ex "run" -ex "bt full" -ex "info registers" \
--args ./my_parser_debug crash_minimised.bin
# Step 4: build without sanitizers and re-run
# If it crashes here too, it is a real crash (segfault, assertion, abort).
# If it doesn't crash here, the sanitizer is detecting a real memory error
# that just doesn't manifest as a visible crash without instrumentation.
./my_parser_nosanit crash_minimised.bin- Step 1: Minimise. A crash that reproduces with a 3-byte input is almost always a real bug. A crash that only reproduces with a 50,000-byte input after specific iteration history is suspicious. Reproducer minimization is the first gate.
- Step 2: Isolate the sanitizer. Run each sanitizer individually in separate builds (
-fsanitize=addressonly, then-fsanitize=undefinedonly). A crash that only appears when both are combined often indicates a sanitizer interaction rather than a target bug. - Step 3: Run without sanitizers. Build the target with full debug symbols and no sanitizers (
-g -O0). Run the minimized input. If the target crashes with a real SIGSEGV or assertion failure, it is a real bug — the sanitizer was detecting a genuine memory error, not a false positive. If it exits cleanly, the sanitizer is catching something that only matters under instrumentation. - Step 4: GDB with the debug build. Run under GDB to get the exact fault address, the CPU registers at the point of crash, and the full call chain. Compare the GDB backtrace to the ASan report. If they agree on the fault location, the crash is real. If GDB shows no crash but ASan does, the sanitizer is more sensitive than the hardware — which usually means a genuine memory error that doesn't cause a visible crash without instrumentation (e.g., a heap overflow into valid memory).
- Step 5: Diagnostic ASan options. Use
ASAN_OPTIONS=halt_on_error=0to see all violations in a single run,log_pathto capture reports to files, and__sanitizer_set_death_callbackto dump target state at the moment of abort.
# Diagnostic run: disable halt_on_error to see ALL sanitizer reports
# in a single execution rather than stopping at the first one.
ASAN_OPTIONS=halt_on_error=0:log_path=/tmp/asan_log ./my_parser crash_input.bin
# Check all reports generated
cat /tmp/asan_log.*
# Run without quarantine to rule out quarantine-related false positives
ASAN_OPTIONS=quarantine_size_mb=0:halt_on_error=1 ./my_parser crash_input.bin
# Run with interceptors disabled to rule out interceptor conflicts
ASAN_OPTIONS=replace_str=0:replace_intrin=0:halt_on_error=1 ./my_parser crash_input.bin
# Check if the crash reproduces without any sanitizer (raw crash = real bug)
./my_parser_nosanit crash_input.bin
echo "exit code: $?"// __sanitizer_set_death_callback is declared in sanitizer/common_interface_defs.h
// (it is part of the sanitizer common runtime, not ASan-specific).
#include <sanitizer/common_interface_defs.h>
#include <stdio.h>
// Register a callback that runs just before the sanitizer terminates the process.
// Useful for logging additional context (e.g., parser internal state)
// when diagnosing whether a crash is a false positive.
static void asan_death_callback(void) {
fprintf(stderr, "=== sanitizer death callback: parser state ===\n");
// Dump any internal parser state that would help distinguish
// a false positive from a real bug. Keep this work minimal and
// signal-safe — the process is on its way to abort().
parser_dump_state(stderr);
fprintf(stderr, "==============================================\n");
}
int main(int argc, char **argv) {
__sanitizer_set_death_callback(asan_death_callback);
// ... rest of your program
return 0;
}
// Compile the debug build with:
// clang -g -O0 -fsanitize=address -o my_parser_debug my_parser.c
// The callback fires after the sanitizer prints its report but before abort().ASan Suppressions and When to Use Them
ASan supports a suppression file that instructs the sanitizer to silently ignore specific errors by function name, source file, or source location. Suppressions are a last resort — before reaching for them, verify you cannot fix the root cause. However, there are legitimate cases: a known issue in a third-party dependency you cannot modify, a benign leak in a one-shot initialization path that runs before any per-iteration reset, or an interceptor conflict with a closed-source library that is not the target of your fuzzing campaign.
A suppression file looks like:
leak:some_third_party_init_function— suppress memory leak reports originating from this functioninterceptor_via_fun:third_party_custom_malloc— suppress interceptor conflicts for a custom allocatornew_delete_type_mismatch:known_safe_type_punning_path— suppress new/delete mismatch for known-safe patterns
Enable the suppression file with ASAN_OPTIONS=suppressions=/path/to/asan.supp. Every suppression you add is a potential blind spot — document why each suppression exists and review the list every time you update the target. A suppression that was correct for one version of a library may hide a real bug introduced in a later version.
For UBSan, the suppression mechanism is per-function via a suppress file loaded with UBSAN_OPTIONS=suppressions=, or per-site in source code using __attribute__((no_sanitize("signed-integer-overflow"))). Prefer the source-code annotation over the suppression file for intentional UB in the target codebase — it documents the intentionality at the code site and survives refactoring.
Tracking False Positives as a Team Practice
In a sustained fuzzing campaign on a non-trivial target, false positives will recur. The same allocator interaction or the same persistent-mode state leak will be re-discovered by the fuzzer after each corpus update. Without a systematic approach to tracking known false positives, the team wastes time re-triaging the same non-bugs.
Practical disciplines that help:
- Maintain a false positive register. When you discard a crash after triage, document it: the crash hash, the reproduction steps, the diagnosis, and the reason for discard. The hash is most useful — platforms like Fuzze.rs generate a content-hash fingerprint for each crash. If the same hash reappears in a later campaign run, it is immediately recognizable as a known non-bug.
- Add discriminating invariants to the harness. If a class of false positive is caused by a persistent-mode interaction between iterations N and N+1 involving a specific parser path, add an assertion at the end of each iteration that verifies the global state is fully reset. The assertion will fire during the campaign if the reset is incomplete, catching the root cause rather than the symptom.
- Separate ASan builds from production builds. False positives from ASan interact with sanitizer-specific behavior (quarantine, redzones, interceptors). Always have a non-sanitized debug build available for confirming real crashes. The confirmation step — reproduce in the non-ASan build — is the fastest triage step and should never be skipped.
- Track the false positive rate. If your campaign is generating 20 crash reports per day and 18 of them are false positives, the cost of triage is dominating the value of the campaign. Fix the root cause: improve the harness reset, fix the allocator interaction, add suppressions for known non-bugs, or switch from persistent mode to fork mode for that target. A campaign that generates reliable, accurate crash reports is worth far more than one that generates volume.
When to File vs When to Discard
File the bug when:
- The minimized input reproduces the crash in a non-ASan build (SIGSEGV, assertion failure, or abort).
- The ASan report points to target code (frame
#0in a source file you own), not to sanitizer internals or libc interceptors. - The crash reproduces with
__AFL_LOOP(1)(rules out state-leak false positives). - The crash is consistent across multiple runs with the same input (rules out timing/race conditions if the target is single-threaded).
Discard the report when:
- The crash only appears under ASan and the non-ASan build exits cleanly — and the ASan report frames point to interceptors or allocator internals rather than target code.
- The crash disappears with
__AFL_LOOP(1)and the reset function is confirmed incomplete. - The process aborts with
ENOMEMfrom the ASan allocator under memory pressure — this is a resource constraint, not a bug. - The crash is a UBSan report for intentional behavior that is documented in the codebase (e.g., signed overflow in a two's-complement arithmetic library that is correctly annotated but the annotation was missed in the build flags).
When in doubt, minimise, document the reproduction steps, and file a low-severity issue with the full ASan report and the triage notes. A real bug that gets incorrectly discarded is worse than a false positive that gets filed with caveats. The cost of a thorough write-up is one hour; the cost of a missed memory safety bug in a security-critical parser is a CVE and a patch under pressure.
The triage skills described here get faster with practice. After triaging a hundred crash reports on the same target, the distinguishing characteristics of each false positive category become pattern-recognizable in seconds. The investment is worth making early in a campaign — a team that understands its false positive landscape can respond to new genuine crashes quickly and confidently, without the uncertainty of "is this real?" slowing down the response.