Chapter 17 — Contained laboratory II
Lesson map
Lesson 17 published. Map, example, EX067.
flowchart TD
N0["Learning outcomes and connection"]
N1["17.1 Rules from the model"]
N2["17.2 Episodes"]
N3["17.3 The pipeline monitors itself"]
N4["17.5 Assessment rubric"]
N5["17.6 Practical activity"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6
N6 --> EX["Worked example"]
EX --> Q["Exercise EX067"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["17.1 Rules from the model"]
N2["17.2 Episodes"]
N3["17.3 The pipeline monitors itself"]
N4["17.5 Assessment rubric"]
N5["17.6 Practical activity"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> Q["EX067"]
[Learning outcomes and connection]
|
v
[17.1 Rules from the model]
|
v
[17.2 Episodes]
|
v
[17.3 The pipeline monitors itself]
|
v
[17.5 Assessment rubric]
|
v
[17.6 Practical activity]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX067]
Learning outcomes and connection
After this chapter you should be able to:
- integrate the course's techniques — decoding (Chapter 2), validation (Chapter 4), detection rules (Chapters 8 and 10), episodes and self-checks — into one detection pipeline;
- write detection rules from the system model, not from a product catalogue: new talker, unauthorized write, identification query;
- group alerts into episodes and explain the trade-off of the grouping window;
- make the pipeline monitor itself: every input record is either parsed or counted as dropped;
- assess the pipeline against a rubric and identify its blind spots.
Chapter 12 built a device. This second assessed laboratory builds the watcher, in the same contained way: fixed synthetic inputs, standard library only, no network.
17.1 Rules from the model
Each rule is derived from a statement in the system model:
- NEW-TALKER: the model lists the hosts that may speak Modbus to stations (scada-01, eng-ws-15). Any other source is a finding.
- UNAUTH-WRITE: only the engineering workstation may write (Chapter 2).
- DEVICE-ID-QUERY: function 43 is reconnaissance-shaped; alert, with context.
These are deliberately simple and explainable (Chapter 8). MITRE ATT&CK for ICS catalogues adversary techniques that such rules relate to, including discovery and unauthorized command messages (ATT&CK for ICS); use it to check coverage, not as the source of rules.
17.2 Episodes
One unauthorized host may generate dozens of alerts in a minute. Grouping alerts with the same rule and source into an episode — extended while new alerts arrive within a window of the previous one — gives an analyst one item with a count and a time span. The window is a trade-off: too short and one activity becomes many episodes; too long and two separate activities merge. Record the chosen window and why.
In Lab 17 the window is measured from the last alert in the episode, not the first, so a steady trickle of alerts every four minutes would extend one episode indefinitely; cap the episode length (for example one hour) or report long episodes as ongoing with their latest time. A five-minute window splits the 07:12 write into its own episode, which is what you want if an analyst might otherwise think the activity had stopped; a much longer window would merge it with the earlier burst and hide the gap.
17.3 The pipeline monitors itself
A detector that silently drops records it cannot parse is blind exactly where an attacker sends malformed traffic. The pipeline must account for every input: records = parsed + dropped, checked by an assertion, with dropped records listed and counted. The NIST continuous monitoring guidance makes the same point at programme level: monitoring must itself be monitored (SP 800-137).
In Lab 17 the check is the line assert parsed + len(dropped) == len(recs), and the summary line prints the dropped list itself: one record, 07:03:03 from 10.20.0.31, whose length field announces six following bytes when only two arrive. The drop is counted, but it raises no alert, because the rules run only on parsed frames — the gap the practical activity closes. On duty, treat the drop count as a signal in its own right: a sensor that suddenly drops many records has a capture fault, a parsing bug or a peer sending deliberately malformed traffic, and each of those deserves a ticket.
17.4 Worked example: the 07:03 activity (synthetic)
Ten records from the control-centre sensor on 12 October.
Lab 17 (save as lab17.py, run with python3 lab17.py):
# Lab 17 - contained detection pipeline for station traffic (offline, standard library only)
# Synthetic records: (UTC time, source, destination, Modbus/TCP frame hex). Rules come from Chapters 2, 9 and 10.
import struct
from datetime import datetime, timedelta
KNOWN = {"10.20.0.10": "scada-01", "10.20.0.15": "eng-ws-15"}
WRITERS = {"10.20.0.15"}
WRITE_FC = {5, 6, 15, 16}
recs = [
("07:00:00", "10.20.0.10", "10.10.4.2", "000100000006010300000004"),
("07:00:05", "10.20.0.10", "10.10.5.2", "000200000006010300000004"),
("07:02:10", "10.20.0.15", "10.10.4.2", "00030000000601060007012c"),
("07:03:00", "10.20.0.31", "10.10.4.2", "000400000006012b0e010000"),
("07:03:01", "10.20.0.31", "10.10.5.2", "000500000006012b0e010000"),
("07:03:02", "10.20.0.31", "10.10.6.2", "000600000006012b0e010000"),
("07:03:03", "10.20.0.31", "10.10.7.2", "0007000000060103"),
("07:04:40", "10.20.0.31", "10.10.4.2", "00080000000601060007ffff"),
("07:05:30", "10.20.0.31", "10.10.4.2", "00090000000601060007ffff"),
("07:12:00", "10.20.0.31", "10.10.4.2", "000a0000000601060007ffff"),
]
alerts, dropped, parsed = [], [], 0
for t, src, dst, hx in recs:
b = bytes.fromhex(hx)
if len(b) < 8 or struct.unpack(">H", b[4:6])[0] != len(b) - 6:
dropped.append((t, src, "malformed")); continue
parsed += 1; fc = b[7]
if src not in KNOWN: alerts.append((t, "NEW-TALKER", src))
if fc in WRITE_FC and src not in WRITERS: alerts.append((t, "UNAUTH-WRITE", src))
if fc == 43: alerts.append((t, "DEVICE-ID-QUERY", src))
assert parsed + len(dropped) == len(recs), "pipeline lost records"
episodes = [] # merge same rule+source within 5 minutes of the previous alert in the episode
for t, rule, src in alerts:
ts = datetime.strptime(t, "%H:%M:%S")
e = next((e for e in episodes if e["key"] == (rule, src) and ts - e["last"] <= timedelta(minutes=5)), None)
if e: e["count"] += 1; e["last"] = ts
else: episodes.append({"key": (rule, src), "first": ts, "last": ts, "count": 1})
for e in episodes:
print(f"{e['first']:%H:%M:%S}-{e['last']:%H:%M:%S}Z {e['key'][0]:<15} {e['key'][1]:<11} x{e['count']}")
print(f"records {len(recs)} parsed {parsed} dropped {len(dropped)} {dropped} alerts {len(alerts)} episodes {len(episodes)}")
Expected output (Python 3.13.5):
07:03:00-07:05:30Z NEW-TALKER 10.20.0.31 x5
07:03:00-07:03:02Z DEVICE-ID-QUERY 10.20.0.31 x3
07:04:40-07:05:30Z UNAUTH-WRITE 10.20.0.31 x2
07:12:00-07:12:00Z NEW-TALKER 10.20.0.31 x1
07:12:00-07:12:00Z UNAUTH-WRITE 10.20.0.31 x1
records 10 parsed 9 dropped 1 [('07:03:03', '10.20.0.31', 'malformed')] alerts 12 episodes 5
Interpretation: a host never seen before, 10.20.0.31, queried device identification on three stations in three seconds, sent one malformed frame to a fourth, then wrote 0xFFFF to register 7 (the pump setpoint) at KW-04 twice, and again six and a half minutes later. Five episodes summarize twelve alerts. Two observations matter beyond the alerts themselves:
- The 07:12 write is a separate episode because it came more than five minutes after the previous alert. An analyst reading only the first three episodes might think the activity had stopped.
- The malformed frame from the new talker was dropped and produced no alert — the self-check shows it, and it is the first thing to fix: unparseable traffic from an unknown host should itself raise NEW-TALKER, since the source address is known even when the payload is not.
The value 0xFFFF (65,535) to a setpoint register whose policy range is 100–400 is the kind of write Lab 12's responder would refuse with exception 03 — if the device had such a policy. Most do not, so the network-side detection and the firewall (Chapter 10) are what stand between this host and the process. In the KVROC story this is the moment to preserve, inform Kestrel Water and open an incident (Chapter 13).
17.5 Assessment rubric
| Criterion | 3 | 1 |
|---|---|---|
| Containment | Fixed inputs; no network; stated | Reads live traffic without scope |
| Rule provenance | Each rule cites the model statement it enforces | Rules from a list without rationale |
| Self-monitoring | Records accounted for; dropped listed | Silent drops |
| Episode design | Window stated with trade-off | Arbitrary or none |
| Blind spots | Lists what the sensor cannot see (station-local traffic, encrypted links) | Claims full coverage |
| Reproducibility | Output recorded with Python version | Not recorded |
Score Lab 17 against each row and write one improvement for any score below three. Blind spots must include station-local traffic and any path that does not pass the control-centre sensor, as Chapter 13 found for KW-04. Keep the scored rubric with the lab output and the Python version.
17.6 Practical activity
Change the pipeline so that a dropped (malformed) record from an unknown source raises NEW-TALKER, and add a rule that alerts on any write whose value is outside the register's policy range, using the Lab 12 policy table. Record the new output and how the episodes change.
Before changing the code, save the current output as the baseline and write down your prediction: which episode should grow, and how many alerts the value-range rule should add. After the change, the records line must still balance at ten (nine parsed, one dropped): fixing the blind drop changes what the dropped record produces, not whether it is counted. Test the value-range rule in both directions — it must fire on the 0xFFFF writes and stay silent on eng-ws-15's write of 300 (0x012c) at 07:02:10 — then re-score the self-monitoring and rule-provenance rows of 17.5.
17.7 Common errors and limits
- Rules copied from a catalogue without a model statement behind them.
- Episodes so long that separate activity disappears.
- Dropping malformed records silently.
- Believing the sensor sees everything: Chapter 13 found a path to KW-04 that did not pass this sensor.
- The pipeline's timestamps are the sensor's; correct its clock like any other (Chapter 7).
Checks that catch these errors:
- For every rule, quote the line of the system model it enforces; a rule without one is removed, or the model is updated first.
- Run the episodes at two windows (as in EX067) and confirm the 07:12 write stays visible, either as its own item or as an extended span.
- Fail the run when records do not equal parsed plus dropped, and review the dropped list every shift.
- Keep a written list of paths that bypass the sensor (station cellular routers, station-local HMI traffic) beside the detection coverage statement.
Exercises — chapter 17
EX065 — Read the episodes. Objective: interpret pipeline output. Inputs: Lab 17 output. Steps: write a five-line summary for the KVROC duty lead using the four labels. Expected evidence: summary. Rubric: 3 = observed: unknown host 10.20.0.31, identification queries to three stations, writes of 65,535 to KW-04 register 7 at 07:04:40, 07:05:30 and 07:12:00, one malformed frame; inferred: reconnaissance followed by attempted setpoint changes; done: nothing changed; open: identity of host, effect on KW-04, path; asks for operations check of KW-04 pump 2; 2 = no labels; 1 = vague; 0 = none.
EX066 — Fix the blind drop. Objective: improve self-monitoring. Inputs: activity 17.6 (first half). Steps: implement and run. Expected evidence: code and output. Rubric: 3 = NEW-TALKER episode for 10.20.0.31 now extends to six records within the same episode (07:03:00–07:05:30 x6), total alerts 13; records still accounted for; 2 = works, counts not explained; 1 = attempted; 0 = none.
EX067 — Window trade-off. Objective: reason about grouping. Inputs: Lab 17. Steps: predict episode counts with windows of 1 minute and 10 minutes, then run. Expected evidence: predictions and outputs. Rubric: 3 = 1 minute: NEW-TALKER splits (07:03:00–07:03:02, 07:04:40–07:05:30, 07:12:00), UNAUTH-WRITE splits into 07:04:40–07:05:30 and 07:12:00, DEVICE-ID one → 6 episodes; 10 minutes: NEW-TALKER and UNAUTH-WRITE each one episode → 3 episodes; recommends a value with reason; 2 = mostly right; 1 = guesses; 0 = none.
EX068 — Value-range rule. Objective: combine device policy and network detection. Inputs: activity 17.6 (second half). Steps: implement for functions 6 and 16 and run. Expected evidence: code and output. Rubric: 3 = alerts on all three 0xFFFF writes and not on eng-ws-15's write of 300; explains that the rule detects but does not prevent, so the firewall remains necessary; 2 = function 6 only; 1 = attempted; 0 = none.
Chapter synthesis
A detection pipeline is a small system with its own requirements: rules that trace to the system model, episodes that summarize without hiding, and a self-check that accounts for every record. Built contained and assessed honestly — including its blind spots — it turns the course's separate techniques into something a duty lead can act on.
Continue studying — chapter 17 of 18
← Chapter 16 · Course chapter index · Chapter 18 →
Continue to the next authored lesson.