OpenCyber Academy
Engineering · Chapter 4 · English
Course code: OCA-137Chapter 04: OCA-137-C004

Chapter 4 — Programming I

Open, free learning for anyone who wants to protect people and technology. No account is required to read this text or its worked answers.

Lesson map

Lesson 04 published. Map, example, EX010.

flowchart TD
  N0["Learning outcomes"]
  N1["4.1 Decisions as functions"]
  N2["4.2 Exact types"]
  N3["4.3 Tests on synthetic decoded frames"]
  N4["4.3b Logging what the function decided"]
  N5["Synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5
  N5 --> EX["Worked example"]
  EX --> Q["Exercise EX010"]

Logical drawing

flowchart LR
  N0["Learning outcomes"]
  N1["4.1 Decisions as functions"]
  N2["4.2 Exact types"]
  N3["4.3 Tests on synthetic decoded frames"]
  N4["4.3b Logging what the function decided"]
  N5["Synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> Q["EX010"]
[Learning outcomes]
    |
    v
[4.1 Decisions as functions]
    |
    v
[4.2 Exact types]
    |
    v
[4.3 Tests on synthetic decoded frames]
    |
    v
[4.3b Logging what the function decided]
    |
    v
[Synthesis]
    |
    v
[Worked example] --> [Exercise EX010]

Learning outcomes

You will (1) write small, pure functions that decide whether an OT message is permitted; (2) represent industrial addresses with exact types; (3) test against synthetic decoded traffic, never live plant traffic.

4.1 Decisions as functions

An allow-list for Modbus is a function permit(source, function_code, register) -> (bool, reason). Making it pure — inputs in, decision out, no hidden configuration — lets you test it on a laptop with synthetic records. The reason string matters: "register-out-of-range" tells a reviewer that the HMI tried to write outside the setpoint block, which is a different story from an unknown station using a diagnostic function.

Lena keeps the reason strings short and fixed so that logs can be counted by reason.

4.2 Exact types

Store function codes as integers, register ranges as range objects, and station identities as fixed strings from an inventory. A frequent bug treats the 4xxxx "holding register" notation as a string and matches by prefix, so 400500 looks like 40050. Convert addresses once, at parse time, into integers with documented offsets.

Lena writes one conversion function for register addresses and tests it at both ends of the HMI's writable range, 40001 and 40010, and one step outside each. Station names come from the inventory, so a typo in the allow-list fails a lookup instead of silently creating a new station that matches nothing.

4.3 Tests on synthetic decoded frames

The lab below never opens a socket; it checks already-decoded tuples. That is deliberate. Course code must not send Modbus traffic to any device. Write one test per rule, plus boundary registers 40001 and 40010.

"""Lab: Modbus function-code allow-list on synthetic, already-decoded requests (no network)."""
ALLOW = {("HMI-1", 3), ("HMI-1", 6), ("HIST-1", 3), ("HIST-1", 4)}  # (source, function code)
WRITE_REGS = {("HMI-1", 6): range(40001, 40011)}                     # HMI may write setpoints only
reqs = [("HMI-1", 3, 40001), ("HMI-1", 6, 40003), ("HMI-1", 6, 40050),
        ("HIST-1", 3, 30001), ("HIST-1", 16, 40001), ("ENG-WS", 8, 0), ("HIST-1", 4, 30010)]
for src, fc, reg in reqs:
    if (src, fc) not in ALLOW:
        d = "BLOCK fc-not-allowed"
    elif (src, fc) in WRITE_REGS and reg not in WRITE_REGS[(src, fc)]:
        d = "BLOCK register-out-of-range"
    else:
        d = "PASS"
    print(f"{src:7} fc={fc:2} reg={reg:5} -> {d}")

Expected output:

HMI-1   fc= 3 reg=40001 -> PASS
HMI-1   fc= 6 reg=40003 -> PASS
HMI-1   fc= 6 reg=40050 -> BLOCK register-out-of-range
HIST-1  fc= 3 reg=30001 -> PASS
HIST-1  fc=16 reg=40001 -> BLOCK fc-not-allowed
ENG-WS  fc= 8 reg=    0 -> BLOCK fc-not-allowed
HIST-1  fc= 4 reg=30010 -> PASS

Tests are grouped by rule so that a failure points straight at the clause that broke. The historian's write test, which caught function 16, is kept as a permanent case. Lena's test table lists each tuple, the expected decision and the reason string, and a reviewer can read it without running anything. New stations or registers added to the allow-list arrive with their own rows in the same pull request.

4.3b Logging what the function decided

Every decision should be logged with the source, function code, register, decision, reason and the version of the allow-list file. When an operator asks why an HMI write was blocked at 03:12, the log answers in one line. Without the version field, nobody can tell whether the block came from yesterday's list or today's.

Synthetic case — Ashford Batch Plant. Lena's first allow-list let HIST-1 use function 16 (write multiple registers) because it "only reads". The test table caught it: the historian has no business writing, so function 16 was removed and the test now expects BLOCK.

4.4 Errors and limits

An allow-list on a monitor is only as good as the decoder. Malformed frames must be counted and alarmed, not silently dropped. Real enforcement devices need vendor validation before deployment.

Lena's monitor counts malformed frames per minute and raises an alarm if the count rises, because a sudden burst can mean a failing device or a decoder bug. Treating such frames as permitted would turn every decoder gap into an open door. The monitor in this course only observes; any device that actually blocks traffic at ABP is a vendor product validated on the bench first.

Exercises

  • EX010 — Rule. Write the permit rule (in words or code) for "HIST-1 may read input and holding registers, nothing else". Rubric: 3 = fc 3 and 4 only, all else BLOCK with reason; 2 = misses a reason; 1 = vague; 0 = includes writes.
  • EX011 — Boundary. Which register values test the edges of the HMI write range in the lab, and what is expected for 40011? Rubric: 3 = 40001 and 40010 PASS, 40011 BLOCK; 2 = one edge; 1 = vague; 0 = wrong.
  • EX012 — Lab. Why does ENG-WS fc=8 block, and why is that the right default? Rubric: 3 = no (ENG-WS, 8) entry; diagnostics/default-deny; 2 = first half; 1 = vague; 0 = wrong.

Synthesis

Pure, typed, tested permit functions are the smallest trustworthy unit of OT security software.

Continue studying — chapter 04 of 50

← Chapter 03 (draft) · Course chapter index · Chapter 05 (draft) →

Worked answers

EX010. permit(src, fc, reg): if src == "HIST-1" and fc in {3, 4} → PASS; if src == "HIST-1" otherwise → BLOCK "fc-not-allowed". No write codes (5, 6, 15, 16) are listed.

EX011. Test 40001 and 40010 with HMI-1 fc=6; both PASS. 40011 is outside range(40001, 40011) and must BLOCK "register-out-of-range".

EX012. There is no (ENG-WS, 8) tuple in ALLOW, so it blocks with fc-not-allowed. Function 8 is diagnostics; a default-deny list forces the engineering workstation's needs to be stated and approved rather than assumed.