Runtime verification for systemd sandboxing/hardening directives on a unit file.
A unit with NoExecPaths=/tmp and RestrictNamespaces=true under
RootDirectory= looks locked down. It isn't — /tmp stays executable,
and systemd-analyze security never notices (see
Example below).
systemd-analyze security <unit> checks which hardening directives are
present in a unit file. systemd-sandbox-check checks whether they are
actually enforced: it starts a transient unit with the same [Service]
properties, runs a battery of probes inside it, and reports directive by
directive whether each restriction holds and whether the application still
works under it.
Single statically linked binary, no runtime dependencies.
cd src
make
sudo cp systemd-sandbox-check /usr/local/bin/Requires gcc and glibc's static-linking support (glibc-devel-static on
openSUSE/RHEL, libc6-dev on Debian/Ubuntu).
Install outside
/home. The orchestrator bind-mounts its own binary into the transient sandbox to re-exec itself there. If the binary lives under a directory a target unit'sProtectHome=hides, that bind mount fails (status=203/EXEC)./usr/local/binis unaffected. The tool also checks this itself at startup and aborts with an explanation rather than lettingsystemd-runfail with an opaque error.
sudo systemd-sandbox-check --unit /path/to/some.serviceRequires root: creating a system-scope transient unit via systemd-run
requires privileges. Add --dry-run to print the systemd-run command
without running anything.
[PASS] protect_system_usr_rw (ProtectSystem): /usr is not writable
[PASS] readwritepaths_rw (ReadWritePaths): /var/lib/example writable as expected
[FAIL] protect_home (ProtectHome): /root is NOT a separate mount -- resolves to the host's real /root
[PASS] restrict_af_netlink (RestrictAddressFamilies): AF_NETLINK blocked as expected
[PASS] restrict_af_inet_control (RestrictAddressFamilies): AF_INET still works
[INFO] syscall_filter (SystemCallFilter): not dynamically probed for safety; see systemd-analyze security
Configured but not dynamically probed (static-only, see `systemd-analyze security`):
- SystemCallArchitectures=native
Summary: 19 PASS, 1 FAIL, 0 WARN, 3 INFO
Exit code is non-zero if any check reports FAIL, so it can be used as a
CI gate.
sudo systemd-sandbox-check --unit /path/to/some.service --exec-checkStarts the unit's real ExecStart command inside the transient sandbox and
polls systemctl show for --exec-check-timeout seconds (default: 5),
reporting whether it reached active or failed. Unlike the other
probes, this runs the actual application with its configured paths and
network bindings — if the same service is already running, expect
conflicts (e.g. a port already bound); stop it first for a clean result.
ExecStart= lines prefixed with +/!/!! (which alter or escape the
sandbox — see systemd.service(5)) are skipped with a warning rather than
run outside the sandbox.
sudo systemd-sandbox-check --unit /path/to/some.service --socket-unit /path/to/some.socketRuns static checks against the matching [Socket] section
(TriggerLimitIntervalSec=/TriggerLimitBurst= presence, Accept=/
MaxConnections= combinations, KeepAlive*= consistency) alongside the
[Service] probe battery.
| Category | Directives | How it's probed |
|---|---|---|
| Namespace & root isolation | PrivateUsers, ProcSubset, ProtectHostname, RestrictNamespaces, RootDirectory/RootImage |
confirms UID mapping, /proc visibility, the UTS namespace, and the process root actually differ from the host; confirms unshare(CLONE_NEWNS) is blocked |
| Filesystem write protection | ProtectSystem, ProtectHome, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectProc |
tries to write under /usr, write a kernel tunable, open /dev/kmsg, lists /proc/1; checks CAP_SYS_MODULE; checks for a dedicated mount at /root and a read-only mount flag on /sys/fs/cgroup via /proc/self/mountinfo (not a permission-based check -- root's default capabilities bypass DAC permissions regardless of the actual protection) |
| Path allow/denylists | ReadWritePaths, BindReadOnlyPaths, BindPaths, NoExecPaths/ExecPaths |
confirms each declared path is writable/read-only/executable exactly as configured, with positive controls for paths that should stay writable |
| Privilege & capability restriction | NoNewPrivileges, CapabilityBoundingSet, AmbientCapabilities, RestrictSUIDSGID, LockPersonality, ProtectClock |
reads NoNewPrivs/the capability bitmask directly; tries chmod +s and a personality() change |
| Network & execution restriction | RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime |
tries a blocked address family (plus an AF_INET positive control), an anonymous RWX mmap, and SCHED_FIFO |
| Resource control readback | OOMScoreAdjust, MemoryMax/MemoryHigh, TasksMax, LimitNOFILE, CPUWeight/IOWeight |
reads back the effective cgroup v2 / getrlimit() values |
PrivateTmp, PrivateDevices, UMask |
— | checks /tmp's mount source, that /dev's filesystem type differs from the host's real devtmpfs (not a specific device node -- that assumes a SATA/SCSI disk, which doesn't exist on NVMe-/virtio-only hosts) but /dev/null still works, and the mode of a freshly created file |
Positive controls (ReadWritePaths, BindPaths, AF_INET) run alongside
the negative ones, so an over-restrictive configuration is reported the
same way a missing restriction is.
Three checks run statically, without starting a transient unit (so they
also work under --dry-run, without root):
- The
"+"path-prefix convention forReadWritePaths=/ReadOnlyPaths=/InaccessiblePaths=/ExecPaths=/NoExecPaths=underRootDirectory=/RootImage=(see systemd/systemd#39935): a bare (non-+-prefixed) path under those directives resolves relative to the host root instead of the chroot whenRootDirectory=/RootImage=is also set; this is flagged. ProtectProc=invisible/noaccess/ptraceableset withoutCapabilityBoundingSet=excludingCAP_SYS_PTRACE: persystemd.exec(5), a process that still holdsCAP_SYS_PTRACEbypassesProtectProc=entirely, making it a no-op.[Socket]section checks, with--socket-unit(see above).
SystemCallFilter/SystemCallArchitectures (seccomp) are reported as
configured-but-not-probed, not exercised live — doing so would require
syscalls (reboot, mount, module load, ...) with real side effects if a
filter turns out not to be enforced. Cross-check them with
systemd-analyze security <unit>.
NoExecPaths=/tmp looks like it makes PrivateTmp=true's private /tmp
non-executable. Under one specific combination of directives, it silently
does not — and systemd-analyze security has no way to catch it, since it
only checks that NoExecPaths= is present, not what it actually covers.
examples/privatetmp-noexecpaths-bug.service
reproduces it:
sudo systemd-sandbox-check --unit examples/privatetmp-noexecpaths-bug.service
# [FAIL] no_exec_paths (NoExecPaths): copy of interpreter outside ExecPaths= EXECUTED -- allowlist not enforcedRootDirectory=/RootImage= alone is sufficient to trigger this — a bare
(non-+-prefixed) path in NoExecPaths=/ExecPaths=/etc. gets checked
against the host's real filesystem, not against the chroot. Once
RootDirectory= is set, that check silently applies to the host's /tmp
instead of the private /tmp mount inside the chroot — the two no longer
match, so the directive restricts a path the sandboxed process never
actually uses, while its real /tmp stays fully executable (see
systemd.exec(5) and systemd/systemd#39935).
RestrictNamespaces= has no effect on it either way — an earlier version
of this README claimed otherwise, based on a misreading of the tool's own
output (see the restrict_namespaces fix in the changelog for why running
that specific check could make an unrelated, later check's result look
different, even though nothing about the actual bug had changed). The fix:
prefix the path with +: NoExecPaths=+/tmp.
Every probe in the default run is either a no-op or self-contained to the transient sandboxed process, regardless of whether the restriction it tests turns out to be enforced: no probe reboots, remounts, changes the system clock, or loads a kernel module. Capability-gated actions of that kind are checked by reading the process's capability bitmask instead of performing them.
--exec-check is the exception: it runs the unit's actual ExecStart, so
it can have the same side effects the unit itself would have.
One static binary has two modes, dispatched via an internal-only CLI flag:
- Orchestrator (default): parses the target unit file, starts a
transient unit via
systemd-runwith the same[Service]properties, re-execs itself inside that transient unit to run the probe battery, and collects and prints the results. - Probe (
--ssc-probe-internal, not user-facing): runs the checks, reading its configuration from environment variables set by the orchestrator.
The probe process runs inside the transient unit, and therefore inherits
the target unit's own NoExecPaths=/ExecPaths= allowlist. A general
interpreter (e.g. python3) is not guaranteed to be executable there — for
example, a unit that only allows its own bundled interpreter to run would
block a system Python. Self-reexec avoids this dependency: the
orchestrator adds its own binary's path to the transient unit's
ExecPaths=/BindReadOnlyPaths=, so the probe process only depends on a
path the orchestrator controls.
sh tests/run.shBlack-box tests against the built binary's --version/--dry-run output:
CLI basics, unit-file parsing and systemd-run argv construction, and all
three static lints. Doesn't cover the live probe battery itself (needs root
and a real systemd-run), and is what CI runs on every push.
MIT, see LICENSE.