Restrict a service with a drop-in
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=.
Unit settings that strip a service of root, system writes and new privileges; systemd-analyze security rates them.
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
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:
User= and Group= run the service as the named user. That user needs no
password, no login and no home directory:
The same cat reads /etc/shadow as root and fails as demo:
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>.
| 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 |
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:
StateDirectory=demo fixes it: systemd hands the directory to demo and
opens it for writing, while the rest of the system stays read-only:
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:
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.
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:
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.
| 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 <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:
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:
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.
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=.
systemd-run runs a command as a temporary service with the same
settings:
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.
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.
The user from User= does not exist on the machine. Create it with
useradd --system or set DynamicUser=yes.
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.
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.
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.
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.
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/.
show, restart, daemon-reload.Found an inaccuracy? Report an error in the article