Sudo Hardening: Least Privilege, sudoers Configuration, and Privilege Escalation Prevention
Problem
sudo is intended to grant specific commands to specific users with accountability. In practice, most deployments grant far more than necessary:
NOPASSWD: ALLfor service accounts. CI systems, configuration management agents, and deployment tooling often receiveusername ALL=(ALL) NOPASSWD: ALL— unconditional root access without a password. A compromised service account becomes a direct root shell.- Wildcard command matching.
sudo /usr/bin/vim /etc/*seems restrictive but allows shell escape via:!bash. Any editor, pager, or utility that can spawn subprocesses allows privilege escalation through sudo. - Unrestricted environment inheritance. By default, sudo passes a subset of environment variables. Variables like
LD_PRELOAD,LD_LIBRARY_PATH, andPYTHONPATHcan redirect execution to attacker-controlled libraries when the target binary uses dynamic linking. sudo suorsudo -s. Granting access tosuor shells via sudo is functionally equivalent to granting unrestricted root.- Missing logging. Default sudo logging writes to syslog. On systems without centralised log shipping, local sudo logs can be cleared by any user who achieves root — erasing the audit trail of their own escalation.
- Stale rules. sudoers rules accumulate over time. Rules added for one-off operational tasks, now-departed employees, or decommissioned services remain active indefinitely.
Target systems: Ubuntu 22.04+, RHEL 9+, Debian 12+ with sudo 1.9+; OpenSSH with sudo-based privilege model; Ansible/Chef/Puppet using sudo for remote execution.
Threat Model
- Adversary 1 — Local privilege escalation via NOPASSWD: An attacker with a low-privilege shell (through a web application compromise, SSH key theft, or container escape) checks
sudo -land finds aNOPASSWDrule. They execute the permitted command to gain root without needing a password. - Adversary 2 — sudo escape via allowed binary: An attacker has sudo permission for a specific binary — a text editor, interpreter, or utility — that can spawn subprocesses. They use the binary’s built-in shell escape (
:!bashin vim,!bashin less,os.system()in Python) to obtain a root shell. - Adversary 3 — Environment variable injection: An attacker sets
LD_PRELOADto a malicious shared library before invoking sudo. Ifenv_resetis disabled orLD_PRELOADis preserved, the library loads in the elevated process. - Adversary 4 — sudoers file manipulation: An attacker with write access to
/etc/sudoers.d/adds their own rule. Without file integrity monitoring on sudoers, the addition goes undetected. - Adversary 5 — sudo log tampering: An attacker who achieves root via sudo deletes or modifies syslog entries covering their escalation. Without centralised log shipping, no audit trail remains.
- Access level: All adversaries start with a non-root shell — a realistic post-exploitation position.
- Objective: Elevate to root; persist; cover tracks.
- Blast radius: Successful privilege escalation via sudo gives full root access on the host. On a Kubernetes node, this enables container escapes, etcd access, and lateral movement to the cluster control plane.
Configuration
Step 1: Audit Existing sudoers Rules
Before hardening, map current exposure:
# List all effective sudo rules for every user.
sudo -l -U root # What root can do (should be unrestricted).
# Show all rules from /etc/sudoers and /etc/sudoers.d/.
visudo -c -f /etc/sudoers # Validate syntax.
cat /etc/sudoers
ls -la /etc/sudoers.d/
cat /etc/sudoers.d/*
# Find NOPASSWD grants — each one is a risk.
grep -r "NOPASSWD" /etc/sudoers /etc/sudoers.d/
# Find ALL command grants — each one is effectively unrestricted root.
grep -rE "ALL\s*$|ALL\s*#|=\s*ALL" /etc/sudoers /etc/sudoers.d/
# Check for shell access grants.
grep -rE "sudo|su|bash|sh|zsh|fish|dash" /etc/sudoers /etc/sudoers.d/
Classify each rule:
| Rule type | Risk | Action |
|---|---|---|
ALL=(ALL) ALL |
Critical | Restrict to specific commands |
ALL=(ALL) NOPASSWD: ALL |
Critical | Remove or replace with specific NOPASSWD commands |
NOPASSWD: /usr/bin/vim |
High | Remove; vim allows shell escape |
NOPASSWD: /usr/bin/systemctl restart app |
Medium | Acceptable if specific; verify no wildcard |
NOPASSWD: /usr/bin/systemctl * |
High | Wildcard allows systemctl daemon-reexec → privilege escalation |
Step 2: Principle of Least Privilege in sudoers
Replace broad grants with the minimum command set needed:
# /etc/sudoers — base configuration.
# Use visudo to edit; never edit directly.
# Defaults: apply security-relevant settings globally.
Defaults env_reset # Reset environment to safe set.
Defaults env_keep += "LANG LC_ALL" # Preserve locale only.
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Defaults requiretty # Require a real TTY (prevents cron abuse).
Defaults logfile="/var/log/sudo.log" # Dedicated sudo log.
Defaults log_input, log_output # Log stdin and stdout of sudo sessions.
Defaults iolog_dir="/var/log/sudo-io" # I/O log directory.
Defaults passwd_timeout=0 # Do not cache password for subsequent sudo calls.
Defaults timestamp_timeout=0 # Require password every time, no grace period.
Defaults badpass_message="Authentication failed."
Defaults mail_badpass # Email on failed sudo.
Defaults use_pty # Allocate PTY for I/O logging.
# Root keeps full access.
root ALL=(ALL:ALL) ALL
# Wheel group: interactive admin with password required.
%wheel ALL=(ALL) ALL
# BAD — do not use:
# deploy ALL=(ALL) NOPASSWD: ALL
# ansible ALL=(ALL) NOPASSWD: ALL
For service accounts that genuinely need specific privileged commands:
# /etc/sudoers.d/deploy-service
# Deployment service: can restart specific services only.
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app, \
/usr/bin/systemctl restart nginx, \
/usr/bin/systemctl status app
# Key constraints:
# 1. No wildcards in command arguments.
# 2. Explicit path (not just 'systemctl').
# 3. NOPASSWD limited to specific commands, not ALL.
# 4. (root) — run as root only, not as any user.
Step 3: Block Shell Escapes — Forbidden Commands
Certain commands must never be granted via sudo because they trivially escape to a shell:
# Commands that allow shell escape — never grant via sudo:
SHELL_ESCAPE_COMMANDS=(
# Editors:
vim vi nvim nano emacs ed
# Pagers:
less more most
# Interpreters:
python python3 ruby perl lua node
# Package managers with exec:
pip pip3 gem npm
# File managers:
mc ranger
# Text processors with exec:
awk gawk
# Shells:
bash sh zsh fish dash ksh
# Other:
env find xargs tee
# su itself:
su
)
# If any of these appear in sudoers, audit immediately.
grep -rFf <(printf '%s\n' "${SHELL_ESCAPE_COMMANDS[@]}") /etc/sudoers /etc/sudoers.d/
For specific required use cases, use wrappers:
#!/bin/bash
# /usr/local/sbin/restart-app — wrapper script instead of granting systemctl wildcard.
# Grant sudo access to this wrapper, not to systemctl.
set -euo pipefail
case "$1" in
restart)
exec /usr/bin/systemctl restart app.service
;;
status)
exec /usr/bin/systemctl status app.service
;;
*)
echo "Usage: restart-app {restart|status}" >&2
exit 1
;;
esac
# /etc/sudoers.d/deploy-service
deploy ALL=(root) NOPASSWD: /usr/local/sbin/restart-app
Step 4: Environment Hardening
Prevent environment variable attacks:
# /etc/sudoers — environment security.
Defaults env_reset
# Only these variables are preserved. Everything else is cleared.
Defaults env_keep = "LANG LC_ALL LC_MESSAGES LANGUAGE TERM DISPLAY"
# Explicitly deny dangerous variables.
# env_reset handles this, but belt-and-suspenders:
Defaults env_delete += "LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT PYTHONPATH RUBYLIB PERL5LIB"
# Secure the PATH used during sudo execution.
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# This PATH is used regardless of the invoking user's PATH.
Step 5: PAM Integration and Authentication Hardening
Sudo authentication goes through PAM. Harden the PAM sudo stack:
# /etc/pam.d/sudo
#%PAM-1.0
# Authentication: require password or FIDO2 token.
auth required pam_faillock.so preauth silent audit deny=5 unlock_time=300
auth sufficient pam_unix.so nullok try_first_pass
auth [default=die] pam_faillock.so authfail audit deny=5 unlock_time=300
auth required pam_faillock.so authsucc audit deny=5 unlock_time=300
# Account checks.
account required pam_unix.so
account required pam_faillock.so
# Session logging.
session required pam_limits.so
session required pam_unix.so
For FIDO2/hardware token authentication with sudo:
# Require FIDO2 touch for production sudo (Ubuntu 22.04+ with libpam-u2f).
# /etc/pam.d/sudo
auth required pam_u2f.so cue [cue_prompt=Touch your security key:] origin=pam://hostname appid=pam://hostname
auth required pam_unix.so
Step 6: Centralised sudo Logging with I/O Recording
Default syslog logging captures command invocations but not session content. Enable I/O logging to capture full session output:
# /etc/sudoers — I/O logging.
Defaults log_input
Defaults log_output
Defaults iolog_dir=/var/log/sudo-io/%{user}
Defaults iolog_file=%{seq}
# Ship sudo logs to a remote syslog server.
# Combine with rsyslog forwarding:
# /etc/rsyslog.d/50-sudo.conf — ship sudo logs to SIEM.
if $programname == 'sudo' then {
action(type="omfwd"
target="syslog.internal.example.com"
port="6514"
protocol="tcp"
StreamDriver="gtls"
StreamDriverMode="1"
StreamDriverAuthMode="anon")
}
Protect local sudo logs from tampering:
# Make sudo log append-only (even root cannot delete entries without chattr).
chattr +a /var/log/sudo.log
# Verify append-only attribute.
lsattr /var/log/sudo.log
# -----a--------e--- /var/log/sudo.log
Step 7: File Integrity Monitoring on sudoers
Monitor for unauthorised changes to sudoers:
# AIDE rule for sudoers files.
# /etc/aide.conf
/etc/sudoers p+i+n+u+g+s+sha256
/etc/sudoers.d p+i+n+u+g+s+sha256+r
For real-time detection with inotifywait:
#!/bin/bash
# /usr/local/bin/sudoers-monitor.sh
inotifywait -m -e modify,create,delete /etc/sudoers /etc/sudoers.d/ 2>/dev/null | \
while read -r directory events filename; do
MSG="ALERT: sudoers modified: ${directory}${filename} (${events})"
logger -p security.alert -t sudoers-monitor "$MSG"
# Send to alerting system.
curl -s -X POST "${SLACK_WEBHOOK}" \
-d "{\"text\": \"${MSG}\"}" || true
done
# systemd unit to run the monitor.
# /etc/systemd/system/sudoers-monitor.service
[Unit]
Description=Monitor sudoers for unauthorised changes
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sudoers-monitor.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Step 8: Telemetry
sudo_invocations_total{user, runas, command, status} counter
sudo_nopasswd_invocations_total{user, command} counter
sudo_auth_failures_total{user} counter
sudo_rule_changes_total{file, event} counter
sudo_session_duration_seconds{user, command} histogram
Alert on:
sudo_auth_failures_totalspike — potential brute-force against sudo; or a misconfigured automation attempting sudo with wrong credentials.sudo_nopasswd_invocations_totalwith unexpectedcommand— a NOPASSWD rule is being used for a command not in the approved list; may indicate rule manipulation.sudo_rule_changes_totalnon-zero — a sudoers file was modified; requires immediate review.- Any sudo invocation by a service account (
deploy,ansible,ci) for commands outside their defined rule set — indicates either misconfiguration or post-compromise escalation attempt.
Expected Behaviour
| Signal | Default sudo config | Hardened sudo config |
|---|---|---|
| Service account privilege escalation | NOPASSWD: ALL grants instant root |
Only permitted commands available; no shell access |
| vim shell escape via sudo | sudo vim → :!bash → root shell |
vim not in sudoers; rule rejected |
| LD_PRELOAD injection | Preserved if env_reset not set | env_reset clears LD_PRELOAD before execution |
| sudoers modification | Undetected | File integrity monitoring alerts within seconds |
| sudo session content | Command line only in syslog | Full I/O captured and shipped to SIEM |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
timestamp_timeout=0 |
No password caching; each sudo requires re-auth | More friction for interactive admins | Acceptable for production; use FIDO2 to reduce burden |
| I/O logging | Full session capture for forensics | Disk space for log storage; slight latency | Log rotation; ship to centralised storage |
| Wrapper scripts for NOPASSWD | Restricts scope precisely | Maintenance overhead for each wrapper | Automate wrapper deployment via configuration management |
requiretty |
Prevents sudo from cron/non-interactive contexts | Breaks automation that uses sudo without TTY | Use sudo -n flag in automation; grant specific NOPASSWD for non-TTY paths |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Legitimate automation breaks after tightening | CI pipeline fails with “permission denied” | CI failure alert | Review sudo rule for the service account; add specific NOPASSWD for required command |
| visudo syntax error locks out sudo | sudo: /etc/sudoers: syntax error |
Test with visudo -c before saving |
Boot to recovery; edit /etc/sudoers directly; or use pkexec visudo if available |
| I/O log disk fills | sudo: unable to write to I/O log |
Disk space alert | Add log rotation for /var/log/sudo-io; archive or ship to remote storage |
| PAM faillock locks out admin | Admin locked after 5 failed sudo attempts | faillock --user admin shows lock |
faillock --user admin --reset; investigate why auth failed |
| sudoers-monitor race condition | Short-lived file change not detected | Missed alert for rapid create/delete | Use auditd rule (-w /etc/sudoers.d/ -p wa) as belt-and-suspenders |