GNU libmicrohttpd -- in-process fuzzing harnesses
=================================================
This directory contains four in-process fuzzing harnesses for MHD. All
of them are *dual mode*:
* they export the libFuzzer entry point
int LLVMFuzzerTestOneInput (const uint8_t *data, size_t size);
so the very same source can be linked with clang/libFuzzer, AFL++ or
OSS-Fuzz, and
* they ship a **built-in standalone driver** (`fuzz_common.h`) with a
deterministic, seeded generator + mutator loop, so they are useful
with nothing but gcc and `-fsanitize=address,undefined`.
The standalone driver is compiled unless `FUZZ_NO_MAIN` is defined.
-------------------------------------------------------------------
1. The harnesses
-------------------------------------------------------------------
fuzz_request.c the flagship. Feeds arbitrary bytes into a real
`struct MHD_Daemon` through a `socketpair()`,
using MHD_USE_NO_LISTEN_SOCKET +
MHD_add_connection() and external polling
(MHD_run()). Everything runs in one thread, so
the harness is deterministic and fast (~20k
requests/s under ASAN+UBSAN).
fuzz_str.c direct fuzzing of the string primitives in
src/microhttpd/mhd_str.c. Every output buffer is
malloc()ed at *exactly* the documented size so
that ASAN's redzone catches a one-byte overrun.
fuzz_auth_header.c direct fuzzing of the "Authorization:" header
parsers, MHD_get_rq_dauth_params_() and
MHD_get_rq_bauth_params_() (gen_auth.c), through
a minimal fabricated `struct MHD_Connection`.
~250k execs/s.
fuzz_postprocessor.c fuzzing of MHD_post_process() with random
Content-Type (urlencoded / multipart with random
boundaries), random post-processor buffer sizes
and random chunking of the POST data.
Shared code lives in `fuzz_common.h` (header-only, so every harness
stays a single translation unit).
-------------------------------------------------------------------
2. Design of fuzz_request
-------------------------------------------------------------------
2.1 Why a socketpair
--------------------
MHD_add_connection() accepts any already-connected socket, so a
`socketpair(AF_UNIX, SOCK_STREAM)` is enough: no listen socket, no port,
no TCP stack, no second thread. The daemon is started with
MHD_USE_NO_LISTEN_SOCKET and *without* MHD_USE_INTERNAL_POLLING_THREAD,
and the harness pumps it with MHD_run() between sends. A fake
127.0.0.1 `struct sockaddr_in` is passed so that per-IP accounting and
MHD_get_connection_info() see something sane.
2.2 Input format
----------------
byte 0 connection memory limit selector
(index into {default,128,192,256,320,384,512,768,1024,
1400,1500,2048,4096,32768})
byte 1 handler behaviour bitmask
0x01 call MHD_digest_auth_check3() /
MHD_queue_auth_required_response3()
0x02 call MHD_basic_auth_get_username_password3()
0x04 run the request body through MHD_post_process()
0x08 iterate MHD_get_connection_values() over headers,
GET arguments, cookies and footers
0x10 unused (see 2.2.1)
0x20 reply with a larger, copied response body
(only meaningful for response kind 0)
0x40 reply 403 instead of 200
0x80 unused
byte 2 low nibble: MHD_OPTION_CLIENT_DISCIPLINE_LVL selector
(index into {-3,-2,-1,0,1,2});
high nibble: reserved for MHD_OPTION_SERVER_INSANITY
(MHD 1.0.7 only defines MHD_DSC_SANE, so the value is 0)
byte 3 digest configuration: bits 0-1 select the algorithm of the
401 challenge {SHA-256, MD5, SHA-512-256, SHA-256},
bit 2 selects the QOP, bits 4-5 select MHD_OPTION_NONCE_NC_SIZE
byte 4-9 the API-selection block, see 2.2.1
byte 10. a sequence of *send segments*. Each segment starts with a
little-endian 16 bit header:
(op << 14) | length length <= 0x3FFF
op 0 send `length` bytes on the current connection
op 1 the payload is the *expected decoded request body*
(ground truth for the body oracle, see 2.4); it is
not sent
op 2 send, then pump the daemon for extra rounds
op 3 close the current connection, open a fresh one on
the same daemon, then send
An explicit length encoding (rather than a magic delimiter) is used so
that the fuzzer can move a split point without having to invent an
escaping scheme. Splitting matters: MHD's parser is incremental and
several bugs only appear for particular split points.
An input shorter than ten bytes is rejected: the configuration block is
mandatory, and an input that short has no segment stream either.
2.2.1 The API-selection block
-----------------------------
Bytes 4-9 pick which parts of the public API the iteration touches.
They are always present. Bytes 0-3 alone decide how MHD *parses* the
request; these six decide which of the response constructors, which
authentication entry point, which event loop, and which introspection
calls run against the parsed result.
The all-zero setting is the plainest one -- a static two byte buffer
response, MHD_run() as the event loop, no suspend, no upgrade, no extra
introspection -- so zeroing bytes 4-9 of any input reduces it to the
request-parsing-only behaviour that the harness had before these bytes
existed.
These bytes were gated behind bit 0x10 of byte 1 while they were being
brought up, so that the corpus predating them kept its byte-exact
meaning. The gate is gone; bit 0x10 is left unused rather than
reassigned, so that a corpus file written while it was a gate cannot
silently change meaning. The reproducers in known-findings/ that predate
the change (K1-K6, seven files) were migrated by inserting six zero bytes
at offset 4 -- the identity transformation, since the all-zero block is
the old behaviour -- and all seven still drive MHD along their recorded
paths, verified by comparing the --verbose daemon, handler, body and
challenge counts before and after.
byte 4 response construction
bits 0-3 which constructor to use:
0 buffer_static 1 buffer_copy 2 empty
3 buffer/PERSISTENT 4 buffer/MUST_FREE 5 buffer/MUST_COPY
6 buffer_with_free_callback 7 data (copy)
8 data (free) 9 callback, known length
10 callback, MHD_SIZE_UNKNOWN (chunked reply)
11 fd 12 fd_at_offset 13 fd64
14 pipe 15 iovec
bits 4-5 number of response headers to add (0-3)
bit 6 also add a response footer (a chunked trailer)
bit 7 exercise MHD_get_response_header(),
MHD_get_response_headers(),
MHD_del_response_header() and
MHD_set_response_options()
byte 5 authentication entry point
bits 0-3 0 check3 (as before) 1 check
2 check2 3 check_digest
4 check_digest2 5 check_digest3
6 get_username 7 get_username3
8 get_request_info3 9 as 0
bits 4-5 challenge variant: 0 queue_auth_required_response3,
1 queue_auth_fail_response,
2 queue_auth_fail_response2,
3 the basic-auth pair
bit 6 use the v1 MHD_basic_auth_get_username_password()
bit 7 also call the connection-less digest helpers
byte 6 event loop and introspection
bits 0-1 0 MHD_run(), 1 MHD_get_fdset()+run_from_select(),
2 and 3 the *2 variants
bit 2 query all four MHD_get_timeout*() forms
bit 3 MHD_lookup_connection_value(),
MHD_lookup_connection_value_n(),
MHD_get_connection_URI_path_n()
bit 4 MHD_set_connection_value()
bit 5 MHD_get_connection_info() / MHD_get_daemon_info()
bit 6 MHD_set_connection_option()
bit 7 MHD_quiesce_daemon() before stopping
byte 7 suspend and upgrade
bits 0-1 0 none, 1 suspend+resume in the handler,
2 suspend and resume from the pump loop,
3 as 1
bit 2 allow HTTP "Upgrade" (only acted on when the
request really asks for one, see below)
bits 3-4 which MHD_upgrade_action() to issue first
byte 8 seed picking the response header names and values, and the
MHD_RF_* flags for MHD_set_response_options()
byte 9 seed for the content-reader callback: body length, block
size, and whether it fails part way through
Two constraints on the harness are worth stating, because both are
application-contract requirements rather than things worth fuzzing, and
violating either makes MHD abort on input that is perfectly legal:
* the connection is only suspended when the handler is about to return
MHD_YES. Returning MHD_NO asks MHD to terminate the connection, and
terminating one that the same callback just suspended trips
mhd_assert (! connection->suspended) in MHD_connection_close_();
* a 101 response is only queued for a request that is actually an
upgrade request (HTTP/1.1, an "Upgrade" header, and a "Connection"
header naming the upgrade token and not "close"). All three come
off the wire, so the path stays attacker-driven.
This is exactly the check a real application can perform, and no
more. It deliberately does not try to predict whether MHD will
accept the upgrade: a request that passes it used to abort MHD, which
is finding K7 in section 6, and MHD_queue_response() now answers
MHD_NO for that case instead. Do not tighten the condition to keep
the suite green -- an application cannot do better than this, so
neither should the harness.
The message-framing headers (Content-Length, Transfer-Encoding) are
deliberately absent from the response-header table: MHD generates them
itself from the response object, so an application that also sets them
by hand is lying to the library about its own body.
MHD_RF_INSANITY_HEADER_CONTENT_LENGTH is the sanctioned way to explore
that corner and is reachable through bit 7 of byte 4.
2.3 The %%NONCE%% placeholder
-----------------------------
Interesting parts of digestauth.c are only reached *after* the client
presents a nonce that MHD itself generated. A stateless fuzzer can
never guess one. Therefore the harness rewrites the literal ASCII token
%%NONCE%%
inside a segment, at send time, into the most recent `nonce="..."` value
seen in a response from the daemon. A generated (or hand-written) input
can thus be:
request 1: GET /a -> handler calls MHD_digest_auth_check3(),
gets MHD_DAUTH_WRONG_HEADER and replies
401 + WWW-Authenticate: Digest ... nonce="X"
op 3: new connection
request 2: GET /a with Authorization: Digest ... nonce="%%NONCE%%"
which walks all the way into the 'response' comparison. Without this
the over-long `response=` stack overflow (see 5.4) is unreachable.
2.4 Oracles
-----------
Memory errors are caught by ASAN/UBSAN and aborts by the signal
handlers. In addition fuzz_request installs two behavioural oracles:
a) MHD_set_panic_func() -- any MHD_PANIC() reached from network input
is a finding (a remote abort), not a legitimate "API violation".
b) A request-body oracle. Framing bugs (chunked transfer coding,
Content-Length) do not corrupt memory, they corrupt *data*, which
is exactly what HTTP request smuggling exploits. The input can
therefore declare the expected decoded body in an `op 1` segment.
Every byte that MHD hands to the application must be the next
expected byte, and when MHD completes the request the delivered
body must be complete. MHD is free to reject the request at any
point -- only what it *does* deliver is checked.
The declaration is honoured only for un-mutated inputs (the driver
exposes this as `fuzz_pristine`), because a random mutation would
of course invalidate the ground truth.
2.5 The generator
-----------------
Purely random bytes essentially never form a valid HTTP request, so the
standalone driver uses a small HTTP grammar (`fuzz_generate()`), and
then optionally applies byte-level mutations on top. Shapes:
0 SHAPE_PLAIN random method/target/version + headers
1 SHAPE_NOHDR_QARG *no header lines at all* plus a trailing
query argument without '=' (this is the
exact shape needed for the read-buffer
shift-back bug; the generator also forces a
small connection memory pool for it)
2 SHAPE_CL_BODY Content-Length body + body oracle
3 SHAPE_CHUNKED chunked body with chunk extensions
(";ext", ";ext=val", ";ext=\"quoted\"",
";a=1;b=2;c") + trailers + body oracle
4 SHAPE_DIGEST_SIMPLE Authorization: Digest with a randomised
parameter set, including unknown
`algorithm=` tokens and `response=` values
of every length up to 128 hex digits
5 SHAPE_DIGEST_REPLAY the two-request nonce handshake of 2.3
6 SHAPE_BASIC Authorization: Basic with random base64
7 SHAPE_POST_FORM urlencoded / multipart POST bodies
8 SHAPE_WEIRD folded headers, bare CR, bare LF,
whitespace before the colon, percent
encoding, absolute-form targets, ...
`MHD_FUZZ_SHAPE=<n>` restricts the generator to a single shape, which is
very handy for triage and for regression-testing a specific past bug.
-------------------------------------------------------------------
3. Running the harnesses
-------------------------------------------------------------------
Build (gcc only, no clang required):
SRC=/path/to/libmicrohttpd # configured build tree
gcc -g -O1 -Wall -Wextra \
-fsanitize=address,undefined -fno-sanitize-recover=all \
-I$SRC -I$SRC/src/include -I$SRC/src/microhttpd -I$SRC/src/fuzz \
-o fuzz_request $SRC/src/fuzz/fuzz_request.c \
$SRC/src/microhttpd/.libs/libmicrohttpd.a -lpthread
(the same command line for fuzz_str, fuzz_auth_header and
fuzz_postprocessor; the static archive is required because fuzz_str and
fuzz_auth_header use functions that are hidden in the shared object).
Options of the built-in driver (identical for all harnesses):
--iterations=N number of generate/mutate iterations [3000]
--seed=N PRNG seed; (harness, seed) fully determines a run
--corpus-dir=DIR replay every regular file in DIR and exit
--file=PATH replay a single input and exit (crash repro)
--crash-dir=DIR where reproducers are written [crashes]
--timeout=SEC per-iteration watchdog, 0 disables [20]
--write-corpus=DIR dump the built-in seed corpus to DIR
--skip-seeds do not replay the built-in corpus first
--verbose enable MHD's error log + print statistics
--help
Environment variables (all optional):
MHD_FUZZ_ITERATIONS, MHD_FUZZ_SEED, MHD_FUZZ_TIMEOUT,
MHD_FUZZ_CRASH_DIR, MHD_FUZZ_VERBOSE, MHD_FUZZ_SKIP_SEEDS
MHD_FUZZ_SHAPE=<n> (fuzz_request) restrict the generator
to one grammar shape
MHD_FUZZ_MIN_DISCIPLINE=<n> (fuzz_request) lower bound for
MHD_OPTION_CLIENT_DISCIPLINE_LVL,
default -3 (the full range)
MHD_FUZZ_MIN_MEM_LIMIT=<n> (fuzz_request) lower bound for
MHD_OPTION_CONNECTION_MEMORY_LIMIT,
default 0 (the full range)
MHD_FUZZ_MODEL_DIGEST_SINK=1 (fuzz_str) enable the modelled
digest 'response' call site, see 5.4
Typical use:
# quick smoke test (a couple of seconds)
./fuzz_request
# a real session
./fuzz_request --iterations=5000000 --seed=$RANDOM
# regression: replay the whole checked-in corpus
./fuzz_request --corpus-dir=corpus
./fuzz_str --corpus-dir=corpus # ignores foreign files gracefully
# reproduce a crash
./fuzz_request --file=crashes/crash-fuzz_request-seed3-iter55.bin
-------------------------------------------------------------------
4. Reproducing a failure
-------------------------------------------------------------------
Whenever the process dies -- ASAN error, UBSAN error, `mhd_assert()`,
MHD_PANIC(), a body-oracle finding, or the watchdog -- the input of the
running iteration is written to
$MHD_FUZZ_CRASH_DIR/crash-<harness>-seed<S>-iter<N>.bin
and a line is printed telling you the harness, the seed and the
iteration. The dump is produced from
* `__sanitizer_set_death_callback()` (weakly linked; present whenever
the binary is built with ASAN), and
* SIGABRT/SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGALRM handlers,
using only async-signal-safe calls. Replay with `--file=...`; the run
is fully deterministic, so `--seed=S --iterations=N+1` reproduces the
whole session as well.
-------------------------------------------------------------------
5. What these harnesses find (regression coverage)
-------------------------------------------------------------------
The four vulnerabilities fixed in MHD 1.0.7+1 are all rediscovered from
scratch. Each has a dedicated seed in `corpus/`, and the generator
finds each of them on its own within a few thousand iterations.
5.1 digestauth.c: unknown `algorithm=` token -> MHD_PANIC()
An `algorithm=` token MHD does not know parses to
MHD_DIGEST_AUTH_ALGO3_INVALID, which is 0, so the allow-mask test
`c_algo == (c_algo & malgo3)` passes for *any* mask; the code then
calls digest_init_one_time() with an invalid algorithm and panics.
Found by: SHAPE_DIGEST_SIMPLE / SHAPE_DIGEST_REPLAY, the panic hook,
corpus seed `digest-unknown-algorithm`.
5.2 connection.c get_req_headers(): read-buffer shift-back underflow
Needs, all at once: a small MHD_OPTION_CONNECTION_MEMORY_LIMIT
(MHD_BUF_INC_SIZE (1500) > read_buffer_size), *no header lines*, and
a trailing query argument without '=' (whose `value` is NULL).
Found by: SHAPE_NOHDR_QARG, corpus seeds
`small-pool-trailing-query-arg[-2]`.
5.3 connection.c process_request_body(): chunk-extension CRLF
`chunk_size_line_len = i` instead of `i + 2` leaves the CRLF of the
chunk-size line in the stream, so the following chunk data is
shifted -- a body desync, i.e. a request-smuggling primitive. This
corrupts no memory, so it is caught by the body oracle (2.4).
Found by: SHAPE_CHUNKED, corpus seeds `chunked-with-extensions`,
`chunked-split`.
5.4 digestauth.c: over-long `response=` -> stack buffer overflow
`response` was accepted up to `digest_size * 4` characters (128 for
SHA-256) and then decoded with MHD_hex_to_bin() into
`uint8_t hash1_bin[MAX_DIGEST]` (32 bytes) -- up to 64 bytes
written, 32 bytes of stack smashed. Reaching it requires a *valid*
nonce, hence the %%NONCE%% mechanism of 2.3.
Found by: SHAPE_DIGEST_REPLAY, corpus seed
`digest-overlong-response`.
fuzz_str additionally reproduces the underlying primitive:
MHD_hex_to_bin() has no output-size parameter and writes len/2
bytes, so any caller with a fixed-size buffer must bound the input
length itself. `MHD_FUZZ_MODEL_DIGEST_SINK=1` enables a target that
replays exactly the pre-fix call site (32 byte heap buffer, input
length bounded only by 4 * 32) and ASAN reports the overflow
immediately. The target models a *caller*, not the library, so it
is off by default.
-------------------------------------------------------------------
6. Findings against MHD 1.0.7 - all fixed, kept as regressions
-------------------------------------------------------------------
Running these harnesses against v1.0.7 built with `--enable-asserts`
reported the following *additional* issues on top of the four
vulnerabilities of section 5. All of them were `mhd_assert()`s reachable
from network input, i.e. a remote abort in builds that keep assertions
enabled, and all of them are fixed on master. They are documented here
because the reproducers are kept as a regression corpus: a failure of one
of them means the corresponding fix has been undone. Byte-exact reproducers are in `corpus/known-findings/`; replay
one with
./fuzz_request --file=corpus/known-findings/K1-digest-empty-realm.bin
K1 digestauth.c:2467 is_param_equal():
mhd_assert (0 != param->value.len) -> fixed in 300a2ab0
Trigger (default daemon configuration!), one request:
GET /a HTTP/1.1
Host: x
Authorization: Digest username="user", realm="", nonce="0000",
uri="/a", response="00"
digest_auth_check_all_inner() rejects a *missing* realm/username but
not an *empty* one, so a zero-length parameter reaches
is_param_equal(), whose documented precondition is a non-empty
value. Requires only that the application calls
MHD_digest_auth_check3(). Real defect (missing validation).
Repro: corpus/known-findings/K1-digest-empty-realm.bin
K2 connection.c:3582 handle_recv_no_space():
mhd_assert ((MHD_PROC_RECV_BODY_CHUNKED != stage) ||
! c->rq.some_payload_processed) -> fixed in 68c83f22
Trigger: small MHD_OPTION_CONNECTION_MEMORY_LIMIT (<= ~400 bytes)
plus a chunked body whose *second* chunk-size line carries a chunk
extension that does not fit into the remaining read buffer. The
flag reflects the last application callback only and survives later
reads, so the assertion is over-strong; the code below it already
handles the situation. Stale assertion.
Repro: corpus/known-findings/K2-chunkext-no-space.bin
K3 connection.c:2881 transmit_error_response_len():
mhd_assert (! connection->stop_with_error) -> fixed in e04eb218
Trigger: small connection memory pool plus an over-long, unterminated
chunk extension:
POST /a HTTP/1.1 / Transfer-Encoding: chunked
d;ext="qqqqqqqq... (longer than the read buffer)
handle_req_chunk_size_line_no_space() is missing a `return` after it
has already queued the "chunk extension too big" response. Real
defect: in a release build the second call forces the connection to
MHD_CONNECTION_CLOSED and the 413 response is never sent.
Repro: corpus/known-findings/K3-chunkext-stop-with-error.bin
K4 connection.c:6099 get_req_header():
mhd_assert ((0 == c->rq.hdrs.hdr.value_start) ||
(0 != c->rq.hdrs.hdr.name_len)) -> fixed in 0b750975
Trigger a) MHD_OPTION_CLIENT_DISCIPLINE_LVL <= -1, first header line
starting with whitespace:
GET /a HTTP/1.1\r\n Host: x\r\n\r\n
Trigger b) MHD_OPTION_CLIENT_DISCIPLINE_LVL <= -2, empty header
name:
GET /a HTTP/1.1\r\n: value\r\nHost: x\r\n\r\n
Both shapes are explicitly allowed by those discipline levels.
Stale assertion.
Repro: corpus/known-findings/K4a-wsp-first-header.bin,
corpus/known-findings/K4b-empty-header-name.bin
K5 connection.c:6394 get_req_header():
mhd_assert ('\r' != chr) -> fixed in 6fcdfd43
Trigger: MHD_OPTION_CLIENT_DISCIPLINE_LVL = -3, which sets
`bare_cr_keep = true`; the branch that keeps a bare CR falls through
into the "not a whitespace, not the end of the line" arm whose
assertion predates that mode.
GET /a HTTP/1.1\r\nHost: x\r\nX: y\r\r\n\r\n
Stale assertion.
Repro: corpus/known-findings/K5-bare-cr-keep.bin
K6 digestauth.c:860 check_nonce_nc():
mhd_assert (0 == nn->nonce[noncelen])
The nonce-nc slot array is indexed by a hash of the nonce, but the
slot content is compared assuming the *stored* nonce has the same
length as the presented one. A client can therefore make MHD read
the terminator of a nonce at the wrong offset by presenting a nonce
whose length belongs to a different digest algorithm.
Note that this one is *timing dependent*: the nonce carries a
millisecond timestamp and whether it counts as stale depends on the
wall clock, so the same input reproduces only in a fraction of the
replays (about 1 in 30 for the corpus file below). Replay it in a
loop.
Trigger (MHD_OPTION_NONCE_NC_SIZE = 1 makes every nonce land in slot
0, which turns the collision into a certainty; larger arrays only
need more attempts):
request 1: GET /a -> 401 with a SHA-256 nonce
(76 chars) stored in the slot
request 2: Authorization: Digest username="user",
realm="TestRealm", nonce="<44 zeros>", uri="/a",
response="e"
(no algorithm parameter -> MD5 -> nonce length 44,
all-zero timestamp -> not stale)
The generator needs ~150k iterations to hit it on its own.
Repro: corpus/known-findings/K6-nonce-length-collision.bin
K7 connection.c:2596 build_header_response():
mhd_assert ((NULL == r->upgrade_handler) ||
(MHD_CONN_MUST_UPGRADE == c->keepalive))
Fixed by commit acef58a0. Was driven entirely from the wire, with
default daemon options.
While parsing the request headers, connection.c sets
c->keepalive = MHD_CONN_MUST_CLOSE
in three places for requests whose message framing it distrusts:
4988 two "Content-Length" headers with different values
(client discipline -3 only)
5012 "Transfer-Encoding" on an HTTP/1.0 request
(client discipline <= 0)
5051 "Content-Length" *and* "Transfer-Encoding: chunked" in the
same request -- no discipline gate at all, so this one
fires with the default configuration
In every case the request is then handed to the access handler as a
normal request. If the handler answers 101 with a response from
MHD_create_response_for_upgrade(), MHD_queue_response() accepts it:
it checks MHD_ALLOW_UPGRADE, the status code, the "Connection"
header and the HTTP version, but not whether the connection can
still be kept open. keepalive_possible() then returns
MHD_CONN_MUST_CLOSE from its very first test, which is placed
*before* the upgrade branch, and the assertion above fires while the
reply header is being built.
The application cannot defend itself. The harness checks the
request the way an application would -- HTTP/1.1, an "Upgrade"
header, and a "Connection" header naming the upgrade token and not
"close" -- and the reproducer satisfies all three. MHD's decision
is not exposed through any public accessor.
It needs --enable-asserts and an application that answers upgrade
requests, so it is a robustness defect rather than a memory-safety
one; but it is reachable from a single well-formed request against a
default-configured server, which is a good deal worse than the
"application misuse" it first looked like.
The fix rejects the response in MHD_queue_response()
(connection.c:8338) next to the other upgrade preconditions, so the
application gets MHD_NO -- which it already has to handle -- instead
of an abort.
Repro: corpus/known-findings/K7-upgrade-after-must-close.bin
GET / HTTP/1.1
Host: x
Connection: Upgrade
Upgrade: fuzz-protocol
Content-Length: 0
Transfer-Encoding: chunked
(then "0\r\n\r\n")
K8 digestauth.c MHD_digest_auth_check_digest2():
MHD_PANIC ("Wrong 'malgo3' value, only one base hashing
algorithm ... must be specified, API violation")
Fixed by commit 07c051dd. An API-contract defect rather than a
parsing bug: unlike K7 the offending argument came from the
application, not from the network.
MHD_DIGEST_ALG_AUTO is a documented member of
enum MHD_DigestAuthAlgorithm, and the `algo` parameter of
MHD_digest_auth_check_digest2() is documented only as "digest
algorithms allowed for verification". But the function maps AUTO to
MHD_DIGEST_AUTH_MULT_ALGO3_ANY_NON_SESSION and forwards it to
MHD_digest_auth_check_digest3(), which requires *exactly one* base
hashing algorithm because the caller supplies a pre-computed digest
of one specific length -- and aborts through MHD_PANIC() when more
than one is named.
So MHD_digest_auth_check_digest2 (..., MHD_DIGEST_ALG_AUTO)
deterministically kills the process. The same is not true of
MHD_digest_auth_check2(), which takes a password rather than a
digest and whose AUTO handling is fine.
Unlike K7 this is entirely under the application's control -- no
network input is involved -- so it is a usability trap rather than a
robustness bug: AUTO simply cannot be used with the
pre-computed-digest entry points, and no mapping can rescue it,
because the digest length does not identify the algorithm (SHA-256
and SHA-512/256 digests are both 32 bytes).
The fix documents the restriction in microhttpd.h and returns MHD_NO
instead of aborting. AUTO is left unusable with the
pre-computed-digest entry points, deliberately: there is nothing to
map it to.
Repro: none committed. The argument is the application's, so the
harness never passes AUTO there (see run_digest_check() in
fuzz_request.c); to see it, change the MHD_DIGEST_ALG_* argument of
the variant-4 call to MHD_DIGEST_ALG_AUTO and replay
corpus/fuzz_request-37.bin.
K9 postprocessor.c:1119 post_process_multipart():
LeakSanitizer: direct leak, strdup() from MHD_post_process()
Found by fuzz_postprocessor, not fuzz_request -- the first finding
that did not come from the daemon harness. Remotely triggerable,
default configuration, no assertions needed.
On entering PP_PerformCheckMultipart the code did
pp->nested_boundary = strstr (pp->content_type, "boundary=");
...
pp->nested_boundary = strdup (&pp->nested_boundary[9]);
The first assignment stores an *interior pointer into
pp->content_type* over whatever pp->nested_boundary held. If the
post processor already owned a boundary -- which it does for every
nested "multipart/mixed" part after the first, unless the state
machine happened to pass through PP_PerformCleanup in between -- that
allocation is lost. MHD_destroy_post_processor() frees only the last
one.
So a body with N nested multipart/mixed parts, each carrying its own
"boundary=", leaks N-1 blocks, and the client picks how long each one
is. That makes it a memory-exhaustion vector against any application
that calls MHD_post_process() on multipart input, not the one byte
the reproducer happens to leak (its boundary is the empty string).
Fixed by copying into a local first and releasing any previously
owned boundary before taking ownership of the new one; the error
path no longer overwrites the old pointer either.
Repro: corpus/known-findings/K9-fuzz_postprocessor-nested-boundary-leak.bin
K10 memorypool.c MHD_pool_deallocate(): end block of an exactly-full
pool was never returned; the front/end dispatch tested pool->pos,
where the two ranges meet. Fixed.
K11 memorypool.c MHD_pool_reallocate(): returned non-NULL for a
wrapping @a new_size, handing the caller a block it believed was
nearly SIZE_MAX bytes long. Fixed.
K12 daemon.c MHD_start_daemon_va(): leaked the GnuTLS DH parameters
built from MHD_OPTION_HTTPS_MEM_DHPARAMS on every startup-failure
exit. The application cannot free them. Fixed.
K13 daemon.c MHD_epoll() / MHD_quiesce_daemon(): both threads remove
the listening socket from the epoll set, and the loser aborts on
ENOENT. Open; two alternative fixes are proposed as
../../patches/K13.diff (tolerate ENOENT everywhere) and
../../patches/K13b.diff (take a lock, keep the strict check).
K14 daemon.c:1358 call_handlers():
mhd_assert (! force_close || MHD_CONNECTION_CLOSED == con->state)
Open, fix proposed in ../../patches/K14.diff.
MHD_connection_handle_read() closes the connection when its
@a socket_error argument is set -- but only once it gets far enough
to try. It returns early, leaving the state untouched, when the
connection is suspended, when a TLS handshake is in progress, and
when the read buffer has no free space at all. The caller asserts
the post-condition unconditionally.
Instrumenting the assertion site with the reproducer gives
state=12 (HEADERS_PROCESSED) suspended=0
rb_size=2021 rb_off=2021 evinfo=READ
i.e. the read buffer is exactly full. A client that fills the
connection memory pool without the application consuming the body
gets there; no unusual API use is involved. Observed through
MHD_run_from_select2(), i.e. from the external event loop.
The reproducer does NOT fire on a plain in-tree gcc build: ASan's
pool redzones change the buffer arithmetic enough to matter, so
confirm it against the OSS-Fuzz build configuration.
Repro: corpus/known-findings/K14-fuzz_eventloop-force-close-not-closed.bin
Status
------
K1-K12 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
K13 and K14 are open, with proposed fixes in ../../patches/. Until K14
lands, `make check-corpus` fails on an `--enable-asserts` build, because
its reproducer is committed and still reproduces -- which is the
documented behaviour for an open finding, but worth knowing before a CI
run.
The reproducers in `corpus/known-findings/` otherwise all replay clean
on a build configured with `--enable-asserts`, and
`make check-corpus` asserts exactly that -- it replays that directory
along with the generated corpus. K8 has no reproducer, because its
trigger is an argument the application chooses rather than anything that
comes off the wire.
A reproducer belongs to the harness that found it, and says so in its
name: `K<n>-<harness>-<what>.bin`. The ones without a harness in the
name predate the convention and are all fuzz_request inputs.
`contrib/oss-fuzz/make_seed_corpus.sh` routes each one into the right
target's seed corpus on that basis -- a fuzz_postprocessor reproducer in
fuzz_request's corpus would just be an uninteresting input.
When the next finding is opened, its reproducer goes into
`corpus/known-findings/` and will make `make check-corpus` fail until the
fix lands. That is the intended behaviour: it is the same arrangement as
the `XFAIL_TESTS` entries in `src/microhttpd/Makefile.am`, except that
here the expectation is not encoded, so the commit that adds a live
reproducer should say so. `contrib/oss-fuzz/make_seed_corpus.sh` has to
skip such a reproducer as well, or every ClusterFuzz run starts by
rediscovering it; see the comment there.
The `make check` defaults in Makefile.am
MHD_FUZZ_ITERATIONS = 50000
MHD_FUZZ_MIN_DISCIPLINE = -3 (full range)
MHD_FUZZ_MIN_MEM_LIMIT = 0 (full range)
exercise the whole matrix and take about three seconds in total under
ASAN+UBSAN. A real session:
MHD_FUZZ_MIN_DISCIPLINE=-3 MHD_FUZZ_MIN_MEM_LIMIT=0 \
./fuzz_request --iterations=1000000 --seed=1
-------------------------------------------------------------------
7. Building with clang/libFuzzer or AFL++
-------------------------------------------------------------------
Both need `-DFUZZ_NO_MAIN` so that the driver's `main()` is left out.
7.1 libFuzzer
-------------
# build the library itself with the same instrumentation
./configure --enable-static --disable-shared --enable-asserts \
CC=clang \
CFLAGS="-g -O1 -fsanitize=fuzzer-no-link,address,undefined \
-fno-sanitize-recover=all -fprofile-instr-generate \
-fcoverage-mapping"
make -C src/microhttpd
clang -g -O1 -DFUZZ_NO_MAIN \
-fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all \
-I. -Isrc/include -Isrc/microhttpd -Isrc/fuzz \
-o fuzz_request src/fuzz/fuzz_request.c \
src/microhttpd/.libs/libmicrohttpd.a -lpthread
mkdir -p CORPUS && ./fuzz_request --help >/dev/null 2>&1 || true
./fuzz_request CORPUS src/fuzz/corpus \
-max_len=8192 -rss_limit_mb=4096 -timeout=20
# minimise a crash found by libFuzzer
./fuzz_request -minimize_crash=1 -runs=100000 crash-<hash>
7.2 AFL++
---------
export CC=afl-clang-lto AFL_USE_ASAN=1 AFL_USE_UBSAN=1
./configure --enable-static --disable-shared --enable-asserts
make -C src/microhttpd
afl-clang-lto -g -O1 -DFUZZ_NO_MAIN \
-I. -Isrc/include -Isrc/microhttpd -Isrc/fuzz \
-o fuzz_request src/fuzz/fuzz_request.c \
$(afl-config --libdir 2>/dev/null || echo /usr/local/lib/afl)/afl-compiler-rt.o \
/usr/local/lib/afl/libAFLDriver.a \
src/microhttpd/.libs/libmicrohttpd.a -lpthread
afl-fuzz -i src/fuzz/corpus -o findings -- ./fuzz_request @@
(`libAFLDriver.a` provides a `main()` that reads the file named on the
command line and calls LLVMFuzzerTestOneInput(); that is why
`-DFUZZ_NO_MAIN` is required. With `AFL_LLVM_PERSISTENT` /
`__AFL_LOOP` the same binary can be used in persistent mode.)
7.3 OSS-Fuzz
------------
Ready to go: see `../../contrib/oss-fuzz/` and its README. That
directory holds the OSS-Fuzz `build.sh`, `project.yaml` and `Dockerfile`,
per-harness dictionaries (`dicts/fuzz_*.dict`), per-harness `.options`
files (`max_len`, `dict`) and `make_seed_corpus.sh`, which packages
`corpus/` — including the `corpus/known-findings/` reproducers of the
findings that are already *fixed*, so that they become permanent
regressions — into the `<fuzzer>_seed_corpus.zip` files OSS-Fuzz
expects.
`build.sh` configures out of tree with
--enable-static --disable-shared --with-pic --enable-fuzzing
--enable-asserts --disable-https --disable-curl --disable-doc
--disable-examples --disable-tools --enable-build-type=neutral
honouring OSS-Fuzz's `$CFLAGS` (no `--enable-sanitizers`: OSS-Fuzz
supplies the instrumentation), and then compiles each harness exactly as
in 7.1 but against `$LIB_FUZZING_ENGINE`. HTTPS is off on purpose — the
harnesses never speak TLS, and leaving GnuTLS out is what makes the
MemorySanitizer build possible.
Two things to know before reading a report from there:
* the request-body oracle of 2.4 is **inactive** under libFuzzer. It
is gated on `fuzz_pristine`, which nothing sets when `FUZZ_NO_MAIN`
is defined — correctly so, since libFuzzer's mutations invalidate the
declared ground truth. Pure framing/desync defects (5.3) therefore
remain the job of the built-in driver, i.e. of `make check`;
* `primary_contact` in `project.yaml` is a placeholder. OSS-Fuzz needs
an address the maintainer controls; it has to be filled in before the
project can be submitted.
The token list in `fuzz_common.h` (`fuzz_interesting_str`) is the
generator's equivalent of those dictionaries; the two are intentionally
similar but are not generated from each other.
OSS-Fuzz is deliberately not part of `contrib/ci/jobs/`; the bounded
in-tree equivalents for CI are `make -C src/fuzz check` and
`make -C src/fuzz check-corpus`.
-------------------------------------------------------------------
8. Adding a harness
-------------------------------------------------------------------
Create `fuzz_<name>.c` with
#define FUZZ_HARNESS_NAME "fuzz_<name>"
#include "fuzz_common.h"
and implement
int LLVMFuzzerTestOneInput (const uint8_t *, size_t);
static size_t fuzz_generate (struct fuzz_rng *, uint8_t *, size_t);
static size_t fuzz_seed_count (void);
static const uint8_t *fuzz_seed_get (size_t, size_t *);
then add it to `check_PROGRAMS` in Makefile.am. Report non-crashing
findings with `fuzz_report_finding("...")`, which dumps the reproducer
and aborts.
Two rules learnt the hard way:
* do not violate documented *preconditions* of the function under
test (e.g. MHD_str_remove_token_caseless_() asserts that the token
contains no space, tab, comma or NUL) -- otherwise the harness only
finds its own bugs;
* never evaluate a macro argument twice when it contains a PRNG call.