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
|
||||
```
|
||||
@@ -1,22 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SMTP server — emulates a realistic ESMTP server (Postfix-style).
|
||||
Logs EHLO/AUTH/MAIL FROM/RCPT TO attempts as JSON, then denies auth.
|
||||
|
||||
Two modes of operation, controlled by SMTP_OPEN_RELAY:
|
||||
|
||||
SMTP_OPEN_RELAY=0 (default) — credential harvester
|
||||
AUTH attempts are logged and rejected (535).
|
||||
RCPT TO is rejected with 554 (relay denied) for all recipients.
|
||||
This captures credential stuffing and scanning activity.
|
||||
|
||||
SMTP_OPEN_RELAY=1 — open relay bait
|
||||
AUTH is accepted for any credentials (235).
|
||||
RCPT TO is accepted for any domain (250).
|
||||
DATA is fully buffered until CRLF.CRLF and acknowledged with a
|
||||
queued-as message ID. Attractive to spam relay operators.
|
||||
|
||||
The DATA state machine (and the 502-per-line bug) is fixed in both modes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
|
||||
import random
|
||||
import string
|
||||
from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
|
||||
SERVICE_NAME = "smtp"
|
||||
SERVICE_NAME = "smtp"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1"
|
||||
|
||||
_SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)")
|
||||
_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME)
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
print(line, flush=True)
|
||||
@@ -24,11 +41,42 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _rand_msg_id() -> str:
|
||||
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return "".join(random.choices(chars, k=12)) # noqa: S311
|
||||
|
||||
|
||||
def _decode_auth_plain(blob: str) -> tuple[str, str]:
|
||||
"""Decode SASL PLAIN: base64(authzid\0authcid\0passwd) → (user, pass)."""
|
||||
try:
|
||||
decoded = base64.b64decode(blob + "==").decode(errors="replace")
|
||||
parts = decoded.split("\x00")
|
||||
if len(parts) >= 3:
|
||||
return parts[1], parts[2]
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
except Exception:
|
||||
pass
|
||||
return blob, ""
|
||||
|
||||
|
||||
class SMTPProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = ("?", 0)
|
||||
self._buf = b""
|
||||
self._peer = ("?", 0)
|
||||
self._buf = b""
|
||||
# per-transaction state
|
||||
self._mail_from = ""
|
||||
self._rcpt_to: list[str] = []
|
||||
# DATA accumulation
|
||||
self._in_data = False
|
||||
self._data_buf: list[str] = []
|
||||
# AUTH multi-step state (LOGIN mechanism sends user/pass in separate lines)
|
||||
self._auth_state = "" # "" | "await_user" | "await_pass"
|
||||
self._auth_user = ""
|
||||
|
||||
# ── asyncio.Protocol ──────────────────────────────────────────────────────
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
@@ -40,14 +88,55 @@ class SMTPProtocol(asyncio.Protocol):
|
||||
self._buf += data
|
||||
while b"\r\n" in self._buf:
|
||||
line, self._buf = self._buf.split(b"\r\n", 1)
|
||||
self._handle_line(line.decode(errors="replace").strip())
|
||||
self._handle_line(line.decode(errors="replace"))
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
# ── Command dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
cmd = line.split()[0].upper() if line.split() else ""
|
||||
# ── DATA body accumulation ────────────────────────────────────────────
|
||||
if self._in_data:
|
||||
if line == ".":
|
||||
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 = []
|
||||
self._mail_from = ""
|
||||
self._rcpt_to = []
|
||||
else:
|
||||
# RFC 5321 dot-stuffing: strip leading dot
|
||||
self._data_buf.append(line[1:] if line.startswith(".") else line)
|
||||
return
|
||||
|
||||
# ── AUTH multi-step (LOGIN mechanism) ─────────────────────────────────
|
||||
if self._auth_state == "await_user":
|
||||
self._auth_user = base64.b64decode(line + "==").decode(errors="replace")
|
||||
self._auth_state = "await_pass"
|
||||
self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
|
||||
return
|
||||
if self._auth_state == "await_pass":
|
||||
password = base64.b64decode(line + "==").decode(errors="replace")
|
||||
self._finish_auth(self._auth_user, password)
|
||||
self._auth_state = ""
|
||||
self._auth_user = ""
|
||||
return
|
||||
|
||||
# ── Normal command dispatch ───────────────────────────────────────────
|
||||
parts = line.split(None, 1)
|
||||
cmd = parts[0].upper() if parts else ""
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if cmd in ("EHLO", "HELO"):
|
||||
domain = line.split(None, 1)[1] if " " in line else ""
|
||||
_log("ehlo", src=self._peer[0], domain=domain)
|
||||
_log("ehlo", src=self._peer[0], domain=args)
|
||||
self._transport.write(
|
||||
f"250-{_SMTP_MTA}\r\n"
|
||||
f"250-PIPELINING\r\n"
|
||||
@@ -59,41 +148,102 @@ class SMTPProtocol(asyncio.Protocol):
|
||||
f"250-8BITMIME\r\n"
|
||||
f"250 DSN\r\n".encode()
|
||||
)
|
||||
|
||||
elif cmd == "AUTH":
|
||||
_log("auth_attempt", src=self._peer[0], command=line)
|
||||
self._transport.write(b"535 5.7.8 Error: authentication failed: UGFzc3dvcmQ6\r\n")
|
||||
self._transport.close()
|
||||
self._handle_auth(args)
|
||||
|
||||
elif cmd == "MAIL":
|
||||
_log("mail_from", src=self._peer[0], value=line)
|
||||
addr = args.split(":", 1)[1].strip() if ":" in args else args
|
||||
self._mail_from = addr
|
||||
_log("mail_from", src=self._peer[0], value=addr)
|
||||
self._transport.write(b"250 2.1.0 Ok\r\n")
|
||||
|
||||
elif cmd == "RCPT":
|
||||
_log("rcpt_to", src=self._peer[0], value=line)
|
||||
self._transport.write(b"250 2.1.5 Ok\r\n")
|
||||
addr = args.split(":", 1)[1].strip() if ":" in args else args
|
||||
if OPEN_RELAY:
|
||||
self._rcpt_to.append(addr)
|
||||
_log("rcpt_to", src=self._peer[0], value=addr)
|
||||
self._transport.write(b"250 2.1.5 Ok\r\n")
|
||||
else:
|
||||
_log("rcpt_denied", src=self._peer[0], value=addr,
|
||||
severity=SEVERITY_WARNING)
|
||||
self._transport.write(
|
||||
b"554 5.7.1 <" + addr.encode() + b">: Relay access denied\r\n"
|
||||
)
|
||||
|
||||
elif cmd == "DATA":
|
||||
self._transport.write(b"354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
if not self._rcpt_to:
|
||||
self._transport.write(b"503 5.5.1 Error: need RCPT 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._auth_state = ""
|
||||
self._auth_user = ""
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
|
||||
elif cmd == "VRFY":
|
||||
_log("vrfy", src=self._peer[0], value=line)
|
||||
_log("vrfy", src=self._peer[0], value=args)
|
||||
self._transport.write(b"252 2.0.0 Cannot VRFY user\r\n")
|
||||
|
||||
elif cmd == "NOOP":
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
|
||||
elif cmd == "STARTTLS":
|
||||
self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
|
||||
|
||||
elif cmd == "QUIT":
|
||||
self._transport.write(b"221 2.0.0 Bye\r\n")
|
||||
self._transport.close()
|
||||
elif cmd == "NOOP":
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
elif cmd == "RSET":
|
||||
self._transport.write(b"250 2.0.0 Ok\r\n")
|
||||
elif cmd == "STARTTLS":
|
||||
# Pretend we don't support upgrading mid-session
|
||||
self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
|
||||
|
||||
else:
|
||||
_log("unknown_command", src=self._peer[0], command=line)
|
||||
_log("unknown_command", src=self._peer[0], command=line[:128])
|
||||
self._transport.write(b"502 5.5.2 Error: command not recognized\r\n")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
# ── AUTH helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_auth(self, args: str) -> None:
|
||||
parts = args.split(None, 1)
|
||||
mech = parts[0].upper() if parts else ""
|
||||
initial = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if mech == "PLAIN":
|
||||
if initial:
|
||||
user, password = _decode_auth_plain(initial)
|
||||
self._finish_auth(user, password)
|
||||
else:
|
||||
# Client will send credentials on next line
|
||||
self._auth_state = "await_plain"
|
||||
self._transport.write(b"334 \r\n")
|
||||
elif mech == "LOGIN":
|
||||
if initial:
|
||||
self._auth_user = base64.b64decode(initial + "==").decode(errors="replace")
|
||||
self._auth_state = "await_pass"
|
||||
self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
|
||||
else:
|
||||
self._auth_state = "await_user"
|
||||
self._transport.write(b"334 VXNlcm5hbWU6\r\n") # "Username:"
|
||||
else:
|
||||
self._transport.write(b"504 5.5.4 Unrecognized authentication mechanism\r\n")
|
||||
|
||||
def _finish_auth(self, username: str, password: str) -> None:
|
||||
_log("auth_attempt", src=self._peer[0],
|
||||
username=username, password=password,
|
||||
severity=SEVERITY_WARNING)
|
||||
if OPEN_RELAY:
|
||||
self._transport.write(b"235 2.7.0 Authentication successful\r\n")
|
||||
else:
|
||||
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"SMTP server starting as {NODE_NAME}")
|
||||
mode = "open-relay" if OPEN_RELAY else "credential-harvester"
|
||||
_log("startup", msg=f"SMTP server starting as {NODE_NAME} ({mode})")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(SMTPProtocol, "0.0.0.0", 25) # nosec B104
|
||||
async with server:
|
||||
|
||||
303
tests/test_smtp.py
Normal file
303
tests/test_smtp.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Tests for templates/smtp/server.py
|
||||
|
||||
Exercises both modes:
|
||||
- credential-harvester (SMTP_OPEN_RELAY=0, default)
|
||||
- open relay (SMTP_OPEN_RELAY=1)
|
||||
|
||||
Uses asyncio transport/protocol directly — no network socket needed.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_fake_decnet_logging() -> ModuleType:
|
||||
"""Return a stub decnet_logging module that does nothing."""
|
||||
mod = ModuleType("decnet_logging")
|
||||
mod.syslog_line = MagicMock(return_value="")
|
||||
mod.write_syslog_file = MagicMock()
|
||||
mod.forward_syslog = MagicMock()
|
||||
mod.SEVERITY_WARNING = 4
|
||||
mod.SEVERITY_INFO = 6
|
||||
return mod
|
||||
|
||||
|
||||
def _load_smtp(open_relay: bool):
|
||||
"""Import smtp server module with desired OPEN_RELAY value.
|
||||
|
||||
Injects a stub decnet_logging into sys.modules so the template can import
|
||||
it without needing the real file on sys.path.
|
||||
"""
|
||||
env = {"SMTP_OPEN_RELAY": "1" if open_relay else "0", "NODE_NAME": "testhost"}
|
||||
for key in list(sys.modules):
|
||||
if key in ("smtp_server", "decnet_logging"):
|
||||
del sys.modules[key]
|
||||
|
||||
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
|
||||
|
||||
spec = importlib.util.spec_from_file_location("smtp_server", "templates/smtp/server.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_protocol(mod):
|
||||
"""Return a (protocol, transport, written) triple. Banner is already discarded."""
|
||||
proto = mod.SMTPProtocol()
|
||||
transport = MagicMock()
|
||||
written: list[bytes] = []
|
||||
transport.write.side_effect = written.append
|
||||
proto.connection_made(transport)
|
||||
written.clear()
|
||||
return proto, transport, written
|
||||
|
||||
|
||||
def _send(proto, *lines: str) -> None:
|
||||
"""Feed CRLF-terminated lines to the protocol."""
|
||||
for line in lines:
|
||||
proto.data_received((line + "\r\n").encode())
|
||||
|
||||
|
||||
def _replies(written: list[bytes]) -> list[str]:
|
||||
"""Flatten written bytes into a list of non-empty response lines."""
|
||||
result = []
|
||||
for chunk in written:
|
||||
for line in chunk.decode().split("\r\n"):
|
||||
if line:
|
||||
result.append(line)
|
||||
return result
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def relay_mod():
|
||||
return _load_smtp(open_relay=True)
|
||||
|
||||
@pytest.fixture
|
||||
def harvester_mod():
|
||||
return _load_smtp(open_relay=False)
|
||||
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_banner_is_220(relay_mod):
|
||||
proto, transport, written = _make_protocol(relay_mod)
|
||||
# written was cleared — re-trigger banner check via a fresh instance
|
||||
proto2 = relay_mod.SMTPProtocol()
|
||||
t2 = MagicMock()
|
||||
w2: list[bytes] = []
|
||||
t2.write.side_effect = w2.append
|
||||
proto2.connection_made(t2)
|
||||
banner = b"".join(w2).decode()
|
||||
assert banner.startswith("220")
|
||||
assert "ESMTP" in banner
|
||||
|
||||
|
||||
# ── EHLO ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ehlo_returns_250_multiline(relay_mod):
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, "EHLO attacker.com")
|
||||
combined = b"".join(written).decode()
|
||||
assert "250" in combined
|
||||
assert "AUTH" in combined
|
||||
assert "PIPELINING" in combined
|
||||
|
||||
|
||||
# ── OPEN RELAY MODE ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestOpenRelay:
|
||||
|
||||
@staticmethod
|
||||
def _session(relay_mod, *lines):
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, *lines)
|
||||
return _replies(written)
|
||||
|
||||
def test_auth_plain_accepted(self, relay_mod):
|
||||
creds = base64.b64encode(b"\x00admin\x00password").decode()
|
||||
replies = self._session(relay_mod, f"AUTH PLAIN {creds}")
|
||||
assert any(r.startswith("235") for r in replies)
|
||||
|
||||
def test_auth_login_multistep_accepted(self, relay_mod):
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto, "AUTH LOGIN")
|
||||
_send(proto, base64.b64encode(b"admin").decode())
|
||||
_send(proto, base64.b64encode(b"password").decode())
|
||||
replies = _replies(written)
|
||||
assert any(r.startswith("235") for r in replies)
|
||||
|
||||
def test_rcpt_to_any_domain_accepted(self, relay_mod):
|
||||
replies = self._session(
|
||||
relay_mod,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<spam@evil.com>",
|
||||
"RCPT TO:<victim@anydomain.com>",
|
||||
)
|
||||
assert any(r.startswith("250 2.1.5") for r in replies)
|
||||
|
||||
def test_full_relay_flow(self, relay_mod):
|
||||
replies = self._session(
|
||||
relay_mod,
|
||||
"EHLO attacker.com",
|
||||
"MAIL FROM:<hacker@evil.com>",
|
||||
"RCPT TO:<admin@target.com>",
|
||||
"DATA",
|
||||
"Subject: hello",
|
||||
"",
|
||||
"Body line 1",
|
||||
"Body line 2",
|
||||
".",
|
||||
"QUIT",
|
||||
)
|
||||
assert any(r.startswith("354") for r in replies), "Expected 354 after DATA"
|
||||
assert any("queued as" in r for r in replies), "Expected queued-as ID"
|
||||
assert any(r.startswith("221") for r in replies), "Expected 221 on QUIT"
|
||||
|
||||
def test_multi_recipient(self, relay_mod):
|
||||
replies = self._session(
|
||||
relay_mod,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"RCPT TO:<e@f.com>",
|
||||
"RCPT TO:<g@h.com>",
|
||||
"DATA",
|
||||
"Subject: spam",
|
||||
"",
|
||||
"hello",
|
||||
".",
|
||||
)
|
||||
assert len([r for r in replies if r.startswith("250 2.1.5")]) == 3
|
||||
|
||||
def test_dot_stuffing_stripped(self, relay_mod):
|
||||
"""Leading dot on a body line must be stripped per RFC 5321."""
|
||||
proto, _, written = _make_protocol(relay_mod)
|
||||
_send(proto,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
"..real dot line",
|
||||
"normal line",
|
||||
".",
|
||||
)
|
||||
replies = _replies(written)
|
||||
assert any("queued as" in r for r in replies)
|
||||
|
||||
def test_data_rejected_without_rcpt(self, relay_mod):
|
||||
replies = self._session(relay_mod, "EHLO x.com", "MAIL FROM:<a@b.com>", "DATA")
|
||||
assert any(r.startswith("503") for r in replies)
|
||||
|
||||
def test_rset_clears_transaction_state(self, relay_mod):
|
||||
proto, _, _ = _make_protocol(relay_mod)
|
||||
_send(proto, "EHLO x.com", "MAIL FROM:<a@b.com>", "RCPT TO:<c@d.com>", "RSET")
|
||||
assert proto._mail_from == ""
|
||||
assert proto._rcpt_to == []
|
||||
assert proto._in_data is False
|
||||
|
||||
def test_second_send_after_rset(self, relay_mod):
|
||||
"""A new transaction started after RSET must complete successfully."""
|
||||
replies = self._session(
|
||||
relay_mod,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"RSET",
|
||||
"MAIL FROM:<new@b.com>",
|
||||
"RCPT TO:<new@d.com>",
|
||||
"DATA",
|
||||
"body",
|
||||
".",
|
||||
)
|
||||
assert any("queued as" in r for r in replies)
|
||||
|
||||
|
||||
# ── CREDENTIAL HARVESTER MODE ─────────────────────────────────────────────────
|
||||
|
||||
class TestCredentialHarvester:
|
||||
|
||||
@staticmethod
|
||||
def _session(harvester_mod, *lines):
|
||||
proto, _, written = _make_protocol(harvester_mod)
|
||||
_send(proto, *lines)
|
||||
return _replies(written)
|
||||
|
||||
def test_auth_plain_rejected_535(self, harvester_mod):
|
||||
creds = base64.b64encode(b"\x00admin\x00password").decode()
|
||||
replies = self._session(harvester_mod, f"AUTH PLAIN {creds}")
|
||||
assert any(r.startswith("535") for r in replies)
|
||||
|
||||
def test_auth_rejected_connection_stays_open(self, harvester_mod):
|
||||
"""After 535 the connection must stay alive — old code closed it immediately."""
|
||||
proto, transport, _ = _make_protocol(harvester_mod)
|
||||
creds = base64.b64encode(b"\x00admin\x00password").decode()
|
||||
_send(proto, f"AUTH PLAIN {creds}")
|
||||
transport.close.assert_not_called()
|
||||
|
||||
def test_rcpt_to_denied_554(self, harvester_mod):
|
||||
replies = self._session(
|
||||
harvester_mod,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<admin@target.com>",
|
||||
)
|
||||
assert any(r.startswith("554") for r in replies)
|
||||
|
||||
def test_relay_denied_blocks_data(self, harvester_mod):
|
||||
"""With all RCPT TO rejected, DATA must return 503."""
|
||||
replies = self._session(
|
||||
harvester_mod,
|
||||
"EHLO x.com",
|
||||
"MAIL FROM:<a@b.com>",
|
||||
"RCPT TO:<c@d.com>",
|
||||
"DATA",
|
||||
)
|
||||
assert any(r.startswith("503") for r in replies)
|
||||
|
||||
def test_noop_and_quit(self, harvester_mod):
|
||||
replies = self._session(harvester_mod, "NOOP", "QUIT")
|
||||
assert any(r.startswith("250") for r in replies)
|
||||
assert any(r.startswith("221") for r in replies)
|
||||
|
||||
def test_unknown_command_502(self, harvester_mod):
|
||||
replies = self._session(harvester_mod, "BADCMD foo")
|
||||
assert any(r.startswith("502") for r in replies)
|
||||
|
||||
def test_starttls_declined_454(self, harvester_mod):
|
||||
replies = self._session(harvester_mod, "STARTTLS")
|
||||
assert any(r.startswith("454") for r in replies)
|
||||
|
||||
|
||||
# ── Queue ID ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_rand_msg_id_format(relay_mod):
|
||||
for _ in range(50):
|
||||
mid = relay_mod._rand_msg_id()
|
||||
assert len(mid) == 12
|
||||
assert mid.isalnum()
|
||||
|
||||
|
||||
# ── AUTH PLAIN decode ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_decode_auth_plain_normal(relay_mod):
|
||||
blob = base64.b64encode(b"\x00alice\x00s3cr3t").decode()
|
||||
user, pw = relay_mod._decode_auth_plain(blob)
|
||||
assert user == "alice"
|
||||
assert pw == "s3cr3t"
|
||||
|
||||
|
||||
def test_decode_auth_plain_garbage_no_raise(relay_mod):
|
||||
user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
|
||||
assert isinstance(user, str)
|
||||
assert isinstance(pw, str)
|
||||
Reference in New Issue
Block a user