Fuzzing
plenty --fuzz FILE -o OUT builds a libFuzzer
executable from a program. libFuzzer runs the program’s main once per
input, watches which code each input reaches, and keeps the inputs that reach
new code to mutate further. Inputs that trap are saved as findings.
plenty --fuzz main.plenty -o fuzzer
./fuzzer -max_total_time=60 corpus/
./fuzzer crash-1060186d20321afe84b2d22eeca505a577c686c6 # replay one input
Fuzz targets optimize by default. --fast-compile before --fuzz skips
optimization when compilation latency matters more than fuzz throughput. --libfuzzer PATH
names libFuzzer’s archive. Discovery asks clang -print-file-name=libclang_rt.fuzzer_no_main.a first, then the native target’s
suffixed name (libclang_rt.fuzzer_no_main-aarch64.a or
libclang_rt.fuzzer_no_main-x86_64.a). It accepts only a successful query naming
an existing absolute file. LLVM’s compiler-rt supplies the archive: install
compiler-rt on Fedora, or clang and libclang-rt-dev on Debian/Ubuntu.
The build fails with a message when neither name resolves. When clang ships a
separate libclang_rt.fuzzer_interceptors archive beside the selected runtime,
Plenty links its matching generic or target-suffixed companion so libFuzzer can
learn the strings compared by the program. It never substitutes another target’s
companion. --linker and --link-arg apply as for
--compile. The libFuzzer flags are LLVM’s; -help=1 lists them.
Writing a target
The program reads the input through two C functions that the fuzz driver
defines. Declare them in a trusted module beside the program, for example
fuzz_input.plentyi:
pub extern def size() -> u64 = "fuzz_input_size"
pub extern def byte(at: u64) -> u8 = "fuzz_input_byte"
byte returns 0 past the end. A target is an ordinary program that imports
this module, rebuilds the data it needs from the bytes, and calls the code
under test:
import fuzz_input
import std.bytes
def main() -> Result[(), std.bytes.BytesError]:
mut data = std.bytes.empty()?
mut at = 0u64
while at < fuzz_input.size():
data.push(fuzz_input.byte(at))?
at += 1u64
# parse `data` here
Ok(())
A finding is a fatal runtime error such as an index out
of range or unwrap of an Err, stack exhaustion on the thread running
inputs (see the driver), or main ending with a nonzero
status, including Err. libFuzzer reports the first as “fuzz target
exited” and the others as “deadly signal”, saves the input, and exits
with status 77. To check a property, return Err when it fails.
Each input runs the whole program, as the runtime’s main would, in the same
process, so state outside the program, such as files it writes, persists
between inputs. A target that serves a network protocol should feed the input
through an in-memory stream rather than a socket; fuzz/http_server/main.plenty
does this for std.http.server.
Instrumentation
Every block of every function, including the standard library’s, increments
its own byte counter when it runs. The counters and a table of their
functions are laid out as clang’s -fsanitize-coverage=inline-8bit-counters,pc-table
lays them out, in sections __sancov_cntrs and __sancov_pcs; the driver
passes both to libFuzzer. Fuzz targets keep their symbol table and debug
sections, unlike --compile output. cov: in libFuzzer’s output counts the blocks
reached, and ft: the features, which also distinguish how often a block
ran.
Blocks are counted, not edges: a branch whose arms meet without running a
block of their own adds nothing. Integer comparisons are not traced. String
and byte-string equality reach memcmp, and libFuzzer’s interceptors,
linked from libclang_rt.fuzzer_interceptors.a beside the --libfuzzer
archive when it exists, report the strings compared, which libFuzzer then
tries in inputs. A dictionary (-dict=FILE) of the format’s keywords helps
with the rest.
Instrumented functions are cached like any other; their entries are separate
from those of ordinary builds. Fuzz builds keep the counters and table of
every function, so --gc-sections drops nothing they compile. Ordinary
builds contain neither the counters nor the driver.
The driver
plenty-runtime/native/fuzz_driver.c is compiled with the compiler and
linked only into fuzz builds, with -Wl,--wrap=main: the C library starts
the driver, which registers the counters and calls libFuzzer’s
LLVMFuzzerRunDriver, and each input calls the runtime’s main. libFuzzer
is C++, so the link adds -lstdc++.
Parallel runs use -jobs=N -workers=N, independent processes sharing the
corpus directory. With the interceptors linked, libFuzzer’s -fork mode
crashes its own parent process within seconds (clang 22.1.8), reporting a
“deadly signal” with an empty input; it is not a finding.
On Linux, libFuzzer’s RSS limit uses getrusage(RUSAGE_SELF).ru_maxrss,
the process’s peak resident memory. Linux preserves this peak across exec,
so a target launched directly by a large process can report an OOM before
doing meaningful work. The integration tests first exec a small Python
interpreter, then spawn the target from that interpreter’s small address
space. An exec alone does not reset the peak. The tests retain a 2048 MiB
per-target limit and report resource-limit failures (oom-* / timeout-*)
separately from expected crash-* findings with exit status 77. The HTTP
fuzz runner already spawns its target from Python.
See LLVM’s peak RSS implementation
and resource-limit handling.
libFuzzer’s handlers for SIGSEGV and SIGBUS, which save the input, run
on the faulting thread’s stack. So that they still run when a program has
exhausted its stack, the driver gives the thread running inputs a 256 KiB
alternate stack and moves the handlers onto it. The runtime’s own
stack-overflow message is not printed in fuzz builds.
Only the thread running inputs has the alternate stack. A stack overflow
on a thread the program spawns kills the process without saving the input.
Without AddressSanitizer libFuzzer has no leak checking or stack traces. Plenty code needs neither for memory safety; a trap prints its source position before libFuzzer’s report.
The target is the installed compiler’s GNU/Linux architecture (see linking). ARM libFuzzer/tool discovery and sanitizers require separate qualification; a successful ordinary ARM build does not prove their availability.