Hardening sudo
Narrow rules for sudo-rs and classic sudo.
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:
# Lines from classic-sudo hardening guides, checked before useDefaults logfile="/var/log/sudo.log"Defaults log_input, log_outputDefaults requirettyDefaults use_ptyDefaults timestamp_timeout=5hard-sudo-web ALL=(root) sudoedit /etc/hard-sudo/sites/*hard-sudo-ops ALL=(root) sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /usr/local/bin/deploy
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.
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:
[Unit]Description=SecOpsLog lab application[Service]ExecStart=/usr/bin/sleep infinity
The rules for the operator:
# 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.servicehard-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
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:
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:
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.
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:
#!/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"
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:
-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:
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:
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:
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:
# 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: ALLDefaults:hard-sudo-adm log_input, log_output
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:
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.