CoursesAdvanced Linux securityFinding privesc before they do

Finding privesc before they do

Audit your own hosts like an attacker.

Advanced12 min · lesson 5 of 17

Knowing how privilege escalation (a normal user quietly gaining more power, usually all the way up to root, the account that can do anything on the box) works only pays off when you turn it around. An attacker who lands on one of your hosts does not attack first. They spend the opening minutes on reconnaissance, walking the machine and writing down every weak hinge and unlatched window. You can run that exact same walk yourself, on your own hosts, on a schedule. Do it once and you close the escalation paths before anyone finds them. Do it forever and the moment someone plants a new one, you watch it appear.

Run the attacker's first five minutes

There are four doors an attacker checks almost every time, because they pay off often: programs that run as root no matter who starts them, programs carrying slices of root's power, rules that let your user run things as root, and root-owned jobs or folders that a normal user can rewrite. Every door has a one-line check. Start with the first and loudest one.

SUID: the hall pass that runs as root

A hall pass lets a student walk the corridors with a teacher's authority, even though they are still a student. The SUID bit (Set User ID, a single flag on a file) does the same for a program: when the flag is set, the program runs with the authority of the file's owner instead of the person who launched it. If root owns the file and the flag is on, then anyone who runs it is briefly acting as root for as long as that program runs. Some binaries genuinely need this. passwd has to edit /etc/shadow, which only root can write, so it is SUID root by design. The danger is a copy of a shell wearing that same hall pass. Drop a root-owned SUID copy of bash somewhere quiet, run it, and you have a root shell with no password.

~/secopslog — bash
$ # every SUID binary on the box, sorted so you can diff it later find / -perm -4000 -type f 2>/dev/null | sort
/usr/bin/chfn /usr/bin/chsh /usr/bin/gpasswd /usr/bin/mount /usr/bin/newgrp /usr/bin/passwd /usr/bin/su /usr/bin/sudo /usr/bin/umount /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/openssh/ssh-keysign /usr/lib/policykit-1/polkit-agent-helper-1 /usr/lib/snapd/snap-confine

Read the flags like this: -perm -4000 asks find for files that have the SUID bit set (4000 in octal is that bit), and the leading minus means "at least this bit, ignore the rest." The 2>/dev/null throws away the flood of permission-denied messages from folders your user cannot open. Swap 4000 for 2000 to list SGID (Set Group ID, the same trick but for a group's authority). The list above is the stock Ubuntu 22.04 set. Learn it by sight, because your job is to notice the day a fourteenth line shows up that no package put there.

Capabilities: root's power cut into single keys

Root is a master key that opens every door in the building. That is a lot of power to hand a program that only needs one door. Linux capabilities are the fix: they break the master key into roughly forty separate keys, each opening exactly one door, so a program can hold just the one it needs. This is why ping is no longer SUID root on modern systems. It carries cap_net_raw (permission to craft raw network packets) and nothing else. The keys to fear are the ones that reopen the whole building. cap_setuid lets a program change its own user ID to anyone, including root, which is a complete escalation on its own. getcap walks the filesystem and prints every file that carries any capability.

~/secopslog — bash
$ getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep /usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_raw=ep

Those two are normal. The day this command prints /usr/bin/python3.10 cap_setuid=ep, treat it as an alarm. Any local user could then run python3 -c 'import os; os.setuid(0); os.system("/bin/bash")' and walk out as root, no exploit required. Stock Python ships with no capabilities at all, so that line did not get there by accident.

Sudo rules and writable back doors

A sudo (superuser do) rule is a signed permission slip: it lets a named user run a named command as root. The rules live in /etc/sudoers and the drop-in files under /etc/sudoers.d, and sudo -l prints the slips your current user holds. The trap is that a slip which looks narrow can still be a full way up, because plenty of ordinary commands can be talked into spawning a shell or running a command of your choosing. GTFOBins (a public catalogue of standard Unix binaries that can be abused to break out into a shell) is the reference for which ones. Here is a rule that reads harmless and is not.

~/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: (ALL) NOPASSWD: /usr/bin/systemctl restart app.service (root) NOPASSWD: /usr/bin/tar *

The second line is the problem. tar with a wildcard means the deploy user can pass any tar options they like, and tar can run a program at each checkpoint. That turns into a root shell in one command: sudo tar -cf /dev/null /dev/null --checkpoint=1 --checkpoint-action=exec=/bin/sh. The rule that granted it looks tidy on disk.

/etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app.service
deploy ALL=(root) NOPASSWD: /usr/bin/tar *

The fourth door is a writable back door. cron (the built-in scheduler that runs jobs at fixed times, many of them as root) is a common one. If a root cron job runs /opt/app/backup.sh, and that script, or the folder holding it, can be written by a normal user, then that user rewrites the script and waits for cron to run their version as root. The same goes for any directory on root's PATH (the ordered list of folders the shell searches for commands) that an unprivileged user can write to. Find the world-writable files with find / -perm -0002 -type f 2>/dev/null, where -0002 is the "writable by everyone" bit, then check whether anything root runs lives in or reads from them.

A new SUID, capability, or sudo rule is guilty until proven innocent
On a stable production host the set of SUID and SGID binaries, file capabilities, and sudo grants barely moves between package updates. So a newly appeared one is rarely harmless. It is either an unreviewed change that widened your attack surface, or an attacker who just built themselves an escalation path or a backdoor. Do not explain it away as a curiosity. Treat every unexplained addition as an incident lead until you can name the change and the person who made it.

Baseline, then let the diff do the detecting

A shop counts its stock at close and compares it to the morning count. Anything that appeared or vanished gets a question. Your hosts get the same treatment. Capture the four surfaces once on a known-good machine, store that as a baseline, and re-capture on a schedule. You are not hunting for a clever signature. The change itself is the detection, because on a healthy server these lists are supposed to sit still. One script records the surface into files you can compare.

/usr/local/sbin/privesc-snapshot.sh
#!/usr/bin/env bash
# record the escalation surface into /var/lib/privesc
set -euo pipefail
out=/var/lib/privesc
install -d -m 700 "$out"
# find returns non-zero the moment it hits one unreadable directory,
# even with stderr sent to /dev/null, so each pipeline ends in
# "|| true", or set -e would kill the run after the first find.
# -xdev keeps find on this one filesystem, off the /proc /sys /dev
# pseudo-mounts that would only add noise.
# SUID + SGID binaries
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | sort > "$out/suid.now" || true
# files carrying Linux capabilities
getcap -r / 2>/dev/null | sort > "$out/caps.now" || true
# world-writable regular files
find / -xdev -perm -0002 -type f 2>/dev/null | sort > "$out/wworld.now" || true

Review the first run by hand, then promote it: cp suid.now suid.baseline, and the same for the others. (If a host keeps /home or /var on their own mounts, snapshot those too, since -xdev will not cross into them.) Now schedule it. On a modern systemd host (systemd being the manager that starts and supervises services on most current Linux distributions), a timer unit is cleaner than a crontab line, because it is version-controlled with the rest of your units and its runs show up in the journal.

/etc/systemd/system/privesc-audit.service
[Unit]
Description=Snapshot the local privilege-escalation surface
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/privesc-snapshot.sh
/etc/systemd/system/privesc-audit.timer
[Unit]
Description=Run the privesc surface snapshot hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
~/secopslog — bash
$ sudo systemctl enable --now privesc-audit.timer systemctl list-timers privesc-audit.timer --no-pager
Created symlink /etc/systemd/system/timers.target.wants/privesc-audit.timer → /etc/systemd/system/privesc-audit.timer. NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-07-17 15:00:00 UTC 41min left Fri 2026-07-17 14:00:03 UTC 18min ago privesc-audit.timer privesc-audit.service 1 timers listed.

With the baseline stored and the timer ticking, the whole detector is one comparison. This is what it looks like the hour an attacker plants a persistence hook.

~/secopslog — bash
$ diff /var/lib/privesc/suid.baseline /var/lib/privesc/suid.now
23a24 > /var/tmp/.sshd

That single line is the finding. A SUID binary named to look like a system daemon has appeared in /var/tmp since your baseline, and /var/tmp is not where packages install anything. Run ls -l and file on it and you will almost always find it is a root-owned copy of /bin/bash. That is a root shell left behind for later, and your diff caught it within the hour instead of during the post-incident review.

Tools that do the enumeration for you

You do not have to hand-write all of this. LinPEAS (Linux Privilege Escalation Awesome Script) and LSE (linux-smart-enumeration) are the offensive scanners: point them at your own hosts and they enumerate SUID binaries, capabilities, cron jobs, writable paths, cached credentials, and kernel (the core of the operating system that talks to the hardware) versions, then highlight the paths that actually lead up. Running an offensive scanner against your own fleet, an internal purple-team exercise, is one of the most direct ways to find what matters, because it finds exactly what the real attacker would. Lynis is the defender-side counterpart. It audits the host, scores its hardening, and flags escalation paths and gaps with test IDs you can track over time.

~/secopslog — bash
$ sudo lynis audit system
[+] File Permissions ------------------------------------ - Checking /etc/crontab [ OK ] - Checking setuid files [ FOUND ] ================================================================================ Lynis security scan details: Hardening index : 68 [############## ] Tests performed : 258 Plugins enabled : 2 Suggestions (26): ---------------------------- * Consider hardening SSH configuration [SSH-7408] - Details : MaxAuthTries (6 --> 3) ================================================================================

To run the same idea across a fleet instead of one box, osquery (a tool that lets you query a machine's live state with SQL, the language you already use for databases) exposes the escalation surface as tables. Its suid_bin table is a ready-made SUID inventory, and osquery's scheduled queries return only what changed since last time, which is the baseline-and-diff pattern turned into a continuous feed you can ship to your logging pipeline. Filter out the expected /usr set and the anomaly surfaces on its own.

~/secopslog — bash
$ osqueryi "SELECT path, username, permissions FROM suid_bin WHERE path NOT LIKE '/usr/%';"
+----------------+----------+-------------+ | path | username | permissions | +----------------+----------+-------------+ | /var/tmp/.sshd | root | U | +----------------+----------+-------------+
The audit loop you run on every host
1Snapshot the surface
SUID, SGID, caps, sudo, writable cron and PATH
2Commit a baseline
from a known-good host, stored read-only
3Re-snapshot on a timer
systemd timer, hourly, journaled
4Diff against baseline
the change is the detection
5Triage each addition
a misconfig to fix, or an incident lead
Quick check
01Your hourly snapshot diff picks up one new line since yesterday: /usr/bin/python3.10 cap_setuid=ep. How do you read it?
Incorrect — Stock Python ships with no capabilities at all, so no packager put that key there. Updates swap the binary out, they do not hand it cap_setuid.
Incorrect — It works the other way round. cap_setuid is what carries an ordinary account up to root, so a queue is exactly what the intruder is counting on.
Incorrect — A capability stands alone. cap_setuid lets the process set its own user ID to zero, with no SUID flag involved anywhere on the file.
Correct — Any local user can start python and come back as root. The line was absent from your baseline, which is enough to open a case rather than a question.
02You want two things this quarter: the escalation paths a real intruder would surface on your fleet, and a hardening score you can follow by test ID. How do you split the work between the tools this lesson names?
Incorrect — You have the roles reversed. Lynis is the auditor that grades a host, and it is not the pair that walks the escalation paths for you.
Correct — LinPEAS and LSE run the intruder's own reconnaissance against hosts you own. Lynis grades what is left standing and hands you IDs you can follow over time.
Incorrect — Only two of the three sit on the offensive side. Lynis is the defender's tool, and which distributions they support is not what divides them.
Incorrect — Neither tool is defined by the account it runs under. The split is attacker-style enumeration against defender-style auditing and scoring.
03Your sudoers drop-in for deploy ends with the line deploy ALL=(root) NOPASSWD: /usr/bin/tar *. A teammate says it is fine because tar only reads and writes archives. What do you tell them?
Incorrect — Sudo hands the arguments straight through, and the wildcard is what permits them. That is how the checkpoint options reach tar in the first place.
Incorrect — The abuse needs no archive from anyone. It comes from tar's own options on the command line, so restricting source paths does not close it.
Correct — One command does it: sudo tar -cf /dev/null /dev/null --checkpoint=1 --checkpoint-action=exec=/bin/sh. Check GTFOBins before you sign off on any sudo rule.
Incorrect — The rule already runs tar with root's authority, so a SUID flag would add nothing. The sudo grant is where the privilege comes from.

Before you trust any of this, prove the detector actually fires. On a throwaway host, plant a harmless decoy that looks like an escalation path, run the snapshot, and confirm the diff calls it out: sudo install -m 4755 -o root /bin/true /var/tmp/.probe, then sudo /usr/local/sbin/privesc-snapshot.sh, then diff /var/lib/privesc/suid.baseline /var/lib/privesc/suid.now. You want to see > /var/tmp/.probe come back. Remove it with sudo rm /var/tmp/.probe once you have seen the catch. If that diff comes back empty, your detection is decorative, and you want to find that out now, not the morning after someone plants the real thing.

Try this

Work through “Tools that do the enumeration for you” 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: a new SUID, capability, or sudo rule is guilty until proven innocent. 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