16 · Industrial protocols and Modbus trace analysis
Depth: developed lesson with executable offline fixture · Prerequisite: SCADA architecture · Time: two 90-minute sessions.
This is an analysis exercise. You will decode seven synthetic request records, compare them with a declared policy, and explain a detector’s blind spot. The supplied program reads local JSON; it does not open sockets, contact devices, or simulate a physical process.
Outcomes
- Decode the fields needed to distinguish a register read from a register write.
- Keep malformed and unsupported records visible instead of counting them as safe.
- Distinguish a protocol address from an engineering meaning.
- Explain why a function-code alert cannot establish actor identity or physical impact.
First separate the layers
A protocol tells you how a request is represented. A device’s documentation and configuration tell you what its data means. An access policy tells you who is permitted to perform an operation. These are different inputs to an investigation.
Modbus represents coils and discrete inputs as bits, and input/holding registers as 16-bit quantities. Function 03 reads holding registers; function 06 writes one holding register. Their request bodies carry an address and either a count or a value. Protocol addresses are zero-based, and multibyte quantities use big-endian encoding. Consult Modbus Application Protocol V1.1b3, sections 4.2–4.4, 6.3 and 6.6.
For this fixture only, define address 100 as a tank-level reading, 101 as a requested setpoint, and 109 as an unrelated maintenance parameter. These meanings are invented for the lesson. A real register map could assign entirely different meanings, scaling, ranges, and permissions.
Read one synthetic request
00 04 | 00 00 | 00 06 | 01 | 06 | 00 65 | 00 63
A B C D E F G
| Field | Value in this record | Interpretation |
|---|---|---|
| A | 4 | Transaction identifier |
| B | 0 | Protocol identifier |
| C | 6 | Number of bytes following the length field |
| D | 1 | Unit identifier |
| E | 6 | Write-single-register function |
| F | 101 | Protocol register address, hexadecimal 0065 |
| G | 99 | Encoded register value, hexadecimal 0063 |
The TCP form has a seven-byte MBAP header. Its length includes the unit identifier and protocol body. Transaction identifiers support pairing; they are not credentials. Unit identifiers support routing, including through gateways. These fields are specified in the Modbus Messaging on TCP/IP Implementation Guide V1.0b, section 3.1.
The record therefore represents a request to write an encoded value. It is not a captured response, authenticated identity, successful write, or measurement of a tank. Keep those distinctions in your report.
Lab setup
Requirements: the repository checkout and Python 3.12 or newer. No extra package, Docker service, or model is needed.
From the repository root:
python3 -m labs.ot_trace
The fixture is at labs/ot_trace/trace.synthetic.json; the decoder is in labs/ot_trace/__main__.py. Read the JSON before examining the answer. The script accepts only traces explicitly marked synthetic, and handles two request functions. It is not a general-purpose packet analyzer or a Modbus conformance suite.
Our toy policy is:
| Claimed source role | Functions the rule permits |
|---|---|
| Historian | 03 |
| HMI | 03 |
| Engineering | 03 and 06 |
| Anything else | Unclassified |
These roles are fixture labels, not authenticated identities. “Matches toy policy” says only that a label/function pair agrees with this table.
Session one: prediction before output
- Decode record
r1manually. Predict its starting address and requested count. - Compare
r3andr4. They use the same write function; explain why the declared role changes the policy result. - Count the bytes in
r5. Decide whether a missing field proves a malicious request, an incomplete observation, or neither by itself. - Identify the function in
r6. It lies outside this decoder’s supported subset; predict how the program should report it. - Run the command and compare each prediction with the output.
Keep a table with record ID, decoded facts, policy decision, and what remains unknown. A parser error belongs in the table too.
Expected fixture results — open after predicting
The bundled input has 7 records, 5 decoded requests, 2 unclassified records, and 1 policy deviation.
r1 reads two registers beginning at address 100. r4 requests a write of 99 to address 101 under the historian label, so it deviates from the toy policy. r5 has a length mismatch. r6 uses function 16, outside the decoder’s subset. Neither unclassified record is automatically safe or automatically malicious.
Session two: find the detector’s blind spot
Record r7 requests a write to address 109 under the engineering label. The toy rule accepts that function for engineering, even though the scenario only described a setpoint change at address 101. This is a deliberate limitation, not proof that address 109 is authorized.
Copy the JSON into your private exercise directory. Change only r7’s source_role to historian and run the copy:
python3 -m labs.ot_trace /path/to/your/trace-copy.json
Predict the result before running it: the deviation count increases from one to two. You changed a teaching label, not a network identity or a device permission.
Next, propose a better decision rule on paper. It should consider the requested operation, destination/unit, register range, approved maintenance window, and the identity evidence available. Specify what it returns when that evidence is missing. Do not simply relabel all engineering traffic as safe.
A useful rule proposal is precise enough to fail a counterexample. For example: “A maintenance write is expected only when the approved work item names the same unit and address, and its time window covers the observation.” Then construct a counterexample involving a valid account and the wrong register.
Interpret success carefully
A correct decoder output demonstrates agreement between code and known fixture bytes. It does not establish coverage of all protocol functions, encrypted traffic, TCP stream reassembly, vendor extensions, or real device behavior. Function 16 is a legitimate protocol function; its unsupported status here is a limit of our decoder. The application specification’s function table is the reference for broader coverage.
The Modbus Organization also publishes a security protocol based on TLS and certificates. Avoid the claim that every system carrying Modbus necessarily has identical transport protections; inspect the actual deployment and applicable specification. See the official specifications index.
Evidence
Save your predictions, the original fixture’s output, your changed record, the second output, and the proposed rule with one counterexample. Record your Python version and repository commit. State the exact denominator: seven synthetic records, not seven devices or seven attacks.
For acceptance, manually justify the r4 deviation, preserve the two unknowns, explain the r7 blind spot, and distinguish request intent from observed execution. The test suite verifies fixture behavior; your explanation is a separate learner assessment.
Safety and scope
Use only local synthetic JSON. Do not replay these bytes against hardware, connect the exercise to a controller, or reinterpret the fixture as approval for an active industrial test. Reset by discarding your private copy; the bundled input remains unchanged.
Teach-back
- Why is address 101 insufficient to tell you a physical setpoint or its units?
- Why is an unsupported function different from a malformed request?
- What evidence would connect a claimed source role to an actual actor?
- What additional observation is needed before claiming the requested value took effect?