core-jmp core-jmpdeath of core jump

AI-Assisted Fuzzing: Generating libFuzzer Harnesses with a Local LLM

Writing the harness is the friction that keeps most codebases from ever being fuzzed. This walkthrough puts an offline open-weights model behind that job: it drafts a libFuzzer harness for a vulnerable C record parser, clang compiles it with AddressSanitizer, and the binary reports a genuine stack-buffer-overflow with a symbolized frame on the offending memcpy. From there it scales to what real targets demand — structure-aware harnesses that keep the framing valid, model-generated seed corpora and -dict= token dictionaries, a sanitizer matrix covering the bug classes ASan cannot see, a coverage feedback loop that hands uncovered branches back to the model, and continuous fuzzing in CI with corpus minimization and differential oracles. The fuzzer finds the bug, the sanitizer makes it loud, and every generated artifact gets reviewed before it is trusted.

oxfemale August 13, 2026 44 min read 92 reads
Export PDF
AI-Assisted Fuzzing: Generating libFuzzer Harnesses with a Local LLM
Original text: “AI-Assisted Fuzzing: Generating libFuzzer Harnesses with a Local LLM”8kSec Research Team, 8kSec (July 6, 2026). Code, tables and figures below are reproduced verbatim with attribution captions.

Executive Summary

Fuzzing is still the single most productive automated bug-finding technique available to a vulnerability researcher, and it is responsible for a large share of the memory-corruption CVEs reported in browsers, media parsers, network stacks and operating-system kernels. Yet a great many codebases are never fuzzed at all, and the reason is rarely a lack of CPU: it is the harness. Somebody has to sit down and write the small piece of glue that converts a blob of fuzzer-generated bytes into a meaningful call into the code under test, and that glue is per-target, repetitive and unrewarding. It is exactly the kind of narrowly-specified boilerplate that a modern language model produces well.

This walkthrough takes that idea end to end with an open-weights model running entirely offline through Ollama. The model drafts a libFuzzer harness for a small C record parser; the harness is compiled with AddressSanitizer; and the resulting binary reports a genuine stack-buffer-overflow, complete with a symbolized frame pointing at the offending memcpy. From there the material scales up to what real targets actually demand: structure-aware harnesses that carve the fuzzer’s entropy into valid framing, model-generated seed corpora and -dict= token dictionaries, a sanitizer matrix that covers bug classes ASan cannot see, a coverage feedback loop that hands uncovered branches back to the model, and continuous fuzzing in CI with corpus minimization and differential oracles. The division of labour is the point throughout: the fuzzer finds the bug, the sanitizer makes it loud, and the model removes the friction that stops the work from starting. Every generated artifact is reviewed before it is trusted.

Introduction

Fuzzing works because it is indifferent to a developer’s assumptions. It throws malformed, mutated and outright hostile input at a program for hours on end and watches for the moment the program misbehaves. Decades of CVEs in image decoders, font shapers, archive handlers, TLS stacks and kernel drivers exist because someone pointed a fuzzer at code that had only ever been tested with well-formed input. The technique is cheap, it parallelizes trivially, and it keeps finding bugs long after manual review has run out of patience.

The friction point sits right at the start. Before the fuzzer can do any of that, the target needs an entry point built for it — the harness — a function that receives an arbitrary byte buffer and turns it into a sensible call into the API under test. Writing one is not hard, but it is per-target toil: every library, every entry point, every format needs its own. Multiply that across a codebase with dozens of parsing entry points and the harness backlog is the reason a target sits unfuzzed.

That backlog is a good fit for a language model, because harness code is small, well specified and highly patterned. In what follows, a local open-source model served by Ollama generates a libFuzzer harness for a deliberately vulnerable C library; clang compiles it with AddressSanitizer; and the resulting binary reproduces a real stack-buffer-overflow on a machine you control. Everything runs offline, which matters when the source belongs to a client under NDA or the analysis happens inside an air-gapped VM.

The material is written to be followed by somebody who has never fuzzed anything, and to remain useful to somebody who fuzzes professionally. If terms like harness and sanitizer are already familiar, skim the primer and go straight to the lab.

📦 Download the lab: the original article ships ai-fuzzing-lab.zip, containing the vulnerable target, the LLM prompt, the generated harness and an ASAN driver. It runs on stock macOS or Linux with clang. For authorized testing and education only.

Primer: what fuzzing actually is

Four ideas carry the rest of the article. Readers who already have them can move on.

  • Fuzzing is automated testing that floods a program with malformed, random or mutated input and watches for crashes. A crash reached through attacker-controllable input is very often a security bug rather than a cosmetic one.
  • Coverage-guided fuzzing — the modern variety implemented by libFuzzer and AFL++ — is considerably smarter than random generation. The fuzzer instruments the target, notices when an input reaches a new code path, and retains that input as a seed for further mutation. Over time the corpus effectively learns the input format well enough to reach deep, rarely-executed code.
  • A harness is the entry point the fuzzer calls. For libFuzzer that is a single function, LLVMFuzzerTestOneInput(const uint8_t *data, size_t size), whose job is to convert the raw bytes the fuzzer produced into a call to the function actually under test.
  • A sanitizer is a compiler feature that makes bugs loud. AddressSanitizer instruments every memory access and aborts with a detailed report the instant the program reads or writes out of bounds, converting a silent and possibly exploitable corruption into an immediate, precise stack trace. Without one, a great many overflows do not crash at all and the fuzzer sails straight past them.

The reason harness-writing is the bottleneck is that none of it generalizes. Each library and each entry point needs its own bespoke glue, written once and then never thought about again. That repetitiveness is precisely what makes it a good candidate for automation by a model.

Where the LLM helps — and where it does not

The division of labour is worth stating plainly, because “AI finds bugs” is a claim that deserves scrutiny.

The fuzzer finds the bug. Coverage-guided mutation exploring millions of inputs is doing the discovery, and AddressSanitizer is doing the detection. Neither of those components is a model.

What the model removes is the friction that prevents people from fuzzing in the first place. It is good at:

  • Reading a function signature or a header file and producing a syntactically correct harness that calls it properly.
  • Handling structured input — splitting the fuzzer’s byte blob into the fields a function expects, such as a length, a type tag and a payload, so that far more inputs are “valid enough” to reach interesting code.
  • Proposing a seed corpus and a dictionary of magic bytes and keywords that get the fuzzer past if (memcmp(data, "FUZZ", 4))-style gates.
  • Scaling that across a large codebase, drafting a first-pass harness for dozens of entry points far faster than a human would work through them.

What it cannot do is guarantee the harness is correct or meaningful, and a subtly wrong harness is worse than none: it burns CPU-days and, more expensively, produces false confidence. The workflow is therefore generate, then review — the harness the model wrote still gets read by a human. Google’s OSS-Fuzz team published the same pattern in their OSS-Fuzz-Gen work: LLM-generated harnesses improved coverage across 272 C/C++ projects, adding more than 370,000 lines of newly-covered code and up to +29% line coverage over existing human-written harnesses, and surfaced real, previously-unreachable bugs on mature, heavily-fuzzed targets.

The target: a length-prefixed record parser

Parsers are the archetypal fuzzing target, because their entire job is to take untrusted bytes and make control-flow decisions based on their contents. The lab ships a very small one carrying a deliberate but entirely realistic defect:

/* target.c - parse [1 byte type][1 byte length][length bytes value] ... */
int parse_records(const uint8_t *data, size_t size) {
    size_t off = 0;
    int checksum = 0;
    while (off + 2 <= size) {
        uint8_t type = data[off];
        uint8_t len  = data[off + 1];
        char value[16];
        memcpy(value, data + off + 2, len);   /* BUG: len may exceed 16 */
        for (uint8_t i = 0; i < len; i++)      /* use `value` so it stays live */
            checksum += value[i] ^ type;
        off += 2 + len;
    }
    return checksum;
}

The bug is textbook, and common enough in shipping code to be depressing: an attacker-controlled length field, len, is passed straight to memcpy as the size of a copy into a fixed 16-byte stack buffer, with no bounds check anywhere. Since len is a full byte it can reach 255, so any record declaring a length above 16 smashes the stack. This is the exact shape of a long line of real CVEs in TLV parsers, image decoders and network protocol handlers.

A note on why the loop is there: the buffer is deliberately read back after the copy. If value were written but never used, an optimizing compiler would eliminate the memcpy as a dead store and the bug would simply evaporate at -O1. That is a genuinely useful lesson in its own right — compiler optimization can hide bugs from a naive harness, which is one reason to fuzz at the optimization level you actually ship.

Generating the harness with a local model

Now the AI part. Everything runs through Ollama with qwen3.6:35b-a3b, an open-weights code model, entirely offline. Why local? Keeping the target source on your own machine matters a great deal when it is a client’s proprietary code under NDA, and running offline means there is no per-token cost when you generate harnesses for hundreds of functions. It also works inside an air-gapped analysis VM, where a hosted API is simply not an option.

The prompt is deliberately narrow — the goal is the harness and nothing else, with no surrounding prose to strip out:

You are a fuzzing expert. Write a libFuzzer harness for the C function below.
Output ONLY C code: an LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
that forwards the input to parse_records. Include the extern declaration.
Keep it minimal. No explanation.

int parse_records(const uint8_t *data, size_t size);

The model returns exactly that:

Terminal: a local LLM (qwen3.6:35b-a3b) generates a libFuzzer harness, then clang + AddressSanitizer compiles it and the program crashes with a stack-buffer-overflow traced through LLVMFuzzerTestOneInput into parse_records
The full pipeline, start to finish. The local model generates a correct LLVMFuzzerTestOneInput that forwards the fuzzer’s bytes to parse_records; clang compiles it with AddressSanitizer; and running it on a 202-byte record with length = 200 produces a real stack-buffer-overflow report, traced through LLVMFuzzerTestOneInput (harness.c:7) into parse_records (target.c:16). Source: original article.

The generated harness is the minimal, correct thing:

#include <stdint.h>
#include <stddef.h>
extern int parse_records(const uint8_t *data, size_t size);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    parse_records(data, size);
    return 0;
}

For a target this simple, forwarding the bytes straight through is genuinely the right harness. The model earns its keep on structured input, where you want it to carve the first four bytes as a header, treat the next two as a length, and pass the remainder as a body. That is where the boilerplate multiplies, and where a human’s attention runs out across a large codebase.

Reviewing the generated harness (guarding against hallucination)

Before compiling anything the model wrote, read it against a short checklist. What separates a good harness from a hallucinated one comes down to a handful of invariants:

  • It calls the real API. The most common failure mode is an invented but plausible function, or the wrong signature — parse_records(buf, &len) when the target takes (data, size). This one is cheap to catch, because it fails to compile or link. Grep the header for the exact symbol before trusting it.
  • It is a pure function of the input bytes. No global state carried across calls, no dependence on the clock or a random seed, no network or filesystem I/O. libFuzzer calls the harness millions of times inside one process, so any leaked state or non-determinism poisons both reproducibility and the coverage signal.
  • The harness itself is memory-safe. It has to be stricter about lengths than the target is: if the harness over-reads the fuzzer’s buffer, ASan flags your glue instead of the bug you were hunting. Every index into data must be gated by size.
  • It actually reaches the target. A harness that compiles but sizes its input wrong, or bails out early on most inputs, fuzzes nothing at all. The only ground truth here is coverage — confirm the target function is executing rather than assuming it.

The discipline generalizes across all AI-assisted security work: the model drafts, and a hallucinated harness is a wrong harness. Either it fails to build, which is harmless, or it fuzzes the wrong thing and hands you false confidence, which is expensive. Reading it takes a minute; a silently-wrong harness wastes CPU-days.

Compiling and catching the bug

Apple’s stock clang does not ship the libFuzzer runtime, so the lab includes a tiny standalone ASAN driver that reads a file from disk and calls the harness — which means the exact same LLVMFuzzerTestOneInput runs on plain macOS. On Linux, or with Homebrew LLVM installed, compile with -fsanitize=fuzzer,address instead and you get true coverage-guided fuzzing.

clang -g -O1 -fsanitize=address target.c harness.c driver.c -o fuzz_target

# a benign record: type=1, len=2, "AB"  -> processed cleanly
python3 -c "import sys;sys.stdout.buffer.write(bytes([1,2,65,66]))" > seed_ok
./fuzz_target seed_ok        # [ok] input of 4 bytes processed with no crash

# a malicious record: type=1, len=200, then 200 bytes -> overflow of value[16]
python3 -c "import sys;sys.stdout.buffer.write(bytes([1,200])+b'A'*200)" > crash
./fuzz_target crash          # AddressSanitizer aborts

The crash report repays reading line by line:

==ERROR: AddressSanitizer: stack-buffer-overflow ...
WRITE of size 200 at 0x... thread T0
    #0 __asan_memcpy
    #1 parse_records target.c:16
    #2 LLVMFuzzerTestOneInput harness.c:7
    #3 main driver.c:10
...
  This frame has 1 object(s):
    [32, 48) 'value' (line 15) <== Memory access ... overflows this variable

Every line is signal. It is a WRITE rather than a read; the size is 200; the faulting frame is parse_records at line 16, which is the memcpy; the path there runs through the model-generated harness; and ASan even names the overflown variable as value, the 16-byte buffer. That is a complete, actionable bug report that a developer can act on in minutes rather than days.

From crash to root cause to fix

The value of a good sanitizer report is that it collapses the distance between “it crashed” and “here is the line to change.” Read the trace top-down: frame #1 parse_records target.c:16 is the faulting instruction, and the object note, 'value' (line 15), names the buffer that was overrun. The root cause is therefore unambiguous — len is attacker-controlled up to 255, value is 16 bytes, and the memcpy writes past the buffer for every len > 16. The fix is one clamp on the write plus one bounds check so the parser never reads past the input either:

Diff of target.c adding a length clamp and an input bounds check before the memcpy that caused the stack-buffer-overflow
From ASan report to patch: len is clamped to the size of value so the copy can never overflow the 16-byte buffer, and off + 2 + len is checked against size so the parser never reads past the fuzzer’s input. The clamp fixes the reported WRITE; the second guard closes the sibling out-of-bounds READ that the same missing-length-check pattern would otherwise expose. Source: original article.

Two habits are worth forming at this point. First, fix the bug class rather than the single input. The reproducer happened to carry len = 200, but the actual defect is an unvalidated length used as a copy size, so the patch has to hold for every value of len. Second, re-fuzz after the fix: rebuild with the same ASan harness, replay the saved crash to confirm it no longer aborts, and then let the fuzzer keep running against the accumulated corpus to make sure the patch did not simply move the overflow one field to the right. Until it has been re-fuzzed, a fix is a hypothesis rather than a result.

Going further: seeds, dictionaries and structure-aware fuzzing

The toy above crashes on the first malformed input. Real targets hide their bugs behind layers of format checking, and that is exactly where an LLM-assisted workflow starts to pay for itself:

  • Seed corpus. A fuzzer starting from random bytes may never produce a valid file header at all. Ask the model for a handful of minimal valid inputs — a valid PNG, a valid record — to seed the corpus, and the fuzzer begins from “almost valid” and mutates outward.
  • Dictionaries. libFuzzer accepts a -dict= file of interesting tokens: magic bytes, keywords, chunk names. A model that has read the spec can produce that dictionary, which gets the fuzzer past if (magic != 0x89504E47) gates it would otherwise take billions of iterations to guess.
  • Structure-aware harnesses. For inputs carrying checksums or length fields, a naive harness wastes almost every execution failing an early integrity check. Prompt the model to write a harness that repairs the structure — recomputing the checksum, fixing the length — before calling the target, so mutations land on the parsing logic that matters. For structured formats this is the single biggest lever on effective fuzzing throughput.
  • Coverage feedback loop. The most advanced version, and an active research direction in OSS-Fuzz and Google’s own work, closes the loop: run the fuzzer, feed the coverage report back to the model, and ask it for a better harness or new seeds aimed at the branches that stayed dark.

Coverage-guided fuzzing internals

“Coverage-guided” has appeared several times already, so it is worth unpacking what it actually means. Understanding how the fuzzer measures progress is what lets you tell a good harness from a wasteful one.

When the target is compiled with -fsanitize=fuzzer, the compiler inserts a tiny callback at every edge in the control-flow graph. An edge is a transition between two basic blocks — think of each if or loop as splitting execution into branches, with each branch taken being an edge. The instrumentation maintains a large table in shared memory, the coverage map, and bumps the corresponding counter every time execution crosses an edge. AFL++ implements the same idea with an 8-bit-per-edge bitmap plus “hit count buckets” recording whether an edge was hit once, 2–3 times, 4–7, or 8–15, which lets it distinguish “we entered the loop once” from “we entered it a thousand times.”

Here is the loop the fuzzer actually runs, in pseudo-code:

corpus = [initial seeds]
while running:
    input   = pick_from(corpus)          # favour small, fast, high-coverage inputs
    mutant  = mutate(input)              # flip bits, splice, insert dict tokens…
    reset_coverage_map()
    run_harness(mutant)                  # execute LLVMFuzzerTestOneInput
    if coverage_map has any NEW edge:
        corpus.add(mutant)              # this input is "interesting" — keep it
    if crash_or_sanitizer_abort:
        save(mutant); report()

The decisive line is if coverage_map has any NEW edge; everything else follows from it. A purely random fuzzer generates a mutant, runs it, learns nothing, and discards it. A coverage-guided fuzzer keeps any mutant that reached code no previous input reached, and then mutates that further. The corpus consequently behaves like an evolving population rather than a fixed list of test cases, with each entry a stepping stone that unlocked a new region of the program. Reaching a deep function often means passing ten nested if statements; random bytes have essentially zero chance of satisfying all ten simultaneously, but coverage feedback lets the fuzzer solve them one at a time, banking the input that cracked each gate.

This is why coverage guidance beats blind random testing by orders of magnitude rather than by a few percent. Consider a four-byte magic check, if (memcmp(data, "\x89PNG", 4) == 0). Blind fuzzing has a one-in-four-billion chance — 232 — of guessing those exact bytes. A coverage-guided fuzzer records progress the moment a mutation gets even the first byte right and changes which branch is taken, and then builds on it, turning an astronomically unlikely event into a short walk. Add a dictionary, covered below, and it becomes near-instant.

Several practical consequences fall out of this model, and they shape directly how you should have the model write harnesses:

  • Fast harnesses fuzz more. Coverage is measured per execution, so executions-per-second is the number that matters. libFuzzer runs in-process — the harness is called millions of times inside one long-lived process, with no fork/exec per input — which is how it reaches tens of thousands of execs/sec. AFL++ traditionally forks a fresh process per input, which is robust against state corruption but slower, and it mitigates that with a fork server and persistent mode, its equivalent of the in-process loop.
  • Global state breaks it. Because libFuzzer reuses the process, a harness that leaks memory or leaves global state dirty across calls will drift and produce results that cannot be reproduced. A good harness is a pure function of its input bytes.
  • Non-determinism poisons the signal. If the code path depends on the clock, a random seed or thread scheduling, the coverage map becomes noisy and the fuzzer chases phantom “new” edges. Pin those sources of entropy inside the harness.
libFuzzerAFL++
Execution modelIn-process, one process, millions of callsFork server / persistent mode
InstrumentationLLVM SanitizerCoverage (compile-time)Compile-time (afl-clang-fast) or QEMU/Frida for binaries
Coverage granularityEdge coverage, -fsanitize=fuzzerEdge coverage + hit-count buckets
Best whenYou have source and a library APIYou have source or only a binary; whole-program targets
Corpus/dict format-dict=, corpus directory-x dict, -i input_dir
libFuzzer versus AFL++ at a glance. Source: original article.

Both engines consume the same corpus and the same dictionaries, and both are happy to run the same LLVMFuzzerTestOneInput, because AFL++ ships a libFuzzer-compatibility driver — so the harness the model writes is portable across engines. That portability is worth keeping in mind: generate one good harness, run it under both engines, and let their different mutation strategies find different bugs.

Structure-aware fuzzing (with code)

Here is the problem the toy target concealed. parse_records crashes on the first malformed byte, so forwarding raw fuzzer bytes works perfectly well. Real parsers are not so obliging. They open with a cascade of format checks — a magic number, a version byte, a length that has to agree with the total size, perhaps a checksum — and any input failing an early check is rejected within the first few instructions. Feed such a parser random bytes and upwards of 99% of executions die at the front door, never reaching the parsing logic where the interesting bugs live. A million execs/sec all bouncing off the same if (magic != EXPECTED) return -1; is a million wasted executions.

The remedy is a structure-aware harness. Instead of passing the fuzzer’s bytes through untouched, the harness carves them into the fields the target expects, so mutations land on the body of the format rather than repeatedly failing the header. The cleanest way to do that in libFuzzer is FuzzedDataProvider, a header-only helper (<fuzzer/FuzzedDataProvider.h>) that treats the fuzz input as a stream you draw typed values from. Here is the kind of harness you would prompt the model to write for a length-prefixed message format:

#include <fuzzer/FuzzedDataProvider.h>
#include <stdint.h>
#include <stddef.h>
#include <vector>

// Target under test: a message = [4-byte magic][1-byte version][2-byte length][body]
extern "C" int parse_message(const uint8_t *buf, size_t len);

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    FuzzedDataProvider fdp(data, size);

    // Carve typed fields off the front of the fuzzer's bytes.
    uint8_t  version = fdp.ConsumeIntegral<uint8_t>();
    // Let the fuzzer pick a body, but keep the message internally consistent.
    std::vector<uint8_t> body = fdp.ConsumeRemainingBytes<uint8_t>();
    uint16_t length = (uint16_t)body.size();

    // Rebuild a *well-formed* message so we sail past the header checks
    // and mutations exercise the body/parser instead of dying at the magic.
    std::vector<uint8_t> msg;
    msg.insert(msg.end(), {0x8B, 'M', 'S', 'G'});          // fixed magic
    msg.push_back(version);                                  // fuzzed version
    msg.push_back((uint8_t)(length >> 8));                   // length hi
    msg.push_back((uint8_t)(length & 0xFF));                 // length lo
    msg.insert(msg.end(), body.begin(), body.end());        // fuzzed body

    parse_message(msg.data(), msg.size());
    return 0;
}

The key move is that the harness spends the fuzzer’s entropy where it matters. The magic bytes are fixed constants, because there is no value in the fuzzer rediscovering them a billion times; the length field is computed from the body so the message is always internally consistent; and everything left over — the version byte and the entire body — is fuzzer-controlled. Now every single execution reaches the parser, and mutations explore version handling and body parsing rather than failing an integrity check.

If your toolchain lacks FuzzedDataProvider — it ships with LLVM but may be missing on a given target compiler — the same carving is trivial by hand, and this version is often clearer for beginners anyway:

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 3) return 0;                 // need at least version + length
    uint8_t  version = data[0];
    uint16_t length  = (uint16_t)((data[1] << 8) | data[2]);
    const uint8_t *body = data + 3;
    size_t body_len = size - 3;

    // Clamp the attacker-supplied length to what we actually have, so the
    // harness itself never over-reads — we want the *target* to be the one
    // that mishandles length, not the harness.
    if (length > body_len) length = (uint16_t)body_len;

    uint8_t msg[8 + 65535];
    size_t n = 0;
    msg[n++] = 0x8B; msg[n++] = 'M'; msg[n++] = 'S'; msg[n++] = 'G';
    msg[n++] = version;
    msg[n++] = (uint8_t)(length >> 8);
    msg[n++] = (uint8_t)(length & 0xFF);
    for (size_t i = 0; i < length; i++) msg[n++] = body[i];

    return parse_message(msg, n);
}

One rule here is easy to get wrong: the harness must be more careful with lengths than the target is. length is clamped to the bytes actually held so the harness never over-reads. Otherwise ASan flags the harness instead of the bug and you lose an afternoon triaging your own glue. The whole point is to hand a well-formed message to parse_message and let it be the code that mishandles the length internally. This is precisely the reasoning to include in the prompt: carve the input into version/length/body, keep the framing valid, and make the harness itself memory-safe so that any ASan report points at the target. A model given that instruction produces a useful harness; a model told only “write a fuzzer” produces the raw-forwarding version that never gets past the magic.

Compile the structure-aware harness with -fsanitize=fuzzer,address and the abstract loop from the previous section becomes concrete. This is what a real libFuzzer session looks like:

libFuzzer running the structure-aware harness: coverage and corpus climbing across NEW lines at tens of thousands of execs per second, then an AddressSanitizer heap-buffer-overflow in parse_message
Reading a libFuzzer status line left to right: #131072 is the execution count, cov: 402 the number of edges hit, ft: 588 the feature count of edges plus hit-count buckets, corp: 38/6.1kb the evolving corpus, and exec/s: 96k the throughput that makes coverage-guided search viable. NEW marks a mutation that reached an unseen edge, and the corpus growing from 5 seeds to 38 is the fuzzer teaching itself the format. Once coverage plateaus the mutator grinds the length field until a declared 0xffff against a 12-byte body trips ASan, and libFuzzer writes the crashing bytes to a crash-* file for replay. Source: original article.

Two numbers in that stream tell you whether the harness is any good. If cov climbs and then plateaus, the fuzzer has learned everything this harness can reach. Coverage that is flat from the start means the harness is stuck at a gate because of bad framing, or the seeds are too weak. And if exec/s is low — hundreds rather than tens of thousands — the harness is doing too much per call, whether allocating, performing I/O or leaking state, and every wasted millisecond is millions of inputs never tried.

Generating seeds and dictionaries with a local LLM (worked)

Structure-aware harnesses get you past framing checks. Seeds and dictionaries get you past value checks: the magic numbers, keywords and enum tags scattered through the parser. Both are things a local model produces in seconds, because generating “a few examples of a valid input” and “a list of the meaningful tokens in this format” sits squarely in an LLM’s wheelhouse. Here is the worked flow against the [4-byte magic][1-byte version][2-byte length][body] message format.

(a) A minimal valid seed corpus. A fuzzer starting from an empty or random corpus may spend a very long time before it stumbles onto even one input that passes the header. Give it a handful of hand-shaped valid inputs — model-shaped, in this case — and it starts from “almost valid” and mutates outward. Note that the prompt asks the model for a small script that emits the seeds, which is considerably more reliable than asking it to print raw bytes:

You are a fuzzing expert. The target parses messages of the form:
  [4-byte magic 0x8B 'M' 'S' 'G'][1-byte version][2-byte big-endian length][body].
Write a short Python script that writes 5 minimal but VALID seed files
named seed_00..seed_04 into ./corpus/, covering: an empty body, a 1-byte
body, a typical body, version 0 and version 255. Output ONLY Python.

A capable local coder model — qwen3.6:35b-a3b via Ollama here — returns something like this:

import os

os.makedirs('./corpus', exist_ok=True)

def make_msg(version, body):
    magic = b'\x8bMSG'
    ver = bytes([version])
    length = len(body).to_bytes(2, 'big')
    return magic + ver + length + body

# Seed 0: empty body, version 0
with open('./corpus/seed_00', 'wb') as f:
    f.write(make_msg(0, b''))

# Seed 1: 1-byte body, version 0
with open('./corpus/seed_01', 'wb') as f:
    f.write(make_msg(0, b'A'))

# Seed 2: typical body, version 0
with open('./corpus/seed_02', 'wb') as f:
    f.write(make_msg(0, b'Hello World!'))

# Seed 3: empty body, version 255
with open('./corpus/seed_03', 'wb') as f:
    f.write(make_msg(255, b''))

# Seed 4: typical body, version 255
with open('./corpus/seed_04', 'wb') as f:
    f.write(make_msg(255, b'Hello World!'))

Each seed is a valid message built by a clean make_msg helper that packs the magic, version, big-endian length and body, so the fuzzer immediately has coverage of the well-formed path and can mutate the body, the version and — most importantly — the length field so that it disagrees with the actual body size, which is where length-handling bugs surface. The model covered the five cases requested, reaching the version-0 and version-255 edges by pairing them with empty and typical bodies rather than adding a distinct 1-byte case at each version. That is a reasonable reading of the spec, and one you would either accept or tighten in the prompt.

(b) A -dict= token dictionary. libFuzzer and AFL++ both accept a dictionary of interesting byte-strings, and the mutator splices those tokens into inputs wholesale, so a four-byte magic that would take 232 random tries to guess gets inserted verbatim. Ask the model to extract the format’s magic constants and keywords:

List the magic bytes, fixed tags, and keyword tokens for the message format
above as a libFuzzer dictionary. Use the name="\xHH..." syntax, one per line.
Output ONLY the dictionary.

The output, saved as msg.dict and passed with -dict=msg.dict:

magic="\x8bMSG"
ver_zero="\x00"
ver_max="\xff"
len_zero="\x00\x00"
len_max="\xff\xff"

The payoff is concrete. Suppose the parser has an inner gate such as if (memcmp(body, "CONFIG", 6) == 0) parse_config(body);. Without the token "CONFIG" in the dictionary, the fuzzer has to guess six exact bytes — 248 tries — before it ever reaches parse_config, which in practice means never. With config_tag="CONFIG" in the dictionary, the mutator drops that literal into the body on an early iteration, the branch flips, coverage records a new edge, and the input is saved for further mutation inside parse_config. Seeds and dictionaries are how an unreachable code region becomes a reachable one, and a local model that has read the format — from a header, a spec, or the parser source pasted into the prompt — is an efficient way to produce both. The same generate-then-review discipline applies: eyeball the seeds and the dictionary, because a model can hallucinate a magic value, and a wrong constant simply wastes the slot.

Sanitizers beyond AddressSanitizer

The overflow above was caught with AddressSanitizer, but ASan is only one member of a family. Each sanitizer instruments the program to make a different class of bug loud, and picking the right one — or the right combination — is the difference between the fuzzer’s crashes being meaningful and the fuzzer silently running straight over bugs it cannot see. A sanitizer is the detection half of fuzzing: the fuzzer generates inputs, but without a sanitizer many bugs execute cleanly and you never learn they happened.

SanitizerFlagCatchesTypical cost
AddressSanitizer (ASan)-fsanitize=addressHeap, stack, and global buffer overflows, use-after-free and double-free, out-of-bounds~2x slower, ~2–3x memory
UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefinedSigned integer overflow, invalid shifts, null deref, misaligned access, bad casts, unreachableLow, often <20%
MemorySanitizer (MSan)-fsanitize=memoryReads of uninitialized memory~3x slower; needs all deps instrumented
ThreadSanitizer (TSan)-fsanitize=threadData races, deadlocks in multithreaded code~5–15x slower, high memory
LeakSanitizer (LSan)-fsanitize=leak, bundled in ASanMemory leaks at exitNegligible
The sanitizer family, their flags, what each catches and what each costs. Source: original article.

A few notes on when each earns its place:

  • ASan is the default for good reason: memory corruption is the highest-severity and most exploitable bug class, and ASan’s reports are the most actionable, giving write-vs-read, size, allocation site and the name of the overflown object. It combines spatial safety — are you inside the bounds of the object — with temporal safety — is the object still alive. LeakSanitizer rides along with ASan for free, catching allocations never released at exit.
  • UBSan is cheap enough to run almost always, and it catches an entire category ASan is blind to: int overflow, shifting by more than the width of the type, dereferencing misaligned pointers. Many “impossible” logic bugs, and some genuine vulnerabilities such as an integer overflow feeding a later allocation, are UBSan finds. Pair it with -fno-sanitize-recover=undefined so that undefined behaviour aborts like a crash instead of being logged and execution continuing, because the fuzzer needs the abort in order to register the input as a bug.
  • MSan answers a question ASan cannot: did we read memory before writing it? Uninitialized reads leak stack and heap contents and produce non-deterministic behaviour. The catch is that MSan needs every library in the process, including the C++ standard library, to be instrumented, or it reports false positives originating in uninstrumented code. That is why it is used less casually than ASan.
  • TSan is the tool for concurrency. If the target spawns threads, or you are fuzzing a lock-based data structure, TSan detects the data races that only manifest under specific interleavings and are otherwise close to impossible to reproduce.

The important operational rule: ASan and MSan cannot be combined in one binary, because both rewrite memory accesses and conflict, and TSan is likewise its own build. So you build separate fuzz binaries per sanitizer and run them against the same corpus. ASan + UBSan + LSan do compose into a single binary, and that trio is the pragmatic default:

# Pragmatic default: memory + undefined behaviour + leaks, aborting on UB.
clang -g -O1 -fsanitize=fuzzer,address,undefined,leak \
      -fno-sanitize-recover=undefined \
      target.c -o fuzz_asan

# Separate binary for uninitialized-read detection.
clang -g -O1 -fsanitize=fuzzer,memory \
      target.c -o fuzz_msan

Run both across the shared corpus and you cover memory corruption, undefined behaviour, leaks and uninitialized reads. This is another natural fit for the model: ask it to generate the per-sanitizer build commands plus a small driver script that fans the corpus out across each binary, and the whole detection matrix is standing up in seconds.

The coverage feedback loop

The most advanced pattern in AI-assisted fuzzing, and the one Google’s OSS-Fuzz team has published real results on, is to close the loop between the fuzzer and the model. So far the model has been a one-shot draftsman: we asked for a harness, it produced one, we ran it. But the fuzzer emits a rich signal that can be fed back — the coverage report states exactly which functions and branches were never reached, and those uncovered regions are precisely where the harness is failing to do its job.

The workflow is iterative:

  1. Build with coverage. Compile the target with source-based coverage using -fprofile-instr-generate -fcoverage-mapping alongside the fuzzer.
  2. Fuzz for a while, then generate the coverage report (see the commands below).
  3. Feed the gaps back to the model. Extract the uncovered functions and the branch conditions guarding them, and hand them over with a targeted request (see the prompt below).
  4. Add the new seeds and harness, re-fuzz, and repeat. Coverage climbs, and each round targets whatever is still dark.
# After a fuzzing run, produce a per-function coverage summary.
llvm-profdata merge -sparse default.profraw -o cov.profdata
llvm-cov report ./fuzz_target -instr-profile=cov.profdata

# Show the specific lines/branches that were NEVER executed.
llvm-cov show ./fuzz_target -instr-profile=cov.profdata \
    --show-branches=count --region-coverage-lt=1 target.c
The fuzzer has 78% line coverage of parser.c but these functions are
0% covered: decode_extension(), parse_tlv_nested(), handle_compressed().
They are only reached when byte[4] (the "flags" field) has bit 0x02 set
AND the body begins with the token "EXT". Here are their signatures and
the calling code: <paste>. Write (a) 3 new seed inputs that reach these
functions, and (b) an improved harness that sets the flags/token so
mutations exercise these branches. Output code only.

Why this works: the model is good at the one reasoning step that is otherwise tedious for a human — reading a branch condition such as if ((flags & 0x02) && starts_with(body, "EXT")) and working backwards to “what input satisfies this?” It is essentially performing lightweight, informal constraint-solving in natural language. It will not always be right, and genuine path constraints may still need a concolic engine or a symbolic executor to solve hard checks, but for the large class of gates that come down to the right magic byte, flag or keyword, a model handed the source and the coverage gap produces a working seed far faster than manual analysis. OSS-Fuzz reported exactly this dynamic: LLM-generated and LLM-refined harnesses reached code that the previous human-written harnesses had left completely uncovered, on widely-fuzzed, mature projects where the newly-reached code was genuinely hard to get to.

Two guardrails keep the loop honest. First, verify the coverage actually moved after each iteration — a plausible-looking seed the model promises reaches a function may simply not, and coverage numbers are the ground truth. Second, watch for the model gaming the harness rather than the seeds: if it “reaches” a branch by hard-coding a direct call to the deep function, it has defeated the purpose, because you are now fuzzing that function in isolation with an unrealistic calling context and potentially inventing bugs that cannot occur in practice. The loop should expand realistic reachability, not manufacture artificial entry points.

Continuous and differential fuzzing

Fuzzing is not a one-afternoon activity. Bugs surface as a function of CPU-hours, and the corpus is an asset that becomes more valuable the longer it runs, which is why the mature model is continuous fuzzing in CI. That is exactly what Google’s OSS-Fuzz and its ClusterFuzz backend do for hundreds of open-source projects: every commit is fuzzed against the accumulated corpus on a fleet of machines, new crashes are automatically deduplicated, minimized, bisected to the offending commit and filed, and fixes are verified when the crashing input stops crashing.

A scaled-down version of the same loop runs perfectly well in your own CI:

# .github/workflows/fuzz.yml  — short per-commit fuzz + persistent corpus
name: continuous-fuzz
on: [push]
jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Restore corpus
        uses: actions/cache@v4
        with:
          path: corpus
          key: fuzz-corpus-${{ github.ref }}
      - name: Build
        run: clang -g -O1 -fsanitize=fuzzer,address,undefined target.c -o fuzz_target
      - name: Fuzz for 5 minutes on top of the saved corpus
        run: ./fuzz_target -max_total_time=300 -print_final_stats=1 corpus
      - name: Minimize corpus before saving it back
        run: |
          ./fuzz_target -merge=1 corpus_min corpus
          rm -rf corpus && mv corpus_min corpus
      - name: Upload any crash reproducers
        if: failure()
        uses: actions/upload-artifact@v4
        with: { name: crashes, path: crash-* }

Two techniques in that workflow deserve a beginner-level word.

Corpus minimization. Over weeks a corpus bloats to tens of thousands of inputs, many of them redundant — covering the same edges as smaller, faster inputs. libFuzzer -merge=1, and AFL++’s afl-cmin, computes a minimal subset that preserves total coverage and discards the rest. A leaner corpus means every input is a more valuable stepping stone and each fuzzing cycle runs faster. -minimize_crash=1 does the analogous thing to a single crashing input, shrinking a 200-byte reproducer down to the handful of bytes that actually trigger the bug, which makes triage dramatically easier.

Terminal running libFuzzer -minimize_crash=1 shrinking a 268-byte reproducer to 18 bytes, then -merge=1 reducing a 3841-file corpus to 217 files while preserving coverage
Both minimizers at work. -minimize_crash=1 repeatedly deletes and re-runs bytes, keeping only what still triggers the abort, so 268 bytes collapse to the 18-byte irreducible trigger of magic, version, and the 0xffff length that overruns the buffer, which is far easier to root-cause than the original blob. -merge=1 then computes the minimal subset of the accumulated corpus that preserves all 402 features, cutting 3,841 files to 217 so each survivor earns its place as a unique stepping stone. Source: original article.

Differential fuzzing. Some of the most valuable bugs never crash at all. They are divergences, where two implementations of the same specification disagree on the same input. Feed identical bytes to two JSON parsers, two X.509 decoders, or an optimized and a reference implementation, and any difference in output is a bug in at least one of them — often a security-relevant parser differential such as a request-smuggling-style desync, or a signature one library accepts and another rejects. The harness compares the two and aborts on mismatch, so the fuzzer’s coverage feedback drives it toward inputs that make the implementations diverge:

extern int parse_A(const uint8_t*, size_t, char *out, size_t out_sz);  // impl A
extern int parse_B(const uint8_t*, size_t, char *out, size_t out_sz);  // impl B

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    char out_a[256] = {0}, out_b[256] = {0};
    int ra = parse_A(data, size, out_a, sizeof out_a);
    int rb = parse_B(data, size, out_b, sizeof out_b);

    // The "oracle": both must accept/reject alike AND agree on output.
    if ((ra == 0) != (rb == 0)) __builtin_trap();          // one accepted, one rejected
    if (ra == 0 && memcmp(out_a, out_b, sizeof out_a) != 0)
        __builtin_trap();                                   // both accepted, different result
    return 0;
}

The line doing the real work is the oracle — the rule that decides whether an input counts as a bug. Writing a correct oracle is the hard, spec-reading part of differential fuzzing, and it is another place a local model helps: prompt it with the two APIs and the specification and ask it to enumerate the equivalences that must hold. For instance, both implementations must reject inputs with trailing garbage, canonical and non-canonical encodings must normalize to the same output, and a leading + must be rejected by both. Those then get encoded as assertions. The model drafts the oracle from the spec and you review it, because a wrong oracle produces a flood of false “divergences” that are really just the harness misunderstanding the format.

A harder worked example: an image/TLV parser

To see all of this land together, picture a more realistic target than the toy: a small image container parser, the kind that reads a signature and then walks a sequence of TLV (Type-Length-Value) chunks, each [2-byte type][4-byte length][length bytes of data], dispatching on the type to sub-parsers for a header chunk, a palette chunk, a pixel-data chunk and a comment chunk. This is representative of PNG, TIFF and countless proprietary formats, and it is precisely the shape that has historically produced a long tail of memory-corruption CVEs.

Applying the full workflow:

  • Structure-aware harness. Raw bytes almost never form a valid chunk stream, so you have the model write a harness that carves the fuzz input into a fixed signature followed by a series of chunks whose 4-byte length fields are computed from the data the fuzzer supplies, keeping the container well-framed so mutations reach the per-chunk sub-parsers instead of dying at the signature. You deliberately allow the fuzzer to make one chunk’s declared length disagree with its actual data, because that mismatch is the classic trigger.
  • Seeds and dictionary. Ask the model for a minimal valid image as the seed — a signature, one header chunk and a tiny pixel chunk — plus a -dict= containing the four chunk-type tags and the signature bytes. Now the fuzzer can splice a valid PLTE-style tag into a mutated chunk and immediately reach the palette sub-parser.
  • Sanitizer matrix. Build one binary with ASan+UBSan to catch spatial overflows in the pixel copy and integer overflow when width * height * bytes_per_pixel is computed for an allocation, and a second with MSan to catch a palette chunk that declares 256 entries but supplies 4, leaving the decoder reading uninitialized palette memory into the output.

The bugs this surfaces are the bread and butter of parser fuzzing:

  • Heap overflow in the pixel copy: a pixel-data chunk whose declared length exceeds the buffer the header dimensions sized, so the memcpy writes past the allocation, which ASan flags as a heap-buffer-overflow WRITE.
  • Integer overflow in allocation sizing: a header with width = 0x10000, height = 0x10000 overflows width * height * 4 to a small value; the parser allocates the small buffer and then writes the full image into it, where UBSan catches the multiply and ASan catches the resulting overflow.
  • Uninitialized read from a short palette: a palette chunk that under-supplies entries, so pixels index into never-initialized palette slots, which MSan flags as a use-of-uninitialized-value.
  • Unbounded recursion / stack exhaustion: a chunk type that references another chunk, which the fuzzer nests deeply until the stack blows.
  • Out-of-bounds read on a truncated chunk: a length larger than the remaining input, so a sub-parser reads past the end of the buffer, which ASan flags as a heap-buffer-overflow READ.

When one of these fires, the ASan report is again the map from crash to root cause. Here is the out-of-bounds read, the kind of thing the fuzzer surfaces within seconds of reaching the chunk dispatcher:

Zoomed AddressSanitizer heap-buffer-overflow READ report for an image parser: faulting frame parse_idat+0x4c in copy_pixels reading past a 36-byte pixel buffer, with the crashing input bytes shown as a hexdump
The trace pinpoints the defect without a debugger: the faulting frame is parse_idat_chunk+0x4c (img_parser.c:76) calling copy_pixels, the overrun object is a 36-byte region allocated by parse_ihdr_chunk from the 1x1 header dimensions, and the hexdump of the crashing input shows the mismatch that caused it: an IDAT chunk declaring a 0xffff length, 65,535, against a buffer sized for a single pixel. Symbolized frames from llvm-symbolizer, plus the allocation site, plus the input bytes, are everything you need to write the length-consistency check that fixes it. Source: original article.

Each of these is a real CVE pattern, and the point of the worked example is that one repeatable pipeline finds them all: the local model drafts the structure-aware harness, the seeds and the dictionary; you review them; the sanitizer matrix provides detection; coverage feedback fed back to the model chases the sub-parsers that are still dark; and CI runs the whole thing continuously, so a regression reintroducing one of these bugs is caught on the commit that adds it. The fuzzer and the sanitizers are what actually found the bug; the AI is what made it economical to stand up a competent harness, seed corpus, dictionary and oracle for a non-trivial format in an afternoon instead of a week.

Where this fits, and its limits

To keep expectations calibrated: the model works alongside the fuzzer, the sanitizer and the human reviewer, and its job is to remove harness-writing friction so you can fuzz more targets, faster. The failure modes are real. A hallucinated API call that will not compile is cheap to catch. A subtly wrong harness that fuzzes the wrong thing and hands you false confidence is expensive to catch, and may never be caught at all if nobody checks. So always read the generated harness, and always confirm the fuzzer is reaching the code you intended by looking at coverage rather than at the harness source.

Used with that discipline, this is a genuine addition to a vulnerability-research workflow: point a local model at a library’s public headers, generate first-draft harnesses for every entry point, review and fix them, and let the fuzzer do what fuzzers do best.

Key Takeaways

  • The fuzzer finds the bug and the sanitizer makes it loud — the model’s contribution is removing the harness-writing friction that keeps targets from ever being fuzzed at all.
  • A local, open-weights model served through Ollama keeps proprietary source on your own machine, costs nothing per token, and works inside an air-gapped VM — all of which matter for client work under NDA.
  • Generate, then review. The four invariants of a good harness are: it calls the real API, it is a pure function of the input bytes, it is itself memory-safe, and coverage proves it actually reaches the target.
  • Raw byte forwarding only works on trivial targets. Real parsers need structure-aware harnesses that fix the magic and compute the length so mutations land past the header checks.
  • Seeds get the fuzzer past framing checks and -dict= tokens get it past value checks; both are things a model produces in seconds, and both convert unreachable code regions into reachable ones.
  • ASan is not the whole story: UBSan is cheap enough to run always, MSan catches uninitialized reads and TSan catches races, and because ASan and MSan cannot share a binary you build a small matrix and run it against one shared corpus.
  • Closing the loop — feeding the coverage report back to the model and asking for seeds or a harness targeting the dark branches — is where OSS-Fuzz-Gen reported real coverage gains on mature, heavily-fuzzed projects.
  • Fuzzing pays off as a function of CPU-hours, so the mature setup is continuous fuzzing in CI with a persistent, periodically minimized corpus.

Hardening Checklist

  • Treat every attacker-supplied length, count or offset as untrusted: clamp it to the destination buffer size and validate it against the remaining input length before any memcpy, memmove or indexed read (CWE-121, CWE-787, CWE-125).
  • Compile CI builds with -fsanitize=address,undefined,leak and -fno-sanitize-recover=undefined so undefined behaviour aborts and registers as a finding rather than being logged and ignored.
  • Stand up a second MSan build for uninitialized-read detection and run both binaries against the same shared corpus; add a TSan build if the target is multithreaded.
  • Write or generate a harness for every externally-reachable parsing entry point, not just the obvious one — unfuzzed entry points are where the long tail of parser CVEs lives.
  • Verify coverage after every harness change. Flat or plateaued coverage is the signal that the harness is stuck at a gate, not that the code is bug-free.
  • Review model-generated harnesses, seeds, dictionaries and oracles before running them: check the symbol and signature against the real header, check the harness cannot over-read, and check the magic constants are the real ones.
  • Run fuzzing continuously in CI against a cached corpus, minimize the corpus with -merge=1 before saving it back, and minimize crash reproducers with -minimize_crash=1 before triage.
  • After every fix, replay the saved crash to confirm it no longer aborts and then keep fuzzing the accumulated corpus — a patch that only moves the overflow one field over is a common outcome.
  • Fuzz at the optimization level you actually ship, since compiler optimization can eliminate the very dead stores that expose a bug at lower levels.

Conclusion

It is worth being honest about who did the work here. The fuzzer found the bug and the sanitizer made it obvious; the model wrote the harness that let the process start, which is the part most people put off doing indefinitely. That turns out to matter more than it sounds, because the harness is usually the reason a target never gets fuzzed at all. And once you leave toy examples behind and start dealing with real formats, the seeds, dictionaries and structure-aware harnesses a model can draft are exactly what get the fuzzer past the header checks and into the code where bugs actually live. The one habit that cannot be skipped is reading what the model produced: a bad harness either fails to compile, which you notice immediately, or it fuzzes the wrong thing and leaves you feeling productive while finding nothing. Generate, then review, every time — and the payoff is mostly your own time back, spent on triage and root cause instead of boilerplate.

References

  1. LLVM — libFuzzer: a library for coverage-guided fuzz testing. llvm.org/docs/LibFuzzer.html
  2. Google — AddressSanitizer. clang.llvm.org/docs/AddressSanitizer.html
  3. Google Security Blog — AI-powered fuzzing: breaking the bug hunting barrier. OSS-Fuzz with LLM harnesses. security.googleblog.com
  4. AFL++ — American Fuzzy Lop plus plus. github.com/AFLplusplus/AFLplusplus
  5. Ollama — Run open-source LLMs locally. ollama.com
  6. Qwen — Qwen3 open-weight code models. github.com/QwenLM/Qwen
  7. Google — OSS-Fuzz-Gen: LLM-powered fuzz-harness generation. github.com/google/oss-fuzz-gen

Original text: “AI-Assisted Fuzzing: Generating libFuzzer Harnesses with a Local LLM” by 8kSec Research Team at 8kSec.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion