Skip to content

Fuzzing & robustness report: DNP3/ENIP analyzers leak per-flow state (unbounded memory) + full results #342

Description

@SackOfHacks

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 capLow

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

  1. 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.
  2. 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.
  3. Close the classic-pcap size-cap gap (Finding 3). Apply the pcapng fstat cap
    to the classic-pcap branch for symmetry.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions