SMTP Smuggling and CRLF Injection: Locking Down Line Endings in Postfix and Exim
SMTP smuggling isn't a spam problem — it's two MTAs disagreeing on where a message ends. Here's the byte-level mechanism, why SPF/DKIM/DMARC never see the forged message, and the exact Postfix and Exim config that closes it.
EvilMail TeamJuly 24, 202610 min read
Picture a single SMTP session that delivers two messages. The first is unremarkable and legitimate: an external sender, a plain body, everything above board. The second one never touched a mail client — it rode in hidden inside the body of the first, and it arrives stamped MAIL FROM:<admin@your-domain>, lands in a user's inbox looking internal, and passes DMARC on the way in. SPF passed. DKIM passed. DMARC said p=reject and still let it through. None of those checks were broken. They just never looked at the second message.
That's SMTP smuggling. It isn't a spam-filter gap or a weak policy — it's two mail servers disagreeing about which bytes end the DATA phase of an SMTP transaction. When your inbound MTA accepts a message terminator that a peer treats as ordinary body content (or the reverse), an attacker can wedge a forged message into that gap. Timo Longin of SEC Consult disclosed this publicly on December 18, 2023, with working exploits against GMX, Ionos, Cisco Secure Email Gateway, and default-configured Postfix. If you run a relay that carries mail for domains you don't personally own — which is exactly what we do at evilmail.pro — a smuggling bug turns your own infrastructure into a spoofing engine for every one of those domains.
How DATA termination actually works (and where it breaks)
RFC 5321 §4.1.1.4 is unambiguous about how the DATA phase ends. After the client sends DATA and gets a 354
, the message continues until the server sees exactly five bytes:
0x0D 0x0A 0x2E 0x0D 0x0A
CR LF . CR LF
A bare CR or a bare LF inside the body is not a terminator. Only the full <CR><LF>.<CR><LF> sequence is. A companion rule — dot-stuffing, or "transparency" — keeps a literal . at the start of a body line from being mistaken for the end marker: the sender prepends an extra . to any line beginning with ., and the receiver strips it back off. Done correctly, a body can contain <CR><LF>.<CR><LF> as text without ever ending the message.
The vulnerability is a parser that accepts *non-standard* end-of-data sequences. Lenient MTAs have been caught treating all of these as "end of message":
<LF>.<LF> — bare linefeeds, no carriage returns
<CR>.<CR> — bare carriage returns
<LF>.<CR><LF> — a mixed sequence
Here's the asymmetry that makes it exploitable. A *sending* MTA that considers <LF>.<LF> to be ordinary body bytes forwards those bytes downstream untouched. A *receiving* MTA that considers <LF>.<LF> to be end-of-data stops reading the message right there — and then interprets whatever follows as fresh SMTP commands: a new MAIL FROM, RCPT TO, DATA, and a second body. One server thinks it saw one message; the other split it into two. That disagreement *is* the smuggle.
Two failure modes: inbound and outbound
You can be on either side of this, and the outbound side is the one operators forget.
Inbound smuggling — your receiver is too lenient. An attacker connects to your MX, sends a payload with an embedded <LF>.<LF>, and your Postfix splits it into two messages. The second appears to originate inside your own session, inherits that connection's context, and looks internal. This is the classic spoofing scenario.
Outbound smuggling — your sender is too lenient. Someone submits mail through you — an authenticated user, a web app, a compromised script — containing bare LF sequences. If your MTA doesn't normalize or reject them, it forwards the raw bytes to a downstream provider. If *that* provider parses <LF>.<LF> as end-of-data, the split happens on their end and you were the delivery vehicle. "We hand outbound off to a big provider, so we're fine" is exactly backwards — being the strict half doesn't help when you're the lenient sender feeding a strict receiver.
Default Postfix was vulnerable because smtpd_data_restrictions didn't reject bare newlines out of the box, and the cleanup daemon happily passed bare-LF content through. That's CVE-2023-51764.
Fixing Postfix
Wietse Venema's response was a new parameter: smtpd_forbid_bare_newline. It ships on (as normalize) by default in Postfix 3.9, and the parameter was backported to the 3.5, 3.6, 3.7 and 3.8 branches — though on those older branches it defaults to no, so a backported build alone doesn't protect you until you set it. Check what you're running and what the default is:
bash
postconf mail_version
postconf -d smtpd_forbid_bare_newline
postconf smtpd_forbid_bare_newline # your live value
If it's empty or no, put this in /etc/postfix/main.cf:
ini
# Reject any SMTP command line ending in a bare CR or LF
smtpd_forbid_bare_newline = reject
# Don't punish trusted internal clients (optional)
smtpd_forbid_bare_newline_exclusions = $mynetworks
# Belt and braces against pipelining-based injection
smtpd_data_restrictions = reject_unauth_pipelining
Then postfix reload. No full restart needed.
The two meaningful values behave differently. normalize silently rewrites bare CR/LF to proper CR+LF and lets the message through — safe, invisible to legitimate senders, and the sensible default for a general MX. reject answers with 550 5.5.2 bare <LF> received and drops the session — stricter, better for a customer-facing relay where you'd rather bounce a malformed submission than guess at intent, and it gives you a clean log signal to alert on. On the relays that carry customer domains at evilmail.pro we run reject, because a normalized-away anomaly is a missed detection. This one parameter closes both the receiver side and the forwarding side, which is why it matters more than any ACL you could hand-write.
Fixing Exim
Exim caught CVE-2023-51766, fixed in 4.97.1 and in the patched distro builds that followed. Check your version and its build options:
bash
exim -bV | head -3
A patched Exim rejects bare LF at the SMTP layer. Beyond the upgrade, tighten the pipelining surface and, if you want body-level defense in depth, keep raw newlines visible so a DATA-phase ACL can actually inspect them. In /etc/exim4/exim4.conf (or the split config under /etc/exim4/conf.d/):
# Only advertise PIPELINING to hosts you trust (custom list separator
# because IPv6 literals contain colons)
pipelining_advertise_hosts = <; 127.0.0.1 ; ::1
# Keep newlines in $message_body — without this Exim turns them into
# spaces and the ACL match below would silently never fire
message_body_newlines = true
begin acl
acl_smtp_data:
# Reject bodies that carry what looks like an embedded SMTP transaction
deny condition = ${if match {$message_body}{(?i)\n(MAIL FROM|RCPT TO|DATA)\b} }
message = Malformed message body rejected
accept
Note that $message_body only holds the first message_body_visible bytes (500 by default), so treat the ACL as a tripwire, not a complete filter — the version upgrade is the real fix. And mind cutthrough delivery (control = cutthrough_delivery): it streams your inbound connection straight to the next hop before the message is fully accepted, so if you relay to a lenient downstream you can re-open the outbound smuggling path even on a patched Exim. If you use cutthrough, make sure the downstream is strict too.
CRLF injection: the same bug, one layer up
Strip away the SMTP framing and the identical failure lives in application code. Any web-to-mail path — a contact form, a "share this" feature, a password-reset trigger — that drops user input into a mail *header* without filtering CR and LF is vulnerable to header injection. The attacker sends %0d%0a (URL-encoded CR+LF) inside a Subject, From, Reply-To or To value and now controls new header lines: an extra Bcc: to a hundred recipients, or a blank line followed by an entirely attacker-authored body.
The fix is one rule: never let untrusted input reach a header without rejecting or stripping CR (0x0D) and LF (0x0A) and their encoded forms.
php
// FIXED — reject anything with an embedded newline, then let a real
// library validate the address shape
foreach (['email','subject'] as $f) {
if (preg_match('/[\r\n]/', $_POST[$f] ?? '')) {
http_response_code(400);
exit('Invalid input');
}
}
$mail = new PHPMailer(true);
$mail->addReplyTo($_POST['email']); // validates the address itself
$mail->Subject = $_POST['subject'];
Most "our contact form is sending spam" tickets are this exact bug. The form was a header-injection oracle, and someone found it before you did.
Verifying you're not exploitable
Don't trust the config — prove it with a raw session. swaks is a convenient first pass, but note that it normalizes lone LF to CR+LF by default, which can quietly defeat the very test you're running:
Read the transcript. A 250 2.0.0 Ok on the *second* MAIL FROM means the bare-LF terminator was honored and you're smuggle-able. A rejection or a single queued message means you're clean.
Operator checklist
Set smtpd_forbid_bare_newline = reject (or normalize for a general MX) and postfix reload.
Confirm it took: postconf smtpd_forbid_bare_newline and postconf -d | grep bare_newline.
Run Postfix ≥ 3.9, or a 3.5–3.8 build with the backport *and the parameter explicitly set*; run Exim ≥ 4.97.1. Verify with postconf mail_version and exim -bV.
Audit every web-to-mail script for CR/LF in user-controlled headers — reject on /[\r\n]/, prefer PHPMailer over raw mail().
Enforce DMARC p=reject on every hosted domain, and treat it as defense in depth, not the fix — smuggling bypasses it.
Test inbound monthly with a crafted bare-LF session and read the transcript; don't assume.
Log and alert on bare <LF> received rejections — that's an attacker probing, not a misconfigured client.
Confirm your outbound smarthost is strict too; being the lenient sender makes you the delivery half of someone else's attack.
Get strict about line endings at every hop, or someone downstream defines them for you.