Files
DECNET/development/SMTP_RELAY.md
anti 94f82c9089 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)
2026-04-10 01:03:47 -04:00

196 lines
5.8 KiB
Markdown

# 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
```