Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

systemd-sandbox-check

Runtime verification for systemd sandboxing/hardening directives on a unit file.

License Build status Follow me on GitHub


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.

Quick Start

1. Build

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's ProtectHome= hides, that bind mount fails (status=203/EXEC). /usr/local/bin is unaffected. The tool also checks this itself at startup and aborts with an explanation rather than letting systemd-run fail with an opaque error.

2. Run

sudo systemd-sandbox-check --unit /path/to/some.service

Requires 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.

3. Optional: --exec-check and --socket-unit

sudo systemd-sandbox-check --unit /path/to/some.service --exec-check

Starts 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.socket

Runs static checks against the matching [Socket] section (TriggerLimitIntervalSec=/TriggerLimitBurst= presence, Accept=/ MaxConnections= combinations, KeepAlive*= consistency) alongside the [Service] probe battery.

What it checks

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 for ReadWritePaths=/ReadOnlyPaths=/ InaccessiblePaths=/ExecPaths=/NoExecPaths= under RootDirectory=/ RootImage= (see systemd/systemd#39935): a bare (non-+-prefixed) path under those directives resolves relative to the host root instead of the chroot when RootDirectory=/ RootImage= is also set; this is flagged.
  • ProtectProc=invisible/noaccess/ptraceable set without CapabilityBoundingSet= excluding CAP_SYS_PTRACE: per systemd.exec(5), a process that still holds CAP_SYS_PTRACE bypasses ProtectProc= 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>.

Example: a restriction that looks enforced but isn't

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 enforced

RootDirectory=/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.

Safety

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.

How it works

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-run with 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.

Testing

sh tests/run.sh

Black-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.

License

MIT, see LICENSE.

About

Dynamically verify that systemd sandboxing/hardening directives on a unit actually take effect

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages