Audit SUID and World-Writable Files With a Python Script

– min read

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.

BucketRuleWhy
HIGHSUID/SGID file whose basename is on the GTFOBins listDocumented privilege escalation recipes, actionable immediately
MEDIUMAny other SUID/SGID binaryExpected for su and passwd, suspicious for a vendor binary nobody remembers
WORLD-WRITABLEFile writable by other, outside a sticky-bit directoryAny 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


SUID census script report showing five high-risk GTFOBins matches in a synthetic lab tree
suid_census.py on the synthetic lab tree. Demo data only.

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.log


Two 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.

Clean SUID census baseline on a real Ubuntu server with no high-risk binaries found
Baseline run against /usr on a production box.
$ 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 match

If 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.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page