Files
alksocks/docs/research/fuzzing.md
T
glm-5.3-flash 08a3694464 fuzz: adopt fuzzing (wave 1) + alkcall 0.8.1 / alktunnels 0.2.1 bumps
Adopt the alk*/fuzzing posture (alkcall 0.8.1 + alktunnels 0.2.1
precedent) before the v0.1 publish — the cheapest moment this crate
will ever have for it:

- alkcall 0.8.1 + alktunnels 0.2.1: the duplicate-adopt/open fix is
  directly relevant to this crate's consumer open/adopt paths
- fuzz/ workspace (the sibling layout): nightly-pinned subtree, stable
  fuzz/shared library (corpus replay = the fuzz gate), detached runner,
  JSON dict, deterministic seed generator; 129 committed seeds
- three wave-1 targets over already-pub APIs: datagram_codec (the
  ASSOCIATE frame codec — the alkcall-fuzz-doc-assigned downstream
  target), rfc1928_codec (the forked RFC 1928 core's handshake state
  machines, both protocol directions), params_parse (the wire-stable
  open-op surface)
- one diagnostic: DatagramReader::retained_bytes() (the decoder-state
  bound's assertion target, the alktunnels retained_bytes() posture)
- docs/research/fuzzing.md: the scoped plan + implementation log;
  AGENTS.md gains the corpus-replay verification line
- campaigns: 3 × 10-min detached runs exited 0 (datagram ~1.9M execs,
  rfc1928 ~2.0M, params ~3.1M; oom/timeout/crash 0/0/0 on all 33 fork
  jobs per target; coverage saturated on both codec targets). No crate
  bugs; three harness-model defects found + fixed (2 pre-campaign by
  corpus replay, 2 campaign-found, crash inputs promoted as seeds 900/
  901): the len==0 well-framed decode partition, the fieldless-struct
  serde-seq form, the BadAtyp re-frame arm, the short-domain count-walk
  gate
- wave 2 (session_pumps stateful target) deferred to post-0.1 — the
  byte-level targets carry the attacker-reachable load for the publish

Verification: cargo test (15 suites ok, default + all-features), clippy
(all-targets + wasm + fuzz/shared), fmt, cargo doc, publish --dry-run,
corpus replay 129 seeds green, cargo fuzz build clean.

Refs: alkcall docs/research/fuzzing.md (§7.9 posture), alktunnels
docs/research/fuzzing.md; docs/research/fuzzing.md (this crate's copy)
2026-09-28 11:18:08 +00:00

23 KiB
Raw Permalink Blame History

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/<target>/      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 UdpHeaderErrors; 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

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 <target>                 # 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)