/
githubmirror
/
libmicrohttpd
Обзор
Документация
Войти
/
githubmirror
/
libmicrohttpd
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/fuzz/README
985 строк
48 KB
Christian Grothoff
more fuzzing fun
29 июл 2026, 18:13
Не верифицирован
29 июл 2026, 18:13
d623ab0
Код
Авторство
О чём код?
GNU libmicrohttpd -- in-process fuzzing harnesses ================================================= This directory contains eight 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. 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). ------------------------------------------------------------------- 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. 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 ------------------------------------------------------------------- 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 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 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 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 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.