Safeguard
Application Security

Fuzz testing for AppSec teams: AFL++, libFuzzer, and OSS-Fuzz

OSS-Fuzz has found over 13,000 vulnerabilities since 2016, yet most AppSec teams still treat fuzzing as a research curiosity rather than a pipeline stage.

Safeguard Research Team
Research
7 min read

Google's OSS-Fuzz program has helped find and fix more than 13,000 vulnerabilities and over 50,000 total bugs across more than 1,000 open-source projects since it launched in 2016, according to the project's own published statistics. That scale is easy to read as a Google-only story, but the underlying technique — coverage-guided, mutation-based fuzzing via tools like AFL++ and libFuzzer — is available to any team willing to write a harness. Most AppSec programs never do. Static analysis and SCA scanning tell you what a dependency looks like on disk; fuzzing tells you what it actually does when you hand it fifty thousand slightly-wrong inputs an hour. The gap between those two views is where memory-corruption bugs live, and it is not a small gap: in November 2024, Google's LLM-assisted fuzzing effort (oss-fuzz-gen) found an out-of-bounds read/write in OpenSSL — CVE-2024-9143 — that had evaded conventional fuzzing for roughly two decades in one of the most heavily audited C codebases in existence. This post is a practical starting point: what coverage-guided fuzzing is, how AFL++, libFuzzer, and OSS-Fuzz fit together, and how to read a crash report once your fuzzer hands you one.

What makes coverage-guided fuzzing different from other testing?

Coverage-guided fuzzing differs from unit testing and manual QA because it generates its own inputs and uses code coverage as a fitness signal, rather than relying on a human to predict which inputs matter. A fuzzer starts with a small corpus of valid inputs, mutates them — flipping bits, splicing two inputs together, inserting boundary values — and instruments the target binary so it can tell whether a given mutation reached new code paths. Mutations that expand coverage are kept and mutated further; mutations that don't are usually discarded. Over millions of executions per hour, this evolutionary loop finds edge cases no test author would think to write by hand: malformed length prefixes, integer overflows in size calculations, recursive structures that blow the stack. Crucially, fuzzing is a dynamic technique — it exercises real, running code — so it complements static analysis rather than replacing it. A static analyzer can flag that a function looks unsafe; a fuzzer can prove it, by crashing it.

How do AFL++ and libFuzzer actually work?

AFL++ (a maintained successor to the original American Fuzzy Lop) and libFuzzer are both coverage-guided, mutation-based fuzzers, but they integrate differently. AFL++ typically fuzzes a compiled binary out-of-process, feeding it files or stdin input and using compile-time or binary instrumentation to track which basic blocks executed; it forks a fresh process per test case, which is slower but works against binaries you can't easily modify. libFuzzer runs in-process: you write a harness function with the signature LLVMFuzzerTestOneInput(const uint8_t *data, size_t size), link it directly against the library code you want to test, and libFuzzer calls that function millions of times in a tight loop without process-restart overhead, making it dramatically faster for library-level testing. Both are typically paired with Clang's sanitizers — AddressSanitizer (ASan) for memory errors, UndefinedBehaviorSanitizer (UBSan) for undefined behavior, and MemorySanitizer (MSan) for uninitialized reads — because a sanitizer converts a silent memory-safety bug into an immediate, loud crash with a stack trace, rather than corruption that surfaces somewhere else entirely.

What does OSS-Fuzz add on top of running a fuzzer yourself?

OSS-Fuzz adds continuous, at-scale infrastructure so a project's fuzz targets keep running against every new commit instead of a single local session. Once a project integrates by contributing build scripts and fuzz harnesses, OSS-Fuzz builds it with ASan/UBSan/MSan variants, runs AFL++ and libFuzzer-based engines continuously, automatically files an issue when a target crashes, and enforces a 90-day public disclosure deadline if the maintainer doesn't fix it. That continuous-integration model is what produced the 13,000-plus vulnerability count: bugs get caught the moment new code introduces them, not months later during an ad hoc audit. In 2024, Google extended the program with oss-fuzz-gen, which uses LLMs to write new fuzz harnesses for functions that had no coverage at all — expanding meaningful coverage across 272 C/C++ projects by more than 370,000 lines of previously-untested code and surfacing 26 new vulnerabilities that hand-written harnesses had missed, per Google's Security Blog writeup.

Why did it take 20 years to fuzz a bug out of OpenSSL?

CVE-2024-9143 is the sharpest illustration of a lesson every fuzzing program eventually learns the hard way: raw CPU-time does not substitute for harness quality. OpenSSL is one of the most fuzzed, most audited open-source codebases in existence, yet the out-of-bounds read/write that became CVE-2024-9143 sat undiscovered for roughly two decades of conventional fuzzing before an LLM-generated harness reached the specific code path and input shape needed to trigger it. The lesson generalizes: a fuzzer only ever explores what its harness exposes and what its seed corpus can mutate toward. A harness that only calls the top-level API of a library will never reach deeply nested parsing logic that's guarded by several layers of validated state. Teams adopting fuzzing should budget real engineering time for harness design and seed-corpus curation — not just point a fuzzer at a binary and assume coverage will follow.

How would a fuzzer have caught something like Heartbleed?

Heartbleed (CVE-2014-0160), the 2014 OpenSSL heartbeat-extension bug that leaked server memory, was discovered independently by two parties in early April 2014: a Google engineer via code audit, and a separate research team using a fuzz-testing tool of their own — so fuzzing did play a role in the original discovery, just not the well-known AFL/libFuzzer tooling this post focuses on. But in April 2015, researcher Hanno Böck published a retrospective demonstration on his own blog showing that AFL, paired with AddressSanitizer and a harness built specifically around the TLS handshake, could find Heartbleed on its own within about six hours — a proof-of-concept after the fact, not the original discovery path. It's since become one of the most-cited case studies for why parsing and deserialization code, specifically, deserves dedicated fuzz targets: heartbeat parsing took attacker-controlled length fields and used them to read past a buffer boundary, which is exactly the class of bug coverage-guided mutation is built to surface once a harness feeds it length-prefixed, adversarial-shaped input.

How do you triage a crash once a fuzzer produces one?

Triage starts with reproducing the crash deterministically, then reading the sanitizer report before touching the stack trace, because ASan and UBSan output tells you the bug class directly: "heap-buffer-overflow READ of size 4" is a different fix and different severity than "use-after-free WRITE." Next, minimize the crashing input — both AFL++ (afl-tmin) and libFuzzer ( -minimize_crash=1 ) can shrink a multi-kilobyte crashing file down to the handful of bytes actually required, which makes root-causing far faster. Then deduplicate: large fuzzing runs commonly produce dozens of crashes that stack-hash to the same underlying bug, so triage tooling should cluster by top-frame-plus-allocation-site before a human looks at anything. Finally, assess reachability and exploitability the same way you would for a static-analysis finding — a heap overflow in a rarely-called debug-only code path is a lower priority than one sitting on the default parsing path for untrusted network input. Whatever CVE or internal ticket comes out the other end of triage still needs to land in the same vulnerability-management pipeline that tracks every other dependency finding, so it gets versioned, patched, and rescanned like any other tracked CVE.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.