Local privilege escalation: SUID, sudo and capabilities
How the paths work and what they leave behind.
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.
"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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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?find reads live filesystem metadata on every run, so the result is already current.cap_setuid binary is invisible to a SUID search and a sudo grant lives in neither scan, so one clean surface leaves two unexamined.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?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.uid and euid=0; this filter drops exactly those.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./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?execve; the record shows uid and euid equal, so a split-based rule never fires.BPRM_FCAPS record when a non-root execve raises file capabilities, so there is a live signal as well as the scan.cap_setuid is only the permission to change IDs later; the exec itself leaves the user IDs as they were.fp mask with the CAP_SETUID bit), and the user IDs only change if the program later calls setuid.