Skip to content

Modbus threat hunting: structured PDU decoding, correlation, hunt rules and RTU-over-TCP - #72

Open
dreadl0ck wants to merge 6 commits into
masterfrom
feature/modbus-threat-hunting
Open

dreadl0ck wants to merge 6 commits into
masterfrom
feature/modbus-threat-hunting

Conversation

@dreadl0ck

@dreadl0ck dreadl0ck commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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

  • Per-function addresses, quantities and written values for FC1-8, 11, 12, 15-17, 20-24 and FC43/MEI14; exception codes, diagnostics, device identification, file records and masks.
  • MessageRole (request/response/unknown) and ParseStatus (valid/malformed/unsupported/lost) are explicit. Malformed PDUs never publish partially decoded values, and vendor-defined content (FC17) is left opaque rather than guessed.
  • Semantic decoding works without -payload; existing payload behaviour is unchanged.
  • Conservative in-connection correlation by transaction, unit and function with bounded pending state, duplicate-ID ambiguity and no negative-latency matches.

Capture loss is visible

A gap or unusable framing emits one marker per loss event with ParseStatus == "lost" and LostBytes (summed extent, -1 when 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

  • Preserve per-packet capture timestamps through reassembly; merge directions without reordering bytes within a direction (this one affects all stream protocols).
  • Honour AllowMissingInit instead of discarding midstream conversations; derive roles from the handshake rather than assuming orientation.
  • Bound Modbus metric labels and numeric encoding; align CSV/Encode columns; increment metrics for live/PCAP decoding, not just audit-file export.
  • Preserve distinct_field/distinct_threshold when the web UI saves or toggles rule sets.
  • Reset recycled reassembly page flags.

Validation

  • go test -tags=nodpi ./... — 2326 passed (excluding TestCapturePCAP, whose fixture is not in the repo).
  • -race across decoder, reassembly, collector, rules, types, cmd — all passing.
  • Fuzzing on the PDU parser and RTU framer; frontend pnpm test 73 passed; full CLI builds.
  • End-to-end on pcaps/nDPI-modbus.pcap: 51 valid requests, 47 matched responses with addresses, values and latency, no false loss markers, 14 rules loaded.
  • Guard tests were each confirmed to fail against the pre-fix behaviour, including one that runs the shipped YAML against real decoder output.

Notes for review

  • Protobuf tags 1-13 are unchanged; new fields occupy 14-51. types/netcap.pb.go is gitignored and regenerated from netcap.proto.
  • Function-code matches still include response echoes unless gated on MessageRole == "request"; the guide and rules do this consistently.
  • Records are emitted at connection completion, and a match is an observed attempt, not proof of a process change.

Remaining

  • Native serial RTU and Modbus ASCII are out of scope.
  • Vendor/user-defined function codes and non-MEI14 FC43 decode as unsupported with the raw PDU retained.
  • The rules engine still has no sequence or grouping operator, so read-to-write timing remains an offline analysis.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

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:

I have read and agree to the NETCAP CLA

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 recheck on this PR.


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 dreadl0ck changed the title Improve Modbus threat hunting evidence and documentation Modbus threat hunting: structured PDU decoding, correlation, hunt rules and RTU-over-TCP Sep 6, 2026
@dreadl0ck
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant