Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 9 min read

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:

  1. No credential inheritance from the caller into the elevated command β€” the service manager forks a clean service context.
  2. polkit authentication, with the prompt isolated from the terminal when possible.
  3. Independent PTY for the invoked command (default when stdio is a TTY).
  4. No SetUID/SetGID implementation path.
  5. Sessions go through the systemd-run0 PAM 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 run0 itself (--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 -i folklore, run0 always 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 sudoers replacement for per-command argument filtering, NOPASSWD host-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 pkexec with a different name β€” both use polkit, but run0 is 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

  1. Confirm systemd β‰₯ 256 and polkit healthy.
  2. Practice run0 id and run0 shells on a lab user in the admin group.
  3. Teach absolute paths and --setenv instead of blanket env preservation.
  4. Move break-glass interactive work to run0 --unit=… so sessions are nameable in the journal.
  5. Only then consider narrowing sudoers or building setuid-free images that rely on service-manager elevation.

References

  • run0(1) β€” privilege elevation; multi-call alias of systemd-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-units defaults (auth_admin / auth_admin_keep)
  • Vendor rules such as /usr/share/polkit-1/rules.d/50-default.rules β€” admin group mapping (often unix-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.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.