Using an LLM to usher fuzzers through the maze.
Coverage-guided fuzzers go blind when interesting behaviour lives in data values, not in which edges run. IJON fixes that with a one-line human annotation that reshapes the feedback function. We replace that human with an autonomous LLM agent: it reads the fuzzer's telemetry, diagnoses why AFL is stuck, and writes the annotation — across mazes, Super Mario, libpng and a real vTPM.
A research log + reproducible evaluation. Built on IJON (Aschermann et al., S&P 2020) and AFL++.
AFL keeps an input when it lights up a new edge. But solve a maze, play a level, or match a checksum and the same edges run regardless of how close you are to the goal — moving the player, or getting the bytes "warmer", produces no new feedback. The fuzzer has no gradient to climb, and the campaign stalls forever.
IJON's insight: let an analyst expose the hidden state to the fuzzer with a tiny source annotation — IJON_SET(hash(x,y)) on the player's position, IJON_MAX(progress), IJON_CMP(a,b). With one line, AFL solves problems that stump conventional coverage-guided fuzzers.
The bottleneck is the human. Someone must read the code, read the fuzzer's progress, diagnose why it's stuck, pick the right primitive, and place it correctly. The IJON authors flag automating this as future work — and note they "typically had very limited understanding of the target." That's the gap we close.
A deterministic Python loop runs the campaign; the LLM (DeepSeek V4 Pro) is invoked at the two genuine judgment points only — classify why-stuck and synthesize the annotation. Everything mechanical — running AFL, detecting the plateau, patching source, rebuilding, measuring real coverage, keep/revert — is code. The agent is one reasoning call on pre-localized source; it never explores the codebase or generates inputs. Its only lever is the reward. Because that judgment call is the only thing the model does, the analyst is pluggable: the same loop runs headless on the DeepSeek API, or with Claude Code itself as the analyst inside the editor (no key) — and scales to a multi-hour campaign that re-annotates on each stall to hunt real crashes.
AFL_LLVM_IJON.Annotations accumulate; failed attempts are fed back so the model doesn't repeat them. Keep/revert is driven by source coverage, never raw edges_found — because IJON map entries inflate the edge count and would otherwise fake "progress".
The agent never gets the whole codebase. A deterministic step joins the static call graph (fuzz-introspector) with runtime coverage (llvm-cov) to find the frontier — and slices just those functions into the prompt. So coverage does double duty: it drives keep/revert and it's what makes the analyst tractable on a real library.
On libpng this surfaced the uncovered chunk handlers (png_handle_iCCP/…) and their shared CRC gate; on the 94k-LOC libtpms it surfaced ExecuteCommand and the session/auth gate — the right handful, mechanically.
(a) Branch-level localization (dataflow to a gating branch). v1 (this release) finds the missing edge A→B but not why — when the blocker lives inside a covered intermediate function (X(); Y(); if (Z()==ok) B();) it shows the model if (Z()==ok) and stops, because X/Y/Z ran. v2 will go from which functions ran to which branch didn't: keep llvm-cov's per-branch coverage + a backward slice from the gating condition, so the agent can pinpoint a blocker buried 2–3 covered functions deep — the X·Y·Z case.
(b) Sequence-loop / state-site localization. v1's localizer is coverage-frontier oriented — it finds uncovered code to reach (great for breadth — reaching new code). But sequence depth — exploring the orderings of operations — lives in covered code: the dispatch loop where state accumulates. The frontier never surfaces it, so the sequence annotation falls back to the nearest visible loop — often the harness, where only raw bytes are in scope. On libtpms that's exactly the split: the reach-new-code annotation landed semantically in the library (IJON_SET(command.index)), but the sequence annotation became a raw-byte hash in the harness. v2 adds a sequence localizer to find the covered loop and the state variable carried across iterations (e.g. command->index), placing the sequence annotation at the semantic library site. We wanted (b) in v1.0; both deferred so v1 ships first.
IJON frames three failure classes. Given only the stripped source + plateau telemetry, the agent classifies the roadblock and emits the matching primitive — verified blind (it never sees the answer; see integrity).
✔ auto-solves. Maze → IJON_SET(hash(x,y)), solved in 8s. maxclimb → IJON_MAX(score), where plain AFL does 0 of 16.8M execs — solved in <1s.
✔ evidenced on real targets. Expose the sequence of events as state. libpng chunk sequences, a vTPM command machine — explored many× more thoroughly than plain AFL.
✔ auto-solves. Checksums & magic gates → IJON_CMP(a,b) turns a cliff into a gradient. Cracks 32-bit checks; breaks a real libpng frontier.
IJON's flagship: AFL "plays" Super Mario via ijon_max(pos_y/16, world_pos) on the screen position. We strip that annotation and hand the agent the bare game loop. It proposes IJON_MAX(world_pos) on its own — right primitive, right state. Here is a fuzzer-found playthrough driven by the agent's annotation (rendered ROM-free with a synthetic tile set; the live world_pos counter is the reward it maximizes):
Every result reproducible, and reported honestly — wins and negatives:
| Experiment | What the agent did | Result | Verdict |
|---|---|---|---|
| Human vs. LLM on IJON's own benchmarks | Strip the authors' annotation; agent re-derives it blind. | maze: exact match to the human's annotation. | match |
| libpng — convergence | chunk-sequence state log, measured over wall-clock. | 41× distinct chunk-sequences vs plain AFL (which saturates). | win |
| libpng — autonomy | loop with a state-diversity reward, no answer shown. | climbs to IJON_STATE(hash(mode,chunk_name)) itself — 30.9×. | closed |
| Super Mario demo, not a benchmark | reproduce IJON_MAX(world_pos) blind; drive a playthrough. | annotation reproduced blind; the agent's annotation plays the level (GIF above). | blind match |
| libtpms (vTPM) 12h, real target | two autonomous annotations (sequence × command-type). | 11.5×* command sequences; 7× command types; 0 bugs. | no bug · big Δ |
On libtpms the gap widened with time — plain AFL flat-lined at ~820 distinct command sequences (8h and 12h identical) while IJON climbed from 4,950 to 9,461. State-guided exploration keeps pulling ahead; it isn't a one-shot boost. No latent bug fell out — expected on a target OSS-Fuzz hammers continuously.
* Conservative — known headroom. The reach-new-code annotation landed semantically in the library (IJON_SET(command.index) in ExecCommand.c), but the sequence annotation fell back to a raw-byte prefix hash in the harness rather than the command-index sequence at the library's dispatch loop — v1's localizer is coverage-frontier oriented and has no sequence-loop localization yet (see the v2.0 roadmap). A sharper semantic sequence site should only raise this gap.
And the gain scales with how invisible it is. The two real targets trace the gradient: libpng's chunk sequences run on shared code (nearly invisible → 41×), while libtpms's command type partly leaks into coverage through per-command handlers (partly visible → 11.5×). Where the state is genuinely hidden — chunk sequences, a maximization score, command orderings — IJON wins, and the win compounds with time.
So the analyst's real skill isn't picking the primitive — it's choosing a state that edge coverage doesn't already capture. The autonomous loop, rewarded on state-diversity, learns to do exactly that.
Credibility is part of the result. We report what didn't work as plainly as what did, and we built guardrails so the agent can't cheat.
We don't report a Mario effectiveness A/B. Its world_pos partly leaks into coverage (so it's a poor place to measure IJON), and our control build didn't cleanly exclude the annotation — so we withdrew the comparison rather than report an unreliable tie. The blind annotation + playthrough stand as a demo.
Recent libpng CVEs are format-gated (16-bit interlaced, palette+gamma), not CRC-gated. Coverage data refuted our CRC hypothesis. IJON-on-CRC was the wrong tool — honestly recorded.
No new bug on an OSS-Fuzz-hardened target — the expected long shot. The reliable win is the deployment + the 11.5× state expansion, not a CVE.
No 1-D synthetic target both defeats plain AFL and is IJON-climbable. The paper measures this class by sequence diversity, not crashes — which is what we demonstrate.
Each benchmark ships the ground-truth annotation behind #ifdef. Before the agent ever sees the source we strip the annotation, redact every line mentioning ijon (leaky comments, prototypes), and hard-assert no ijon token survives. The model knows the IJON API and taxonomy legitimately, from its own prompt — it is never handed the specific answer. (Our first checksum run "passed" only because a comment named the fix; we caught it and re-ran.)