← All cheat sheets

SYSTEMD SERVICE HARDENING

Plain-text reference · 6 KB. Read it, search it (Ctrl-F) or print it.

Sandbox a Linux service with its own unit settings, then measure the
result: systemd-analyze security scores each service, a drop-in file
adds the settings, and a test run shows what breaks. Checked against
the systemd 255 manual pages (systemd.exec(5), systemd.resource-control(5),
systemd-analyze(1), systemctl(1), systemd-run(1)). Settings can differ
in other versions: check "man systemd.exec" on your own system.

MEASURE FIRST#

systemd-analyze security                  # all loaded long-running services
systemd-analyze security nginx.service    # one unit, setting by setting
# The score is an "exposure level" from 0.0 to 10.0: high means very
# little sandboxing, low means tight sandboxing. It only counts what
# systemd applies; it is not proof that a service is or is not vulnerable.
systemd-analyze security --threshold=50 nginx.service
#   returns an error when the exposure level is above the threshold
#   (default 100; on systemd 255 a level of 9.6 passes 96, fails 95),
#   so it can gate a CI job or a deploy
systemd-analyze security --offline=yes ./myapp.service   # unit file only
systemd-analyze verify ./myapp.service    # syntax and reference check

ADD SETTINGS WITHOUT EDITING THE PACKAGE FILE#

systemctl edit nginx.service              # creates a drop-in override.conf
systemctl edit --drop-in=hardening nginx.service   # named drop-in instead
systemctl cat nginx.service               # unit file + every drop-in
systemctl show -p ProtectSystem,NoNewPrivileges nginx.service
systemctl daemon-reload && systemctl restart nginx.service
journalctl -u nginx.service -b            # what failed after the change

A STARTING SET (put in the [Service] section, then test)#

NoNewPrivileges=yes       # no setuid/setgid/file-capability gain on execve
ProtectSystem=strict      # whole file system read-only except /dev /proc /sys
ReadWritePaths=/var/lib/myapp   # the few paths it really must write
ProtectHome=yes           # /home, /root and /run/user empty and inaccessible
PrivateTmp=yes            # own /tmp and /var/tmp, removed when it stops
PrivateDevices=yes        # only pseudo devices (/dev/null, /dev/random...)
ProtectKernelTunables=yes # /proc/sys, /sys and similar read-only
ProtectKernelModules=yes  # no explicit module loading
ProtectKernelLogs=yes     # no access to the kernel log ring buffer
ProtectControlGroups=yes  # cgroup hierarchy read-only
ProtectClock=yes          # no changes to the system or hardware clock
ProtectHostname=yes       # no hostname/domain name changes
ProtectProc=invisible     # other users' processes hidden in /proc
RestrictSUIDSGID=yes      # cannot set the SUID/SGID bits on files
RestrictNamespaces=yes    # no namespace creation or switching
RestrictRealtime=yes      # no realtime scheduling
LockPersonality=yes       # execution domain cannot change
MemoryDenyWriteExecute=yes   # no writable+executable memory (breaks JITs)
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=CAP_NET_BIND_SERVICE   # only what it needs
UMask=0077

WHAT EACH ONE DOES NOT COVER#

# ProtectSystem=yes: /usr and the boot loader dirs read-only; =full adds
# /etc; =strict makes everything read-only except /dev, /proc and /sys.
# The manual says it "cannot ensure protection in all cases".
# ProtectHome=read-only or =tmpfs when the service needs some of it.
# NoNewPrivileges only covers the unit's own processes, not work it asks
# other services to do (at, crontab, systemd-run, IPC).
# RestrictAddressFamilies restricts socket(2) only: sockets passed in by
# socket activation are not affected. It has no effect on 32-bit x86.
# SystemCallFilter without "~" is an allow list: other calls kill the
# process with SIGSYS, or return SystemCallErrorNumber= when it is set.
# Allow lists are recommended over deny lists (new calls stay blocked).
# Pair SystemCallFilter and RestrictAddressFamilies with
# SystemCallArchitectures=native so a second ABI cannot bypass them.
# ProtectProc= does not affect root: use it with User= or DynamicUser=yes
# and without CAP_SYS_PTRACE.
# CapabilityBoundingSet= with an empty value removes every capability;
# a "~" prefix removes only the listed ones.
# PrivateTmp: two units can share one private /tmp with JoinsNamespaceOf=.
# ProcSubset=pid hides the non-process parts of /proc; the manual says
# it is useful only in a few cases, as many programs need those APIs.
# Settings apply to the service's own code only: if it can ask another
# service over IPC (for example D-Bus), check that access policy too.

RUN AS A THROWAWAY USER#

DynamicUser=yes           # user+group created at start, released at stop
# Not added to /etc/passwd; implies ProtectSystem=strict, ProtectHome=
# read-only, PrivateTmp=yes and RestrictSUIDSGID=yes (and more).
StateDirectory=myapp      # /var/lib/myapp owned by the dynamic user
CacheDirectory=myapp      # /var/cache/myapp
LogsDirectory=myapp       # /var/log/myapp

NETWORK AND DEVICE LIMITS (systemd.resource-control)#

IPAddressDeny=any         # drop all IP traffic of the unit...
IPAddressAllow=localhost  # ...except loopback (allow wins over deny)
IPAddressAllow=10.0.0.0/8
DevicePolicy=closed       # only standard pseudo devices + DeviceAllow=
DeviceAllow=/dev/ttyUSB0 rw
# IPAddress* need eBPF cgroup support; without it they have no effect,
# so do not rely on them alone. Socket-activated services: put the same
# list on the .socket unit too.

TRY A SETTING BEFORE YOU COMMIT IT#

systemd-run --pty -p ProtectSystem=strict -p PrivateTmp=yes /bin/sh
#   a transient service with the settings: try the writes your app does
systemd-run --wait -p DynamicUser=yes -p ProtectHome=yes ls /home
#   --wait: block until it finishes and show the exit status
# Then compare: systemd-analyze security <unit> before and after.

CHECKLIST#

# 1. Score every long-running service; start with network-facing ones.
# 2. Add the starting set in a drop-in, one group at a time.
# 3. Restart, read journalctl -u, fix with ReadWritePaths= or by
#    relaxing only the setting that broke it.
# 4. Re-score; record the new level and gate it with --threshold=.
# 5. Re-check after package updates: systemctl cat shows the drop-in
#    is still applied.

Defensive reference on CyberRamen. Offensive / red-team sheets live on OffensiveRamen.com.