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.

DirectiveEffectTypical value
NoNewPrivilegesBlocks gaining privileges via setuid or file capabilitiesyes
ProtectSystemMakes /usr, /boot, /etc read-only (or the whole tree)strict
ProtectHomeHides or blocks /home, /root, /run/useryes
PrivateTmpGives the service its own /tmp, isolated from othersyes
PrivateDevicesReplaces /dev with a minimal, safe subsetyes
RestrictAddressFamiliesLimits which socket families the service may useAF_INET AF_INET6
CapabilityBoundingSetCaps the capabilities the service may ever holddrop all not needed
SystemCallFilterseccomp 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

  1. Start every unit with NoNewPrivileges=yes, ProtectSystem=strict, PrivateTmp=yes, and an explicit ReadWritePaths= for the few directories it writes.
  2. Always use systemctl edit to create a drop-in override rather than editing the shipped unit, so updates do not clobber your hardening.
  3. Score with systemd-analyze security before and after, and treat the number as a relative measure, not an absolute target to minimize.
  4. Add directives one at a time, restart, and check the journal for permission denials so you can attribute any breakage to a single line.
  5. Prefer widening a specific directive over removing a protection when something breaks, keeping the sandbox as tight as the workload allows.
  6. Drop capabilities aggressively with CapabilityBoundingSet= and restrict syscalls with SystemCallFilter=@system-service, since most services need neither raw sockets nor exotic syscalls.