Postfix Architecture: Reading the Source to Understand the Surface
Most articles about Postfix tell you what to put in
main.cfThis post opens the Postfix source code (postfix.org/source — the official release tarballs), traces a single message through the daemons it visits, and maps the attack surface as we go. By the end you'll understand why Postfix is structured the way it is, and you'll be able to read its log lines like a deck of cards being dealt.
A note on versions: I'm referring to Postfix 3.x throughout. The architecture has been stable since the original Wietse Venema design from 1998, so most of this applies to any version you'll find in the wild.
Why Postfix isn't one process
The first thing that surprises people coming from Sendmail is that Postfix is many processes. Run
ps auxfmaster
├─ pickup
├─ qmgr
├─ smtpd (when a connection comes in)
├─ cleanup (when a message is being processed)
├─ smtp (when delivering outbound)
└─ local (when delivering to a local mailbox)This isn't an accident — it's the entire security argument. Postfix's multi-process design exists for one reason: privilege separation. Sendmail in its 1990s form was a single setuid-root binary that did everything. A bug anywhere meant a root compromise. Postfix breaks the work into ten-plus daemons, most of which run as the unprivileged
postfixsmtpdsmtpdpostfixRead
src/master/master.csrc/global/mail_proto.hmasterThe master daemon
mastermaster.cfA simplified view of what
master.c/* Conceptual — see src/master/master.c for the real thing */
for (;;) {
event_loop(); /* select/poll on all listening sockets */
if (incoming_connection_on(smtpd_socket))
fork_child("smtpd", smtpd_socket);
if (queue_has_work())
fork_child("qmgr", null);
reap_dead_children();
}Master itself runs as root (it has to — it's the one that opens privileged ports like 25). But it does almost nothing else as root. The moment a child is forked, the child drops to the
postfixAttack surface of master: essentially zero from the network.
masterThe pipeline: smtpd → cleanup → qmgr → smtp/local/lmtp
Here's what happens to one inbound message, in the order the processes touch it.
smtpd — the network face
When a remote MTA opens a connection on port 25 (or a client opens 587),
mastersmtpdsrc/smtpd/smtpd.c/* Conceptual SMTP command dispatch in smtpd */
while (smtp_get_command(&cmd, peer) > 0) {
switch (cmd.verb) {
case HELO: smtpd_helo(state, &cmd); break;
case MAIL: smtpd_mail(state, &cmd); break;
case RCPT: smtpd_rcpt(state, &cmd); break;
case DATA: smtpd_data(state, &cmd); break;
case STARTTLS: smtpd_starttls(state); break;
/* ... */
}
}smtpdsmtpd_relay_restrictionssmtpd_recipient_restrictionssmtpd_sender_restrictionsAttack surface of smtpd: maximum. It parses arbitrary bytes from arbitrary internet senders. Every SMTP command parser, every TLS handshake, every base64-decoded SASL credential, every header-line continuation — it all happens here. Historically, the most severe Postfix bugs (and email-server bugs generally) have lived in the network-facing parser. Post 6 covers the patterns.
cleanup — the rewriter and validator
Once
smtpdcleanupsrc/cleanup/cleanup.c- Header rewriting (canonicalisation, masquerading)
- Address rewriting via ,
canonical,virtualmapsrelocated - Header validation and conformance fixing (folding, encoding)
- Adding the trace header
Received: - Calling out to milters (mail filters) like OpenDKIM and rspamd
- Computing the queue ID and writing the message to disk in
incoming/
cleanuppostfixqmgr — the queue manager
Once a message is in
incoming/src/qmgr/qmgr.cqmgrThe queue itself lives in
/var/spool/postfix//var/spool/postfix/
├── incoming/ # cleanup just wrote a message here
├── active/ # qmgr is currently working on it
├── deferred/ # delivery failed temporarily, retry later
├── hold/ # admin manually held it (postsuper -h)
└── corrupt/ # cleanup decided this message was malformedEach subdirectory uses a hashed layout (
incoming/0/0/incoming/0/1/qmgrsmtplocallmtpsmtp / local / lmtp — the deliverers
- (
smtp) is the client-side SMTP daemon. Whensrc/smtp/smtp.csays "deliver this to gmail.com,"qmgropens a connection to a Gmail MX, does TLS, and speaks SMTP as a client. The same code path is used for both outbound delivery and relay.smtp - (
local) writes messages to mbox or Maildir on the local filesystem. It can also pipe messages through user-defined filters (src/local/local.c)..forward - is like
lmtpbut speaks LMTP, used for handing messages to Dovecot's LMTP listener for final delivery into a Maildir or mdbox managed by Dovecot.smtp
Tracing a message live
Here's the part that turns the diagram into something concrete. Send a message to your own server and follow it:
# Watch the master and all children. Use pgrep, not pidof — `pidof master`
# matches anything literally named "master" on the box (nginx workers, etc.).
sudo strace -f -p $(pgrep -f 'postfix/master') -e trace=network,read,write 2>&1 | \
grep -E 'smtpd|cleanup|qmgr|smtp\b'In another terminal:
swaks --to me@example.com --from sender@external.test \
--server mail.example.com:25You'll see, in roughly this order:
- accept()s on port 25, fork()s
master - The child execs
smtpd - reads
smtpd,EHLO,MAIL FROM,RCPT TO, the message bodyDATA - writes the message to
smtpdover a Unix socketcleanup - writes to a temp file, runs through milters, renames into
cleanupincoming/ - picks it up, moves it to
qmgractive/ - decides:
qmgrif it's outbound,smtp/localif it's for a local userlmtp - The deliverer either succeeds and removes the queue file, or fails and defers it
qmgr
Each transition is logged in
/var/log/mail.logApr 27 10:14:23 mail postfix/smtpd[12345]: connect from external.test[1.2.3.4]
Apr 27 10:14:23 mail postfix/smtpd[12345]: 4F8B22C0A: client=external.test[1.2.3.4]
Apr 27 10:14:23 mail postfix/cleanup[12346]: 4F8B22C0A: message-id=<...>
Apr 27 10:14:23 mail postfix/qmgr[12347]: 4F8B22C0A: from=<sender@external.test>, size=1234, nrcpt=1
Apr 27 10:14:24 mail postfix/local[12348]: 4F8B22C0A: to=<me@example.com>, status=sent
Apr 27 10:14:24 mail postfix/qmgr[12347]: 4F8B22C0A: removedThat
4F8B22C0Apostcat -q 4F8B22C0AThe attack surface map
Now we can pin down where attacks actually land:
| Daemon | Network exposure | Privileges | Untrusted input | Historical bug count | |--------|-----------------|------------|-----------------|---------------------| | master | Listens but doesn't read | root | None directly | Very low | | smtpd | Accepts internet connections | postfix, chroot | All SMTP traffic | High | | cleanup | Local IPC only | postfix | Headers and bodies | Medium | | qmgr | Local IPC only | postfix | Trusted internal | Low | | smtp | Outbound connections | postfix | Server responses | Medium | | local | None | postfix | User .forward files | Low (config-driven) |
The pattern: if it speaks to the network, it's where the bugs live.
smtpdsmtpArchitecture-specific misconfigurations
Some misconfigs only make sense once you understand the architecture. Three of the worst:
smtpd_relay_restrictions
smtpd_relay_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_destinationThat's correct — it permits local networks, permits authenticated users, and rejects everything else trying to relay. But a single edit moves
reject_unauth_destinationreject_unauth_destination, permit_mynetworksTrusting mynetworks
mynetworksmynetworkssmtpd_recipient_restrictionssmtpd_relay_restrictions
smtpd_recipient_restrictionssmtpd_relay_restrictionssmtpd_relay_restrictionsGotcha: Postfix's
chroot=ymaster.cfsmtpdproxymapWhat this gives you
Read this far and you should be able to:
- Look at a line and know which daemon emitted it
mail.log - Trace a stuck message through the queue (,
postqueue -p,postcat)postsuper - Reason about which daemon a CVE affects and therefore how exposed you are
- Understand why Postfix's privilege separation matters and what bypassing it would look like
Post 3 does the same exercise for Dovecot, which has a similar design philosophy but a different process layout — and a much larger pre-auth attack surface, because IMAP and POP3 sessions are inherently long-lived.
If you remember nothing else, remember this: Postfix's security comes from the boundaries between processes, not from any one process being bug-free. Every CVE history of Postfix is, fundamentally, a history of bugs in
smtpdDiscussion
0 comments
Share your thoughts
No comments yet. Be the first to share your thoughts!