From one-off fuzzing to continuous coverage

Previous attempts to harden Exiv2 relied on a single, finite fuzzing campaign with AFL. That approach uncovered a steady stream of bugs and bought roughly a year of quiet, but it was never designed to be sustainable. When new vulnerability reports started arriving again, it was clear that a one-time exercise wasn't enough—the project needed a permanent fuzzing pipeline. That meant enrolling in Google's OSS-Fuzz.

Building a libFuzzer target that exercises more than the default path

AFL fuzzing is comparatively simple: compile the target with afl-clang and point AFL at it. OSS-Fuzz requires a libFuzzer target, which means modifying both source code and the build system. The extra effort pays off in coverage. The earlier AFL campaign only exercised Exiv2's default configuration, leaving the many non-default command-line options untested—and most of the bugs reported this year lived in those code paths. The new libFuzzer target is designed to reach everything those options control.

The core difference from AFL is that libFuzzer supplies its own main function. Instead, you provide an entry point:

extern "C" int LLVMFuzzerTestOneInput(const uint8_t * data, size_t size) {
    // Run some tests with the data
    ...

    return 0;
}

The entry point receives a byte array treated as an image file, which the target attempts to read, print, and modify metadata for. One common libFuzzer technique is to reserve the first bytes of the buffer as a header encoding test options as a bitmap. Exiv2's target doesn't do that—it simply runs all the different processing modes on every input to maximize coverage per test case.

When building a libFuzzer target, the -fsanitize=fuzzer compiler and linker flag adds the main wrapper. That flag must not be applied to libraries themselves, as it causes linker errors; libraries need -fsanitize=fuzzer-no-link instead. The initial fuzz target was added in pull request #1773 and found numerous bugs quickly, though further adjustments were needed for OSS-Fuzz compatibility.

The OSS-Fuzz enrollment gotchas

Enrolling in OSS-Fuzz follows a simple pattern: open a pull request against google/oss-fuzz adding a project directory with config files describing how to build the fuzz targets. The Exiv2 enrollment, in pull request #6186, took several attempts to get right.

Two mistakes stood out. First, do not use the -fsanitize=fuzzer linker option when building for OSS-Fuzz. Instead, OSS-Fuzz provides an environment variable that must be included on the linker command line. Second, the build script should not add sanitizer flags itself—OSS-Fuzz controls those and will set its own.

The practical lesson from debugging this: test the configuration by creating a pull request against the main branch of your own fork of google/oss-fuzz. Your fork runs the same workflows as Google's repository, so you can iterate on failures before submitting anything upstream.

Running the fuzz target privately before enrolling is also wise, to avoid being overwhelmed by a flood of newly discovered issues. Even after weeks of private fuzzing on a rented cloud server, OSS-Fuzz has still found new problems—partly because it runs multiple fuzzing engines with different sanitizer configurations than a single local libFuzzer setup.

Corpus and dictionary: what makes the fuzzer effective

A good corpus is essential. Exiv2's initial corpus comes from the several hundred image files in its test/data subdirectory. Regression tests added when fixing bugs also enrich this corpus over time, which is one reason they matter for more than just preventing regressions.

A dictionary of interesting strings can further improve results. For example, one reported bug involved hitting code that requires the input to contain the literal string “type=”:

if (buf.length() > 5 && buf.substr(0, 5) == "type=") {
    std::string::size_type pos = buf.find_first_of(' ');
    type = buf.substr(5, pos-5);
    // Strip quotes (so you can also specify the type without quotes)
    if (type[0] == '"') type = type.substr(1);  <===== out-of-bounds array access
    if (type[type.length()-1] == '"') type = type.substr(0, type.length()-1);
    b.clear();
    if (pos != std::string::npos) b = buf.substr(pos+1);
}

Adding “type=” to Exiv2's fuzzing dictionary is what made that bug reachable. The dictionary itself was generated with a simple CodeQL query that locates literal strings passed to comparison functions like strcmp or startsWith:

import cpp
import semmle.code.cpp.dataflow.DataFlow

predicate parser_string(string s, StringLiteral l) {
  s = l.getValue() and
  exists(FunctionCall call, string fcnName |
    DataFlow::localExprFlow(l, call.getAChild+()) and
    fcnName = call.getTarget().getName()
  |
    fcnName.matches("%cmp%") or
    fcnName.matches("%find%") or
    fcnName = "startsWith" or
    fcnName = "operator==" or
    fcnName = "operator!="
  )
}

from string s
where parser_string(s, _)
select s

The query isn't run automatically—the dictionary doesn't change often—so the generated dictionary file is simply checked in.

A lasting shift in bug-finding

The move to OSS-Fuzz was substantial work, but it transformed how Exiv2 is tested. The project is now fuzzed continuously with more thorough coverage than any one-off campaign achieved, and the steady stream of bugs that surfaced during enrollment has largely been resolved. Ongoing fuzzing makes it substantially harder for new defects to go unnoticed.