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)
23 KiB
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:
-
alkcall's fuzz doc §5 named this crate's surfaces as downstream targets. Its "non-surfaces (deliberately)" note ends: the channels layer carries opaque
Bytesand the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets. Oursrc/wire.rsdatagram 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. -
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'sread_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 viaunwrap()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'sReplyError::from_u8ended inunreachable!()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. -
The differential suite's blind spot is exactly fuzzing's strength.
tests/rfc1928-differential.rspins 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 plaincargo testis the fuzz gate. - Root
Cargo.tomlneeds the explicit[workspace]table (members = ["."],exclude = ["fuzz"]) and"fuzz/"on the publish exclude list — without it cargo auto-discoversfuzz/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:
feednever errors at the reassembly layer — every frame completes asOkor as one of the four decode-garbageWireErrorvariants (EmptyFrame/BadAtyp/ShortAddress/DomainNotUtf8);Oversize/Addressare unreachable fromfeed(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; alen == 0frame (zero inner bytes) always surfaces asEmptyFrame, never as an empty datagram. - bad-ATYP loudness: every
BadAtyperror'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(orframe_innerover its inner bytes) and re-decodes to itself; well-framed input re-frames byte-for-byte. - joint bound:
framerejects iffaddr_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_innerrejects iffinner.len() > MAX_INNER_LEN. Thechecked_addpath (payload overflow wrapping the u16) rejects, never wraps. - decoder-state bound:
buf+ in-flightcurrentstay 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'sDatagramReaderexposes this viais_mid_frame()+ a newretained_bytes()diagnostic (the alktunnelsretained_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 typedAddrError(IncorrectAddressTypefor 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_commandover a duplex,accept_no_auth+read_command): no-panic on any byte sequence; every malformed shape is a typedSocksServerError(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 theGeneralFailurecatch-all (the F-1 pin — neverunreachable!()); 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 typedUdpHeaderErrors; round-trip withnew_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_u8full-domain table: everyu8maps, 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_fieldsdispatch, 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'sadditionalProperties: falseagree). - 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_reasonyieldsSome(details.reason)iff the code ischannel:open_failedand the reason is a string — any string is surfaced as-is (the alktunnels harness-model finding #2, adopted here as the modeled contract);SocksEstablishmentReason::parsenever 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_doorRFC 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 —
alkcallbumped 0.8.0 → 0.8.1,alktunnels0.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, stablefuzz/sharedlibrary, runner, dict, generator); the three targets over already-pubAPIs — 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):
- A
len == 0frame is well-framed but decodes toErr(EmptyFrame)— the re-frame round-trip first expected every well-framed frame to decodeOk. The frame count still holds over every frame; the byte-for-byte re-frame holds over the decodable ones;EmptyFrameis pinned as the only zero-inner-byte shape. The same arm widened forShortAddress/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. - A fieldless
deny_unknown_fieldsstruct deserializes from ANY empty JSON composite (the serde seq form[]included) — not only{}. Not wire-reachable (the registry'stype: objectinput 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{}."
- A
- 2026-09-28 — first campaign finding (fork-mode containment as
designed): a third harness-model defect. The
datagram_codeccampaign crashed at ~35 s on input0005 0301 1203 03bb 00(BadAtyp(0)shape) — the re-frame walk'sErrarm (fixed in the replay pass above) still omittedBadAtyp. Fixed; the crash input is committed asseed-900; the campaign relaunched clean. - 2026-09-28 — second campaign finding (also harness, fork-mode
containment): the count walk's
declared >= 7gate excluded short domains. Input0005 0301 1203 03bb 00— a domain frame with a 1-byte domain (addr_len = 5) is a legal empty-payload frame, but the walk'sdeclared >= 7gate (meant to skip addr-less frames) rejected it; the per-ATYPaddr_lencomputation already does that job. The gate lowered to>= 4(the minimum ATYP + length-byte prefix the walk reads); the crash input is committed asseed-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/0throughout. 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/0throughout. 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/0throughout. 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)