Auditing permissions, SUID/SGID and umask

Find risky files and set sane defaults.

Intermediate14 min · lesson 10 of 24

Permissions drift. Packages add SUID programs, someone makes a directory world-writable to get past an error, an account is deleted and leaves its files behind. Each of these can hand a local user more than you meant to give. In this lesson you audit a server for the permissions that give privilege away: SUID and SGID programs, file capabilities, world-writable and ownerless files, and the modes of the account files and home directories. You remove a SUID bit so that it stays removed, set a umask policy for logins and services, and keep a baseline to compare against. What SUID, SGID, the sticky bit and the umask are is covered in Linux essentials; how attackers abuse them is covered in linux-det.

Every SUID and SGID program, accounted for

A SUID root program runs as root whoever starts it, so a bug in it is a way to root for every local user. Start with the list, on every local filesystem: find -xdev stays on the filesystem where it starts, so name each mount point.

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ findmnt -t ext4,xfs,btrfs,vfat
TARGET SOURCE FSTYPE OPTIONS / /dev/vda1 ext4 rw,relatime,discard,errors=remount-ro,commit=30 └─/boot /dev/vda13 ext4 rw,relatime └─/boot/efi /dev/vda15 vfat rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro
$ sudo find / /boot -xdev -type f -perm /6000 -printf '%M %u:%g %p\n' | sort -k3
-rwxr-sr-x root:shadow /usr/bin/chage -rwsr-xr-x root:root /usr/bin/chfn -rwsr-xr-x root:root /usr/bin/chsh -rwxr-sr-x root:crontab /usr/bin/crontab -rwxr-sr-x root:shadow /usr/bin/expiry -rwsr-xr-x root:root /usr/bin/fusermount3 -rwsr-xr-x root:root /usr/bin/gpasswd -rwsr-xr-x root:root /usr/bin/mount -rwsr-xr-x root:root /usr/bin/newgrp -rwsr-xr-x root:root /usr/bin/ntfs-3g -rwsr-xr-x root:root /usr/bin/passwd -rwxr-sr-x root:_ssh /usr/bin/ssh-agent -rwsr-xr-x root:root /usr/bin/su -rwsr-xr-x root:root /usr/bin/sudo.ws -rwsr-xr-x root:root /usr/bin/umount -rwsr-xr-x root:root /usr/lib/cargo/bin/su -rwsr-xr-x root:root /usr/lib/cargo/bin/sudo -rwsr-xr-- root:messagebus /usr/lib/dbus-1.0/dbus-daemon-launch-helper -rwsr-xr-x root:root /usr/lib/openssh/ssh-keysign -rwxr-sr-x root:shadow /usr/sbin/pam_extrausers_chkpwd -rwxr-sr-x root:shadow /usr/sbin/unix_chkpwd

-perm /6000 matches SUID or SGID, and /boot is scanned separately because it is its own filesystem (the vfat /boot/efi cannot hold these bits at all). Linux essentials walked through this list; in short it holds account tools that let users change their own entries (passwd, chfn, SGID shadow helpers), privilege tools (su and sudo-rs), mount helpers and a few service helpers such as dbus's launch helper and ssh-keysign. An audit asks one question of each entry: does this server need it?

Three entries are installed but not in use. /usr/bin/sudo.ws is classic sudo, kept as an alternative to sudo-rs; /usr/lib/cargo/bin/su is sudo-rs's su, while the active su is util-linux's /usr/bin/su. Both are still SUID root, so both are code any user can run as root, and both belong to packages the system needs. ntfs-3g lets ordinary users mount NTFS disks, which a server rarely needs, and nothing requires it:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ apt-cache depends ubuntu-standard udisks2 | grep -E '^[a-z]|ntfs'
ubuntu-standard Recommends: ntfs-3g udisks2 Recommends: ntfs-3g
$ apt-get -s purge ntfs-3g | grep -E '^(Purg|Remv|Inst)'
Purg ntfs-3g [1:2022.10.3-5ubuntu1.1]

ubuntu-standard and udisks2 only recommend ntfs-3g, and apt keeps a package installed when one of its recommendations is removed, so the simulated purge removes ntfs-3g and nothing else. Removing an unused SUID program is the strongest fix: there is no code left to exploit. On RHEL 10 the set differs: pkexec, pam_timestamp_check and grub2-set-bootflag are SUID, and unix_chkpwd is SUID root because /etc/shadow is mode 0 there.

deploy@rocky10 · Rocky Linux 10.2
$ sudo find / /boot -xdev -type f -perm /6000 -printf '%M %u:%g %p\n' | sort -k3
… -rwsr-xr-x root:root /usr/bin/pkexec … ---s--x--x root:root /usr/bin/sudo … -rwsr-xr-x root:root /usr/sbin/grub2-set-bootflag … -rwsr-xr-x root:root /usr/sbin/pam_timestamp_check -rwsr-xr-x root:root /usr/sbin/unix_chkpwd

Removing a SUID bit so that it stays removed

Some SUID programs must stay installed although nobody on this server should run them, such as sudo.ws and sudo-rs's su above. For those the fix is to remove the bit. ntfs-3g serves as the harmless example here (on a real server you would purge it). The obvious fix is chmod u-s. Watch what the next package update does to it; a reinstall stands in for the update:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo chmod u-s /usr/bin/ntfs-3g ls -l /usr/bin/ntfs-3g
-rwxr-xr-x 1 root root 199736 Jul 11 15:55 /usr/bin/ntfs-3g
$ sudo apt-get install --reinstall -y ntfs-3g ls -l /usr/bin/ntfs-3g
… 0 upgraded, 0 newly installed, 1 reinstalled, 0 to remove and 6 not upgraded. … Unpacking ntfs-3g (1:2022.10.3-5ubuntu1.1) over (1:2022.10.3-5ubuntu1.1) ... Setting up ntfs-3g (1:2022.10.3-5ubuntu1.1) ... … -rwsr-xr-x 1 root root 199736 Jul 11 15:55 /usr/bin/ntfs-3g

dpkg sets every file to the mode in the package, silently. The Debian way to make a local decision stick is dpkg-statoverride, which records owner, group and mode for a path and applies them whenever dpkg installs that file. --update also applies them now.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo dpkg-statoverride --update --add root root 0755 /usr/bin/ntfs-3g ls -l /usr/bin/ntfs-3g
-rwxr-xr-x 1 root root 199736 Jul 11 15:55 /usr/bin/ntfs-3g
$ sudo apt-get install --reinstall -y ntfs-3g ls -l /usr/bin/ntfs-3g
… 0 upgraded, 0 newly installed, 1 reinstalled, 0 to remove and 6 not upgraded. … Unpacking ntfs-3g (1:2022.10.3-5ubuntu1.1) over (1:2022.10.3-5ubuntu1.1) ... Setting up ntfs-3g (1:2022.10.3-5ubuntu1.1) ... … -rwxr-xr-x 1 root root 199736 Jul 11 15:55 /usr/bin/ntfs-3g
$ dpkg-statoverride --list '/usr/bin/*'
root crontab 2755 /usr/bin/crontab root root 755 /usr/bin/ntfs-3g

The bit stays off through the reinstall. Ubuntu itself uses the same mechanism: crontab's SGID mode and dbus's launch helper are statoverrides set by their packages. The impact is that ordinary users can no longer run the program with root's powers; root still can. Removing unused SUID programs, or their bit when the package must stay, is SecOpsLog advice. CIS benchmarks ask you to review SUID and SGID programs and do not list which to remove, because that depends on what the server does. To roll back, remove the override and restore the mode:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo dpkg-statoverride --remove /usr/bin/ntfs-3g sudo chmod 4755 /usr/bin/ntfs-3g ls -l /usr/bin/ntfs-3g
-rwsr-xr-x 1 root root 199736 Jul 11 15:55 /usr/bin/ntfs-3g

RPM has no statoverride. An update restores the packaged mode, and rpm -V reports the difference with an M in the mode column, which makes it a quick drift check for packaged files on RHEL:

deploy@rocky10 · Rocky Linux 10.2
$ sudo chmod u-s /usr/bin/chfn sudo rpm -Vf /usr/bin/chfn
.M....... /usr/bin/chfn
$ sudo rpm --setperms $(rpm -qf /usr/bin/chfn) ls -l /usr/bin/chfn
-rws--x--x. 1 root root 69184 Jan 15 2026 /usr/bin/chfn

Capabilities instead of SUID

A file capability grants a program part of root's power instead of all of it (Linux essentials showed the default set: ping and mtr-packet with cap_net_raw, snap-confine with several). find -perm does not see capabilities, so the audit lists them separately:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ sudo getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep /usr/bin/mtr-packet cap_net_raw=ep /usr/lib/aarch64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep /usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p

What matters for hardening is how capabilities appear and disappear. The packaging shows the choice a package makes:

deploy@web01 · Ubuntu 26.04 LTS
$ grep -A6 'If setcap' /var/lib/dpkg/info/mtr-tiny.postinst
# If setcap is installed, try setting cap_net_raw+ep, # which allows us to install our binaries without the setuid # bit. if command -v setcap > /dev/null; then if ! setcap cap_net_raw+ep /usr/bin/mtr-packet; then echo "Setcap failed on /usr/bin/mtr-packet, falling back to setuid" >&2 chmod u+s /usr/bin/mtr-packet
$ cp /usr/bin/mtr-packet /var/tmp/hard-perms-mtr-packet getcap /usr/bin/mtr-packet /var/tmp/hard-perms-mtr-packet
/usr/bin/mtr-packet cap_net_raw=ep

The install script tries a capability first and falls back to SUID only when the filesystem cannot store one. Capabilities live in an extended attribute, security.capability, and a plain cp drops it: the copy above has none. The same attribute can be added to any file by root with setcap, which is why a baseline must list capabilities as well as mode bits. Judge each entry by what the capability allows: cap_net_raw is narrow, while cap_setuid, cap_dac_override and cap_sys_admin each lead to full root. For your own services, grant a capability with AmbientCapabilities= in the unit instead of on the file (the sandboxing lesson).

World-writable and ownerless files

A world-writable directory without the sticky bit lets any user delete or replace any file in it, including a script that root runs from there. A world-writable file can be changed by anyone. A file whose owner no longer exists is inherited by the next account created with that UID. The CIS RHEL 10 Level 1 server profile checks all three, and they apply unchanged to Ubuntu. A fresh install has none of them:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ sudo find / /boot -xdev -type d -perm -0002 ! -perm -1000 sudo find / /boot -xdev -type f -perm -0002 sudo find / /boot -xdev \( -nouser -o -nogroup \)

No output is the result you want. The lab machine has been given one of each: a shared drop directory with a script in it, and a file left by a deleted account (UID 4242).

deploy@web01 · Ubuntu 26.04 LTS
$ sudo find / /boot -xdev -type d -perm -0002 ! -perm -1000 sudo find / /boot -xdev -type f -perm -0002 sudo find / /boot -xdev \( -nouser -o -nogroup \)
/var/tmp/hard-perms-share /var/tmp/hard-perms-share/nightly.sh /var/tmp/hard-perms-orphan
$ sudo chmod o-w /var/tmp/hard-perms-share /var/tmp/hard-perms-share/nightly.sh sudo chown root:root /var/tmp/hard-perms-orphan
$ sudo find / /boot -xdev -type d -perm -0002 ! -perm -1000 sudo find / /boot -xdev -type f -perm -0002 sudo find / /boot -xdev \( -nouser -o -nogroup \)

Removing write access for "other" fixes the directory and the script; the sticky bit (chmod +t) is the alternative for a directory that must stay shared, as /tmp is. The orphan now belongs to root until someone decides whether to keep it. The impact of these fixes is on whatever depended on the loose mode, so find the owner before you change a production path. To roll back, set the old mode again.

Account files, home directories and the umask

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ stat -c '%a %U:%G %n' /etc/passwd /etc/shadow /etc/gshadow /home/*
644 root:root /etc/passwd 640 root:shadow /etc/shadow 640 root:shadow /etc/gshadow 750 deploy:deploy /home/deploy 750 lima:lima /home/lima.linux
$ grep -E '^(UMASK|HOME_MODE|USERGROUPS_ENAB)' /etc/login.defs grep -E '^[^#].*pam_umask' /etc/pam.d/common-session
HOME_MODE 0750 USERGROUPS_ENAB yes session optional pam_umask.so
deploy@rocky10 · Rocky Linux 10.2
$ stat -c '%a %U:%G %n' /etc/passwd /etc/shadow /etc/gshadow /home/*
644 root:root /etc/passwd 0 root:root /etc/shadow 0 root:root /etc/gshadow 700 deploy:deploy /home/deploy 700 hard-perms-ann:hard-perms-ann /home/hard-perms-ann 700 lima:lima /home/lima.linux
$ grep -E '^(UMASK|HOME_MODE|USERGROUPS_ENAB)' /etc/login.defs grep -c pam_umask /etc/pam.d/system-auth /etc/pam.d/password-auth
UMASK 022 HOME_MODE 0700 USERGROUPS_ENAB yes /etc/pam.d/system-auth:0 /etc/pam.d/password-auth:0

The two platforms protect the same files differently, and both are consistent. Ubuntu makes /etc/shadow readable by group shadow so the SGID unix_chkpwd can check passwords; RHEL makes it mode 0 and unix_chkpwd SUID root. Do not "fix" one to look like the other; check that neither has drifted. Home directories are 0750 on Ubuntu and 0700 on RHEL (HOME_MODE); the CIS RHEL 10 Level 1 profile requires 0750 or stricter. /home/lima.linux belongs to the lab's VM tool.

The umask decides the mode of every new file. On Ubuntu, pam_umask sets it at login. /etc/login.defs has no UMASK line, so the session keeps the 022 it inherits from sshd, and pam_umask(8) applies only its USERGROUPS_ENAB adjustment: because USERGROUPS_ENAB is yes and the user's primary group has their own name, it copies the owner bits to the group bits. The test needs an account that logs in over SSH with a key; the lab creates one, with the key in deploy's ~/.ssh:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd -m -s /bin/bash hard-perms-ann ssh-keygen -q -t ed25519 -N '' -C 'hard-perms lab' -f ~/.ssh/hard_perms sudo install -d -m 700 -o hard-perms-ann -g hard-perms-ann /home/hard-perms-ann/.ssh sudo install -m 600 -o hard-perms-ann -g hard-perms-ann ~/.ssh/hard_perms.pub /home/hard-perms-ann/.ssh/authorized_keys

The first ssh to localhost asks you to accept the host key. A real SSH login then shows the result:

deploy@web01 · Ubuntu 26.04 LTS
$ ssh -i ~/.ssh/hard_perms hard-perms-ann@localhost 'umask; touch file1; mkdir dir1; stat -c "%a %n" file1 dir1'
0002 664 file1 775 dir1
$ sudo sh -c umask
0022

The login gets 0002: new files are 664 and directories 775, so the user's private group (only the user) may write and everyone else may read. Commands run through sudo get 0022.

The CIS RHEL 10 Level 1 server profile asks for a user umask of 027, so that "other" gets nothing. On Ubuntu that means one UMASK line in /etc/login.defs, read by pam_umask at the next login; login.defs(5) treats a line as a comment when # is its first non-blank character.

/etc/login.defs (lines added at the end)
# SecOpsLog: new files get no permissions for "other". With
# USERGROUPS_ENAB yes, pam_umask turns this into 007 for users
# whose primary group has their own name.
UMASK 027
deploy@web01 · Ubuntu 26.04 LTS
$ ssh -i ~/.ssh/hard_perms hard-perms-ann@localhost 'umask; touch file2; mkdir dir2; stat -c "%a %n" file2 dir2'
0007 660 file2 770 dir2
$ sudo sh -c umask
0027
$ systemctl show -p UMask cron.service
UMask=0022

The new login gets 0007, not 0027: pam_umask applied USERGROUPS_ENAB and copied the owner bits to the group, as its man page describes. Others get nothing, which is the goal, and group members of a shared setgid directory can still write, which is what user private groups are for. The surprise is sudo: /etc/pam.d/sudo includes common-session-noninteractive, which runs pam_umask too, so everything created through sudo is now 0640 or 0750. A config file written with sudo tee into a new directory can become unreadable for a service that runs as its own user, so check such paths after the change. Services started by systemd are not affected at all: cron.service still has systemd's default UMask=0022. To roll back, delete the lines from /etc/login.defs; new logins pick the change up.

deploy@rocky10 · Rocky Linux 10.2
$ ssh -i ~/.ssh/hard_perms hard-perms-ann@localhost 'umask; touch file1; stat -c "%a %n" file1'
0022 644 file1

RHEL 10 has UMASK 022 in login.defs but no pam_umask in its PAM stack, so an SSH login keeps the 0022 it inherits from sshd, whatever login.defs says. The CIS RHEL 10 profile therefore sets 027 in /etc/login.defs, /etc/profile and /etc/bashrc.

A service that writes secrets or reports needs its own setting, UMask= in the unit. This small unit writes a report as root; create it with sudo systemctl edit --force --full hard-perms-writer.service:

/etc/systemd/system/hard-perms-writer.service
[Unit]
Description=Writes a report file (SecOpsLog lab)
[Service]
Type=oneshot
StateDirectory=hard-perms-writer
ExecStart=/bin/sh -c 'echo report > /var/lib/hard-perms-writer/report.txt'
deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl start hard-perms-writer sudo stat -c "%a %U:%G %n" /var/lib/hard-perms-writer/report.txt
644 root:root /var/lib/hard-perms-writer/report.txt
$ printf "[Service]\n# SecOpsLog: files this service creates are not readable by other.\nUMask=0027\n" | sudo systemctl edit --stdin hard-perms-writer sudo rm /var/lib/hard-perms-writer/report.txt sudo systemctl start hard-perms-writer sudo stat -c "%a %U:%G %n" /var/lib/hard-perms-writer/report.txt systemctl show -p UMask hard-perms-writer
Successfully installed edited file '/etc/systemd/system/hard-perms-writer.service.d/override.conf'. 640 root:root /var/lib/hard-perms-writer/report.txt UMask=0027
$ sudo systemctl revert hard-perms-writer
Removed '/etc/systemd/system/hard-perms-writer.service.d/override.conf'. Removed '/etc/systemd/system/hard-perms-writer.service.d'.

A baseline to compare against

An audit is a snapshot; drift is found by comparing snapshots. This script lists SUID and SGID files and file capabilities on every local filesystem, sorted so that diff shows only what changed:

/usr/local/sbin/perms-snapshot
#!/bin/sh
# SecOpsLog: SUID/SGID files and file capabilities on the local
# filesystems only, sorted, so that two snapshots can be compared
# with diff. Network and pseudo filesystems are never walked.
{
for fs in $(findmnt -n -l -o TARGET -t ext4,xfs,btrfs); do
find "$fs" -xdev -type f -perm /6000 -printf '%M %u:%g %p\n'
find "$fs" -xdev -type f -exec getcap {} +
done
} | sort

The capability check runs inside the same per-filesystem loop on purpose. getcap -r /, fine for a one-off look, walks every mount, including NFS and CIFS shares, so a daily job would read shared storage and report files that belong to other hosts. Even so the script reads the metadata of every file on the local filesystems: schedule it outside busy hours. Save it with sudoedit, make it executable, and take the first snapshot:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo chmod 0755 /usr/local/sbin/perms-snapshot ls -l /usr/local/sbin/perms-snapshot
-rwxr-xr-x 1 root root 393 Sep 27 09:16 /usr/local/sbin/perms-snapshot
$ sudo perms-snapshot > ~/perms.baseline wc -l ~/perms.baseline
26 /home/deploy/perms.baseline
$ sudo perms-snapshot | diff ~/perms.baseline -
15a16 > -rwsr-xr-x root:root /var/tmp/hard-perms-planted 26a28 > /var/tmp/hard-perms-helper cap_setuid=ep

The lab then planted two files: an empty SUID root file in /var/tmp and a copy of true with cap_setuid. The diff names both and exits 1, which is what a scheduled job alerts on. Keep the baseline where an intruder cannot rewrite it (root-only, or better off the host), run the comparison from a timer (the scheduled jobs lesson), and refresh the baseline after each reviewed package update. AIDE, covered later, watches file contents as well; linux-det shows the same checks from the attacker's side.

Try this

On your Ubuntu lab machine, run sudo chmod u-s /usr/bin/ntfs-3g, then sudo apt-get install --reinstall ntfs-3g, and confirm with ls -l that the SUID bit is back. Repeat with sudo dpkg-statoverride --update --add root root 0755 /usr/bin/ntfs-3g and confirm it stays off after the reinstall; then remove the override and run sudo chmod 4755 /usr/bin/ntfs-3g. (On a server you manage, the better end state is sudo apt purge ntfs-3g.) Create a directory with mode 0777 under /var/tmp, find it with the world-writable search, and fix it. Finally save a snapshot with perms-snapshot, give a copy of /usr/bin/true in /var/tmp the cap_setuid=ep capability with setcap, and confirm that the diff names it. Delete the copy when you are done.

Takeaway

Know every SUID, SGID and capability on the host and why it is there, make removals stick with dpkg-statoverride, and compare against a baseline instead of trusting a one-off audit. Set the umask for logins and for services separately, because each is configured in a different place.

Quick check
01On an Ubuntu server you ran sudo chmod u-s /usr/bin/chfn in March. In June an audit finds it SUID root again, and nobody admits to changing it. What most likely happened?
Incorrect — No such job exists. The mode is set when dpkg unpacks the file, not by a scheduled check.
Incorrect — The journal protects metadata consistency; it does not undo a completed chmod.
Incorrect — chmod takes effect at once, as ls -l showed straight after it.
Correct — dpkg sets the packaged mode on every install; dpkg-statoverride is how a local mode survives.
02An audit of your Ubuntu web servers finds /usr/bin/ntfs-3g SUID root. Nobody mounts NTFS disks there, and apt-cache depends shows that ubuntu-standard only recommends ntfs-3g. What is the strongest fix?
Incorrect — A recommendation is not a dependency. The simulated purge removed ntfs-3g alone and left ubuntu-standard installed.
Correct — An unused program that is gone cannot be exploited, and apt keeps the recommending packages installed.
Incorrect — The next update of ntfs-3g sets the packaged mode again; the baseline would only report the return of the bit.
Incorrect — This works and survives updates, but it keeps code installed that nobody needs. It is the tool for packages that must stay.
03After adding UMASK 027 to /etc/login.defs on Ubuntu, a user's new SSH session reports umask 0007 instead of 0027. Why, and does it matter?
Incorrect — 027 is valid; pam_umask read it and then adjusted the group bits.
Incorrect — sshd's own umask applies only where no PAM module sets one; here pam_umask did.
Correct — The group is the user's own, so 007 exposes nothing extra, and shared setgid directories keep working.
Incorrect — Each login runs pam_umask afresh; the new value applied at the first new login.

Related