DEV Community

Finley Zhou
Finley Zhou

Posted on

The Free Model Wrote 87 C++ Fuzz Seeds. Only 6 Earned Coverage.

Model-generated fuzz seeds are not seeds until they earn coverage. In this C++ case study, a free model endpoint returned 87 candidate binary inputs for a packet parser. The coverage ledger kept 6. The other 81 were rejected before they reached the corpus, because the model could propose paths but could not run the fuzz target.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The workflow uses MonkeyCode's free model access to propose seeds and its free server option to run the fuzz target. It does not assume a specific model name, quota, or hardware limit.

Background

packet_decoder is a small C++17 binary protocol parser with a libFuzzer harness. The starting corpus had 14 hand-written seeds and 62% line coverage. Most uncovered code sat in checksum verification, length validation, and malformed-header paths.

The model endpoint was tempting because it could generate many edge-case inputs quickly. The mistake would have been treating those generated inputs as corpus entries by default. That would add volume without proof.

Goal

Keep the model's speed while making every addition prove itself. The acceptance test is simple: an input is a seed only if a clean run produces at least one new coverage edge. The model proposes; the runner decides.

Implementation

1. Ask the model for proposals, not explanations

The first request is deliberately narrow. It asks for hex-encoded binary inputs only, not severity guesses or root-cause paragraphs.

{
  "task": "propose_cpp_fuzz_seeds",
  "target": "packet_decoder",
  "language": "c++",
  "max_seeds": 10,
  "max_bytes_per_seed": 64,
  "existing_corpus_coverage": 62,
  "output_format": {
    "seeds": [
      {"data": "hex-string", "note": "short"}
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The response may be valid JSON and still contain unusable entries: odd-length hex, non-hex characters, oversized buffers, or notes that sound confident without adding value.

2. Normalize and discard structural noise

A small parser rejects candidates before they use compute time. The only accepted representation is lowercase, even-length hex with a length ceiling.

def parse_seed(obj):
    if not isinstance(obj, dict):
        return None
    data = obj.get("data")
    if not isinstance(data, str):
        return None
    compact = data.strip().lower()
    if not compact or any(c not in "0123456789abcdef" for c in compact):
        return None
    if len(compact) % 2 != 0:
        return None
    raw = bytes.fromhex(compact)
    if len(raw) > 64:
        return None
    return raw
Enter fullscreen mode Exit fullscreen mode

This step rejects malformed model output before it becomes a confusing fuzzing failure.

3. Run each candidate on a clean fuzz target

Each normalized input runs as a fresh libFuzzer invocation. The timeout avoids losing the whole batch to a slow seed.

#!/usr/bin/env bash
set -uo pipefail

TARGET="${1:?fuzz target required}"
SEED_FILE="${2:?seed file required}"
MAX_RUNS="${3:-200}"
MAX_LEN="${4:-64}"
TIMEOUT_SECONDS="${5:-30}"

timeout "${TIMEOUT_SECONDS}" \
  "${TARGET}" -runs="${MAX_RUNS}" -max_len="${MAX_LEN}" -print_final_stats=1 "${SEED_FILE}" \
  >"${SEED_FILE}.fuzz.log" 2>&1
Enter fullscreen mode Exit fullscreen mode

A crash is not the same as a seed. If ASan aborts, the input opens a bug report and does not enter the corpus.

4. Let the coverage ledger make the final call

The runner log contains coverage lines. The ledger looks for at least one new coverage edge, not for the absence of errors.

A representative log line looks like this:

#0 READ   units: 1
#1 NEW    cov: 192 ft: 47 corp: 1/14b lim: 64 exec/s: 0 rss: 37Mb L: 2/2 MS: 1 ChangeBit-
Enter fullscreen mode Exit fullscreen mode

The parser normalizes whitespace and rejects anything that did not produce a NEW marker.

import re

NEW_EDGE_RE = re.compile(r"^\s*#\d+\s+NEW\b", re.MULTILINE)

def found_new_coverage(log_text: str) -> bool:
    return bool(NEW_EDGE_RE.search(log_text))
Enter fullscreen mode Exit fullscreen mode

Decision table

Runner outcome Ledger action
Malformed hex or oversized input Reject before running
Fuzz target crashes under ASan Open bug, do not add to corpus
Clean run, no NEW line Reject seed
Clean run, at least one NEW line Add to corpus

Results

One recent sample returned 87 candidate seeds. The parser rejected 11 for malformed hex or excessive length. The runner executed 76. The ledger kept 6 because their logs contained at least one NEW line. Three runs crashed under ASan and became separate bug reports. One of the six accepted seeds later exposed a checksum branch that the original 14 seeds never touched.

Treat those numbers as a reproducible example, not a benchmark. The ratio will change with the fuzz target and the model prompt.

Lessons learned

  1. Split generation from judgment. The model generated 87 plausible inputs; the ledger supplied the only evidence that mattered.
  2. A crashing input is useful, but it is not a seed. Keeping it in the corpus can hide a real bug behind repeated crashes.
  3. Order can change results. A seed that shows no new edge in isolation may still be useful after another seed lands, so rejected candidates should remain in a cold pool instead of being deleted.
  4. Structural validation saves more time than prompt engineering. Rejecting malformed hex before launch prevents misleading failures.

Limitations and who should not use this

This workflow works best for stateless input parsers. It will not capture seeds that only show value in combination, and it can miss timing-dependent paths that do not appear in a short run.

Avoid this approach if your fuzz target is expensive to start, requires a live service, or needs licensed hardware. Teams that want a model to rank fuzz quality without coverage instrumentation will also find the ledger too strict. It intentionally treats every unmeasured proposal as noise.

If your C++ fuzz corpus has stalled, add the coverage ledger before adding another model call.

Top comments (0)