Back to articles
web servers
16 min readApril 11, 2026

Postfix Architecture: Reading the Source to Understand the Surface

Most Postfix articles tell you what to put in main.cf. This one opens the C source, traces a message through the queue with strace, and maps the attack surface of every process in the pipeline.

Postfix Architecture: Reading the Source to Understand the Surface

Postfix Architecture: Reading the Source to Understand the Surface

Most articles about Postfix tell you what to put in

main.cf
. There's nothing wrong with config recipes — they get servers running. But if you're trying to understand the security of Postfix, config recipes are completely the wrong altitude. The attack surface lives in the architecture: which process speaks to the network, which one is privileged, which one parses untrusted bytes, and where the boundaries between them are.

This 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 auxf
on a working server and you'll see something like:

master
 ├─ 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

postfix
user, with only the few that genuinely need elevated privileges keeping them. A bug in
smtpd
(the network-facing daemon) is constrained by the fact that
smtpd
runs as
postfix
inside a chroot.

Read

src/master/master.c
and
src/global/mail_proto.h
if you want the full picture. The short version: there's one supervisor (
master
), and it forks a small zoo of specialized daemons on demand.

The master daemon

master
is Postfix's supervisor. It reads
master.cf
(which you've definitely seen) at startup, opens the listening sockets it's configured for, and forks the right child for each incoming connection. It's also responsible for reaping dead children, restarting them, and enforcing per-service rate limits.

A simplified view of what

master.c
does:

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

postfix
user before doing any real work. This is the security boundary.

Attack surface of master: essentially zero from the network.

master
doesn't speak SMTP. It only speaks to its own children over local sockets. The only way to attack master directly is to compromise a child first, and then attack the IPC channel — which has been hardened progressively over the years and is now a well-defined protocol with strict input validation.

The 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),

master
forks
smtpd
. This is the daemon that speaks SMTP. It's the only Postfix process that ever reads bytes from an untrusted network peer.

src/smtpd/smtpd.c
is the file. It's about 5,000 lines and it's where most of the SMTP-protocol attack surface lives. The state machine looks roughly like:

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;
        /* ... */
    }
}

smtpd
is also where access control happens —
smtpd_relay_restrictions
,
smtpd_recipient_restrictions
,
smtpd_sender_restrictions
, all those config knobs you've configured (or copied from somewhere). Each of those is a list of checks evaluated in order, and if they're in the wrong order, you have an open relay. We'll come back to that.

Attack 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

smtpd
has accepted a message, it doesn't deliver it. It hands the message to
cleanup
(
src/cleanup/cleanup.c
), which does the work that has to happen on every message regardless of source:

  • Header rewriting (canonicalisation, masquerading)
  • Address rewriting via
    canonical
    ,
    virtual
    ,
    relocated
    maps
  • Header validation and conformance fixing (folding, encoding)
  • Adding the
    Received:
    trace header
  • Calling out to milters (mail filters) like OpenDKIM and rspamd
  • Computing the queue ID and writing the message to disk in
    incoming/

cleanup
is privileged in a different sense — it has write access to the queue directory. It runs as the
postfix
user, but a bug here can corrupt the queue or write attacker-controlled data into queue files in unexpected ways.

qmgr — the queue manager

Once a message is in

incoming/
, the queue manager (
src/qmgr/qmgr.c
) takes over.
qmgr
decides what to do with each message — deliver it now, defer it, hold it, or bounce it. It maintains per-destination connection caches and concurrency limits, and it's the thing that decides "Gmail is being slow today, retry this in 5 minutes."

The 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 malformed

Each subdirectory uses a hashed layout (

incoming/0/0/
,
incoming/0/1/
, etc.) for filesystem performance — directories with millions of files were a real problem in 1998 and the layout dates from then.

qmgr
doesn't speak the network. It hands messages to
smtp
(for outbound) or
local
/
lmtp
(for delivery to a local mailbox) via Postfix's internal IPC.

smtp / local / lmtp — the deliverers

  • smtp
    (
    src/smtp/smtp.c
    ) is the client-side SMTP daemon. When
    qmgr
    says "deliver this to gmail.com,"
    smtp
    opens 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.
  • local
    (
    src/local/local.c
    ) writes messages to mbox or Maildir on the local filesystem. It can also pipe messages through user-defined filters (
    .forward
    ).
  • lmtp
    is like
    smtp
    but speaks LMTP, used for handing messages to Dovecot's LMTP listener for final delivery into a Maildir or mdbox managed by Dovecot.

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:

bash
# 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:

bash
swaks --to me@example.com --from sender@external.test \
      --server mail.example.com:25

You'll see, in roughly this order:

  1. master
    accept()s on port 25, fork()s
  2. The child execs
    smtpd
  3. smtpd
    reads
    EHLO
    ,
    MAIL FROM
    ,
    RCPT TO
    ,
    DATA
    , the message body
  4. smtpd
    writes the message to
    cleanup
    over a Unix socket
  5. cleanup
    writes to a temp file, runs through milters, renames into
    incoming/
  6. qmgr
    picks it up, moves it to
    active/
  7. qmgr
    decides:
    smtp
    if it's outbound,
    local
    /
    lmtp
    if it's for a local user
  8. The deliverer either succeeds and removes the queue file, or fails and
    qmgr
    defers it

Each transition is logged in

/var/log/mail.log
with the queue ID, which is your single most useful piece of debugging data:

Apr 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: removed

That

4F8B22C0A
is the queue ID. Grep on it and you have the entire history of one message across every Postfix daemon that touched it.
postcat -q 4F8B22C0A
will show you the message itself if it's still in the queue.

The 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.

smtpd
is the biggest target by far, followed by
smtp
(which acts as a client and parses peer responses, including TLS handshakes and SMTP banners — there have been bugs here too).

Architecture-specific misconfigurations

Some misconfigs only make sense once you understand the architecture. Three of the worst:

smtpd_relay_restrictions
ordering. Postfix evaluates restriction lists in order, with the first matching rule winning. If you have:

smtpd_relay_restrictions = permit_mynetworks,
                           permit_sasl_authenticated,
                           reject_unauth_destination

That's correct — it permits local networks, permits authenticated users, and rejects everything else trying to relay. But a single edit moves

reject_unauth_destination
to the front and you've quietly changed nothing visible — until you realise that a misordered version like
reject_unauth_destination, permit_mynetworks
rejects everything outbound from local networks too. Order is everything.

Trusting

mynetworks
too widely.
mynetworks
is the set of IPs Postfix considers "us." Anything in
mynetworks
is allowed to relay without authentication. The default in some distributions includes the entire RFC 1918 private space, which is fine on a server with no shared private network. The moment you put it on a host that shares its private subnet with hundreds of customer VMs, you've got an open relay accessible to every one of them.

smtpd_recipient_restrictions
doing the work
smtpd_relay_restrictions
should.
Older Postfix configs put relay control in
smtpd_recipient_restrictions
. Modern Postfix has a separate
smtpd_relay_restrictions
evaluated before recipient restrictions specifically so that the relay decision is harder to accidentally bypass. Configs migrated from older versions often still have the old structure and it works — until a future edit to recipient restrictions changes its order and the relay control quietly weakens. If you're on Postfix 2.10+, your relay rules belong in
smtpd_relay_restrictions
.

Gotcha: Postfix's

chroot=y
flag in
master.cf
puts daemons in a chroot. That's good for
smtpd
. But chroot interacts badly with libnss-style name service modules (LDAP, NIS, mysql) — your auth backend may need files copied into the chroot, or you need to switch to a
proxymap
-based lookup. The default Debian Postfix configs handle this; many tutorials don't.

What this gives you

Read this far and you should be able to:

  • Look at a
    mail.log
    line and know which daemon emitted it
  • 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

smtpd
that didn't escalate further because of those boundaries. Post 6 walks through the pattern.

Discussion

0 comments

Share your thoughts

No comments yet. Be the first to share your thoughts!