Finding privesc before they do
Audit your own hosts like an attacker.
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.
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.
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.
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.
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app.servicedeploy 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.
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/bin/env bash# record the escalation surface into /var/lib/privescset -euo pipefailout=/var/lib/privescinstall -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 binariesfind / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | sort > "$out/suid.now" || true# files carrying Linux capabilitiesgetcap -r / 2>/dev/null | sort > "$out/caps.now" || true# world-writable regular filesfind / -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.
[Unit]Description=Snapshot the local privilege-escalation surface[Service]Type=oneshotExecStart=/usr/local/sbin/privesc-snapshot.sh
[Unit]Description=Run the privesc surface snapshot hourly[Timer]OnCalendar=hourlyPersistent=true[Install]WantedBy=timers.target
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.
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.
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.
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.