Measure a systemd service sandbox before changing it

John Burns

A systemd service can start reliably while having almost no containment. That is easy to miss when a unit file is short: an ExecStart line, perhaps a restart policy, and nothing visibly wrong. If the process is later compromised, its ability to read host paths, create sockets, load devices, or gain privileges is determined by the unit’s service settings as well as by the application itself.

This guide uses systemd-analyze security --offline=yes to establish a repeatable before-and-after review for a unit file without starting the service. The completed check takes a candidate unit, records the analyzer’s exposure result, adds restrictions that fit the service, and records the remaining findings for review. In an isolated Linux AMD64 test using systemd 257.13, a minimal Type=oneshot unit scored 9.4, UNSAFE. Adding a deliberately restricted set of filesystem, privilege, device, and socket controls reduced it to 6.3, MEDIUM. The result is not a claim that the service is safe; it is evidence that the proposed sandbox changed the boundaries that systemd can measure.

The systemd project published maintenance releases 258.10 and 259.8 on 24 July 2026. That is a useful reminder to check both the installed systemd version and the directives supported by the hosts that will run a hardened unit. Do not copy a long list of sandbox settings into a production service and call the score a pass. The settings can prevent a real application from reading a needed certificate, opening a required socket, or writing its state. Start with the service’s actual needs and verify each restriction before rollout.

Review a file without touching the running service

systemd-analyze security evaluates service-manager controls and assigns an estimated exposure level from 0.0 to 10.0. Lower is more restricted. It does not examine application code, a remote service reached over D-Bus, a kernel vulnerability, or access granted elsewhere in the system. Use it as a structured review of systemd’s own sandboxing settings, not as a vulnerability scan or a compliance verdict.

The offline mode is useful during a change review because it reads the named unit file rather than loading, restarting, or changing an installed unit. Create a copy of the proposed unit in a private working directory. Do not run this procedure against a production service merely to obtain an article-like score.

systemd-analyze --version
systemd-analyze security --offline=yes ./example.service

The version output matters. A directive accepted on an administrator workstation may not exist on an older fleet host. systemd-analyze security --offline=yes prints each tested control, why it matters, and an overall exposure line. Save the complete output with the change request. A single final score is useful for comparison, but the individual unchecked controls explain what has not yet been restricted.

Use a plain unit as a starting point only when its program has no unusual requirements:

[Unit]
Description=Example service reviewed offline

[Service]
Type=oneshot
ExecStart=/usr/bin/true

In the isolated test, this file produced an overall exposure level of 9.4, marked UNSAFE. That expectedly high value is a baseline, not an application failure. It shows that the unit has not declared boundaries for its filesystem, privileges, devices, or network families.

Build the restriction set around the service

Before adding settings, write down what the process must do: which paths it reads and writes, whether it needs network sockets, whether it needs devices, which users it runs as, and whether it must change kernel or mount state. Check its packaged unit, service documentation, logs, and a staging run. A web service that needs a writable cache has different requirements from a local batch job that only reads a configuration file.

The following example is intentionally narrow. It is suitable as an offline demonstration because /usr/bin/true does not need writable host paths, devices, privileges, or internet sockets. It is not a drop-in profile for a daemon.

[Unit]
Description=Example service reviewed offline

[Service]
Type=oneshot
ExecStart=/usr/bin/true
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native

NoNewPrivileges=yes blocks the service and its children from gaining privileges through mechanisms such as set-user-ID executables. RestrictSUIDSGID=yes complements it by restricting creation of set-user-ID and set-group-ID files. These controls can break software that intentionally changes identity or installs privileged helpers, so confirm that behavior before applying them.

ProtectSystem=strict makes most of the filesystem read-only or inaccessible from the service’s mount namespace. It is powerful precisely because it can expose hidden writes. If a service needs a state directory, declare the smallest approved exception with ReadWritePaths= or use systemd’s managed StateDirectory= facility instead of making a broad tree writable. ProtectHome=yes blocks access to home directories, while PrivateTmp=yes gives the service a separate temporary area. Together they reduce accidental dependence on user files and shared temporary data.

PrivateDevices=yes, ProtectKernelTunables=yes, ProtectKernelModules=yes, and ProtectControlGroups=yes remove access that ordinary application services rarely need. They should be reviewed carefully for hardware agents, container runtimes, and monitoring software because those categories may legitimately require devices, cgroups, or kernel interfaces.

The last three settings tighten execution and network behavior. LockPersonality=yes prevents the process from changing its execution-domain personality. SystemCallArchitectures=native limits system calls to the host’s native architecture. MemoryDenyWriteExecute=yes prevents writable memory from becoming executable, but may conflict with runtimes that generate executable code. RestrictAddressFamilies=AF_UNIX permits only local Unix-domain sockets in this demonstration; a network-facing service needs the specific address families it uses, commonly AF_INET and AF_INET6, and then needs separate egress policy.

Compare the proposed unit before installing it

Analyze the hardened copy before it reaches the unit directory:

systemd-analyze security --offline=yes ./example-hardened.service
systemd-analyze verify ./example-hardened.service

The first command shows whether the controls were recognized and how they changed the exposure result. systemd-analyze verify is a syntax and unit-structure check; it does not prove that the application works under the sandbox. Treat unknown directives, malformed settings, and unexpected analyzer output as stop conditions.

The isolated 257.13 run changed the overall exposure level from 9.4 to 6.3. The remaining report still identified UMask= as unset. That result was useful because it prevented a misleading conclusion: the example was more constrained, but it was not close to a universal hardening profile. A service that creates files should set an appropriate UMask= after confirming the permissions expected by its consumers. For a private service state, UMask=0077 is often a sensible starting point; a service that publishes group-readable files may need a different value.

Do not optimize only for a low number. Some analyzer recommendations are intentionally unsuitable for a particular service. A database may require network sockets and a writable data directory. An agent may need a device. Record each remaining exposure with one of three decisions: restrict it, grant the smallest necessary exception, or leave it deliberately available with an operational reason. That record is more valuable than a copied configuration block.

Test the real service in staging

After the offline review, apply the candidate settings in a staging unit or a scoped systemd drop-in. Reload the manager and restart only the authorized test service:

sudo systemctl daemon-reload
sudo systemctl restart <service-name>
systemctl status <service-name> --no-pager
journalctl -u <service-name> -b --no-pager

Run the application’s own health check and one representative request or job. Inspect logs for denied paths, failed socket creation, and permissions errors. If the service cannot write where expected, add a narrow writable path rather than weakening ProtectSystem for the whole filesystem. If a runtime fails with executable-memory errors, establish whether it truly requires just-in-time compilation before changing MemoryDenyWriteExecute.

Repeat systemd-analyze security against the installed unit after the staging result is accepted. Keep its output alongside the configuration diff and the functional test record. This creates a small, reviewable loop: the analyzer identifies a declared boundary, staging proves whether the boundary fits, and the final output records what remains exposed by design.

A systemd sandbox is most useful when it describes a service’s real behavior rather than an aspirational template. Offline analysis gives a safe first measurement, and a before-and-after result makes the proposed change concrete. Use the score to start the review, then rely on the service’s staging behavior and the documented exceptions to decide which restrictions belong in production.

Sources