TL;DR: Finding a crashing input is the easy part. The work that makes a vulnerability report useful is proving that the crash belongs to the target, reducing it to one root cause, checking that it is new, choosing the right disclosure channel, and verifying the maintainer’s actual fix. This is the workflow I settled on while reviewing C and C++ parsers with AFL++, AddressSanitizer, static analysis, and small purpose-built harnesses.
The examples here are already public: the dr_flac SEEKTABLE over-read, libmysofa dataspace-v2 denial of service, and libConfuse empty-comment null dereference. Private and unresolved reports are deliberately excluded.
A crash is a lead, not a finding
AFL++ is very good at producing unusual inputs. It is not qualified to decide whether those inputs demonstrate a vulnerability.
Across several parser campaigns, saved crashes have turned out to be:
- assertions intentionally enabled in development builds;
- UndefinedBehaviorSanitizer diagnostics with no memory-safety consequence;
- faults caused by an undersized output buffer in my own harness;
- multiple heap layouts exposing the same root cause;
- duplicates of issues another researcher had already reported; and
- genuine, reachable defects worth coordinating with maintainers.
Those categories can look identical in an AFL output directory. Treating every
id:* file as a new security issue produces bad reports and wastes maintainer
time. My rule is simple: a saved input does not become a finding until it
reproduces on a fresh, non-AFL sanitizer build and I can explain the faulty
source-level invariant.
Choose targets before choosing tools
The highest-leverage decision happens before fuzzing starts. I look for code that:
- parses attacker-controlled files or protocol messages;
- is written in memory-unsafe C or C++;
- has real downstream users;
- is not already receiving continuous OSS-Fuzz coverage; and
- does not have an active in-tree or external fuzzing campaign covering the same surface.
That last check matters. I once spent a campaign reproducing a dozen crashes in a media parser only to find that every stack matched recently filed public issues. The code was a reasonable security target, but it was a poor research target at that moment.
Maintenance health matters too. An untouched parser may contain bugs, but a responsive project is far more likely to review, fix, release, and credit a good report. The sweet spot is active code with a neglected parsing surface.
Harness the quiet path
The obvious API is rarely the only useful one. A decoder’s main path may have years of accidental production testing while metadata, indexing, reconstruction, and auxiliary-resource paths receive far less attention.
That distinction found the dr_flac issue. Ordinary audio decoding was clean.
The useful harness called the metadata API and consumed pRawData according to
its documented rawDataSize. The SEEKTABLE handler allocated one size and
advertised another, producing a deterministic heap over-read.
The same principle shaped later campaigns:
- libConfuse used separate schema and dynamic key/value parser harnesses;
- deeper sub-parsers received their own harnesses when static review showed the top-level API was not exercising enough of their state space;
- format-aware dictionaries and valid corpora were used to reach parser states that random bytes would almost never satisfy.
A harness must also be audited like production code. During one texture-parser campaign, an apparent heap overflow came from my harness using the wrong bytes per compressed block. Correcting the allocation made every seed clean. That was not a disappointing result; rejecting your own false positive is part of doing the work properly.
Make sanitizer output useful
I build targets statically with debug symbols, frame pointers, ASan, and UBSan. The fuzzing build and the clean reproduction build are separate. AFL instrumentation helps discovery, but the final evidence should not depend on it.
UBSan remains useful, but not every arithmetic warning is a security boundary failure. A NaN-to-integer conversion in an audio sample path and a signed overflow whose result is immediately clamped may deserve maintenance fixes, but they are not equivalent to an attacker-controlled out-of-bounds write.
For queue triage I group inputs by symbolized sanitizer stack, not filename or
signal number. Thirty-one libConfuse crash files reduced to one fault:
strdup(NULL) after the lexer emitted an empty comment token with a null value.
Reporting thirty-one bugs would have been absurd; reporting one root cause with
thirty-one confirming inputs was useful.
Minimize until the invariant is obvious
The best proof of concept is not merely small. It should make the violated assumption obvious.
For libConfuse, the final input was one byte:
#
With CFGF_COMMENTS enabled, the lexer completed a line comment without ever
initializing its string buffer. The parser then duplicated the missing token
value. The fix was correspondingly small: ensure the comment buffer contains an
empty string before returning the token.
For dr_flac, the PoC was a 77-byte FLAC containing a deliberately sized SEEKTABLE. The numbers told the story: a 35-byte on-disk block produced one 24-byte in-memory seekpoint, while the API still advertised 35 valid bytes. The consumer did exactly what the API documented and read eleven bytes beyond the allocation.
A clear invariant—“the advertised length must equal the allocation” or “the token value must never be null”—gives maintainers something they can review without first reverse-engineering the fuzzer.
Static review is most useful after the first bug
Broad static-analysis output is noisy. It becomes much more valuable once a confirmed bug reveals the project’s local coding habits.
After root-causing a finding, I search for siblings:
- additions used as bounds checks where subtraction would avoid overflow;
- multiplication performed before widening;
- assertions standing in for validation of untrusted values;
- length fields reused across packed and in-memory representations;
- empty-state assumptions around lazy buffers; and
- similar code written by the same author or introduced in the same change.
Cppcheck, Semgrep, Flawfinder, and targeted rg searches are useful here, but
their output is a reading list, not a vulnerability list. Every candidate still
has to survive reachability and runtime validation.
A recurring example illustrates the distinction. A direct internal call may produce an ASan finding for a state the public file API can never construct. That can justify a hardening patch, but it is not a file-reachable memory-safety disclosure until the same state is shown to arise from attacker-controlled input.
Check novelty before contacting anyone
Before packaging a report, I search:
- the project’s open and closed issues;
- pull requests and recent commits touching the affected function;
- published GitHub advisories and CVEs;
- OSS-Fuzz integration and existing fuzz targets; and
- reports from researchers currently working on the same project.
This step should be allowed to kill a finding. A duplicate is not made novel by using a different input, and a known issue does not become yours because you found it independently.
Match the disclosure channel to the project
There is no universal “security email first” rule. I follow the project’s documented route in this order:
SECURITY.md;- private GitHub vulnerability reporting;
- a published security address;
- the public issue tracker when the project explicitly directs bug reports there and the impact is proportionate; or
- a current maintainer privately when no documented channel exists.
The report itself should contain:
- exact affected component and commit;
- impact stated without inflation;
- minimal PoC and build/run commands;
- sanitizer stack;
- root cause with the relevant source;
- a small proposed fix; and
- evidence that the fix was tested.
The libConfuse null dereference was low severity and the project directed bug reports to its public tracker. The maintainer confirmed it, applied the patch, added regression tests for multiple comment forms, credited the report and fix, and scheduled it for version 3.4. That is successful coordinated maintenance; it does not need a dramatic severity claim or a forced CVE request.
Verify the maintainer’s commit, not your patch
“Fixed on master” is the start of verification, not the end.
I fetch the exact upstream commit, build it fresh, and replay:
- the original minimized PoC;
- every unique historical sanitizer stack;
- valid seeds that exercise the changed path; and
- nearby edge cases suggested by the patch.
This catches stale binaries and fixes that block one input without restoring the underlying invariant. It also catches regressions. For libmysofa, verification meant recovering the exact PoC from the disclosure thread, running it against the maintainer’s fix and subsequent hardening commit, and confirming that it returned an invalid-format error immediately with no hang or sanitizer finding.
Only after that do I mark a finding fixed and decide whether a public advisory or write-up is appropriate.
Keep the workflow human-operated
Automation is excellent for building, fuzzing, collecting stats, and replaying inputs. It should not silently decide that a candidate is a vulnerability, change disclosure state, email a maintainer, or publish an advisory.
For each finding I keep a small manifest recording the affected and verified commits, CWE, lifecycle state, disclosure channel, fix, follow-up date, and publication decision. The technical report remains the contract; the manifest prevents status notes, email, and the website from drifting apart.
That separation has become the core of the workflow:
discover -> reproduce -> deduplicate -> root-cause -> check novelty
-> package -> disclose -> verify upstream -> publish
Every arrow is a decision point. Skipping one usually creates work for somebody else.
What I would optimize next
More CPU is rarely the first answer. When coverage plateaus, the best moves are usually:
- add a valid seed that reaches a missing feature;
- introduce a format dictionary;
- split a deep sub-parser into its own harness;
- use static review to form a specific mutation hypothesis; or
- retire the campaign and choose a better target.
The goal is not the largest execution count. It is a short chain of evidence from attacker-controlled bytes to a violated invariant, followed by a fix that survives the same test.
That is the difference between collecting crashes and doing vulnerability research.