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"
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]++;
}
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"
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
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 }
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
The TLV demo target has a real bounds-check bug. Phaedra finds it within the first few seconds.
Top comments (0)