diff --git a/development/BUG_FIXES.md b/development/BUG_FIXES.md new file mode 100644 index 0000000..675cd55 --- /dev/null +++ b/development/BUG_FIXES.md @@ -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: `

403 Forbidden

` + +Replace with the actual Apache 2.4 default 403 page: + +```html + + +403 Forbidden + +

Forbidden

+

You don't have permission to access this resource.

+
+
Apache/2.4.54 (Debian) Server at {hostname} Port 80
+ +``` + +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(" 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 ` +- `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 `502`. + +**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..." +``` diff --git a/development/IMAP_BAIT.md b/development/IMAP_BAIT.md new file mode 100644 index 0000000..f7eff41 --- /dev/null +++ b/development/IMAP_BAIT.md @@ -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 \r\n\r\n. +TOP N L → +OK\r\n\r\n. +UIDL → +OK\r\n1 \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 +``` diff --git a/development/SMTP_RELAY.md b/development/SMTP_RELAY.md new file mode 100644 index 0000000..bfa6a5d --- /dev/null +++ b/development/SMTP_RELAY.md @@ -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 .` 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 .\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:\r\nRCPT TO:\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 +# 221 2.0.0 Bye +``` diff --git a/templates/smtp/server.py b/templates/smtp/server.py index 5b826b8..6bfa8f0 100644 --- a/templates/smtp/server.py +++ b/templates/smtp/server.py @@ -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 .\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 .\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: diff --git a/tests/test_smtp.py b/tests/test_smtp.py new file mode 100644 index 0000000..8a6e93a --- /dev/null +++ b/tests/test_smtp.py @@ -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:", + "RCPT TO:", + ) + 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:", + "RCPT TO:", + "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:", + "RCPT TO:", + "RCPT TO:", + "RCPT TO:", + "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:", + "RCPT TO:", + "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:", "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:", "RCPT TO:", "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:", + "RCPT TO:", + "RSET", + "MAIL FROM:", + "RCPT TO:", + "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:", + "RCPT TO:", + ) + 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:", + "RCPT TO:", + "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)