Skip to main content
RunBook Academy

LinuxV · sudo and Privileged Accesssudo design

Shell escapes, NOEXEC, and sudoedit

Intermediate⏱ ~14 minsudovisudosudoedit

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

Not yet marked complete on this device.

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:

IntentActual capability
Copy /srv/data to the backup targetsudo rsync /tmp/pwn /etc/sudoers.d/99-pwn writes a new sudoers rule
Read the files being backed upsudo rsync /etc/shadow /tmp/ exfiltrates every password hash
Run a network transfersudo 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.

ClassWhat it gives youExamples
Spawn a subprocessA root shell, directlyvi (:!sh), less (!sh), awk (BEGIN{system("/bin/sh")}), find -exec, tar --checkpoint-action=exec=, env, nmap, zip -TT
Write an arbitrary pathA new sudoers rule, a cron job, a modified /etc/passwdrsync, cp, dd, tee, tar -x, install, mv
Evaluate code or configArbitrary execution through an optionpython, 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:

  1. sudo copies the target file to a temporary file.
  2. Your editor runs as you, with your privileges.
  3. 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
Read-only / Safeeffective grants
$ sudo -l -U webops
User 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 nginx

Illustrative 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 %backup can edit the wrapper, %backup has ALL.
  • Every path inside the script is absolute. Defaults secure_path and env_reset are on by default; do not disable them for the rule.
Read-only / Safewritable wrapper check
$ 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:backup

Illustrative output

Reviewing an existing policy

Read-only / Safeevery effective rule
$ 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/tar

Illustrative output

  1. For every rule, list the commands it grants. Resolve Cmnd_Alias entries first.
  2. For each command, ask the three questions: can it spawn a subprocess, write an arbitrary path, or evaluate code or config?
  3. If the answer is yes and the rule has no argument list, treat it as a (ALL) ALL grant and raise it as a finding.
  4. Rewrite it: pin the exact arguments, or replace the tool with a reviewed wrapper granted with the "" argument.
  5. Add NOEXEC: to any leaf tool - editor, pager, viewer - that has no reason to spawn anything.
  6. Replace every sudo <editor> grant with sudoedit <file>.
  7. Confirm each wrapper is root-owned and not writable by the granted group.
  8. Validate with visudo -c -f FILE, then confirm the result with sudo -l -U <user>.

Knowledge check

Knowledge check · 3 questions

  1. Q1. The rule "%backup ALL=(root) /usr/bin/rsync" is equivalent to granting the backup group ALL.

  2. Q2. An operator needs to edit /etc/nginx/nginx.conf. Which grant is correct?

  3. Q3. Which statements about NOEXEC are correct? Select all that apply.

Passing score: 75%. Answers are checked in this browser.