Four complete, start-to-finish runs on real targets:
- libxml2 + a libFuzzer harness — the C/C++ path, with static-library expansion and an indirect error callback.
- The
urlcrate + a ziggy harness — the Rust path, rooted at the Rustmain. - cpp_demangle + a cargo-afl harness — the Rust manual route on a parse-only harness, where the unused render half is generic and never emitted at all.
- rustyknife + a cargo-afl harness — the Rust manual route, for a feature-gated AFL bin.
The exact counts below come from real runs (LLVM 22, type-based backend); yours will vary with library version, build flags, and LLVM release.
Take libxml2-2.9.2 and a small C++ libFuzzer harness, and compute which libxml2 functions the harness can reach. This exercises three features at once:
- C/C++ acquisition — building with the gllvm wrappers to get bitcode.
- Static-library expansion — analyzing all of
libxml2.a, not just the parts the harness pulls in (--static-libs). - Indirect-call resolution — the harness installs an error callback, and the analyzer reaches it through a function pointer.
The harness (harness.cc):
#include "libxml/parser.h"
void ignore(void *ctx, const char *msg, ...) {
// Error handler to silence libxml2 parser messages.
}
extern "C" int LLVMFuzzerTestOneInput(const unsigned char *data, size_t size) {
xmlSetGenericErrorFunc(NULL, &ignore);
auto doc = xmlReadMemory(reinterpret_cast<const char *>(data), size,
"noname.xml", NULL, 0);
if (doc) {
xmlFreeDoc(doc);
xmlCleanupParser();
}
return 0;
}A built analyzer and gllvm on PATH — see the README
Install section. In short:
export REACHABILITY_ANALYZER=$PWD/analyzer/build/reachability-analyzer
export PATH="$(go env GOPATH)/bin:$PATH" # gclang / gclang++ / get-bc
source .venv/bin/activate
reachability check-toolchain # confirm the LLVM toolchain is coherentcurl -fsSL -O https://raw.githubusercontent.com/vanhauser-thc/fuzzing-targets/refs/heads/master/libxml2-2.9.2.tar.gz
tar xf libxml2-2.9.2.tar.gz
cd libxml2-2.9.2Save the harness above as harness.cc in the libxml2-2.9.2 directory. The
public headers live under include/, so it is compiled with -I include.
One command builds the library, compiles the harness, and analyzes the result:
reachability run \
--lang cpp \
--project . \
--build-cmd './configure --without-python --disable-shared && make -j"$(nproc)" && gclang++ -I include -c harness.cc -o harness.o' \
--artifact harness.o \
--static-libs all \
--entry LLVMFuzzerTestOneInput \
-vNote the --static-libs all - this is important!
What each piece does:
--build-cmdruns under the gllvm wrappers (the driver injectsCC=gclang/CXX=gclang++), so./configure && makebuildslibxml2.awith embedded bitcode, andgclang++ -c harness.cccompiles the harness to a bitcode-carrying object.--without-python --disable-sharedkeeps the build small and produces a static.libs/libxml2.a. (An auto-detectedconfigurebuild now forces--disable-shared --enable-staticitself; the flag is spelled out here only because this--build-cmdis explicit — it also compiles the harness.)--artifact harness.opicks the harness object as the entry-bearing input. (Auto-detection would otherwise prefer one of libxml2's many test executables.)--static-libs allmerges the full contents of every bitcode archive in the tree with the harness object. This is what lets the report cover all of libxml2, including functions the harness never calls.--entry LLVMFuzzerTestOneInputroots reachability at the harness. (For--lang cppthis is already a default entry; passing it is just explicit.)
Why
alland notauto?autodiscovers linked archives from a linked binary's metadata, which a lone object file does not have. Since the harness is an object,allis the simple choice. libxml2 builds two bitcode archives —libxml2.aand a smalltestdso.atest helper — andallpulls in both; they link without conflict. If you instead link a real fuzzer binary (harness +libxml2.a+ libFuzzer), point--artifactat that binary and use the default--static-libs auto, exactly like the README static-library example.
The run prints a one-line summary. On this machine it is:
reachable 1294 / defined 2467 (302 indirect-only, 35 low-confidence, 1173 unreachable) [backend=type-based]
- defined (2467) — every libxml2 function (plus the harness) in the merged
module. Because of
--static-libs all, this is the whole library, not just the parts the harness pulls in. - reachable (1294) — the subset reachable from
LLVMFuzzerTestOneInput. - unreachable (1173) —
defined − reachable, everything the harness can never touch (listed innot_reached.txt). - indirect-only (302) — reachable functions reached only through an indirect call. This is the over-approximation surface.
- low-confidence (35) — indirect-only functions supported by type matching but not by entry-relative address-flow evidence. They remain reachable.
Reachable — the parser and everything it pulls in, since xmlReadMemory
drives a full parse: LLVMFuzzerTestOneInput, xmlReadMemory,
xmlParseDocument, the lexer and SAX callbacks, xmlFreeDoc, xmlCleanupParser.
The harness's ignore handler is reachable too — via an indirect edge. The
harness takes its address (&ignore) and hands it to xmlSetGenericErrorFunc;
libxml2 later calls the installed handler through a function pointer. The
type-based backend sees an address-taken function whose type matches that call
site and adds the edge. Because ignore is a C++ function, it appears under its
mangled name:
{
"mangled": "_Z6ignorePvPKcz",
"demangled": "ignore(void*, char const*, ...)",
"via": "indirect",
"indirect_only": true,
"depth": 2
}A directly-reached function carries its source location:
{
"mangled": "xmlReadMemory",
"demangled": "xmlReadMemory",
"file": "parser.c",
"line": 15376,
"via": "direct",
"indirect_only": false,
"depth": 1,
"basic_blocks": 4,
"dangerous_calls": 0,
"C11": 3,
"cyclomatic": 2,
"loops": 0,
"interesting": true,
"bottleneck": true,
"dead_end": false
}(via is direct, indirect, or both.)
Unreachable (not_reached.txt) — the large parts of libxml2 the harness
never enters: the XPath evaluator (xmlXPathEval, xmlXPathCompile),
schema/RelaxNG validation (xmlSchemaValidateDoc, xmlRelaxNGValidateDoc), and
the serialization/writer API (xmlSaveFile, xmlTextWriterStartDocument).
Before --static-libs all, these would have been absent from the analysis
entirely; now they are correctly reported as defined-but-unreachable.
Over-approximation in action. Some helpers look reachable through the type-based backend even when their feature is not. For example
xmlXPathEvalis unreachable, yet the XPath axis callbacks (xmlXPathNextAncestor, …) are reported reachablevia indirect: they are address-taken and their type matches an indirect call site the parser reaches. This is the sound-leaning bias at work — never miss a real edge, even at the cost of some false ones.
Feed either list to clang so SanitizerCoverage instruments just the code the harness can reach — smaller binaries and faster fuzzing:
# instrument ONLY reachable functions:
clang -fsanitize-coverage=trace-pc-guard -fsanitize-coverage-allowlist=reached.txt ...
# OR: instrument everything EXCEPT unreachable functions:
clang -fsanitize-coverage=trace-pc-guard -fsanitize-coverage-ignorelist=not_reached.txt ...gclang: not foundduring the build. Put gllvm onPATH(export PATH="$(go env GOPATH)/bin:$PATH").--static-libs alland overlapping archives. Archive manifests identify the exact bitcode objects each archive contains, so an archive wholly covered by an already selected archive is skipped without relying on object basenames. If selected archives still contain conflicting definitions, the merge fails rather than silently replacing one function body. Useautoor narrow the archive set when those definitions are genuinely duplicated.configurefails on a very new system. libxml2-2.9.2 predates current toolchains; the analyzer never treats warnings as errors, but if configure itself errors, add the flags the project needs (e.g.--without-lzma) to the--build-cmd.
Now the Rust path. The ziggy repository ships
an example harness that fuzzes the real url crate from several angles
(examples/url/src/main.rs). Its fuzz loop lives inside fn main():
fn main() {
ziggy::fuzz!(|data: &[u8]| {
if let Ok(string) = std::str::from_utf8(data) {
invariant_fuzz(string); // url::Url::parse, then assert an invariant
differential_fuzz(string); // parse two ways, compare
correctness_fuzz(string);
consistency_fuzz(string);
idempotency_fuzz(string);
}
});
}A ziggy harness has no LLVMFuzzerTestOneInput — the entry is the Rust
main. --lang ziggy acquires the Rust bitcode and roots there automatically.
A built analyzer and a recent nightly rustc/cargo. The analyzer's LLVM must
be at least as new as rustc's (here: analyzer 22, rustc 21.1.1 — fine).
reachability check-toolchain confirms it. --lang ziggy builds via cargo afl
under the hood, so the AFL++ LLVM plugins must be installed
(cargo afl config --build --plugins --force).
git clone https://github.com/srlabs/ziggy
cd ziggyreachability run --lang ziggy --project examples/url --clean -vWhat happens:
--lang ziggybuilds through ziggy's own driver (cargo ziggy build --no-honggfuzz) under aRUSTC_WRAPPERthat adds--emit=llvm-bc, collects each crate's.bc, merges them, and roots atmain. Collection reads the bitcode straight from each rustc--out-dir, so it does not matter thatexamples/urlis a workspace member whose target dir lives at the workspace root — noCARGO_TARGET_DIRjuggling needed.- The build matches the fuzz binary automatically. Because it is ziggy's real
build, the bitcode already carries the same
cfg(fuzzing), optimization level, and instrumentation as the binary you instrument — so the reachable set lines up.--profile releaseadds--release;--build-cmdoverrides the command for a specific target/sanitizer/profile. The--cleanabove runscargo cleanfirst, so a cached build can't leave the merge with stale or empty bitcode.
The summary on this machine:
reachable 11867 / defined 17428 (462 indirect-only, 60 low-confidence, 5561 unreachable) [backend=type-based]
Rooting at the Rust main. The token main resolves to two symbols, shown
in the JSON entries:
"entries": ["main", "_ZN8url_fuzz4main17h58c435803ec45a52E"]The first is the C-ABI shim that starts the Rust runtime; the second is the real
url_fuzz::main holding the ziggy::fuzz! body. Rooting at the bare token finds
both, so reachability is complete — you never type a mangled symbol. (Rooting at
only the C shim would reach almost nothing, since the real work hangs off
url_fuzz::main.)
Reachable — the harness and the url parser it drives: url_fuzz::main, all
five strategies (invariant_fuzz, differential_fuzz, correctness_fuzz,
consistency_fuzz, idempotency_fuzz), and url::ParseOptions::parse with the
parser internals beneath it.
Unreachable (not_reached.txt) — API the harness never calls. A clean
example: the harness only parses URLs, so the whole mutation API is
unreachable — url::Url::set_scheme, set_host, set_port, and the other 16
Url::set_* setters. Much of the Unicode/IDNA machinery (icu_*, zerovec) and
the proc-macro crates (proc_macro2, parts of syn) are unreachable too.
Over-approximation is heavier in Rust. The reachable count is large (11,867 of 17,428) because Rust dispatches pervasively through trait-object vtables. The type-based backend treats every address-taken function whose signature matches a reached indirect call site as a candidate, so once any
Debug::fmt-shaped call is reachable, same-shapedfmtimpls across dependencies (much ofsyn, parts oficu_*) are pulled in as well. That is the sound-leaning bias: it never drops a real edge. Theindirect-onlycount (462) measures functions reached only this way.
cpp_demangle is a pure-Rust demangler
for C++ (Itanium) symbols. It ships a cargo-afl harness at
src/bin/afl_runner.rs that feeds raw fuzz bytes straight into the parser:
#[macro_use]
extern crate afl;
extern crate cpp_demangle;
fn main() {
afl::fuzz!(|bytes| {
let _ = cpp_demangle::Symbol::new(bytes);
});
}Like ziggy, a cargo-afl harness keeps its fuzz loop in main, so reachability
roots at the Rust main (the --lang afl shape). reachability run --lang afl
builds with cargo afl build, but afl_runner is feature-gated
(required-features = ["afl"]), so the bin is skipped unless you pass the
feature: --build-cmd 'cargo afl build --features afl --bin afl_runner' (which
needs the AFL++ LLVM plugins installed). The manual route shown here needs
neither the plugins nor a working AFL runtime — emit bitcode with the feature
enabled, llvm-link, then run the analyzer directly. cpp_demangle pins a current
afl 0.17 that compiles cleanly on a modern toolchain; only the final link fails
(the AFL runtime is absent), which is exactly what --emit=llvm-bc expects.
git clone https://github.com/gimli-rs/cpp_demangle
cd cpp_demangleRUSTFLAGS="--emit=llvm-bc -Cembed-bitcode=yes -Ccodegen-units=1" \
cargo build --features afl --bin afl_runnerThe link fails with undefined symbol: __afl_manual_init (and
__afl_persistent_loop, __afl_fuzz_len) — AFL runtime symbols injected only by
cargo afl build. That is expected under --emit=llvm-bc; the per-crate .bc in
target/debug/deps/ are already written.
llvm-link-22 target/debug/deps/*.bc -o merged.bc # every fresh crate version/CGU; clean deps/ first
reachability-analyzer merged.bc --entry main \
--out cpp_demangle.json --reached-out reached.txt --not-reached-out not_reached.txt(reachability-analyzer is the built binary or $REACHABILITY_ANALYZER;
llvm-link-22 is the LLVM tool matching the analyzer's major. Run directly it
writes the JSON report and the two lists; reachability run wraps these steps
and additionally prints a one-line summary.)
The report's summary (in cpp_demangle.json) holds the counts:
"backend": "type-based",
"summary": { "defined": 2446, "reachable": 1142, "indirect_only": 29, "low_confidence": 18, "unreachable": 1304 }As with ziggy, --entry main resolves to both the C-ABI shim and the real Rust
main — entries: ["main", "_ZN10afl_runner4main17h…E"].
Reachable — within cpp_demangle, the parser and little past it:
afl_runner::main → the afl::fuzz! closure → cpp_demangle::Symbol::new, then
the recursive-descent grammar beneath it — the ast::*::parse_internal rules,
OperatorName / CtorDtorName, the starts_with lookahead predicates,
ParseContext, and the subs::SubstitutionTable inserts that record
back-reference candidates as the parse runs. (Most of the 1,142 total are the
Rust runtime — core / alloc / std — that the parser pulls in.)
{
"mangled": "_ZN12cpp_demangle15Symbol$LT$T$GT$3new17h66f1211aad4bb946E",
"demangled": "cpp_demangle::Symbol$LT$T$GT$::new::h66f1211aad4bb946",
"file": "src/lib.rs",
"line": 179,
"via": "direct",
"indirect_only": false
}Unreachable (not_reached.txt) — the render side of the library, because
the harness parses but never demangles. Symbol::new builds the AST; it never
calls Symbol::demangle, so the substitution-resolution helpers
(SubstitutionTable::get_type, pop, non_substitution), the back-reference
resolvers (TypeHandle::back_reference, TemplateParam::resolve, …) and the AST
accessors used only while printing (BareFunctionType::args / ret) are
defined-but-unreachable — as is the whole options-builder API
(DemangleOptions::new, no_params, recursion_limit, …) the harness never
constructs.
Dead generic code is absent, not merely unreachable. The demangler proper —
Symbol::demangle, theDemangle/DemangleWriteimpls, theDisplayrendering — appears in neither list. It is generic, and nothing in this build instantiates it, so rustc never monomorphizes it and emits no bitcode for it. Reachability can only classify functions that exist; an uninstantiated generic is invisible to it. (cpp_demangle's other harness — the cargo-fuzzfuzz/fuzzers/parse_and_stringify.rs— does callsym.demangle(); point a run at that one and the render half of the library snaps intodefined, much of it then reachable.)
The defined set includes the build toolchain.
afl0.17 pulls insemver,xdg,home, andrustc_version(its build-script and runtime-config crates); their bitcode is emitted and merged, paddingdefined(2,446), and a few even read as reachable through type-based over-approximation. The 29indirect-onlyfunctions are almost allcatch_unwind/FnOnce::call_oncevtable shims from theafl::fuzz!panic-handling machinery — the harness's dispatch surface, not the library's.
rustyknife is an email-parsing library
with a cargo-afl harness at src/bin/fuzz_mailbox.rs. Like ziggy, a cargo-afl
harness puts the fuzz loop in main, so reachability roots at the Rust main
(the --lang afl shape):
#[macro_use]
extern crate afl;
fn main() {
fuzz!(|data: &[u8]| {
let _ = rustyknife::rfc5321::mailbox::<rustyknife::behaviour::Intl>(data);
});
}Two things differ from the url walkthrough, so the native --lang afl path
(which would run cargo afl build) does not fit, and this one uses the manual
route — emit bitcode, llvm-link, then run the analyzer directly:
- The
fuzz_mailboxbin is feature-gated (required-features = ["fuzz"]), so it builds only with--features fuzz. - rustyknife pins the old
afl0.8 crate, whose bundled AFL does not compile on current toolchains — socargo afl buildcannot complete. We need only the bitcode, not AFL's runtime, so we nudge its build past two checks.
git clone https://github.com/zerospam/rustyknife
cd rustyknifeafl 0.8 builds a 2020-era AFL that trips a modern compiler's x86 self-test and
its -Werror. Skip the first with AFL_NO_X86=1, and defeat the second with
tiny compiler wrappers that append -Wno-error (any method works — the C runtime
is irrelevant to reachability):
mkdir -p /tmp/nowerror
for t in cc clang clang++; do
printf '#!/bin/sh\nexec /usr/bin/%s "$@" -Wno-error\n' "$t" > /tmp/nowerror/$t
chmod +x /tmp/nowerror/$t
done
export PATH="/tmp/nowerror:$PATH" CC=/tmp/nowerror/cc AFL_NO_X86=1
RUSTFLAGS="--emit=llvm-bc -Cembed-bitcode=yes -Ccodegen-units=1" \
cargo build --features fuzz --bin fuzz_mailboxThe final link fails (undefined reference to __afl_manual_init — AFL runtime
symbols added only by cargo afl build); that is expected under --emit=llvm-bc.
Only the per-crate .bc in target/debug/deps/ matter, and they are now there.
Match the profile and -Ccodegen-units here to the build that produces the
instrumented binary (add --release and the matching codegen-units for a release
fuzz build); they decide which monomorphizations are emitted. With
-Ccodegen-units above 1, rustc splits each crate into several
deps/<crate>-<hash>.<cgu>.rcgu.bc — pass all of them to llvm-link.
llvm-link-22 target/debug/deps/*.bc -o merged.bc # every fresh crate version/CGU; clean deps/ first
reachability-analyzer merged.bc --entry main \
--out rustyknife.json --reached-out reached.txt --not-reached-out not_reached.txt(reachability-analyzer is the built binary or $REACHABILITY_ANALYZER;
llvm-link-22 is the LLVM tool matching the analyzer's major. Run directly it
writes the JSON report and the two lists; reachability run wraps these steps
and additionally prints a one-line summary.)
The report's summary (in rustyknife.json) holds the counts:
"backend": "type-based",
"summary": { "defined": 17000, "reachable": 2233, "indirect_only": 88, "low_confidence": 76, "unreachable": 14767 }As with ziggy, --entry main resolves to both the C-ABI shim and the real Rust
main — entries: ["main", "_ZN12fuzz_mailbox4main17h…E"].
Reachable — a tight slice: fuzz_mailbox::main → afl::fuzz → the closure →
rustyknife::rfc5321::mailbox, and the nom parser combinators beneath it
(~150 nom functions, plus rfc5321 grammar rules such as dot_string).
Unreachable — rustyknife's other parsers, which this harness never calls:
the RFC 5322 message grammar (rustyknife::rfc5322::*) and the RFC 2047
encoded-word decoder (rustyknife::rfc2047::decode_text). The harness targets
only the RFC 5321 mailbox grammar, and the report reflects exactly that.
A focused harness yields a focused report. Only 2,233 of 17,000 functions are reachable, just 88 of them indirect-only. Because the harness funnels through a single parser entry, the over-approximation stays small — a sharp contrast with the
urlexample (67% reachable), where pervasive trait-object dispatch pulled in far more.