- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
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, namelyread_address(the address codec),negotiate_auth(method negotiation),read_command(the command encoding),read_username_password(RFC 1929) and 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/) are 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 to 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, so 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, with 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)