# Fuzzing alksocks: Research and Plan **Status:** adopted — wave-1 targets (`datagram_codec`, `rfc1928_codec`, `params_parse`) implemented, seeds committed, campaigns run clean (§7). Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the detached runner (§6) — **never as a foreground child of an agent session** (§5). **Date:** 2026-09-28 **Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (one real finding: the duplicate adopt/open destroying the live channel — fixed in 0.8.1) and `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2 adopted, six harness-model findings, no crate bugs). Tooling, corpus policy, runner, and the no-hosted-CI posture are inherited from those docs wholesale — this doc does not re-litigate them; it scopes them to this crate. --- ## 1. Should this crate be fuzzed? **Yes — and alkcall's fuzz doc already assigned this to us.** Three lines, all pointing the same way: 1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream targets.** Its "non-surfaces (deliberately)" note ends: *the channels layer carries opaque `Bytes` and the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets.* Our `src/wire.rs` datagram codec **is** that named downstream target (shaped on alktunnels' codec, which alktunnels fuzzed as its wave-1 first target — the same assignment, landed here). Fuzzing it completes the upstream scope rather than duplicating it. 2. **We own an entire RFC handshake state machine fed by arbitrary peers — on both sides.** The targeted fork (`src/rfc1928/`, ADR-013) is fully owned code with project conventions applying unconditionally (convention 17). Its parse surfaces — `read_address` (the address codec), `negotiate_auth` (method negotiation), `read_command` (the command encoding), `read_username_password` (RFC 1929), the client's `read_request_reply` — are fed bytes produced by **arbitrary peers** in both directions: the producer drives the server typestate against consumer bytes, and the consumer sessions + front doors drive the client path against producer bytes. An RFC state machine is exactly the shape the quinn-proto CVE class (RUSTSEC-2026-0037 — remote DoS via `unwrap()` in parsing, the upstream doc §1's strongest datapoint) lives in: hand-rolled state transitions, length arithmetic, and boundary handling. One fork-carried correction is already of this class — upstream's `ReplyError::from_u8` ended in `unreachable!()` for unassigned reply codes; the fork made it a typed catch-all (the review's F-1) precisely because the value comes off the wire. A fuzz target is how that posture is *demonstrated* across the whole codec, not believed spotwise. 3. **The differential suite's blind spot is exactly fuzzing's strength.** `tests/rfc1928-differential.rs` pins our fork's behavior against the reference checkout on *valid* RFC edge cases — curated inputs a human thought of. What no example-based suite provides: random *malformed* input exploration (the panic-on-attacker-bytes class), stateful interleavings, and resource-bound assertions under adversarial sequences. The fork is about to be published (0.1) as wire-stable; after that, targets are regression protection for a frozen surface — cheapest now. **The honest caveat** (mirroring both upstream docs): the crate has no `unsafe` (checked at the crate root), the codec's decode direction is infallible by contract (oversize rejected at frame time; the decoder buffers, bounded by the u16 declared length), the RFC 1929 length fields are one byte each (the attacker-controlled byte cannot ask for more than 255), and the fork's error vocabulary is typed end-to-end (the `unreachable!()`/`unwrap` sweep already ran in review 001). Expected yield is therefore not memory-safety bugs; it is (a) the demonstration that the infallibility/no-panic contracts hold on adversarial input, (b) the decoder-state bound assertion (alktunnels §1.3's finding class — a peer chunk amplifying into unbounded state), and (c) regression protection for the published wire surface. If the campaigns find nothing, that is the successful-negative-result posture alkcall's §1 codified. ## 2. Toolchain (inherited, not re-decided) Identical to both upstream docs: **cargo-fuzz 0.13.2 + `arbitrary` 1.4.x**, nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88. AFL optional/deferred; bolero deferred (the async-friction argument); libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address` with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate). **Layout (the upstream pattern, load-bearing):** ``` fuzz/ ├── Cargo.toml alksocks-fuzz (nightly-only bins; own [workspace]) ├── rust-toolchain.toml pins nightly for this subtree only ├── fuzz_targets/ thin fuzz_target! wrappers (3-line each) │ ├── datagram_codec.rs │ ├── rfc1928_codec.rs │ └── params_parse.rs ├── shared/ alksocks-fuzz-shared — STABLE-toolchain library │ └── src/… invariant logic + corpus replay (§3) ├── corpus// committed hand-made seeds (§3) ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) ├── run-detached.sh §6 detached runner (CWD-independent) └── README.md ``` - **Invariant logic lives in `fuzz/shared/`**, not inline in the target binaries — the stable-toolchain corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is alkcall §7.2 tier-3's deliverable minus automation; per alkcall §7.9 (adopted here identically) this repo has no hosted CI and will not get one — corpus replay as plain `cargo test` is the fuzz gate. - **Root `Cargo.toml` needs the explicit `[workspace]` table** (`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the publish exclude list — without it cargo auto-discovers `fuzz/shared/` into the main workspace and the MSRV toolchain tries to build the nightly-consumed dev-deps (the exact failure alkcall hit). - **Corpus policy:** commit hand-made seeds, gitignore grown corpora (quinn/h2 model). Seeds per target listed in §3. ## 3. Invariants by target ### 3.1 `datagram_codec` (the ASSOCIATE frame codec, `src/wire.rs`) Input: raw `&[u8]`. Drives `DatagramReader::feed` + `frame` / `frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1 `datagram_codec` target's shape, scoped to this crate's codec (the frame carries `addr + data` inner bytes and decodes to `(TargetAddr, payload)`, not opaque datagrams — the invariants follow the inner structure). Invariants: - **no-panic on any byte sequence** (the baseline). - **infallible reassembly**: `feed` never errors at the reassembly layer — every frame completes as `Ok` or as one of the four *decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` / `ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are unreachable from `feed` (they are frame-time errors — a shape assertion pins the partition). - **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes only from a real `[len][addr]` frame whose payload region is empty — never invented by the codec; a `len == 0` frame (zero inner bytes) always surfaces as `EmptyFrame`, never as an empty datagram. - **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual first inner byte of its frame, and it is not one of the three RFC ATYPs (the post-reply control-byte semantic — any non-ATYP control byte must fail loud, never guess). - **consumption accounting / splitting transparency**: feeding in 3-byte slices yields exactly the datagram sequence the whole-input feed yields (both directions' framing is transparent to splitting). - **round-trip**: every decoded datagram re-frames via `frame` (or `frame_inner` over its inner bytes) and re-decodes to itself; well-framed input re-frames byte-for-byte. - **joint bound**: `frame` rejects iff `addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing `(total, max)` exactly — including the max-domain case where a legal RFC payload (65507 to a 255-char domain = 65766) must reject (the joint-bound reason the codec exists); `frame_inner` rejects iff `inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload overflow wrapping the u16) rejects, never wraps. - **decoder-state bound**: `buf` + in-flight `current` stay bounded by the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any adversarial input — the alktunnels §1.3 finding-class assertion (state must never grow with input). The crate's `DatagramReader` exposes this via `is_mid_frame()` + a new `retained_bytes()` diagnostic (the alktunnels `retained_bytes()` posture). Seeds: valid frames (all three ATYPs), truncation at every prefix length, `len = 0` / `len = MAX` / `len = MAX+1`, empty payloads, batched frames, split frames, garbage ATYP, non-UTF-8 domains, the 65766 joint-bound shape, deep batch/split mixes. ### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`) Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio duplex streams (the in-tree test shape — mock bytes, no sockets): - **`read_address`** (the address codec, both directions consume it): no-panic on any ATYP/bytes; every failure is a typed `AddrError` (`IncorrectAddressType` for unknown ATYP — the F-1 class, already fixed; never a panic); round-trip for every well-formed address (`to_be_bytes` → parse → equal). - **the server handshake** (`negotiate_auth` → `read_command` over a duplex, `accept_no_auth` + `read_command`): no-panic on any byte sequence; every malformed shape is a typed `SocksServerError` (version mismatch, unacceptable method, unknown command, bad addr); a well-formed negotiation + CONNECT request parses to the exact command + target. - **the client reply path** (`read_request_reply`): no-panic on any server byte sequence; unassigned reply codes map to the `GeneralFailure` catch-all (the F-1 pin — never `unreachable!()`); version mismatches are typed errors. - **`parse_udp_request`** (the RFC UDP header codec, front doors only but still wire-reachable): no-panic; garbage RSV / unknown ATYP / truncation are typed `UdpHeaderError`s; round-trip with `new_udp_header`. - **auth sub-handshakes** (`read_username_password`, RFC 1929): no-panic; the u8 length fields bound allocations structurally (≤ 255 bytes — asserted); empty username/password are typed errors; round-trip accept/reject status bytes. - **`ReplyError::from_u8`** full-domain table: every `u8` maps, no panic, unassigned → catch-all (the direct probe of the F-1 class). This is the wave-1 target of highest value: it is the entire RFC 1928 byte surface, fed attacker bytes from both protocol directions, and the fork is owned code whose behavior the differential suite pins only on valid inputs. Seeds: valid handshakes (no-auth / password), version mismatches, truncations at every prefix length of each message shape, unknown methods/commands/ATYPs, empty and 255-byte domains, non-UTF-8 domains, unassigned reply codes, RSV garbage. ### 3.3 `params_parse` (the open-op params, `src/params.rs`) Input: raw bytes → `serde_json` (alkcall's bounded `arbitrary_value` generator) → `Socks5Params` deserialization + `socks5_open_spec()` + `SocksEstablishmentReason::parse` / `establishment_reason`. Invariants: - **no-panic on any JSON-shaped input** (`deny_unknown_fields` dispatch, the registry-spec builder). - **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` == `{}`, and unknown fields always reject (the loud-rejection posture — the serde pin and the schema's `additionalProperties: false` agree). - **the spec is a constant**: `socks5_open_spec()`'s wire-visible fields (op id, ALPN, scopes, schemas) are identical on every execution — the open-op surface is wire-stable (ADR-001); the reason-enum table equals the typed parse table (the schema/typed-view drift guard, mirrored from the in-suite test). - **the reason surface is a pass-through extraction, not a validated table**: `establishment_reason` yields `Some(details.reason)` iff the code is `channel:open_failed` and the reason is a string — any string is surfaced as-is (the alktunnels harness-model finding #2, adopted here as the modeled contract); `SocksEstablishmentReason::parse` never panics and is total. ### 3.4 What is deliberately NOT a target (yet) - **The producer pump (`serve_socks5_over`) / consumer sessions as stateful op-sequence targets** — wave 2, deferred (§4). The handshake-level target (§3.2) covers the byte surface; the session-level interleavings (associate stage select-shape, BIND quiet wait, the two-pump CONNECT plane) are stateful shapes over a live channels stack — the alktunnels wave-2 pattern, worth its own session post-0.1. - **The doors (`door.rs`, `local/`, `composed/`)** — feature-gated assembly; their RFC-codec boundary (`parse_udp_request` / `new_udp_header`) is covered by the §3.2 target. The rest is I/O plumbing over the fuzzed cores, not parse surfaces. - **`front_door` RFC 1929 negotiation** — covered by §3.2's auth sub-handshake invariants (the door drives the same forked parsers). ## 4. Scope: two waves **Wave 1 (this session):** the three targets above. All drive already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching both upstream docs' experience across all eight targets to date). One diagnostic addition to the crate: `DatagramReader::retained_bytes()` (the decoder-state byte count §3.1's bound asserts against, in alktunnels' `retained_bytes()` style). **Wave 2 (deferred — post-0.1):** `session_pumps`, the stateful op-sequence target over the producer pump + consumer sessions in the alkcall `manager_routing` / alktunnels `session_pumps` style (live channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the two-pump shutdown-on-completion contract, the egress readiness contract, and the datagram-stage exit matrix as invariants). Deferred because wave 1 covers the wire byte surface completely, the session harness is a large build, and the crate is one commit from its first publish — the alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption, wave 2 the same session) was possible there because its session shape was the crate's whole surface; here the byte-level targets carry the attacker-reachable load and the stateful session target is a separate planning unit. Sequencing mirrors the upstream docs: init + wave-1 targets → 10–30 min campaigns per target via the detached runner → fix anything found → wave 2 when scheduled. ## 5. Operational isolation: campaigns never share fate with the agent session **Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels §5)** — an environment constraint, not a nice-to-have. Agent sessions run fuzzer builds/campaigns through a bash tool that spawns the fuzzer as a child of the session's own process tree and cgroup; an OOM in a fuzz target must cost the fuzzer, never the session. Three layers, all mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048` `-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode (`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only the short-lived child; the harness survives and persists the artifact; also the deadlock answer); (3) process detachment — the runner script wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a log file; poll, never `wait`. Also inherited: the operational note that `cargo fuzz run` returns before its last fork-mode children fully exit — prefer `pgrep -af` to LIST, kill by explicit pid from the runner's echoed `pid=`, and never pattern-kill-and-recheck in one call. ## 6. No hosted CI Per alkcall §7.9 / alktunnels §6 — the standing policy applies to this repo identically: no hosted CI will exist; do not add Actions/Gitea Actions workflow files. **Corpus replay is the fuzz gate as plain `cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` replays every committed seed through the invariant functions — add it to the release verification checklist (AGENTS.md). Campaigns run manually via the detached runner (§6) — e.g. before a release, or after touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`. ## 7. Implementation log - **2026-09-28 — proposal adopted** (this doc). Wave-1 targets `datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred (§4). - **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1, `alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change; alkcall's fix (the duplicate adopt/open destroying the live channel) is directly relevant to this crate's consumer sessions — every session's teardown reaps via the manager, and the open-op-response-replayed scenario alkcall's changelog names is the consumer open path. Verification: full suite (default + all-features) green, clippy, fmt, wasm check (§8 commands). - **2026-09-28 — wave-1 implemented.** The `fuzz/` workspace laid out per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner, dict, generator); the three targets over already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (the upstream docs' experience, now across eleven targets). One diagnostic addition to the crate: `DatagramReader::retained_bytes()` (§3.1's bound target). Seeds committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2 campaign-found crash inputs promoted (`datagram_codec/seed-900`, `seed-901` — the §7 harness findings below). - **2026-09-28 — two harness-model defects caught by corpus replay pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern): 1. **A `len == 0` frame is well-framed but decodes to `Err(EmptyFrame)`** — the re-frame round-trip first expected every well-framed frame to decode `Ok`. The frame count still holds over every frame; the byte-for-byte re-frame holds over the decodable ones; `EmptyFrame` is pinned as the only zero-inner-byte shape. The same arm widened for `ShortAddress`/`DomainNotUtf8`/ `BadAtyp`: a well-framed frame can carry any addr-garbage shape — the decode refuses the addr, and the raw frame is the only re-frame possible. 2. **A fieldless `deny_unknown_fields` struct deserializes from ANY empty JSON composite** (the serde seq form `[]` included) — not only `{}`. Not wire-reachable (the registry's `type: object` input schema is the gate that keeps non-object forms from ever reaching the type), but the harness must model the serde reality: the trivial-params invariant is now "an empty composite parses, everything non-empty rejects, the canonical round-trip is `{}`." - **2026-09-28 — first campaign finding (fork-mode containment as designed): a third harness-model defect.** The `datagram_codec` campaign crashed at ~35 s on input `0005 0301 1203 03bb 00` (`BadAtyp(0)` shape) — the re-frame walk's `Err` arm (fixed in the replay pass above) still omitted `BadAtyp`. Fixed; the crash input is committed as `seed-900`; the campaign relaunched clean. - **2026-09-28 — second campaign finding (also harness, fork-mode containment): the count walk's `declared >= 7` gate excluded short domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a 1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but the walk's `declared >= 7` gate (meant to skip addr-less frames) rejected it; the per-ATYP `addr_len` computation already does that job. The gate lowered to `>= 4` (the minimum ATYP + length-byte prefix the walk reads); the crash input is committed as `seed-901`; the campaign relaunched clean. - **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`; codec `-max_len=65536`, rfc1928 `-max_len=4096 -use_value_profile=1`, params `-dict=json.dict -use_value_profile=1). All three exited 0 with empty artifact directories — no crashes, hangs, OOMs, or leaks.** - `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s), 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 481 edges (ft 2222, corp 209) — the 2-byte-header state machine is fully enumerated (the alktunnels wave-1 saturation profile). - `rfc1928_codec` — ~2.0M execs (~3.6k exec/s), 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429 edges (ft 7987, corp 704) — the handshake state machines' byte-level shape space is fully enumerated; both protocol directions held across every adversarial sequence. - `params_parse` — ~3.1M execs (~5.5k exec/s), 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft 12970, in-memory corpus 2981 from the 19 committed seeds) — the serde/untagged shape space was still expanding at budget end; the spec-constant and pass-through-reason invariants held everywhere. - The decoder-state bound (§3.1) held everywhere: `retained_bytes()` never exceeded fed + one frame on any adversarial input. Grown corpora were **not** merged into the committed seeds (the §2 policy). - **NO crate bugs found by wave 1.** All three findings this session were harness-model defects, each invisible to example-based review and caught by fuzzing/replay — the upstream "fuzzer as a harness-checker" pattern, confirmed a third time (alkcall §7.7, alktunnels §7, here §7). The codec's contracts (infallible reassembly, the F-2 layering, the joint bound, the retained-bytes bound) and the RFC core's no-panic posture held across every adversarial input the campaigns generated. - (progress appended as implementation proceeds — see git log) ## 8. Verification commands ```bash cargo test # main suite (stable) cargo test --all-features # the feature-gated suite cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate) cargo fuzz build # in fuzz/ (nightly, ASAN) fuzz/run-detached.sh # campaigns (never foreground) cargo clippy --all-targets -- -D warnings cargo fmt --check cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings cargo fmt --manifest-path fuzz/shared/Cargo.toml --check cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean ``` Campaign flags per target: `datagram_codec -max_len=65536`; `rfc1928_codec -max_len=4096 -use_value_profile=1`; `params_parse -dict=json.dict -use_value_profile=1`. ## 9. References - Upstream campaigns + operational model (authoritative for tooling, runner, corpus policy, no-CI posture): `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`, `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` - alkcall 0.8.1 changelog — the finding our consumer open/adopt paths are exposed to - This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013 (the targeted fork), `docs/architecture/wire.md`, `docs/architecture/rfc1928.md` - External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust Fuzz Book (all linked from the upstream doc's §9)