One stray SUID binary quietly cancels months of hardening work. Every privesc writeup I read lately starts the same way: a find one-liner, a forgotten vendor binary, and root inside three commands. Checking for that drift by hand means re-reading the same forty coreutils entries you already trust, so I wrote a census script instead.
Reference: GTFOBins · Python stat module
What it checks
The script walks the roots you give it (or a sensible default list), never crosses into another filesystem, ignores symlinks, and sorts every hit into three buckets.
| Bucket | Rule | Why |
|---|---|---|
| HIGH | SUID/SGID file whose basename is on the GTFOBins list | Documented privilege escalation recipes, actionable immediately |
| MEDIUM | Any other SUID/SGID binary | Expected for su and passwd, suspicious for a vendor binary nobody remembers |
| WORLD-WRITABLE | File writable by other, outside a sticky-bit directory | Any local user can tamper with configs, keys and spool files |
The known-noise list matters as much as the risk list: unix_chkpwd and crontab are SGID on every distro, and a script that flags them gets muted within a week. Everything that survives that filter is something a human should look at.
The script
Pure stdlib, no dependencies, runs on any box with Python 3. Drop it in /usr/local/bin/suid_census.py.
#!/usr/bin/env python3
"""suid_census.py - inventory SUID/SGID binaries and world-writable files.
Walks one or more roots (default: /usr, /opt, /var, /etc, /home, /srv, /tmp),
skips other filesystems, and prints a triage report:
1. SUID/SGID binaries that match known GTFOBins names -> HIGH
2. Other SUID/SGID binaries -> MEDIUM
3. World-writable files NOT in a sticky-bit directory -> MEDIUM
No dependencies beyond stdlib. Usage: suid_census.py [path ...]
"""
import os
import stat
import sys
# Binary basenames that have documented GTFOBins privesc recipes.
# Trimmed to the ones actually seen in the wild on Linux boxes.
GTFOBINS = {
"awk", "bash", "cp", "dd", "diff", "find", "gcc", "gdb", "python",
"python2", "python3", "perl", "nmap", "vim", "vi", "nano", "rsync",
"tar", "less", "more", "man", "nl", "cpulimit", "env", "fseek",
"install", "wget", "curl", "rsync", "screen", "tmux", "irssi",
"awk", "php", "node", "ruby", "lua", "grep", "bg", "tee", "strace",
}
# SGID-only names that are noisy and usually fine (chown helpers etc.)
SGID_OK = {"unix_chkpwd", "crontab", "ssh-agent", "mail", "dotlockfile", "at"}
def is_boundary(path, dev):
"""True when path sits on a different filesystem than its parent."""
try:
return os.stat(path).st_dev != dev
except OSError:
return True
def walk(roots):
files = []
for root in roots:
if not os.path.isdir(root):
continue
stack = [(root, os.stat(root).st_dev)]
while stack:
path, dev = stack.pop()
try:
entries = os.scandir(path)
except PermissionError:
continue
for e in entries:
try:
if e.is_symlink():
continue
if e.is_dir(follow_symlinks=False):
st = e.stat(follow_symlinks=False)
if st.st_dev != dev: # mount boundary, skip fs
continue
stack.append((e.path, st.st_dev))
elif e.is_file(follow_symlinks=False):
st = e.stat(follow_symlinks=False)
mode = st.st_mode
if mode & (stat.S_ISUID | stat.S_ISGID) or mode & 0o002:
files.append((e.path, mode, st.st_uid, st.st_gid))
except (PermissionError, OSError):
continue
return files
def octal(mode):
return format(stat.S_IMODE(mode), "04o")
def sticky_parent(path):
try:
return bool(os.stat(os.path.dirname(path)).st_mode & stat.S_ISVTX)
except OSError:
return False
def main():
roots = sys.argv[1:] or ["/usr", "/opt", "/var", "/etc", "/home", "/srv", "/tmp"]
entries = walk(roots)
high, medium, wfiles = [], [], []
for path, mode, uid, gid in entries:
name = os.path.basename(path).lower()
suid = bool(mode & stat.S_ISUID)
sgid = bool(mode & stat.S_ISGID)
other_w = bool(mode & 0o002)
if suid or sgid:
kind = "SUID" if suid and sgid else ("SUID" if suid else "SGID")
if name in GTFOBINS:
high.append((path, kind, mode, uid, gid))
elif not (sgid and not suid and name in SGID_OK):
medium.append((path, kind, mode, uid, gid))
if other_w and not os.path.basename(path).startswith("."):
if not sticky_parent(path):
wfiles.append((path, mode, uid, gid))
print(f"suid_census: scanned {len(roots)} root(s), "
f"{len(high) + len(medium) + len(wfiles)} interesting entries")
print()
print("== HIGH: SUID/SGID with known GTFOBins recipes ==")
for path, kind, mode, uid, gid in sorted(high):
print(f" {kind:<5} {octal(mode):<6} uid={uid:<4} gid={gid:<4} {path}")
if not high:
print(" (none)")
print()
print("== MEDIUM: other SUID/SGID binaries ==")
for path, kind, mode, uid, gid in sorted(medium):
print(f" {kind:<5} {octal(mode):<6} uid={uid:<4} gid={gid:<4} {path}")
if not medium:
print(" (none)")
print()
print("== WORLD-WRITABLE files outside sticky dirs ==")
for path, mode, uid, gid in sorted(wfiles):
print(f" rw-rw {octal(mode):<6} uid={uid:<4} gid={gid:<4} {path}")
if not wfiles:
print(" (none)")
rc = 1 if high else 0
sys.exit(rc)
if __name__ == "__main__":
main()Prove it finds something first
Before trusting a clean report, build a synthetic tree with planted landmines. All demo data below is generated, no real hosts involved. The tree plants five GTFOBins-named SUID/SGID binaries, two odd vendor binaries, three world-writable files (one inside a sticky directory that must NOT be flagged), and a symlink decoy.
FR=/tmp/fakeroot
rm -rf $FR
mkdir -p $FR/usr/bin $FR/usr/sbin $FR/var/log $FR/etc $FR/home/devuser $FR/srv/uploads $FR/tmp-spool
# decoy binaries (copies of /bin/true, renamed)
for n in python3 nmap cp awk vim; do cp /bin/true $FR/usr/bin/$n; done
mv $FR/usr/bin/vim $FR/home/devuser/vim
cp /bin/true $FR/usr/bin/mydeploy; cp /bin/true $FR/usr/sbin/backup-helper
# sprinkled risk bits
chmod 4755 $FR/usr/bin/python3 $FR/usr/bin/nmap $FR/usr/bin/mydeploy $FR/home/devuser/vim
chmod 2755 $FR/usr/bin/cp $FR/usr/bin/awk
chmod 6755 $FR/usr/sbin/backup-helper
# world-writable files, one inside a sticky dir (should NOT be flagged)
echo app > $FR/srv/uploads/config.bak && chmod 666 $FR/srv/uploads/config.bak
echo data > $FR/var/log/app.log && chmod 666 $FR/var/log/app.log
chmod 1777 $FR/tmp-spool && echo x > $FR/tmp-spool/session.tmp && chmod 666 $FR/tmp-spool/session.tmp
# symlink decoy (must be skipped, it is not a real SUID file)
ln -s /usr/bin/python3 $FR/home/devuser/python-link
And the raw text of that run:
suid_census: scanned 1 root(s), 10 interesting entries
== HIGH: SUID/SGID with known GTFOBins recipes ==
SUID 4755 uid=0 gid=0 /tmp/fakeroot/home/devuser/vim
SGID 2755 uid=0 gid=0 /tmp/fakeroot/usr/bin/awk
SGID 2755 uid=0 gid=0 /tmp/fakeroot/usr/bin/cp
SUID 4755 uid=0 gid=0 /tmp/fakeroot/usr/bin/nmap
SUID 4755 uid=0 gid=0 /tmp/fakeroot/usr/bin/python3
== MEDIUM: other SUID/SGID binaries ==
SUID 4755 uid=0 gid=0 /tmp/fakeroot/usr/bin/mydeploy
SUID 6755 uid=0 gid=0 /tmp/fakeroot/usr/sbin/backup-helper
== WORLD-WRITABLE files outside sticky dirs ==
rw-rw 0662 uid=0 gid=0 /tmp/fakeroot/etc/notes.txt
rw-rw 0666 uid=0 gid=0 /tmp/fakeroot/srv/uploads/config.bak
rw-rw 0666 uid=0 gid=0 /tmp/fakeroot/var/log/app.logTwo things the first test run taught me
The symlink decoy was the important one. An early version followed symlinks, and a python-link sitting in a home directory made the report claim a SUID binary that does not exist on that path. The file-type checks now run with follow_symlinks=False everywhere, so the report only lists real files.
The second: my sticky-directory spool file showed up in the world-writable bucket until I added the parent-directory check. Files in /tmp-style 1777 directories are world-writable by design. Sticky parent means skip. Both fixes are in the listing above.
Baseline on a real server
On an actual Ubuntu 24.04 box, a full /usr scan takes about three seconds and the report reads like a textbook: no HIGH findings, twenty expected SUID/SGID entries, no loose world-writable files.

$ python3 suid_census.py /usr
suid_census: scanned 1 root(s), 20 interesting entries
== HIGH: SUID/SGID with known GTFOBins recipes ==
(none)
== MEDIUM: other SUID/SGID binaries ==
SGID 2755 uid=0 gid=42 /usr/bin/chage
SUID 4755 uid=0 gid=0 /usr/bin/chfn
SUID 4755 uid=0 gid=0 /usr/bin/passwd
SUID 4755 uid=0 gid=0 /usr/bin/su
SUID 4754 uid=0 gid=996 /usr/lib/dbus-1.0/dbus-daemon-launch-helper
...
== WORLD-WRITABLE files outside sticky dirs ==
(none)That listing is the point of the whole exercise. Not the one-off audit, the saved baseline you diff next week against. A vendor updater that installs an SUID helper now shows up as one changed line in a file, and the exit code does the rest: 0 means no HIGH findings, 1 means page somebody.
Schedule it
Cron plus a diff is enough for most fleets. If your crontab itself needs a health check first, I covered that in auditing cron and systemd timer hygiene, and the notify.sh in the block below follows the same alerting pattern as my uptime monitor script.
# /etc/cron.weekly style, Monday 06:00, keep a diff-able history
0 6 * * 1 root cd /var/log/suid-census && \
/usr/bin/python3 /usr/local/bin/suid_census.py /usr /opt /srv \
> new.txt 2>&1; diff -q old.txt new.txt > /dev/null || \
{ cp new.txt old.txt; /usr/local/bin/notify.sh "SUID census changed, check /var/log/suid-census"; }
# exit code alone is enough for a cheap healthcheck ping:
# 0 = no HIGH findings, 1 = at least one GTFOBins matchIf you have already hardened sshd along the lines of the sshd_config gap audit, this census closes the local half of the same loop. Remote entry gets audited weekly, local privilege surface gets counted weekly, and drift in either direction stops being a surprise.
Run it once before your next patch cycle. The baseline is free, the diff is the payoff.