Handbook sections

Hardening systemd services

Last reviewed

Every running daemon is reachable attack surface. Turn off the units you don't need and sandbox the ones you keep, then measure each one's exposure.

The threat: forgotten daemons and over-reaching ones

A stock Linux install boots dozens of units you never asked for: a printing stack (cups), a name-caching resolver, Bluetooth, Avahi/mDNS discovery, a debug shell, core-dump collection. Each one is code that runs as a privileged process, frequently listens on a socket, and can be reached before a single human logs in. The 2024 cups-browsed RCE chain is the textbook case: a service almost nobody needed on a server, listening on the network, turned into remote code execution.

A service you forgot is running is worse than one you chose: it is unpatched in your threat model, unmonitored, and often socket-activated so it springs to life on demand even when it looks idle. And a daemon that must run still executes with far more reach than it needs (full filesystem visibility, write access to /home, the ability to gain new privileges), so a single exploit in it owns the host.

Why harden it

The safest service is the one that isn't running, and the second safest is the one that can't reach beyond its job. Disabling unneeded units shrinks the attack surface: fewer open ports, fewer privileged processes, fewer CVEs that apply to you. For the daemons you keep, sandboxing caps the blast radius: if the process is compromised, the kernel still denies it the filesystem, the home directories, the extra capabilities and the dangerous syscalls it never legitimately uses.

Measure exposure first: systemd-analyze security

Don't guess which services are exposed, measure them. systemd-analyze security lists every service with an exposure score from 0.0 (safe) to 10.0 (unsafe) and an UNSAFE/EXPOSED/MEDIUM/OK verdict; systemd-analyze security <unit> breaks down each sandboxing directive and what it would gain. This is the baseline before and after hardening, and the fastest way to find the worst offender.

systemd-analyze security              # rank all services by exposure
systemd-analyze security nginx.service  # per-directive breakdown + suggested fixes

Sandbox the services you keep

Apply the directives in a drop-in, never by editing the vendor unit: systemctl edit <unit> writes /etc/systemd/system/<unit>.d/override.conf, then systemctl daemon-reload && systemctl restart <unit>. The high-value directives:

Directive Effect
ProtectSystem=strict the whole filesystem read-only except API paths (pair with ReadWritePaths=)
ProtectHome=yes /home, /root, /run/user made inaccessible
PrivateTmp=yes a private /tmp, isolated from the host
PrivateDevices=yes a minimal /dev, no raw hardware
NoNewPrivileges=yes blocks setuid/setgid/capability escalation via execve
ProtectKernelTunables/Modules/Logs=yes /proc+/sys read-only, no module load, no kmsg
ProtectControlGroups=yes, ProtectClock=yes no cgroup or clock tampering
RestrictAddressFamilies= only the socket families the service needs
RestrictNamespaces=, RestrictSUIDSGID=yes, RestrictRealtime=yes deny namespace creation, setuid files, RT scheduling
MemoryDenyWriteExecute=yes, LockPersonality=yes no W+X memory, frozen architecture
SystemCallFilter=@system-service allowlist the syscalls a service legitimately uses
CapabilityBoundingSet= drop every Linux capability the service doesn't need

What Pavois audits: the live systemd state

The differentiator is that Pavois reads the live systemd state, never a config file. For each unit it queries the resolved is-enabled / is-active status (InSpec's service resource over systemctl), so it sees what the machine really does at the next boot, and it is aware of socket activation: a cups.service that looks idle can still be revived by cups.socket, which a scan of /etc/cups/cupsd.conf would never catch.

Pavois's rules check that unneeded units are disabled and stopped (cups, avahi-daemon, bluetooth, cockpit, nfs, rpcbind, snmpd, slapd, named, dnsmasq, autofs, smb, vsftpd, tftp, telnet, xinetd, rsyncd, dovecot, squid, apport, kdump, the kernel debug-shell and systemd-coredump) and that security-relevant units are enabled and running (auditd, systemd-journald, sssd, usbguard, fapolicyd, rngd, cron/crond). Findings map to CIS, NIST CM-7 (least functionality) and ANSSI BP-028 R67, and the harden plan can disable/stop or enable/start each unit.

Pitfalls

  • ProtectSystem=strict without ReadWritePaths= makes the service's own state/log/cache directories read-only and it fails to start. Add the paths it must write (/var/lib/<svc>, /var/log/<svc>).
  • A too-aggressive directive leaves the service active but mute: it starts, then silently can't do its job (e.g. a denied syscall). Watch systemctl status and the exit code.
  • Sandbox exit codes are diagnostic: 226/NAMESPACE, 227/SECCOMP... 228/SECCOMP, 233/NETWORK point straight at the directive that is too tight.
  • mask vs disable: systemctl disable stops auto-start; systemctl mask makes the unit unstartable entirely (a stronger guarantee for a banned service).

FAQ

How do I know which services are over-exposed? systemd-analyze security ranks them 0-10; start with the UNSAFE ones.

Why does Pavois read systemctl, not the config? Because socket activation and the resolved enabled/active state are the truth; a unit can be socket-revived even when its config file looks inert.

My service won't start after sandboxing. Why? Most often ProtectSystem=strict with no ReadWritePaths=, or a SystemCallFilter that dropped a needed call. The 226-233 exit codes name the culprit; loosen the one directive, not the whole sandbox.