feat(smtp): fix DATA state machine; add SMTP_OPEN_RELAY mode
- Buffer DATA body until CRLF.CRLF terminator — fixes 502-on-every-body-line bug - SMTP_OPEN_RELAY=1: AUTH accepted (235), RCPT TO accepted for any domain, full DATA pipeline with queued-as message ID - Default (SMTP_OPEN_RELAY=0): credential harvester — AUTH rejected (535) but connection stays open, RCPT TO returns 554 relay denied - SASL PLAIN and LOGIN multi-step AUTH both decoded and logged - RSET clears all per-transaction state - Add development/SMTP_RELAY.md, IMAP_BAIT.md, ICS_SCADA.md, BUG_FIXES.md (live-tested service realism plans)
This commit is contained in:
232
development/ICS_SCADA.md
Normal file
232
development/ICS_SCADA.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# ICS/SCADA Bait — Plan
|
||||
|
||||
> Scenario: attacker finds MQTT broker on a water treatment plant, subscribes to
|
||||
> sensor topics, publishes commands trying to "open the valve" or "disable chlorination".
|
||||
|
||||
---
|
||||
|
||||
## Services in scope
|
||||
|
||||
| Service | Port | Current state | Target state |
|
||||
|---------|------|--------------|-------------|
|
||||
| MQTT | 1883 | CONNACK 0x05 (reject all) | CONNACK 0x00, fake sensor topics |
|
||||
| SNMP | 161/UDP | Functional, generic sysDescr | sysDescr tuned per archetype |
|
||||
| Conpot | 502 | Not responding | Investigate + fix port mapping |
|
||||
|
||||
---
|
||||
|
||||
## MQTT — water plant persona
|
||||
|
||||
### Current behavior
|
||||
|
||||
Every CONNECT gets `CONNACK 0x05` (Not Authorized) and the connection is closed.
|
||||
An ICS attacker immediately moves on — there's nothing to interact with.
|
||||
|
||||
### Target behavior
|
||||
|
||||
Accept all connections (`CONNACK 0x00`). Publish retained sensor data on
|
||||
realistic SCADA topics. Log every PUBLISH command (attacker trying to control plant).
|
||||
|
||||
### Topic tree
|
||||
|
||||
```
|
||||
plant/water/tank1/level → "73.4" (percent full)
|
||||
plant/water/tank1/pressure → "2.81" (bar)
|
||||
plant/water/pump1/status → "RUNNING"
|
||||
plant/water/pump1/rpm → "1420"
|
||||
plant/water/pump2/status → "STANDBY"
|
||||
plant/water/chlorine/dosing → "1.2" (mg/L)
|
||||
plant/water/chlorine/residual → "0.8" (mg/L)
|
||||
plant/water/valve/inlet/state → "OPEN"
|
||||
plant/water/valve/drain/state → "CLOSED"
|
||||
plant/alarm/high_pressure → "0"
|
||||
plant/alarm/low_chlorine → "0"
|
||||
plant/alarm/pump_fault → "0"
|
||||
plant/$SYS/broker/version → "Mosquitto 2.0.15"
|
||||
plant/$SYS/broker/uptime → "2847392 seconds"
|
||||
```
|
||||
|
||||
All topics have `retain=True` so subscribers immediately receive the last value.
|
||||
|
||||
### Protocol changes needed
|
||||
|
||||
Add handling for:
|
||||
|
||||
- **SUBSCRIBE (pkt_type=8)**: Parse topic filter + QoS pairs. For each matching topic,
|
||||
send SUBACK then immediately send a PUBLISH with the retained value.
|
||||
- **PUBLISH (pkt_type=3)**: Log the topic + payload (this is the attacker "sending a command").
|
||||
Return PUBACK for QoS 1. Do NOT update the retained value (the plant ignores the command).
|
||||
- **PINGREQ (pkt_type=12)**: Already handled. Keep alive.
|
||||
- **DISCONNECT (pkt_type=14)**: Close cleanly.
|
||||
|
||||
Do NOT implement: UNSUBSCRIBE, QoS 2. Return SUBACK with QoS 1 for all subscriptions.
|
||||
|
||||
### CONNACK change
|
||||
|
||||
```python
|
||||
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00" # session_present=0, return_code=0
|
||||
```
|
||||
|
||||
### Env vars
|
||||
|
||||
| Var | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `MQTT_PERSONA` | `water_plant` | Topic tree preset |
|
||||
| `MQTT_ACCEPT_ALL` | `1` | Accept all connections |
|
||||
| `NODE_NAME` | `mqtt-broker` | Hostname in logs |
|
||||
|
||||
---
|
||||
|
||||
## SUBSCRIBE packet parsing
|
||||
|
||||
```python
|
||||
def _parse_subscribe(payload: bytes):
|
||||
"""Returns (packet_id, [(topic, qos), ...])"""
|
||||
pos = 0
|
||||
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
|
||||
pos += 2
|
||||
topics = []
|
||||
while pos < len(payload):
|
||||
topic, pos = _read_utf8(payload, pos)
|
||||
qos = payload[pos] & 0x03
|
||||
pos += 1
|
||||
topics.append((topic, qos))
|
||||
return packet_id, topics
|
||||
```
|
||||
|
||||
### SUBACK
|
||||
|
||||
```python
|
||||
def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
|
||||
payload = struct.pack(">H", packet_id) + bytes(granted_qos)
|
||||
return bytes([0x90, len(payload)]) + payload
|
||||
```
|
||||
|
||||
### PUBLISH (server → client, retained)
|
||||
|
||||
```python
|
||||
def _publish(topic: str, value: str, retain: bool = True) -> bytes:
|
||||
topic_bytes = topic.encode()
|
||||
topic_len = struct.pack(">H", len(topic_bytes))
|
||||
payload = value.encode()
|
||||
# Fixed header: type=3, retain flag, no QoS (fire and forget for retained)
|
||||
fixed = 0x31 if retain else 0x30
|
||||
remaining = len(topic_len) + len(topic_bytes) + len(payload)
|
||||
return bytes([fixed, remaining]) + topic_len + topic_bytes + payload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SNMP — sysDescr per archetype
|
||||
|
||||
Current `sysDescr` is a generic Linux string. It should reflect the decky's persona.
|
||||
|
||||
### Archetype strings
|
||||
|
||||
| Archetype | sysDescr |
|
||||
|-----------|---------|
|
||||
| water_plant | `Linux scada-plc01 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64` |
|
||||
| factory | `VxWorks 6.9 (Rockwell Automation Allen-Bradley ControlLogix 5580)` |
|
||||
| substation | `SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0` |
|
||||
| hospital | `Linux medlogic-srv01 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 x86_64` |
|
||||
| default | `Linux decky-host 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64` |
|
||||
|
||||
Env var `SNMP_ARCHETYPE` selects the string. The SNMP server should also tune:
|
||||
|
||||
- `sysContact.0` → `ICS Admin <ics-admin@plant.local>`
|
||||
- `sysLocation.0` → `Water Treatment Facility — Pump Room B`
|
||||
- `sysName.0` → `scada-plc01` (from `NODE_NAME`)
|
||||
|
||||
---
|
||||
|
||||
## Conpot — Modbus TCP (port 502)
|
||||
|
||||
### Current state
|
||||
|
||||
Port 502 shows `CLOSED` in nmap. Conpot is deployed as a service container but
|
||||
is either not binding to 502 or the port mapping is wrong.
|
||||
|
||||
### Diagnosis steps
|
||||
|
||||
1. Check the compose fragment: `decnet services conpot` — what port does it expose?
|
||||
2. `docker exec decky-01-conpot netstat -tlnp` or `ss -tlnp` — is Conpot listening on 502?
|
||||
3. Check Conpot's default config — it may listen on a non-standard port (e.g. 5020) and
|
||||
expect a host-level iptables REDIRECT rule to map 502 → 5020.
|
||||
|
||||
### Fix options
|
||||
|
||||
**Option A** (preferred): Configure Conpot to listen directly on 502 by editing its
|
||||
`default.xml` template and setting `<port>502</port>`.
|
||||
|
||||
**Option B**: Add `iptables -t nat -A PREROUTING -p tcp --dport 502 -j REDIRECT --to-port 5020`
|
||||
to the base container entrypoint. Fragile — prefer A.
|
||||
|
||||
### What Modbus should respond
|
||||
|
||||
Conpot's default Modbus template already implements a plausible PLC. The key registers
|
||||
to tune for water-plant persona:
|
||||
|
||||
| Register | Address | Value | Description |
|
||||
|----------|---------|-------|-------------|
|
||||
| Coil | 0 | 1 | Pump 1 running |
|
||||
| Coil | 1 | 0 | Pump 2 standby |
|
||||
| Holding | 0 | 734 | Tank level (73.4%) |
|
||||
| Holding | 1 | 281 | Pressure (2.81 bar × 100) |
|
||||
| Holding | 2 | 12 | Chlorine dosing (1.2 mg/L × 10) |
|
||||
|
||||
These values should be consistent with the MQTT topic tree so an attacker who
|
||||
probes both sees a coherent picture.
|
||||
|
||||
---
|
||||
|
||||
## Log events
|
||||
|
||||
### MQTT
|
||||
|
||||
| event_type | Fields | Trigger |
|
||||
|------------|--------|---------|
|
||||
| `connect` | src, src_port, client_id, username | CONNECT packet |
|
||||
| `subscribe` | src, topics | SUBSCRIBE packet |
|
||||
| `publish` | src, topic, payload | PUBLISH from client (attacker command!) |
|
||||
| `disconnect` | src | DISCONNECT or connection lost |
|
||||
|
||||
### SNMP
|
||||
|
||||
No changes to event structure — sysDescr is just a config string.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `templates/mqtt/server.py` | Accept connections, SUBSCRIBE handler, retained PUBLISH, PUBLISH log |
|
||||
| `templates/snmp/server.py` | Add `SNMP_ARCHETYPE` env var, tune sysDescr/sysContact/sysLocation |
|
||||
| `templates/conpot/` | Investigate port config, fix 502 binding |
|
||||
| `tests/test_mqtt.py` | New: connect accepted, subscribe → retained publish, attacker publish logged |
|
||||
| `tests/test_snmp.py` | Extend: sysDescr per archetype |
|
||||
|
||||
---
|
||||
|
||||
## Verification against live decky
|
||||
|
||||
```bash
|
||||
# MQTT: connect and subscribe
|
||||
mosquitto_sub -h 192.168.1.200 -t "plant/#" -v
|
||||
|
||||
# Expected output:
|
||||
# plant/water/tank1/level 73.4
|
||||
# plant/water/pump1/status RUNNING
|
||||
# ...
|
||||
|
||||
# MQTT: attacker sends a command (should be logged)
|
||||
mosquitto_pub -h 192.168.1.200 -t "plant/water/valve/inlet/state" -m "CLOSED"
|
||||
|
||||
# Modbus: read coil 0 (pump status)
|
||||
# (requires mbpoll or similar)
|
||||
mbpoll -a 1 -r 1 -c 2 192.168.1.200
|
||||
|
||||
# SNMP: sysDescr check
|
||||
snmpget -v2c -c public 192.168.1.200 1.3.6.1.2.1.1.1.0
|
||||
# Expected: STRING: "Linux scada-plc01 4.19.0..."
|
||||
```
|
||||
Reference in New Issue
Block a user