- 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)
196 lines
5.8 KiB
Markdown
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
|
|
```
|