System Hardening
Systemd Hardening
Sandboxing services with systemd directives.
A service compromise is only as bad as what the service can reach. A web server that gets exploited but can only see its own document root and cannot write to /etc, load kernel modules, or open raw sockets is a contained problem. The same exploit against a service running as root with full filesystem access is game over. Systemd hardening is about narrowing what each unit can do, so that a foothold in one service does not become control of the host.
The best part is that systemd builds the sandbox for you. Instead of hand-rolling namespaces and seccomp filters, you add declarative directives to a unit file and systemd wires up the kernel primitives. This page covers the highest-value directives, how to score a unit objectively, and how to harden a real service without breaking it.
The core sandboxing directives
These directives map onto kernel isolation features (mount namespaces, capability dropping, seccomp, namespace restrictions). Each one removes a capability the service almost certainly does not need.
| Directive | Effect | Typical value |
|---|---|---|
NoNewPrivileges | Blocks gaining privileges via setuid or file capabilities | yes |
ProtectSystem | Makes /usr, /boot, /etc read-only (or the whole tree) | strict |
ProtectHome | Hides or blocks /home, /root, /run/user | yes |
PrivateTmp | Gives the service its own /tmp, isolated from others | yes |
PrivateDevices | Replaces /dev with a minimal, safe subset | yes |
RestrictAddressFamilies | Limits which socket families the service may use | AF_INET AF_INET6 |
CapabilityBoundingSet | Caps the capabilities the service may ever hold | drop all not needed |
SystemCallFilter | seccomp allowlist of syscall groups | @system-service |
ProtectSystem=strict combined with an explicit ReadWritePaths= for the few directories the service genuinely writes is the workhorse pattern: everything is read-only except the paths you name.
Scoring a unit with systemd-analyze security
You do not have to guess how exposed a service is. systemd-analyze security inspects a unit’s directives and produces a weighted exposure score from 0 (locked down) to 10 (wide open), plus a line-by-line breakdown of what is and is not set.
systemd-analyze security ssh.service | head -n 12
Expected output:
NAME DESCRIPTION EXPOSURE
✗ PrivateNetwork Service has access to the host... 0.5
✗ User=/DynamicUser= Service runs as root 0.4
✗ CapabilityBoundingSet=~CAP.. Service may acquire... 0.3
✓ NoNewPrivileges=yes Service processes cannot acquire...
✗ ProtectHome= Service has access to home dir... 0.2
...
→ Overall exposure level for ssh.service: 9.6 UNSAFE 😨
The score is a guide, not a target to game. SSH legitimately needs broad access, so a high score there is expected. The value is comparative: run it before and after your changes to confirm each directive lowered exposure, and use it to spot a service that is far more privileged than its job requires.
systemd-analyze security --no-pager | sort -k2 -rn | head -n 5
Expected output:
ssh.service 9.6 UNSAFE
NetworkManager.service 8.8 EXPOSED
systemd-logind.service 8.4 EXPOSED
cron.service 9.6 UNSAFE
rsyslog.service 7.7 MEDIUM
Hardening a service step by step
Never edit a shipped unit file directly; use a drop-in override so package updates do not overwrite your work. Suppose you have a small internal service myapi.service that listens on a TCP port and writes only to /var/lib/myapi.
sudo systemctl edit myapi.service
This opens an override; add the hardening block:
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ReadWritePaths=/var/lib/myapi
RestrictAddressFamilies=AF_INET AF_INET6
CapabilityBoundingSet=
SystemCallFilter=@system-service
Apply and confirm the service still works, then re-score it.
sudo systemctl daemon-reload
sudo systemctl restart myapi.service
systemctl is-active myapi.service && systemd-analyze security myapi.service | tail -n1
Expected output:
active
→ Overall exposure level for myapi.service: 2.1 OK 🙂
The exposure dropped from wherever it started to 2.1, and the service is still running. That last check matters: hardening that breaks the service is worse than no hardening, because it teaches people to disable it. Add directives incrementally, restart, and watch the journal for permission errors.
journalctl -u myapi.service -n 20 --no-pager | grep -i 'denied\|permission'
Expected output:
(no output)
No denials means the sandbox fits. If you see Read-only file system or Operation not permitted, widen the specific directive (add a path to ReadWritePaths, add a capability back) rather than removing the protection entirely. The errors land in the journal, which is covered in System Logs, and unexpected denials are exactly the kind of event the audit subsystem in Auditd can record.
Practical Guidance
- Start every unit with
NoNewPrivileges=yes,ProtectSystem=strict,PrivateTmp=yes, and an explicitReadWritePaths=for the few directories it writes. - Always use
systemctl editto create a drop-in override rather than editing the shipped unit, so updates do not clobber your hardening. - Score with
systemd-analyze securitybefore and after, and treat the number as a relative measure, not an absolute target to minimize. - Add directives one at a time, restart, and check the journal for permission denials so you can attribute any breakage to a single line.
- Prefer widening a specific directive over removing a protection when something breaks, keeping the sandbox as tight as the workload allows.
- Drop capabilities aggressively with
CapabilityBoundingSet=and restrict syscalls withSystemCallFilter=@system-service, since most services need neither raw sockets nor exotic syscalls.