LinuxV · sudo and Privileged Accesssudo design
Shell escapes, NOEXEC, and sudoedit
What you'll learn
- Explain why an unpinned command grant is equivalent to ALL
- Recognise the three shell-escape classes in a sudoers rule
- Pin command arguments correctly and know the wildcard traps
- Apply NOEXEC and sudoedit where each one actually helps
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11
Most over-grants in production sudoers files are not written by careless people. They are written by careful people who believed a rule was bounded when it was not.
sudo checks the command line, then calls execve(). After
that it has no further control. Whatever it started is root,
and whatever that program can be persuaded to do, root does.
The grammar trap
man 5 sudoers states the rule plainly:
A command name is a fully qualified file name which may include shell-style wildcards. A simple file name allows the user to run the command with any arguments they wish.
A rule that names only a path grants that command with every possible argument. This one looks like least privilege:
%backup ALL=(root) /usr/bin/rsync
It is not. rsync copies any path to any path as root, and
--rsh runs a command of your choosing. Three of the many
consequences:
| Intent | Actual capability |
|---|---|
Copy /srv/data to the backup target | sudo rsync /tmp/pwn /etc/sudoers.d/99-pwn writes a new sudoers rule |
| Read the files being backed up | sudo rsync /etc/shadow /tmp/ exfiltrates every password hash |
| Run a network transfer | sudo rsync -e 'sh -c "sh 0<&2 1>&2"' 127.0.0.1:/dev/null opens a root shell |
The group has root. Nobody wrote ALL, and nobody had to.
The three escape classes
Before granting any command, ask which of these it can do as
root. If the answer is any of them, the unpinned grant is
ALL.
| Class | What it gives you | Examples |
|---|---|---|
| Spawn a subprocess | A root shell, directly | vi (:!sh), less (!sh), awk (BEGIN{system("/bin/sh")}), find -exec, tar --checkpoint-action=exec=, env, nmap, zip -TT |
| Write an arbitrary path | A new sudoers rule, a cron job, a modified /etc/passwd | rsync, cp, dd, tee, tar -x, install, mv |
| Evaluate code or config | Arbitrary execution through an option | python, perl, apt-get -o APT::Update::Pre-Invoke::=, git -c core.pager=, systemctl and journalctl (both page long output through less, which has !) |
GTFOBins catalogues these breakouts binary by binary. Treat it as the checklist you run before writing a grant, not the report you read after the incident.
Pinning arguments
If a rule lists arguments, the command line must match them exactly. Three forms matter:
# Exactly these arguments, nothing else.
%ops ALL=(root) /usr/bin/systemctl restart nginx
# No arguments at all are permitted.
%backup ALL=(root) /usr/local/sbin/backup-run.sh ""
# Any trailing arguments are permitted - use with care.
%ops ALL=(root) /usr/bin/systemctl restart *
The "" form is the strongest thing you can write, and it is
the right form for a wrapper script that takes no input.
The trailing * form is weaker than it looks. systemctl restart * permits restarting any unit on the host,
including one the operator just wrote into a directory they
control.
NOEXEC
NOEXEC: tells sudo to stop the command it launches from
launching anything else. This is the man page’s own example:
aaron shanty = NOEXEC: /usr/bin/more, /usr/bin/vi
The implementation differs by build. Todd Miller’s sudo
preloads sudo_noexec.so, which stubs out the exec family -
so it does not cover statically linked binaries.
sudo-rs installs a seccomp() filter instead, which also
covers static binaries.
Apply it per rule, per alias, or globally with an escape hatch:
Cmnd_Alias VIEW = /usr/bin/less /var/log/audit/audit.log
Defaults!VIEW noexec
# Or globally, with explicit exceptions:
Defaults noexec
%deploy ALL=(root) EXEC: /usr/local/sbin/deploy.sh ""
sudoedit
sudo vi /etc/nginx/nginx.conf runs an editor as root. That
editor can shell out and can write anywhere. The file being
edited is irrelevant to what the grant actually permits.
sudoedit (also sudo -e) inverts the model:
- sudo copies the target file to a temporary file.
- Your editor runs as you, with your privileges.
- sudo copies the result back, preserving owner and mode.
The editor never runs as root, so there is nothing to escape from. Grant it by the built-in name, with no leading path:
# Correct - the sudoedit built-in.
%web ALL=(root) sudoedit /etc/nginx/nginx.conf
# Wrong - this grants nothing useful, and /usr/bin/vi
# would grant a root editor instead.
%web ALL=(root) /usr/bin/sudoedit /etc/nginx/nginx.conf
$ sudo -l -U webopsUser webops may run the following commands on web01:
(root) sudoedit /etc/nginx/nginx.conf
(root) NOEXEC: /usr/sbin/nginx -t
(root) /usr/bin/systemctl reload nginxIllustrative output
The wrapper pattern
When the task genuinely needs a powerful tool, the grant goes to a script you reviewed, not to the tool:
#!/bin/bash
# /usr/local/sbin/backup-run.sh - root:root 0755
set -euo pipefail
exec /usr/bin/rsync -a --delete \
--exclude-from=/etc/backup/excludes \
/srv/data/ /mnt/backup/srv-data/
%backup ALL=(root) /usr/local/sbin/backup-run.sh ""
The rules that make the pattern hold:
- The script takes no operator-supplied paths. If something must vary, accept a name and map it to a path inside the script.
- The script is owned by root and not writable by the granted
group. If
%backupcan edit the wrapper,%backuphasALL. - Every path inside the script is absolute.
Defaults secure_pathandenv_resetare on by default; do not disable them for the rule.
$ find /usr/local/sbin -maxdepth 1 -type f -perm /go+w -printf '%p %M %u:%g
'/usr/local/sbin/backup-run.sh -rwxrwxr-x root:backupIllustrative output
Reviewing an existing policy
$ sudo grep -REv '^[[:space:]]*(#|$)' /etc/sudoers /etc/sudoers.d//etc/sudoers.d/40-backup:%backup ALL=(root) /usr/bin/rsync
/etc/sudoers.d/50-ops:%ops ALL=(root) NOPASSWD: /usr/bin/journalctl
/etc/sudoers.d/60-app:%app ALL=(root) /usr/bin/tarIllustrative output
- For every rule, list the commands it grants. Resolve
Cmnd_Aliasentries first. - For each command, ask the three questions: can it spawn a subprocess, write an arbitrary path, or evaluate code or config?
- If the answer is yes and the rule has no argument list, treat it as a
(ALL) ALLgrant and raise it as a finding. - Rewrite it: pin the exact arguments, or replace the tool with a reviewed wrapper granted with the
""argument. - Add
NOEXEC:to any leaf tool - editor, pager, viewer - that has no reason to spawn anything. - Replace every
sudo <editor>grant withsudoedit <file>. - Confirm each wrapper is root-owned and not writable by the granted group.
- Validate with
visudo -c -f FILE, then confirm the result withsudo -l -U <user>.
Knowledge check
Knowledge check · 3 questions
Q1. The rule "%backup ALL=(root) /usr/bin/rsync" is equivalent to granting the backup group ALL.
Q2. An operator needs to edit /etc/nginx/nginx.conf. Which grant is correct?
Q3. Which statements about NOEXEC are correct? Select all that apply.
Passing score: 75%. Answers are checked in this browser.