# Tutorial: OT / ICS decoys (advanced)

**Audience:** Operators comfortable with Contained Modbus/SNMP who want the full
**Conpot-parity OT plane**: shared Unified Device Persona, retentive process
image, UDP anti-amplification, Tier2 Denial-of-Wallet budgets, passive canaries,
and the remaining Contained protocols (BACnet, IPMI, IEC-104, TFTP).

**Time:** about 45-60 minutes  
**Outcome:** You prove anti-Frankenstein identity across protocols, exercise
UDP egress limits, enable optional canaries, and know how research compose /
Ops labgen expose the new listeners.

!!! warning "Contained means no PLC execution"
    Block uploads (S7 0x1B) and BACnet AtomicWriteFile may **vault** bytes under
    budget for CTI: they never execute MC7 / firmware. Canaries are **passive
    only** (no outbound beacon from inbound hits). See
    [OT decoys reference](../reference/ot-decoys.md).

## What you will learn

1. How `otwire` shares persona / `otmemory` / Tier2 / UDP guard across listeners
2. How `--ot-vendor-family` and `--ot-profile` change ceilings (edge vs research)
3. How SNMP/SIP/DNS and ENIP/BACnet honor UDP amp + rate caps
4. How to enable passive OT canaries and retentive WAL
5. How Ops / labgen / compose publish S7, ENIP, BACnet, IPMI, IEC-104, TFTP

## Prerequisites

- Completed [OT beginner tutorial](ot-decoys-beginner.md) (or equivalent)
- Sensor build (`cd engine && go build -o ../bin/sensor ./cmd/sensor`)
- Python 3; optional `snmpget`, Docker for compose path
- Willingness to read sensor logs and status JSON

## Architecture (mental model)

```text
Attacker tool  →  Sensor OT Listen (Go Contained emulator)
                      │
                      ├─ connguard / Admit  (before wirejitter)
                      ├─ ottier Gate       (Tier1 recon vs Tier2 CTI)
                      ├─ shared otmemory   (registers / DeviceState RUN|STOP)
                      ├─ otbudget Tier2    (events + vault puts / IP / hour)
                      └─ UDPEgressGuard    (amp factor ≈3, subnet+global, no broadcast)
                               │
                               └─ optional VaultAdmitted.AfterPut
                                    → MC7 header parse + YARA sandbox + GFH sidecar
                                    (no dial, no exec)
```

One Unified Device Persona seeds Modbus + SNMP + BACnet + IPMI facts so the
host does not look like four unrelated vendors.

## Path A: Shared identity + full OT listeners (~25 min)

### A1: Start with Rockwell family + research ceilings

```bash
mkdir -p /tmp/chn-intel-ot-adv /tmp/chn-ot-retentive

./bin/sensor \
  --profile=research \
  --interaction=contained \
  --mode=static \
  --listen-status=127.0.0.1:8080 \
  --listen-ssh=127.0.0.1:12222 \
  --listen-http=127.0.0.1:18081 \
  --listen-modbus=127.0.0.1:1502 \
  --listen-snmp=127.0.0.1:1161 \
  --listen-s7=127.0.0.1:1102 \
  --listen-enip=127.0.0.1:44818 \
  --listen-bacnet=127.0.0.1:47808 \
  --listen-ipmi=127.0.0.1:6623 \
  --listen-iec104=127.0.0.1:2404 \
  --listen-tftp=127.0.0.1:6969 \
  --ot-profile=research \
  --ot-vendor-family=rockwell \
  --ot-retentive-dir=/tmp/chn-ot-retentive \
  --ot-disable-mutation \
  --containment-config=config/containment/research.json \
  --intel-dir=/tmp/chn-intel-ot-adv \
  --telemetry-spool=/tmp/chn-spool-ot-adv \
  --sensor-id=ops-ot-adv-01
```

Expect log line `OT shared context ready ... vendor=rockwell retentive=true` and
decoy lines for each listen.

### A2: Prove status lists every OT protocol

```bash
curl -s http://127.0.0.1:8080/ | python3 -c '
import sys,json
d=json.load(sys.stdin)
need={"modbus","s7comm","enip","bacnet","ipmi","iec104","tftp","snmp"}
got=set(d.get("protocols") or [])
missing=sorted(need-got)
print("status", d.get("status"), "missing", missing or "none")
'
```

### A3: Anti-Frankenstein: SNMP text matches vendor family

With `--ot-vendor-family=rockwell`, SNMP `sysDescr` / seeded `ot.vendor` facts
should not claim Siemens after OT seed. Compare:

```bash
snmpget -v2c -c public 127.0.0.1:1161 1.3.6.1.2.1.1.1.0 || true
```

Restart once with `--ot-vendor-family=siemens` if you want a side-by-side
contrast (stop sensor first). Hostname stays the sensor `--hostname` / hostid
rewrite so SSH and OT do not disagree on FQDN.

### A4: DeviceState / plc_mini (Modbus)

Default shared plant profile is `plc_mini` (`--ot-plant-profile`). Coil 15 maps
to RUN/STOP on the production `otprocess` gate. Holding index N is the same cell
on BACnet AV N and ENIP assembly N. RUN→STOP emits Class B `ot.device_state`;
setpoint / control coil writes emit `ot.control_write`. Advanced FSM tests live
in `engine/internal/otprocess` and `engine/internal/otwire`.

### A5: Same-plant cross-protocol probe

1. Write Modbus holding register 3.
2. Read BACnet analog-value instance 3 and S7 DB1 word at the Contained fiction
   offset for the same cell: values should match.
3. Issue S7 CPU STOP; SZL opstate should report STOP (`0x03`); further process
   writes fail until RUN.

## Path B: UDP anti-amp + Tier2 mental model (~15 min)

### B1: Broadcast / oversized UDP should not amp

ENIP ListIdentity and BACnet Who-Is are guarded:

- response size capped ≈ `3 × request` (absolute cap also applies),
- per-subnet / global reply rates,
- broadcast / multicast sources should not get helpful replies.

Send a tiny BACnet Who-Is style datagram and observe either a bounded reply or
silence under guard: the sensor must **not** emit multi-kilobyte reflections
to spoofed sources.

```bash
python3 - <<'PY'
import socket
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(1.5)
# undersized / nonsense probe: guard may drop; listener must stay up
s.sendto(b"\x81\x0a\x00\x08\x01\x00\xff\xff", ("127.0.0.1", 47808))
try:
    data,_=s.recvfrom(65535)
    print("bacnet_reply_len", len(data))
except socket.timeout:
    print("bacnet_no_reply_or_filtered")
PY
```

Repeat rapidly from a loop; after budgets trip you should see **fewer** replies,
not unbounded growth. Package tests:
`TestTier2Budget_TokenBucket`, SNMP/ENIP UDP guard tests under
`engine/internal/protocol/*/`.

### B2: Edge vs research ceilings

| Knob | Edge | Research |
|------|------|----------|
| `--ot-profile` | `edge` | `research` (default) |
| Process-image / severity ring | tighter (≈100 MiB class) | larger (≈500 MiB class) |
| Retentive spool cap | lower | higher |

Flip to `--ot-profile=edge` on a constrained laptop when you only need recon
fidelity, not large vault rings.

### B3: Vault / CTI (conceptual)

S7 block-upload (0x1B) and BACnet AtomicWriteFile admit blobs **only** when
Tier2 vault budget allows, **before** disk stage. AfterPut enrichment runs
MC7 header parse + time/size-capped YARA + GFH into a sidecar: Contained, no
outbound. Over budget: decoy state may still update; **no** new vault blob /
YARA. Details and defaults: [OT decoys reference](../reference/ot-decoys.md).

## Path C: Passive canaries (~10 min)

```bash
./bin/sensor \
  ...same listens as A1... \
  --ot-canaries \
  --ot-canary-secret='lab-only-not-for-prod' \
  --sensor-id=ops-ot-canary-01
```

Canaries watermark serial / DNS-shaped tokens into persona facts. They are
**observed passively** on inbound text/payloads: the sensor does not phone home
when a token is seen. Confirm `canaries=true` in the OT ready log line.

IT canaries remain opt-in elsewhere; OT defaults stay off until `--ot-canaries`.

## Path D: Research compose / Ops labgen (~10 min)

### Compose

`engine/deploy/research/docker-compose.yml` publishes loopback OT ports and
passes `--listen-s7`, `--listen-enip`, `--listen-bacnet`, `--listen-ipmi`,
`--listen-iec104`, `--listen-tftp` on the sensor command. Public research compose
maps privileged OT ports (e.g. host `502→1502`, `623→623/udp`): still research,
not Internet-safe production.

```bash
docker compose -f engine/deploy/research/docker-compose.yml --profile research up --build -d
# probe 127.0.0.1:1502 / 1102 / 44818 / … as in beginner
./engine/deploy/research/teardown.sh   # when finished
```

### Ops GUI / labgen

Lab selection flags (JSON): `enable_s7`, `enable_enip`, `enable_bacnet`,
`enable_ipmi`, `enable_iec104`, `enable_tftp` (plus existing `enable_modbus` /
`enable_snmp`). Labgen writes persona mounts (`bacnet-labgen.json`, …) and
compose overlay listen flags. Free-text chat intent also recognizes words like
`s7`, `bacnet`, `enip`, `ipmi`, `tftp`.

## Assurance: run the OT package tests

```bash
cd engine && go test ./internal/otpersona/ ./internal/otmemory/ ./internal/otbudget/ \
  ./internal/ottier/ ./internal/otprocess/ ./internal/otparse/ ./internal/yarasandbox/ \
  ./internal/otgfh/ ./internal/otcanary/ ./internal/otwire/ ./cmd/sensor/ \
  ./internal/protocol/modbus/ ./internal/protocol/snmp/ \
  ./internal/protocol/s7comm/ ./internal/protocol/enip/ \
  ./internal/protocol/bacnet/ ./internal/protocol/ipmi/ \
  ./internal/protocol/iec104/ ./internal/protocol/tftp/ -count=1
```

## Residuals (do not claim)

- Linux TCP stack fingerprint / PROFINET DCP (L2)
- Full Conpot feature parity for Guardian AST / Kamstrup (smoke helpers only)
- Production Internet exposure of OT ports without evidence pack / exposure gates

## Related docs

- Beginner path: [ot-decoys-beginner.md](ot-decoys-beginner.md)
- Reference: [OT decoys](../reference/ot-decoys.md)
- Fingerprints: [protocol fingerprints](../architecture/protocol-fingerprints.md)
- Safety: [safety model](../explanation/safety-model.md)
