Shore Up
a magnifying glass moving over rows of filing-cabinet drawers, some drawers flagged with warning tags because their locks are hanging open
LinuxSecurity

Audit World-Writable Files and SUID Binaries with Bash

Ketan Aagja9 min read
No ratings yet

Before you run this

This script reads your filesystem and reports two classes of risky files: world-writable files and directories (anyone on the box can modify them) and SUID/SGID binaries (they run with the owner's or group's privileges, often root). It writes a timestamped report and, optionally, a baseline you can diff against later. It does not change any permissions, delete anything, or modify a single file — it only runs find and writes a text report to a directory you choose.

Because of that, this is one of the safer scripts I publish. The caution is about acting on its output, not running it:

  • Privileges: run it as root (or with sudo). As a normal user, find cannot descend into directories it can't read, so you'll get a flood of "Permission denied" noise and an incomplete report. Root gives you a complete, quiet audit.
  • Read it first. It's short — read the whole thing before you run it, and run it on a test VM or a non-production host the first time so you know what its output looks like on your systems.
  • The danger is in the follow-up. When you see a SUID binary or world-writable file and decide to chmod or rm it, that is where you can break things. Legitimate SUID binaries exist (sudo, passwd, mount, and others). Never blanket-strip SUID bits or delete flagged files. Investigate each finding, and change permissions on one file at a time on a test host before touching production.

Assumed environment: Debian 12 or Ubuntu 22.04, GNU findutils (the find that ships by default), and bash. The find expressions here are GNU syntax. On RHEL/Alma the same find flags work; only the package manager and a few default paths differ.

What "world-writable" and "SUID" actually mean

A world-writable file has the o+w bit set — the "other" class can write to it. On a config file or a script that root runs, that's a privilege-escalation path. World-writable directories are fine only when they also carry the sticky bit (/tmp is the classic case); without the sticky bit, anyone can rename or delete other users' files in them.

SUID (u+s, mode 4000) and SGID (g+s, mode 2000) binaries execute with the file owner's or group's identity. That's necessary for tools like passwd, but every extra SUID-root binary is extra attack surface, so you want an inventory and you want to notice when it changes.

The audit script

Save this as perm-audit.sh, make it executable with chmod +x perm-audit.sh, and run it as root.

#!/usr/bin/env bash
#
# perm-audit.sh — read-only audit of world-writable files/dirs and SUID/SGID binaries.
# Writes a timestamped report. Changes nothing on disk except the report file.

set -euo pipefail

# Where to write the report. Replace with a path you control.
REPORT_DIR="/var/log/perm-audit"

# Pseudo-filesystems and network mounts we don't want to scan.
# -prune skips these subtrees entirely.
PRUNE=( -path /proc -o -path /sys -o -path /dev -o -path /run )

mkdir -p "$REPORT_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
REPORT="$REPORT_DIR/report-$STAMP.txt"

{
  echo "Permission audit on $(hostname) at $(date)"
  echo "======================================================"

  echo
  echo "## SUID binaries (mode 4000)"
  # -perm -4000 matches any file with at least the SUID bit set.
  find / \( "${PRUNE[@]}" \) -prune -o -type f -perm -4000 -print

  echo
  echo "## SGID binaries (mode 2000)"
  find / \( "${PRUNE[@]}" \) -prune -o -type f -perm -2000 -print

  echo
  echo "## World-writable files"
  # -perm -0002 matches any file with the 'other write' bit set.
  find / \( "${PRUNE[@]}" \) -prune -o -type f -perm -0002 -print

  echo
  echo "## World-writable directories WITHOUT sticky bit (investigate these)"
  # -perm -0002 = other-writable; ! -perm -1000 = sticky bit NOT set.
  find / \( "${PRUNE[@]}" \) -prune -o -type d -perm -0002 ! -perm -1000 -print

} > "$REPORT"

echo "Report written to: $REPORT"

A few notes on the non-obvious parts:

  • \( ... \) -prune -o ... -print is the standard GNU find idiom for "skip these subtrees, then apply the test to everything else." I prune /proc, /sys, /dev, and /run because they're kernel/pseudo filesystems and scanning them is meaningless.
  • -perm -4000 (note the leading dash) means "the SUID bit is set, regardless of the other bits." Without the dash, find would match only files whose mode is exactly 4000, which is not what you want.
  • I deliberately do not use -xdev. If you want to restrict the scan to the root filesystem and skip all other mounts (NFS, big data volumes), add -xdev right after find /. That's the one common variant worth knowing.

Add detail to the findings

A bare list of paths is fine for a first pass, but for a report you'll actually read, you want owner, mode, and size. Swap the -print actions for -ls, which prints an ls -l-style line for each match. For example, the SUID section becomes:

find / \( "${PRUNE[@]}" \) -prune -o -type f -perm -4000 -ls

-ls is a standard GNU find action; it needs no extra tooling.

Turn it into a baseline you can diff

The real value is catching change — a new SUID binary appearing after an install or a compromise. Capture today's SUID inventory as a baseline:

# Create the baseline once, on a known-good system.
find / \( -path /proc -o -path /sys -o -path /dev -o -path /run \) -prune \
  -o -type f -perm -4000 -print | sort > /var/lib/perm-audit/suid.baseline

Then compare a fresh scan against it:

find / \( -path /proc -o -path /sys -o -path /dev -o -path /run \) -prune \
  -o -type f -perm -4000 -print | sort > /tmp/suid.now

# Lines only in the new scan = newly-appeared SUID binaries.
diff /var/lib/perm-audit/suid.baseline /tmp/suid.now

Anything diff reports with a > is a SUID binary that wasn't there when you took the baseline. That's your signal to investigate.

If you'd rather not roll your own baseline logic, this is exactly what dedicated tools like AIDE (Advanced Intrusion Detection Environment) do across the whole filesystem, and it's packaged for Debian/Ubuntu. Use the script above for a quick, dependency-free check; reach for AIDE when you want continuous, signed integrity monitoring.

Verify it worked

The script doesn't change your system, so "verification" means confirming the audit itself is sound:

  1. Confirm the report exists and has content:
    ls -l /var/log/perm-audit/
    less /var/log/perm-audit/report-*.txt
    
  2. Sanity-check against a known SUID binary. passwd is SUID-root on a normal system:
    ls -l /usr/bin/passwd
    
    You should see an s in the owner-execute position (-rwsr-xr-x) and that same path in your report's SUID section. If it's missing from the report, your scan pruned too much or didn't run as root.
  3. Confirm you ran as root — if you saw Permission denied lines, re-run with sudo.

Undo

There is nothing to roll back: the script only creates a report file. To clean up, delete the report directory you chose:

rm -rf /var/log/perm-audit    # only if you created it for this and want it gone

The moment you decide to act on a finding — say, removing an unexpected SUID bit with chmod u-s /path/to/filerecord the original mode first (stat -c '%A %a %n' /path/to/file) so you can restore it. But that's a separate, deliberate change, made one file at a time, after you've confirmed the file is genuinely unwanted. For the exact behaviour of find's -perm and -prune, see the GNU findutils manual (man find, or the "Finding Files" manual on the GNU project site).

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.