Shrinking the Blast Radius: systemd Sandboxing and chroot for Postfix, Dovecot and Rspamd
A single RCE in a milter or spam filter shouldn't hand an attacker your whole box. Here's how to confine the real EvilMail stack — Postfix, Dovecot, Rspamd, OpenDKIM — with kernel-backed systemd sandboxing and selective chroot, measured with systemd-analyze security before and after.
EvilMail TeamJuly 25, 202612 min read
Picture the worst realistic day: an attacker lands code execution inside your spam filter. Not a kernel bug, not a Postfix zero-day — a poisoned dependency in a Python header rewriter, or a memory-corruption bug in a milter that parses attacker-controlled MIME. This is not hypothetical. Exim's CVE-2019-10149 turned a crafted recipient address into root RCE through deliver_message(); CVE-2023-42115 was an out-of-bounds write reachable over plain SMTP. The mail edge is one of the few services on your box that runs logic over bytes strangers send you, unauthenticated, all day.
The question that decides how bad that day gets is not "can I be compromised" — you can — but "how far does the attacker reach from the process they landed in." That is blast radius, and most Postfix/Dovecot/Rspamd installs size it wrong. Postfix's smtpd already drops to postfix:postfix via privilege separation, so people assume the whole stack is confined. It isn't. Rspamd workers, custom milters, LMTP delivery, Sieve interpreters and cleanup helpers each carry their own filesystem reach, syscall surface, capability set and network egress — and by default many of them can walk /home, dial out to the internet, ptrace their neighbors and write executable memory.
Blast radius is concrete. It is four things: filesystem read/write reach, syscall surface
,
network egress
, and
capability set
(plus the ability to see and trace other processes). Shrink all four and a filter RCE becomes an annoyance instead of a pivot to your database. In 2026 you do not need a hand-rolled chroot skeleton or an AppArmor profile to do it. systemd already ships a kernel-backed sandbox — seccomp, mount and network namespaces, capability bounding,
ProtectSystem
— that you bolt onto any service with a drop-in override in about ten minutes per daemon.
Measure first, or you're doing theater
Hardening you cannot measure is decoration. Before touching a single unit, get a baseline:
Each unit gets an aggregate exposure score from 0.0 to 10.0: roughly 0–4 is OK, 4–6 medium, 6–8 exposed, 8–10 unsafe. A stock rspamd.service typically lands around 9.6 UNSAFE; the Postfix master hovers near 9.5. Then read the full per-directive table:
That table lists every sandboxing directive with a PASS/EXPOSED verdict and a weighted contribution to the score. Treat the number as a heuristic, not a grade — it does not know that Rspamd legitimately needs DNS, and it will happily reward you for locking down a directive that breaks mail. But it is the fastest way to find low-hanging fruit and to prove a delta. The workflow is a loop, and you never skip the fourth step:
1.Baseline the score.
2.Apply a drop-in override.
3.daemon-reload, restart, re-measure the delta.
4.Functionally test real mail flow and watch the journal.
The sandbox toolbox, ranked by payoff
Not all directives are worth the same. Ranked by value-for-risk on a mail box:
`ProtectSystem=strict` + `ReadWritePaths=` — the single biggest win. The entire filesystem becomes read-only except the paths you name. For Rspamd that's /var/lib/rspamd; for Postfix /var/spool/postfix and /var/lib/postfix. An attacker in a strict service cannot drop a binary in /usr/local/bin or edit a cron file.
`ProtectHome=true` and `PrivateTmp=true` — /home, /root and /run/user vanish from the service's mount namespace; it gets a private /tmp and /var/tmp. That kills an entire class of /tmp symlink races and cross-service /tmp snooping.
`PrivateDevices=true` (empty /dev, no CAP_MKNOD), `ProtectKernelTunables=true`, `ProtectKernelModules=true`, `ProtectKernelLogs=true`, `ProtectControlGroups=true`, `ProtectClock=true`, `ProtectProc=invisible` — cheap, almost never break anything, each closes a well-worn escalation path.
`NoNewPrivileges=true` — sets the kernel no_new_privs bit, so no setuid/setcap binary can raise privileges. Powerful, but it breaks any daemon whose master legitimately drops-and-spawns as another user (Postfix master, Dovecot master). Scope it to leaf services, not privilege-separating parents.
`CapabilityBoundingSet=` — an empty value drops every capability. For a filter or milter that binds nothing privileged, empty is correct. Keep CAP_NET_BIND_SERVICE only where a service actually binds :25/:465/:587/:993, and CAP_SETUID CAP_SETGID CAP_CHOWN only on masters that switch user.
`SystemCallFilter=@system-service` then `~@privileged @resources @mount @debug @swap @reboot`, with `SystemCallArchitectures=native` and `SystemCallErrorNumber=EPERM` — the real syscall-surface reducer, a seccomp-BPF allowlist. @privileged alone strips dozens of syscalls a filter never needs.
`RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` — drops AF_NETLINK, AF_PACKET and friends, cutting off raw-socket and route-scraping tricks.
`IPAddressDeny=any` + `IPAddressAllow=` — eBPF-based egress control. On a milter that should never dial out, IPAddressDeny=any is a hard wall against exfiltration and reverse shells.
`PrivateNetwork=true` — a private loopback-only network namespace. Perfect for pure UNIX-socket helpers, catastrophic for anything that talks to Redis, Postgres or DNS.
`MemoryDenyWriteExecute=true`, `LockPersonality=true`, `RestrictRealtime=true`, `RestrictSUIDSGID=true`, `RestrictNamespaces=true`, `RemoveIPC=true`, `ProtectHostname=true` — the finishing set. MemoryDenyWriteExecute enforces W^X and stops most shellcode, but it breaks any JIT — flag it hard for Rspamd (LuaJIT and PCRE JIT), covered below.
Real drop-ins that move the score
Overrides go in /etc/systemd/system/<unit>.d/override.conf via systemctl edit, so package upgrades never clobber them. Here is a full Rspamd override that drops the score from ~9.6 to under 2.0. Note what is deliberately allowed — Rspamd is useless without DNS and Redis:
OpenDKIM is stricter still. In this stack it signs outbound mail over a milter socket and leaves DKIM verification to Rspamd, so it makes no outbound TCP connections at all and writes nothing but its socket and PID:
If you also verify inbound DKIM in OpenDKIM rather than Rspamd, drop IPAddressDeny=any or allowlist your resolver's IP — verification needs DNS. After each edit:
Dovecot needs care. It is a master process that spawns imap, pop3, lmtp and auth as the vmail user (uid/gid 5000 on the EvilMail stack, with vhosts under /var/mail/vhosts/{domain}/{user} owned 5000:5000). Aggressive NoNewPrivileges on the top unit breaks the per-user setuid the master relies on. Put filesystem and network restrictions on the master unit — ProtectSystem=strict with ReadWritePaths=/var/mail/vhosts /var/lib/dovecot /run/dovecot is safe — but leave the setuid-dependent bits to Dovecot's own service { ... } process definitions.
Postfix is not one process either. The master runs as root and manages its own privilege separation per master.cf. You sandbox *around* it — ProtectSystem=strict, ReadWritePaths=/var/spool/postfix /var/lib/postfix, ProtectHome, PrivateDevices — but you do not slap NoNewPrivileges=true on [email protected], because master must retain CAP_SETUID/CAP_SETGID to spawn smtpd as postfix and delivery agents as their target users.
Where chroot still earns its keep
Postfix has shipped its own chroot for decades: the fifth field in master.cf (y/n/-) jails a service into /var/spool/postfix. smtpd, cleanup and qmgr chrooted there cannot see the real root filesystem at all.
# master.cf — the chroot column is the 5th field
# service type private unpriv chroot wakeup maxproc command
smtp inet n - y - - smtpd
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
The jail is not free. A chrooted service sees only files copied inside it, so you must populate it — /etc/resolv.conf, /etc/localtime, /etc/services, passwd fragments — using the manifest in /etc/postfix/postfix-files, then run postfix set-permissions to fix ownership. On Debian and Ubuntu the chroot column ships as - (off) by default in modern Postfix 3.x packaging, precisely because maintaining that skeleton is fiddly.
Here is the honest tradeoff. systemd namespaces give you a per-service filesystem view with zero jail skeleton to maintain — ProtectSystem=strict synthesizes the view at start. But chroot predates and coexists with it, and if your box already runs a working Postfix chroot, do not rip it out to feel modern. The decisive point is coverage: chroot only restricts the filesystem. It does nothing about syscalls, capabilities or network egress, and it never covered LMTP delivery, Sieve, milters or Rspamd. Layer them — chroot for the classic Postfix daemons that support it, systemd for everything chroot never touched, and both where they overlap. chroot is the 1990s answer, namespaces are the 2020s answer, and you want each where it's strongest.
The break-glass list: directives that silently break mail
This is what separates operators from people pasting a blog checklist. Every one of these has cost someone a night:
`ProtectSystem=strict` with no `ReadWritePaths` for the queue → Read-only file system in the journal and silently deferred delivery. Mail piles up, no bounce, no crash. Add the spool and state dirs.
`SystemCallFilter` too tight → Failed to fork, or the daemon dies on SIGSYS at startup because it can't setrlimit/prctl. Loosen with @process @signal before narrowing again.
`IPAddressDeny=any` on Rspamd → DNS lookups time out, so DNSBL, SPF and DKIM checks silently fail and every message is scored wrong or greylisted. Allowlist your resolver and Redis host instead of a blanket deny.
`MemoryDenyWriteExecute=true` on Rspamd → LuaJIT and PCRE JIT can't mprotect executable pages; the worker crashes on start with a Lua allocation error. Leave it off for Rspamd, keep it on for OpenDKIM and static C daemons.
`NoNewPrivileges=true` on Dovecot or Postfix master → per-user setuid delivery breaks; the master can't switch to the target user and delivery fails. Scope it to leaf services only.
`PrivateNetwork=true` on a DB-dependent helper → connection refused to 127.0.0.1:5432 or :6379, because the private netns has only its own loopback. Reserve it for pure UNIX-socket helpers.
`PrivateTmp=true` where another service expects a socket in `/tmp` → the peer can't find the socket that now lives in a private namespace. Move the socket to /run or drop PrivateTmp on that pair.
Verify like you mean it
Prove confinement, don't assume it. Exec into the running unit's namespace and test what the sandbox actually guarantees:
bash
pid=$(systemctl show -p MainPID --value rspamd.service)
nsenter -t "$pid" -m -p -- ls /home # -> empty (ProtectHome)
nsenter -t "$pid" -m -p -- ls /root # -> Permission denied
nsenter -t "$pid" -m -p -- touch /usr/local/bin/x # -> Read-only file system
systemd-cgls -u rspamd.service # confirm the cgroup tree
ProtectHome is what makes /home and /root disappear from the worker's mount namespace, and ProtectSystem=strict is what turns the write into Read-only file system. The worker already couldn't read /etc/shadow — it runs as _rspamd, not root, so DAC blocks it — but if you want that guaranteed regardless of process UID, add InaccessiblePaths=/etc/shadow. Confirm the seccomp and capability state applied with systemctl show -p SystemCallFilter -p CapabilityBoundingSet, and diff the systemd-analyze security score against your baseline.
The operator checklist, per daemon:
1.Baseline-measure with systemd-analyze security.
2.systemctl edit a drop-in override.
3.Add ReadWritePaths for the spool and state dirs the daemon actually writes.
4.Strip CapabilityBoundingSet to empty, add back only CAP_NET_BIND_SERVICE / setuid caps where required.
5.Add the seccomp allowlist (@system-service, subtract @privileged @resources).
6.Set RestrictAddressFamilies; deny egress on filters that never dial out.
7.daemon-reload, restart, re-measure the delta.
8.Send a real test message through every path — inbound SMTP, submission on :587, Sieve delivery, DKIM signing, Rspamd scan.
9.Tail the journal for EPERM, denied and Read-only file system for a full 24 hours before you call it done.
Confinement is not a one-time score you screenshot for a compliance ticket. It is the difference between a filter bug that files a CVE and a filter bug that reads your customer database. Layer chroot and namespaces, measure the delta, and let the attacker who eventually gets in find themselves in an empty room with the doors already locked.