systemd Service Sandbox Hardening in Practice: Minimize Process Privileges
After a service is compromised, limiting lateral movement is a key defense-in-depth layer. Covers systemd sandboxing: ProtectSystem read-only filesystem, PrivateTmp private /tmp, NoNewPrivileges, CapabilityBoundingSet, plus systemd-analyze security scoring. Includes path-miss and CentOS 7 directive compatibility troubleshooting.
Applicable scenarios
When a host runs Nginx, Redis, a Python app, and more at once, the goal of systemd service hardening is: assume a service is compromised by a vuln, lock the attacker's activity inside that service's sandbox—cannot read system config beyond /etc/passwd, cannot tamper with system directories, cannot escalate. Compared with MAC such as AppArmor or SELinux, systemd sandboxing needs no extra software, enables per-service, and is the most practical defense-in-depth built into Linux by default.
Prerequisites
- Host uses systemd (CentOS 7+/Rocky/AlmaLinux, Ubuntu 16.04+, Debian 8+)
- Target service starts as a systemd unit (Nginx, Redis, and most distro defaults)
- systemd 232 or later (all directives in this article; CentOS 7 is 219, some directives are no-ops—see FAQ)
- root or sudo
How it works
systemd service hardening uses kernel mount namespaces, capabilities, seccomp, and related mechanisms to drop rights at start: read-only filesystem mounts, private /tmp, drop root capabilities, restrict syscalls. Directives live in the unit file; PID 1 applies them at fork/exec. After the attacker has process control they are already in a restricted context; escape needs an extra kernel vuln. Official `systemd-analyze security` scores each service so you can quantify hardening.
Procedure
Step 1: Check current security score
`systemd-analyze security nginx.service | tail -20`
The end of the output is a 0-10 total score; 10 is most secure. Unhardened Nginx is usually 4-6. Each EXPOSE item maps to a directive you can tighten.
Step 2: Create an override
Do not edit the distro unit file (upgrades overwrite it). Use `systemctl edit` to generate an override:
`systemctl edit nginx.service`
In the editor, write a `[Service]` section (Nginx example):
`[Service]
# Entire filesystem read-only except whitelist directories
ProtectSystem=strict
ReadWritePaths=/var/log/nginx /var/cache/nginx /run
# Private /tmp, isolated from other processes
PrivateTmp=true
# Forbid privilege escalation (setuid/setcap both ineffective)
NoNewPrivileges=true
# /home directory not visible
ProtectHome=true
# Forbid modifying kernel parameters and devices
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
# Restrict address families (IPv4/IPv6/Unix socket only)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Narrow capability set (Nginx only needs bind-port capability)
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Forbid obtaining new privileges
RestrictSUIDSGID=true`
Key params: ProtectSystem=strict mounts the whole filesystem tree read-only; only paths in ReadWritePaths are writable. PrivateTmp=true gives the service its own /tmp (stacks with prior /tmp noexec hardening). NoNewPrivileges=true blocks the classic setuid-binary path to root. Writable paths must be listed in ReadWritePaths one by one, or the service fails at start with Permission denied.
Step 3: Apply and verify
`# Reload unit definition and restart the service
systemctl daemon-reload
systemctl restart nginx
# Confirm sandbox directives have taken effect
systemctl show nginx -p ProtectSystem -p PrivateTmp -p NoNewPrivileges
# Score again; compare before vs after hardening
systemd-analyze security nginx.service | tail -3`
Use the same method for Redis (add `ProtectClock=true`, `RestrictNamespaces=true`) and in-house services, one by one. Target: raise every internet-facing service above 7.
Config verification
Enter the service sandbox context and test write rights:
`# Inside the nginx service namespace, try writing /etc (should be rejected)
systemd-run --scope -p ProtectSystem=strict sh -c 'touch /etc/test && echo write succeeded || echo write blocked'
# Confirm the service is working
curl -sI http://127.0.0.1/ | head -1
# Confirm logs have no permission-denied errors
journalctl -u nginx --since \"5 minutes ago\" | grep -i denied`
FAQ
FAQ 1: After restart, Permission denied, service will not start
Most common cause: a runtime write path was not added to ReadWritePaths. Find it: `journalctl -u SERVICE -p err --since today` — look at the exact denied path, append it to ReadWritePaths, then `systemctl daemon-reload && systemctl restart`. Note Nginx reload also fails if the PID file directory is not writable; keep /run writable.
FAQ 2: CentOS 7 reports Unknown key for some directives, or they do not take effect
CentOS 7 ships systemd 219. It does not support RestrictAddressFamilies, ProtectKernelModules, and other newer directives. systemd prints a warning but still starts; only old directives apply. Trust the actual `systemd-analyze security` output and verify item by item. Upgrade the OS if you can; the full sandbox directive set is complete only after systemd 232.
Summary
systemd service hardening builds a per-service sandbox at near-zero cost: read-only filesystem, private /tmp, no privilege escalation, narrowed capability set, plus a scoring tool to keep quantifying each service's exposure. Complements WAF and firewalls at the perimeter—the perimeter stops inbound; the sandbox stops lateral movement after inbound. Start with internet-facing Nginx and Redis, tighten service by service. Keep all changes in override.d so OS upgrades do not drop them.