Sudo Hardening: Least Privilege, sudoers Configuration, and Privilege Escalation Prevention

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: ALL for service accounts. CI systems, configuration management agents, and deployment tooling often receive username 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, and PYTHONPATH can redirect execution to attacker-controlled libraries when the target binary uses dynamic linking.
  • sudo su or sudo -s. Granting access to su or 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 -l and finds a NOPASSWD rule. 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 (:!bash in vim, !bash in less, os.system() in Python) to obtain a root shell.
  • Adversary 3 — Environment variable injection: An attacker sets LD_PRELOAD to a malicious shared library before invoking sudo. If env_reset is disabled or LD_PRELOAD is 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_total spike — potential brute-force against sudo; or a misconfigured automation attempting sudo with wrong credentials.
  • sudo_nopasswd_invocations_total with unexpected command — a NOPASSWD rule is being used for a command not in the approved list; may indicate rule manipulation.
  • sudo_rule_changes_total non-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