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:
- build a contained laboratory in which protocol behaviour can be studied without any network or real device;
- implement a minimal Modbus/TCP responder with a write policy: which registers may be written, within which value range;
- generate and read Modbus exception responses (illegal function, illegal data address, illegal data value);
- assess a lab against a rubric: containment, reproducibility, evidence and honesty about what the lab does not model.
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:
- reads of holding registers (function 3) are allowed within the table, quantity 1–125 (the protocol's limit for one read);
- writes of single registers (function 6) are allowed only to register 7, the pump setpoint, and only for values 100–400;
- anything else returns an exception: the function code with its high bit set (code + 0x80) and an exception code — 01 illegal function, 02 illegal data address, 03 illegal data value — as defined in the Modbus Application Protocol specification (Modbus specifications).
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
- Using a real network socket "just for testing" and leaving it bound to all interfaces.
- Accepting a partial multi-register write that changes some registers before refusing others.
- Forgetting the response for function 6 is an echo, then mis-parsing it.
- Adding function 16 in activity 12.7 without reusing the single-write policy, so register 7's 100–400 limit is enforced for one write path and not the other.
- Testing only the allowed write and never the refused ones; the log of exception responses is the evidence that the policy works.
- Treating the lab's exception behaviour as a vendor's behaviour. State containment as a technical property in every lab report.
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.