Hardening sudo

Narrow rules, logging, and the escape hatches.

Intermediate10 min · lesson 4 of 16

Every time you type sudo (short for 'superuser do'), a program holding the keys to the whole machine checks a list, decides whether to let you through, runs your job as root (the machine's all-powerful superuser account, the one with user id 0), and writes a line in a logbook. That program is the bouncer on the door of your server. The list is a file called /etc/sudoers. Hardening sudo is two jobs: make the list say exactly who may do exactly what, and keep the logbook honest enough to reconstruct any night. Most real Linux break-ins do not need a clever kernel bug. They need one sloppy line in that list.

A rule in the sudoers file looks like alice ALL=(root) /usr/bin/systemctl restart nginx.service. Read it as: the user alice, on any host (that first ALL), may run that one command as root. (systemctl is the command that starts, stops, and restarts services.) The trap is that sudo trusts the command completely. Whatever you name runs with the full authority of user id 0, so the only thing standing between a normal account and total control is how tightly that one command is written. You never edit sudoers with a plain text editor. You use visudo, a wrapper that checks the syntax before it saves, because a single typo can lock every account out of sudo at once and leave you booting from rescue media to fix it.

See What You Actually Grant

Before you tighten anything, find out what is already promised. Two commands do most of the work. sudo -l prints what the current user may run. Then a recursive grep (a search that walks through files line by line) hunts the sudoers files for the three patterns that most often hide a hole: NOPASSWD (the user needs no password), ALL (any command at all), and * (a wildcard that quietly widens a rule past what you meant).

~/secopslog — bash
$ sudo -l sudo grep -rnE 'ALL|NOPASSWD|\*' /etc/sudoers /etc/sudoers.d/
Matching Defaults entries for alice on web01: env_reset, mail_badpass, secure_path=/usr/sbin:/usr/bin:/sbin:/bin User alice may run the following commands on web01: (root) NOPASSWD: /usr/bin/systemctl restart nginx.service /etc/sudoers:20:root ALL=(ALL:ALL) ALL /etc/sudoers:27:%sudo ALL=(ALL:ALL) ALL /etc/sudoers.d/nginx:1:alice ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx.service /etc/sudoers.d/backup:1:%ops ALL=(root) NOPASSWD: /usr/bin/tar * /etc/sudoers.d/deploy:3:%deploy ALL=(root) NOPASSWD: /usr/bin/find /var/www -delete

Read every hit, because the search is deliberately noisy and most lines are fine. The root and %sudo lines are the distribution's own defaults: real administrators are meant to have full root, so those stay. Pasting that same blanket (ALL:ALL) ALL onto a service account, though, is how one compromised app turns into root, so watch for it. The alice line is the shape you actually want, one exact command with nothing left open. Two lines should stop you cold. The %ops ... tar * grant hands the ops group a wildcard on tar, and tar can run any command you feed it through its --checkpoint-action option, so that single line is a standing root shell for everyone in the group. The find line looks narrow, but find is one of the shell-spawning programs you will meet in a minute, so it earns a second look too.

Narrow The Command, Kill The Wildcard

A good sudo rule is a key cut for exactly one lock. /usr/bin/systemctl restart nginx.service opens that one door and nothing else. A wildcard is a master key you did not mean to hand out. sudo compares the command and its arguments as written, character for character, so /usr/bin/systemctl * matches anything you put after systemctl. That includes systemctl status, which pages its output through a pager (a program like less that shows long output one screen at a time, and one you can break out of), or a unit file the attacker planted. Spell the arguments out and the user has to match them exactly and cannot bolt on their own. That is what makes the narrow form safe.

/etc/sudoers.d/10-deploy
# Validate before you trust it: visudo -cf /etc/sudoers.d/10-deploy
# A Cmnd_Alias is a named group of commands you can reuse, which keeps
# the file readable and easy to audit.
Cmnd_Alias NGINX_CTL = /usr/bin/systemctl restart nginx.service, \
/usr/bin/systemctl reload nginx.service
%deploy ALL=(root) NOPASSWD: NGINX_CTL
# NEVER write these:
# %deploy ALL=(root) NOPASSWD: /usr/bin/systemctl * (any unit + pager escape)
# %deploy ALL=(root) NOPASSWD: ALL (blanket root)

Leave the arguments off a command entirely and the user may pass whatever they like to it, so a bare command name is nearly as loose as a wildcard. For a script you own, you can go one step further and pin the exact binary by its cryptographic hash (a short fingerprint that changes if even one byte of the file changes), so nobody can swap your script for a trojan. Prefix the command with sha256: and the digest, as in %deploy ALL=(root) NOPASSWD: sha256:b5f1...9c /usr/local/bin/deploy.sh. If the file on disk no longer matches that fingerprint, sudo refuses to run it.

Two easy ways to lock yourself out
First, never save a sudoers file straight out of vi or nano. Go through visudo (or visudo -f /etc/sudoers.d/10-deploy for a drop-in file), because it refuses to save anything that would not parse, and a broken sudoers means nobody can become root until you boot from rescue media. Second, sudo silently ignores any file in /etc/sudoers.d/ whose name contains a dot or ends in ~. A file called hardening.conf is never read, and your careful rules do nothing at all. Name it 10-hardening, with no extension.

The Programs That Hand Back A Shell

Some commands keep a second exit quiet, like a supply closet with a hidden door into the vault. Give someone the right to run less as root to read a log, and from inside less they can type !sh and step straight out as root. less, more, vi, view, man, find, tar, awk, nmap, and git, among many others, can each launch a program or a shell from inside themselves. There is a public catalogue of these tricks called GTFOBins (the name is a play on 'get the heck out' plus 'binaries'), a curated list of standard Unix tools that can be talked into breaking out of their intended job. The rule of thumb: if a program can open a file, run a command, or start an editor, granting it through sudo is the same as granting a root shell.

~/secopslog — bash
$ sudo less /var/log/syslog # then, from inside the pager, type: !sh
# id uid=0(root) gid=0(root) groups=0(root) # whoami root

This is why even a grant that looks read-only is dangerous. sudo systemctl status ssh (a harmless-looking peek at the remote-login service) pipes long output through less when you are on a terminal, and from that pager you are one !sh away from root. To read safely, defeat the pager: add --no-pager, or blank it out with sudo SYSTEMD_PAGER='' systemctl status ssh. To edit a root-owned file safely, use sudoedit (also written sudo -e). It copies the file to a scratch location, opens that copy with your editor running as you, then writes the result back as root. Your editor never holds root's power, so there is no shell to escape into.

/etc/sudoers.d/10-web
# Let the web group edit nginx config safely.
# The editor runs as you, not as root, so there is no shell to break out to.
%web ALL=(root) sudoedit /etc/nginx/sites-available/*

Record Every Session

A guard who keeps no logbook is useless the morning after. sudo can write two kinds of record: a one-line-per-command log, and a full recording of everything typed and printed during a session that you can play back later like a screen capture. Turn on both.

/etc/sudoers.d/10-hardening
# Validate: visudo -cf /etc/sudoers.d/10-hardening
Defaults logfile="/var/log/sudo.log" # one line per sudo command
Defaults log_input, log_output # full session capture (keystrokes + output)
Defaults iolog_dir="/var/log/sudo-io" # where the recordings are stored
Defaults use_pty # run the command under its own pseudo-terminal
Defaults !visiblepw # refuse to run if the password would echo on screen
Defaults timestamp_timeout=5 # re-ask for the password after 5 idle minutes
Defaults passwd_tries=3

timestamp_timeout is how long sudo remembers your password before it asks again. The default is 15 minutes. Drop it to 5, or to 0 to demand the password every single time, and you shrink the window in which a walked-away terminal is a free root shell. use_pty runs the command inside its own pseudo-terminal (a fake terminal device the kernel hands out on demand), which stops a backgrounded malicious process from shoving fake keystrokes back into your real terminal and makes the output recording reliable. With log_output on, every session lands under /var/log/sudo-io, and you replay it with sudoreplay.

~/secopslog — bash
$ sudo sudoreplay -l sudo sudoreplay 000007
Jul 17 09:14:03 2026 : alice : TTY=pts/0 ; CWD=/home/alice ; USER=root ; TSID=000007 ; COMMAND=/usr/bin/vi /etc/hosts Replaying sudo session: /usr/bin/vi /etc/hosts [keystrokes and screen output play back here in real time]

Because the recording captures output as well as input, it captures the escape too. If someone ran sudo vi and dropped to a shell with :!sh, the replay shows the shell prompt appear and every command they typed after it. That is the difference between knowing a command's name and knowing what actually happened on the box.

Three Jobs When You Harden sudo
Narrow the grant
One binary, exact args
no wildcards, no bare ALL
Cmnd_Alias for groups
readable and auditable
Pin by sha256
binary cannot be swapped
Close the escape hatches
No shell-capable tools
less, vi, find, tar, awk
sudoedit for files
editor runs as the user
Kill the pager
--no-pager, SYSTEMD_PAGER=''
Record everything
log_input, log_output
full session replay
sudoreplay after an incident
see exactly what ran
short timestamp_timeout
smaller free-root window
Narrow rules, honest logs, no hidden doors.

After any edit, prove it. Delete the loose drop-ins first, then check what is left. visudo -c confirms that every sudoers file still parses, and sudo -l -U <user> reads a named user's grants back exactly as sudo will apply them. If the list shows only the narrow commands you meant to grant, and nothing carrying a * or a bare ALL, the change did what you wanted.

~/secopslog — bash
$ sudo visudo -c sudo -l -U deploybot
/etc/sudoers: parsed OK /etc/sudoers.d/10-deploy: parsed OK /etc/sudoers.d/10-hardening: parsed OK /etc/sudoers.d/10-web: parsed OK User deploybot may run the following commands on web01: (root) NOPASSWD: /usr/bin/systemctl restart nginx.service, /usr/bin/systemctl reload nginx.service
requiretty can break your automation
An older hardening tip is to switch on requiretty, which forces sudo to run only from a real login terminal. It sounds safe, but it quietly breaks cron jobs (scheduled background tasks), Ansible (a tool that configures servers over the network), and anything else that runs with no terminal attached, and modern distributions dropped it from their defaults for exactly that reason. Prefer use_pty, which gives you the security win (a private pseudo-terminal plus reliable logging) without blocking headless tooling. If you do set requiretty, expect scripted sudo to start failing until you carve out exceptions.
Quick check
01A teammate swears that ops ALL=(root) NOPASSWD: /usr/bin/systemctl status * is safe, because status only reads state and never changes anything. Why is it still a full root compromise?
Correct — Any granted command that can open a pager or an editor hands back a shell, so 'read-only' stops mattering the moment you can reach a prompt.
Incorrect — systemctl is not setuid; here it is sudo that grants root, not a permission bit on the file.
Incorrect — sudo matches the literal word status; a different verb would not match this rule at all.
Incorrect — Logging is a separate concern. Missing logs would hide the act, but they would not create the escalation; the pager escape does.
02A sudoers rule reads: '%deploy ALL=(root) NOPASSWD: sha256:b5f1...9c /usr/local/bin/deploy.sh'. What does the sha256: prefix accomplish?
Incorrect — the digest is a fingerprint for verification, not encryption, and it does not change who can read the file.
Incorrect — the digest verifies file contents and has no effect on the password prompt.
Incorrect — the digest is taken of the command file on disk, not of any password.
Correct — pinning by digest means a modified or replaced binary no longer matches, and sudo declines to run it.
03You put your hardening rules in /etc/sudoers.d/hardening.conf, 'visudo -cf' reports it parses OK, but the rules never take effect. What is wrong?
Incorrect — a parse-OK file is readable; the problem is the filename, not its permissions.
Incorrect — the main file already includes sudoers.d; the issue is that this specific filename is skipped.
Correct — the '.conf' dot makes sudo skip the file entirely, even though it parses fine on its own.
Incorrect — drop-in files fully support NOPASSWD; the filename is what disqualifies this one.

Put a recurring reminder on your calendar to re-run the grep from the top of this lesson across every host you own. Sudoers files grow the way a kitchen junk drawer does: someone adds a NOPASSWD line for a deploy that shipped two years ago, and it never leaves. The audit that finds it takes thirty seconds. The breach that uses it does not announce itself.

Try this

Work through “Record Every Session” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: two easy ways to lock yourself out. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related