SMTP Smuggling on Debian 13: Postfix Defenses

10 min read

SMTP smuggling lets an attacker with an ordinary mailbox at one provider deliver mail to your server under someone else's sender address, and the mail can still pass SPF. It works because two mail servers disagree about where a message ends. SEC Consult published it in December 2023. Postfix is tracked as CVE-2023-51764, Sendmail as CVE-2023-51765 and Exim as CVE-2023-51766.

Debian 13 (trixie) ships Postfix 3.10.13-0+deb13u1, which the Debian security tracker lists as fixed. A fixed package isn't the same as a protected server, though. The protection is a setting, and an old main.cf or a -o override in master.cf can switch it off without any warning. This post explains how SMTP smuggling works, how to check that your Postfix really blocks it, and how to test that from outside.

How SMTP smuggling works

RFC 5321 says the message data after DATA ends with <CR><LF>.<CR><LF>, a dot alone on a line with proper CRLF line endings. Sendmail accepted a bare <LF> as a line ending for decades. Postfix, Exim and others copied that for compatibility. So a receiving server might also treat sequences like <LF>.<CR><LF> or <LF>.<LF> as the end of the data.

The attack needs two servers that behave differently:

  1. Service A is the outbound server at the attacker's provider. The attacker logs in normally. A copies the odd sequence through to the next hop unchanged, because to A it's just message content.
  2. Service B is the receiving MX, for example your Postfix. B sees <LF>.<CR><LF>, ends the first message there, and reads everything after it as new SMTP commands on the same connection.

The attacker writes a body like this. The first part is a normal message. Then comes the non-standard terminator, then a full second transaction: MAIL FROM with another domain hosted at A, RCPT TO with a victim at B, DATA, forged headers, and the real terminator. A sends one message. B receives two.

Why the spoofed mail passes SPF

The smuggled message really does come from A's IP address. If the forged sender domain authorises A in its SPF record, which is common when many tenants share a large platform's outbound servers, SPF passes. If the forged From: header uses that same domain, the SPF result is also aligned. Under RFC 9989, which obsoleted RFC 7489 in May 2026, DMARC passes when either SPF or DKIM gives an aligned pass. So a smuggled message can get dmarc=pass without any DKIM signature. SEC Consult reported exactly that in one of their tests.

Smuggling also needs pipelining: B has to read the extra commands from the data stream instead of waiting for each reply. That's why the upstream short-term workaround targets pipelining and BDAT.

The public PoC tooling is in SEC Consult's SMTP-Smuggling-Tools repository, linked from their write-up. You don't need it to check your own server; the self-test below is enough. The Postfix author has also warned that some scanners report sequences that a real outbound provider would never pass through, such as patterns that RFC 5321 dot-stuffing turns harmless. A red result from a scanner is a reason to investigate, not proof that you're vulnerable.

Postfix SMTP smuggling settings on Debian 13

The fix went through two rounds upstream, and the default only changed in 3.9:

Postfix versionsmtpd_forbid_bare_newline valuesDefault
3.8.4, 3.7.9, 3.6.13, 3.5.23yes, nono (vulnerable)
3.8.5, 3.7.10, 3.6.14, 3.5.24adds normalize, rejectno (vulnerable)
3.9 and later (trixie: 3.10.13)adds notenormalize

What each value does, according to postconf(5) on Debian 13:

  • normalize: requires the standard end-of-data sequence. Bare <LF> is still accepted elsewhere and treated as <CR><LF>, so ordinary mail from sloppy clients still gets through, but a smuggled terminator doesn't end the message.
  • note (3.9+): the same as normalize, and it also logs notes=bare_lf on the disconnect line when a session contained bare LFs. Use this if you want to see what's happening.
  • reject: refuses commands or content lines with bare <LF>, logs bare <LF> received and replies with smtpd_forbid_bare_newline_reject_code (default 550). It's stricter and more likely to drop legitimate mail.
  • yes is an alias for normalize. no restores the old behaviour, which is vulnerable.

Clients that match smtpd_forbid_bare_newline_exclusions skip all of this. Its default is $mynetworks.

How to detect exposure and attempts

Check defaults, explicit settings and overrides

First compare the built-in defaults with what your configuration actually sets:

postconf -d cleanup_replace_stray_cr_lf smtpd_forbid_bare_newline smtpd_forbid_bare_newline_exclusions smtpd_forbid_unauth_pipelining
postconf -n | grep -E 'bare_newline|forbid_unauth|mynetworks|discard_ehlo|stray_cr_lf|receive_override'
postconf smtpd_forbid_bare_newline mynetworks

On a stock trixie install, postconf -d should show yes, normalize, $mynetworks and yes, in that order. If postconf -n shows smtpd_forbid_bare_newline = no, the protection is off. That usually comes from a main.cf carried over from bookworm or from a tutorial. To list every setting that differs from the defaults, use the comparison from postconf(1):

LANG=C comm -23 <(postconf -n) <(postconf -d)

Check every smtpd service in master.cf

main.cf only sets the global value. Each smtpd entry in master.cf (port 25 smtp, submission on 587, smtps on 465, and content-filter re-injection ports) can override it with -o. postconf -P lists those per-service overrides as service/type/parameter=value:

postconf -P | grep -E 'bare_newline|forbid_unauth|mynetworks|discard_ehlo|receive_override'
postconf -Mf smtp/inet
postconf -Mf submission/inet
postconf -Mf smtps/inet

If nothing is printed for smtp/inet, port 25 uses the global value. If you find a stray override, remove it like this:

postconf -PX 'smtp/inet/smtpd_forbid_bare_newline'

Two more things to look for. cleanup_replace_stray_cr_lf = yes stops your own server from acting as service A: it replaces stray CR and LF characters in message content with spaces before signing. postconf(5) notes that it's turned off when receive_override_options = no_header_body_checks applies, which is common in amavis or other content-filter setups. Check the postconf -P output for that.

Logs and headers

On Debian 13, Postfix logs through syslog into the journal (and /var/log/mail.log if rsyslog is installed). These are the lines that matter:

journalctl -t postfix/smtpd --since '-7d' | grep -E 'notes=bare_lf|bare <LF> received|improper command pipelining'

notes=bare_lf (with note) shows which clients send bare LFs. A rejection logs bare <LF> received. Pipelining violations caught by smtpd_forbid_unauth_pipelining end with a 554 synchronization error, and the unexpected input is logged. A sudden burst of these from a large provider's outbound IPs is worth a closer look.

For individual messages, read the Authentication-Results header your filter (OpenDMARC, rspamd and so on) adds. A suspicious pattern looks like this:

Authentication-Results: mx.example.net;
 spf=pass smtp.mailfrom=bank.example;
 dkim=none;
 dmarc=pass header.from=bank.example

SPF passes and DMARC passes, but there's no DKIM signature from a domain that normally signs everything, and the message arrived from a shared provider. Compare header.d of any DKIM signature with header.from. An aligned, valid DKIM signature is much harder to smuggle than an IP-based SPF pass.

How to defend Postfix against SMTP smuggling

On trixie, set the values explicitly so a later edit can't silently undo them. Choose note to get logging on top of the normalize behaviour:

# SMTP smuggling (CVE-2023-51764)
smtpd_forbid_bare_newline = note
smtpd_forbid_bare_newline_exclusions = $mynetworks
smtpd_forbid_unauth_pipelining = yes
cleanup_replace_stray_cr_lf = yes

Keep mynetworks small, because anything in it bypasses the check. Loopback plus the hosts that actually relay through you is usually enough. Adding a whole Docker bridge range or an office /16 widens the exclusion. If you're sorting out who should be in mynetworks at all, see the Postfix relay access denied guide. You can also set a narrower list just for this check:

smtpd_forbid_bare_newline_exclusions = 127.0.0.0/8, [::1]/128

Pipelining and CHUNKING workaround

Upstream's short-term workaround blocks unauthorised command pipelining and BDAT. It's for hosts that can't be upgraded yet. Upstream also suggests disabling CHUNKING on 3.9+, where pipelining enforcement is already on by default:

# Postfix 3.9+ (and 3.8.1, 3.7.6, 3.6.10, 3.5.20)
smtpd_forbid_unauth_pipelining = yes
smtpd_discard_ehlo_keywords = chunking, silent-discard
# Older versions: smtpd_data_restrictions = reject_unauth_pipelining

This doesn't stop malformed line endings on its own. The bare-newline setting is the actual fix. Badly written clients may break with pipelining enforcement, but upstream says that's rare on the Internet.

Warning: a reload changes how live SMTP sessions are handled. A broken main.cf can stop mail delivery, and reject can bounce legitimate mail. Validate the configuration first, then reload (don't restart), and watch the log:

postfix check
postconf smtpd_forbid_bare_newline
systemctl reload postfix
journalctl -t postfix/smtpd -f

DMARC alignment as the second layer

For your own domains, DKIM-sign all outbound mail and publish a DMARC policy. Then check which shared platforms your SPF record include:s, because every customer of those platforms is a possible service A. Fewer shared includes means fewer places a smuggled message can get an aligned SPF pass. As a receiver, a filter that scores dkim=none on mail from domains that always sign catches some of what gets past SPF.

Self-test: prove your server rejects bare LF

Run this only against a mail server you own or are authorised to test. Run it from a host outside that server's mynetworks, because testing from localhost hits the exclusion and tells you nothing. Many cloud and home ISPs block outbound port 25, so use a VPS that can reach it. The script uses Debian's netcat-openbsd without -C, because -C would convert the bare LF into CRLF. It waits between commands so the pipelining checks don't interfere.

#!/bin/bash
# Bare-LF end-of-data self-test. Own/authorised servers only.
set -u
mx="${1:?usage: bare-lf-test.sh MX SENDER RECIPIENT}"
sender="${2:?usage: bare-lf-test.sh MX SENDER RECIPIENT}"
rcpt="${3:?usage: bare-lf-test.sh MX SENDER RECIPIENT}"

{
  sleep 2
  printf 'EHLO selftest.invalid\r\n'
  sleep 1
  printf 'MAIL FROM:<%s>\r\n' "$sender"
  sleep 1
  printf 'RCPT TO:<%s>\r\n' "$rcpt"
  sleep 1
  printf 'DATA\r\n'
  sleep 1
  printf 'Subject: bare LF self-test\r\n\r\nline one\n.\r\n'
  echo '>>> sent LF.CRLF - a 250 reply now means VULNERABLE' >&2
  sleep 5
  printf 'line two\r\n.\r\n'
  echo '>>> sent real CRLF.CRLF' >&2
  sleep 2
  printf 'QUIT\r\n'
} | nc -w 15 "$mx" 25
bash bare-lf-test.sh mx.example.org probe@example.net postmaster@example.org

On a protected server, nothing comes back after the first marker. The only 250 ... queued reply arrives after the real terminator. Here's an example; your banner and queue ID will differ:

354 End data with <CR><LF>.<CR><LF>
>>> sent LF.CRLF - a 250 reply now means VULNERABLE
>>> sent real CRLF.CRLF
250 2.0.0 Ok: queued as 4ZxQ1k2Lm3z9
221 2.0.0 Bye

If a 250 arrives straight after the first marker, the server treated <LF>.<CR><LF> as end-of-data and is vulnerable. With reject you get a 550 instead. With note, the server's disconnect line for your test IP should show notes=bare_lf. Repeat the test against every port that runs smtpd and accepts unauthenticated mail. To watch the server log during tests, the journal approach in Fail2ban on Debian 13 with nftables works the same way.

Takeaways

  • Check dpkg -l postfix: trixie should show 3.10.13-0+deb13u1 or later.
  • Run postconf -n and make sure nothing sets smtpd_forbid_bare_newline = no. Set note or normalize explicitly.
  • Run postconf -P and look for -o overrides on smtp, submission, smtps and re-injection services.
  • Keep mynetworks (and so the exclusions) as small as possible.
  • Keep cleanup_replace_stray_cr_lf = yes, and check whether no_header_body_checks disables it.
  • Run the bare-LF self-test from an outside host and expect one 250, sent only after the real CRLF.CRLF.
  • DKIM-sign everything, publish DMARC, trim shared SPF includes, and treat spf=pass plus dkim=none from always-signing domains as suspicious.

Sources

Comments