Stop Leaning on setuid sudo Alone: Practical run0 Privilege Elevation on Linux
Stop Leaning on setuid sudo Alone: Practical run0 Privilege Elevation on Linux sudo still works. It also still depends on a classic pattern: a setuid helper, caller environment inheritance (unless tightly filtered), an
Stop Leaning on setuid sudo Alone: Practical run0 Privilege Elevation on Linux
sudo still works. It also still depends on a classic pattern: a setuid helper, caller environment inheritance (unless tightly filtered), and a policy language that lives outside the service manager.
systemd 256+ ships a different elevation tool: run0.
run0 is not a rebranded sudoers parser. It is a multi-call alias of systemd-run that starts your command as a fresh transient service, authenticates through polkit, allocates an independent pseudo-TTY when you are on a terminal, and never relies on setuid/setgid bits to gain power. That makes it especially interesting on hardened hosts where NoNewPrivileges= is on the table, and on fleets that already treat systemd as the source of truth for process isolation.
This guide is written against the run0(1) / systemd-run(1) manuals and a live systemd 257 install (Debian trixie). Newer releases add more knobs; stick to the man page on your box when in doubt.
What problem it solves
Operator elevation usually fails in one of these ways:
- The elevated process still carries too much of the callerβs environment and session assumptions.
- Authentication and the privileged program share the same TTY in ways that are hard to reason about.
- setuid helpers stop working (or become undesirable) when the system manager enforces
NoNewPrivileges=yes. - One-off root work is invisible to cgroup accounting, journal unit filters, and slice placement.
run0 attacks those points directly:
- No credential inheritance from the caller into the elevated command β the service manager forks a clean service context.
- polkit authentication, with the prompt isolated from the terminal when possible.
- Independent PTY for the invoked command (default when stdio is a TTY).
- No SetUID/SetGID implementation path.
- Sessions go through the
systemd-run0PAM stack.
If you already wrote sudoers.d policy, keep it for command allowlists that polkit does not express well. If you already use plain systemd-run to sandbox one-offs, keep that for non-interactive isolation. run0 sits in the middle: interactive (or scripted) privilege change that looks like sudo, but executes like a managed unit.
Requirements
-
systemd β₯ 256 for
run0itself (--pty/--pipe/ shell prompt prefix land in 257 on the manuals used here). - Working polkit (
polkitd) for non-root callers. - Permission to manage units β by default that is polkit action
org.freedesktop.systemd1.manage-units.
Check what you have:
systemctl --version | head -n1
command -v run0
ls -l "$(command -v run0)"
run0 --version | head -n1
On a typical install you should see something like:
systemd 257 (257.x)
/usr/bin/run0 -> systemd-run
Confirm polkit is alive:
systemctl is-active polkit
pkaction --action-id org.freedesktop.systemd1.manage-units
On Debian/Ubuntu-style defaults, that action is:
-
allow_any/allow_inactive: auth_admin -
allow_active(active local session): auth_admin_keep
And the stock admin identity rule often nominates unix-group:sudo as the administrative group. That does not mean run0 reads /etc/sudoers. It means polkit and sudo frequently share the same human admin group name.
Mental model
you (uid=1000)
β run0 [options] command...
βΌ
polkit: org.freedesktop.systemd1.manage-units
β auth_admin / auth_admin_keep
βΌ
PID 1 starts a transient .service (often under user.slice for run0)
β fresh execution context
β optional independent PTY
β PAM stack: systemd-run0
βΌ
command runs as root (default) or --user=/--group=
Important implementation detail from the manual: run0 is a symbolic link to systemd-run. Invoking the binary as run0 selects the elevation personality; invoking it as systemd-run selects the general transient-unit tool.
1) Everyday elevation
Interactive root shell (no command β shell):
run0
One-shot root command:
run0 id
run0 cat /etc/shadow | head -n1 # careful: still privileged output
run0 systemctl restart chrony
Run a specific shell explicitly:
run0 --setenv=SHELL=/bin/bash
# or
run0 /bin/bash -l
Notes that surprise sudo veterans:
- With no command, the shell defaults to the originating userβs shell, not the target userβs shell (local case).
- With
--machine=, the fallback shell is/bin/sh. - Unlike
sudo -ifolklore,run0always uses login-shell semantics for shells it starts (manual wording on current docs: always login shell semantics regardless of-i-style habits from sudo).
2) Become another user, not only root
# service account shell / command
run0 --user=www-data --group=www-data id
run0 -u postgres -g postgres --chdir=/var/lib/postgresql psql --version
# interactive as that user
run0 -u deploy
Working directory rules:
- Root elevation: default cwd is the clientβs current directory.
- Non-root target user: default cwd is that userβs home directory.
- Override with
-D/--chdir=:
run0 -D /etc/nginx nginx -t
run0 -u backup -D /var/backups /usr/local/bin/backup-now
3) See the unit, not just the exit code
Because elevation is a transient service, you can observe it like any other unit.
# In one terminal, start a longer session:
run0 --unit=admin-shell.service
# Elsewhere:
systemctl status admin-shell.service --no-pager
systemctl show admin-shell.service -p MainPID -p User -p Slice -p ControlGroup
journalctl -u admin-shell.service -n 50 --no-pager
Useful properties to pass through --property= (same assignment syntax as systemctl set-property):
# Hard ceiling example for a risky maintenance command
run0 --property=MemoryMax=512M --property=TasksMax=100 \
--description="compress logs" \
tar -C /var/log -czf /root/logs.tgz .
# CPU niceness without a full policy rewrite
run0 --nice=10 updatedb
Place the session in a slice:
run0 --slice=system-admin.slice bash
# or inherit/nest relative to the callerβs slice
run0 --slice-inherit --slice=maint bash
Default slice for run0 is user.slice (per run0(1)), which is worth remembering if you expected system.slice from bare systemd-run --system habits.
4) TTY vs pipe mode
From systemd 257:
| Mode | Flag | When to use |
|---|---|---|
| Pseudo-TTY | --pty |
Interactive programs, shells, anything that wants a terminal |
| Pass-through stdio | --pipe |
Pipelines and scripts where FDs should flow through |
| Auto | (default) | If stdin/stdout/stderr are all TTYs β PTY; otherwise β pipe |
# Force pipeline-friendly behavior
run0 --pipe sh -c 'gzip -c </var/log/syslog' > /tmp/syslog.gz
# Force a PTY even if you are unsure about detection
run0 --pty htop
Security note from the manual: TTY isolation is a core feature. Do not casually hand raw terminal control to untrusted programs when overriding defaults.
5) Environment: what you get and what you set
run0 inherits the system manager environment, not your full interactive profile dump. On top of that it sets:
| Variable | Meaning |
|---|---|
$TERM |
Copied from caller (override with --setenv) |
$SUDO_USER |
Originating username |
$SUDO_UID |
Originating UID |
$SUDO_GID |
Originating primary GID |
$SHELL_PROMPT_PREFIX |
Default superhero emoji prefix when supported (257+) |
Pass more with repeated --setenv:
run0 --setenv=EDITOR=vim --setenv=LANG=C.UTF-8 visudo -c
run0 --setenv=HTTP_PROXY # value taken from caller env when =value omitted
Prompt cosmetics:
run0 --shell-prompt-prefix='(root) '
# or disable
run0 --shell-prompt-prefix= bash
# or via env for defaults
SYSTEMD_RUN_SHELL_PROMPT_PREFIX='' run0
Background tint (visual βyou are elevatedβ cue):
# default: reddish as root, yellowish as other UID
run0 --background=44 bash # blue
run0 --background= bash # disable tint
6) polkit: where policy actually lives
run0 does not consult sudoers for the elevation decision. The gate is polkitβs unit-management action:
pkaction --verbose --action-id org.freedesktop.systemd1.manage-units
Expect a description like βManage system services or other unitsβ and defaults requiring administrative authentication.
Debianβs stock rule file commonly includes:
// /usr/share/polkit-1/rules.d/50-default.rules (vendor file β do not edit in place)
polkit.addAdminRule(function(action, subject) {
return ["unix-group:sudo"];
});
Local overrides belong under /etc/polkit-1/rules.d/ (or the distributionβs documented local path). Example lab-only pattern for a dedicated admin group active on the local console β tighten this for production:
// /etc/polkit-1/rules.d/60-admin-manage-units.rules
// Example only: prefer narrow groups + active-session checks in real fleets.
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
subject.isInGroup("wheel") &&
subject.local && subject.active) {
return polkit.Result.YES;
}
});
After changing rules, reload/restart polkit according to your distro (systemctl restart polkit is the blunt approach; some setups pick up rules automatically).
--no-ask-password skips interactive auth prompts. That is appropriate for already-authorized automation contexts, not as a way to bypass policy.
7) Why this pairs with NoNewPrivileges=
From systemd-system.conf(5):
# /etc/systemd/system.conf.d/10-nnp.conf # powerful; understand the blast radius
[Manager]
NoNewPrivileges=yes
When true, PID 1 and its children cannot gain new privileges through execve(2) via setuid/setgid bits or file capabilities. Classic sudo is exactly that class of helper. run0 is documented as the elevation approach that still works in environments where setuid support is unavailable, because the service manager starts the privileged unit instead of a setuid binary flipping credentials in-process.
Do not flip NoNewPrivileges= globally on a general-purpose distro without a test plan β lots of legacy tooling still assumes setuid. Use it where you intentionally build a setuid-free image.
8) Containers and machines
# Elevate inside a local machine/container known to machined
run0 --machine=webtest systemctl status nginx --no-pager
run0 --machine=webtest
This is the same --machine= plumbing family as systemd-run / machinectl, not SSH.
9) Practical operator cookbook
Drop into root for a change window
run0 --unit=change-window.service --description="2026-09-18 change window"
# work...
# exit
journalctl -u change-window.service --since "10 min ago" --no-pager
Restart a unit without keeping a root shell
run0 systemctl try-restart caddy.service
Edit a root-owned file with your $EDITOR semantics
run0 --setenv=EDITOR --setenv=TERM --chdir=/etc/ssh \
sh -c '"$EDITOR" sshd_config'
sshd -t # still verify as root if needed: run0 sshd -t
Bounded maintenance job
run0 \
--unit=apt-maintenance.service \
--property=Nice=10 \
--property=MemoryHigh=1G \
--property=MemoryMax=2G \
--property=TasksMax=300 \
--description="apt update && upgrade" \
bash -lc 'apt-get update && apt-get -y upgrade'
Verify identity and origin fields inside the session
run0 bash -lc 'id; printf "SUDO_USER=%s SUDO_UID=%s SUDO_GID=%s\n" \
"$SUDO_USER" "$SUDO_UID" "$SUDO_GID"'
You should see root (or the target user) for id, and your original account in the SUDO_* fields.
10) Failure modes and debugging
| Symptom | Likely cause | What to check |
|---|---|---|
| Immediate auth failure | polkit denied / no admin group |
pkaction, group membership, active session |
| Works on console, fails over SSH | agent / session class differences | run on a real active session; install a polkit agent if needed |
| Command not found inside run0 | clean service PATH
|
use absolute paths or --setenv=PATH=
|
| Interactive TUI garbled | pipe mode instead of PTY | pass --pty
|
| Unit left failed | command non-zero exit |
systemctl reset-failed / journalctl -u β¦
|
| βLooks like sudo but ignore sudoersβ | expected | policy is polkit + unit properties, not sudoers
|
Debug trail:
journalctl -u polkit.service -n 100 --no-pager
journalctl -t run0 -n 50 --no-pager # if tagged on your build
systemctl list-units 'run-*.service' --all --no-pager
11) What run0 is not
-
Not a full
sudoersreplacement for per-command argument filtering,NOPASSWDhost-by-host matrices, or existing enterprise sudo policy distributions. -
Not the same article as βsandbox any one-off with
systemd-runβ β that tool is broader (timers, scopes, user manager, etc.) and is not specifically the elevation UX. -
Not
pkexecwith a different name β both use polkit, butrun0is explicitly a systemd transient-service elevation path with unit properties, slices, and journal integration. -
Not a substitute for service hardening (
ProtectSystem=,CapabilityBoundingSet=, Landlock, seccomp). Elevation gets you in; hardening still belongs on long-running units.
Migration cheat sheet
| Old habit | run0-shaped habit |
|---|---|
sudo -s |
run0 |
sudo -u postgres -i |
run0 -u postgres |
sudo -E cmd |
selective run0 --setenv=NAME (prefer explicit) |
sudo nice -n 10 cmd |
run0 --nice=10 cmd |
sudo systemd-run β¦ |
often just run0 --property=β¦
|
| audit via sudo logs only | also journalctl -u <unit> + polkit events |
Keep sudo installed while you migrate muscle memory. Many images will ship both for years.
Rollout suggestion
- Confirm systemd β₯ 256 and polkit healthy.
- Practice
run0 idandrun0shells on a lab user in the admin group. - Teach absolute paths and
--setenvinstead of blanket env preservation. - Move break-glass interactive work to
run0 --unit=β¦so sessions are nameable in the journal. - Only then consider narrowing sudoers or building setuid-free images that rely on service-manager elevation.
References
-
run0(1)β privilege elevation; multi-call alias ofsystemd-run(systemd 256+) -
systemd-run(1)β transient services/scopes and shared options -
systemd-system.conf(5)βNoNewPrivileges=manager setting -
polkit(8)/pkaction(1)β authorization actions and admin rules - Debian policy file
org.freedesktop.systemd1.policyβmanage-unitsdefaults (auth_admin/auth_admin_keep) - Vendor rules such as
/usr/share/polkit-1/rules.d/50-default.rulesβ admin group mapping (oftenunix-group:sudo) - Local verification base for this article: systemd 257.13 on Debian trixie,
run0 -> systemd-run, polkitd present
sudo taught a generation of admins to borrow root carefully. run0 keeps the careful part, drops the setuid helper, and makes the elevated session a first-class systemd citizen. If your hosts already boot, isolate, and heal through units, elevating through units is the consistent next step.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.