Tag Archives: systemd

systemd Hardening for Long-Running Daemons

A long-running daemon that talks to the network and a database is the thing an attacker wants most, and it is usually the thing running with the fewest restrictions. systemd ships a large set of sandboxing directives that cost nothing at runtime and are almost never used, mostly because the documentation presents forty options with no indication of which ones matter.

Measure First

systemd will grade the unit for you, which is a better starting point than reading the manual page end to end:

systemd-analyze security tracker.service

You get a per-directive table and an overall exposure score from 0 to 10. A default unit with no hardening scores around 9.6, which the tool cheerfully labels UNSAFE. The score is a rough heuristic rather than a real measure of risk, and it is genuinely useful for ordering the work, since it sorts by exposure and shows what each setting would buy.

The Directives Worth Setting

These are the ones I put on every service, in rough order of value per unit of trouble:

[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RemoveIPC=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

NoNewPrivileges=yes is the single highest-value line. It blocks setuid escalation for the process and everything it spawns, and almost nothing legitimately needs the alternative.

ProtectSystem=strict mounts the entire filesystem read-only apart from /dev, /proc, and /sys. Combined with ProtectHome=yes, a compromised process cannot write anywhere at all until you grant specific paths back:

ReadWritePaths=/var/lib/tracker /var/log/tracker
RuntimeDirectory=tracker
StateDirectory=tracker

The RuntimeDirectory and StateDirectory forms are better than listing paths by hand, because systemd creates them with the right ownership and cleans up runtime state on stop.

CapabilityBoundingSet= with an empty value drops every capability. Most daemons need none, and the common exception is binding a port below 1024, which needs CAP_NET_BIND_SERVICE added back. Better still, use a socket unit and let systemd hold the privileged socket so the service never needs the capability at all.

RestrictAddressFamilies is worth the thought. A daemon doing DNS and PostgreSQL over TCP needs AF_INET, AF_INET6, and AF_UNIX for the local socket. It has no business creating a raw packet socket or an AF_NETLINK socket, and denying those closes a real avenue.

The Ones That Will Break You

MemoryDenyWriteExecute=yes blocks pages that are both writable and executable. It is excellent against a large class of exploit, and it breaks anything with a JIT. For a C++ daemon it is usually free. For anything embedding a scripting runtime, test carefully.

SystemCallFilter=@system-service is the setting most likely to produce a mysterious failure. The allow list covers what normal services do, and a program using something unusual gets killed with SIGSYS and no useful message. When a service dies immediately after adding it, find the offending call rather than guessing:

journalctl -u tracker.service | grep -i seccomp
auditctl -a always,exit -F arch=b64 -S all -F key=seccomp-debug

ProtectSystem=strict breaks anything writing outside its declared paths, which in practice means log files in unexpected places and PID files in /var/run. Both are better fixed than exempted.

ProtectProc=invisible hides other users’ processes in /proc. Harmless for most daemons and immediately fatal for anything that inspects the process table, including some monitoring agents.

Do It Incrementally

Adding all twenty lines at once and finding the service dead tells you nothing about which line did it. Add the block above minus SystemCallFilter and MemoryDenyWriteExecute, confirm the service still works under real load rather than just starting, then add those two separately.

“Starts successfully” is a weak test. Sandboxing failures show up on code paths that only run occasionally, so the syscall your daemon makes once a day during log rotation is exactly the one the filter blocks. Give a hardened unit a full cycle of whatever periodic work it does before considering it verified.

None of this replaces running as an unprivileged user, keeping the software patched, or putting the database somewhere the internet cannot reach. It is the layer underneath those, and it is roughly twenty lines of configuration that turns a total compromise of the process into a considerably less useful foothold.