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:
333
development/BUG_FIXES.md
Normal file
333
development/BUG_FIXES.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Bug Fixes — Non-Feature Realism Issues
|
||||
|
||||
> These are fingerprint leaks and broken protocol handlers that don't need new
|
||||
> interaction design — just targeted fixes. All severity High or above from REALISM_AUDIT.md.
|
||||
|
||||
---
|
||||
|
||||
## 1. HTTP — Werkzeug header leak (High)
|
||||
|
||||
### Problem
|
||||
|
||||
Every response has two `Server:` headers:
|
||||
```
|
||||
Server: Werkzeug/3.1.3 Python/3.11.2
|
||||
Server: Apache/2.4.54 (Debian)
|
||||
```
|
||||
|
||||
nmap correctly IDs Apache from the second header, but any attacker that does
|
||||
`curl -I` or runs Burp sees the Werkzeug leak immediately. Port 6443 (K8s) also
|
||||
leaks Werkzeug in every response.
|
||||
|
||||
### Fix — WSGI middleware to strip/replace the header
|
||||
|
||||
In `templates/http/server.py` (Flask app), add a `@app.after_request` hook:
|
||||
|
||||
```python
|
||||
@app.after_request
|
||||
def _fix_server_header(response):
|
||||
response.headers["Server"] = os.environ.get("HTTP_SERVER_HEADER", "Apache/2.4.54 (Debian)")
|
||||
return response
|
||||
```
|
||||
|
||||
Flask sets `Server: Werkzeug/...` by default. The `after_request` hook runs after
|
||||
Werkzeug's own header injection, so it overwrites it.
|
||||
|
||||
Same fix applies to the K8s server if it's also Flask-based.
|
||||
|
||||
### Fix — Apache 403 page body
|
||||
|
||||
Current response body: `<h1>403 Forbidden</h1>`
|
||||
|
||||
Replace with the actual Apache 2.4 default 403 page:
|
||||
|
||||
```html
|
||||
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
|
||||
<html><head>
|
||||
<title>403 Forbidden</title>
|
||||
</head><body>
|
||||
<h1>Forbidden</h1>
|
||||
<p>You don't have permission to access this resource.</p>
|
||||
<hr>
|
||||
<address>Apache/2.4.54 (Debian) Server at {hostname} Port 80</address>
|
||||
</body></html>
|
||||
```
|
||||
|
||||
Env var `HTTP_SERVER_HEADER` and `NODE_NAME` fill the address line.
|
||||
|
||||
### Env vars
|
||||
|
||||
| Var | Default |
|
||||
|-----|---------|
|
||||
| `HTTP_SERVER_HEADER` | `Apache/2.4.54 (Debian)` |
|
||||
|
||||
---
|
||||
|
||||
## 2. FTP — Twisted banner (High)
|
||||
|
||||
### Problem
|
||||
|
||||
```
|
||||
220 Twisted 25.5.0 FTP Server
|
||||
```
|
||||
|
||||
This is Twisted's built-in FTP server banner. Immediately identifies the framework.
|
||||
|
||||
### Fix
|
||||
|
||||
Override the banner. The Twisted FTP server class has a `factory.welcomeMessage` or
|
||||
the protocol's `sendLine()` for the greeting. Simplest fix: subclass the protocol
|
||||
and override `lineReceived` to intercept the `220` line before it goes out, OR
|
||||
use a `_FTPFactory` subclass that sets `welcomeMessage`:
|
||||
|
||||
```python
|
||||
from twisted.protocols.ftp import FTPFactory, FTPAnonymousShell
|
||||
from twisted.internet import reactor
|
||||
import os
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
||||
BANNER = os.environ.get("FTP_BANNER", f"220 (vsFTPd 3.0.3)")
|
||||
|
||||
factory = FTPFactory(portal)
|
||||
factory.welcomeMessage = BANNER # overrides the Twisted default
|
||||
```
|
||||
|
||||
If `FTPFactory.welcomeMessage` is not directly settable, patch it at class level:
|
||||
|
||||
```python
|
||||
FTPFactory.welcomeMessage = BANNER
|
||||
```
|
||||
|
||||
### Anonymous login + fake directory
|
||||
|
||||
The current server rejects everything after login. Fix:
|
||||
|
||||
- Use `FTPAnonymousShell` pointed at a `MemoryFilesystem` with fake entries:
|
||||
```
|
||||
/
|
||||
├── backup.tar.gz (0 bytes, but listable)
|
||||
├── db_dump.sql (0 bytes)
|
||||
├── config.ini (0 bytes)
|
||||
└── credentials.txt (0 bytes)
|
||||
```
|
||||
- `RETR` any file → return 1–3 lines of plausible fake content, then close.
|
||||
- Log every `RETR` with filename and client IP.
|
||||
|
||||
### Env vars
|
||||
|
||||
| Var | Default |
|
||||
|-----|---------|
|
||||
| `FTP_BANNER` | `220 (vsFTPd 3.0.3)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. MSSQL — Silent on TDS pre-login (High)
|
||||
|
||||
### Problem
|
||||
|
||||
No response to standard TDS pre-login packets. Connection is dropped silently.
|
||||
nmap barely recognizes port 1433 (`ms-sql-s?`).
|
||||
|
||||
### Diagnosis
|
||||
|
||||
The nmap fingerprint shows `\x04\x01\x00\x2b...` which is a valid TDS 7.x pre-login
|
||||
response fragment. So the server is sending _something_ — but it may be truncated or
|
||||
malformed enough that nmap can't complete its probe.
|
||||
|
||||
Check `templates/mssql/server.py`: look for the raw bytes being sent in response to
|
||||
`\x12\x01` (TDS pre-login type). Common bugs:
|
||||
- Wrong packet length field (bytes 2-3 of TDS header)
|
||||
- Missing `\xff` terminator on the pre-login option list
|
||||
- Status byte 0x01 instead of 0x00 in the TDS header (signaling last packet)
|
||||
|
||||
### Correct TDS 7.x pre-login response structure
|
||||
|
||||
```
|
||||
Byte 0: 0x04 (packet type: tabular result)
|
||||
Byte 1: 0x01 (status: last packet)
|
||||
Bytes 2-3: 0x00 0x2b (total length including header = 43)
|
||||
Bytes 4-5: 0x00 0x00 (SPID)
|
||||
Byte 6: 0x01 (packet ID)
|
||||
Byte 7: 0x00 (window)
|
||||
--- TDS pre-login payload ---
|
||||
[VERSION] option: type=0x00, offset=0x001a, length=0x0006
|
||||
[ENCRYPTION] option: type=0x01, offset=0x0020, length=0x0001
|
||||
[INSTOPT] option: type=0x02, offset=0x0021, length=0x0001
|
||||
[THREADID] option: type=0x03, offset=0x0022, length=0x0004
|
||||
[MARS] option: type=0x04, offset=0x0026, length=0x0001
|
||||
Terminator: 0xff
|
||||
VERSION: 0x0e 0x00 0x07 0xd0 0x00 0x00 (14.0.2000 = SQL Server 2017)
|
||||
ENCRYPTION: 0x02 (ENCRYPT_NOT_SUP)
|
||||
INSTOPT: 0x00
|
||||
THREADID: 0x00 0x00 0x00 0x00
|
||||
MARS: 0x00
|
||||
```
|
||||
|
||||
Verify the current implementation's bytes match this exactly. Fix the length field if off.
|
||||
|
||||
---
|
||||
|
||||
## 4. MongoDB — Silent on OP_MSG (High)
|
||||
|
||||
### Problem
|
||||
|
||||
No response to `OP_MSG isMaster` command. nmap shows `mongod?` (partial recognition).
|
||||
|
||||
### Diagnosis
|
||||
|
||||
MongoDB wire protocol since 3.6 uses `OP_MSG` (opcode 2013). Older clients use
|
||||
`OP_QUERY` (opcode 2004) against `admin.$cmd`. Check which one `templates/mongodb/server.py`
|
||||
handles, and whether the response's `responseTo` field matches the request's `requestID`.
|
||||
|
||||
Common bugs:
|
||||
- Handling `OP_QUERY` but not `OP_MSG`
|
||||
- Wrong `responseTo` in the response header (must echo the request's requestID)
|
||||
- Missing `flagBits` field in OP_MSG response (must be 0x00000000)
|
||||
|
||||
### Correct OP_MSG `hello` response
|
||||
|
||||
```python
|
||||
import struct, bson
|
||||
|
||||
def _op_msg_hello_response(request_id: int) -> bytes:
|
||||
doc = {
|
||||
"ismaster": True,
|
||||
"maxBsonObjectSize": 16777216,
|
||||
"maxMessageSizeBytes": 48000000,
|
||||
"maxWriteBatchSize": 100000,
|
||||
"localTime": {"$date": int(time.time() * 1000)},
|
||||
"logicalSessionTimeoutMinutes": 30,
|
||||
"connectionId": 1,
|
||||
"minWireVersion": 0,
|
||||
"maxWireVersion": 17,
|
||||
"readOnly": False,
|
||||
"ok": 1.0,
|
||||
}
|
||||
payload = b"\x00" + bson.encode(doc) # section type 0 = body
|
||||
flag_bits = struct.pack("<I", 0)
|
||||
msg_body = flag_bits + payload
|
||||
# MsgHeader: totalLength(4) + requestID(4) + responseTo(4) + opCode(4)
|
||||
header = struct.pack("<iiii",
|
||||
16 + len(msg_body), # total length
|
||||
1, # requestID (server-generated)
|
||||
request_id, # responseTo: echo the client's requestID
|
||||
2013, # OP_MSG
|
||||
)
|
||||
return header + msg_body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Redis — Empty keyspace (Medium)
|
||||
|
||||
### Problem
|
||||
|
||||
`KEYS *` returns `*0\r\n` after a successful AUTH. A real exposed Redis always has data.
|
||||
Attacker does `AUTH anypassword` → `+OK` → `KEYS *` → empty → leaves.
|
||||
|
||||
### Fix — fake key-value store
|
||||
|
||||
Add a module-level dict with bait data. Handle `KEYS`, `GET`, `SCAN`, `TYPE`, `TTL`:
|
||||
|
||||
```python
|
||||
_FAKE_STORE = {
|
||||
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
||||
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
||||
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
|
||||
b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
|
||||
b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
|
||||
b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}',
|
||||
b"config:db_password": b"Pr0dDB!2024#Secure",
|
||||
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
|
||||
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
b"rate_limit:192.168.1.1": b"42",
|
||||
}
|
||||
```
|
||||
|
||||
Commands to handle:
|
||||
- `KEYS *` → all keys as RESP array
|
||||
- `KEYS pattern` → filtered (basic glob: `*` matches all, `prefix:*` matches prefix)
|
||||
- `GET key` → value or `$-1` (nil)
|
||||
- `SCAN 0` → `*2\r\n$1\r\n0\r\n` + keys array (cursor always 0, return all)
|
||||
- `TYPE key` → `+string\r\n`
|
||||
- `TTL key` → `:-1\r\n` (no expiry)
|
||||
|
||||
---
|
||||
|
||||
## 6. SIP — Hardcoded nonce (Low)
|
||||
|
||||
### Problem
|
||||
|
||||
`nonce="decnet0000"` is hardcoded. A Shodan signature could detect this string.
|
||||
|
||||
### Fix
|
||||
|
||||
```python
|
||||
import secrets
|
||||
nonce = secrets.token_hex(16) # e.g. "a3f8c1b2e7d94051..."
|
||||
```
|
||||
|
||||
Generate once per connection in `connection_made`. The WWW-Authenticate header
|
||||
becomes: `Digest realm="{NODE_NAME}", nonce="{nonce}", algorithm=MD5`
|
||||
|
||||
---
|
||||
|
||||
## 7. VNC — Hardcoded DES challenge (Low)
|
||||
|
||||
### Problem
|
||||
|
||||
The 16-byte DES challenge sent during VNC auth negotiation is static.
|
||||
|
||||
### Fix
|
||||
|
||||
```python
|
||||
import os
|
||||
self._vnc_challenge = os.urandom(16)
|
||||
```
|
||||
|
||||
Generate in `connection_made`. Send `self._vnc_challenge` in the Security handshake.
|
||||
|
||||
---
|
||||
|
||||
## 8. PostgreSQL — Hardcoded salt (Low)
|
||||
|
||||
### Problem
|
||||
|
||||
`AuthenticationMD5Password` response contains `\xde\xad\xbe\xef` as the 4-byte salt.
|
||||
|
||||
### Fix
|
||||
|
||||
```python
|
||||
import os
|
||||
self._pg_salt = os.urandom(4)
|
||||
```
|
||||
|
||||
Use `self._pg_salt` in the `R\x00\x00\x00\x0c\x00\x00\x00\x05` response bytes.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `templates/http/server.py` | `after_request` header fix, proper 403 body |
|
||||
| `templates/ftp/server.py` | Banner override, anonymous login, fake dir |
|
||||
| `templates/mssql/server.py` | Fix TDS pre-login response bytes |
|
||||
| `templates/mongodb/server.py` | Add OP_MSG handler, fix responseTo |
|
||||
| `templates/redis/server.py` | Add fake key-value store, KEYS/GET/SCAN |
|
||||
| `templates/sip/server.py` | Random nonce per connection |
|
||||
| `templates/vnc/server.py` | Random DES challenge per connection |
|
||||
| `templates/postgres/server.py` | Random MD5 salt per connection |
|
||||
| `tests/test_http_headers.py` | New: assert single Server header, correct 403 body |
|
||||
| `tests/test_redis.py` | Extend: KEYS *, GET, SCAN return bait data |
|
||||
|
||||
---
|
||||
|
||||
## Priority order
|
||||
|
||||
1. HTTP header leak — immediately visible to any attacker
|
||||
2. FTP banner — immediate framework disclosure
|
||||
3. MSSQL silent — service appears dead
|
||||
4. MongoDB silent — service appears dead
|
||||
5. Redis empty keyspace — breaks the bait value proposition
|
||||
6. SIP/VNC/PostgreSQL hardcoded values — low risk, quick wins
|
||||
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..."
|
||||
```
|
||||
220
development/IMAP_BAIT.md
Normal file
220
development/IMAP_BAIT.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# IMAP Bait Mailboxes — Plan
|
||||
|
||||
> Scenario: attacker credential-stuffs IMAP, logs in as `admin`/`admin`,
|
||||
> browses mail, finds juicy internal communications and credential leaks.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
Both IMAP and POP3 reject **all** credentials with a hard-coded failure.
|
||||
No mailbox commands are implemented. An attacker that successfully guesses
|
||||
credentials (which they can't, ever) would have nothing to read anyway.
|
||||
|
||||
This is the biggest missed opportunity in the whole stack.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Credential policy
|
||||
|
||||
Accept a configurable set of username/password pairs. Defaults baked into
|
||||
the image — typical attacker wordlist winners:
|
||||
|
||||
```
|
||||
admin / admin
|
||||
admin / password
|
||||
admin / 123456
|
||||
root / root
|
||||
mail / mail
|
||||
user / user
|
||||
```
|
||||
|
||||
Env var override: `IMAP_USERS=admin:admin,root:toor,user:letmein`
|
||||
|
||||
Wrong credentials → `NO [AUTHENTICATIONFAILED] Invalid credentials` (log the attempt).
|
||||
Right credentials → `OK` + full session.
|
||||
|
||||
### Fake mailboxes
|
||||
|
||||
One static mailbox tree, same for all users (honeypot doesn't need per-user isolation):
|
||||
|
||||
```
|
||||
INBOX (12 messages)
|
||||
Sent (8 messages)
|
||||
Drafts (1 message)
|
||||
Archive (3 messages)
|
||||
```
|
||||
|
||||
### Bait email content
|
||||
|
||||
Bait emails are seeded at startup from a `MAIL_SEED` list embedded in the server.
|
||||
Content is designed to reward the attacker for staying in the session:
|
||||
|
||||
**INBOX messages (selected)**
|
||||
|
||||
| # | From | Subject | Bait payload |
|
||||
|---|------|---------|-------------|
|
||||
| 1 | devops@company.internal | AWS credentials rotation | `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` |
|
||||
| 2 | monitoring@company.internal | DB password changed | `mysql://admin:Sup3rS3cr3t!@10.0.1.5:3306/production` |
|
||||
| 3 | noreply@github.com | Your personal access token | `ghp_16C7e42F292c6912E7710c838347Ae178B4a` |
|
||||
| 4 | admin@company.internal | VPN config attached | `vpn.company.internal:1194 user=vpnadmin pass=VpnP@ss2024` |
|
||||
| 5 | sysadmin@company.internal | Root password | New root pw: `r00tM3T00!` — change after first login |
|
||||
| 6 | backup@company.internal | Backup job failed | Backup to `192.168.1.50:/mnt/nas` — credentials in /etc/backup.conf |
|
||||
| 7 | alerts@company.internal | SSH brute-force alert | 47 attempts from 185.220.101.x against root — all blocked |
|
||||
|
||||
**Sent messages**
|
||||
|
||||
| # | To | Subject | Bait payload |
|
||||
|---|-----|---------|-------------|
|
||||
| 1 | vendor@external.com | API credentials | API key: `sk_live_xK3mF2...9aP` |
|
||||
| 2 | helpdesk@company.internal | Need access reset | My password is `Winter2024!` — please reset MFA |
|
||||
|
||||
**Drafts**
|
||||
|
||||
| # | Subject | Bait payload |
|
||||
|---|---------|-------------|
|
||||
| 1 | DO NOT SEND - k8s secrets | `kubectl get secret admin-token -n kube-system -o yaml` output pasted in |
|
||||
|
||||
---
|
||||
|
||||
## Protocol implementation
|
||||
|
||||
### IMAP4rev1 commands to implement
|
||||
|
||||
```
|
||||
CAPABILITY → * CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
|
||||
LOGIN → authenticate or reject
|
||||
SELECT → select INBOX / Sent / Drafts / Archive
|
||||
LIST → return folder tree
|
||||
LSUB → same as LIST (subscribed)
|
||||
STATUS → return EXISTS / RECENT / UNSEEN for a mailbox
|
||||
FETCH → return message headers or full body
|
||||
UID FETCH → same with UID addressing
|
||||
SEARCH → stub: return all UIDs (we don't need real search)
|
||||
EXAMINE → read-only SELECT
|
||||
CLOSE → deselect current mailbox
|
||||
LOGOUT → BYE + OK
|
||||
NOOP → OK
|
||||
```
|
||||
|
||||
Commands NOT needed (return `BAD`): `STORE`, `COPY`, `APPEND`, `EXPUNGE`.
|
||||
Attackers rarely run these. Logging `BAD` is fine if they do.
|
||||
|
||||
### Banner
|
||||
|
||||
Change from:
|
||||
```
|
||||
* OK [omega-decky] IMAP4rev1 Service Ready
|
||||
```
|
||||
To:
|
||||
```
|
||||
* OK Dovecot ready.
|
||||
```
|
||||
|
||||
nmap currently says "(unrecognized)". Dovecot banner makes it ID correctly.
|
||||
|
||||
### CAPABILITY advertisement
|
||||
|
||||
```
|
||||
* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
|
||||
```
|
||||
|
||||
### SELECT response
|
||||
|
||||
```
|
||||
* 12 EXISTS
|
||||
* 0 RECENT
|
||||
* OK [UNSEEN 7] Message 7 is first unseen
|
||||
* OK [UIDVALIDITY 1712345678] UIDs valid
|
||||
* OK [UIDNEXT 13] Predicted next UID
|
||||
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
|
||||
* OK [PERMANENTFLAGS (\Deleted \Seen \*)] Limited
|
||||
A3 OK [READ-WRITE] SELECT completed
|
||||
```
|
||||
|
||||
### FETCH envelope/body
|
||||
|
||||
Message structs stored as Python dataclasses at startup. `FETCH 1:* (FLAGS ENVELOPE)` returns
|
||||
envelope tuples in RFC 3501 format. `FETCH N BODY[]` returns the raw RFC 2822 message.
|
||||
|
||||
---
|
||||
|
||||
## POP3 parity
|
||||
|
||||
POP3 is much simpler. Same credential list. After successful PASS:
|
||||
|
||||
```
|
||||
STAT → +OK 12 48000 (12 messages, total ~48 KB)
|
||||
LIST → +OK 12 messages\r\n1 3912\r\n2 2048\r\n...\r\n.
|
||||
RETR N → +OK <size>\r\n<raw message>\r\n.
|
||||
TOP N L → +OK\r\n<first L body lines>\r\n.
|
||||
UIDL → +OK\r\n1 <uid>\r\n...\r\n.
|
||||
DELE N → +OK Message deleted (just log it, don't actually remove)
|
||||
CAPA → +OK\r\nTOP\r\nUSER\r\nUIDL\r\nRESP-CODES\r\nAUTH-RESP-CODE\r\nSASL\r\n.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State machine (IMAP)
|
||||
|
||||
```
|
||||
NOT_AUTHENTICATED
|
||||
→ LOGIN success → AUTHENTICATED
|
||||
→ LOGIN fail → NOT_AUTHENTICATED (log, stay open for retries)
|
||||
|
||||
AUTHENTICATED
|
||||
→ SELECT / EXAMINE → SELECTED
|
||||
→ LIST / LSUB / STATUS / LOGOUT / NOOP → stay AUTHENTICATED
|
||||
|
||||
SELECTED
|
||||
→ FETCH / UID FETCH / SEARCH / EXAMINE / SELECT → stay SELECTED
|
||||
→ CLOSE / LOGOUT → AUTHENTICATED or closed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `templates/imap/server.py` | Full rewrite: state machine, credential check, mailbox commands, bait emails |
|
||||
| `templates/pop3/server.py` | Extend: credential check, STAT/LIST/RETR/UIDL/TOP/DELE/CAPA |
|
||||
| `tests/test_imap.py` | New: login flow, SELECT, FETCH, bad creds, all mailboxes |
|
||||
| `tests/test_pop3.py` | New: login flow, STAT, LIST, RETR, CAPA |
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- All bait emails are hardcoded Python strings — no files to load, no I/O.
|
||||
- Use a module-level `MESSAGES: list[dict]` list with fields: `uid`, `flags`, `size`, `date`,
|
||||
`from_`, `to`, `subject`, `body` (full RFC 2822 string).
|
||||
- `_format_envelope()` builds the IMAP ENVELOPE tuple string from the message dict.
|
||||
- Thread safety: all state per-connection in the Protocol class. No shared mutable state.
|
||||
|
||||
---
|
||||
|
||||
## Env vars
|
||||
|
||||
| Var | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `IMAP_USERS` | `admin:admin,root:root,mail:mail` | Accepted credentials (user:pass,...) |
|
||||
| `IMAP_BANNER` | `* OK Dovecot ready.` | Greeting line |
|
||||
| `NODE_NAME` | `mailserver` | Hostname in responses |
|
||||
|
||||
---
|
||||
|
||||
## Verification against live decky
|
||||
|
||||
```bash
|
||||
# Credential test (should accept)
|
||||
printf "A1 LOGIN admin admin\r\nA2 SELECT INBOX\r\nA3 FETCH 1:3 (FLAGS ENVELOPE)\r\nA4 FETCH 5 BODY[]\r\nA5 LOGOUT\r\n" | nc 192.168.1.200 143
|
||||
|
||||
# Credential test (should reject)
|
||||
printf "A1 LOGIN admin wrongpass\r\n" | nc 192.168.1.200 143
|
||||
|
||||
# nmap fingerprint check (expect "Dovecot imapd")
|
||||
nmap -p 143 -sV 192.168.1.200
|
||||
```
|
||||
195
development/SMTP_RELAY.md
Normal file
195
development/SMTP_RELAY.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# SMTP Open Relay — Plan
|
||||
|
||||
> Priority: **P0** — DATA handler is broken (502 on every body line).
|
||||
> Scenario: attacker finds open relay, sends mail through it.
|
||||
|
||||
---
|
||||
|
||||
## What's broken today
|
||||
|
||||
`templates/smtp/server.py` sends `354 End data with <CR><LF>.<CR><LF>` on `DATA`, then
|
||||
falls back to `_handle_line()` for every subsequent line. Because those lines don't start
|
||||
with a recognized SMTP verb, every line gets:
|
||||
|
||||
```
|
||||
502 5.5.2 Error: command not recognized
|
||||
```
|
||||
|
||||
The session never completes. The message is silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## Fix: DATA state machine
|
||||
|
||||
Add a `_in_data` flag. Once `DATA` is received, accumulate raw body lines until the
|
||||
terminator `\r\n.\r\n`. On terminator: log the message, return `250`, flip flag back.
|
||||
|
||||
### State variables added to `SMTPProtocol.__init__`
|
||||
|
||||
```python
|
||||
self._in_data = False
|
||||
self._data_buf = [] # accumulate body lines
|
||||
self._mail_from = ""
|
||||
self._rcpt_to = []
|
||||
```
|
||||
|
||||
### Modified `data_received`
|
||||
|
||||
No change — still splits on `\r\n`.
|
||||
|
||||
### Modified `_handle_line`
|
||||
|
||||
```python
|
||||
def _handle_line(self, line: str) -> None:
|
||||
# DATA body accumulation mode
|
||||
if self._in_data:
|
||||
if line == ".":
|
||||
# end of message
|
||||
body = "\r\n".join(self._data_buf)
|
||||
msg_id = _rand_msg_id()
|
||||
_log("message_accepted",
|
||||
src=self._peer[0],
|
||||
mail_from=self._mail_from,
|
||||
rcpt_to=",".join(self._rcpt_to),
|
||||
body_bytes=len(body),
|
||||
msg_id=msg_id)
|
||||
self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
|
||||
self._in_data = False
|
||||
self._data_buf = []
|
||||
else:
|
||||
# RFC 5321 dot-stuffing: leading dot means literal dot, strip it
|
||||
self._data_buf.append(line[1:] if line.startswith("..") else line)
|
||||
return
|
||||
|
||||
cmd = line.split()[0].upper() if line.split() else ""
|
||||
# ... existing handlers ...
|
||||
elif cmd == "MAIL":
|
||||
self._mail_from = line.split(":", 1)[1].strip() if ":" in line else line
|
||||
_log("mail_from", src=self._peer[0], value=self._mail_from)
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
elif cmd == "RCPT":
|
||||
rcpt = line.split(":", 1)[1].strip() if ":" in line else line
|
||||
self._rcpt_to.append(rcpt)
|
||||
_log("rcpt_to", src=self._peer[0], value=rcpt)
|
||||
self._transport.write(b"250 2.1.5 Ok\r\n")
|
||||
elif cmd == "DATA":
|
||||
if not self._mail_from or not self._rcpt_to:
|
||||
self._transport.write(b"503 5.5.1 Error: need MAIL command\r\n")
|
||||
else:
|
||||
self._in_data = True
|
||||
self._transport.write(b"354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
elif cmd == "RSET":
|
||||
self._mail_from = ""
|
||||
self._rcpt_to = []
|
||||
self._in_data = False
|
||||
self._data_buf = []
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
```
|
||||
|
||||
### Helper
|
||||
|
||||
```python
|
||||
import random, string
|
||||
|
||||
def _rand_msg_id() -> str:
|
||||
"""Return a Postfix-style 12-char hex queue ID."""
|
||||
return "".join(random.choices("0123456789ABCDEF", k=12))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open relay behavior
|
||||
|
||||
The current server already returns `250 2.1.5 Ok` for any `RCPT TO` regardless of domain.
|
||||
That's correct — do NOT gate on the domain. The attacker's goal is to relay spam. We let
|
||||
them "succeed" and log everything.
|
||||
|
||||
Remove the `AUTH` rejection + close. An open relay doesn't require authentication. Replace:
|
||||
|
||||
```python
|
||||
elif cmd == "AUTH":
|
||||
_log("auth_attempt", src=self._peer[0], command=line)
|
||||
self._transport.write(b"535 5.7.8 Error: authentication failed: ...\r\n")
|
||||
self._transport.close()
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```python
|
||||
elif cmd == "AUTH":
|
||||
# Log the attempt but advertise that auth succeeds (open relay bait)
|
||||
_log("auth_attempt", src=self._peer[0], command=line)
|
||||
self._transport.write(b"235 2.7.0 Authentication successful\r\n")
|
||||
```
|
||||
|
||||
Some scanners probe AUTH before DATA. Accepting it keeps them engaged.
|
||||
|
||||
---
|
||||
|
||||
## Banner / persona
|
||||
|
||||
Current banner is already perfect: `220 omega-decky ESMTP Postfix (Debian/GNU)`.
|
||||
|
||||
The `SMTP_BANNER` env var lets per-decky customization happen at deploy time via the
|
||||
persona config — no code change needed.
|
||||
|
||||
---
|
||||
|
||||
## Log events emitted
|
||||
|
||||
| event_type | Fields |
|
||||
|------------------|---------------------------------------------------|
|
||||
| `connect` | src, src_port |
|
||||
| `ehlo` | src, domain |
|
||||
| `auth_attempt` | src, command |
|
||||
| `mail_from` | src, value |
|
||||
| `rcpt_to` | src, value (one event per recipient) |
|
||||
| `message_accepted` | src, mail_from, rcpt_to, body_bytes, msg_id |
|
||||
| `disconnect` | src |
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `templates/smtp/server.py` | DATA state machine, open relay AUTH accept, RSET fix |
|
||||
| `tests/test_smtp.py` | New: DATA → 250 flow, multi-recipient, dot-stuffing, RSET |
|
||||
|
||||
---
|
||||
|
||||
## Test cases (pytest)
|
||||
|
||||
```python
|
||||
# full send flow
|
||||
conn → EHLO → MAIL FROM → RCPT TO → DATA → body lines → "." → 250 2.0.0 Ok: queued as ...
|
||||
|
||||
# multi-recipient
|
||||
RCPT TO x3 → DATA → body → "." → 250
|
||||
|
||||
# dot-stuffing
|
||||
..real dot → body line stored as ".real dot"
|
||||
|
||||
# RSET mid-session
|
||||
MAIL FROM → RCPT TO → RSET → assert _mail_from == "" and _rcpt_to == []
|
||||
|
||||
# AUTH accept
|
||||
AUTH PLAIN base64 → 235
|
||||
|
||||
# 503 if DATA before MAIL
|
||||
DATA (no prior MAIL) → 503
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification against live decky
|
||||
|
||||
```bash
|
||||
# Full relay test
|
||||
printf "EHLO test.com\r\nMAIL FROM:<hacker@evil.com>\r\nRCPT TO:<admin@target.com>\r\nDATA\r\nSubject: hello\r\n\r\nBody line 1\r\nBody line 2\r\n.\r\nQUIT\r\n" | nc 192.168.1.200 25
|
||||
|
||||
# Expected final lines:
|
||||
# 354 End data with ...
|
||||
# 250 2.0.0 Ok: queued as <ID>
|
||||
# 221 2.0.0 Bye
|
||||
```
|
||||
Reference in New Issue
Block a user