Most Linux users have typed
sudoten thousand times. Almost none can explain what the kernel is doing when those ten characters are hit. This article walks through the whole chain — setuid, the dynamic linker scrubbing your env, PAM authentication, the difference betweensuandsu -, whysudokeeps a 5-minute grace, and whatsudo-rsis doing to make the whole thing less of a CVEs-as-a-service situation in 2026.
The short version: when you run sudo, the kernel does a privilege bump (effective UID 0) before your code even starts, the dynamic linker strips a list of dangerous environment variables, PAM verifies your password against /etc/shadow, the binary reads the sudoers file to figure out what you can do, and then a second execve runs the target as the right user. Everything else — the password prompt, the 15-minute grace, the audit log entries — sits on top of those five steps. This article walks through each.
The setuid mechanism — how /usr/bin/sudo becomes root
/usr/bin/sudo and /bin/su ship with mode 4755, owner root:root. The 4 is the set-user-ID bit (S_ISUID). When the kernel’s ELF loader (fs/binfmt_elf.c) loads a setuid binary, it calls bprm_fill_uid(), which sets:
- Real UID (rUID): stays as the caller’s UID (your normal user)
- Effective UID (eUID): set to the file owner’s UID (
root= 0) - Saved-set-UID: set to root too
A normal binary’s eUID equals rUID. A setuid root binary’s eUID is 0 while rUID stays yours. The kernel records this in the auxiliary vector — AT_SECURE is set non-zero (per man ld.so(8): “AT_SECURE may have a nonzero value … if the process’s real and effective user IDs differ, typically … a set-user-ID program”). The C runtime sees that flag and routes every getenv() call through secure_getenv(), which checks AT_SECURE and returns NULL for any environment variable that the dynamic linker considers dangerous.
The list of variables the dynamic linker scrubs for AT_SECURE processes is the same list POSIX, glibc, and the historical Unix security model all converged on:
LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, GCONV_PATH, GETCONF_DIR,
HOSTALIASES, LOCALDOMAIN, NIS_PATH, NLSPATH, RESOLV_HOST_CONF,
RES_OPTIONS, TMPDIR, TZDIRSo if you do LD_PRELOAD=/tmp/evil.so sudo whatever, the dynamic linker ignores the variable, sudo runs against the real C library, and your evil shim never loads. This is the first layer of sudo security. It is kernel-enforced, not a sudo policy choice.
Why root-owned? Mode 4755 only grants the file owner’s UID — and the file has to be owned by the user you want to become. sudo and su are both owned by root because they’re supposed to become root. If sudo were owned by nobody, the kernel would set eUID 65534 on execve, which is useless.
Why scripts cannot be setuid: Script execve re-enters through the interpreter (the shebang line). The kernel can’t trust the shebang because the script file is interpreted by a separate process — and the kernel cannot force the interpreter itself to be setuid. Modern Linux refuses to honor S_ISUID on interpreted scripts entirely. Only native ELF binaries get the privilege change. This is why chmod 4755 some-script.sh silently does nothing on every modern Linux.
What shows up in the audit log: auditd records type=USER_AUTH and type=USER_CMD events with exe="/usr/bin/sudo" or exe="/bin/su", acct=<target username>, res=success or res=failed. PAM operations (op=PAM:authentication, op=PAM:session_open, op=PAM:session_close) appear inside the msg='...' field of the surrounding audit record. This is what your security team grepping for “who ran sudo and when” is parsing.
PAM — the actual authentication step
After sudo has eUID 0, before it executes the target command, it calls into PAM (Pluggable Authentication Modules). PAM is a loadable-module framework: sudo doesn’t know how to talk to /etc/shadow directly — it asks PAM, and PAM loads a stack of modules that do the actual work.
The configuration is in /etc/pam.d/sudo and /etc/pam.d/su. A typical Debian/Ubuntu /etc/pam.d/sudo looks roughly like:
auth include common-auth
account include common-account
session include common-session-noninteractiveThe common-auth file is where the actual modules get listed. A typical stack on Debian/Ubuntu:
auth sufficient pam_rootok.so
auth [success=1 default=ignore] pam_uid.so
auth requisite pam_nologin.so
auth @include common-auth
auth optional pam_mail.so
account include common-account
password include common-password
session required pam_limits.so
session required pam_unix.so
session optional pam_systemd.soReading this top-to-bottom: pam_rootok.so skips the password entirely if the caller is already root. pam_uid.so (when present) verifies the caller’s UID against a list. pam_nologin.so blocks login if /etc/nologin exists. Then common-auth is included, which typically has pam_unix.so reading /etc/shadow to verify the password. The control flags matter:
sufficient— succeed immediately and skip the rest of this stackrequisite— fail immediately and abort the authrequired— fail the entire stack if this fails, but let remaining modules runoptional— ignore unless this is the only one that returned a result
After auth succeeds, PAM transitions to the account stack (is the account allowed to log in at this time, has it expired), then session (apply resource limits, register with logind, log session start/end). For sudo, the session stack runs as root before the target execve, so pam_systemd.so registers the child process as a transient scope in logind, pam_unix.so writes session opened / session closed log lines, and pam_limits.so applies /etc/security/limits.conf ulimits.
/etc/pam.d/su is similar but typically has an extra pam_wheel.so line that restricts who can su to root at all (only members of the wheel group can). On Debian/Ubuntu, the convention is the sudo group (GID 27) instead of wheel, with the analogous check living in /etc/pam.d/sudo instead.
The whole wheel convention comes from BSD (4.3BSD in 1986); the term predates Unix — it’s from TENEX/TOPS-20’s “big wheel” group bit in the 1960s. Imported into Unix in the 1980s, survived into Linux largely because PAM still has pam_wheel.so.
su vs su - — why the hyphen matters
This is the most underappreciated distinction in the whole su family:
-
su target_user— non-login shell. The kernel execs/bin/bashastarget_userbut does not passargv[0] = "-bash". So/bin/bashreads~/.bashrc(or whatever the user’s rc files are) but does not run/etc/profile,~/.bash_profile, or~/.bash_login. PATH, HOME, USER, LOGNAME, SHELL, MAIL all keep the caller’s values. The most dangerous consequence: the caller’sPATHstays in effect, and a user-planted binary in the caller’s PATH runs with the target user’s privileges. -
su - target_user(orsu -l target_user) — login shell.argv[0]is set to-bash, the kernel’s PAM session stack runspam_envfrom/etc/environment, and the shell reads/etc/profile,~/.bash_profile,~/.bash_login, and resets PATH/UMASK/etc. to the target user’s defaults. Thepam_envstep is what makes this safe — it explicitly sources/etc/environmentand appliesenv_keep/env_blockrules from/etc/security/pam_env.conf.
The general security advice: use su -, never plain su, on a system you don’t fully trust. On a single-user dev box it doesn’t matter. On a multi-user production system it does.
sudo vs su — the configuration file
su authenticates against the same /etc/shadow as login. sudo authenticates against /etc/shadow (or PAM-modules) and then checks /etc/sudoers (edited only via visudo) or the drop-in files in /etc/sudoers.d/. The sudoers line format is:
user HOST=(RUNAS_USER:RUNAS_GROUP) COMMANDSSo root ALL=(ALL:ALL) ALL means “from any host, root can run any command as any user/group”. %sudo ALL=(ALL:ALL) ALL means “any user in the sudo group can run any command as any user” (with the default 15-minute grace). Real-world sudoers are usually tighter than this — restricting which commands a user can run, requiring re-authentication for dangerous commands, or NOPASSWD: tags for scripted automation.
The 15-minute grace is timestamp_timeout in Defaults. Per man 5 sudoers: “The default is 15.” The timestamp is stored in /var/db/sudo/ts/<user> on BSD-derived systems and /var/run/sudo/ts/<user> on Linux (it’s a tiny binary file, not a database). sudo -v re-validates the timestamp without running a command; sudo -k kills the timestamp so the next sudo will prompt again.
env_reset is on by default in modern sudo. This means sudo builds a minimal environment for the target command:
TERM, PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,
HOME, MAIL, SHELL, LOGNAME, USER, USERNAMEThe caller’s environment survives only if the sudoers env_keep list includes it. This is why sudo FOO=BAR some_command with FOO not in env_keep silently loses the assignment.
The lecture (Defaults lecture=once|first|always; default once) is the “We trust you have received the usual lecture about the importance of password hygiene” message you probably skip past. It shows once per user, the first time they run sudo.
sudoedit path: When you run sudoedit /etc/hosts, sudo opens a temporary copy of the file as root, drops privileges to the calling user, runs your $EDITOR on the temp copy, and on save atomically renames the temp back over the original. This way your editor runs unprivileged, but the edit gets applied with root authority. Many sudo hardening guides (CIS, DISA-STIG) require sudoedit instead of sudo $EDITOR for precisely this reason.
run0, doas, polkit — the modern alternatives
Three projects exist because setuid-root tools have been a CVE factory for twenty years:
run0 ships with systemd v256 (June 2024). It is not setuid — it’s a symlink to systemd-run. When you run run0 some_command, systemd-run asks PID 1 to spawn the target as a transient unit. Authorization goes through polkit, the process gets a new pseudo-TTY, and (optionally) the terminal tints red. Lennart Poettering’s design rationale was: “an execution context for privileged code that is half under the control of unprivileged code … is just not how security engineering should be done in 2024 anymore.” In other words: a single setuid binary you can’t audit line-by-line is the wrong layer to put all your privilege escalation behind.
doas originated in OpenBSD in 2015 (Ted Unangst). It’s a ~300-line C program with a single config file at /etc/doas.conf using a syntax like permit nopass user as root cmd /path/to/program. OpenBSD’s doas is in the base system. Linux ports are available — opendoas in Alpine and Void Linux, plus user-maintained packages in other distros. doas is dramatically simpler than sudo: no lecture, no timestamp_timeout (it re-prompts every time by default), no fancy logging infrastructure — but also no rich sudoers semantics.
`polkit (formerly PolicyKit) is the D-Bus authorization daemon that underlies both pkexec for graphical apps and run0. Rules live in /usr/share/polkit-1/actions/*.policy (XML) and /etc/polkit-1/rules.d/*.rules (JavaScript since polkit 0.106, 2012). polkit is the most flexible of the three — you can write rules that depend on the current session, the time of day, the connected smart card — but it’s also the most complex to configure.
The sudo CVE history (and why sudo-rs exists)
sudo’s track record of CVEs is what motivated the memory-safe rewrite:
- CVE-2021-3156 “Baron Samedit” — heap-based buffer overflow in argument unescaping via
sudoedit -s \. Any local user, no auth required, root escalation. Fixed in sudo 1.9.5p2 on 2021-01-26. - CVE-2023-22809 —
sudoeditmishandledSUDO_EDITOR/VISUAL/EDITORenv vars, letting a user withsudoediton any file edit/etc/shadowor sudoers. Fixed in sudo 1.9.12p2 on 2023-01-19. - CVE-2023-42465 — Rowhammer bit-flip on error-return values; fixed by comparing against success rather than
!= error. Fixed in sudo 1.9.15p1 on 2023-12-21.
sudo-rs is the memory-safe Rust reimplementation by the Trifecta Tech Foundation (originally Prossimo at ISRG, moved to TTF in July 2024). First stable release 2023-08-29. Ubuntu 25.10 (October 2025) made sudo-rs the default sudo implementation. Ubuntu 26.04 LTS ships sudo-rs v0.2.13 + backports. sudo-rs is also packaged in Debian 13, Fedora, Arch, NixOS, and FreeBSD. The point of the rewrite isn’t faster — it’s to eliminate the class of memory-safety bugs that produced Baron Samedit and friends.
Setuid pitfalls in 2026
Three things still bite setuid-root tools that memory-safe rewrites don’t fully address:
- Any file descriptor or env handle the unprivileged caller passes is visible to the now-privileged binary. The kernel scrubs
LD_PRELOADand friends via the dynamic linker, but custom ELF dynamic tags and argv-from-caller abuse can still leak. LD_PRELOAD/LD_AUDITstripping only covers the env vars the dynamic linker knows about. A tool that usesgetenv()directly (skippingsecure_getenv()) can still read whatever the caller passed in. This is why some hardening guides say “audit your setuid binaries for directgetenv()calls.”- Scripts cannot be setuid on modern Linux (already covered). If you want a “root script” with safe privilege handoff, the right answer is
sudoeditor a polkit rule, not a setuid script.
Reading the audit trail
Three log sources to check, in order of usefulness:
/var/log/audit/audit.log(when auditd is enabled).ausearch -m USER_CMD,EXECVE -i(orausearch -k sudo_priv_cmdif you have a custom audit rule on/usr/bin/sudo -p x) gives you the per-command history withacct=<target user>,exe=/usr/bin/sudo, the full command line, and the result./var/log/auth.log(Debian/Ubuntu) or/var/log/secure(RHEL/Fedora). Records the human-readable “user : TTY=pts/N ; PWD=… ; COMMAND=…” lines for accepted and rejected sudo invocations.journalctl _COMM=sudo _COMM=su— systemd-journald’s structured view of the same auth.log events. Use-fto follow live,--since "1 hour ago"to scope.
TL;DR
sudo and su work by the same five-step chain: (1) kernel sets eUID 0 via the S_ISUID bit on a root-owned ELF binary, (2) dynamic linker scrubs a known list of dangerous env vars via AT_SECURE, (3) PAM verifies the password against /etc/shadow, (4) the binary checks its own policy file (/etc/sudoers for sudo, none for su which always requires root’s password), (5) a second execve runs the target as the requested user. The five-minute grace, the lecture, the audit log, the env_reset — all sit on top of these five steps. CVE history has shown this design has been a continuous source of low-level memory-safety bugs, which is why sudo-rs in Rust is now the default in Ubuntu 26.04 LTS.
Sources verified July 2026: man7.org ld.so(8), getenv(3), sudoers(5); Trifecta Tech Foundation sudo-rs page (Ubuntu 25.10 / 26.04 announcements); sudo.ws advisories for CVE-2021-3156 / CVE-2023-22809 / CVE-2023-42465; LWN Apr 2024 run0 coverage; Linux-PAM FOSDEM 2026 slides. Concrete values: timestamp_timeout=15 (per man 5 sudoers), sudo-rs default in Ubuntu since 25.10, run0 ships with systemd 256.
Comments