CoursesAdvanced Linux securityLocal privesc: SUID, sudo, capabilities

Local privesc: SUID, sudo, capabilities

The misconfigurations attackers find first.

Advanced16 min · lesson 3 of 17

A lock on a front door only matters if the key is scarce. Local privilege escalation is what happens when an attacker who slipped through a side window finds a key lying on the floor that opens the master door. On Linux that master door is root, the administrative account with user ID 0 that every permission check waves through. The keys on the floor 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. You learn to find these keys first, because the attacker's opening move after landing on a host is to look for the same three.

Three surfaces produce most local root on a modern Linux box: SUID binaries, sudo rules, and file capabilities. Each is a real feature doing a real job, and each turns into a road to root the moment it is handed out too generously. We will take them one at a time: what the feature is, how an attacker flips it, and the exact command you run to see it on your own hosts before they do.

The SUID Bit, a Key That Works No Matter Who Turns It

Normally a program runs as you. Start passwd (the tool that changes your login password) as the user alice and the process is alice. But passwd has to write to /etc/shadow, the file that stores everyone's password hashes, and that file is owned by root and off-limits to ordinary users, who cannot read it or write it. So how does an ordinary user change their own password? The Set User ID bit, written SUID. It is one flag on an executable that tells the kernel (the core of the operating system, the part that enforces every permission check): when anyone runs this file, run it as the file's owner, not as the caller. passwd is owned by root, so it runs as root no matter who starts it, long enough to update /etc/shadow, then it hands control back.

Two identities are in play at once, like a visitor who keeps their own name badge on while holding a borrowed master key. Your real user ID (RUID, the badge, who you actually are) does not change. Your effective user ID (EUID, the key the kernel actually checks every permission against) becomes root for the life of that process. You can see the SUID flag in a plain directory listing: the owner's execute slot shows an s where it would normally show an x.

~/secopslog — bash
$ # the s in "rws" is the SUID bit: this file runs as its owner (root), whoever calls it ls -l /usr/bin/passwd # enumerate every SUID-root binary on the host (-4000 means "has the SUID bit set") find / -xdev -perm -4000 -type f 2>/dev/null
-rwsr-xr-x 1 root root 59976 Nov 24 2022 /usr/bin/passwd /usr/bin/passwd /usr/bin/chsh /usr/bin/chfn /usr/bin/newgrp /usr/bin/gpasswd /usr/bin/su /usr/bin/sudo /usr/bin/mount /usr/bin/umount /usr/bin/fusermount3 /usr/bin/pkexec /usr/lib/openssh/ssh-keysign /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/polkit-1/polkit-agent-helper-1 /usr/bin/find <-- not standard: why is find SUID-root?

Most of that list is supposed to be there. mount, su, sudo, passwd, and the openssh and polkit helpers ship SUID by design. What you are hunting for is the line that does not belong, because a SUID binary is only as safe as what it lets you do while you hold root. If the program can read any file, write any file, or run a command of your choosing, then running it as root hands you root. find is the textbook case: it has a -exec flag that runs commands, so a SUID find runs your command as root.

~/secopslog — bash
$ # find is SUID-root, and its -exec flag runs commands, as root # -p keeps the shell from dropping the elevated EUID back to your own UID find /etc/hostname -exec /bin/sh -p \; id
uid=1000(deploy) gid=1000(deploy) euid=0(root) groups=1000(deploy)

That trick is not special to find. GTFOBins (a public catalog of everyday Unix programs and the tricks that turn each one into a way to climb higher or break out of a restricted shell) lists dozens of them. Some hand you a root shell the instant they run SUID, with vim, less, and awk among them, because each can spawn a subshell on demand. Others, like cp and tar, only read or write files, but a program that can write any file as root can overwrite a cron job or /etc/passwd, which walks you to root all the same. So the audit question is not "does this binary look dangerous," it is "does anything on this host carry the SUID bit that is not on my known-good baseline." Notice what is missing from the scan above: ping. It used to be SUID-root and no longer is, which is the perfect bridge to the third surface.

Sudo, the Guard With a Clipboard

sudo is a guard standing outside the manager's office with a clipboard. The clipboard lists, per person, exactly which tasks each is allowed to carry out as the manager, and usually asks you to prove who you are with your own password, not the manager's. Ask for something on your line and the guard waves you through. Ask for anything else and you are turned away. That clipboard is the sudoers file, and any user can read their own line of it with one command.

~/secopslog — bash
$ sudo -l
Matching Defaults entries for deploy on web01: env_reset, mail_badpass, use_pty, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin User deploy may run the following commands on web01: (root) NOPASSWD: /usr/bin/systemctl restart app.service (root) NOPASSWD: /opt/app/deploy.sh (root) NOPASSWD: /usr/bin/vim /etc/app/config.yml
/etc/sudoers.d/deploy
# managed by config-mgmt; edit the template, never the host
# deploy service account: manage the app and edit its config file
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.service
deploy ALL=(root) NOPASSWD: /opt/app/deploy.sh
deploy ALL=(root) NOPASSWD: /usr/bin/vim /etc/app/config.yml # vim shells out -> use sudoedit

Read that the way an attacker does, hunting the one grant that is broader than it looks. Three patterns turn a sudo rule into a root shell. The first is a shell-capable program. Any editor, pager, or interpreter can run a command, so permission to run one as root is permission to run anything as root. The vim rule is that trap: from inside vim you type a colon command that shells out, and the shell is root.

~/secopslog — bash
$ # the rule allows vim as root; vim runs shell commands with :! sudo vim -c ':!/bin/sh' /etc/app/config.yml id
uid=0(root) gid=0(root) groups=0(root)

The second pattern is an editable target. The deploy.sh rule looks tight until you ask who can change what is inside deploy.sh. Check the file's own permissions. The group deploy has write access, so the deploy user edits the script, drops in a line that starts a shell, runs it through the sudo rule, and lands as root. The third pattern is a wildcard. A rule like (root) /usr/bin/systemctl * lets the argument be status, whose long output pipes through a pager, and the pager shells out the same way vim did. Wildcards and shell-capable programs are one bug in two outfits.

~/secopslog — bash
$ # is the script the rule trusts writable by the user who can sudo it? ls -l /opt/app/deploy.sh
-rwxrwxr-x 1 root deploy 812 Jul 14 09:20 /opt/app/deploy.sh

The fix for the vim rule is sudoedit, also written sudo -e. It copies the file to a temporary path, lets the user edit it as themselves, and writes it back as root, so no root-owned editor process ever exists to break out of. NOPASSWD is a separate weakness: it drops the password check, so a phished or reused low-privilege credential escalates with no second gate. Keep NOPASSWD off anything that is not genuinely unattended automation, and keep the runas target as narrow as the task allows.

Capabilities, Root Split Into a Keyring

Root is one key that opens every door, which is exactly what makes handing it out risky. Linux capabilities break that single key into roughly forty separate keys on a ring, each opening one specific door. A tool that only needs to open raw network sockets can be given the one key for that job, cap_net_raw, and nothing else, so it never runs as full root. That is genuinely safer, and it is why ping no longer needs SUID. It also opens a quieter road to root, because a binary carrying the wrong key escalates without ever being SUID and without showing up in a SUID scan.

Three keys are the ones to watch. cap_setuid lets a program call setuid() and become any user, root included. cap_dac_read_search switches off the file-read and directory-search permission checks, so whoever holds it reads every file on the system, including /etc/shadow and every private key. cap_sys_admin is a grab bag so wide it is close to full root by itself. You list file capabilities with getcap, and unlike SUID there is no other place they show up.

~/secopslog — bash
$ # capabilities never appear in a SUID scan; getcap is the only way to see them getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep /usr/bin/mtr-packet cap_net_raw=ep /usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin=ep /usr/bin/python3.11 cap_setuid=ep <-- red flag: an interpreter with cap_setuid is root

ping carrying cap_net_raw is fine, and it is exactly why ping dropped its SUID bit. python3.11 carrying cap_setuid is not fine, and that one is game over. The =ep suffix means the capability is both permitted (the process is allowed to use it) and effective (it is switched on the moment the program starts). With that single key, one line of Python becomes root.

~/secopslog — bash
$ # python holds cap_setuid, so it can set its own UID to 0 and keep it python3 -c 'import os; os.setuid(0); os.system("id")'
uid=0(root) gid=1000(deploy) groups=1000(deploy)

Nothing in a SUID scan would have shown that binary. Nothing in sudo -l would either. A defender who checks only SUID and sudo has a blind spot the exact size of the capabilities model, which is why this audit stands on three legs, not two. How does a stray capability land on a binary? A careless package, an automation that ran setcap to fix a networking tool and pointed at the wrong file, or an attacker who set it on purpose as a quiet way back to root. The command that plants it is setcap cap_setuid+ep /path, and getcap prints the result back with the =ep spelling you saw above.

The Audit That Runs First, and Keeps Running

All three checks share one shape. Enumerate the privileged surface, compare it against a known-good baseline, and treat every difference as a finding. The first time you run them you are hardening a host. Every time after, you are hunting, because a SUID binary, sudo grant, or capability that was not there last week is both a way up for an attacker and a sign that one has already been through.

The three roads to local root, and the command that reveals each
SUID binaries
what it is
file runs as its owner (root)
find it
find / -xdev -perm -4000 -type f
red flag
anything off your baseline
sudo grants
what it is
run named commands as root
find it
sudo -l, per user
red flag
editor, pager, wildcard, editable script
file capabilities
what it is
one slice of root on a binary
find it
getcap -r /
red flag
cap_setuid, cap_dac_read_search, cap_sys_admin
Check all three. A binary carrying cap_setuid is invisible to a SUID scan, and a sudo grant lives in neither scan.
~/secopslog — bash
$ # baseline once; then every run, diff against it and treat additions as findings { find / -xdev -perm -4000 -type f; getcap -r /; } 2>/dev/null | sort > /var/lib/privesc/today diff /var/lib/privesc/baseline /var/lib/privesc/today
12a13,14 > /usr/bin/find > /usr/bin/python3.11 cap_setuid=ep

Remediate by removing what should not be there. Strip a stray SUID bit with chmod u-s, and strip an unwanted capability with setcap -r. Replace an editor sudo rule with sudoedit. Then verify the way you found the problem: re-run the scans and confirm the finding is gone. In the check below, ls shows find back to a plain -rwxr-xr-x with the s removed, and getcap prints nothing for python, which is what a clean binary looks like.

~/secopslog — bash
$ # strip the planted SUID bit and the stray capability, then confirm both are gone sudo chmod u-s /usr/bin/find sudo setcap -r /usr/bin/python3.11 ls -l /usr/bin/find; getcap /usr/bin/python3.11
-rwxr-xr-x 1 root root 320160 Feb 6 2024 /usr/bin/find
Strip the delta, not the baseline
Do not sweep every SUID bit or capability off a host. mount, su, sudo, and passwd need SUID to work, and ping needs cap_net_raw; remove those and ordinary users can no longer change their password or reach the network. Remove only what differs from your known-good baseline. Two more traps worth setting once: run find with -xdev so it stays on the local disk instead of wandering onto network mounts and hanging, and mount any user-writable or removable filesystem with the nosuid option, so a SUID binary dropped there is ignored by the kernel.
Quick check
01You run find / -xdev -perm -4000 -type f on a host, compare it to your baseline, and it matches exactly, with no unexpected SUID binaries. Why is it too early to conclude there is no local privesc path?
Incorrect — the SUID scan covers only one of the three surfaces.
Correct — a cap_setuid binary is invisible to a SUID search, and sudo is a separate surface entirely.
Incorrect — SGID sets the group identity, not root; it is a different and usually lesser issue.
Incorrect — there is no such cache; find reads live filesystem metadata every run.
02Running the SUID-root find with find /etc/hostname -exec /bin/sh -p \; gives a shell whose id shows uid=1000(deploy) ... euid=0(root). Why does uid stay 1000 while euid is 0, and what does the -p flag do?
Correct — SUID splits identity into an unchanged real uid and an elevated effective uid, and -p preserves the elevated euid in the new shell.
Incorrect — SUID raises only the effective uid, and there is no caching bug; the split is exactly how SUID is meant to work.
Incorrect — -p preserves the elevated effective uid, it does not change the real uid, which stays 1000 regardless.
Incorrect — the kernel checks the effective uid, so euid=0 is precisely what grants this shell root power.
03sudo -l shows (root) NOPASSWD: /opt/app/deploy.sh, and the script is root-owned so you cannot edit its contents through the rule. Then ls -l /opt/app/deploy.sh prints -rwxrwxr-x 1 root deploy ... and your id shows you are in the deploy group. How does this become a root shell?
Incorrect — ownership is not the whole story, because group-write on the file lets you change what the script runs.
Correct — a sudo rule is only as safe as the write permissions on the exact target it trusts.
Incorrect — the sudo rule already executes the script as root, so no capability is involved.
Incorrect — an exact-path rule is still unsafe when the named target is writable by a lower-privileged user.

A privileged binary that was not in last week's baseline is your earliest and cheapest intrusion signal. Put the three scans in a nightly job that diffs against the baseline and alerts on any addition, and the same line of output catches both the misconfiguration you made by accident and the backdoor someone else left on purpose.

Try this

Work through “The Audit That Runs First, and Keeps Running” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: strip the delta, not the baseline. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related