Hardening the SMTP Edge Against Connection Floods and Slow-Loris Abuse
A connection flood against port 25 rarely shows up on your bandwidth graph. It shows up as stalled smtpd workers and a mail outage. Here's how to defend the SMTP edge in three concentric rings — kernel, MTA, and reputation — and why generous timeouts, not missing rate limits, are the hole most operators fall through.
EvilMail TeamJuly 25, 202612 min read
The attack that doesn't trip your bandwidth graphs
SMTP is a synchronous, stateful protocol. Every TCP connection your MTA accepts pins a real process for the entire lifetime of the session — CONNECT → EHLO → MAIL FROM → RCPT TO → DATA → QUIT. In Postfix that's one smtpd process per live conversation, and by default you get exactly 100 of them (default_process_limit = 100). That number is the whole game. An attacker who wants to take your mail offline doesn't need to saturate your uplink. They need to open 100 TCP sessions and then say almost nothing.
That's why a connection-exhaustion attack is invisible on the graphs everyone watches. A volumetric DDoS lights up the netgraph in red. This one sits under a megabit while your MTA quietly goes dark. The tell isn't bandwidth — it's this climbing while delivery flatlines:
SMTP Edge DDoS: Stop Connection Floods & Slow-Loris on Port 25 — EvilMail Blog
bash
ss -tan state established '( sport = :25 )' | wc -l
There are two flavors, and they need different countermeasures:
Connection flood — open TCP sessions as fast as possible and abandon them, or send CONNECT and never speak. Each one occupies an accept slot and, if it gets past the listener, an smtpd fork.
Slow read / slow write (slow-loris for SMTP) — the nasty one. The attacker drips a single command, or one byte of the DATA body, every ~25 seconds. It stays *just* under your session timeout, so the worker is never released. A hundred of these and every process slot is held hostage by traffic measured in bytes per minute.
The failure mode almost everyone ships with is not a missing rate limit — it's a generous timeout. Postfix's stock smtpd_timeout is 300 seconds under normal load. Multiply that by a process limit of 100 and you've handed an attacker a five-minute lease on your entire mail plant for the cost of a few TCP handshakes.
The defense is three concentric rings. Drop the cheap junk far from your workers; spend expensive smtpd forks only on sessions that actually talk.
Ring 1 — drop junk at the kernel before it costs a worker
A kernel packet drop costs microseconds. An smtpd fork costs milliseconds plus a couple of megabytes of RSS. That gap is the entire economic argument for pushing rejection outward: abusive sources should never reach userspace.
With nftables, cap concurrent connections per source IP and rate-limit new SYNs before they ever hit the listener:
nft
table inet mailedge {
chain input {
type filter hook input priority -10; policy accept;
# per-source concurrent connection cap on the mail ports
tcp dport { 25, 465, 587 } ct state new \
meter conn { ip saddr ct count over 20 } \
counter drop
# new-connection rate limit (SYN-flood dampener)
tcp dport 25 ct state new \
meter synrate { ip saddr limit rate over 10/second burst 20 packets } \
counter drop
}
}
Pair it with the sysctl knobs that keep the accept queue alive under a SYN burst:
The iptables equivalent is -p tcp --dport 25 -m connlimit --connlimit-above 20 -j DROP plus -m hashlimit for the rate.
The CGNAT trap. Do not set that ct count to 5 and walk away. Mobile carriers and CGNAT deployments put thousands of legitimate users behind a single IPv4. For a temp-mail service specifically, a large slice of real inbound arrives from shared egress IPs. A hard low cap at the kernel will silently guillotine a real carrier NAT during morning peak, and you'll never see it because the packets are just… gone. Keep Ring 1 generous — 20 to 30 concurrent — and do the tight, behavior-aware filtering one ring in, where the MTA can tell a talker from a squatter.
Ring 2 — make the MTA itself stingy with concurrency and time
This is the heart of it. Two families of controls: anvil rate and concurrency limits, and the timeout knobs that actually kill slow-loris.
postscreen runs *before* a full smtpd is spawned — pre-queue triage on port 25. It delays the real 220 greeting, watches for bots that speak before the greeting arrives (pregreet), scores the client against DNSBLs, and only then hands clean sessions to a real worker. It's the single highest-ROI control on this list because it absorbs the flood without ever forking smtpd.
Here's a main.cf block that closes the door on both attack variants:
The line that matters most is smtpd_timeout = 30s. The default 300s is what makes slow-loris viable; drop it and a drip session gets cut long before it ties up a worker for five minutes. smtpd_starttls_timeout closes the same hole during the TLS handshake, where a stalled client can otherwise hang a worker mid-negotiation.
smtpd_junk_command_limit = 3 and smtpd_error_sleep_time = 5s handle the other slow variant — a client that dribbles NOOP/RSET/VRFY to look busy without advancing the transaction. After three junk commands Postfix starts inserting a deliberate delay (a mini-tarpit), and smtpd_hard_error_limit eventually hangs up.
Enable postscreen in master.cf by pointing port 25 at it:
ini
smtp inet n - y - 1 postscreen
smtpd pass - - y - - smtpd
dnsblog unix - - y - 0 dnsblog
tlsproxy unix - - y - 0 tlsproxy
Raising default_process_limit buys headroom, but every extra slot is more committed RAM and a bigger footgun if you *don't* fix timeouts — you're just giving the attacker more workers to freeze. Fix the timeout first, then size the pool.
Running Haraka instead? The Node MTA has direct analogues. Its limit plugin caps concurrent connections, per-IP concurrency, and connection rate; set a low connection and data-line timeout to kill drip sessions before they hold a slot. The three-ring model is identical — only the config keys change.
Ring 3 — reputation and stateful eviction
Rings 1 and 2 make a single abusive connection cheap to reject. Ring 3 makes a *repeat* offender someone the kernel drops on sight, by feeding bans back out to Ring 1.
fail2ban watches the mail log for the fingerprints of this attack and bans at the firewall. The patterns that matter for connection abuse:
ini
# /etc/fail2ban/filter.d/postfix-flood.conf
[Definition]
failregex = lost connection after (CONNECT|EHLO|STARTTLS) from \S+\[<HOST>\]
too many connections from \S+\[<HOST>\]
\[<HOST>\]: Connection rate limit exceeded
timeout after \S+ from \S+\[<HOST>\]
Greylisting (postgrey). Temp-fail unknown triplets for 300 seconds on first contact. A real MTA retries and gets through; a flood bot almost never retries. It's a nearly free filter against the exact population you're fighting.
Weighted DNSBL scoring at postscreen. The *2 / *1 weights above mean no single list is a lone point of false-positive. Spamhaus zen has to agree with a second source before the threshold = 3 trips into enforce.
Whitelist yourself first. This is where operators self-inflict outages. Your own outbound relays, your monitoring probes, and known-good high-volume senders retry aggressively and legitimately — exactly the behavior a flood filter punishes. Add them to postscreen_access_list and a fail2ban ignoreip before you arm anything. For a temp-mail platform the traffic shape is lopsided: you receive far more than you send, so almost all the hardening ROI is on inbound port 25 — but the fastest way to page yourself at 3am is to ban your own transactional relay.
Watching it work — the signatures of an attack
You need to tell an attack from a Monday-morning burst. Baseline these, then alert on deviations.
bash
# live established sessions on :25 (compare to baseline)
ss -tan state established '( sport = :25 )' | wc -l
# how often clients are bailing mid-session
grep 'lost connection' /var/log/mail.log | wc -l
# anvil's own high-water marks
grep 'statistics:' /var/log/mail.log | grep 'connection count\|connection rate'
# queue depth — climbs when workers are starved
postqueue -p | tail -1
Signal
Normal
Under attack
Established conns on :25
~baseline
> 3× baseline
smtpd process count
well under limit
pinned at process_limit
Established-conn : messages-delivered
roughly balanced
inverted (many conns, near-zero delivery)
lost connection after CONNECT/EHLO
occasional
spiking continuously
The single most diagnostic metric is the connection-to-delivery ratio inverting: many live sessions, almost no mail moving. That's the fingerprint of workers held by clients that aren't talking. Ship the anvil statistics: lines and process counts to Prometheus via mtail or a grok exporter, and alert when established connections exceed 3× baseline or smtpd sits at its process limit for more than a minute.
Checklist — the SMTP edge hardening pass
Run this against a live MTA:
Kernel `ct count` cap set on 25/465/587 and CGNAT-aware (20–30, not 5).
SYN cookies on, somaxconn and tcp_max_syn_backlog raised.
postscreen enabled on :25 with pregreet (greet_action = enforce) plus weighted DNSBL.
`smtpd_timeout ≤ 30s` and smtpd_starttls_timeout ≤ 30s — the slow-loris fix.
fail2ban jail active and banning at nftables, not just logging.
postgrey on for inbound.
Own relays and monitoring whitelisted in postscreen access list and fail2ban ignoreip.
Port 587 submission separated from :25 — authenticated SASL users get their own, looser policy; never apply anonymous-:25 flood rules to logged-in submission.
Monitoring alert on established-conn spike and process-count-at-limit.
A tested panic mode — a one-command clamp to ct count over 2 plus an smtpd_error_sleep_time bump you can drop in during an active incident and pull afterward.
Get the timeout right and the rest is defense in depth. Leave it at 300 seconds and every other control on this list is decoration around an open door.