Skip to content

Hardening Services

Scope: systemd's built-in sandboxing — restricting what a service can touch, so a bug or a compromise in the program isn't automatically a compromise of the machine. Functional effects verified for real; scores quoted from systemd-analyze.

The idea

A service usually needs far less than it gets by default: it runs as root, can read the whole disk, write most of it, see every process, and use every syscall. The [Service] sandboxing directives carve that down — no code changes, just unit lines. They use the kernel's namespaces and filters underneath; your program just sees a smaller world.

The demonstration

A probe service ran with:

ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/mere-demo

and tried three writes. From its journal:

write to allowed path:
  OK: /var/lib/mere-demo writable
write to /etc (should be blocked by ProtectSystem=strict):
  mkdir: cannot create directory ‘/etc/mere-test’: Read-only file system
  • ProtectSystem=strict — the whole filesystem becomes read-only for the service, except the API filesystems (/dev, /proc, /sys)...
  • ...minus ReadWritePaths= — the carve-outs the service genuinely needs (its data directory).
  • ProtectHome=yes — home directories are invisible (empty) to the service. (The probe's home-write failed as well; the journal shows the attempt and no file appeared.)

No code in the probe knew it was sandboxed. That's the appeal: the restrictions hold even against a program that's actively misbehaving.

The greatest hits

Directive Effect
User= / Group= don't run as root — the single highest-value line in this table
NoNewPrivileges=yes the process and its children can never gain privileges (no setuid surprises)
PrivateTmp=yes its own /tmp, invisible to others
ProtectSystem=strict + ReadWritePaths= read-only filesystem except named paths
ProtectHome=yes homes hidden or read-only
PrivateDevices=yes no access to physical devices beyond the basics
CapabilityBoundingSet= drop Linux capabilities (empty = none)
MemoryMax= / CPUQuota= resource ceilings — the service can't eat the machine

Measure it: systemd-analyze security

systemd-analyze security mere-demo.service

grades ~70 checks and ends with:

→ Overall exposure level for mere-demo.service: 9.6 UNSAFE 😨

The bare demo (root, no restrictions) deserves that score. The probe's three directives alone moved it only to 9.0 — the rubric counts capabilities, namespaces, syscall filtering, and more, so no single setting transforms it. A variant adding User=nobody, NoNewPrivileges, PrivateTmp, PrivateDevices, the kernel protections, an empty CapabilityBoundingSet and friends scored:

→ Overall exposure level for mere-hardened.service: 4.8 OK 🙂

Use the grader as a checklist, not a verdict: work down its ✗ list for any service exposed to a network or to untrusted input, and stop where a restriction starts breaking your program's actual job — which the journal will tell you about, loudly, as the probe demonstrated.

The honest boundary

Sandboxing limits blast radius; it doesn't patch the bug, authenticate the user, or encrypt anything. It's seatbelts, not sobriety. And restrictions can bite: the first time a service mysteriously can't read a file it plainly should, check status's journal lines for a sandbox doing its job — the fix is a ReadWritePaths= or a User= alignment, not removing the walls wholesale.