Linux File Permissions and Ownership Management
The article delves into the intricate realm of Linux file permissions and ownership management, elucidating the vital role they play in securing endpoint devices. It discusses the nuances of setting access controls, managing user privileges, and ensuring data integrity on Linux systems. By understanding and implementing these practices effectively, organizations can fortify their endpoints against unauthorized access and potential security breaches.
The first command in most Linux privilege escalation attempts is find / -perm -4000 -type f 2>/dev/null. It takes under a second, it is entirely passive, and on a depressing number of production servers it returns something that should not be there — a nmap from a decade ago, a vendor helper binary, a backup script somebody made SUID root in 2019 to stop a cron job failing. That one line is the whole reason permissions are worth understanding properly rather than approximately.
Linux inherited a multi-user design from Unix and never let go of it. The enforcement mechanism is Discretionary Access Control — the kernel checks the requesting process’s UID and GIDs against the ownership and mode bits stored on the object, and permits or denies. “Discretionary” is the load-bearing word: the owner decides who gets access, not a central policy. That is why a single careless chmod 777 defeats an otherwise well-run system, and why SELinux and AppArmor exist as a mandatory layer on top.
What follows is the permission string, then the special bits people actually get wrong, then the audit commands worth running on a schedule.
Understanding the Permission String
Almost everything in Linux is reached through a file-like object — regular files, directories, sockets, device handles. The access rules live in the inode, not in the filename, which is a detail with real consequences: hard links to the same inode share one set of permissions, so tightening a file’s mode via one path tightens it everywhere. Renaming or moving a file changes nothing about who can read it.
ls -l prints those rules as ten characters in the first column.
(-, d, l, c, b, s, p)"]:::accent Root --> Owner["Owner (User)
r w x — Octal 7"]:::accent Root --> Group["Group Access
r - x — Octal 5"]:::accent Root --> Others["Others (World)
r - - — Octal 4"]:::accent Owner --> SUID["Special Bit: SUID (s)
Runs as File Owner"]:::accent Group --> SGID["Special Bit: SGID (s)
Runs as Group / Inherits Group"]:::accent Others --> Sticky["Special Bit: Sticky (t)
Only Owner Can Delete"]:::accent
The 10-character string breaks down like this:
-
Character 1 — File Type: Tells you what kind of object this is.
-: Regular filed: Directoryl: Symbolic linkc: Character device (e.g.,/dev/tty)b: Block device (e.g.,/dev/sda)s: Local socketp: Named pipe (FIFO)
-
Characters 2–4 — Owner Permissions: What the file’s owner can do.
-
Characters 5–7 — Group Permissions: What members of the file’s group can do.
-
Characters 8–10 — Others Permissions: What everyone else on the system can do.
Each block holds Read (r), Write (w) and Execute (x), with a dash where the permission is absent.
Two things about this that catch people out. The kernel checks the three blocks in order and stops at the first that applies — so if you own a file with mode 044, you cannot read it, even though “others” can. Owner is checked first, matches, and denies. Being root is the only way around it.
The second is that those letters mean different things on directories, and the difference matters more than the file case. On a directory, r means you can list the names inside it; x means you can traverse into it and access things by name. They are independent. --x on a directory is a genuinely useful configuration: anyone who already knows a filename can open it, but nobody can enumerate the contents. And r-- without x gives you a listing of names you cannot then stat or open, which produces the confusing situation where ls works and ls -l returns permission-denied for every entry.
From Bits to Octal: How Numbers Map to Permissions
Octal notation looks arbitrary until you see that each three-character block is three bits, and octal exists precisely because one octal digit is three bits:
- Read (r) = binary
100= 4 - Write (w) = binary
010= 2 - Execute (x) = binary
001= 1
Add the values within a block and you have that block’s digit. Nothing more to it — and once you have the mapping, 640 reads as fast as rw-r----- does:
| Octal | Permissions | Symbolic | Binary |
|---|---|---|---|
| 0 | None | --- | 000 |
| 1 | Execute only | --x | 001 |
| 2 | Write only | -w- | 010 |
| 3 | Write + Execute | -wx | 011 |
| 4 | Read only | r-- | 100 |
| 5 | Read + Execute | r-x | 101 |
| 6 | Read + Write | rw- | 110 |
| 7 | Full access | rwx | 111 |
Take rwxr-xr-x as an example:
- Owner:
rwx= 4 + 2 + 1 = 7 - Group:
r-x= 4 + 0 + 1 = 5 - Others:
r-x= 4 + 0 + 1 = 5 - Result: 755
Common Permission Modes in Practice
chmod 777 file— Everyone can read, write and execute. This is almost never the actual fix for whatever problem prompted it; it is what people reach for when they have not worked out which of owner, group or others was being denied. On a web root it means any process on the box can rewrite your application code.chmod 755 script.sh— Owner can modify and run; everyone else can read and run. The default for anything in/usr/local/bin.chmod 644 config.txt— Owner writes, everyone reads. Fine for genuinely public configuration, wrong the moment a credential appears in the file.chmod 600 id_rsa— Owner only. Not optional for SSH private keys: OpenSSH refuses to use a key file that group or others can read, and tells you so rather than failing quietly. It is one of the few places where the tooling enforces the correct mode for you.chmod 700 private_dir— Only the owner can enter it at all.
[!WARNING] A common mistake: thinking
chmod 700means read-only for others. It doesn’t —rwx------means group and world users have zero access, not read-only access. If you want full access for the owner and read/execute for everyone else, use755. For files where execute isn’t needed, use744.
Changing Permissions and Ownership
chmod — Two Ways to Use It
Octal sets the entire mode at once. Symbolic notation adjusts parts of it, using scope letters — u (user/owner), g (group), o (others), a (all) — with + to add, - to remove and = to set exactly.
The distinction is worth caring about because octal is absolute. chmod 644 on a file that was 600 has just granted read access to the entire system, and it does that silently whether or not you were thinking about the group and others blocks at the time. When you only mean to change one thing, say only that thing: chmod u+x cannot accidentally open a file to the world.
| Command | What It Does |
|---|---|
chmod +x run.sh | Adds execute for everyone — but masked by your umask, so it usually behaves as a+x minus whatever umask withholds. Use u+x when you mean the owner alone |
chmod u-w config.cfg | Removes write permission from the owner |
chmod o=r public.txt | Sets others to read-only, removing any other access they had |
chmod u+wx,g-x,o=rx file | Mixed update across all three categories in one command |
chown and chgrp — Changing Who Owns the File
Only root can hand a file to another user. A regular user cannot give their own files away either — that restriction exists so nobody can dump a file into someone else’s account and have it count against their disk quota, or plant something that appears to have been created by a privileged user.
The recursive form at the end of this list deserves more caution than it usually gets. chown -R on a path with a trailing typo, or on a symlink that points somewhere unexpected, rewrites ownership across a subtree with no confirmation and no undo. chown -R www-data:www-data / is a genuine, recoverable-only-from-backup way to destroy a server, and it differs from the intended command by one character.
# Change the owner
sudo chown alice database.db
# Change the group
sudo chgrp security database.db
# Change both at once
sudo chown alice:security database.db
# Apply recursively to a directory and all its contents
sudo chown -R alice:security /var/www/html/
Special Permission Bits: SUID, SGID, and the Sticky Bit
Three extra bits sit above the nine you have just read about. They are where nearly every interesting local privilege escalation lives, and where the octal notation stops being intuitive — a mode like 4755 is not “four categories”, it is the special bits followed by the usual three.
SUID — Set User ID
- Octal value:
4000(shows assin the owner’s execute position:rws------) - When set on an executable, it runs with the file owner’s privileges, not the caller’s.
- The classic example is
/usr/bin/passwd— it’s owned by root and SUID-flagged, which is how a regular user can update their own password entry in/etc/shadowwithout having root access themselves. - The risk is not subtle. Any SUID-root binary that can be persuaded to execute an arbitrary command hands you root —
findwill do it with-exec,vimwith:!sh,nmap’s old interactive mode with!sh. The binary does not have to be malicious or even buggy; it just has to have a feature that runs something else. - Watch for
sversusSinls -loutput. A capitalSmeans the SUID bit is set but the execute bit is not — usually a mistake, and worth investigating rather than ignoring. - The bit is ignored on shell scripts on Linux, which is why attackers reach for compiled wrappers. It is also ignored entirely on filesystems mounted
nosuid, which is the cheapest mitigation available: mount/tmp,/var/tmp,/homeand any removable media withnosuidand a whole class of escalation stops working, at the cost of breaking any legitimate SUID tooling a user expected to run from their home directory.
SGID — Set Group ID
- Octal value:
2000(shows assin the group’s execute position) - On files: the binary executes with the owning group’s permissions.
- On directories: new files inherit the directory’s group rather than the creator’s primary group. This is the fix for the shared-project-folder problem, where everyone can write but each file ends up owned by a different group and nobody else can read it.
- The directory behaviour is genuinely useful and the file behaviour is mostly a liability. SGID on a binary is less immediately catastrophic than SUID root, but a binary running as group
shadowordiskis one careless file operation away from being just as bad — audit for it with the same seriousness.
Sticky Bit
- Octal value:
1000(shows astin the others’ execute position:rwxrwxrwt) - On a directory, only the file’s owner, the directory’s owner, or root can delete or rename files inside — even if the directory itself is world-writable.
/tmp(drwxrwxrwt) is the canonical case, and the reason it exists: without the sticky bit, any user could delete any other user’s temporary files, and a great many programs handle that badly.- It stops deletion and renaming. It does not stop reading or writing to a file whose own mode permits it, and it does nothing about the classic symlink race, where an attacker pre-creates a predictable filename in
/tmppointing at something valuable and waits for a privileged process to write to it. Any world-writable directory is still hostile ground; the sticky bit just makes it survivable.
| Special Bit | Octal | File Effect | Directory Effect |
|---|---|---|---|
| SUID | 4000 | Runs as file owner | N/A |
| SGID | 2000 | Runs as file group | New files inherit parent group |
| Sticky Bit | 1000 | N/A | Only owners can delete |
To set these:
chmod u+s executable_file # SUID
chmod g+s shared_directory # SGID
chmod +t shared_directory # Sticky bit
chmod 4755 script # SUID + rwxr-xr-x in one command
Hardening Default Permissions
umask — Setting Sane Defaults at Creation Time
Permissions set by hand are the ones you thought about. Most files on a system were never thought about — they were created by a service, a package, a script — and their mode came from umask, which masks bits out of the templates the creating process requested:
- File template:
666(rw-rw-rw-) - Directory template:
777(rwxrwxrwx)
With the standard umask of 022:
- New files: 666 minus 022 = 644 (
rw-r--r--) - New directories: 777 minus 022 = 755 (
rwxr-xr-x)
For environments that handle sensitive data, tighten the umask to 027:
- New files: 666 minus 027 = 640 (no access for others at all)
- New directories: 777 minus 027 = 750
For strict isolation, use 077:
- New files: 600 (owner read/write only)
- New directories: 700 (owner only)
“Subtracts” is the usual shorthand and it is close enough for the common values, but the operation is actually a bitwise clear — the umask turns bits off, it does not do arithmetic. With sensible masks the two give the same answer; with an odd one they diverge, which is why umask 023 produces results people find surprising.
The trade-off with 027 and 077 is not the numbers, it is what breaks. Tighten the mask system-wide and anything relying on group-readable files starts failing: web content a service account can no longer read, logs a monitoring agent can no longer parse, shared directories that were working by accident. Those failures show up hours later as permission-denied errors far from the change that caused them. Set it per-service or per-user first, watch for a week, then consider it system-wide.
Note also where the setting has to live. /etc/profile and ~/.bashrc are read by interactive login shells — they do not apply to systemd services, cron jobs, or anything else the system starts on its own. A daemon’s umask comes from systemd (UMask= in the unit file), and assuming otherwise is how people end up with a hardened login environment and a service still writing world-readable files.
# Check your current umask
umask
# Set it temporarily
umask 027
To make it permanent, add umask 027 to /etc/profile (system-wide) or ~/.bashrc / ~/.zshrc (per user).
File Attributes: Protection Beyond Permissions
Permissions are checked against the calling user, and root passes every check. Filesystem attributes are enforced a layer down, against everyone.
Immutable bit (+i) — the file cannot be modified, renamed, appended to, deleted, or hard-linked, and root gets the same refusal as anyone else:
sudo chattr +i /etc/resolv.conf
lsattr /etc/resolv.conf
sudo chattr -i /etc/resolv.conf # Remove it
Two caveats before you reach for this. Root can remove the attribute with chattr -i, so it protects against accident and careless automation rather than a determined attacker who already has root — unless you have also dropped CAP_LINUX_IMMUTABLE, at which point not even root can take it off until reboot. And it is invisible in ls -l, so the next person to hit it sees a package manager or a config-management run failing with “operation not permitted” on a file whose mode says 644, and has no obvious reason why. Anything you mark immutable belongs in your documentation.
Append-only bit (+a) — data can be added to the end; existing content cannot be altered or removed. Good for logs, with the same caveat: it defends the log against a process that has been compromised, not against root deciding to clear the attribute first. Genuine tamper-evidence needs the logs off the host:
sudo chattr +a /var/log/secure_audit.log
Access Control Lists — Granular Per-User Rules
One owner, one group, everyone else. That model runs out the first time you need to give exactly one extra person read access — and the usual workaround, creating a group for the pair of them, produces a system with forty single-purpose groups that nobody can reason about a year later. POSIX ACLs attach per-user and per-group rules directly to the file.
# See current ACLs on a file
getfacl sensitive_report.txt
# Grant read-only access to a specific user
setfacl -m u:bob:r sensitive_report.txt
# Revoke that access
setfacl -x u:bob sensitive_report.txt
The cost of ACLs is that they are nearly invisible. ls -l shows a + at the end of the mode string and nothing else, so a file that looks like -rw-r-----+ root root may in fact be readable by three named users, and you will only find out by running getfacl. Anyone auditing the system by eye will miss them entirely. They also do not survive tools that are not ACL-aware — cp without -a, rsync without -A, and most tar-based backups drop them silently, which means a restore can quietly remove access somebody depended on, or, worse, leave a file with a mode that was only ever safe because an ACL was narrowing it.
Use them where they genuinely fit, keep the count small enough to enumerate, and include getfacl -R output in whatever you consider your record of the system’s access model.
Security Audit: What to Check Regularly
These are the commands an attacker runs in their first minute on a box. Running them yourself, on a schedule, and diffing the output against last month’s is the entire game — a system’s SUID inventory should be stable, and a new entry appearing is either a package update or something you very much want to know about.
Find SUID Binaries
find / -perm -4000 -type f 2>/dev/null
A typical distribution returns fifteen to twenty-five results here, and most of them belong — passwd, sudo, su, mount, ping on older systems. The finding is never the count; it is the entry you cannot account for. Cross-reference against GTFOBins, which catalogues exactly how each abusable binary is turned into a shell.
The 2>/dev/null on the end suppresses permission errors from directories you cannot read, which keeps the output legible. It also means that if you run this as an unprivileged user, you are silently not searching parts of the filesystem. Run the audit as root, or accept that the empty result may just be a result you were not allowed to see.
Find SGID Binaries
find / -perm -2000 -type f 2>/dev/null
Find World-Writable Files
Outside /tmp and similar scratch space, these should not exist. A world-writable file inside a web root or a script directory is not a hygiene issue — it is a code execution path for anyone who reaches the box as any user:
find / -perm -o+w -type f ! -path "/proc/*" ! -path "/sys/*" 2>/dev/null
Run the same check for directories (-type d), and treat those as the higher priority. A world-writable directory lets an attacker delete your file and put their own in its place — which defeats every permission you carefully set on the original.
Find Orphaned Files
Permissions are stored as numeric UIDs; usernames are a lookup applied when you read them. Delete an account and the files remain, owned by a number with nothing behind it. Create a new account later and the system reuses the lowest free UID — which may be that number, at which point a new employee silently owns a departed one’s files. Nothing announces this. ls -l simply starts printing the new name.
The same mechanism bites when restoring backups across hosts, or mounting a disk from another system, where identical UIDs map to entirely different people:
find / -nouser 2>/dev/null
find / -nogroup 2>/dev/null
Verify Critical Config Files
# /etc/passwd should be world-readable but only root-writable
ls -l /etc/passwd # Expected: -rw-r--r-- (644)
# /etc/shadow holds password hashes — no world access at all
ls -l /etc/shadow # Expected: -rw-r----- (640) or -r-------- (400)
/etc/shadow being readable is the finding that ends an assessment early — hashes go straight into hashcat, and on a system with any weak passwords the game is over.
Automate all of this. Ansible, Chef, or a cron job writing the output to a file and mailing you the diff will all work; the mechanism matters far less than the fact that it runs without anyone remembering to run it. Manual audits happen twice, enthusiastically, and then stop.
And be clear about the limit of everything above. Discretionary access control constrains users, not root, and every mitigation here — the audits, the nosuid mounts, the immutable bits — is about narrowing the paths to root rather than containing what happens after. That containment is what SELinux and AppArmor are for. Permissions are the foundation, and a foundation is not the whole building.