Local privilege escalation: SUID, sudo and capabilities

How the paths work and what they leave behind.

Advanced16 min · lesson 3 of 15

Local privilege escalation is what happens when an account that slipped in as a nobody finds a way to become root on the same host. The paths are rarely exotic kernel bugs; they are misconfigurations: a program carrying more power than its job needs, a rule that trusts a user too far, a permission bit set once and forgotten. Three surfaces produce most local root on a modern Linux server: SUID binaries, sudo rules, and file capabilities. You met their mechanics in linux-hard/perms, linux-hard/mounts and linux-hard/sudohard, so this lesson recaps them briefly and spends its time on what a defender needs: the evidence each surface leaves in an audit record, and what to do when a new entry appears. It uses harmless test binaries and never a working escalation command; the goal is to recognise the artefact, not to hand anyone a way up.

SUID: two identities at once

Record the surface before changing anything, so the end of the lesson has something to compare against. The scan lists every SUID file on the root filesystem and every file capability, sorted so two runs can be diffed line by line.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo sh -c "{ find / -xdev -perm -4000 -type f; getcap -r /; } 2>/dev/null | sort" > /var/tmp/pl-baseline.txt wc -l /var/tmp/pl-baseline.txt
19 /var/tmp/pl-baseline.txt

"SUID, SGID and the sticky bit" in Linux essentials showed the mechanism with this same test binary: a SUID-root program keeps your real user ID and runs with root's effective user ID. Here the copy is the planted finding that the audit records and the baseline diff later in this lesson have to catch, so create it again: a SUID-root copy of GNU id in /var/tmp, made from /usr/bin/gnuid because Ubuntu 26.04's own id is the uutils multi-call binary.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -d -m 0755 /var/tmp/pl-lab sudo install -m 4755 -o root -g root /usr/bin/gnuid /var/tmp/pl-lab/showid
$ ls -l /var/tmp/pl-lab/showid
-rwsr-xr-x 1 root root 68232 Sep 27 08:54 /var/tmp/pl-lab/showid
$ /var/tmp/pl-lab/showid
uid=1001(deploy) gid=1001(deploy) euid=0(root) groups=1001(deploy),4(adm),27(sudo)

The copy reports euid=0(root) next to the real uid=1001(deploy); id only reports, it grants nothing. That split is the whole danger: if a SUID-root program can be steered into running a command of your choosing, or into writing any file, it does that as root. find is the classic example because it can run commands as part of a search, and file-writing tools such as tar or cp are dangerous because a program that writes any file as root can overwrite the ones that grant access. The command lines that abuse these are public (GTFOBins catalogues them) and are not the point here. The defender's question is whether anything carries the SUID bit that was not on the baseline.

deploy@web01 · Ubuntu 26.04 LTS
$ find / -xdev -perm -4000 -type f 2>/dev/null
/var/tmp/pl-lab/showid /usr/bin/fusermount3 /usr/bin/gpasswd /usr/bin/su /usr/bin/chfn /usr/bin/mount /usr/bin/sudo.ws /usr/bin/chsh /usr/bin/ntfs-3g /usr/bin/newgrp /usr/bin/umount /usr/bin/passwd /usr/lib/openssh/ssh-keysign /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/cargo/bin/su /usr/lib/cargo/bin/sudo

Most of that is the normal 26.04 set from the threat-model lesson. The line that does not belong is /var/tmp/pl-lab/showid. On a real host, an unexpected SUID-root file, especially one outside the packaged locations, is the finding.

Why /tmp and /dev/shm are nosuid

An ordinary account cannot make a SUID-root file of its own. It may set the SUID bit on a file it owns, but it cannot hand that file to root.

deploy@web01 · Ubuntu 26.04 LTS
$ cp /usr/bin/gnuid ~/pl-mine; chmod 4755 ~/pl-mine chown root ~/pl-mine ls -l ~/pl-mine rm ~/pl-mine
chown: changing ownership of '/home/deploy/pl-mine': Operation not permitted (os error 1) -rwsr-xr-x 1 deploy deploy 68232 Sep 27 08:54 /home/deploy/pl-mine

chown fails with "Operation not permitted" (chown(2): only a privileged process may change a file's owner), so the file stays SUID-deploy, which lends nothing beyond deploy's own identity. The nosuid mount option therefore protects against other cases: an intruder who reached root once and leaves a SUID-root copy of a shell in a writable scratch directory as a way back, SUID files arriving on untrusted media or inside unpacked images, and one user's SUID file lending that user's identity to everyone else. On Ubuntu 26.04 the world-writable scratch filesystems carry the option.

deploy@web01 · Ubuntu 26.04 LTS
$ findmnt -no TARGET,OPTIONS /tmp findmnt -no TARGET,OPTIONS /dev/shm
/tmp rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota /dev/shm rw,nosuid,nodev,inode64,usrquota

Both are nosuid: the kernel ignores the SUID bit for any file executed from such a mount (mount(8)). The same root-owned SUID copy, installed under /tmp, proves it.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -d -m 0755 /tmp/pl-lab sudo install -m 4755 -o root -g root /usr/bin/gnuid /tmp/pl-lab/showid
$ ls -l /tmp/pl-lab/showid /tmp/pl-lab/showid
-rwsr-xr-x 1 root root 68232 Sep 27 08:54 /tmp/pl-lab/showid uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)

The s is on the file, yet there is no euid=0: the effective user stays deploy. /var/tmp on a default install sits on the root filesystem, which is not nosuid, which is why the first copy worked there.

sudo rules: a grant only as safe as what it runs

linux-hard/sudohard built a sudo policy; here is the reviewer's view of it. A rule is dangerous when the command it allows can be turned into a general-purpose root, and three patterns do that.

A shell-capable program. Any editor, pager or interpreter allowed as root can run other commands as root, so a rule such as (root) /usr/bin/vim /etc/nginx/nginx.conf is effectively a rule for a root shell, whatever file it names. The safe alternative is sudoedit (sudo -e): the editor runs as the user on a temporary copy, and sudo writes the result back as root, so no root-owned editor process exists.

An editable target. (root) /opt/app/deploy.sh is only as safe as the write permissions on deploy.sh and its directory. If the calling user's group can edit the script, the rule runs their content as root. Keep targets root-owned and not group- or world-writable, all the way up the path.

A wildcard. (root) /usr/bin/systemctl * allows every systemctl subcommand, not just the restart someone had in mind: edit opens an editor as root, link and enable accept a unit file the user wrote, and start runs any unit, each of them root by another route. (The old pager escape through systemctl status is closed on current systemd: under sudo it runs the pager in secure mode or not at all, per $SYSTEMD_PAGERSECURE in systemctl(1).) sudo-rs accepts * only as the final argument (sudoers(5) on Ubuntu 26.04), which is exactly the form this rule uses. Name the full command line instead: (root) /usr/bin/systemctl restart nginx.service.

You audit these by reading sudo -l for each account and the files in /etc/sudoers.d, you catch their use in the sudo log the killchain lesson showed, and you keep NOPASSWD off anything that is not genuinely unattended.

Capabilities: a road no SUID scan can see

Capabilities split root's authority into about forty separate keys, so a tool can be given just the one it needs; that is why ping no longer needs SUID. A binary carrying the wrong key escalates without being SUID and without appearing in a SUID scan. Three keys matter most. cap_setuid lets a process set its own user ID to anyone, root included. cap_dac_read_search switches off file-read and directory permission checks, so its holder reads every file, including /etc/shadow and private keys. cap_sys_admin is so broad it is close to root by itself. Give a second copy of GNU id the first of them and look.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -m 0755 -o root -g root /usr/bin/gnuid /var/tmp/pl-lab/capprobe sudo setcap cap_setuid+ep /var/tmp/pl-lab/capprobe
$ sudo getcap -r / 2>/dev/null
/var/tmp/pl-lab/capprobe cap_setuid=ep /usr/bin/ping cap_net_raw=ep /usr/bin/mtr-packet cap_net_raw=ep /usr/lib/aarch64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep /usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
$ /var/tmp/pl-lab/capprobe
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)

getcap shows the planted line, /var/tmp/pl-lab/capprobe cap_setuid=ep: permitted and effective (=ep) at launch. ping, mtr-packet and the gstreamer helper are expected, and snap-confine's permitted-only (=p) set is managed by snapd. Running capprobe prints uid=1001 with no euid at all. A file capability changes no user ID at execve (capabilities(7), "Transformation of capabilities during execve()"); it changes what the process may do. A cap_setuid program becomes root only when it later calls setuid(0) itself, which id never does. That difference decides how you detect it, as the next section shows.

The records each road leaves

When a program runs, the kernel's audit subsystem can record the execve. auditd is part of this lab's toolset (it is not installed on a default Ubuntu server; linux-det/auditpipe installs it and explains the fields). Load four temporary rules: one for each test binary, and two that record a sudo run, the sudo binary itself and the program it starts. Then run all three.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo auditctl -a always,exit -F arch=b64 -S execve -F path=/var/tmp/pl-lab/showid -F key=pl_suid sudo auditctl -a always,exit -F arch=b64 -S execve -F path=/var/tmp/pl-lab/capprobe -F key=pl_cap sudo auditctl -a always,exit -F arch=b64 -S execve -F path=/usr/lib/cargo/bin/sudo -F key=pl_sudo sudo auditctl -a always,exit -F arch=b64 -S execve -F path=/usr/bin/gnuid -F key=pl_sudo
$ /var/tmp/pl-lab/showid >/dev/null /var/tmp/pl-lab/capprobe >/dev/null sudo gnuid -u
0

First the SUID run. ausearch --input-logs reads the log files even when it is run from a script without a terminal, -ts recent limits the search to the last ten minutes, -k selects the rule key, and -sc execve keeps only execve events (adding or deleting a rule writes an event with the same key).

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ausearch --input-logs -ts recent -k pl_suid -sc execve 2>/dev/null | grep type=SYSCALL | tail -1
type=SYSCALL msg=audit(1790499272.114:25280): arch=c00000b7 syscall=221 success=yes exit=0 a0=c7cb9a1d1a70 a1=c7cb9a1d1ce0 a2=c7cb9a1d4710 a3=e254e2181c90 items=2 ppid=150954 pid=150969 auid=1001 uid=1001 gid=1001 euid=0 suid=0 fsuid=0 egid=1001 sgid=1001 fsgid=1001 tty=(none) ses=64 comm="showid" exe="/var/tmp/pl-lab/showid" subj=unconfined key="pl_suid"

uid=1001 is the real user and euid=0 the effective one. auid=1001 is the login identity, deploy, which stays the same through sudo and su. exe="/var/tmp/pl-lab/showid" names the program, so you can see it ran from a scratch directory rather than a packaged path. The architecture shows in arch=c00000b7, the audit constant for aarch64, and syscall=221, which is execve on aarch64; an x86_64 server records arch=c000003e and syscall=59, so a search that hard-codes one architecture's numbers misses the other. Now the capability run.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ausearch --input-logs -ts recent -k pl_cap -sc execve 2>/dev/null | grep -E "type=(SYSCALL|BPRM_FCAPS)" | tail -2
type=BPRM_FCAPS msg=audit(1790499272.115:25281): fver=2 fp=0000000000000080 fi=0 fe=1 old_pp=0 old_pi=0 old_pe=0 old_pa=0 pp=0000000000000080 pi=0 pe=0000000000000080 pa=0 frootid=0 type=SYSCALL msg=audit(1790499272.115:25281): arch=c00000b7 syscall=221 success=yes exit=0 a0=c7cb9a1d1a90 a1=c7cb9a1d1070 a2=c7cb9a1d4710 a3=e254e2181c90 items=2 ppid=150954 pid=150970 auid=1001 uid=1001 gid=1001 euid=1001 suid=1001 fsuid=1001 egid=1001 sgid=1001 fsgid=1001 tty=(none) ses=64 comm="capprobe" exe="/var/tmp/pl-lab/capprobe" subj=unconfined key="pl_cap"

Here uid and euid are both 1001: no split. The capability shows up in a separate BPRM_FCAPS record, which the kernel writes when an execve by a non-root user raises capabilities from a file; fp=0000000000000080 is the file's permitted set as a bit mask, and bit 7 is CAP_SETUID (capability number 7 in capabilities(7); ausearch -i prints it as setuid), fe=1 says it is effective at launch, and pe is the process's new effective set. A detection keyed only on uid differing from euid never sees this case. Last, the sudo run, one search per program (-c selects by command name).

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ausearch --input-logs -ts recent -k pl_sudo -c sudo 2>/dev/null | grep type=SYSCALL | tail -1 sudo ausearch --input-logs -ts recent -k pl_sudo -c gnuid 2>/dev/null | grep type=SYSCALL | tail -1
type=SYSCALL msg=audit(1790499272.115:25282): arch=c00000b7 syscall=221 success=yes exit=0 a0=c7cb9a1d45e0 a1=c7cb9a1d8980 a2=c7cb9a1dc850 a3=e254e2181c90 items=2 ppid=150953 pid=150954 auid=1001 uid=1001 gid=1001 euid=0 suid=0 fsuid=0 egid=1001 sgid=1001 fsgid=1001 tty=(none) ses=64 comm="sudo" exe="/usr/lib/cargo/bin/sudo" subj=unconfined key="pl_sudo" type=SYSCALL msg=audit(1790499272.118:25286): arch=c00000b7 syscall=221 success=yes exit=0 a0=c3f022b020d0 a1=c3f022b02c00 a2=c3f022b01ed0 a3=30fc08ac08340 items=2 ppid=150954 pid=150972 auid=1001 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=(none) ses=64 comm="gnuid" exe="/usr/bin/gnuid" subj=unconfined key="pl_sudo"

The first record is sudo itself, a SUID-root program, and it shows the same split as the test binary: uid=1001 euid=0. The second is the command sudo ran: uid=0 euid=0, with auid=1001 still naming the person. So the split is the signature of any SUID run, and sudo, su, passwd and chsh produce it every time someone uses them legitimately. A detection that survives a real fleet keys on euid=0 with auid of 1000 or above for an exe that is not on your SUID baseline; on BPRM_FCAPS records for capability runs; and on auid differing from uid=0 for sudo use, which the sudo log also records.

Three roads to local root, and what each leaves
SUID binaries
runs as its owner (root)
the s in rws
find it
find / -xdev -perm -4000
in audit
uid 1001, euid 0, exe off baseline
sudo rules
runs named commands as root
sudo -l per account
find it
review sudoers targets
in audit
auid 1001, uid 0; the sudo log line
capabilities
a slice of root, no SUID
e.g. cap_setuid
find it
getcap -r /
in audit
uid = euid; a BPRM_FCAPS record
A cap_setuid binary is invisible to a SUID scan, and a sudo grant lives in neither scan, so check all three.

A new entry is an incident: capture, then remove

The first run of these scans hardens a host; every run after is hunting. Compare with the baseline taken at the start.

deploy@web01 · Ubuntu 26.04 LTS
$ diff /var/tmp/pl-baseline.txt <(sudo sh -c "{ find / -xdev -perm -4000 -type f; getcap -r /; } 2>/dev/null | sort")
19a20,21 > /var/tmp/pl-lab/capprobe cap_setuid=ep > /var/tmp/pl-lab/showid

The two additions are the planted files, and diff exits non-zero because the sets differ, which is the alert. Think about who could have made them. Only root can create a SUID-root file or set cap_setuid on one, so a new line here that no change record explains means someone already had root. That is an incident, not drift. Capture before you touch anything, because chmod and setcap update the file's change time (ctime), the one timestamp that dates when the bit was set.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo stat -c "%n %A %U birth=%w change=%z" /var/tmp/pl-lab/showid /var/tmp/pl-lab/capprobe sha256sum /var/tmp/pl-lab/showid /var/tmp/pl-lab/capprobe /usr/bin/gnuid dpkg -S /var/tmp/pl-lab/showid /var/tmp/pl-lab/capprobe
/var/tmp/pl-lab/showid -rwsr-xr-x root birth=2026-09-27 08:54:31.164286623 +0000 change=2026-09-27 08:54:31.165917078 +0000 /var/tmp/pl-lab/capprobe -rwxr-xr-x root birth=2026-09-27 08:54:31.555285770 +0000 change=2026-09-27 08:54:31.559285761 +0000 fbb37575b847ede6acdb7b9cf40a6ba319514744ffb742acb1e826d640f995df /var/tmp/pl-lab/showid fbb37575b847ede6acdb7b9cf40a6ba319514744ffb742acb1e826d640f995df /var/tmp/pl-lab/capprobe fbb37575b847ede6acdb7b9cf40a6ba319514744ffb742acb1e826d640f995df /usr/bin/gnuid dpkg-query: no path found matching pattern /var/tmp/pl-lab/showid dpkg-query: no path found matching pattern /var/tmp/pl-lab/capprobe

stat records the birth and change times, the hashes identify the content (both files are byte-identical to the packaged /usr/bin/gnuid, which already tells you what they are), and dpkg -S confirms no package owns either path. On a real host you would also copy both files to your evidence store, search the audit log for the chmod or setxattr that set the bits, and hand over to the incident process (linux-det/triage and linux-det/ir). Removal is part of eradication, after that.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo chmod u-s /var/tmp/pl-lab/showid sudo setcap -r /var/tmp/pl-lab/capprobe
$ ls -l /var/tmp/pl-lab/showid; sudo getcap /var/tmp/pl-lab/capprobe
-rwxr-xr-x 1 root root 68232 Sep 27 08:54 /var/tmp/pl-lab/showid

showid is back to a plain -rwxr-xr-x and getcap prints nothing for capprobe. Never sweep every SUID bit or capability off a host; mount, su, sudo and passwd need SUID and ping needs cap_net_raw. Remove only the delta from your baseline, once it is captured.

Try this

On a lab host, save a baseline exactly as this lesson did: sudo sh -c "{ find / -xdev -perm -4000 -type f; getcap -r /; } 2>/dev/null | sort" > ~/pl-baseline.txt. Create a harmless SUID decoy with sudo install -m4755 -o root /usr/bin/true /var/tmp/.probe, run the same command into a second file, and diff the two: the decoy should be the only addition. Move the decoy under /tmp and repeat; it disappears from the scan because find -xdev stays on the root filesystem and /tmp is a separate mount. Run sudo stat /var/tmp/.probe (or the /tmp path) before you remove it with sudo rm, and confirm the diff is clean again.

Takeaway

Audit SUID files, sudo rules and capabilities together, because clearing one surface leaves the other two. In audit records, match each road by its own mark: euid=0 on a binary off your baseline, a BPRM_FCAPS record, or auid behind uid=0, and treat a new privileged file as a root compromise to capture before you clean it.

Quick check
01You run find / -xdev -perm -4000 -type f, compare it to your baseline, and it matches exactly. Why is it too early to say the host has no local-root path?
Incorrect — There is no such cache; find reads live filesystem metadata on every run, so the result is already current.
Incorrect — SGID sets the group identity, not the user; it is a different and usually lesser issue, not an equivalent root path.
Correct — A cap_setuid binary is invisible to a SUID search and a sudo grant lives in neither scan, so one clean surface leaves two unexamined.
Incorrect — The scan covers one of three surfaces; a match on SUID says nothing about capabilities or sudo.
02A new fleet rule alerts on every execve audit record where uid differs from euid, and it fires thousands of times an hour on healthy hosts. Which change keeps the SUID signal and drops the noise?
Correct — sudo, su, passwd and the other stock SUID programs produce the split on every routine run; filtering on the baseline leaves the binary that should not be there.
Incorrect — The two differ by design whenever a SUID program runs; the split is real, it is just not rare.
Incorrect — Escalation starts from an ordinary account, so the interesting records have a non-zero uid and euid=0; this filter drops exactly those.
Incorrect — BPRM_FCAPS marks capabilities raised from a file by a non-root user; a SUID-root run does not produce one, so this filter would drop every SUID event.
03An interpreter at /opt/tools/py carries cap_setuid=ep, and a user runs it. What does the audit trail of that execve show, and what should a detection key on?
Incorrect — A file capability changes no user ID at execve; the record shows uid and euid equal, so a split-based rule never fires.
Incorrect — The kernel writes a BPRM_FCAPS record when a non-root execve raises file capabilities, so there is a live signal as well as the scan.
Incorrect — cap_setuid is only the permission to change IDs later; the exec itself leaves the user IDs as they were.
Correct — Capabilities travel in their own record (the fp mask with the CAP_SETUID bit), and the user IDs only change if the program later calls setuid.

Related