◇ OpenCyber Academy
Engineering · Chapter 4 · English

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.

Chapter 4 — Programming I

Lesson map

Lesson 04 published. Map, example, EX008.

flowchart TD
  N0["Learning outcomes and connection"]
  N1["4.1 Programs as precise descriptions"]
  N2["4.2 Implementing a protocol checksum"]
  N3["4.3 Defensive parsing"]
  N4["4.5 Practical activity: Lab 3"]
  N5["Chapter synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5
  N5 --> EX["Worked example"]
  EX --> Q["Exercise EX008"]

Logical drawing

flowchart LR
  N0["Learning outcomes and connection"]
  N1["4.1 Programs as precise descriptions"]
  N2["4.2 Implementing a protocol checksum"]
  N3["4.3 Defensive parsing"]
  N4["4.5 Practical activity: Lab 3"]
  N5["Chapter synthesis"]
  N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> Q["EX008"]
[Learning outcomes and connection]
    |
    v
[4.1 Programs as precise descriptions]
    |
    v
[4.2 Implementing a protocol checksum]
    |
    v
[4.3 Defensive parsing]
    |
    v
[4.5 Practical activity: Lab 3]
    |
    v
[Chapter synthesis]
    |
    v
[Worked example] --> [Exercise EX008]

Learning outcomes and connection

After this chapter you can (1) write small, correct Python programs with functions, loops, conditionals, bytes and dictionaries; (2) implement a published algorithm — the Modbus RTU CRC-16 — and test it against a known vector; (3) parse untrusted binary input defensively, rejecting rather than repairing malformed data; and (4) explain why parsing code is security-critical in infrastructure. It turns Chapter 2's logic into code and prepares Chapter 9.

4.1 Programs as precise descriptions

A program is a description of a computation precise enough for a machine. Infrastructure software must be precise about three things beginners blur: types (a byte is not a character; an integer register is not a float), boundaries (what happens at the first and last element, at zero, at the maximum) and failure (what the program does with input it did not expect). Python is used in this book because its standard library covers bytes, hashing and simulation without installation. Its bytes type is immutable and indexable to integers; int.from_bytes(b, "big") converts with an explicit byte order.

4.2 Implementing a protocol checksum

Modbus is a long-lived industrial protocol; its serial form (RTU) ends each frame with a 16-bit cyclic redundancy check, sent low byte first, as defined in the Modbus Application Protocol and serial-line specifications published by the Modbus Organization. A CRC detects accidental corruption — noise on a serial line — with high probability. It is not a security mechanism: anyone can compute a valid CRC for a forged frame. Learn the difference early: integrity against nature versus integrity against an adversary (Chapter 19).

The standard Modbus CRC starts at 0xFFFF, XORs each byte into the low bits and shifts right eight times, XORing with 0xA001 when the shifted-out bit is 1. A widely published test vector is the request 01 03 00 00 00 0A (unit 1, read 10 holding registers from address 0), whose CRC bytes are C5 CD. Testing against a vector before using your function is non-negotiable.

4.3 Defensive parsing

A parser that reads data from a field device, a network or a file is the boundary between the outside world and your program's assumptions. Rules: check length before indexing; check integrity (CRC) before trusting any field; check every field against what you asked for (unit, function, byte count); check values against physical limits; on any failure, reject and report — do not guess. The OWASP and CWE lists of common weaknesses (CWE Top 25) are dominated by input-handling errors: out-of-bounds reads and writes, improper input validation, integer overflow.

4.4 Worked example: reading Hollin Hill level (synthetic)

LRWA's RTU at PS3 returns two registers: reservoir level in centimetres and outlet pressure in kPa. Lab 3 builds the request frame, then parses five responses: a good one, a single-bit flip, a Modbus exception, a value outside the physical range of the tank (65535 cm — an uninitialised register) and a truncated frame. Each bad case is rejected with a reason that an operator or log analyst can act on.

Every rejection reason is written so that it points to a cause: a CRC failure suggests line noise, an exception points to the RTU's own state, and a range failure suggests a fault or an uninitialised register that maintenance should investigate.

4.5 Practical activity: Lab 3

# Lab 3 - Modbus RTU CRC-16 and a defensive response parser (offline)
def crc16(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc
def frame(body: bytes) -> bytes:
    c = crc16(body); return body + bytes([c & 0xFF, c >> 8])   # low byte first
req = frame(bytes.fromhex("01030000000A"))
print("request:", req.hex(" "))
def parse(resp: bytes, unit=1, count=2, lo=0, hi=4000):
    if len(resp) < 5: return "reject: short"
    if crc16(resp[:-2]) != resp[-2] | resp[-1] << 8: return "reject: crc"
    if resp[0] != unit: return "reject: unit"
    if resp[1] & 0x80: return f"reject: exception {resp[2]}"
    if resp[1] != 3 or resp[2] != 2 * count or len(resp) != 5 + 2 * count: return "reject: shape"
    vals = [int.from_bytes(resp[3 + 2*i:5 + 2*i], "big") for i in range(count)]
    if any(not lo <= v <= hi for v in vals): return f"reject: range {vals}"
    return f"ok {vals}"
good = frame(bytes.fromhex("010304") + (3150).to_bytes(2, "big") + (212).to_bytes(2, "big"))
tests = {"good": good, "bitflip": good[:4] + bytes([good[4] ^ 1]) + good[5:],
         "exception": frame(bytes.fromhex("018302")), "outrange": frame(bytes.fromhex("010304FFFF00D4")),
         "truncated": good[:6]}
for k, v in tests.items(): print(f"{k:9} {parse(v)}")

Expected output:

request: 01 03 00 00 00 0a c5 cd
good      ok [3150, 212]
bitflip   reject: crc
exception reject: exception 2
outrange  reject: range [65535, 212]
truncated reject: crc

The truncated frame is rejected by the CRC check because its last two bytes are not a CRC; a parser that checked shape first would give a clearer reason. Both orders are safe; the important property is that nothing is accepted on partial evidence.

Notice that the good response decodes to a level of 3,150 cm and a pressure of 212 kPa, both inside the physical limits configured in the parser. The out-of-range case shows why repair is dangerous: clamping 65,535 to the 4,000 cm maximum would report a full reservoir when the register had never been written. Rejecting it with the raw value in the reason gives maintenance staff the clue they need, and gives an investigator evidence that the value was not simply noise on the line.

4.6 Common errors and limits

Each item has a matching test in Lab 3 or EX008. A parser that passes those tests but was never checked against a truncated or malformed frame has only been tested for the happy path, and the first noisy serial line at a pumping station will show what it does with input it did not expect. Write the failure tests first, because they are the ones that matter when something goes wrong in the field.

Exercises — chapter 4

EX007 — CRC by hand and by code. Objective: verify an implementation. Inputs: Lab 3. Steps: (1) compute the CRC of 01 06 00 01 00 03 (write single register) with the function; (2) flip one bit in the data and show the CRC changes; (3) explain in two sentences why an attacker on the serial line is not stopped by the CRC. Expected evidence: hex values and explanation. Rubric: 3 = correct CRC (verify against the worked result), demonstration, correct security statement; 2 = one missing; 1 = code not run; 0 = none.

EX008 — Parser hardening. Objective: improve error reporting without weakening checks. Inputs: parse(). Steps: reorder checks so a truncated frame reports "short" or "shape" before CRC, add a check that rejects a response whose unit identifier is 0 (broadcast), and add two test cases. Expected evidence: code diff and outputs. Rubric: 3 = all original rejections preserved, new checks correct, tests included; 2 = one rejection lost; 1 = untested; 0 = none.

Chapter synthesis

Write code that is explicit about types, boundaries and failure. Implement published algorithms against published test vectors. Treat every parser as a security boundary: length, integrity, shape, then physical meaning, and reject with a reason rather than guess. Keep accidental-error detection (CRC) separate in your mind from adversarial integrity (MACs and signatures).

Continue studying — chapter 04 of 50

← Chapter 03 · Course chapter index · Chapter 05 →

Continue to the next authored lesson.