more fuzzing fun

This commit is contained in:
Christian Grothoff
2026-07-29 17:13:46 +02:00
parent d43e9a6cb6
commit d623ab0f69
2503 changed files with 1480 additions and 29 deletions
+35 -2
View File
@@ -48,11 +48,17 @@ to the build system is not required and not wanted.
make_seed_corpus.sh packages src/fuzz/corpus/ into
$OUT/<fuzzer>_seed_corpus.zip
fuzz_request.options per-harness libFuzzer options (max_len, dict)
fuzz_options.options
fuzz_eventloop.options
fuzz_str.options
fuzz_memorypool.options
fuzz_auth_header.options
fuzz_postprocessor.options
dicts/fuzz_request.dict per-harness fuzzing dictionaries
dicts/fuzz_options.dict
dicts/fuzz_eventloop.dict
dicts/fuzz_str.dict
dicts/fuzz_memorypool.dict
dicts/fuzz_auth_header.dict
dicts/fuzz_postprocessor.dict
README this file
@@ -64,7 +70,7 @@ the corpus packager live here and not in the OSS-Fuzz repository.
-------------------------------------------------------------------
2. The four fuzz targets
2. The fuzz targets
-------------------------------------------------------------------
fuzz_request a real struct MHD_Daemon driven over a
@@ -75,13 +81,28 @@ the corpus packager live here and not in the OSS-Fuzz repository.
constructor, the authentication entry point,
the event-loop API, and whether the
connection is suspended or upgraded
fuzz_options the daemon configuration surface: the input
picks the MHD_FLAG bits and the MHD_OPTION
array, reaching the flag validation, the
thread pool, the internal polling modes, the
listen socket and the connection limits
fuzz_eventloop the external event loop: the input is a
program of opcodes that polls, ignores
timeouts, mismatches descriptor sets and
suspends/resumes against a live daemon
fuzz_str the mhd_str.c primitives with exactly-sized
output buffers
fuzz_memorypool memorypool.c, the per-connection bump
allocator, with the harness's own oracles
fuzz_auth_header MHD_get_rq_dauth_params_() /
MHD_get_rq_bauth_params_()
fuzz_postprocessor MHD_post_process()
All four are single translation units that export
src/fuzz/ has one more harness, `fuzz_tls`, which is deliberately absent
here: it needs a TLS backend and this build is --disable-https (see
section 3).
All seven are single translation units that export
int LLVMFuzzerTestOneInput (const uint8_t *data, size_t size);
@@ -107,8 +128,20 @@ harness ignores the extra bytes, so libFuzzer does not waste its budget:
and a body-oracle declaration. Larger
inputs are safe -- every segment is bounded
independently -- but buy almost nothing.
fuzz_options 8192 the flag/option block plus one short
request; the request half is what makes
the tail of the input worth anything, so
this tracks fuzz_request rather than the
(much smaller) option block alone.
fuzz_eventloop 512 configuration byte + the opcode program.
Opcodes are one byte each and the program
is bounded, so a longer input only adds
opcodes that are never interpreted.
fuzz_str 514 2 selector bytes + the payload, which the
harness truncates to 512.
fuzz_memorypool 200 the smallest of the set: a pool size and a
short program of allocate/reallocate/reset
operations, all bounded.
fuzz_auth_header 4097 1 selector byte + the Authorization header
value, truncated to 4096.
fuzz_postprocessor 8196 4 selector bytes + the POST body, truncated
+14
View File
@@ -40,6 +40,7 @@ OUTDIR="${2:-${OUT:-$(pwd)/out}}"
CORPUS="${SRCDIR}/src/fuzz/corpus"
FINDINGS="${CORPUS}/known-findings"
DISTILLED="${CORPUS}/distilled"
PATCHES="${SRCDIR}/patches"
FUZZERS="fuzz_request fuzz_options fuzz_eventloop fuzz_str fuzz_memorypool fuzz_auth_header fuzz_postprocessor"
@@ -98,6 +99,19 @@ for fuzzer in ${FUZZERS}; do
done
fi
# distilled/ is the edge-minimal residue of a fuzzing campaign, named
# "<harness>-dNNN.bin" and therefore routed by prefix like the seeds
# above. It is by far the largest part of the seed corpus and the
# reason a fresh ClusterFuzz run starts near the coverage the last
# campaign reached instead of climbing back to it.
if [ -d "${DISTILLED}" ]; then
for f in "${DISTILLED}/${fuzzer}"-*.bin; do
[ -f "${f}" ] || continue
cp "${f}" "${dir}/distilled-$(basename "${f}")"
n=$((n + 1))
done
fi
if [ "${n}" -eq 0 ]; then
echo "ERROR: no seeds found for ${fuzzer} in ${CORPUS}" >&2
exit 1
+10 -3
View File
@@ -146,19 +146,26 @@ refresh-corpus: $(check_PROGRAMS)
# Replay the whole on-disk corpus through every harness; this is what a
# CI regression run should do after a crash has been fixed.
#
# corpus/known-findings/ is listed separately because --corpus-dir does
# not recurse. It holds the reproducers of the findings in README
# corpus/known-findings/ and corpus/distilled/ are listed separately
# because --corpus-dir does not recurse.
#
# known-findings/ holds the reproducers of the findings in README
# section 6; all of them are fixed, so anything failing there is a
# regression. The one intended exception is a reproducer committed while
# its finding is still open: that fails here until the fix lands, which
# is what a regression test for an unfixed bug is supposed to do.
#
# distilled/ is the edge-minimal residue of a fuzzing campaign; see
# corpus/README. It is the slowest part of check-corpus by file count
# and the most valuable part by coverage.
#
# Note that these only bite on a tree configured with --enable-asserts;
# without it mhd_assert() compiles away and most of these reproducers
# pass whether MHD is fixed or not.
CORPUS_DIRS = \
$(srcdir)/corpus \
$(srcdir)/corpus/known-findings
$(srcdir)/corpus/known-findings \
$(srcdir)/corpus/distilled
.PHONY: check-corpus
check-corpus: $(check_PROGRAMS)
+117 -3
View File
@@ -1,7 +1,7 @@
GNU libmicrohttpd -- in-process fuzzing harnesses
=================================================
This directory contains four in-process fuzzing harnesses for MHD. All
This directory contains eight in-process fuzzing harnesses for MHD. All
of them are *dual mode*:
* they export the libFuzzer entry point
@@ -46,6 +46,50 @@ fuzz_postprocessor.c fuzzing of MHD_post_process() with random
boundaries), random post-processor buffer sizes
and random chunking of the POST data.
fuzz_options.c the daemon *configuration* surface. fuzz_request
always starts the daemon in one shape; this one
lets the input pick the MHD_FLAG bits and the
MHD_OPTION array, so the flag validation in
MHD_start_daemon(), parse_options_va(), the
internal polling thread, the thread pool,
epoll/poll/select, the listen socket, the per-IP
and per-daemon connection limits and quiesce all
become reachable.
fuzz_eventloop.c the external event loop and the scheduling of the
connection life cycle. The input is not a
request but a *schedule*: a program of one-byte
opcodes interpreted against a live daemon, so the
application can poll at adversarial times, ignore
the timeout it was given, call
MHD_run_from_select() with descriptor sets that
do not match what MHD asked for, and
suspend/resume across those calls. Byte 3 bit 4
also lets it stop the daemon with a connection
queued by MHD_add_connection() but never
started, which is the only way into
new_connection_close_() -- see section 5.5.
fuzz_memorypool.c direct fuzzing of src/microhttpd/memorypool.c,
the per-connection bump allocator. A single
mis-computed offset there is a cross-request
information leak that ASAN cannot see on its own,
because the whole pool is one malloc()ed object;
the harness therefore carries its own oracles and
knows the red zone size that
MHD_ASAN_POISON_ACTIVE adds between two blocks.
fuzz_tls.c MHD's own TLS plumbing -- the MHD_USE_TLS option
surface (HTTPS_MEM_KEY/CERT/TRUST/DHPARAMS,
PRIORITIES, CRED_TYPE, KEY_PASSWORD, ALPN, SNI,
GNUTLS_PSK_CRED_HANDLER) rather than GnuTLS,
which has its own OSS-Fuzz project. Byte 9 bit 7
switches on the TLS-PSK scenario, the only route
to psk_gnutls_adapter() -- see section 5.5. It
needs a TLS backend and is therefore the one
harness contrib/oss-fuzz/build.sh does not build
(that build is --disable-https).
Shared code lives in `fuzz_common.h` (header-only, so every harness
stays a single translation unit).
@@ -426,6 +470,45 @@ finds each of them on its own within a few thousand iterations.
immediately. The target models a *caller*, not the library, so it
is off by default.
5.5 Code that only a deliberate scenario reaches
A coverage-guided engine gets to a branch by mutating towards it, so
it never gets to one that needs several unrelated configuration
bytes to be right at the same time: the intermediate inputs score no
better than the ones around them, and the fuzzer has no gradient to
follow. An 8 hour, 12 core, 1.6 billion execution campaign, on top
of the whole test suite, left exactly three library functions at zero
coverage for that reason. All three now have a scenario:
psk_gnutls_adapter() (daemon.c) -- MHD's only piece of TLS code that
takes a buffer straight from an application callback. Needs
GNUTLS_CRD_PSK, a PSK-capable priority string *on both ends*
("NORMAL" has none), MHD_OPTION_GNUTLS_PSK_CRED_HANDLER, and a
client offering a PSK identity. fuzz_tls byte 9 bit 7 switches all
four on together; bits 3-5 then pick which arm of the adapter to
exercise (missing callback, failing callback, 4 KiB key, key length
taken from the identity the client sent, size above UINT_MAX, and
the two sizes below the MHD_PSK_MIN_SIZE the adapter enforces --
zero and one byte short, which together with the exactly-minimum
PSK_OK bracket that check from both sides). Seeds `psk-*`.
new_connection_close_() (daemon.c) -- frees a connection that was
accepted but never started. On a thread-safe daemon (the default,
including a pure external event loop) MHD_add_connection() only
queues the socket; the connection object is built by the next run.
The function is reachable only if the daemon is stopped before that
run happens, and every harness used to call MHD_run() once more
during teardown. fuzz_eventloop byte 3 bit 4 queues one connection
after the teardown and stops without running. Seeds
`stop-with-queued-connection`, `stop-with-queued-after-quiesce`.
It is not a race: no second thread is involved.
MHD_check_response_header_token_ci() (response.c) -- this one was
not a fuzzing problem. The function was added in 2017 and never
called, so no input could reach it. The single place that wants it,
the "Connection: upgrade" check in MHD_queue_response(), open-coded
the scan against response->first_header instead; that now calls the
helper, and the existing `ext-upgrade` seed and K7 cover it.
-------------------------------------------------------------------
6. Findings against MHD 1.0.7 - all fixed, kept as regressions
@@ -695,15 +778,46 @@ K14 daemon.c:1358 call_handlers():
confirm it against the OSS-Fuzz build configuration.
Repro: corpus/known-findings/K14-fuzz_eventloop-force-close-not-closed.bin
K15 daemon.c:9201 close_all_connections():
mhd_assert (MHD_D_IS_USING_THREADS_ (daemon))
Fixed; found while giving new_connection_close_() its first coverage
(section 5.5).
Three places describe the same invariant and one of them disagreed.
internal_add_connection() queues a connection when
`external_add && MHD_D_IS_THREAD_SAFE_(daemon)`, and
new_connections_list_process_() asserts MHD_D_IS_THREAD_SAFE_ --
that is, whenever the daemon was not started with
MHD_USE_NO_THREAD_SAFETY, which includes every external event loop.
The shutdown drain asserted the much stronger
MHD_D_IS_USING_THREADS_ instead, i.e. that the daemon has an
internal polling thread.
So an application that drives an external event loop, calls
MHD_add_connection() and then MHD_stop_daemon() before the next
MHD_run() aborts on an --enable-asserts build. No threads and no
race are involved, and a build with NDEBUG handles the same sequence
correctly, which is why nothing had noticed: the list is exactly what
holds the connection at that point and new_connection_close_() frees
it properly.
The assert was the outlier and now matches the other two sites. The
opposite reading -- that an external event loop should not queue at
all and internal_add_connection() should test
MHD_D_IS_USING_THREADS_ -- would change MHD_add_connection()'s
threading contract, since queueing is what makes that call safe from
another thread on a daemon with no thread of its own.
Repro: corpus/known-findings/K15-fuzz_eventloop-stop-with-queued-connection.bin
Status
------
K1-K12 are fixed on master:
K1-K12 and K15 are fixed on master:
K1 300a2ab0 K4a 0b750975 K7 acef58a0 K10 (memorypool)
K2 68c83f22 K4b 0b750975 K8 07c051dd K11 (memorypool)
K3 e04eb218 K5 6fcdfd43 K9 (postproc) K12 (daemon/TLS)
K6 f438804c
K6 f438804c K15 (daemon)
K13 and K14 are open, with proposed fixes in ../../patches/. Until K14
lands, `make check-corpus` fails on an `--enable-asserts` build, because
+80
View File
@@ -128,6 +128,76 @@ fuzz_postprocessor seeds
Content-Type.
distilled/
----------
The edge-minimal residue of a fuzzing campaign: 2467 inputs, 222 KB,
named `<harness>-dNNN.bin` and routed by that prefix exactly like the
seeds above.
Unlike `<harness>-NN.bin` these are *not* generated from a built-in
table, so `refresh-corpus` neither writes nor clobbers them; and unlike
`known-findings/` they are not hand-edited and carry no individual
meaning. Do not document them one by one -- the set is only ever
regenerated wholesale.
Provenance: an 8 hour, 12 core, 1.62 billion execution campaign
(ASan+UBSan, libFuzzer, hourly restarts) starting from the seed corpus
above. The campaign corpus was 18 226 inputs / 12 MB, which is too much
to carry in a source tree, so it was reduced by greedy set cover over
*edges* rather than by `-merge=1`, which minimises for features and
keeps roughly seven times as many files:
<target> -merge=1 -merge_control_file=mcf.txt <empty dir> <corpus>
writes a `COV` line per input, and the cover is computed from those
offline. Because these harnesses are mildly non-deterministic, the
cover is built only from edges that two independent merges agree an
input reproduces -- the same "several independent merges" rule stated at
the top of this file.
**An engine-produced corpus is not pristine, and this directory has to
be filtered before it can be committed.** `fuzz_request` inputs carry a
ground-truth body declaration in their `op == 1` segment, and the body
oracle is armed only for a `fuzz_pristine` input precisely because
"random mutations invalidate such declarations" (`fuzz_common.h`).
libFuzzer mutates that segment independently of the wire segments, so a
campaign corpus is full of inputs whose declaration no longer describes
their own request -- 383 of 6884 here, 5.6%. Under libFuzzer that is
harmless: `-DFUZZ_NO_MAIN` compiles out every assignment to
`fuzz_pristine`, so the oracle never arms and the campaign never
notices. But `--corpus-dir` sets `fuzz_pristine = 1` for *every* file it
reads, so replaying such an input reports a "request body desync"
against a declaration that was never true -- a false positive, and one
that looks exactly like a request-smuggling finding.
Committing the raw cover therefore broke `make check-corpus` with 42
such reports. The fix is to sweep every candidate with
`--file=` first and exclude the ones that fire *before* computing the
cover, which is what produced the set in this directory; all 2467 files
replay clean through all eight harnesses. Anyone replaying a downloaded
ClusterFuzz corpus locally will hit the same thing, and it is not a bug
in MHD.
The set reaches ~99% of the campaign's edges, not 100%: 3763/3798 for
fuzz_request, and exactly 100% for the four direct-API harnesses, which
are deterministic. Two thirds of that shortfall is the exclusion above
-- 64 edges were reached only by inputs with a stale declaration -- and
the rest is edges no single input reproduces reliably. Forcing the
latter in by adding every input that covers a rare edge was tried and
moved the number by one, so that part is inherent to minimising rather
than a fixable omission. Trading 1% of edges for 86% fewer files is the
intended bargain. Do not chase it.
To regenerate after a long campaign: keep the campaign corpus, sweep it
for oracle-firing inputs, run two independent merges over what is left,
recompute the cover, and replace this directory wholesale. Check the
result the same way it was checked here -- replay both the campaign
corpus and the distilled set with `-runs=0` three times each and compare
the `INITED cov:` figures, because a single measurement of either is
worth a few edges of noise -- and finish with `make check-corpus`.
known-findings/
---------------
@@ -179,6 +249,16 @@ hand-written input would be clobbered or renumbered by the next
Only fires on an --enable-asserts
*ASan* build: the redzones change
the pool arithmetic that reaches it.
K15-fuzz_eventloop- daemon.c close_all_connections()
stop-with-queued-connection.bin asserted that only a daemon with an
internal polling thread can have
connections queued by
MHD_add_connection(), while the code
that fills that list queues for any
thread-safe daemon. An external
event loop that adds a connection and
stops before the next MHD_run()
aborts on an --enable-asserts build.
These files are *not* regenerated by `--write-corpus`; they are edited by
hand. When the input format changes they have to be migrated, and the
Binary file not shown.
@@ -0,0 +1 @@
B:
@@ -0,0 +1 @@
�s
@@ -0,0 +1 @@
�s
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Basic Y
@@ -0,0 +1 @@
SHA5 2
@@ -0,0 +1 @@
Basic
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest qop="\z"
@@ -0,0 +1 @@
"a
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Basic 45]f
@@ -0,0 +1 @@
Digest nc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Basic dXNlcjpwYXNz
@@ -0,0 +1 @@

@@ -0,0 +1 @@
i:n¤st
@@ -0,0 +1 @@
Basic
Binary file not shown.
@@ -0,0 +1 @@
Basic Q Q
Binary file not shown.
@@ -0,0 +1 @@
Digest ,
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest nc="""
@@ -0,0 +1 @@
Basic dXNÿcjNz
@@ -0,0 +1 @@
Basic QQas(c$Q
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest qop="A\D"
Binary file not shown.
@@ -0,0 +1 @@
@Digest qop="a\uth"
@@ -0,0 +1 @@
逑レレレレレレレレレレレレfレレレレレレレレレレレレ)=レレレレレレレレレレレレレレレレレレレ/レレレレレレレレレレレes
@@ -0,0 +1 @@
f˙˙˙˙˙˙˙˙˙˙˙˙rfrealm="Te"f˙˙˙˙˙˙stRealm"f˙˙˙˙˙˙˙˙˙˙˙ffff"ťfffff
@@ -0,0 +1,2 @@
BstReamm:peeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee '
捏
Binary file not shown.
@@ -0,0 +1 @@
eg
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Basic ;
@@ -0,0 +1 @@
Basic ,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest "x
@@ -0,0 +1 @@
Basic ====
@@ -0,0 +1 @@
Basic QQ==
Binary file not shown.
@@ -0,0 +1 @@
Basic dXN{
@@ -0,0 +1 @@
Basic 1QQ=
@@ -0,0 +1 @@
basic zzz=
@@ -0,0 +1 @@
Basic sh==
@@ -0,0 +1 @@
Basic E(=J
@@ -0,0 +1 @@
Basic QQ=-
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
basic QëDigest
@@ -0,0 +1 @@
Basic QQ==QQ==
@@ -0,0 +1 @@
Basic =QQ==QQ=
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest qop="\D"
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest qop="a\!"
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
@Digest qop="auth\Ò"
Binary file not shown.
@@ -0,0 +1 @@
Digest userhash="\g"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest algorithm="\¯"
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest algorithm="\MD5"
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
Digest algorithm=MD5-sess
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More