wirerust Fuzzing & Robustness Report
Target: wirerust v0.11.0 — Fast PCAP forensics and network triage CLI (Rust 2024)
Binary under test: target/release/wirerust (release profile, overflow-checks = true)
Date: 2026-06-30
Tester: Claude (Opus 4.8)
Corpus: ~/workdir/practice/pcaps/ (9,235 capture files, 193 GB; sizes from 25 B to 6.4 GB)
1. Executive Summary
The tool was exercised for three classes of defect — security (can it be made to
crash), performance (can it be made to degrade or exhaust resources), and
functional (does it output results as advertised).
| Class |
Result |
| Security (crash) |
No crashes found. 0 panics / signals / hangs across 400,000 mutation-fuzz iterations and a 4,377-file corpus sweep (8 modes each). The parsing code is exceptionally well hardened. |
| Performance (DoS) |
One confirmed resource-exhaustion bug. The --dnp3 and --enip analyzers never release per-flow state, growing memory without bound and silently bypassing the TCP reassembler's own DoS caps. An 80 MB capture drives 1.4 GB RSS (~17×). |
| Performance (secondary) |
The classic-pcap reader has no file-size cap (only pcapng is gated at 4 GB) and uses an all-in-memory model. |
| Functional |
Outputs are consistent. JSON = CSV = terminal finding counts; summary counts = analyze counts; --all enables all 8 analyzers. |
The headline finding is performance/DoS, not a crash: two of the ICS protocol
analyzers leak per-flow state for the entire run, defeating the memory protections
that the rest of the codebase carefully implements.
2. Tool Under Test
PCAP file → Reader → Decoder → Analyzers → Reporter
- Reader (
src/reader.rs): classic-pcap (libpcap) and pcapng; magic-byte probe;
multi-link-type (Ethernet, Raw IP, IPv4, IPv6, Linux SLL).
- Decoder (
src/decoder.rs): etherparse strict→lax parse; IPv4/IPv6/TCP/UDP/ICMP/ARP.
- Analyzers (
src/analyzer/): DNS, HTTP, TLS, Modbus, DNP3, EtherNet/IP (ENIP), ARP.
- Reassembly (
src/reassembly/): forensic TCP stream reassembler with max_flows
and memcap eviction.
- Reporter (
src/reporter/): terminal, JSON, CSV.
Subcommands: analyze (threat detection) and summary (triage). Analyzer flags:
--dns --http --tls --modbus --dnp3 --arp --enip, or --all.
3. Methodology
3.1 Corpus crash sweep
A driver (sweep.sh) ran every capture <10 MB (4,377 files) through 8 invocation
profiles — summary, analyze --all, JSON, CSV, --mitre, --http --tls --dns,
--modbus --dnp3 --arp, --reassemble --all — with a per-run timeout, classifying
each exit as PANIC (101), SIGNAL (>128), HANG (timeout), or clean.
3.2 Black-box mutation fuzzer
A multi-threaded fuzzer (fuzz.py, ~1,000 exec/s) mutated a seed corpus and ran the
binary under randomly chosen flag sets, catching panics/signals/hangs and saving any
crashing input + metadata.
- Seeds (one per analyzer/link-type):
modbus.pcap, dnp3.pcap, enip.pcap,
dns.pcap, http.pcap, tls.pcap, arp.pcap, small.pcapng, v6.pcap.
- 10 mutation strategies: bit-flips, interesting-byte sets, random bytes,
truncation, length-field smashing (u16/u32 → 0xFFFF/0/0x7FFFFFFF),
byte insertion, chunk duplication (grow), zero/0xFF region fills, cross-seed splice.
- Release build has
overflow-checks = true, so any integer-overflow bug surfaces as
a panic — length-field smashing specifically targets this.
3.3 Targeted code review + grammar-aware inputs
Reviewed every slice/arithmetic hot spot in the analyzers
(enip.rs, modbus.rs, dnp3.rs, tls.rs, decoder.rs, reader.rs) and the
reassembly engine. Built structured pcaps (gen_flows.py) to probe flow-table
memory growth, and a cross-format checker (func_check.py) to compare JSON/CSV/
terminal/summary outputs.
4. Findings
Finding 1 — Security / crash resistance: PASS (no defects)
- 400,000 mutation-fuzz iterations: 0 panics, 0 signals, 0 hangs.
- 4,377 real captures × 8 modes: 0 panics, 0 signals, 0 hangs.
The parsing/analyzer layer is heavily defensively coded: formally-verified pure-core
functions (Kani harnesses), bounds-checked .get() accesses, saturating_*
arithmetic, explicit length gates before every slice, and overflow-checks = true
in release. Representative bounds-safe sites confirmed by review:
enip.rs frame walk (buf.len() - cursor >= 24 guard), parse_cpf_items/
parse_cip_header (bound-before-slice), modbus.rs 3-point ADU validity gate
(adu_len ≤ 260), tls.rs handshake-reassembly drain
(carry_len - consumed < 4 + body_len guard, body_len ≤ MAX_BUF).
No action required. This is a strong result.
Finding 2 — Performance / DoS: DNP3 and ENIP analyzers leak per-flow state (unbounded memory) — Medium
Summary. The --dnp3 and --enip analyzers (and therefore --all) accumulate
per-flow state that is never released until end-of-run summarize(). Modbus,
HTTP, and TLS purge their per-flow state when a flow closes or is evicted; DNP3 and
ENIP do not. Memory therefore grows linearly with the number of distinct flows ever
observed, with no ceiling.
Root cause. src/dispatcher.rs on_flow_close (lines ~405–414):
Some(DispatchTarget::Dnp3) => {
// "Dnp3Analyzer does not implement StreamHandler; no forwarding needed."
let _ = reason;
}
Some(DispatchTarget::Enip) => {
// "EnipAnalyzer does not implement StreamHandler; no forwarding needed."
let _ = reason;
}
- DNP3 (
Dnp3Analyzer) has no flow-close handler at all.
- ENIP (
EnipAnalyzer) has a correct purge method —
on_flow_close at enip.rs:693 does self.flows.remove(&flow_key) and folds the
state into aggregates — but the dispatcher never calls it. It is effectively
dead code.
In contrast, Modbus/Http/Tls arms call <analyzer>.on_flow_close(...), so their
live state is bounded by the reassembler's max_flows = 100_000 cap.
Why it matters. The TCP reassembler (src/reassembly/) goes to considerable
lengths to bound memory — max_flows = 100_000, 1 GB memcap, LRU/non-established
eviction (lifecycle.rs::evict_flows). The DNP3/ENIP analyzers silently undo that
protection: even as the reassembler evicts flows to stay within its cap, the
analyzer retains a FlowState for every distinct 5-tuple it has ever seen. Each
Dnp3FlowState/EnipFlowState holds multiple HashMaps and Vecs (~1 KB retained
per flow once populated). ICS reconnaissance/DDoS traffic — many short-lived flows —
is exactly the workload these analyzers are built to inspect.
Evidence (measured peak RSS, one PSH/ACK packet per distinct 5-tuple to the analyzer port):
| Distinct flows |
--dnp3 (port 20000) |
--enip (port 44818) |
--modbus (port 502, purges) |
| 100,000 |
191 MB |
190 MB |
210 MB |
| 300,000 |
488 MB |
— |
388 MB |
| 1,000,000 |
1,382 MB |
1,318 MB |
462 MB (plateaus) |
DNP3/ENIP RSS scales linearly and without bound; Modbus plateaus (its growth past
100K flows reflects only the larger input file, not retained analyzer state — note its
input file was slightly larger than DNP3's at each row). At 1M flows the input pcap is
~80 MB but DNP3 RSS is ~1.4 GB — roughly 17× amplification over file size, and a
6 GB capture full of distinct DNP3/ENIP flows would require tens of GB.
Reproduction:
# generate 1,000,000 distinct DNP3 flows (one packet each) -> ~79 MB pcap
python3 gen_flows.py 1000000 20000 dnp3_flood.pcap 0564050c0100000000
/usr/bin/time -l target/release/wirerust analyze dnp3_flood.pcap --dnp3 # ~1.4 GB RSS
# Modbus equivalent for comparison (purges; plateaus)
python3 gen_flows.py 1000000 502 modbus_flood.pcap 000000000006010300000001
/usr/bin/time -l target/release/wirerust analyze modbus_flood.pcap --modbus # ~0.46 GB RSS
Remediation. Route on_flow_close to the DNP3 and ENIP analyzers in
dispatcher.rs, mirroring the Modbus arm. ENIP needs only the call wired to its
existing on_flow_close; DNP3 needs an equivalent purge method added (remove the
flow's FlowState, folding any needed counters into aggregates first). This bounds
analyzer memory to live flows (~max_flows), consistent with Modbus/HTTP/TLS.
Finding 3 — Performance (secondary): classic-pcap path has no file-size cap — Low
src/reader.rs::from_file gates file size only on the pcapng branch
(MAX_PCAPNG_FILE_BYTES = 4 GiB, error E-INP-014). The classic-.pcap branch is
ungated and, under the all-in-memory model, reads the entire file into RAM plus a
Vec<RawPacket> with a per-packet heap Vec<u8> (roughly doubling resident size).
The corpus contains a 6.4 GB classic .pcap (challenges/5gb-tcp-connection.pcap).
This is consistent with the documented "all-in-memory is a known limitation" note, but
the gating asymmetry (pcapng protected, classic not) is worth closing — apply an
equivalent fstat-based cap to the classic-pcap branch.
Finding 4 — Functional output correctness: PASS
Cross-format consistency was verified on example-01 and a 300-file random sample:
summary packet/byte counts == analyze JSON total_packets/total_bytes
(e.g. 22,639 packets / 63,159,771 bytes — identical).
- JSON finding count == CSV row count == terminal (no-collapse) finding count
(e.g. 1,241 == 1,241 == 1,241 on a Modbus DDoS capture; 2 == 2 == 2 on example-01).
--all enables all 8 analyzers, including EtherNet/IP (verified via JSON
analyzers[]); --enip alone enables only Reassembly + EtherNet/IP.
- Findings are capped at
MAX_FINDINGS = 10_000 per analyzer (bounded output).
Notes (by-design behaviors, not defects):
- The top-line "Packets" counts decoded IP packets only; ARP/non-IP frames are
excluded from that total. The ARP analyzer reports its own "Packets analyzed: N",
and skipped_packets accounts for non-IP frames. A pure-ARP capture run without
--arp therefore shows Packets: 0, which is correct but can surprise.
- Terminal finding lines are prefixed by MITRE ThreatCategory
([Execution], [Reconnaissance], …), not a fixed [Anomaly]/[Threat] tag.
5. Test Artifacts
All harnesses and reproduction inputs are in the session scratchpad:
| File |
Purpose |
sweep.sh |
Corpus crash sweep driver (4,377 files × 8 modes) |
fuzz.py |
Multi-threaded black-box mutation fuzzer (10 strategies) |
gen_flows.py |
Generates N-distinct-flow pcaps for memory testing |
func_check.py |
Cross-format (JSON/CSV/terminal/summary) consistency checker |
seeds/ |
Per-analyzer seed corpus |
cust_dnp3.pcap, cust_enip.pcap, cust_modbus.pcap |
300K-flow repro captures |
6. Conclusion & Recommendations
- Crash resistance is excellent — no input produced a panic, signal, or hang
across 400K fuzz iterations and 4,377 real captures. The defensive coding and
formal-verification investment is paying off.
- Fix the DNP3/ENIP per-flow memory leak (Finding 2). It is a genuine
resource-exhaustion / DoS vector that contradicts the codebase's own reassembler
DoS protections and the behavior of the sibling Modbus/HTTP/TLS analyzers. The fix
is small and local to dispatcher.rs.
- Close the classic-pcap size-cap gap (Finding 3). Apply the pcapng
fstat cap
to the classic-pcap branch for symmetry.
- Functional output is trustworthy — formats agree and
--all covers all
analyzers.
A natural next step is to add the two deferred in-tree cargo-fuzz targets the code
already TODOs (F-P9-002: parse_cpf_items, parse_cip_header) plus a coverage-guided
target over PcapSource::from_pcap_reader, which would explore far deeper than
black-box mutation can.
wirerust Fuzzing & Robustness Report
Target:
wirerustv0.11.0 — Fast PCAP forensics and network triage CLI (Rust 2024)Binary under test:
target/release/wirerust(release profile,overflow-checks = true)Date: 2026-06-30
Tester: Claude (Opus 4.8)
Corpus:
~/workdir/practice/pcaps/(9,235 capture files, 193 GB; sizes from 25 B to 6.4 GB)1. Executive Summary
The tool was exercised for three classes of defect — security (can it be made to
crash), performance (can it be made to degrade or exhaust resources), and
functional (does it output results as advertised).
--dnp3and--enipanalyzers never release per-flow state, growing memory without bound and silently bypassing the TCP reassembler's own DoS caps. An 80 MB capture drives 1.4 GB RSS (~17×).summarycounts =analyzecounts;--allenables all 8 analyzers.The headline finding is performance/DoS, not a crash: two of the ICS protocol
analyzers leak per-flow state for the entire run, defeating the memory protections
that the rest of the codebase carefully implements.
2. Tool Under Test
src/reader.rs): classic-pcap (libpcap) and pcapng; magic-byte probe;multi-link-type (Ethernet, Raw IP, IPv4, IPv6, Linux SLL).
src/decoder.rs):etherparsestrict→lax parse; IPv4/IPv6/TCP/UDP/ICMP/ARP.src/analyzer/): DNS, HTTP, TLS, Modbus, DNP3, EtherNet/IP (ENIP), ARP.src/reassembly/): forensic TCP stream reassembler withmax_flowsand memcap eviction.
src/reporter/): terminal, JSON, CSV.Subcommands:
analyze(threat detection) andsummary(triage). Analyzer flags:--dns --http --tls --modbus --dnp3 --arp --enip, or--all.3. Methodology
3.1 Corpus crash sweep
A driver (
sweep.sh) ran every capture <10 MB (4,377 files) through 8 invocationprofiles —
summary,analyze --all, JSON, CSV,--mitre,--http --tls --dns,--modbus --dnp3 --arp,--reassemble --all— with a per-run timeout, classifyingeach exit as PANIC (101), SIGNAL (>128), HANG (timeout), or clean.
3.2 Black-box mutation fuzzer
A multi-threaded fuzzer (
fuzz.py, ~1,000 exec/s) mutated a seed corpus and ran thebinary under randomly chosen flag sets, catching panics/signals/hangs and saving any
crashing input + metadata.
modbus.pcap,dnp3.pcap,enip.pcap,dns.pcap,http.pcap,tls.pcap,arp.pcap,small.pcapng,v6.pcap.truncation, length-field smashing (u16/u32 →
0xFFFF/0/0x7FFFFFFF),byte insertion, chunk duplication (grow), zero/
0xFFregion fills, cross-seed splice.overflow-checks = true, so any integer-overflow bug surfaces asa panic — length-field smashing specifically targets this.
3.3 Targeted code review + grammar-aware inputs
Reviewed every slice/arithmetic hot spot in the analyzers
(
enip.rs,modbus.rs,dnp3.rs,tls.rs,decoder.rs,reader.rs) and thereassembly engine. Built structured pcaps (
gen_flows.py) to probe flow-tablememory growth, and a cross-format checker (
func_check.py) to compare JSON/CSV/terminal/summary outputs.
4. Findings
Finding 1 — Security / crash resistance: PASS (no defects)
The parsing/analyzer layer is heavily defensively coded: formally-verified pure-core
functions (Kani harnesses), bounds-checked
.get()accesses,saturating_*arithmetic, explicit length gates before every slice, and
overflow-checks = truein release. Representative bounds-safe sites confirmed by review:
enip.rsframe walk (buf.len() - cursor >= 24guard),parse_cpf_items/parse_cip_header(bound-before-slice),modbus.rs3-point ADU validity gate(
adu_len ≤ 260),tls.rshandshake-reassembly drain(
carry_len - consumed < 4 + body_lenguard,body_len ≤ MAX_BUF).No action required. This is a strong result.
Finding 2 — Performance / DoS: DNP3 and ENIP analyzers leak per-flow state (unbounded memory) — Medium
Summary. The
--dnp3and--enipanalyzers (and therefore--all) accumulateper-flow state that is never released until end-of-run
summarize(). Modbus,HTTP, and TLS purge their per-flow state when a flow closes or is evicted; DNP3 and
ENIP do not. Memory therefore grows linearly with the number of distinct flows ever
observed, with no ceiling.
Root cause.
src/dispatcher.rson_flow_close(lines ~405–414):Dnp3Analyzer) has no flow-close handler at all.EnipAnalyzer) has a correct purge method —on_flow_closeatenip.rs:693doesself.flows.remove(&flow_key)and folds thestate into aggregates — but the dispatcher never calls it. It is effectively
dead code.
In contrast,
Modbus/Http/Tlsarms call<analyzer>.on_flow_close(...), so theirlive state is bounded by the reassembler's
max_flows = 100_000cap.Why it matters. The TCP reassembler (
src/reassembly/) goes to considerablelengths to bound memory —
max_flows = 100_000, 1 GB memcap, LRU/non-establishedeviction (
lifecycle.rs::evict_flows). The DNP3/ENIP analyzers silently undo thatprotection: even as the reassembler evicts flows to stay within its cap, the
analyzer retains a
FlowStatefor every distinct 5-tuple it has ever seen. EachDnp3FlowState/EnipFlowStateholds multipleHashMaps andVecs (~1 KB retainedper flow once populated). ICS reconnaissance/DDoS traffic — many short-lived flows —
is exactly the workload these analyzers are built to inspect.
Evidence (measured peak RSS, one PSH/ACK packet per distinct 5-tuple to the analyzer port):
--dnp3(port 20000)--enip(port 44818)--modbus(port 502, purges)DNP3/ENIP RSS scales linearly and without bound; Modbus plateaus (its growth past
100K flows reflects only the larger input file, not retained analyzer state — note its
input file was slightly larger than DNP3's at each row). At 1M flows the input pcap is
~80 MB but DNP3 RSS is ~1.4 GB — roughly 17× amplification over file size, and a
6 GB capture full of distinct DNP3/ENIP flows would require tens of GB.
Reproduction:
Remediation. Route
on_flow_closeto the DNP3 and ENIP analyzers indispatcher.rs, mirroring the Modbus arm. ENIP needs only the call wired to itsexisting
on_flow_close; DNP3 needs an equivalent purge method added (remove theflow's
FlowState, folding any needed counters into aggregates first). This boundsanalyzer memory to live flows (~
max_flows), consistent with Modbus/HTTP/TLS.Finding 3 — Performance (secondary): classic-pcap path has no file-size cap — Low
src/reader.rs::from_filegates file size only on the pcapng branch(
MAX_PCAPNG_FILE_BYTES = 4 GiB, errorE-INP-014). The classic-.pcapbranch isungated and, under the all-in-memory model, reads the entire file into RAM plus a
Vec<RawPacket>with a per-packet heapVec<u8>(roughly doubling resident size).The corpus contains a 6.4 GB classic
.pcap(challenges/5gb-tcp-connection.pcap).This is consistent with the documented "all-in-memory is a known limitation" note, but
the gating asymmetry (pcapng protected, classic not) is worth closing — apply an
equivalent
fstat-based cap to the classic-pcap branch.Finding 4 — Functional output correctness: PASS
Cross-format consistency was verified on example-01 and a 300-file random sample:
summarypacket/byte counts ==analyzeJSONtotal_packets/total_bytes(e.g. 22,639 packets / 63,159,771 bytes — identical).
(e.g. 1,241 == 1,241 == 1,241 on a Modbus DDoS capture; 2 == 2 == 2 on example-01).
--allenables all 8 analyzers, including EtherNet/IP (verified via JSONanalyzers[]);--enipalone enables only Reassembly + EtherNet/IP.MAX_FINDINGS = 10_000per analyzer (bounded output).Notes (by-design behaviors, not defects):
excluded from that total. The ARP analyzer reports its own "Packets analyzed: N",
and
skipped_packetsaccounts for non-IP frames. A pure-ARP capture run without--arptherefore showsPackets: 0, which is correct but can surprise.(
[Execution],[Reconnaissance], …), not a fixed[Anomaly]/[Threat]tag.5. Test Artifacts
All harnesses and reproduction inputs are in the session scratchpad:
sweep.shfuzz.pygen_flows.pyfunc_check.pyseeds/cust_dnp3.pcap,cust_enip.pcap,cust_modbus.pcap6. Conclusion & Recommendations
across 400K fuzz iterations and 4,377 real captures. The defensive coding and
formal-verification investment is paying off.
resource-exhaustion / DoS vector that contradicts the codebase's own reassembler
DoS protections and the behavior of the sibling Modbus/HTTP/TLS analyzers. The fix
is small and local to
dispatcher.rs.fstatcapto the classic-pcap branch for symmetry.
--allcovers allanalyzers.
A natural next step is to add the two deferred in-tree
cargo-fuzztargets the codealready TODOs (
F-P9-002:parse_cpf_items,parse_cip_header) plus a coverage-guidedtarget over
PcapSource::from_pcap_reader, which would explore far deeper thanblack-box mutation can.