Chapter 9 — Critical infrastructure and sector security: tools and methods
Lesson map
Lesson 09 published. Map, example, EX033.
flowchart TD
N0["Learning outcomes and connection"]
N1["9.1 Choosing a method"]
N2["9.2 DNP3 at the link layer"]
N3["9.3 Validate the tool first"]
N4["9.4 Error detection is not authentication"]
N5["9.6 Practical activity"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6
N6 --> EX["Worked example"]
EX --> Q["Exercise EX033"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["9.1 Choosing a method"]
N2["9.2 DNP3 at the link layer"]
N3["9.3 Validate the tool first"]
N4["9.4 Error detection is not authentication"]
N5["9.6 Practical activity"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> Q["EX033"]
[Learning outcomes and connection]
|
v
[9.1 Choosing a method]
|
v
[9.2 DNP3 at the link layer]
|
v
[9.3 Validate the tool first]
|
v
[9.4 Error detection is not authentication]
|
v
[9.6 Practical activity]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX033]
Learning outcomes and connection
After this chapter you should be able to:
- choose the right technical tool for a question in a control environment: configuration export, passive capture, protocol decoder, log query, vendor engineering tool or active test;
- implement and verify an integrity check from its published parameters, using DNP3's link-layer CRC as the example;
- explain the difference between an error-detection code and authentication, and why a valid CRC proves nothing about who sent a frame;
- validate a tool before trusting it: known-answer tests, negative tests and documented versions.
Chapters 7 and 8 used telemetry. This chapter is about the instruments themselves — how to pick them, build small ones correctly and know when they lie.
9.1 Choosing a method
| Question | Prefer | Avoid unless scoped |
|---|---|---|
| What is configured? | Configuration export, vendor tool read-only view | Probing ports to infer configuration |
| Who talks to whom? | Flow logs, passive capture on a SPAN port or tap | Active scanning |
| What does a device run? | Asset records, engineering-tool inventory during an approved session | Fingerprinting by probing |
| Does a deny rule work? | Firewall logs plus one scoped connect test | Broad scans |
| Is a frame well-formed? | Decoder with protocol checks (Chapter 2, this chapter) | Eyeballing hex |
The guide to OT security in NIST SP 800-82 Rev. 3 discusses passive monitoring and the risks of active techniques (SP 800-82 Rev. 3). Packet analysers such as Wireshark include dissectors for Modbus and DNP3; your own scripts are for the checks those tools do not do for you, and for repeatable, reviewable automation.
9.2 DNP3 at the link layer
DNP3 is widely used in electricity and water telecontrol. Each link-layer frame starts with a 10-byte header: start bytes 05 64, a length byte, a control byte, a 2-byte destination and a 2-byte source address (little-endian), and a 2-byte CRC. User data follow in blocks of up to 16 bytes, each with its own CRC. In the control byte, bit 7 (DIR) is set for frames from the master, bit 6 (PRM) for primary (initiating) frames, and the low four bits are the link function code — for example primary 0 "reset link states", primary 4 "unconfirmed user data", secondary 11 "link status". The DNP Users Group publishes an overview; the full standard is IEEE 1815 (DNP3 overview).
The CRC is CRC-16/DNP: polynomial 0x3D65, initial value 0, input and output reflected, final XOR 0xFFFF, with the catalogue check value 0xEA82 for the ASCII string 123456789 (CRC catalogue). Reflected, the polynomial is 0xA6BC, which is the constant used in a bit-by-bit implementation.
9.3 Validate the tool first
A CRC routine that is subtly wrong still produces numbers. Before using it, run a known-answer test: compute the CRC of 123456789 and compare with 0xEA82. Then a negative test: change one bit of a valid frame and confirm the check fails. Lab 9 does both — the assert line is the known-answer test — and record the Python version, because the result of any tool is evidence only if the tool is identified.
Keep the assert that checks the catalogue value in the script, and fail the job if it does not hold after a change to the CRC function. Record the Python version next to every result that will be used as evidence. Run the negative test on a frame you corrupted yourself, so you know the checker rejects bad CRCs before you trust it on a capture.
9.4 Error detection is not authentication
A CRC is computed from public parameters; anyone who can send a frame can compute a valid CRC. It detects accidental corruption, not forgery. Lab 9 shows this directly: a frame from an unknown source address (77) has a valid CRC. Telling genuine from forged needs either cryptographic authentication (DNP3 Secure Authentication, or a protected tunnel) or context — known source addresses, known paths, expected timing. Compare Chapter 15 of the technologist book, which uses HMAC (RFC 2104) for authenticity (RFC 2104).
9.5 Worked example: checking SS-3 headers (synthetic)
The Kestrel Grid master (DNP3 address 1) polls substation SS-3's outstation (address 10) through fep-01. A technician captured six link headers.
Lab 9 (save as lab09.py, run with python3 lab09.py):
# Lab 9 - check DNP3 link-layer headers and their CRC (offline, standard library only)
# CRC-16/DNP: poly 0x3D65 (reflected 0xA6BC), init 0x0000, reflected in/out, xorout 0xFFFF.
# Synthetic frames between the Kestrel Grid master (address 1) and substation SS-3 outstation (address 10).
import struct
def crc_dnp(data: bytes) -> int:
crc = 0
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0xA6BC if crc & 1 else crc >> 1
return crc ^ 0xFFFF
assert crc_dnp(b"123456789") == 0xEA82 # catalogue check value
def header(length, ctrl, dest, src):
h = bytes([0x05, 0x64, length, ctrl]) + struct.pack("<HH", dest, src)
return h + struct.pack("<H", crc_dnp(h))
capture = [
("master->SS-3", header(0x05, 0xC0, 10, 1).hex()), # primary function 0: reset link states
("SS-3->master", header(0x05, 0x0B, 1, 10).hex()), # secondary function 11: link status
("master->SS-3", header(0x11, 0xC4, 10, 1).hex()), # primary function 4: unconfirmed user data
("master->SS-3", "056411c40a000100" + "ffff"), # wrong CRC
("unknown->SS-3", header(0x11, 0xC4, 10, 77).hex()), # source not in station table
("master->SS-3", "0565" + header(0x05, 0xC0, 10, 1).hex()[4:]), # bad start bytes
]
KNOWN_SOURCES = {1}
for label, hx in capture:
b = bytes.fromhex(hx)
if b[:2] != b"\x05\x64": print(f"{label:<14} {hx} REJECT start bytes {b[:2].hex()}"); continue
got = struct.unpack("<H", b[8:10])[0]; want = crc_dnp(b[:8])
length, ctrl, dest, src = b[2], b[3], *struct.unpack("<HH", b[4:8])
if got != want: print(f"{label:<14} {hx} REJECT crc {got:04x} != {want:04x}"); continue
direction = "from master" if ctrl & 0x80 else "from outstation"
note = "" if src in KNOWN_SOURCES or src == 10 else " ALERT unknown source address"
print(f"{label:<14} {hx} ok len={length} ctrl=0x{ctrl:02x} ({direction}, fc={ctrl & 0x0F}) dest={dest} src={src}{note}")
Expected output (Python 3.13.5):
master->SS-3 056405c00a000100b1ac ok len=5 ctrl=0xc0 (from master, fc=0) dest=10 src=1
SS-3->master 0564050b01000a006ded ok len=5 ctrl=0x0b (from outstation, fc=11) dest=1 src=10
master->SS-3 056411c40a0001000615 ok len=17 ctrl=0xc4 (from master, fc=4) dest=10 src=1
master->SS-3 056411c40a000100ffff REJECT crc ffff != 1506
unknown->SS-3 056411c40a004d0073e6 ok len=17 ctrl=0xc4 (from master, fc=4) dest=10 src=77 ALERT unknown source address
master->SS-3 056505c00a000100b1ac REJECT start bytes 0565
Three frames are valid and expected. One has CRC ffff where 1506 was expected — corruption or a crafted frame; on a noisy link, a run of CRC failures is a link-quality finding for the telecom provider. One has wrong start bytes and is rejected before anything else is read. The most important line is the one that passes the CRC: source address 77 is not in the station table. The CRC says the bytes arrived intact; only the address check says they came from somewhere unexpected.
9.6 Practical activity
Extend Lab 9 to verify the CRC of each 16-byte user-data block that follows the header. Construct a frame with 17 bytes of user data (one full block and one 1-byte block), compute both block CRCs, and corrupt one byte in the second block to show the check catches it.
Corrupt one byte in the second block and confirm that only that block's check fails. Keep the construction steps in the lab notes so a reviewer can rebuild the same frame, and note which byte you corrupted and the expected and computed CRC of that block.
9.7 Common errors and limits
- Trusting a home-made checker without a known-answer test.
- Getting byte order wrong: DNP3 addresses and CRC are little-endian; Modbus fields are big-endian.
- Treating "CRC valid" as "authentic".
- Active probing when a passive source answers the question.
- Lab 9 checks headers only; transport and application layers carry the commands and need their own decoding.
-
Decoding a capture without recording the tool version and byte-order settings used, so nobody can reproduce the result.
-
Reading the Lab 9 ALERT for source address 77 as proof of an attack; it only shows an address missing from the master list.
Exercises — chapter 9
EX033 — Known-answer test. Objective: validate a tool. Inputs: Lab 9's crc_dnp. Steps: (1) replace 0xA6BC with 0x3D65 and run; (2) explain the result. Expected evidence: output and explanation. Rubric: 3 = the assertion fails because a right-shifting (reflected) algorithm needs the reflected polynomial; the known-answer test caught a plausible bug before any frame was judged; 2 = observes failure without explanation; 1 = attempted; 0 = none.
EX034 — Decode the control byte. Objective: read DNP3 link fields. Inputs: control bytes 0xC9, 0x44 and 0x0B. Steps: give DIR, PRM and function code for each and name the function. Expected evidence: three rows. Rubric: 3 = 0xC9: DIR 1, PRM 1, fc 9 request link status; 0x44: DIR 0, PRM 1, fc 4 unconfirmed user data from an outstation (for example an unsolicited response); 0x0B: DIR 0, PRM 0, fc 11 link status; 2 = one wrong; 1 = bits only; 0 = none.
EX035 — Forgery thought experiment. Objective: separate integrity and authenticity. Inputs: a frame with valid CRC, source address 1 and destination 10, arriving at the outstation from an address on the MPLS network that is not fep-01. Steps: explain what the CRC shows, what the address shows and what would actually establish authenticity. Expected evidence: short explanation. Rubric: 3 = CRC shows no accidental corruption; DNP3 address 1 is self-asserted; authenticity requires Secure Authentication or a cryptographic tunnel terminating at known endpoints, plus network enforcement of the path; 2 = partial; 1 = "valid CRC so genuine"; 0 = none.
EX036 — Method selection. Objective: pick the least intrusive method. Inputs: questions: (a) which DNP3 master addresses SS-3 has seen this month; (b) whether fep-01's firewall permits only the outstation; (c) the outstation's firmware version. Steps: choose a method for each and justify. Expected evidence: three choices. Rubric: 3 = (a) passive capture or fep-01 logs analysed with a decoder; (b) configuration export plus logs, then a scoped test if needed; (c) asset record or vendor tool during an approved session with the protection engineer's agreement; 2 = one intrusive choice; 1 = scanning; 0 = none.
Chapter synthesis
Pick the least intrusive method that answers the question, and validate every tool before trusting it: known-answer tests, negative tests, recorded versions. DNP3's link-layer CRC shows the discipline in miniature — implement it from published parameters, check it against the catalogue value — and also its limit: error detection proves the bytes arrived intact, never who sent them.
Continue studying — chapter 09 of 18
← Chapter 08 · Course chapter index · Chapter 10 →
Continue to the next authored lesson.