From 4afaf92fd50e07074c720b7115540eb1f98f8baf Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 01:48:46 -0700 Subject: [PATCH] Coverage-guided fuzzing of the broker's request parser and the proxy's wire parser runs weekly, on a dated nightly cargo-fuzz targets for both parsers of zone bytes. compartments/kryptikd/fuzz builds the daemon's own main.rs with cfg(fuzzing), where the harness takes main's place, because the parser is private to a binary crate; it feeds parse_request any header line and holds every request it accepts to the bounds its refusals enforce. compositor/wlproxy/fuzz compiles wire.rs, protocol.rs and the generated tables as they are, since they name nothing else in the proxy, and reads a message stream against every signature, checking the string offsets and the writer the rewrites use. Each is a package with a workspace and lockfile of its own: libfuzzer-sys and what it pulls in (arbitrary, cc and cc's build helpers) are in neither shipped Cargo.lock nor either binary's dependencies, and the licence check and the source bundle read only the shipped lockfiles. kryptikd's Cargo.toml declares the fuzzing cfg so the ordinary build does not warn about it. .github/workflows/fuzz.yml runs both on Sundays and on dispatch: a nightly pinned by date from rustup, cargo-fuzz 0.13.2 installed --locked, the fuzz lockfile held with cargo fetch --locked, the broker seeded from fuzz-corpus/broker-requests one request per file, twenty minutes per target with no sanitizer (the parsers are safe Rust), and any crash input kept as an artifact for 30 days. --- .github/workflows/fuzz.yml | 74 ++++++++++++++++ .gitignore | 4 + compartments/kryptikd/Cargo.toml | 4 + compartments/kryptikd/fuzz/Cargo.lock | 90 ++++++++++++++++++++ compartments/kryptikd/fuzz/Cargo.toml | 33 +++++++ compartments/kryptikd/fuzz/broker_request.rs | 27 ++++++ compartments/kryptikd/src/main.rs | 7 ++ compositor/wlproxy/fuzz/Cargo.lock | 89 +++++++++++++++++++ compositor/wlproxy/fuzz/Cargo.toml | 29 +++++++ compositor/wlproxy/fuzz/wire.rs | 49 +++++++++++ docs/design/broker.md | 9 +- 11 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 compartments/kryptikd/fuzz/Cargo.lock create mode 100644 compartments/kryptikd/fuzz/Cargo.toml create mode 100644 compartments/kryptikd/fuzz/broker_request.rs create mode 100644 compositor/wlproxy/fuzz/Cargo.lock create mode 100644 compositor/wlproxy/fuzz/Cargo.toml create mode 100644 compositor/wlproxy/fuzz/wire.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..b4b832cc --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,74 @@ +name: Fuzz + +# Coverage-guided fuzzing of the two parsers of zone bytes (docs/design/broker.md), weekly on a pinned nightly. + +on: + schedule: + - cron: '0 4 * * 0' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + name: Fuzz ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - target: broker_request + dir: compartments/kryptikd + seeds: compartments/kryptikd/fuzz-corpus/broker-requests + max_len: 4096 + - target: wire + dir: compositor/wlproxy + seeds: '' + max_len: 8192 + env: + # A dated nightly, so a new finding is never a new compiler's. + RUSTUP_TOOLCHAIN: nightly-2026-09-28 + FUZZ_TARGET: ${{ matrix.target }} + CRATE_DIR: ${{ matrix.dir }} + SEEDS: ${{ matrix.seeds }} + MAX_LEN: ${{ matrix.max_len }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Nightly Rust and cargo-fuzz + run: | + rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal + cargo +stable install cargo-fuzz --version 0.13.2 --locked + + # libfuzzer-sys comes from the fuzz crate's own lockfile, never from what ships. + - name: The fuzz crate's lockfile holds + run: cargo fetch --locked --manifest-path "$CRATE_DIR/fuzz/Cargo.toml" + + - name: Seeds from the corpus in the tree + run: | + mkdir -p "$RUNNER_TEMP/corpus" + # The tree keeps one request per line; libFuzzer takes one input per file. + if [ -n "$SEEDS" ]; then + n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + printf '%s\n' "$line" > "$RUNNER_TEMP/corpus/seed-$n" + done < "$SEEDS" + fi + echo "$(find "$RUNNER_TEMP/corpus" -type f | wc -l) seeds" + + # Safe Rust throughout, so no sanitizer: panics, overflow checks and the harness's asserts are the oracle. + - name: Twenty minutes of fuzzing + working-directory: ${{ matrix.dir }} + run: cargo fuzz run --sanitizer none "$FUZZ_TARGET" "$RUNNER_TEMP/corpus" -- -max_total_time=1200 -max_len="$MAX_LEN" -timeout=10 -print_final_stats=1 + + - name: Inputs that broke the parser + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: fuzz-${{ matrix.target }} + path: ${{ matrix.dir }}/fuzz/artifacts/ + retention-days: 30 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f2be4355..6560196f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ # Rust and Python /compartments/kryptikd/target/ compositor/target/ +**/fuzz/target/ +**/fuzz/corpus/ +**/fuzz/artifacts/ +**/fuzz/coverage/ __pycache__/ # Editor / OS noise diff --git a/compartments/kryptikd/Cargo.toml b/compartments/kryptikd/Cargo.toml index df31a6b5..c3220108 100644 --- a/compartments/kryptikd/Cargo.toml +++ b/compartments/kryptikd/Cargo.toml @@ -10,6 +10,10 @@ license = "GPL-2.0-or-later" [dependencies] libc = "0.2" +# Set only by cargo fuzz, which builds main.rs from fuzz/Cargo.toml. +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } + [profile.release] opt-level = 2 lto = true diff --git a/compartments/kryptikd/fuzz/Cargo.lock b/compartments/kryptikd/fuzz/Cargo.lock new file mode 100644 index 00000000..9d691662 --- /dev/null +++ b/compartments/kryptikd/fuzz/Cargo.lock @@ -0,0 +1,90 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "kryptikd-fuzz" +version = "0.0.0" +dependencies = [ + "libc", + "libfuzzer-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/compartments/kryptikd/fuzz/Cargo.toml b/compartments/kryptikd/fuzz/Cargo.toml new file mode 100644 index 00000000..a1fbf9fa --- /dev/null +++ b/compartments/kryptikd/fuzz/Cargo.toml @@ -0,0 +1,33 @@ +# The broker's request parser under libFuzzer, run weekly by +# .github/workflows/fuzz.yml. A package and lockfile of its own, so +# libfuzzer-sys never reaches kryptikd's dependencies or Cargo.lock (ADR-010). +[package] +name = "kryptikd-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libc = "0.2" +libfuzzer-sys = "0.4" + +# The daemon itself: under cfg(fuzzing) main.rs takes broker_request.rs in place of main. +[[bin]] +name = "broker_request" +path = "../src/main.rs" +test = false +doc = false +bench = false + +# The release build's overflow checks, and debug assertions as well. +[profile.release] +debug-assertions = true +overflow-checks = true + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } + +[workspace] diff --git a/compartments/kryptikd/fuzz/broker_request.rs b/compartments/kryptikd/fuzz/broker_request.rs new file mode 100644 index 00000000..3012a511 --- /dev/null +++ b/compartments/kryptikd/fuzz/broker_request.rs @@ -0,0 +1,27 @@ +//! The broker's request parser under libFuzzer: any header line a zone can +//! send, and whatever it accepts held to the bounds its refusals enforce. + +use libfuzzer_sys::fuzz_target; + +use crate::broker::{check_transfer_name, parse_request, Request, CLIPBOARD_MAX, MIME_TYPES}; +use crate::update::{POINTER_MAX, PUT_MAX}; + +fuzz_target!(|data: &[u8]| { + // The header as serve_connection takes it: up to the first newline, decoded lossily. + let Some(nl) = data.iter().position(|b| *b == b'\n') else { return }; + let header = String::from_utf8_lossy(&data[..nl]); + match parse_request(&header) { + Err(_) | Ok(Request::Version | Request::ClipboardGet | Request::UpdatePoll) => {} + Ok(Request::ClipboardSet { mime, len }) => assert!(MIME_TYPES.contains(&mime.as_str()) && len <= CLIPBOARD_MAX), + Ok(Request::Transfer { dest, name }) => { + assert!((1..=12).contains(&dest.len())); + assert!(dest.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')); + assert!(check_transfer_name(&name).is_ok()); + } + Ok(Request::TimeOffset(c)) => assert!(c.offset.is_finite() && c.offset.abs() <= 1e10 && (1..=16).contains(&c.sources)), + Ok(Request::UpdateLatest { plen, slen }) => assert!((1..=POINTER_MAX).contains(&plen) && (1..=POINTER_MAX).contains(&slen)), + Ok(Request::UpdatePut { name, len, .. }) => assert!(check_transfer_name(&name).is_ok() && (1..=PUT_MAX).contains(&len)), + Ok(Request::NotAZoneVerb(v)) => assert_eq!(v, "clipboard-move"), + Ok(Request::Unknown(v)) => assert!(!v.is_empty() && !v.contains(char::is_whitespace)), + } +}); diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index d2f54db7..ab7b5b9e 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -4,6 +4,9 @@ //! process that creates zones or moves data between them. Anything not built //! is refused with an error, never a silent no-op. +// cargo fuzz builds this file with cfg(fuzzing), and libFuzzer brings its own main. +#![cfg_attr(fuzzing, no_main, allow(dead_code))] + mod broker; mod caps; mod cgroup; @@ -24,6 +27,10 @@ mod update; mod volume; mod wifi; mod zone; +// The parser is private to the daemon, so its fuzz target compiles inside it (fuzz/Cargo.toml). +#[cfg(fuzzing)] +#[path = "../fuzz/broker_request.rs"] +mod fuzz; use std::path::{Path, PathBuf}; use std::process::ExitCode; diff --git a/compositor/wlproxy/fuzz/Cargo.lock b/compositor/wlproxy/fuzz/Cargo.lock new file mode 100644 index 00000000..30cd514d --- /dev/null +++ b/compositor/wlproxy/fuzz/Cargo.lock @@ -0,0 +1,89 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "wlproxy-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", +] diff --git a/compositor/wlproxy/fuzz/Cargo.toml b/compositor/wlproxy/fuzz/Cargo.toml new file mode 100644 index 00000000..44a0c655 --- /dev/null +++ b/compositor/wlproxy/fuzz/Cargo.toml @@ -0,0 +1,29 @@ +# kryptik-wlproxy's wire parser under libFuzzer, run weekly by +# .github/workflows/fuzz.yml. A package, workspace and lockfile of its own, so +# libfuzzer-sys never reaches the compositor's dependencies or Cargo.lock +# (ADR-010). +[package] +name = "wlproxy-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[[bin]] +name = "wire" +path = "wire.rs" +test = false +doc = false +bench = false + +# The release build's overflow checks, and debug assertions as well. +[profile.release] +debug-assertions = true +overflow-checks = true + +[workspace] diff --git a/compositor/wlproxy/fuzz/wire.rs b/compositor/wlproxy/fuzz/wire.rs new file mode 100644 index 00000000..5938f201 --- /dev/null +++ b/compositor/wlproxy/fuzz/wire.rs @@ -0,0 +1,49 @@ +//! kryptik-wlproxy's wire parser under libFuzzer: message framing, every +//! signature in the generated tables, and the writer the rewrites use. These +//! three files name nothing else in the proxy, so they compile here as they are. + +#![no_main] +#![allow(dead_code)] + +#[path = "../src/protocol.rs"] +mod protocol; +#[path = "../src/protocol_tables.rs"] +mod protocol_tables; +#[path = "../src/wire.rs"] +mod wire; + +use libfuzzer_sys::fuzz_target; +use protocol::{decode, Arg, Message}; +use wire::{ArgReader, Header, MessageWriter, HEADER_LEN}; + +/// Every request and event in the tables. +fn messages() -> &'static [&'static Message] { + static ALL: std::sync::OnceLock> = std::sync::OnceLock::new(); + ALL.get_or_init(|| protocol_tables::INTERFACES.iter().flat_map(|i| i.requests.iter().chain(i.events)).collect()) +} + +fuzz_target!(|data: &[u8]| { + let all = messages(); + let mut rest = data; + // A stream of messages, each read against the signature its object id picks. + while let Ok(h) = Header::parse(rest) { + assert_eq!(Header::parse(&h.encode()), Ok(h)); + let size = h.size as usize; + let Some(body) = rest.get(HEADER_LEN..size) else { break }; + let m = all[h.object as usize % all.len()]; + if let Ok(d) = decode(m, body) { + assert_eq!(d.new_object.is_some(), m.args.iter().any(|a| matches!(a, Arg::NewId { .. }))); + assert_eq!(d.bind_version.is_some(), m.args.contains(&Arg::NewId { iface: None })); + for (at, s) in &d.strings { + // The offset a rewrite trusts, and what the proxy would write in its place. + assert_eq!(&body[at + 4..at + 4 + s.len()], s.as_bytes()); + assert_eq!(body[at + 4 + s.len()], 0); + if let Some(out) = MessageWriter::new(h.object, h.opcode).string(s).finish() { + assert_eq!(Header::parse(&out).map(|w| w.size as usize), Ok(out.len())); + assert_eq!(ArgReader::new(&out[HEADER_LEN..]).string(), Ok(Some(*s))); + } + } + } + rest = &rest[size..]; + } +}); diff --git a/docs/design/broker.md b/docs/design/broker.md index 50ff3345..b8ef5d72 100755 --- a/docs/design/broker.md +++ b/docs/design/broker.md @@ -195,7 +195,14 @@ title to `[zone] ...`, from which the compositor draws the zone's border. the body, only exact parses accepted) and feeds a damaged opening through a live session in arbitrary fragments (only whole messages reach the compositor). Inputs that ever break a parser join the corpus. - Coverage-guided fuzzing needs nightly Rust and belongs in a scheduled job. +- `.github/workflows/fuzz.yml` fuzzes both parsers under libFuzzer weekly, + twenty minutes each, on a nightly pinned by date. `compartments/kryptikd/fuzz` + takes the request parser, seeded from that corpus, and holds every request + it accepts to the bounds its refusals enforce; `compositor/wlproxy/fuzz` + takes the framing, every signature in the tables and the writer the + rewrites use. An input that breaks one is kept as the run's artifact. Each + is a package with a lockfile of its own, so libfuzzer-sys is in nothing that + ships. ## Not built