Hardening sudo

Narrow rules for sudo-rs and classic sudo.

Intermediate14 min · lesson 5 of 24

Every sudo rule is a promise that a user may run something as root, and a loose rule is a root shell with extra steps. In this lesson you write rules that grant one operation each, check every file before sudo reads it, keep editors and helper programs from becoming shells, and find out what each sudo implementation records. That last part matters more than it used to: Ubuntu 26.04 and RHEL 10 no longer run the same sudo, and a hardening snippet written for one can fail on the other. The basics (sudo -l, visudo, /etc/sudoers.d) are in Linux essentials, and the accounts lesson showed how to review who is in the sudo and wheel groups.

Two implementations, one file format

Linux essentials showed the split: Ubuntu 26.04 runs sudo-rs, a Rust reimplementation, through the /usr/bin/sudo alternative, and RHEL 10 runs the original sudo 1.9.17. Both read /etc/sudoers and /etc/sudoers.d, but sudo-rs supports only a subset of the syntax, so a snippet from a classic-sudo hardening guide needs checking before it goes near a live server. visudo -cf FILE checks a single file without installing it; this candidate collects lines such guides recommend:

~/classic-hardening (a candidate file, checked before use)
# Lines from classic-sudo hardening guides, checked before use
Defaults logfile="/var/log/sudo.log"
Defaults log_input, log_output
Defaults requiretty
Defaults use_pty
Defaults timestamp_timeout=5
hard-sudo-web ALL=(root) sudoedit /etc/hard-sudo/sites/*
hard-sudo-ops ALL=(root) sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /usr/local/bin/deploy
deploy@web01 · Ubuntu 26.04 LTS
$ sudo visudo -cf ~/classic-hardening
/home/deploy/classic-hardening:2:10: syntax error: unknown setting: 'logfile' Defaults logfile="/var/log/sudo.log" ^~~~~~~ /home/deploy/classic-hardening:3:10: syntax error: unknown setting: 'log_input' Defaults log_input, log_output ^~~~~~~~~ /home/deploy/classic-hardening:4:10: syntax error: unknown setting: 'requiretty' Defaults requiretty ^~~~~~~~~~ /home/deploy/classic-hardening:7:26: syntax error: wildcards are not allowed in command arguments hard-sudo-web ALL=(root) sudoedit /etc/hard-sudo/sites/* ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ /home/deploy/classic-hardening:8:26: syntax error: digest specifications are not supported hard-sudo-ops ALL=(root) sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /usr/local/bin/deploy ^~~~~~ visudo: invalid sudoers file
deploy@rocky10 · Rocky Linux 10.2
$ sudo visudo -cf ~/classic-hardening
/home/deploy/classic-hardening: parsed OK

sudo-rs rejects five of the seven lines and classic sudo accepts them all. Each rejection has a replacement. sudo-rs always logs through syslog, so there is no separate logfile: its lines land in the journal and /var/log/auth.log. It has no session recording (log_input, log_output), covered below. requiretty refused sudo to anything without a terminal, which broke cron jobs and automation, and is best left out on both. Wildcards in arguments are allowed only as a final * meaning "any further arguments", so list each file a user may edit. Digest pinning (sha256:) does not exist; protect a script you grant by making it and its directory writable only by root.

The accepted lines need a word too. use_pty runs the command in its own pseudo-terminal, so a program run as root cannot push keystrokes into your terminal after it exits; it is on by default in both implementations (sudo-rs's sudoers(5), and sudo -V on RHEL below). timestamp_timeout is how many minutes sudo remembers your password: 15 by default in sudo-rs, 5 on RHEL. The CIS RHEL 10 Level 1 server profile (scap-security-guide 0.1.82) asks for use_pty, a timeout of at most 15 minutes, no !authenticate and a logfile; that last item cannot be met with sudo-rs.

deploy@rocky10 · Rocky Linux 10.2
$ sudo sudo -V | grep -E 'timestamp timeout|pseudo-tty|Lecture|Syslog facility'
Syslog facility if syslog is being used for logging: authpriv Lecture user the first time they run sudo Authentication timestamp timeout: 5.0 minutes Always run commands in a pseudo-tty

Narrow rules, checked before they are installed

The threat is a grant broader than the job: NOPASSWD: ALL for an automation account, or a command name with no arguments, which lets the user pass any arguments at all. As in Linux essentials, write each rule as the exact command line, write the file in your home directory, check it, then install it with root ownership and mode 0440. The example is an operator account, hard-sudo-ops, that looks after a lab service. The service is a unit that only sleeps:

/etc/systemd/system/hard-sudo-app.service
[Unit]
Description=SecOpsLog lab application
[Service]
ExecStart=/usr/bin/sleep infinity
deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl daemon-reload sudo systemctl start hard-sudo-app.service sudo useradd -m -s /bin/bash hard-sudo-ops sudo mkdir /etc/hard-sudo echo 'workers=2' | sudo tee /etc/hard-sudo/app.conf
workers=2

The rules for the operator:

~/50-hard-sudo-ops (installed as /etc/sudoers.d/50-hard-sudo-ops)
# SecOpsLog lab: what hard-sudo-ops may run as root, and nothing else.
# The arguments are part of each rule; any other form is refused.
Cmnd_Alias HARD_APP = /usr/bin/systemctl restart hard-sudo-app.service, \
/usr/bin/systemctl status hard-sudo-app.service
hard-sudo-ops ALL=(root) NOPASSWD: HARD_APP
# Edit one file with sudoedit: the editor runs as hard-sudo-ops, not root.
hard-sudo-ops ALL=(root) NOPASSWD: sudoedit /etc/hard-sudo/app.conf
# A backup command that may not start other programs.
hard-sudo-ops ALL=(root) NOPASSWD: NOEXEC: /usr/bin/tar -czf /var/backups/hard-sudo.tgz /etc/hard-sudo
deploy@web01 · Ubuntu 26.04 LTS
$ sudo visudo -cf ~/50-hard-sudo-ops
/home/deploy/50-hard-sudo-ops: parsed OK
$ sudo install -m 0440 -o root -g root ~/50-hard-sudo-ops /etc/sudoers.d/50-hard-sudo-ops sudo visudo -c
/etc/sudoers: parsed OK
$ sudo -l -U hard-sudo-ops
User hard-sudo-ops may run the following commands on web01: (root) NOPASSWD: /usr/bin/systemctl restart hard-sudo-app.service, /usr/bin/systemctl status hard-sudo-app.service (root) NOPASSWD: sudoedit /etc/hard-sudo/app.conf (root) NOEXEC: NOPASSWD: /usr/bin/tar -czf /var/backups/hard-sudo.tgz /etc/hard-sudo

visudo -cf checked the file on its own, and visudo -c then checked the whole configuration with the new file in place; sudo-rs reports only the top file, classic sudo lists every file it parsed. sudo -l -U prints what sudo will allow that user, read back from the installed policy. The operator has no password or key; open a shell as it with sudo -iu hard-sudo-ops and try the rules:

hard-sudo-ops@web01 · Ubuntu 26.04 LTS
$ sudo systemctl restart hard-sudo-app.service && echo restarted
restarted
$ sudo systemctl restart hard-sudo-app sudo systemctl stop hard-sudo-app.service
sudo: I'm sorry hard-sudo-ops. I'm afraid I can't do that sudo: I'm sorry hard-sudo-ops. I'm afraid I can't do that

The exact command works. hard-sudo-app without .service is refused although systemctl would treat it as the same unit, because sudo compares the arguments as text. stop is refused because no rule mentions it. The operational cost is that people and scripts must use the exact form, and every new task needs a new rule; that friction is the point.

Name files in /etc/sudoers.d without a dot: both implementations skip names that contain a . or end in ~, silently. On classic sudo a file that fails to parse no longer stops sudo altogether: since 1.9.3 its error_recovery default discards the broken line. sudo-rs behaves the same way but repeats the error on every run. Install a file with one classic-only line and see:

deploy@web01 · Ubuntu 26.04 LTS
$ echo 'Defaults:hard-sudo-ops log_output' > ~/60-hard-sudo-io sudo install -m 0440 -o root -g root ~/60-hard-sudo-io /etc/sudoers.d/60-hard-sudo-io
$ sudo visudo -c; echo "visudo exit status: $?" sudo -n true && echo "sudo still works"
/etc/sudoers.d/60-hard-sudo-io:1:24: unknown setting: 'log_output' Defaults:hard-sudo-ops log_output ^~~~~~~~~~ /etc/sudoers.d/60-hard-sudo-io:1:24: syntax error: unknown setting: 'log_output' Defaults:hard-sudo-ops log_output ^~~~~~~~~~ visudo: invalid sudoers file visudo exit status: 1 /etc/sudoers.d/60-hard-sudo-io:1:24: unknown setting: 'log_output' Defaults:hard-sudo-ops log_output ^~~~~~~~~~ sudo still works

sudo still works, minus the broken line. If that line was the one that granted you root, you have lost it, so keep a root shell open (sudo -i in a second terminal) while you change sudoers. To roll back, delete the file and run sudo visudo -c; a file you checked with -cf first rarely needs it.

Commands that start other commands

A command run through sudo can do anything its code allows, and many ordinary tools can start another program: editors (vi's :!), pagers such as less, find -exec, awk's system(), tar's checkpoint actions and compression programs, git (pager, hooks, editor), and every interpreter. Tools that write a file you name (tee, cp, dd) are just as dangerous, since writing /etc/sudoers.d is root. Granting any of them is granting a root shell. GTFOBins is a public list of these behaviours; check a program there before you grant it. The Advanced Linux security course shows what the abuse leaves behind.

One old example has changed. sudo systemctl status pipes long output through less, but systemctl(1) documents that systemd tools switch the pager to secure mode (LESSSECURE=1) when they run under sudo, which disables less's shell and file commands. A rule for /usr/bin/systemctl * is still root, because it also allows systemctl edit, which opens an editor as root.

Before you grant a command through sudo
Someone needs something done as root
only reads logs
Group membership, no sudo
adm (Ubuntu) or systemd-journal (both)
edits a config file
sudoedit with the exact path
the editor runs as the user
runs one operation
Exact command and arguments
one rule per operation
may start programs
NOEXEC, or do not grant it
check GTFOBins first

sudoedit copies the file to a temporary file the user owns, runs the user's own editor on the copy, and writes the result back as root. An editor escape then gives the user nothing they did not already have. The lab uses a small script in the operator's home as the "editor" (via SUDO_EDITOR) that reports who runs it and edits the copy:

~/set-workers (in hard-sudo-ops's home, mode 755)
#!/bin/sh
# Stands in for an editor: prints who runs it and what it edits, then edits.
id -un; echo "$1"
sed -i s/workers=2/workers=4/ "$1"
hard-sudo-ops@web01 · Ubuntu 26.04 LTS
$ SUDO_EDITOR=~/set-workers sudoedit /etc/hard-sudo/app.conf ls -l /etc/hard-sudo/app.conf cat /etc/hard-sudo/app.conf
hard-sudo-ops /tmp/sudoers-lyIYaD/0/app.conf -rw-r--r-- 1 root root 10 Sep 27 09:46 /etc/hard-sudo/app.conf workers=4
$ SUDO_EDITOR=~/set-workers sudoedit /etc/hosts
sudo: I'm sorry hard-sudo-ops. I'm afraid I can't do that

The editor ran as hard-sudo-ops on a copy in /tmp, and the real file, still owned by root, now says workers=4. Any other path is refused. Be careful what you let people edit, though: the configuration of a daemon that runs as root can often point it at a script, a module or a log path, which makes that edit root-equivalent.

NOEXEC is the control for programs that should never start anything. sudo-rs implements it with a seccomp filter, a kernel list of system calls a process may make (its sudoers(5); the sandboxing lesson covers seccomp); classic sudo on Linux does too. The backup rule above shows its cost:

hard-sudo-ops@web01 · Ubuntu 26.04 LTS
$ sudo tar -czf /var/backups/hard-sudo.tgz /etc/hard-sudo
tar: Removing leading `/' from member names tar (child): gzip: Cannot exec: Permission denied tar (child): Error is not recoverable: exiting now tar: Child returned status 2 tar: Error is not recoverable: exiting now

-z makes tar run gzip as a child program, and NOEXEC blocked that exec with "Permission denied", the same block that stops !sh in a pager. With the rule changed to tar -cf /var/backups/hard-sudo.tar /etc/hard-sudo (no compression), there is nothing to block:

hard-sudo-ops@web01 · Ubuntu 26.04 LTS
$ sudo tar -cf /var/backups/hard-sudo.tar /etc/hard-sudo && echo backed up
tar: Removing leading `/' from member names backed up

Test every NOEXEC rule this way before you rely on it, and remember its limit: it stops new programs, not writes, so a root process can still overwrite any file it is able to open.

What gets logged

sudo-rs writes one line per command it runs, under sudo (or sudoedit) in the journal and in /var/log/auth.log:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo journalctl -t sudo -t sudoedit --since '-1 min' --no-hostname | grep 'hard-sudo-ops :'
Sep 27 09:46:03 sudo[262421]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/usr/bin/systemctl restart hard-sudo-app.service Sep 27 09:46:03 sudoedit[262474]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=sudoedit /etc/hard-sudo/app.conf Sep 27 09:46:03 sudo[262511]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/usr/bin/tar -czf /var/backups/hard-sudo.tgz /etc/hard-sudo Sep 27 09:46:03 sudo[262563]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/usr/bin/tar -cf /var/backups/hard-sudo.tar /etc/hard-sudo

Read it as: who, the directory they were in, the target user, and the full command. The refused attempts from the operator's terminal left no line at all: in the lab, sudo-rs 0.2.13 logged neither the refused commands nor anything else about them, while wrong passwords appear as pam_unix(sudo:auth) failures. Its own sudoers-rs(5) says unsuccessful attempts are logged; in 0.2.13 the refusal only reaches the user's terminal (the source returns before any syslog call), so detect refusals another way, such as an audit rule (the auditd lesson) or classic sudo. Classic sudo logs refusals by default (log_denied), to /var/log/secure on RHEL, and asks for the password before it says no:

hard-sudo-ops@rocky10 · Rocky Linux 10.2
$ sudo systemctl stop hard-sudo-app.service
… We trust you have received the usual lecture from the local System … sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper sudo: a password is required
# The first refusal also prints sudo's one-time lecture; the password prompt fails because the lab has no terminal.
deploy@rocky10 · Rocky Linux 10.2
$ sudo grep 'sudo.*hard-sudo-ops :' /var/log/secure | tail -n 3
Sep 27 09:27:12 rocky10 sudo[58647]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/usr/bin/systemctl restart hard-sudo-app.service Sep 27 09:27:14 rocky10 sudo[58660]: hard-sudo-ops : command not allowed ; PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/bin/systemctl stop hard-sudo-app.service Sep 27 09:27:14 rocky10 sudo[58693]: hard-sudo-ops : PWD=/home/hard-sudo-ops ; USER=root ; COMMAND=/usr/bin/tar -czf /var/backups/hard-sudo.tgz /etc/hard-sudo

Classic sudo can also record whole sessions. log_output stores what the command printed and log_input what was typed, under /var/log/sudo-io, and sudoreplay plays them back. This is the control for administrators with full root, where the command line (/bin/bash) says nothing about what happened:

/etc/sudoers.d/50-hard-sudo-adm (RHEL lab)
# SecOpsLog lab: a full administrator whose sudo sessions are recorded.
# NOPASSWD only because the lab has no terminal to type a password into.
hard-sudo-adm ALL=(ALL) NOPASSWD: ALL
Defaults:hard-sudo-adm log_input, log_output
hard-sudo-adm@rocky10 · Rocky Linux 10.2
$ printf 'id -un\ncat /etc/hard-sudo/app.conf\n' | sudo -i
root workers=2
deploy@rocky10 · Rocky Linux 10.2
$ sudo sudoreplay -l
Sep 27 09:27:15 2026 : hard-sudo-adm : HOST=rocky10 ; CWD=/home/hard-sudo-adm ; USER=root ; TSID=000001 ; COMMAND=/bin/bash
$ sudo sudoreplay -f stdin 000001 sudo sudoreplay 000001
Replaying sudo session: /bin/bashid -un cat /etc/hard-sudo/app.conf Replaying sudo session: /bin/bashroot workers=2
# Without a terminal, the header runs into the first recorded line.
$ sudo grep 'sudo.*hard-sudo-adm :' /var/log/secure | tail -n 1
Sep 27 09:27:15 rocky10 sudo[58843]: hard-sudo-adm : PWD=/root ; USER=root ; TSID=000001 ; COMMAND=/bin/bash

The syslog line carries TSID=000001, the recording's ID, so an investigator can go from the log to the replay. -f stdin replays what was typed and the default replays the output. The impact is real. Input logging records whatever is typed, by default database passwords included, so the recordings are secrets. Since 1.9.10, Defaults !log_passwords replaces what is typed after a prompt matching passprompt_regex with asterisks (sudoers(5)); it catches prompts that look like password prompts, not every secret. Input logging also consumes standard input, which the I/O logging section of sudoers(5) warns can break scripts. Recordings grow with every session, and root can delete them, so forward them off the host (the logging lesson covers forwarding). To roll back, delete the Defaults:hard-sudo-adm line and run sudo visudo -c; existing recordings stay in /var/log/sudo-io until you remove them.

Session recording on Ubuntu

If recording sudo sessions is a hard requirement on Ubuntu 26.04, the classic implementation is installed alongside sudo-rs as sudo.ws, and the alternatives system switches between them:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo update-alternatives --set sudo /usr/bin/sudo.ws sudo --version | head -n 1 sudo visudo -cf ~/classic-hardening ls /usr/bin/sudoreplay*
update-alternatives: using /usr/bin/sudo.ws to provide /usr/bin/sudo (sudo) in manual mode Sudo version 1.9.17p2 /home/deploy/classic-hardening: parsed OK /usr/bin/sudoreplay.ws
$ sudo update-alternatives --auto sudo sudo --version
update-alternatives: using /usr/lib/cargo/bin/sudo to provide /usr/bin/sudo (sudo) in auto mode sudo-rs 0.2.13-0ubuntu1.2

After the switch, sudo is classic sudo 1.9.17 reading the same /etc/sudoers, and the candidate file passes. The replay tool is installed only as sudoreplay.ws. --auto returns to sudo-rs, the higher-priority alternative. The trade-off is the reason Ubuntu switched: you run the larger C code base that has had memory-safety vulnerabilities, such as CVE-2021-3156, and you give up sudo-rs's smaller feature set. Two traps follow. Switching back to sudo-rs with classic-only lines in /etc/sudoers.d leaves every sudo printing errors, as shown above; sudo-rs reads /etc/sudoers-rs in preference to /etc/sudoers if that file exists, which keeps the two policies apart. And any recording that stays on the host can be deleted by root. The alternative without switching is the audit system. pam_tty_audit (in libpam-modules) is a PAM session module: a line such as session required pam_tty_audit.so disable=* enable=hard-sudo-adm in /etc/pam.d/sshd makes the kernel record what that user types at a terminal in their SSH sessions, sudo -i shells included, because the setting is inherited by every process they start. The records go to the audit log, so auditd must be installed and running (the auditd lesson sets it up), and sudo aureport --tty lists them. Input typed with echo off, such as a password at a prompt, is left out unless you add log_passwd (pam_tty_audit(8)). Roll back a switch with sudo update-alternatives --auto sudo.

Try this

On an Ubuntu test machine, with a second session open as root (sudo -i), create a user and the 50-hard-sudo-ops file for a unit of your own, check it with sudo visudo -cf, install it and confirm with sudo -l -U. As that user (sudo -iu NAME), confirm that the exact restart works and that the same command without .service prints "I'm sorry ... I'm afraid I can't do that". Rename the installed file to 50-hard-sudo-ops.conf and run sudo -l -U again: it now reports "User hard-sudo-ops is not allowed to run sudo". Rename it back, grant tar -czf with NOEXEC and watch it fail with "gzip: Cannot exec: Permission denied", then change the rule to tar -cf and confirm the backup works. Finish with sudo rm on the file and sudo visudo -c.

Takeaway

Grant one exact command line per rule, use sudoedit for files, and treat any program that can start another program as a root shell. Check every sudoers file with visudo -cf on the machine that will read it, because sudo-rs and classic sudo accept different settings.

Quick check
01You paste a CIS-style snippet containing Defaults logfile=/var/log/sudo.log and Defaults log_input, log_output onto an Ubuntu 26.04 server, and visudo -cf reports "unknown setting" for each. What is the right conclusion?
Incorrect — The quoting is fine; the error names the setting itself. sudo-rs has no logfile setting at all.
Incorrect — sudo-rs has no session recording of any kind. It logs one syslog line per command it runs.
Incorrect — sudo does skip the lines, but that means the controls do not exist, and every sudo run prints the error.
Correct — sudo-rs logs through syslog only. If session recording is mandatory, switch the alternative to sudo.ws and accept the trade-off.
02An operator must change one line in /etc/hard-sudo/app.conf. You grant sudoedit /etc/hard-sudo/app.conf instead of /usr/bin/vim /etc/hard-sudo/app.conf. What does that change if the operator uses vim's :!sh?
Correct — sudoedit copies the file, runs the user's editor on the copy as the user, and only writes the result back as root.
Incorrect — sudoedit changes no mounts. The editor, and anything it starts, simply runs as the invoking user.
Incorrect — That is what the vim rule would do. sudoedit exists to keep the editor out of root's hands.
Incorrect — NOEXEC is a separate tag. The escape works; it just yields a shell as the operator, with nothing gained.
03On RHEL you enable log_input and log_output for administrators. A DBA runs sudo -i and then types a database password at a mysql prompt. What is the consequence?
Incorrect — That is what the ordinary syslog line does. log_input records everything typed during the session.
Incorrect — Recording works with and without a terminal; the lab recorded a session with no terminal at all.
Correct — sudoers(5) warns that input may contain passwords. Protect and forward the recordings like any other secret store.
Incorrect — By default, input logging stores the keystrokes sudo passes to the command, whether or not the terminal echoes them; only !log_passwords masks input after a recognised password prompt.

Related