DEV Community

Zayd Mulani
Zayd Mulani

Posted on

I built a local-first coverage-guided fuzzer in Rust with LLM seed bootstrap

I built a local-first coverage-guided fuzzer in Rust with LLM seed bootstrap

rust #opensource #security #fuzzing


The problem that kept bothering me: I wanted to fuzz a custom binary protocol parser. Not something with a public corpus of real traffic. Just a service my team built that speaks a length-prefixed binary format over TCP.

AFL++ requires a C harness. cargo-fuzz wraps libFuzzer but you still need to write the harness and you're starting from random bytes. Neither tool helps you when the first thing the parser does is check a 4-byte magic header and exit if it doesn't match. You spend hours getting past that check before you find anything real.

So I built Phaedra.

The LLM seeding idea

The core insight is that you don't need random bytes to start. You need bytes that look plausible. If you describe what your target parses in plain English, a language model can generate a structured initial corpus.

phaedra fuzz \
  --target ./my_parser \
  --description "TLV binary protocol with PHDR magic header, u8 type, u16be length, payload bytes"
Enter fullscreen mode Exit fullscreen mode

Phaedra sends that description to a local Ollama instance and gets back hex-encoded seed inputs. No API key, no data leaving your machine. OpenAI and Anthropic work as drop-in backends if you want them.

The seed generation prompt asks for variety: valid inputs, boundary values, truncated records, inputs where the length field exceeds the payload. The parser gets past the magic check immediately because the seeds include valid magic bytes. Campaign starts finding new coverage on the first execution instead of the hundredth.

Coverage via SanCov edge bitmaps

Once you have seeds, you need coverage feedback. I chose SanCov edge bitmaps over shared memory rather than LLVM .profraw files for one reason: speed. Reading a profraw file after each execution means a file write, a file read, and a parse. Shared memory is a single shmat call and a memcpy.

The setup: Phaedra allocates a 65536-slot edge bitmap in POSIX shared memory, passes the segment ID to the target via __PHAEDRA_SHM_ID, and the target's SanCov runtime shim writes edge hit counts directly into that segment on every execution. After the process exits, Phaedra reads the bitmap, XORs against the global "seen" bitmap, and if new edges fired, the input goes into the corpus.

The shim is a small C file that gets compiled into the target:

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard || !phaedra_map) return;
    phaedra_map[(*guard - 1) % PHAEDRA_MAP_SIZE]++;
}
Enter fullscreen mode Exit fullscreen mode

Schema-aware mutation

Random byte mutation gets past the magic check once you have good seeds. But it still struggles with structured fields. If your protocol has a u16be length prefix followed by exactly that many payload bytes, random mutation almost never produces a valid record because it corrupts the length without adjusting the payload.

The schema DSL solves this. You describe your protocol in TOML:

name = "my_protocol"

[[fields]]
name = "magic"
type = "magic"
length = 4
value = "50484452"
mutable = false

[[fields]]
name = "length"
type = "u16_be"

[[fields]]
name = "payload"
type = "lp_bytes16_be"
Enter fullscreen mode Exit fullscreen mode

30% of mutations operate at the field level: corrupting the length prefix specifically, setting it to 0, to max value, or to a value larger than the actual payload. The other 70% are still raw byte mutations. The split keeps the campaign from getting stuck in schema-valid inputs only.

If you have a corpus but no schema, phaedra infer analyzes the samples with six heuristics and writes a starting schema:

phaedra infer --corpus-db ./phaedra-corpus/corpus.db --name my_proto
Enter fullscreen mode Exit fullscreen mode

Crash triage

When Phaedra finds a crash it deduplicates by signal number and input fingerprint. SIGSEGV and SIGABRT are CRITICAL. SIGILL and SIGFPE are HIGH. No-signal crashes are LOW. Each unique signature gets one entry in SQLite with a hit counter so if the same bug triggers 500 times you see one entry with hit_count=500, not 500 files in your crash directory.

phaedra crashes

ID  SEVERITY   HITS  SIGNATURE     STATUS
1   LOW        47    crash_3f2a1b  Crash { signal: None }
2   LOW        12    crash_7e4d2c  Crash { signal: None }
Enter fullscreen mode Exit fullscreen mode

phaedra minimize runs delta minimization to reduce a crashing input to the smallest bytes that still trigger it. Found a 200-byte crash? It will usually get it down to under 10.

Try it

cargo install phaedra
phaedra fuzz --demo tlv
Enter fullscreen mode Exit fullscreen mode

The TLV demo target has a real bounds-check bug. Phaedra finds it within the first few seconds.

GitHub: https://github.com/zaydmulani09/phaedra

Top comments (0)