sudo and least privilege

sudo-rs on Ubuntu, sudo on RHEL, and the logs.

Beginner14 min · lesson 15 of 29

sudo runs one command with another user's privileges, usually root's, after checking a rule that says you are allowed. It is how you stay logged in as your own limited account and reach for root only for the task that needs it, with every elevation recorded against your name. This lesson shows which sudo Ubuntu 26.04 ships (it is not the one most guides describe), how to read your own rights, how to write and validate a narrow rule, what the logs record, and where Ubuntu and RHEL differ. The goal is least privilege: grant the exact command needed, and no path from it to a root shell.

Which sudo is this?

Ubuntu 26.04 replaced the original sudo with sudo-rs, a reimplementation in Rust that reads the same /etc/sudoers rules but supports a deliberately smaller set of features. It matters because its output and its error messages differ from the classic tool, and because it rejects some classic syntax. Check what you have:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo --version readlink -f /usr/bin/sudo
sudo-rs 0.2.13-0ubuntu1.2 /usr/lib/cargo/bin/sudo

sudo --version reports sudo-rs 0.2.13, and /usr/bin/sudo resolves through the alternatives system to /usr/lib/cargo/bin/sudo. The original sudo is still installed as sudo.ws for when you need a feature sudo-rs does not have. RHEL 10 ships the classic sudo (version 1.9.17) at /usr/bin/sudo, so the two families behave differently in the details below. sudo-rs refuses some classic settings outright rather than ignoring them, for example wildcards inside command arguments and session recording (log_output), so a sudoers file copied from an older guide can fail validation. Where a classic-only feature is a hard requirement, update-alternatives can point sudo at sudo.ws; the hardening course covers that choice.

A narrow rule, validated before it is installed

To follow along you need something to grant. The setup below creates a small lab service, ess-sudo-app (a unit that only runs sleep; the systemd lesson explains unit files), an operator account ess-sudo-ops, and a service account ess-sudo-svc with a configuration file only its group may read:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo tee /etc/systemd/system/ess-sudo-app.service > /dev/null <<'EOF' [Unit] Description=SecOpsLog lab service [Service] ExecStart=/usr/bin/sleep infinity EOF sudo systemctl daemon-reload sudo systemctl start ess-sudo-app.service sudo useradd -m -s /bin/bash ess-sudo-ops sudo useradd --system --no-create-home --shell /usr/sbin/nologin ess-sudo-svc sudo mkdir -p /etc/ess-sudo echo 'workers=2' | sudo tee /etc/ess-sudo/app.conf > /dev/null sudo chown root:ess-sudo-svc /etc/ess-sudo/app.conf sudo chmod 0640 /etc/ess-sudo/app.conf

Rules live in /etc/sudoers or, better, in separate files under /etc/sudoers.d/ so each grant stays small and easy to review. Here is a rule that lets the operator restart that one unit, and read its status, and nothing else. Save it in your home directory first (with any editor) as ~/50-ess-sudo-ops:

~/50-ess-sudo-ops (installed as /etc/sudoers.d/50-ess-sudo-ops)
# ess-sudo-ops may bounce one service on this host, and nothing else.
# who host=(run-as) TAG: exact command, arguments included
ess-sudo-ops ALL=(root) NOPASSWD: /usr/bin/systemctl restart ess-sudo-app.service, \
/usr/bin/systemctl --no-pager status ess-sudo-app.service

Read a rule from the left. The user comes first (a name starting with % is a group, as in Ubuntu's own %sudo ALL=(ALL:ALL) ALL, which lets members of sudo run anything as anyone). ALL in the host field means on any host, which only matters when one sudoers file is shared by many machines. (root) is the account the command may run as, NOPASSWD: skips the password prompt, and the rest is the exact command. The command and its arguments are part of the rule, so any other form is refused. --no-pager is part of it on purpose: systemctl status normally shows long output in less, and systemctl(1) recommends disabling the pager when untrusted users run it with elevated privileges, because a pager can start other programs.

A broken sudoers file can lock everyone out of sudo, so never edit it with a plain editor. visudo -cf FILE checks a candidate file's syntax without installing it, which is what to run before the file goes anywhere. Only after it passes do you install it and re-check the whole configuration.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo visudo -cf ~/50-ess-sudo-ops
/home/deploy/50-ess-sudo-ops: parsed OK
$ sudo install -m 0440 -o root -g root ~/50-ess-sudo-ops /etc/sudoers.d/50-ess-sudo-ops sudo visudo -c
/etc/sudoers: parsed OK

visudo -cf reported parsed OK, then install placed the file with mode 0440 owned by root, and visudo -c confirmed the entire sudoers set still parses. On a fleet, run visudo -c in a check before the file ships. The same check refuses a bad rule. A common mistake is naming a command without its full path; here a one-line file with that mistake is checked, and sudo-rs requires an absolute path and says so:

deploy@web01 · Ubuntu 26.04 LTS
$ echo 'ess-sudo-ops ALL=(root) systemctl restart ess-sudo-app.service' > ~/bad-rule sudo visudo -cf ~/bad-rule
/home/deploy/bad-rule:1:25: syntax error: fully qualified path needed ess-sudo-ops ALL=(root) systemctl restart ess-sudo-app.service ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ visudo: invalid sudoers file

It names the file, line and column, explains that a fully qualified path is needed, and exits non-zero, so a check step can fail the change before it reaches a machine.

A narrow rule can still be a full root shell
The real prize an attacker hunts for is a sudo rule that names a program which can launch other programs: an editor (vim can run :!sh), a pager (less can run !sh), an interpreter (python, awk, perl), or tools like find -exec and tar --checkpoint-action. Granting one of those with NOPASSWD is granting a root shell, not one command. When a user must edit one file as root, grant sudoedit /path (supported by sudo-rs) instead of an editor, so the editor runs as the user on a copy. The hardening and detection courses go deeper into these escapes.

What the operator may run, and what happens when they try

sudo -l lists the rules that apply to you. The next terminals run as the operator: to do the same, switch to that account with sudo -iu ess-sudo-ops (-i starts a login shell, -u names the account; the operator needs no password of their own for this) and type exit to come back. sudo-rs prints the rules plainly, with no "Matching Defaults" block:

ess-sudo-ops@web01 · Ubuntu 26.04 LTS
$ sudo -l
User ess-sudo-ops may run the following commands on web01: (root) NOPASSWD: /usr/bin/systemctl restart ess-sudo-app.service, /usr/bin/systemctl --no-pager status ess-sudo-app.service

Now the operator reads the status, typed exactly as the rule names it, runs the allowed restart, then tries a command just outside the rule:

ess-sudo-ops@web01 · Ubuntu 26.04 LTS
$ sudo systemctl --no-pager status ess-sudo-app.service
● ess-sudo-app.service - SecOpsLog lab service Loaded: loaded (/etc/systemd/system/ess-sudo-app.service; static) Active: active (running) since Sun 2026-09-27 08:39:44 UTC; 140ms ago …
$ sudo systemctl restart ess-sudo-app.service && echo restarted
restarted
$ sudo systemctl stop ess-sudo-app.service
sudo: I'm sorry ess-sudo-ops. I'm afraid I can't do that

restart runs. stop is refused with sudo-rs's denial message, and the command never runs. This is least privilege on one screen: the operator can bounce the service, which is the job, and cannot stop it, wipe it or open a shell. Note that the rule named restart ess-sudo-app.service exactly, so even systemctl restart ess-sudo-app without the .service suffix would be refused, because sudoers matches the whole command line.

What sudo does on every call
1You run sudo <command>
as your normal, limited account
2sudo checks the rules
in /etc/sudoers and /etc/sudoers.d
3Password, unless NOPASSWD
or a still-valid recent entry
4Runs the one command as root
then hands control back to you
5Writes a log line
who, what, when, on this host

Every allowed use is written down

On Ubuntu the records go to /var/log/auth.log, which the adm group may read, so an admin account reads it without sudo. These are the last two sudo lines for the operator:

deploy@web01 · Ubuntu 26.04 LTS
$ grep "sudo: ess-sudo-ops" /var/log/auth.log | tail -n 2
2026-09-27T08:39:44.302434+00:00 web01 sudo: ess-sudo-ops : PWD=/home/ess-sudo-ops ; USER=root ; COMMAND=/usr/bin/systemctl --no-pager status ess-sudo-app.service 2026-09-27T08:39:44.325748+00:00 web01 sudo: ess-sudo-ops : PWD=/home/ess-sudo-ops ; USER=root ; COMMAND=/usr/bin/systemctl restart ess-sudo-app.service

Each line names the user, the working directory, the target user and the exact command: the status and the restart the operator ran. Notice what is missing: the refused stop came after the restart, yet it has no line. This is specific to the version tested here. sudoers-rs(5), the manual page installed with sudo-rs 0.2.13, says that by default it logs both successful and unsuccessful attempts, but in this lab the refusal was shown to the operator on screen and written nowhere, in auth.log or the journal. Classic sudo on RHEL logs both, as the callout below shows. So on Ubuntu 26.04 do not build an alert on refused-sudo lines without testing that they appear, and re-test after sudo-rs updates. To answer "what could this account do?", read its rules with sudo -l -U USER rather than waiting for the log.

Become another user, not only root

Sometimes you do not need root, you need to be a service account for one command. sudo -u USER runs a command as that account, which is how you read a file or open a shell as www-data or a database user without their password.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo -u ess-sudo-svc id -un sudo -u ess-sudo-svc cat /etc/ess-sudo/app.conf
ess-sudo-svc workers=2

Here the command ran as ess-sudo-svc and read the file that account's group owns, then handed the identity straight back: a smaller blast radius than a root shell.

Three related habits are worth knowing. After you type your password, sudo remembers it per terminal for a while (15 minutes on Ubuntu, 5 on RHEL; timestamp_timeout in sudoers sets it), and sudo -k forgets it at once, which is a good reflex before you walk away from a session. sudo -i starts root's login shell (reading root's startup files) and sudo -s starts a root shell without reading them; both are full root sessions, so prefer single commands, which each leave their own log line. su - switches user by asking for the target account's password, which on Ubuntu root does not have, so sudo is the normal route. Ubuntu's /etc/sudoers also sets Defaults use_pty, which runs each command in its own pseudo-terminal so a program run through sudo cannot push keystrokes back into your shell after it exits.

The RHEL callout: classic sudo

On Rocky Linux 10 the same rule, installed the same way, is reported differently. sudo -l prints a "Matching Defaults entries" block (with env_reset, secure_path and more), the admin group is wheel rather than sudo, and refused commands are logged to /var/log/secure.

deploy@rocky10 · Rocky Linux 10.2
$ sudo -l -U ess-sudo-ops
Matching Defaults entries for ess-sudo-ops on rocky10: !visiblepw, always_set_home, match_group_by_gid, always_query_group_plugin, env_reset, env_keep="COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS", env_keep+="MAIL PS1 PS2 QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE", env_keep+="LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES", env_keep+="LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE", env_keep+="LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY", secure_path=/sbin\:/bin\:/usr/sbin\:/usr/bin User ess-sudo-ops may run the following commands on rocky10: (root) NOPASSWD: /usr/bin/systemctl restart ess-sudo-app.service, /usr/bin/systemctl --no-pager status ess-sudo-app.service
$ sudo grep 'ess-sudo-ops :' /var/log/secure | tail -n 2
Sep 27 08:17:42 rocky10 sudo[1149990]: ess-sudo-ops : PWD=/home/ess-sudo-ops ; USER=root ; COMMAND=/usr/bin/systemctl restart ess-sudo-app.service Sep 27 08:17:43 rocky10 sudo[1150003]: ess-sudo-ops : command not allowed ; PWD=/home/ess-sudo-ops ; USER=root ; COMMAND=/bin/systemctl stop ess-sudo-app.service

The command not allowed line is exactly what a defender wants: on classic sudo a burst of them across many commands is someone probing what they can run, and it belongs in your alerts. secure_path in the Defaults block is a quiet win: it forces sudo to search a known, trusted list of directories, so a command named in a rule cannot be swapped for a look-alike planted in the caller's own PATH.

Try this

On a lab machine, create a throwaway user with sudo useradd -m -s /bin/bash ops1. Write ~/50-ops1 containing the single line ops1 ALL=(root) NOPASSWD: /usr/bin/systemctl --no-pager status cron.service, check it with sudo visudo -cf ~/50-ops1, install it with sudo install -m 0440 -o root -g root ~/50-ops1 /etc/sudoers.d/50-ops1 and run sudo visudo -c. Become the user with sudo -iu ops1, run sudo -l to read the grant and sudo systemctl --no-pager status cron.service (allowed), then sudo systemctl restart cron.service and read the refusal. Type exit, and on Ubuntu check with grep ops1 /var/log/auth.log which of the two was logged. Remove the drop-in with sudo rm /etc/sudoers.d/50-ops1, confirm sudo visudo -c still parses, and remove the user with sudo userdel -r ops1.

Takeaway

Grant the narrowest exact command, by absolute path, with no wildcard and no program that can spawn a shell, and validate every sudoers file with visudo -cf before it is installed. Read sudo --version so you know whether you are on sudo-rs or classic sudo, and remember that on Ubuntu's sudo-rs 0.2.13 only allowed commands reached the log in testing, so audit rights with sudo -l -U rather than trusting the log to show refusals.

Quick check
01A teammate proposes ci ALL=(root) NOPASSWD: /usr/bin/vim /etc/nginx/nginx.conf, arguing it only lets ci edit one file. What is the real problem?
Correct — A rule is only as narrow as what its program can do, and vim can open a root shell; grant sudoedit for the file instead.
Incorrect — It looks scoped, but the danger is the program it names, not the file it opens.
Incorrect — NOPASSWD works in sudoers.d files; that is not the issue here.
Incorrect — A crashed service is minor next to the root shell that vim hands over.
02A rule grants ops NOPASSWD: /usr/bin/systemctl restart web.service. The operator runs sudo systemctl stop web.service and is refused. Why?
Incorrect — Caching only affects whether a password is asked; it does not decide which commands are allowed.
Correct — The command and its arguments are part of the rule, so a different subcommand is a different command line and is refused.
Incorrect — The rule refuses stop outright because it is not listed; it is not a password prompt.
Incorrect — systemctl would stop the unit if sudo allowed the command; the refusal comes from the sudo rule, not systemd.
03On which platform does a refused sudo command reliably show up in the system log for you to alert on?
Incorrect — They differ: in the lab, Ubuntu's sudo-rs 0.2.13 logged allowed commands but not a policy refusal.
Incorrect — Classic sudo does record refusals, writing a "command not allowed" line to the log.
Correct — Classic sudo logs refused commands, so a burst of them is a signal; in the lab sudo-rs 0.2.13 showed the refusal on screen and logged nothing, although its man page says it logs failures.
Incorrect — It is the reverse: in the lab sudo-rs 0.2.13 wrote no line for a policy refusal, while classic sudo on RHEL did.

Related