Skip to content

Fuzz harnesses for the command parser and the reply stream (#144) - #148

Merged
linuxnow merged 17 commits into
mainfrom
cluster/plugin-hostd-144-fuzz
Oct 24, 2026
Merged

linuxnow merged 17 commits into
mainfrom
cluster/plugin-hostd-144-fuzz

Conversation

@linuxnow

@linuxnow linuxnow commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Two libFuzzer harnesses, built with clang under ASan and UBSan (-fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all), with a seed corpus, a coverage witness and one mutant per property, run by a new fuzz workflow on every push and pull request. The chunking test of ci.yml stays.

fuzz_command drives verbs_receive and the dispatcher of src/verbs.c, with the supervisor, pins, descriptors and socket send replaced by a recorder (fuzz/recorder.c) that answers each verb's declared reply or an input-chosen refusal. Properties: one NUL-terminated reply of a declared shape per message, passed on unchanged; a parsed verb is acted on only with an argument count its declared syntax allows; a command's words joined by one space make the same calls, reply and state; a refused command changes nothing.

fuzz_reply includes src/relay.c and answers its recv and send. Properties: the buffer of an unfinished line stays bounded; the controller reads every complete line once and in order however the stream is cut, against a reference splitter; every line the relay writes of its own is an event as written or the end of a gesture still held.

Mutants (fuzz/mutants/, one per property): make fuzz-sabotage applies each and requires a seed to abort in that property's own check_* function. All 7 are found.

The harnesses found these, fixed here in their own commits:

  • Behaviour change (7c6be04): worker_list, supervisor_reset and quit acted on words their grammar does not take. worker_list x answered resp 0 with the list, supervisor_reset all x reset, and quit now answered resp 0 and stopped the daemon. Each now answers resp -902 (ERR_INVALID_OPERATION) and does nothing, as mod-host refuses a command with too many arguments. A controller sending the bare verbs, or supervisor_reset <worker|all>, sees no change. Daemon test: a_word_past_the_grammar_of_a_daemon_verb_is_refused.
  • Behaviour change (deb0c1b): a tab at the end of a command is now trimmed like a space. param_set 0 gain 6\r\t used to reach the worker with \r\t and a last word of 6\r; it now reaches it as param_set 0 gain 6. Daemon test: the_whitespace_that_ends_a_command_is_not_part_of_its_last_word; seed trailing-cr-tab. The round-trip property no longer leaves anything out.
  • Fix (7d5441d): the end of a held gesture longer than 1024 bytes went out cut short, with no end word. Guarded in make test by a case in tests/gesture_alloc_test.c (red on the old relay) and by the seed gesture-long.

Build floors: mod-host-protocol-devel >= 0.10.6.f14a230-11 in the spec, which the fuzz workflow reads, and libmod-host-protocol-dev (>= 0.1.0-5) in debian/control. These are the first channel releases with the mod-audio/mod-host#109 headers, the only ones check-generated reproduces protocol/mod-host.json from; I checked every release in both channels.

make fuzz-sabotage passes a mutant only when every seed that aborts does so in that mutant's own check_* function.

Verified locally (clang 22, Fedora 44): make fuzz, make fuzz-run FUZZ_TIME=30 (no crash), make fuzz-sabotage (7 of 7, every aborting seed in its own property), make fuzz-coverage (command 153 of 153 lines, reply 189 of 210; the rest are allocation-failure paths, the relay thread and fd guards), make fuzz-corpus-check, make test.

Closes #144

protocol.h declares worker_list with no argument, supervisor_reset with
at most one and mod-host's quit with none, but the daemon acted on any
number of words after them. Each now answers ERR_INVALID_OPERATION to a
word past its grammar, as mod-host refuses a command with too many
arguments.
The end of a gesture the relay holds was written through a 1024-byte
buffer, so a gesture whose line was longer reached the controller cut
short, without its end word, and the control stayed held. The held line
and the end word now go out as two writes under the relay's lock, one
line of any length.
fuzz/fuzz_command.c is a libFuzzer harness over src/verbs.c: a run of
controller messages, each in an allocation of exactly its length, with
everything below the dispatcher (supervisor, pins, descriptors, the
socket's send) replaced by the recorder of fuzz/recorder.c. The recorder
answers with the reply each verb declares in protocol.h, or with the
refusal the input chose, and keeps a digest of what accepted calls
changed.

It checks four properties, each an abort(): one NUL-terminated reply of
a declared shape per message, passed on unchanged; a verb the daemon
parses is acted on only with an argument count its declared syntax
allows; a command's words joined by one space make the same calls; and a
refused command changes nothing.
fuzz/fuzz_reply.c includes src/relay.c, as tests/gesture_alloc_test.c
does, and answers its recv and send itself: the input chooses what each
worker writes, where each read cuts it, when a worker closes or is
removed, which instance goes, and how the controller's socket takes the
lines. A reference splitter reads the same bytes.

Its properties: the buffer of an unfinished line stays bounded; the
controller reads every complete line once, in order, however the stream
was cut; and every line the relay writes of its own is an event as
written or the end of a gesture still held. The relay's line limit can
now be set at build time, so the harness reaches the drop of a long line
with inputs of a few kilobytes.
fuzz/gen_corpus.py writes the seed corpus from protocol/plugin-hostd.json
and protocol/mod-host.json, every verb with sample arguments, with its
optional words, with one word too many, refused by the supervisor and
split by tabs, and from the send lines of README.md and docs/own-host.md.
The reply seeds cut worker feedback at every byte and before every NUL,
hold and end gestures, and put lines at and past the line limit.
make fuzz builds both harnesses into build/fuzz/ with clang,
-fsanitize=fuzzer,address,undefined and -fno-sanitize-recover=all,
nothing of the ordinary build touched. make fuzz-run fuzzes each from its
corpus for FUZZ_TIME seconds and leaves a crashing input and its log in
fuzz/findings/. make fuzz-corpus writes the corpus again, and
fuzz-corpus-check fails when the committed one is not what it writes.
fuzz/mutants/<harness>-<property>.patch is a one-line bug for each
property a harness checks. make fuzz-sabotage applies each, rebuilds,
runs the harness over its seeds one at a time and is red unless a seed
makes it abort in check_<property>; a crash in another property does not
count. The source is restored with git checkout on every exit path.
make fuzz-coverage builds each harness for llvm-cov, runs it once over
its corpus and prints the line coverage of the functions it drives and
every line no seed reaches. fuzz/coverage-floor.txt holds the covered
lines of each, 153 of 153 for the dispatcher and 189 of 210 for the
relay, and the target is red below them, or when a property check of a
harness is reached by no seed (fuzz/checks.awk, as libreac has it).
The fuzz workflow builds both harnesses in a fedora:44 container with
the mod-host protocol headers of the FreeMixer channel, checks the
corpus is current, fuzzes each for 60 s, finds every mutant and holds
the coverage floor, and uploads a crashing input as fuzz-findings.
The tokenizer takes a tab for whitespace, but the end of a command was
trimmed of newlines, carriage returns and spaces only. A command ending
in a carriage return and a tab reached the worker with both, and its
last word kept the carriage return, while the same command without the
tab lost it. A trailing tab is now trimmed too.
With a tab at the end trimmed like the rest of the whitespace, a
command's words joined by one space are read back as the same words
whatever its last word ends in, so the round-trip property no longer
leaves out a last word ending in a carriage return or a newline. The
seed trailing-cr-tab is the command that was left out.
tests/gesture_alloc_test.c holds a 2000-byte gesture and ends it over a
socket pair standing for the controller: what arrives is the held line,
" end" and one NUL. Before the relay wrote the end in two parts this
came out cut at 1024 bytes, with no end word, and the case is red.
make fuzz-sabotage stopped at the first seed that aborted in the
mutant's own check_ function, so a mutant that also broke another
property passed. It now runs every seed and is red unless at least one
aborts and each that aborts does so in an abort() whose innermost check_
frame is the property the mutant names; a sanitizer report or another
property's check makes the mutant red.
protocol/mod-host.json is generated from mod-host's headers and the
build's check regenerates it; only the headers carrying param_info,
remote_pages and track_info (mod-audio/mod-host#109) give it back
unchanged. In the FreeMixer channel those are mod-host-protocol-devel
0.10.6.f14a230-11 (audinux's 11 and 12 carry them too) and
libmod-host-protocol-dev 0.1.0-5: every earlier release there gives
another mod-host.json, and those before 0.10.6.f14a230-8 do not compile
tests/fake_host.c. The spec and debian/control declare these floors,
and the fuzz workflow installs the headers at the spec's floor.
worker_list, supervisor_reset and quit refuse a word past their grammar
with -902, a trailing tab is trimmed like a space, and a held gesture
longer than 1024 bytes is ended whole. The build needs the mod-host
protocol headers of release 11 (0.1.0-5 for the DEBs) or newer. The
command parser and the reply stream have fuzz harnesses (#144).
@linuxnow
linuxnow merged commit 45ef53e into main Oct 24, 2026
36 checks passed
@linuxnow
linuxnow deleted the cluster/plugin-hostd-144-fuzz branch October 24, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

libFuzzer harnesses for the command parser and the reply stream, under ASan and UBSan in CI

1 participant