A file system security audit reviews the permissions, ownership and special bits on your server's files to find the misconfigurations that let an attacker escalate privileges or read data they should never see. This page documents the 7 checks in the CtrlOps File System audit, in the order the audit runs them.
A misconfigured permission is how a low-privilege compromise escalates to root, how a deleted contractor's files get inherited by the next account created, and how password hashes end up readable by every user on the box. Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.
Key takeaways
Seven checks cover the permission gaps that turn a contained compromise into a full root takeover. Each takes two to five minutes to verify by hand, which is manageable for one server and unrealistic across a fleet.
- Three checks are rated HIGH: SUID/SGID binaries, blank-password accounts and account file permissions. If you only have five minutes, do those three.
- Four need root: the world-writable scan, the SUID scan, the blank-password check and the unowned-file scan. The other three run as any user.
- The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 20 minutes by hand.
- A set-id binary in /tmp or /home is almost always malicious. No legitimate package installs one in a user-writable directory.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | World-writable files | MEDIUM | Files any user can modify in system directories | Yes |
| 2 | SUID/SGID binaries | HIGH | Privilege escalation via set-id binaries in /tmp or /home | Yes |
| 3 | /tmp sticky bit | MEDIUM | Cross-user deletion in the shared temp directory | No |
| 4 | System log permissions | MEDIUM | Auth logs readable by any local user | No |
| 5 | Blank-password accounts | HIGH | Accounts that require no password to log in | Yes |
| 6 | Account file permissions | HIGH | World-readable password hashes in /etc/shadow | No |
| 7 | Unowned files | LOW | Orphaned files from removed accounts | Yes |
/proc/mounts and scan every local filesystem on the host (ext2/3/4, xfs, btrfs, jfs, f2fs, reiserfs), not just /. This matters more than it sounds: a bare find / -xdev stops at the root mount, so a separate /home, /var or /srv, which is exactly where a planted file lives, was never scanned at all. Virtual filesystems such as /proc, /sys and /dev are separate mounts and -xdev excludes them anyway.Check 1: Are there world-writable files outside /tmp?
A world-writable file can be modified by any user on the system. Inside /tmp and /var/tmp that is expected, and the sticky bit contains it. Outside those directories it means any compromised service account, any local user, and any process running as nobody can overwrite the file. If that file is a cron script, a config file or a startup hook, the overwrite becomes code execution as whatever user reads it next.
The audit scans every local filesystem for files carrying the world-writable bit (-perm -0002), skipping /tmp and /var/tmp.
How to check manually
sudo find / -xdev -type f -perm -0002 -not -path '/tmp/*' -not -path '/var/tmp/*'| Result | When | What it means |
|---|---|---|
| PASS | No world-writable files outside the temp directories | No file on a local filesystem can be modified by an arbitrary user. |
| FAIL | One or more world-writable files exist outside the temp directories | The count and the first three paths are listed. Run chmod o-w on each unless it is genuinely required. |
| SKIP | Root access was not available | A full filesystem scan needs root. Re-run with sudo rather than treating this as a pass. |
The scan stops collecting at 200 results, so a badly misconfigured host reports "200 or more" rather than an exact number. That is a deliberate cap on scan cost, and it is also the point at which individual file paths stop being the useful output: something systemic set those bits, and finding it beats fixing them one at a time.
How to fix it
# One file at a time, after reading the list
sudo chmod o-w /path/to/file
# In bulk, once you have reviewed what the scan returned
sudo find / -xdev -type f -perm -0002 \
-not -path '/tmp/*' -not -path '/var/tmp/*' -exec chmod o-w {} +Check 2: Are there set-id binaries in user-writable paths?
A SUID (set-user-id) binary runs as its owner no matter who executes it. A SGID binary runs with its group's permissions. The system ships a handful of legitimate ones (sudo, passwd, ping), all in protected system directories, and they are fine.
A set-id binary in /tmp, /var/tmp, /dev/shm or /home is a different thing entirely. Those are the paths any process can write to. A set-id binary sitting in one of them is either a privilege escalation tool an attacker left behind or a catastrophic packaging mistake, and in both cases it runs as root for anyone who executes it.
The audit does two things: it counts SUID binaries across all local filesystems as a baseline figure, and it fails outright on any set-id binary found in the four user-writable paths.
How to check manually
# The finding: set-id binaries where users can write
sudo find /tmp /var/tmp /dev/shm /home -xdev -type f \( -perm -4000 -o -perm -2000 \)
# The baseline: how many SUID binaries exist system-wide
sudo find / -xdev -type f -perm -4000 | wc -l| Result | When | What it means |
|---|---|---|
| PASS | Nothing set-id in /tmp, /var/tmp, /dev/shm or /home | The system-wide SUID count is reported as a baseline. Save it and diff it on the next audit. |
| FAIL | One or more set-id binaries exist in those paths | The first five paths are listed. Investigate immediately, then remove the bit or the file. |
This is the only check on the page with no SKIP row, and the reason is worth knowing. Without root the audit does not give up: it runs the same searches unprivileged and attaches a "partial scan" note to the result. A set-id binary in /tmp is world-visible often enough that an unprivileged scan still finds it, and a finding you can act on beats a clean SKIP. The count in the PASS message is the part to distrust when root was unavailable.
How to fix it
# Investigate first. Do not delete the evidence.
ls -la /tmp/suspicious_binary
file /tmp/suspicious_binary
stat /tmp/suspicious_binary
# Strip the set-id bit
sudo chmod u-s /tmp/suspicious_binary
# Or remove it outright once you are sure it is not legitimate
sudo rm /tmp/suspicious_binary/tmp on a production host means treating the server as compromised until you can show otherwise. Find out who created the file, when, and from which process. Read the auth logs around that timestamp, review recent deployments, and treat every credential that has touched the box as exposed. A legitimate package never installs a set-id binary in /tmp or /home, so the question is not whether to remove it, it is how long it has been there and what it has been used for.Check 3: Does /tmp have the sticky bit?
The sticky bit on a directory restricts deletion: only the file's owner, the directory's owner, or root can delete or rename anything inside it. Without it on /tmp, any user can delete any other user's temporary files. That breaks applications relying on predictable temp paths, and it enables symlink attacks where an attacker removes a temp file and replaces it with a symlink pointing at something sensitive, which the privileged process then writes through.
The expected mode is 1777: world-writable so every user can create temp files, plus the sticky bit so they can only remove their own.
How to check manually
stat -c '%a' /tmp| Result | When | What it means |
|---|---|---|
| PASS | The mode is exactly 1777 | Sticky bit set. Users can create files but only delete their own. |
| FAIL | The mode is anything other than 1777 | The actual mode is reported. Run chmod 1777 /tmp. |
| SKIP | stat could not read /tmp | Check the mount and its permissions. This is close to unheard of on a healthy host. |
The comparison is exact rather than "at least sticky". 1777 is the one correct answer, so a mode of 1755 fails even though it has the sticky bit, and 0777 fails even though it is world-writable.
How to fix it
sudo chmod 1777 /tmpOne command, immediate effect, no restart and no service interruption. If /tmp is a separate mount, this is also the moment to check that its /etc/fstab entry carries nosuid and nodev, which is the mount-level version of what check 2 looks for.
Check 4: Are system logs world-readable?
System logs hold authentication attempts, source IP addresses, usernames, timestamps and, when someone types a password into the username prompt by mistake, occasional credential fragments. A world-readable auth log means any local user or compromised service account can read every failed login, every sudo invocation and every SSH connection on the host: a free reconnaissance feed listing exactly which accounts exist and which are worth attacking.
The audit tests six files for the world-readable bit: /var/log/auth.log, /var/log/secure, /var/log/syslog, /var/log/messages, /var/log/kern.log and /var/log/btmp. Files that do not exist on the distribution are skipped rather than counted against it.
How to check manually
ls -la /var/log/auth.log /var/log/secure /var/log/syslog \
/var/log/messages /var/log/kern.log /var/log/btmp 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | None of the core system logs carries the world-readable bit | Log files are restricted to root and the adm or syslog group. |
| WARN | One or more of them is world-readable | The file names are listed. Set chmod 640 and keep the group as adm. |
There is no FAIL branch here. A readable log is an information disclosure rather than an access path, and on several distributions the packaged default is genuinely 644, which would fail every fresh install. The warning says what to change without pretending the host is breached.
How to fix it
sudo chmod 640 /var/log/auth.log /var/log/syslog
sudo chown root:adm /var/log/auth.log /var/log/syslogchmod 640 on the current file is undone at the next rotation if the logrotate config says create 644. Open /etc/logrotate.d/rsyslog (or whichever config owns the file) and set create 640 root adm. This is the single most common reason this check passes once and warns again a week later.Check 5: Do any accounts have a blank password?
An account with a blank password field in /etc/shadow lets anyone who reaches a login prompt, whether that is the console, SSH with password auth enabled, or any PAM-backed service, log in by pressing Enter. No guessing, no brute force, no exploit.
The audit reads /etc/shadow and looks for entries whose second field is genuinely empty. An account locked with ! or * is not empty and does not count: those are the normal state for system accounts and they pass.
How to check manually
sudo awk -F: '($2==""){print $1}' /etc/shadow| Result | When | What it means |
|---|---|---|
| PASS | No account has an empty password field | Every account either requires a password or is locked. |
| FAIL | One or more accounts have a genuinely empty password field | The account names are listed. Lock them with passwd -l or set a password now. |
| SKIP | Root access was not available | /etc/shadow is unreadable without root. Re-run with sudo. |
How to fix it
# Lock the account: disables all password-based login, keeps the account
sudo passwd -l account_name
# Or give it a real password
sudo passwd account_name-p. Lock it in the first minute, then work out whether the account should exist at all. The investigation can wait; the open door cannot.Check 6: Are shadow and passwd properly locked down?
/etc/shadow holds the hashed password for every account on the system. If it is world-readable, every hash on the host is available to any local user, who can then run them through hashcat or John the Ripper offline: no failed-login alerts, no rate limiting, no detection, and as much time as they want.
The expected modes are 640 or tighter for /etc/shadow and /etc/gshadow (root and the shadow group only), and 644 for /etc/passwd and /etc/group, which contain no secrets and are read by ordinary tooling. The audit fails when a shadow file is world-readable, and warns when any of the four is looser than expected without being world-readable. Stricter than expected always passes.
This check needs no root because file modes are metadata: stat reads them without reading the file.
How to check manually
stat -c '%a %n' /etc/shadow /etc/gshadow /etc/passwd /etc/group| Result | When | What it means |
|---|---|---|
| PASS | All four files carry their expected modes, or tighter | Shadow files are restricted; passwd and group are world-readable as they are meant to be. |
| WARN | One or more files are looser than expected, but no shadow file is world-readable | Each file and its actual mode are listed. Tighten them to the expected values. |
| FAIL | /etc/shadow or /etc/gshadow carries the world-readable bit | Every password hash on the host is readable by any local account. Run chmod 640 now. |
The two-tier result is the useful part. /etc/shadow at 644 is a live exposure and fails. /etc/passwd at 666 is wrong but exposes no secret, so it warns. Both need fixing; only one is an incident.
How to fix it
sudo chmod 640 /etc/shadow /etc/gshadow
sudo chmod 644 /etc/passwd /etc/group
sudo chown root:shadow /etc/shadow /etc/gshadowCheck 7: Are there files owned by deleted users?
When an account is deleted with userdel and no -r flag, its files stay on the filesystem but now belong to a numeric UID that maps to nothing. Linux assigns UIDs sequentially, so when a new account is created later and happens to land on that number, it silently inherits every file the deleted account left behind: home directory, SSH keys, cron tabs, config files with credentials in them.
The audit scans all local filesystems for files with no matching user or group, excluding /var/lib/docker, which routinely and legitimately contains files owned by container UIDs that do not exist on the host.
How to check manually
sudo find / -xdev \( -nouser -o -nogroup \) -not -path '/var/lib/docker/*' | head -20| Result | When | What it means |
|---|---|---|
| PASS | No files with an unmatched user or group | Account cleanup on this host has been thorough. |
| WARN | One or more files have no matching user or group | The count and the first three paths are listed. These are leftovers from a removed account, and a new account reusing that UID inherits them. |
| SKIP | Root access was not available | A full filesystem scan needs root. Re-run with sudo. |
The scan collects at most 20 paths, so the reported count tops out there. On a host that has churned through many accounts, read a count of 20 as "at least 20" and run the manual command without the head to see the real scale.
How to fix it
# Reassign the files to an account that should own them
sudo chown newowner:newgroup /path/to/orphaned/file
# Or remove them if nothing needs them
sudo rm /path/to/orphaned/file
# Prevent it recurring: always delete accounts with -r
sudo userdel -r old_account-r. It is a LOW finding today and a HIGH one the moment a new account picks up the recycled UID, which is precisely the kind of delayed failure nobody connects back to a deletion six months earlier.What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds. This audit reads permissions and ownership from the filesystem. It does not test application behaviour or any access control above the file layer.
- ACLs and security contexts. Only traditional Unix permission bits are read. POSIX ACLs (
getfacl) and SELinux or AppArmor file contexts are not evaluated, so a file that looks correctly permissioned here may still carry an ACL granting extra access. - Encryption at rest. Whether a filesystem is encrypted is not checked. LUKS, dm-crypt and eCryptfs status are not reported.
- File integrity. There is no comparison against a known-good state. For "has this file changed since last week", use AIDE, Tripwire or OSSEC; this audit answers "who can change it".
- Container filesystems. Files inside running containers and inside image layers are not scanned. Only the host filesystem is covered, and
/var/lib/dockeris explicitly excluded from check 7. - Rootkit detection. A rootkit that hides its files from
findis invisible here. This audit can catch the set-id binary a rootkit drops in/tmp, but only before the rootkit conceals it.
The manual audit problem at scale
Running these 7 checks on one server takes about 20 minutes. The filesystem scans (checks 1, 2 and 7) dominate that, because each one walks every mounted filesystem on the host. For one server it is a long coffee break. For 10 servers it is over three hours, and it is not a once-a-year job.
The SUID baseline is what makes manual auditing genuinely frustrating rather than merely slow. A healthy server carries roughly 15 to 30 legitimate SUID binaries. The count on its own tells you nothing. The only way to know whether a new one appeared is to save the list and diff it against the next run, and nobody does that by hand across a fleet. The check that matters most is the one manual auditing is worst at.
The unowned-files check has the same shape. On a server with regular account churn you need the historical context to know whether the file the scan surfaced is a harmless leftover from 2023 or a config file orphaned by last month's offboarding. Without a previous report to compare against, every finding looks the same.
That is the drift problem again in a different costume: the same failure mode as managing multiple servers without a central view.
How CtrlOps runs all 7 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole File System audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select File System
Choose the File System audit from the catalog. All 7 checks are listed with their descriptions, severity levels and the estimated run time (about 10 seconds in total). Toggle off any check you want to skip.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table you can sort and filter
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. For this audit in particular, that stored history is the feature: the SUID count from the last run is the baseline the current one is compared against, which is the diff manual auditing never gives you.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 7 checks | About 2.5 hours | About 90 seconds |
| Get a scored report | Not available | Automatic |
| Diff the SUID baseline | Only if you saved last time | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
| Re-audit after fixing | Another 2.5 hours | Another 90 seconds |
Conclusion
File system permissions are where a contained problem becomes a total one. World-writable files in system directories, set-id binaries in user-writable paths and a readable /etc/shadow are each a direct route from low-privilege access to root, and none of them announces itself.
Auditing all 7 by hand across a fleet is slow, and the check that matters most, the SUID baseline, only works if you kept last month's output. The CtrlOps Security Audit runs the whole set in about 10 seconds per server, agentlessly, keeps the history so the diff is automatic, and puts AI-assisted fixes behind an approval gate.
Once the file layer is clean, the neighbouring checklists cover the rest of the host: SSH and access control, the firewall and network perimeter, and the application and database layer.
Frequently asked questions
A file system security audit checks the permissions, ownership and special bits on a Linux server's files and directories. It covers world-writable files outside the temp directories, SUID and SGID binaries in user-writable paths, the sticky bit on /tmp, system log permissions, accounts with blank passwords, the modes on the shadow and passwd files, and files left behind by deleted users. The goal is to find the permission misconfigurations that let a low-privilege compromise escalate to root.
A SUID (set-user-id) binary runs as its owner, typically root, regardless of who executes it. System binaries such as sudo and passwd use SUID legitimately and live in protected directories. The danger is a SUID binary in a user-writable directory like /tmp or /home, where any process can drop a file. One placed there gives instant root execution to anyone on the system, which is why it is a standard post-compromise persistence technique rather than a simple configuration mistake.
/etc/shadow contains the hashed password for every account on the system. If it is world-readable, any local user can copy the hashes and crack them offline with hashcat or John the Ripper: no failed-login alerts, no rate limiting, no detection, and unlimited attempts. The correct mode is 640, readable only by root and the shadow group. A world-readable shadow file is the most direct route there is from "has a local account" to "has every password on the server".
When an account is deleted without userdel -r, its files stay on disk but belong to a numeric UID with no matching user. Linux assigns UIDs sequentially, so a new account created later can receive that same UID and silently inherit everything the deleted account left behind: config files with credentials, SSH keys, cron tabs, application data. Nothing warns you, because from the kernel's point of view the file ownership is perfectly valid.
The sticky bit shows as t in ls output and as the leading 1 in the octal mode 1777. On a directory it restricts deletion: only the file's owner, the directory's owner, or root can delete or rename anything inside. Without it on /tmp, any user can delete any other user's temp files, which enables both denial of service and symlink attacks against applications that use predictable temp paths.
Run one after every account change (creation, deletion, permission edit) and after every application deployment, since those are what actually move permissions. Beyond that, monthly is a reasonable production baseline. The SUID check in particular is most valuable diffed against the previous run rather than read on its own, so regular scheduling is what makes it meaningful.
Four of the 7 checks do: the world-writable scan, the SUID scan, the blank-password check and the unowned-file scan. The other three read file modes and /tmp permissions, which any user can do. Checks that need root and do not have it report SKIP rather than guessing, with one exception: the SUID check falls back to an unprivileged scan and labels the result a partial scan, because a set-id binary in /tmp is often visible without root and a real finding beats a clean skip.
No. It reads file permissions and ownership, and a rootkit's job is to hide its files from exactly those tools. For rootkit detection use rkhunter or chkrootkit, ideally booted from trusted external media rather than run on the potentially compromised host. This audit can catch the set-id binary a rootkit drops in /tmp, but only in the window before the rootkit hides it from find.
Because it is information disclosure rather than an access path, and because several distributions ship some of these files at 644 by default, which would fail every fresh install and train people to ignore the result. It still matters: an auth log tells a local attacker which accounts exist, which are used, and from where. The fix is chmod 640 plus a matching create 640 root adm in the logrotate config, or the next rotation undoes it.
If your workloads run entirely in containers and the host filesystem is minimal, container image scanning (Trivy, Grype, Snyk Container) is more relevant than a host permission audit. If what you need is file integrity monitoring, detecting unauthorised changes to known-good files, use AIDE or Tripwire instead. This audit answers who can change a file, not whether its contents have already changed.
Yes. Every check works on any Linux host reachable over SSH: EC2, DigitalOcean Droplets, Linode, Vultr, Hetzner, bare metal or a VPS from any other provider. The scans read /proc/mounts to find the local filesystems, so a host with separate /home or /var volumes, which is common on cloud images, is covered properly rather than scanned only as far as the root mount.