IJON·LLM
▸ FUZZING · IJON · AUTONOMOUS ANALYST

IJON Reloaded

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.

41×
libpng state-space
chunk-sequence diversity vs plain AFL
30.9×
autonomy closure
loop reaches the rich annotation itself
11.5×
vTPM, 12h real-target
command sequences vs plain
1
LLM call / decision
diagnose · annotate — rest is code

A research log + reproducible evaluation. Built on IJON (Aschermann et al., S&P 2020) and AFL++.

01 · THE WALL

Edge coverage is blind to state.

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.

02 · THE LOOP

The agent supplies the feedback function. AFL does the search.

A deterministic Python loop runs the campaign; the LLM (DeepSeek V4 Pro) is invoked at the two genuine judgment points onlyclassify 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.

01
Fuzz
Run AFL++ until it plateaus (flat coverage, nothing favoured left).
02
Localize
Static call-graph × live coverage → the frontier the fuzzer is stuck at.
03
Diagnose
LLM: which of IJON's 3 failure classes, and the exact annotation + placement.
04
Patch & build
Insert the one-liner, rebuild with AFL_LLVM_IJON.
05
Keep / revert
Real source-coverage delta decides: kept if it helped, reverted + fed back if not.

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".

Localization — how the agent sees ~10 functions, not 94k LOC

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.

STATICfuzz-introspector call graph — who-calls-whom · reachable set · complexity
×
RUNTIMEllvm-cov coverage — which functions actually executed
FRONTIERcovered caller → reachable-but-uncovered callee, ranked by the unexplored complexity it gates (+ the shared gate the blocked handlers commonly call — the check, not just the hottest loop)
PROMPT~10 focused functions to the agent

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.

▸ v2.0 · ROADMAP — TWO DEEPER LOCALIZATION AXES

(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.

03 · THE TAXONOMY

Three ways a fuzzer gets stuck — and the agent diagnoses all three.

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).

CLASS 01

Known relevant state values

IJON_SETIJON_MAX

✔ 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.

CLASS 02

Known state changes

IJON_STATE

✔ 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.

CLASS 03

Missing intermediate state

IJON_CMP

✔ auto-solves. Checksums & magic gates → IJON_CMP(a,b) turns a cliff into a gradient. Cracks 32-bit checks; breaks a real libpng frontier.

04 · IT PLAYS THE GAME

The agent reproduces the paper's iconic demo — blind.

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):

AFL+IJON playing Super Mario Bros, driven by the agent's IJON_MAX(world_pos) annotation; the world_pos counter climbs as Mario advances
AFL+IJON · liveannotation: IJON_MAX(world_pos) — proposed blind
THE FUZZER IS PLAYING. NO ROM USED — SYNTHETIC TILES, REAL GAMEPLAY.

The evidence base

Every result reproducible, and reported honestly — wins and negatives:

ExperimentWhat the agent didResultVerdict
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 — convergencechunk-sequence state log, measured over wall-clock.41× distinct chunk-sequences vs plain AFL (which saturates).win
libpng — autonomyloop 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.

05 · THE UNIFYING FINDING

When does IJON actually help?

IJON helps when the state is invisible to coverage.

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.

06 · NO SLOP, NO SPIN

The honest negatives — and the integrity gate.

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.

Mario: demo, not a measurement

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.

libpng bug-hunt: clean miss

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.

libtpms: 12h, zero crashes

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.

Sequence-class crash auto-solve: the 1-D wall

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.

The fairness gate

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.)