Skip to content

Commit 106d542

Browse files
committed
fuzzer
1 parent e2e1df7 commit 106d542

82 files changed

Lines changed: 1276 additions & 15 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ build/
4949
doc
5050
/build2/
5151
/build3/
52+
/build-fuzz/
5253
/cov-int/
5354
/.vs/
5455
/build-mtls/

CMakeLists.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ option(LWS_WITH_SYS_METRICS "Lws Metrics API" OFF)
218218
option(LWS_WITH_LATENCY "Event loop latency tracking and monitoring (see ../READMEs/README.LWS_WITH_LATENCY.md)" OFF)
219219
option(LWS_WITH_UPNG "Enable stateful PNG stream decoder" ON)
220220
option(LWS_WITH_GZINFLATE "Enable internal minimal gzip inflator" ON)
221+
option(LWS_WITH_FUZZERS "Build libFuzzer targets against untrusted-input parsers in ./fuzz (needs clang)" OFF)
221222
option(LWS_WITH_JPEG "Enable stateful JPEG stream decoder" ON)
222223
option(LWS_WITH_DLO "Enable Display List Objects" ON)
223224
option(LWS_WITH_WEBRTC "Enable WebRTC" OFF)
@@ -1373,6 +1374,15 @@ list(APPEND LIB_LIST ${LIB_LIST_AT_END})
13731374
# Second-level CMakeLists
13741375
#
13751376

1377+
if (LWS_WITH_FUZZERS)
1378+
#
1379+
# Instrument the whole lib for libFuzzer coverage + ASan, for the
1380+
# targets in ./fuzz. The fuzz targets themselves add the libFuzzer
1381+
# runtime when they link. Requires clang and libclang-rt-*-dev.
1382+
#
1383+
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=fuzzer-no-link,address -fno-omit-frame-pointer -g")
1384+
endif()
1385+
13761386
include_directories("${PROJECT_SOURCE_DIR}/lib")
13771387
add_subdirectory(lib)
13781388

@@ -1444,6 +1454,10 @@ if (NOT LWS_WITHOUT_TESTAPPS)
14441454
add_subdirectory(test-apps)
14451455
endif()
14461456

1457+
if (LWS_WITH_FUZZERS)
1458+
add_subdirectory(fuzz)
1459+
endif()
1460+
14471461
if (NOT LWS_WITH_PLUGINS_BUILTIN)
14481462
add_subdirectory(plugins)
14491463
endif()

fuzz/CMakeLists.txt

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#
2+
# libwebsockets - fuzz targets for untrusted-input parsers
3+
#
4+
# Copyright (C) 2010 - 2026 Andy Green <andy@warmcat.com>
5+
#
6+
# Permission is hereby granted, free of charge, to any person obtaining a copy
7+
# of this software and associated documentation files (the "Software"), to
8+
# deal in the Software without restriction, including without limitation the
9+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
10+
# sell copies of the Software, and to permit persons to whom the Software is
11+
# furnished to do so, subject to the following conditions:
12+
#
13+
# The above copyright notice and this permission notice shall be included in
14+
# all copies or substantial portions of the Software.
15+
#
16+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
22+
# IN THE SOFTWARE.
23+
#
24+
# Each fuzz target lives in fuzz-<name>/ with the harness in main.c and
25+
# committed starting inputs in fuzz-<name>/seeds/. ctest runs each target
26+
# against its seeds only, as a fast smoke test of the harness under ASan; use
27+
# ./fuzz/run.sh for actual fuzzing campaigns with persistent corpora.
28+
#
29+
30+
if (NOT CMAKE_C_COMPILER_ID MATCHES "Clang")
31+
message(FATAL_ERROR "LWS_WITH_FUZZERS needs clang for libFuzzer support (found '${CMAKE_C_COMPILER_ID}'). Install clang and libclang-rt-*-dev and set CC=clang.")
32+
endif()
33+
34+
#
35+
# Inputs: <name> target name, fuzz-<name>/main.c etc
36+
#
37+
macro(lws_add_fuzz_target name)
38+
39+
add_executable(fuzz-${name} fuzz-${name}/main.c)
40+
target_compile_options(fuzz-${name} PRIVATE -fsanitize=fuzzer,address)
41+
target_link_options(fuzz-${name} PRIVATE -fsanitize=fuzzer,address)
42+
target_link_libraries(fuzz-${name} websockets ${LIBWEBSOCKETS_DEP_LIBS})
43+
44+
# run the harness once over each seed under ASan, deterministically
45+
46+
add_test(NAME fuzz-${name}-smoke
47+
COMMAND fuzz-${name}
48+
${CMAKE_CURRENT_SOURCE_DIR}/fuzz-${name}/seeds
49+
-runs=0)
50+
set_tests_properties(fuzz-${name}-smoke PROPERTIES TIMEOUT 300)
51+
52+
endmacro()
53+
54+
if (LWS_WITH_LEJP)
55+
lws_add_fuzz_target(lejp)
56+
endif()
57+
58+
if (LWS_WITH_CBOR)
59+
lws_add_fuzz_target(lecp)
60+
endif()
61+
62+
if (LWS_WITH_HTTP3 AND LWS_WITH_NETWORK)
63+
lws_add_fuzz_target(qpack)
64+
endif()
65+
66+
if (LWS_WITH_UPNG AND LWS_WITH_GZINFLATE)
67+
lws_add_fuzz_target(upng)
68+
endif()
69+
70+
if (LWS_WITH_LHP AND LWS_WITH_DLO)
71+
lws_add_fuzz_target(lhp)
72+
endif()
73+
74+
# the wsi-bound protocol parsers need the evil-peer helper in peer.h, which
75+
# uses socketpair + adopt, so they are POSIX-only
76+
77+
if (UNIX AND LWS_WITH_NETWORK)
78+
lws_add_fuzz_target(h1)
79+
80+
if (LWS_WITH_HTTP2)
81+
lws_add_fuzz_target(h2)
82+
endif()
83+
84+
if (LWS_ROLE_WS)
85+
lws_add_fuzz_target(ws)
86+
endif()
87+
endif()

fuzz/README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# lws fuzzing
2+
3+
Coverage-guided fuzzing of lws' untrusted-input parsers, using clang's
4+
libFuzzer + AddressSanitizer. Everything runs locally with no dependency
5+
on any external fuzzing infrastructure.
6+
7+
## Quick start
8+
9+
Requirements: `clang` and the libFuzzer runtime (Debian-ish:
10+
`clang-19 libclang-rt-19-dev`), plus `libgnutls28-dev` if you want the
11+
`qpack` target (it needs the h3 role, which needs a QUIC-capable TLS
12+
provider; without gnutls that one target is silently skipped).
13+
14+
```sh
15+
./fuzz/run.sh # build + 60s per target against all targets
16+
./fuzz/run.sh 3600 # an hour per target
17+
./fuzz/run.sh 600 lejp qpack # selected targets only
18+
BUILD=~/fz ./fuzz/run.sh # non-default build dir
19+
```
20+
21+
Corpora accumulate per-target in `<build>/fuzz/corpus-<name>/` across runs
22+
(so coverage keeps advancing between campaigns), seeded from the committed
23+
inputs in `fuzz/fuzz-<name>/seeds/`. Anything the fuzzer finds is written
24+
to `<build>/fuzz/crash-<sha1>`; re-run it directly on the artifact file to
25+
reproduce:
26+
27+
```sh
28+
./build-fuzz/bin/fuzz-qpack build-fuzz/fuzz/crash-<sha1> # repro under ASan
29+
```
30+
31+
`clang` is autodetected if there is no `clang` binary (eg `clang-19`).
32+
Additional cmake options can be passed via `FUZZ_CMAKE_OPTS`.
33+
34+
## Reading the output
35+
36+
- `Done N runs in Ts` for a target means its time slice completed with no
37+
findings. Corpus (`corp: N/MKb`) and coverage (`cov:`) growing between
38+
runs is the normal steady state, not a problem.
39+
- Parser error logs during fuzzing are the *expected* face of malformed
40+
input being rejected, not findings — the harnesses silence them to keep
41+
throughput. Set `LWS_FUZZ_VERBOSE=1` to replay a specific input with
42+
logs, e.g. `LWS_FUZZ_VERBOSE=1 ./build-fuzz/bin/fuzz-qpack <artifact>`.
43+
- An actual finding is an ASan/UBSan report on stderr plus a
44+
`crash-<sha1>` artifact in `<build>/fuzz/`, and `run.sh` exits nonzero.
45+
- `Ctrl-C` mid-campaign is safe; corpora reached so far are kept, and each
46+
target also self-terminates at its `-max_total_time`.
47+
48+
## CI / ctest
49+
50+
The same build registers a fast smoke test per target (`-runs=0`, runs
51+
each committed seed exactly once under ASan):
52+
53+
```sh
54+
CC=clang-19 cmake .. --fresh -DLWS_WITH_FUZZERS=ON -DLWS_WITH_CBOR=ON
55+
cmake --build . --parallel
56+
ctest -R fuzz- # seconds, deterministic
57+
```
58+
59+
`LWS_WITH_FUZZERS` implies whole-lib `-fsanitize=fuzzer-no-link,address`
60+
instrumentation, so it should stay OFF for normal builds and normal CI.
61+
Long campaigns belong on a dedicated runner or a nightly job via
62+
`./fuzz/run.sh`.
63+
64+
## Targets
65+
66+
| target | parser under test | notes |
67+
|---|---|---|
68+
| `fuzz-lejp` | lejp JSON parser (`lib/misc/lejp.c`) | policy, JOSE, RPC JSON; fed in two chunks to cover partial-input states |
69+
| `fuzz-lecp` | lecp CBOR parser (`lib/misc/lecp.c`) | needs `-DLWS_WITH_CBOR=ON` |
70+
| `fuzz-qpack` | native QPACK decoders (`lib/roles/h3/qpack.c`) | first byte selects encoder-stream vs header-block decode; needs h3 (`LWS_WITH_HTTP3` + gnutls) |
71+
| `fuzz-upng` | stateful PNG decoder (`lib/misc/upng.c`) | seeds from `test-apps/*.png` |
72+
| `fuzz-lhp` | HTML5 + CSS parser (`lib/misc/lhp.c`) | builds a dlo document per input and destroys it; leaks and heap errors in teardown are caught |
73+
| `fuzz-h1` | h1 server header/body parser (`lib/roles/http/`) | evil-peer target, see below |
74+
| `fuzz-h2` | h2 framing + hpack (`lib/roles/h2/`) | evil-peer: the fuzz input is h2 frames after a canned h2c upgrade + connection preface |
75+
| `fuzz-ws` | ws server frame parser (`lib/roles/ws/`) | evil-peer: the fuzz input is client frames after a canned upgrade handshake |
76+
77+
The `fuzz-h1`, `fuzz-h2` and `fuzz-ws` targets use the shared evil-peer
78+
helper in [peer.h](peer.h): one real, adopted server-side connection per
79+
input over a socketpair, fed the fuzz bytes as if received from the peer,
80+
serviced deterministically via `lws_service_fd()`, then hung up on so the
81+
close paths run too. This exercises the production wsi state machines,
82+
not just the parsing functions in isolation. The same pattern will work
83+
for any other adoptable server-side parser (eg, mqtt).
84+
85+
When a crash is found: reproduce on the artifact, minimize it, fix, then
86+
commit a minimized seed under the target's `seeds/` and add the same input
87+
as a case to the corresponding api-test where one exists, so it stays
88+
covered in normal CI.
89+
90+
## Survey: what to harness next
91+
92+
The wsi-bound h1, h2/hpack and ws parsers are covered via `peer.h`. The
93+
remaining untrusted-input surfaces, easiest first:
94+
95+
- mqtt rx parser (`lib/roles/mqtt/mqtt.c`): an evil-peer target with a
96+
canned CONNECT prelude, same shape as `fuzz-ws`
97+
- JPEG decoder (`lib/misc/jpeg.c`, `LWS_WITH_JPEG`) — standalone, same
98+
shape as `fuzz-upng`
99+
- async DNS wire parser (`lib/system/async-dns/async-dns-parse.c`)
100+
- auth-dns zone parser (`lib/system/auth-dns/`)
101+
- COSE sign/validate (`lib/cose/`), jrpc (`lib/misc/jrpc/`),
102+
dht messages (`lib/misc/dht`), sshd userauth / bipacket
103+
- `lws_tokenize`, `lws_b64_decode`, iso8601 and friends — standalone,
104+
cheap to add
105+
- wt (WebTransport) and full h3/QUIC framing: these need the UDP/QUIC
106+
stack stood up, a larger project than a socketpair
107+
108+
Fault injection (`lws_fi`) can additionally be used from inside harnesses
109+
to fail the Nth allocation during parsing, which is where most parser
110+
lifetime bugs hide.

fuzz/fuzz-h1/main.c

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* lws fuzz target: h1 server-side header and body parsing
3+
*
4+
* Written in 2010 - 2026 Andy Green <andy@warmcat.com>
5+
*
6+
* This file is made available under the Creative Commons CC0 1.0
7+
* Universal Public Domain Dedication.
8+
*
9+
* The input is raw h1 request bytes from an untrusted client, fed to a real
10+
* adopted server connection. Everything the h1 server parser touches is
11+
* covered: header tokenizing into the ah, URL and query parsing, body and
12+
* chunked body handling, and upgrade requests (ws and h2c role transitions).
13+
*/
14+
15+
#include "../peer.h"
16+
17+
/*
18+
* Fuzzing means constantly feeding the parser garbage, so its rejection
19+
* logs are expected noise that dominates the runtime. Set LWS_FUZZ_VERBOSE=1
20+
* to get them back (eg, when replaying a crash artifact).
21+
*/
22+
23+
int
24+
LLVMFuzzerInitialize(int *argc, char ***argv)
25+
{
26+
(void)argc;
27+
(void)argv;
28+
29+
if (getenv("LWS_FUZZ_VERBOSE"))
30+
/* all lws logs, for triage */
31+
lws_set_log_level(0x7fff, NULL);
32+
else
33+
lws_set_log_level(0, NULL);
34+
35+
if (fuzz_peer_init())
36+
return 1;
37+
38+
return 0;
39+
}
40+
41+
int
42+
LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
43+
{
44+
fuzz_peer_session(NULL, 0, data, size);
45+
46+
return 0;
47+
}

fuzz/fuzz-h1/seeds/absuri.http

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
GET http://fuzz/a/b?c=d HTTP/1.1
2+
Host: fuzz
3+

fuzz/fuzz-h1/seeds/chunked.http

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
POST /x HTTP/1.1
2+
Host: fuzz
3+
Transfer-Encoding: chunked
4+
5+
5
6+
hello
7+
0
8+

fuzz/fuzz-h1/seeds/duphdrs.http

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
GET / HTTP/1.1
2+
Host: fuzz
3+
X-A: 1
4+
X-A: 2
5+
Cookie: a=b
6+
Cookie: c=d
7+

fuzz/fuzz-h1/seeds/get.http

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
GET / HTTP/1.1
2+
Host: fuzz
3+

fuzz/fuzz-h1/seeds/longvalue.http

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GET / HTTP/1.1
2+
Host: fuzz
3+
X-Long: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
4+

0 commit comments

Comments
 (0)