Auditing SUID Binaries and File Capabilities on a VPS: Inventory, Removal and Drift Checks

A stock Debian 12 install has 13 files with the SUID bit set, and several are unneeded on a server: /usr/bin/su, /usr/bin/mount and /usr/bin/chsh are standard privilege-escalation footholds after a compromise. Finding them takes one command. Auditing them — verifying nothing breaks when the bit comes off, and catching file capabilities that find -perm -4000 never shows — takes twenty minutes.

This audit was run against a minimal web-facing VPS serving nginx and PHP-FPM. Everything is read-only until the removal section, so it is safe on a production box; the package step uses dpkg, with an rpm equivalent.

Why the SUID bit alone is an incomplete picture

A SUID binary runs with its owner’s privileges. A file capability grants a subset of root privileges without SUID — cap_net_bind_service lets an unprivileged process bind port 80, cap_setuid lets it change its own UID, which is most of what an attacker needs. Capabilities live in an extended attribute, so find -perm -4000 reports nothing.

A third category matters more in practice: binaries marked +s inside a tarball-extracted application directory, where ownership may be a non-root UID but the binary still runs with that UID’s rights. All three belong in one inventory.

Enumerating SUID and SGID files

Start from both ends — filesystem and package database. The difference separates “installed by a package” from “arrived some other way”, and the second set is where surprises live.

# all SUID/SGID binaries, with owner and capability, excluding /proc and /sys
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f \
     -printf '%m %u:%g %p\n' 2>/dev/null | sort -k3

# file capabilities (extended attribute security.capability)
getcap -r / 2>/dev/null

# files that changed since the package was installed (deb)
dpkg -V 2>/dev/null | head -40

find returned 13 SUID and 8 SGID files, all root-owned and package-owned except a chrome-sandbox under a leftover application directory, extracted from a tarball seven months earlier and owned by no package.

The -xdev flag matters: without it find / descends into network volumes and /proc, producing false positives and taking minutes. Run it once per filesystem.

Reading the file capability set

getcap -r / prints each file with a capability and whether it is ep (effective + permitted), p (permitted only, must be raised by the program) or ei (inheritable). The distinction matters: cap_net_raw+p on ping is benign because ping raises it deliberately, while cap_setuid+ep on any binary is effectively root.

$ getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/lib/nmap/nmap cap_net_raw,cap_net_admin,cap_d_sys_admin=eip

The nmap line is the classic problem: cap_net_admin lets a process reconfigure interfaces and firewall rules without a root shell. A web server does not need nmap, and removing it removes the capability. Where one is genuinely needed, use the narrowest form: setcap cap_net_bind_service=+ep /usr/local/bin/app instead of running as root.

Check running processes too, since a service manager can grant capabilities the binary does not carry on disk: grep Cap /proc/<pid>/status shows CapEff, and capsh --decode=00000000a80425fb expands the hex. A service with cap_sys_admin in CapEff is one kernel bug from full compromise.

The inventory table

The audit produces a table to review whenever a package update touches one of these paths. Reduced form for a small web host — every entry needs a stated reason:

PathModePrivilegePackage-ownedKeep?
/usr/bin/passwd4755SUID rootpasswdyes — users change passwords
/usr/bin/chsh, chfn4755SUID rootpasswdno on a server
/usr/bin/su4755SUID rootutil-linuxno — sudo is used instead
/usr/bin/mount, umount4755SUID rootutil-linuxno — no fstab user mounts
/usr/bin/newgrp, gpasswd4755SUID rootpasswdno
/usr/bin/pingcap_net_raw=epfile capabilityiputils-pingyes
/usr/lib/openssh/ssh-keysign4755SUID rootopenssh-clientonly if host-based auth used
/opt/app/chrome-sandbox4755SUID rootnoneno — unowned, remove

Removing the bit is reversible with chmod u+s and keeps updates working. The risk is a silent dependency: a cron job using su, a script calling mount via fstab user.

Verifying nothing breaks: remove one bit at a time

Remove one bit, wait a day of normal operation, check the journal for Permission denied or Operation not permitted, then continue. Batch removal makes failures unattributable — and they surface at 03:00 in a backup script.

# remove the SUID bit (reversible with chmod u+s)
chmod u-s /usr/bin/su /usr/bin/chsh /usr/bin/chfn /usr/bin/newgrp /usr/bin/gpasswd
chmod u-s /usr/bin/mount /usr/bin/umount

# verify
find / -xdev -perm -4000 -type f 2>/dev/null

# watch for fallout for 24h
journalctl -b --since "24 hours ago" -p warning | grep -iE "permission denied|not permitted" | head

Removing seven bits dropped the inventory from 13 to 6 with zero journal errors over seven days. The one that would have broken was su: a monitoring script ran su -s /bin/sh postgres and failed with su: Authentication failure, not a permission error. SUID removals on su and mount surface as authentication or filesystem errors.

To clear a capability instead, use setcap -r /path/to/binary and verify with getcap -r /; removing the package is cleaner when the binary has no other purpose.

Using the inventory as a baseline for drift detection

A static list is only useful if it is checked. A nightly diff against a stored baseline catches the common case — a new SUID file after a deploy or tarball extraction — without the CPU cost of hashing the filesystem.

# create the baseline once, after the audit is clean
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -printf '%m %u:%g %p\n' 2>/dev/null | sort > /var/lib/suid-baseline.txt

# daily diff (cron or systemd timer)
diff -u /var/lib/suid-baseline.txt /dev/null | sort) \
  && echo "suid inventory unchanged" \
  || logger -t suidaudit "SUID inventory changed - review immediately"

Run the same diff for getcap -r /; capability changes do not always add a file. Updating the baseline must be a reviewed step — the value is in the human review of the delta.

Add a periodic dpkg -V for binaries modified in place: 5 means the checksum changed, U ownership, M mode. A SUID binary also flagged 5 is the highest-priority finding in any audit.

Tightening what remains

  • Bind low ports with cap_net_bind_service on the app binary instead of running as root, or proxy from nginx on 80 to an unprivileged port.
  • Replace su-based admin scripts with sudo -u rules scoped to a specific command, which keeps an audit trail in /var/log/auth.log.
  • Mount /tmp and /dev/shm with nosuid,nodev,noexec — this alone neutralises most dropper techniques.
  • Enable fs.protected_symlinks=1 and fs.protected_hardlinks=1 (default on modern kernels) and confirm with sysctl -n.
  • For services, NoNewPrivileges=yes makes SUID binaries inside the service a dead end.

nosuid catches the most: a SUID binary written into /tmp gains nothing. Verify with findmnt -no OPTIONS /tmp.

A one-page audit routine

  1. find for SUID/SGID with -xdev, diff against the stored baseline.
  2. getcap -r /, diff against the stored capability baseline.
  3. dpkg -V / rpm -Va for modified package files; investigate every 5 flag.
  4. Review any unowned SUID binary — the tarball-extracted one is the usual finding.
  5. Confirm nosuid,nodev,noexec on /tmp and /dev/shm.
  6. For each remaining entry, write down why it exists. An entry without a reason is a candidate for removal.

Ten minutes on a small host once baselines exist, and the part of server configuration that pays off after an intrusion attempt rather than before.

Leave a Reply