Conversation
|
Thank you for your contribution to NETCAP! Before we can accept your pull request, you need to sign our Contributor License Agreement (CLA). To sign the CLA, please read the Individual CLA and comment below with: If you are contributing on behalf of a company or organization, please contact the maintainers to complete the Entity CLA instead. You can re-trigger this check by commenting I have read and agree to the NETCAP CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
Extend the Modbus stream decoder from MBAP metadata to hunting-grade evidence: per-function addresses, quantities and written values, exception codes, FC8 diagnostics, FC43/MEI14 device identification, file records and masks. Requests and responses are classified explicitly and correlated within a connection, with bounded pending state and no inferred matches. Add opt-in RTU-over-TCP framing for explicitly configured gateway endpoints via -modbus-rtu-endpoints, sharing the PDU parser but keeping its own CRC16 framing, broadcast handling and recovery. Ship rules/examples/modbus_hunt.yml with scoped write-authorization templates that validate the whole written interval, plus discovery, diagnostic, enumeration and visibility triage rules. Site-dependent rules stay disabled and configure no response actions. Supporting fixes: - preserve per-packet capture timestamps through TCP reassembly and merge directions without reordering bytes within a direction - honor AllowMissingInit instead of dropping midstream conversations - derive message roles from the handshake rather than assuming orientation - bound Modbus metric labels and numeric encoding, and align CSV output - increment metrics when decoding live or PCAP traffic - preserve distinct_field settings when the web UI saves rules - reset recycled reassembly page flags Documented in docs/modbus-threat-hunting.md.
dreadl0ck
marked this pull request as ready for review
September 6, 2026 00:34
Decode FC7, 11, 12, 17 and 24 instead of reporting them unsupported. FC17 vendor content stays opaque in Payload and FC12 event bytes are surfaced as raw codes, since neither has a portable structure worth inventing. Make capture loss visible instead of silent. A gap or unusable framing now emits one marker record per loss event with ParseStatus "lost" and the new LostBytes field, reporting the summed extent and -1 when any contributing gap is of unknown size. Decoding resumes at the first byte after a bounded gap with a single validation attempt; the first message after any arbitrary resume point stays provisional and must parse, so header-like payload bytes can no longer fabricate records. The framer still never scans forward through a body looking for a header. Correlation stays honest across loss: outstanding requests become ambiguous and responses near the gap are not matched, because a request destroyed in the gap is indistinguishable from one that was never sent. Also frame the four-byte empty-request functions and FC24 over RTU, count loss markers separately in metrics, and add hunt rules for FC17 server identification and capture-loss coverage.
Rules can now require an earlier related record before alerting:
sequence:
after: <expression matching the earlier record>
group_by: [SrcIP, DstIP, UnitID, Bank, Address]
within: 900
The gate runs before threshold and distinct counting, and rules without a
sequence are unaffected. Grouping is exact equality on record fields, so it
pairs a read and a write of the same start address rather than proving that
a written span overlaps an earlier read. A record can never satisfy its own
precondition, records missing a group field are not correlated, and state is
per rule and bounded by the window.
Group fields are validated against the record type at load, and sequences on
disabled rules are validated too, so a typo or a broken hunt template fails
loudly instead of producing a rule that can never fire. A precondition that
fails to evaluate is reported rather than silently disabling detection.
An out-of-order record is older than the stored observation and must not
evict it: records reach the engine from several workers, so a late arrival
would otherwise destroy a valid later pair.
Ship the Modbus write-after-read hunt using this, disabled by default since
read-modify-write masters do it legitimately, and preserve and display
sequences through the rules API.
An MBAP header is a weak signature. LDAP's BER framing satisfies every field of it, so a SEQUENCE with a long-form length reads as protocol ID 0 with a plausible length, unit and function code. On a mixed capture that produced 293 Modbus records for LDAP traffic, with function codes and addresses an analyst would have to disprove. Adopt a conversation only when one complete ADU decodes. Real Modbus is unaffected: the same capture that produced 51 requests and 47 matched responses still does. The LDAP conversations now produce nothing rather than 51 malformed and loss records, which also keeps the default-enabled triage rules quiet on ordinary enterprise traffic. The cost is that a conversation whose first complete ADU never arrives is not claimed.
File, Mail, Secret, Alert and Exploit records are produced by other decoders, so their write helpers are reached whenever the traffic appears even if that decoder was never selected and has no writer. Each one dereferenced the nil writer and took down the capture mid-run. Sweeping the pcap corpus with the producing decoders enabled and these five switched off crashed on 25 of 2311 captures, including the Ultimate PCAP, on any file carrying mail or an extractable transfer. Guard each helper the way imap_tls.go and vulnerability already do, and log a write failure instead of calling log.Fatal from inside a decoder. Re-swept the corpus three times, 2311 captures each: default decoders, producers with these consumers disabled, and every consumer excluded. No crashes remain.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Turns the Modbus stream decoder into a usable threat-hunting source, from MBAP metadata only to structured, correlated evidence, and ships hunt rules plus a practical guide.
Decoding
MessageRole(request/response/unknown) andParseStatus(valid/malformed/unsupported/lost) are explicit. Malformed PDUs never publish partially decoded values, and vendor-defined content (FC17) is left opaque rather than guessed.-payload; existing payload behaviour is unchanged.Capture loss is visible
A gap or unusable framing emits one marker per loss event with
ParseStatus == "lost"andLostBytes(summed extent,-1when unknown). Decoding resumes at the first byte after a bounded gap with a single validation attempt, and the first message after any arbitrary resume point must parse before it is emitted — header-like payload bytes cannot fabricate records. The framer never scans forward through a body. Correlation does not match across a loss.This makes coverage auditable: an empty hunt result over a conversation carrying loss markers is not evidence of absence.
RTU over TCP
Opt-in via
-modbus-rtu-endpoints/NC_MODBUS_RTU_ENDPOINTS, for explicitly named gateway endpoints only, never CRC-guessing on arbitrary traffic. Shares the PDU parser but has its own function-length + CRC16 framing, broadcast handling and bounded recovery.Hunting
rules/examples/modbus_hunt.yml(14 rules, six enabled by default): write authorization validating the whole written interval per bank, separate FC21 file writes, FC43/MEI14 and FC17 discovery, disruptive FC8 diagnostics, enumeration by distinct destination/unit/read address, correlated illegal-address exceptions, and visibility/coverage triage. Site-dependent rules ship disabled with documentation IPs and no response actions.Supporting fixes
AllowMissingInitinstead of discarding midstream conversations; derive roles from the handshake rather than assuming orientation.distinct_field/distinct_thresholdwhen the web UI saves or toggles rule sets.Validation
go test -tags=nodpi ./...— 2326 passed (excludingTestCapturePCAP, whose fixture is not in the repo).-raceacross decoder, reassembly, collector, rules, types, cmd — all passing.pnpm test73 passed; full CLI builds.pcaps/nDPI-modbus.pcap: 51 valid requests, 47 matched responses with addresses, values and latency, no false loss markers, 14 rules loaded.Notes for review
types/netcap.pb.gois gitignored and regenerated fromnetcap.proto.MessageRole == "request"; the guide and rules do this consistently.Remaining
unsupportedwith the raw PDU retained.