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:
| Path | Mode | Privilege | Package-owned | Keep? |
|---|---|---|---|---|
/usr/bin/passwd | 4755 | SUID root | passwd | yes — users change passwords |
/usr/bin/chsh, chfn | 4755 | SUID root | passwd | no on a server |
/usr/bin/su | 4755 | SUID root | util-linux | no — sudo is used instead |
/usr/bin/mount, umount | 4755 | SUID root | util-linux | no — no fstab user mounts |
/usr/bin/newgrp, gpasswd | 4755 | SUID root | passwd | no |
/usr/bin/ping | cap_net_raw=ep | file capability | iputils-ping | yes |
/usr/lib/openssh/ssh-keysign | 4755 | SUID root | openssh-client | only if host-based auth used |
/opt/app/chrome-sandbox | 4755 | SUID root | none | no — 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_serviceon the app binary instead of running as root, or proxy from nginx on 80 to an unprivileged port. - Replace
su-based admin scripts withsudo -urules scoped to a specific command, which keeps an audit trail in/var/log/auth.log. - Mount
/tmpand/dev/shmwithnosuid,nodev,noexec— this alone neutralises most dropper techniques. - Enable
fs.protected_symlinks=1andfs.protected_hardlinks=1(default on modern kernels) and confirm withsysctl -n. - For services,
NoNewPrivileges=yesmakes 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
findfor SUID/SGID with-xdev, diff against the stored baseline.getcap -r /, diff against the stored capability baseline.dpkg -V/rpm -Vafor modified package files; investigate every5flag.- Review any unowned SUID binary — the tarball-extracted one is the usual finding.
- Confirm
nosuid,nodev,noexecon/tmpand/dev/shm. - 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
You must be logged in to post a comment.