Where to start First steps track · 15 stages
Linux Labs

Service hardening: User=, ProtectSystem, NoNewPrivileges

13 min Ubuntu 24.04 · systemd 255

Unit settings that strip a service of root, system writes and new privileges; systemd-analyze security rates them.

Cheat sheet

7 commands Printable version
  • systemd-analyze security rating of every service
  • systemd-analyze security app settings table and total
  • systemd-analyze security --threshold=50 app exit code 1 above 5.0
  • systemctl show app -p User -p ProtectSystem -p NoNewPrivileges
  • sudo systemctl edit app drop-in with limits
  • ps -o user,pid,cmd -C python3 who runs the process
  • sudo systemd-run --pipe --wait -q -p User=demo id try a setting

Overview

By default a service gets everything: it runs as root, writes to any directory, sees home directories and can run sudo. A bug in it is root on the machine. Restrictions are settings in the [Service] section: systemd applies them to the process before ExecStart runs, and the program does not know about them. They go into a drop-in, so the package's unit stays as it is. The general rule is least privilege: a service gets only what it cannot work without.

The lab service demo-web is python3 -m http.server on port 8090. Without restrictions it runs as root with all capabilities:

text
student@ubuntu:~$ ps -o user,pid,cmd -C python3
USER         PID CMD
root        2382 python3 -m http.server 8090 --bind 127.0.0.1
  --directory /srv/demo
student@ubuntu:~$ sudo grep -E 'Uid|CapEff' /proc/2382/status
Uid:	0	0	0	0
CapEff:	000001ffffffffff

Which user

User= and Group= run the service as the named user. That user needs no password, no login and no home directory:

bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin demo

The same cat reads /etc/shadow as root and fails as demo:

text
student@ubuntu:~$ sudo systemd-run --pipe --wait -q -p User=demo \
> cat /etc/shadow
/usr/bin/cat: /etc/shadow: Permission denied

DynamicUser=yes does without useradd: systemd creates a temporary user for the lifetime of the service (uid=64021(run-u50)) and removes it afterwards. The data of such a service lives in /var/lib/private/<name>.

File system

Setting What the service sees
ProtectSystem=yes /usr and /boot read-only
ProtectSystem=full the same plus /etc
ProtectSystem=strict the whole file system read-only except /dev, /proc, /sys and allowed paths
ProtectHome=yes /home, /root, /run/user empty and closed
ProtectHome=read-only home directories read-only
PrivateTmp=yes its own empty /tmp and /var/tmp
ReadWritePaths= paths opened for writing under strict
StateDirectory= /var/lib/<name>: created, owned by User=, writable
text
student@ubuntu:~$ sudo systemd-run --pipe --wait -q \
> -p ProtectSystem=strict touch /etc/demo-file
/usr/bin/touch: cannot touch '/etc/demo-file': Read-only file system
student@ubuntu:~$ sudo systemd-run --pipe --wait -q -p ProtectHome=yes \
> ls -la /home
total 4
d---------  2 root root   40 Oct 21 05:05 .
drwxr-xr-x 21 root root 4096 Oct 20 23:33 ..

Logs, cache and sockets have similar settings: LogsDirectory= (/var/log/<name>), CacheDirectory=, RuntimeDirectory= (/run/<name>).

At start demo-web appends a line to /var/lib/demo/started. The directory was created back when the service ran as root, and after User=demo and ProtectSystem=strict the write fails — the service goes down:

text
student@ubuntu:~$ sudo journalctl -u demo-web -o cat -n 3
/bin/sh: 1: cannot create /var/lib/demo/started: Permission denied
demo-web.service: Main process exited, code=exited,
  status=2/INVALIDARGUMENT
demo-web.service: Failed with result 'exit-code'.

StateDirectory=demo fixes it: systemd hands the directory to demo and opens it for writing, while the rest of the system stays read-only:

text
student@ubuntu:~$ ls -ld /var/lib/demo
drwxr-xr-x 2 demo demo 4096 Oct 21 05:06 /var/lib/demo

New privileges

NoNewPrivileges=yes sets the no_new_privs flag on the process: neither it nor its children can raise their rights through setuid programs (sudo, su) or file capabilities. sudo inside such a service says so directly:

text
student@ubuntu:~$ sudo systemd-run --pipe --wait -q -p User=demo \
> -p NoNewPrivileges=yes sudo -n true
sudo: The "no new privileges" flag is set, which prevents sudo from
  running as root.
sudo: If sudo is running in a container, you may need to adjust the
  container configuration to disable the flag.

Without the flag sudo would get as far as the password check (sudo: a password is required) — and further for a user with a sudoers rule.

Capabilities

CapabilityBoundingSet= is the ceiling of the service's capabilities: an empty value takes all of them away, a list keeps only the named ones. A root service with an empty ceiling cannot bypass file permissions or load a kernel module. AmbientCapabilities= gives parts to a service with User=, for example a port below 1024:

ini
[Service]
User=demo
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

A part from AmbientCapabilities= works only if the ceiling has it too. An empty CapabilityBoundingSet= in another drop-in cuts it off, and the service gets PermissionError: [Errno 13] Permission denied again.

Other settings

Setting What it takes away
PrivateDevices=yes devices: /dev keeps null, zero, random, urandom, tty and the like
ProtectKernelTunables=yes writes to /proc/sys and /sys
ProtectKernelModules=yes loading kernel modules
ProtectKernelLogs=yes the kernel log
ProtectClock=yes setting the system clock
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX other socket types
RestrictNamespaces=yes creating namespaces
RestrictSUIDSGID=yes creating setuid and setgid files
SystemCallFilter=@system-service system calls outside the usual service set
IPAddressDeny=any with IPAddressAllow=localhost the network beyond the machine itself
UMask=0077 others reading the files it creates

systemd-analyze security

systemd-analyze security <unit> checks about 80 settings and prints a table of three columns: NAME, DESCRIPTION, EXPOSURE. ✗ means the protection is off, with its weight on the right; ✓ means it is on. A few rows for demo-web:

NAME DESCRIPTION EXPOSURE
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
SupplementaryGroups= Service runs as root, option does not matter
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
✗ NoNewPrivileges= Service processes may acquire new privileges 0.2

The last line is the total, the exposure level from 0 to 10:

text
student@ubuntu:~$ systemd-analyze security demo-web | tail -1
→ Overall exposure level for demo-web.service: 9.6 UNSAFE :-{

Without a unit name the command prints one line per service: on the lab machine almost all services are 9.5–9.6 UNSAFE, rsyslog is 6.3 MEDIUM, polkit is 1.6 OK. The levels:

Rating Level
10.0 DANGEROUS
9.0–9.9 UNSAFE
7.5–8.9 EXPOSED
5.0–7.4 MEDIUM
1.0–4.9 OK
0.1–0.9 SAFE
0.0 PERFECT

How the rating of demo-web changed:

What is on Rating
nothing, the service runs as root 9.6 UNSAFE
User=demo, ProtectSystem=strict, NoNewPrivileges=yes, StateDirectory=demo 8.7 EXPOSED
plus ProtectHome, PrivateTmp, PrivateDevices, an empty CapabilityBoundingSet=, ProtectKernel*, RestrictAddressFamilies, SystemCallFilter and more 1.5 OK
plus PrivateUsers, ProtectProc, IPAddressDeny and more 0.9 SAFE

The rating is a sum of row weights. The first settings close the most dangerous holes, but they cover three rows, so the rating drops by only 0.9. Capabilities have the most rows: an empty CapabilityBoundingSet= closes them all at once.

--threshold=N turns the rating into a check. The threshold is in tenths: a rating of 9.6 is 96. Above the threshold the exit code is 1, equal or below it is 0:

text
student@ubuntu:~$ for t in 95 96; do
>   systemd-analyze security --threshold=$t demo-web >/dev/null
>   echo "threshold=$t rc=$?"
> done
threshold=95 rc=1
threshold=96 rc=0

The rating is computed from the unit's settings: after daemon-reload it is already new, while the running process stays the old one until restart. Check the process too — ps -o user, /proc/<PID>/status.

Recipes

2

Restrict a service with a drop-in

bash
sudo mkdir -p /etc/systemd/system/demo-web.service.d
sudo tee /etc/systemd/system/demo-web.service.d/hardening.conf <<'EOF'
[Service]
User=demo
StateDirectory=demo
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
EOF
sudo systemctl daemon-reload
sudo systemctl restart demo-web
systemctl is-active demo-web
curl -s localhost:8090
systemd-analyze security demo-web | tail -1
ps -o user,pid,cmd -C python3

Check every setting by the service's work: active does not yet mean it answers and writes its data. If it fails, read journalctl -u: the path from the error goes into ReadWritePaths= or StateDirectory=.

Try a setting on one command

systemd-run runs a command as a temporary service with the same settings:

bash
sudo systemd-run --pipe --wait -q -p ProtectSystem=strict \
  -p User=demo sh -c 'touch /etc/x; cat /etc/shadow; id'
echo $?        # the command's exit code

Without -q, systemd-run prints the temporary unit's name (Running as unit: run-u49.service) and the result: Finished with result: exit-code, Main processes terminated with: code=exited/status=1.

Troubleshooting

5

The service fails after ProtectSystem=strict

The log shows Read-only file system or Permission denied on a write. The service writes outside the allowed paths. Its own data directory goes into StateDirectory=, another path into ReadWritePaths=. If root created the directory before User=, you get Permission denied even without strict: StateDirectory= changes the owner itself.

status=217/USER

text
Main PID: 1772 (code=exited, status=217/USER)

The user from User= does not exist on the machine. Create it with useradd --system or set DynamicUser=yes.

Permission denied on a port below 1024

After User= the service lost the right to port 80 or 443. It needs AmbientCapabilities=CAP_NET_BIND_SERVICE and the same part in CapabilityBoundingSet= — more in capabilities.

The rating dropped, the process did not change

systemd-analyze security shows 1.5 OK, while ps shows the service as root. There was a daemon-reload without a restart: the rating already uses the new settings, the process was started with the old ones. Restart the service.

sudo: The "no new privileges" flag is set

The service's script calls sudo or another setuid program, and the unit has NoNewPrivileges=yes. Give the service the right it needs directly — User=, a group, a capability — with no sudo inside.

Danger zone

2

ProtectSystem=strict for a service with data

The service may start and answer, then fail later — on the first write to its database or log. Before strict, find out where the service writes and test exactly that write.

Editing the package's unit

Lines written straight into /lib/systemd/system/<unit>.service are lost on a package upgrade. Restrictions belong only in a drop-in in /etc/systemd/system/<unit>.d/.

Terms

3
least privilege
The rule that a program or a user gets only the rights it cannot do its job without. A service gets its own user, writes only to its own directory and has no capabilities it does not need. Also appears in: Capabilities: root split into parts
NoNewPrivileges
The no_new_privs process flag: neither the process nor its children gain rights through setuid programs (sudo, su) or file capabilities. In a unit it is NoNewPrivileges=yes; sudo inside answers "no new privileges".
exposure level
A number from 0 to 10 printed by systemd-analyze security: how many protections the unit lacks. 9.6 UNSAFE is a root service with no limits; below 5 is MEDIUM or lower. --threshold turns it into a check with an exit code.

See also

Loading…