Dropping privileges in a long-running bash script: setpriv vs runuser vs su, and why sudo -u is the wrong choice here

Last quarter I had a long-running bash daemon that needed to start as root (to bind a privileged port and read a key file in /etc) and then immediately drop to an unprivileged user before doing anything else. The “obvious” choice — sudo -u myuser ./worker.sh — turned out to be the worst of the available options. Here’s what’s available, what each one actually does to the process, and why I landed on setpriv.

The four contenders

  • sudo -u user — sudo invoking a command as a different user.
  • su user -c '...' — substitute user, run a single command.
  • runuser user -c '...' — like su, but designed to be called from privileged scripts.
  • setpriv --reuid=user --regid=user --init-groups — direct syscall to drop UID/GID/groups.

They all “drop privileges” in the loose sense. They differ in what else they do — and most of those side effects are either useful, useless, or actively harmful depending on your use case.

Why sudo -u is the wrong tool here

sudo is built for interactive use. When you call sudo -u, it does much more than drop privileges:

  • Logs the invocation to syslog (auth.log). Every fire of your daemon writes a line. This adds noise and can be misread as a security event by SIEM tools.
  • Resets most environment variables to a “safe” subset. If your script depended on $HOME, $PATH, or app-specific env, it’s gone unless you used --preserve-env.
  • Reads /etc/sudoers and applies any Defaults in there — which can include rate-limiting, password requirements (even for passwordless sudo, the lookups happen), and umask resets.
  • Drops to a new PAM session. Pluggable Authentication Modules can add latency (LDAP lookup, MOTD generation), and a misconfigured PAM stack will hang your script.
  • Spawns through a TTY allocator if one is needed.

For a daemon that runs every 30 seconds, the syslog noise alone is unacceptable. The PAM hangs are worse — I had a script silently freeze for 3 minutes when our LDAP server hiccuped, and sudo was sitting there waiting for it.

What su / runuser do

su was designed for “log in as that other user,” and it shows. It loads a login shell (running /etc/profile, the user’s .bash_profile), allocates a TTY, and runs your command inside it. Faster than sudo because it skips PAM authentication when called from root, but still slower than necessary because of all the shell-init machinery.

runuser is util-linux’s “su, but for scripts”: it skips the login-shell init, doesn’t allocate a TTY, and bypasses PAM. It’s the closest thing to a “drop privileges and run this command” primitive in the standard toolkit. Available on basically every modern Linux distro.

runuser -u myuser -- ./worker.sh

The -- separates runuser’s options from the command’s arguments. You can pass -g group to set the GID and -G group1,group2 for supplementary groups.

Why I prefer setpriv

setpriv is the lowest-level option: it’s a thin CLI over the setuid / setgid / setgroups / capset syscalls. No PAM, no shell init, no TTY allocation. It’s also explicit about exactly what’s being done:

setpriv \
  --reuid=worker \
  --regid=worker \
  --init-groups \
  --inh-caps=-all \
  --no-new-privs \
  -- ./worker.sh

Walking through:

  • --reuid=worker — set real UID. Numeric UIDs work too.
  • --regid=worker — set real GID.
  • --init-groups — read /etc/group and load supplementary group memberships. Without this, the worker has only its primary group, which often surprises you when it can’t read files in shared groups.
  • --inh-caps=-all — drop all inheritable Linux capabilities. Defense in depth: even if the binary has file capabilities, those don’t propagate.
  • --no-new-privs — set the kernel’s NO_NEW_PRIVS bit. This means no execve() in the child can ever gain privileges via setuid binaries or file caps. Closes the entire class of “the daemon executes /bin/su somehow” attacks.

The script that drops to worker via setpriv has noticeably less attack surface than the same script using sudo. And it’s faster — a setpriv invocation is roughly 1ms; a sudo invocation is 50-200ms depending on PAM config.

The “init the worker as root, then drop” pattern

Sometimes the daemon really needs to do one thing as root (bind to port 80, mlock memory, read /etc/key) and then run as worker for the rest of its life. The full pattern:

#!/bin/bash
set -euo pipefail
[ "$EUID" -eq 0 ] || { echo "Run as root for initial setup"; exit 1; }

# Privileged work
/bin/cp /etc/key /run/worker/key
chown worker:worker /run/worker/key
chmod 400 /run/worker/key

# Drop and re-exec
exec setpriv \
  --reuid=worker --regid=worker \
  --init-groups --inh-caps=-all --no-new-privs \
  -- /opt/worker/loop.sh

exec replaces the bash process with the unprivileged one — no parent root process hanging around to be exploited. By the time loop.sh is running, there’s no path back to root.

Quick decision tree

  • Interactive command at the prompt → sudo -u. The logging and PAM are features, not bugs.
  • One-off admin script you’ll run by hand → su or runuser. Familiar, less overhead than sudo.
  • Long-running service that drops privileges and re-execs → setpriv. Explicit, fast, hardenable.
  • systemd-managed service → don’t do any of this; use User= + NoNewPrivileges=yes in the unit. systemd is doing the same thing, more visibly.

If you’re reaching for sudo -u in a daemon-style script, stop. There’s a more appropriate tool, and the upgrade is mostly about removing complexity, not adding it.

Cover photo: Peter Dyllong on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.