◇ OpenCyber Academy
Technical · Chapter 12 · English

Chapter 12 — Contained laboratory 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.

Chapter 12 — Contained laboratory I

Lesson map

Lesson 12 published. Map, example, EX045.

flowchart TD
  N0["Learning outcomes and connection"]
  N1["12.1 What contained means"]
  N2["12.2 The responder's job"]
  N3["12.3 Logging inside the device"]
  N4["12.5 Assessment rubric for a contained lab"]
  N5["12.6 What the lab does not model"]
  N6["12.7 Practical activity"]
  N7["Chapter synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7
  N7 --> EX["Worked example"]
  EX --> Q["Exercise EX045"]

Logical drawing

flowchart LR
  N0["Learning outcomes and connection"]
  N1["12.1 What contained means"]
  N2["12.2 The responder's job"]
  N3["12.3 Logging inside the device"]
  N4["12.5 Assessment rubric for a contained lab"]
  N5["12.6 What the lab does not model"]
  N6["12.7 Practical activity"]
  N7["Chapter synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7 --> Q["EX045"]
[Learning outcomes and connection]
    |
    v
[12.1 What contained means]
    |
    v
[12.2 The responder's job]
    |
    v
[12.3 Logging inside the device]
    |
    v
[12.5 Assessment rubric for a contained lab]
    |
    v
[12.6 What the lab does not model]
    |
    v
[12.7 Practical activity]
    |
    v
[Chapter synthesis]
    |
    v
[Worked example] --> [Exercise EX045]

Learning outcomes and connection

After this chapter you should be able to:

Chapters 1–11 analysed traffic and configuration. This chapter builds something you can safely break. It is the first of two assessed laboratories; Chapter 17 is the second.

12.1 What "contained" means

A contained lab cannot affect anything outside itself. In this book that is guaranteed technically, not by good intentions: Lab 12 uses socket.socketpair(), which creates two connected sockets inside one process (Python socket). Nothing listens on a port, nothing is routed, and the program cannot reach a real controller even by mistake. Larger labs use isolated virtual networks with no route out, or spare hardware on a bench with its network cable going nowhere else. Write down the containment property of every lab you build, and how you checked it.

12.2 The responder's job

A real controller decides whether to execute a write based on its own logic, which in classic Modbus devices often means "always". Some devices and protocol gateways allow write protection by register range; many do not. The lab responder makes the policy explicit:

A normal response to function 6 echoes the request. A normal response to function 3 carries a byte count and the register values.

12.3 Logging inside the device

The responder logs every read, every accepted write with its old and new value, and every exception. On real equipment these are exactly the records you wished you had in Chapter 7. A burst of exception 02 responses from one source is a strong sign of someone mapping the register space; a run of exception 03 is someone testing limits.

On real equipment those logs are often missing, which is why the lab makes them visible. Keep the lab log with the hex responses so a reviewer can match each exception to a request.

12.4 Worked example: the bench responder (synthetic)

Lab 12 (save as lab12.py, run with python3 lab12.py):

# Lab 12 - contained Modbus/TCP responder with a write policy (offline, standard library only)
# Uses socket.socketpair(): both ends live inside this process; nothing touches a real network.
import socket, struct
regs = [0] * 10; regs[0], regs[1], regs[7] = 342, 61, 250      # level cm, chlorine x100, pump setpoint
WRITABLE = {7: (100, 400)}                                      # register: allowed value range
log = []
def respond(req: bytes) -> bytes:
    tid, pid, length, unit, fc = struct.unpack(">HHHBB", req[:8])
    def exc(code):
        log.append(f"tid={tid} fc={fc} exception {code}")
        return struct.pack(">HHHBBB", tid, 0, 3, unit, fc | 0x80, code)
    if pid != 0 or length != len(req) - 6: return exc(3)
    if fc == 3:
        addr, qty = struct.unpack(">HH", req[8:12])
        if not 1 <= qty <= 125 or addr + qty > len(regs): return exc(2)
        data = struct.pack(f">{qty}H", *regs[addr:addr + qty])
        log.append(f"tid={tid} read {addr}..{addr + qty - 1}")
        return struct.pack(">HHHBBB", tid, 0, 3 + len(data), unit, 3, len(data)) + data
    if fc == 6:
        addr, val = struct.unpack(">HH", req[8:12])
        if addr not in WRITABLE: return exc(2)
        lo, hi = WRITABLE[addr]
        if not lo <= val <= hi: return exc(3)
        old, regs[addr] = regs[addr], val
        log.append(f"tid={tid} WRITE reg {addr} {old}->{val}")
        return req[:12]                                         # echo is the normal fc 6 reply
    return exc(1)
client, server = socket.socketpair()
def call(tid, fc, a, b):
    client.sendall(struct.pack(">HHHBBHH", tid, 0, 6, 1, fc, a, b))
    server.sendall(respond(server.recv(260)))
    r = client.recv(260)
    return r.hex()
print("read 0..2     ", call(1, 3, 0, 3))
print("write 7=300   ", call(2, 6, 7, 300))
print("write 7=900   ", call(3, 6, 7, 900))
print("write 0=0     ", call(4, 6, 0, 0))
print("fc 8 diag     ", call(5, 8, 0, 0))
print("read 8..12    ", call(6, 3, 8, 5))
client.close(); server.close()
print("registers", regs[:3], "setpoint", regs[7])
for entry in log: print("log:", entry)

Expected output (Python 3.13.5):

read 0..2      0001000000090103060156003d0000
write 7=300    00020000000601060007012c
write 7=900    000300000003018603
write 0=0      000400000003018602
fc 8 diag      000500000003018801
read 8..12     000600000003018302
registers [342, 61, 0] setpoint 300
log: tid=1 read 0..2
log: tid=2 WRITE reg 7 250->300
log: tid=3 fc=6 exception 3
log: tid=4 fc=6 exception 2
log: tid=5 fc=8 exception 1
log: tid=6 fc=3 exception 2

Read the hex responses. The first is a normal read: 03 06 (function 3, six bytes) followed by 0x0156 = 342, 0x003D = 61 and 0. The write of 300 to register 7 is echoed and the log records 250 → 300. Writing 900 returns 86 03 — function 6 + 0x80, exception 3, illegal value. Writing register 0 returns 86 02, illegal address — register 0 is a measurement, not writable. Function 8 (diagnostics) returns 88 01, illegal function. A read of five registers starting at 8 runs past the 10-register table and returns 83 02. The registers at the end show that only the approved write took effect.

12.5 Assessment rubric for a contained lab

Criterion 3 (excellent) 1 (weak)
Containment Technically impossible to reach a real device; property stated and checked Relies on "I won't point it at anything"
Reproducibility Fixed inputs; exact output recorded with Python version Output varies or is not recorded
Fidelity statement Lists what the lab models and what it does not Claims to represent real devices
Evidence Logs and outputs kept, hashed, linked to exercises Screenshots only
Safety of extension Changes reviewed so they cannot remove containment Adds real network code without review

12.6 What the lab does not model

Real devices differ: some ignore unit identifiers, some accept writes to any register, some crash on malformed requests, and few log anything. The responder handles one request at a time, has no concurrency, timeouts or partial reads. Its value is in making policy and exceptions visible, not in predicting a particular vendor's behaviour. Never use results from a lab to decide how a live device will respond to a test.

If the responder returns exception 2 for a read of registers 8 to 12, that says nothing about what a real KW-04 RTU would do. Real devices differ in unit-identifier handling, write protection and logging, and many crash on malformed requests. State those gaps in the assessment, and keep live verification to scoped, authorized methods from Chapter 11.

12.7 Practical activity

Add function 16 (write multiple registers) to the responder with the same policy: every register in the range must be writable and every value in limits, or the whole write is refused. Test four cases: all allowed; one address not writable; one value out of range; byte count inconsistent with quantity.

Record the four hex responses and the log lines next to them. Finally, send a function 16 write that includes a register your function 6 handler already refuses (such as register 0), and confirm the log gives the same reason for both refusals.

12.8 Common errors and limits

Exercises — chapter 12

EX045 — Read the responses. Objective: decode exception and normal replies. Inputs: Lab 12 output. Steps: for each of the six responses, give the transaction id, function (with or without exception bit), and meaning. Expected evidence: six rows. Rubric: 3 = all six correct, including 0x86 = exception to 6, 0x88 = exception to 8, 0x83 = exception to 3; 2 = one wrong; 1 = normal replies only; 0 = none.

EX046 — Implement function 16. Objective: extend the responder safely. Inputs: activity 12.7. Steps: implement and run the four cases. Expected evidence: code and outputs. Rubric: 3 = all-or-nothing behaviour; exception 02 for a non-writable address, 03 for an out-of-range value, 03 (or a malformed-request handling with justification) for an inconsistent byte count; normal reply echoes address and quantity only; log lines for each; 2 = works with one wrong code; 1 = partial writes accepted; 0 = none.

EX047 — Detect mapping attempts. Objective: use device-side logs. Inputs: the responder's log. Steps: write a rule that alerts on mapping or limit-testing behaviour and test it by adding five reads of out-of-range addresses from one transaction sequence. Expected evidence: rule and output. Rubric: 3 = for example "≥3 exception-02 responses within 60 s from one source"; demonstrates it fires on the test and not on the original six requests (which contain only two exception-02 replies; counting 02 and 03 together would already reach three and fire); 2 = fires on both; 1 = rule only; 0 = none.

EX048 — Assess a lab. Objective: apply the rubric. Inputs: a colleague's lab that runs a Modbus server on 0.0.0.0:502 on a laptop "only used in the office". Steps: score it on containment and fidelity and propose a fix. Expected evidence: scores and fix. Rubric: 3 = containment 1 (reachable from any network the laptop joins, and the laptop may be taken to stations); fix with socketpair or loopback-only binding plus host firewall, or an isolated VM network; fidelity statement required; 2 = fix without reasoning; 1 = accepts it; 0 = none.

Chapter synthesis

A contained lab is one where safety is a technical property. Lab 12 builds a Modbus responder that enforces a write policy and returns proper exceptions, making visible what most real devices hide: which requests were refused and why. Assess every lab for containment, reproducibility, evidence and a candid statement of what it does not model.

Continue studying — chapter 12 of 18

← Chapter 11 · Course chapter index · Chapter 13 →

Continue to the next authored lesson.

Worked answers

EX045 — Read the responses. tid 1: function 3, normal, 6 bytes: 342, 61, 0. tid 2: function 6, normal echo — register 7 = 300 written. tid 3: 0x86 = exception to function 6, code 03 illegal data value (900 outside 100–400). tid 4: 0x86, code 02 illegal data address (register 0 not writable). tid 5: 0x88 = exception to function 8, code 01 illegal function. tid 6: 0x83 = exception to function 3, code 02 (range 8–12 exceeds the table).

EX046 — Implement function 16. Parse addr, qty, bc = struct.unpack(">HHB", req[8:13]); if bc != 2 * qty or len(req) != 13 + bc return exception 03; decode values; if any address in addr..addr+qty-1 is not in WRITABLE return 02; if any value is outside its range return 03; otherwise write all and return struct.pack(">HHHBBHH", tid, 0, 6, unit, 16, addr, qty). Log each outcome. No partial writes.

EX047 — Detect mapping attempts. Rule: three or more exception-02 responses to one source within 60 s. The original six requests produce two exception-02 replies (tid 4 and tid 6), so it does not fire; adding five out-of-range reads produces seven and it fires. Counting 02 and 03 together would already reach three in the original run (tids 3, 4, 6) and fire on ordinary mistakes; keeping the limit-testing rule (exception 03) separate gives clearer alerts.

EX048 — Assess a lab. Containment 1: a server on 0.0.0.0:502 is reachable from any network the laptop joins, including a station network if the laptop is used on site. Fidelity: no statement, score 1. Fix: socketpair() or bind to 127.0.0.1 with a host firewall rule, or an isolated VM network with no route out; add a fidelity statement; record outputs with the Python version.