SYSTEMD SERVICE HARDENING
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.
SYSTEMD SERVICE HARDENING ========================= 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.