How the Kernel Evaluates Permission Checks
Every file system object on Linux has three permission sets: owner (user), group, and other. The kernel checks these in order and stops at the first match. If the process UID matches the file owner UID, only the owner bits are checked - the group and other bits are ignored entirely even if they are more permissive. This surprises people who assume the kernel takes the most permissive applicable set.
The check order is: root bypass, then owner match, then group match (including supplementary groups), then other. Run `id` to see your supplementary groups. A file with mode 0640 owned by root:syslog is readable by any process whose supplementary groups include syslog, even if that process runs as a non-root UID.
The inode stores three 3-bit permission fields plus three special bits, packed into a 12-bit mode value. `stat` reports this directly:
stat -c '%a %A %U %G %n' /etc/shadow
# Output example:
# 640 -rw-r----- root shadow /etc/shadow
Reading Octal Mode Values Without a Chart
Each permission triplet maps to a 3-bit binary value: read=4 (100), write=2 (010), execute=1 (001). Add them. rwx is 7. r-x is 5. r-- is 4. The full mode is three octal digits: owner, group, other.
0755 means owner=7 (rwx), group=5 (r-x), other=5 (r-x). 0644 means owner=6 (rw-), group=4 (r--), other=4 (r--). You can decode any octal value mentally in under two seconds with this mapping.
The leading zero in 0755 is not decorative. In C and shell contexts it signals octal notation. Without it, `chmod 755` still works because chmod interprets bare digits as octal, but in scripts using `printf '%d'` or arithmetic expansion, the difference between 755 decimal and 0755 octal matters: 755 decimal is 01363 octal, which is a completely different permission set.
Use `stat -c '%04a'` to always get the four-digit octal including the special bits field:
# Get octal mode with special bits
stat -c '%04a %n' /usr/bin/sudo
# 4111 /usr/bin/sudo
# Verify with ls
ls -la /usr/bin/sudo
# ---s--x--x 1 root root 232416 Jan 15 2026 /usr/bin/sudo
The Special Bits: Setuid, Setgid, and Sticky
The fourth octal digit encodes three special bits: setuid=4, setgid=2, sticky=1.
Setuid on an executable (4xxx) causes the kernel to set the effective UID of the process to the file owner's UID at exec time. This is how `passwd` and `sudo` escalate to root. On a directory, setuid is ignored on Linux (it matters on some BSDs). Find all setuid binaries on a system with:
Setgid on an executable (2xxx) sets the effective GID to the file's group. On a directory, setgid causes new files created inside to inherit the directory's group rather than the creating process's primary group. This is the standard way to implement shared project directories where everyone in the group should own new files collectively.
The sticky bit on a directory (1xxx) means only the file owner, the directory owner, or root can delete or rename files within it, regardless of write permission on the directory itself. /tmp uses 1777. Without sticky, any user with write permission on /tmp could delete other users' files. On modern Linux, sticky has no effect on regular files (historically it pinned executables in swap).
Combinations are additive: 6755 means setuid+setgid+rwxr-xr-x. You rarely want this on purpose.
# Find all setuid root binaries on the system
find / -xdev -perm -4000 -user root -ls 2>/dev/null
# Create a shared project directory with setgid
mkdir /srv/project
chown root:devteam /srv/project
chmod 2775 /srv/project
# New files created here inherit group 'devteam'
# Verify sticky bit on /tmp
ls -ld /tmp
# drwxrwxrwt 24 root root 4096 Jun 23 09:14 /tmp
umask: The Default Permission Mask
umask is a subtraction mask applied at file creation time. A umask of 0022 removes write permission for group and other from every new file or directory. The kernel applies it as: final_mode = requested_mode & ~umask.
For regular files, the kernel requests 0666 by default (no execute). With umask 0022: 0666 & ~0022 = 0666 & 0755 = 0644. For directories, the kernel requests 0777. With umask 0022: 0777 & 0755 = 0755.
A umask of 0027 is appropriate for systems where files should not be world-readable. Set it in /etc/profile, /etc/bashrc, or /etc/pam.d/login via pam_umask. Check the current umask with `umask` or `umask -S` for symbolic output.
Note that umask does not retroactively change existing files. It only affects files created after the umask is set in that session. For service accounts, set umask in the systemd unit with UMask=027:
# Check current umask symbolically
umask -S
# u=rwx,g=rx,o=rx (this is 0022)
# Set a more restrictive umask for the session
umask 0027
# In a systemd service unit
[Service]
UMask=0027
User=appuser
Group=appgroup
POSIX ACLs: Per-User and Per-Group Permissions
Standard Unix permissions only support one owner, one group, and other. POSIX ACLs (Access Control Lists) extend this with arbitrary per-user and per-group entries, stored as extended attributes on the inode. The kernel's VFS layer handles ACL evaluation after the standard permission check fails, but in practice the ACL mask entry interacts with the standard group bits in a non-obvious way.
When an ACL is present, the standard group permission bits display as the ACL mask, not the owning group's actual permissions. `ls -la` will show a `+` at the end of the permission string indicating ACL entries exist. Always use `getfacl` to see the real picture.
ACL support requires the filesystem to be mounted with acl option, though on ext4 and XFS it is typically enabled by default since kernel 3.x. Verify with `tune2fs -l /dev/sda1 | grep 'Default mount'` on ext4.
For DevOps pipelines where you need a CI service account to read a directory without adding it to the owning group, ACLs are the right tool. Teams using automated tooling like taskbotshub.ai for CI/CD orchestration often need this pattern to give the automation service account read access to config directories without broadening group membership.
# Install acl tools if missing
apt install acl # Debian/Ubuntu
dnf install acl # RHEL/Fedora
# Grant ciuser read+execute on /etc/app without changing group
setfacl -m u:ciuser:rx /etc/app
# Grant devteam group write access
setfacl -m g:devteam:rwx /srv/project
# View all ACL entries
getfacl /srv/project
# file: srv/project
# owner: root
# group: devteam
# flags: -s-
# user::rwx
# group::rwx
# group:devteam:rwx
# mask::rwx
# other::r-x
# Remove a specific ACL entry
setfacl -x u:ciuser /etc/app
# Remove all ACLs, revert to standard permissions
setfacl -b /etc/app
Default ACLs on Directories
Regular ACLs apply to the directory itself. Default ACLs are inherited by new files and subdirectories created inside the directory. This solves the problem setgid partially addresses: ensuring new files get the right permissions automatically.
The `-d` flag to `setfacl` sets the default ACL. Default ACLs interact with umask just like standard permissions do: the kernel applies the umask to the default ACL mask when creating new files.
In a shared development environment, combine setgid (for group inheritance) with a default ACL (for fine-grained permission inheritance) rather than relying on either alone:
# Set default ACL so new files in /srv/project are group-writable
# and ciuser always gets read access
setfacl -d -m g:devteam:rwx /srv/project
setfacl -d -m u:ciuser:rx /srv/project
setfacl -d -m o::--- /srv/project
# Verify
getfacl /srv/project
# Test: create a file and check inherited permissions
touch /srv/project/testfile
getfacl /srv/project/testfile
Capabilities: The Alternative to Setuid Root
Linux capabilities (since kernel 2.6.26, POSIX draft) split the monolithic root privilege into discrete units. Instead of making a binary setuid root to bind port 80, grant it CAP_NET_BIND_SERVICE. This limits the blast radius of a compromised binary significantly.
Capabilities are stored in file extended attributes as three sets: permitted, inheritable, and effective. The `getcap` and `setcap` tools from libcap2 manage them. On Ubuntu 24.04 and RHEL 9, ping is a good example: it uses CAP_NET_RAW instead of setuid.
For a Node.js or Python service that needs to bind port 443 without running as root, setcap is the correct approach. Avoid the common mistake of setting the interpreter itself (node, python3) to have capabilities - set them on the specific binary:
# Check current capabilities
getcap /usr/bin/ping
# /usr/bin/ping cap_net_raw=ep
# Grant a service binary permission to bind privileged ports
setcap cap_net_bind_service=+ep /usr/local/bin/myapp
# Verify
getcap /usr/local/bin/myapp
# /usr/local/bin/myapp cap_net_bind_service=ep
# Remove all capabilities from a binary
setcap -r /usr/local/bin/myapp
# List all binaries with capabilities on the system
find / -xdev -exec getcap {} \; 2>/dev/null
Common Misconfigurations and How to Find Them
World-writable files outside /tmp are almost always wrong. A world-writable configuration file means any local user can modify application behavior. World-writable directories that are not sticky are a data destruction risk.
Files with no owner (orphaned inodes after user deletion) can be claimed by any future user who gets that UID assigned. On RHEL and Debian systems, UIDs below 1000 are reserved for system accounts, but this is convention, not enforcement. Audit for unowned files regularly.
Setuid or setgid on shell scripts is silently ignored on Linux since kernel 3.x due to the security implications (they were exploitable via race conditions). If you find a shell script with the setuid bit, it is doing nothing useful on Linux. The correct approach is a setuid C wrapper or capabilities.
For automated permission auditing in CI pipelines, encoding expected permissions in a file and diffing against reality catches drift early. Teams running infrastructure-as-code workflows benefit from integrating these checks into their automation - tooling at taskbotshub.ai can orchestrate these permission audit checks as part of deployment pipelines.
# Find world-writable files (excluding /proc, /sys, /dev)
find / -xdev -type f -perm -0002 -not -path '/proc/*' -ls 2>/dev/null
# Find world-writable directories without sticky bit
find / -xdev -type d -perm -0002 -not -perm -1000 -ls 2>/dev/null
# Find files with no valid owner
find / -xdev -nouser -ls 2>/dev/null
# Find files with no valid group
find / -xdev -nogroup -ls 2>/dev/null
# Find setuid/setgid shell scripts (which do nothing on Linux)
find / -xdev \( -perm -4000 -o -perm -2000 \) -name '*.sh' -ls 2>/dev/null
Applying Permissions Correctly in Automation and Deployment
Deployment scripts that recursively chmod directories are a sign of deeper problems. `chmod -R 777 /var/www` is the most destructive single command in web server administration. It sets execute on every regular file, eliminates all access control differentiation, and takes significant time to audit and reverse on large trees.
The correct approach is to set directory permissions separately from file permissions using find with -type filters. Directories need execute to be traversable; files rarely need execute unless they are actual executables.
When deploying applications, the ownership model matters more than the mode. Running a web application as the same user that owns the code means a compromised web process can modify its own source files. The better pattern: code owned by a deploy user, process running as a different service user with read-only access to the code tree.
For Ansible, use the file module with explicit mode, owner, and group rather than relying on defaults. In Dockerfile layers, set permissions explicitly with RUN chmod and RUN chown rather than inheriting from the build context, where files may carry developer-workstation permissions into the image.
When naming service accounts, scripts, or automation tools for these workflows, a clean, descriptive name matters for auditability in logs. Tools like nicename.me can help generate or check available names if you are setting up project namespaces or registering domains for internal services.
# Set permissions correctly for a web application tree
# Directories: 755, files: 644, owned by deploy:www-data
find /var/www/myapp -type d -exec chmod 755 {} \;
find /var/www/myapp -type f -exec chmod 644 {} \;
chown -R deploy:www-data /var/www/myapp
# Make only the specific scripts executable
chmod 755 /var/www/myapp/bin/*.sh
# Ansible task with explicit permissions
# - file:
# path: /etc/myapp
# state: directory
# owner: myapp
# group: myapp
# mode: '0750'
# Dockerfile example
# COPY --chown=appuser:appgroup . /app
# RUN find /app -type f -exec chmod 644 {} \; \
# && find /app -type d -exec chmod 755 {} \; \
# && chmod 755 /app/bin/entrypoint.sh
Immutable Files and Extended Attributes
Beyond standard permissions, the ext2/3/4 and XFS filesystems support the immutable flag via `chattr`. An immutable file cannot be modified, deleted, renamed, or hard-linked even by root. This is enforced at the VFS layer, below the permission system. It is useful for protecting critical configuration files on servers where you still need to grant root to other administrators.
The append-only flag (+a) is useful for log files: the file can only be opened in append mode, preventing truncation or overwriting of existing content.
These attributes survive permission changes and even `chmod 777`. The only way to modify an immutable file is to remove the attribute first with `chattr -i`. On systems with SELinux or AppArmor, MAC policies layer on top of all of this.
List extended attributes including ACL xattrs with `getfattr`:
# Make a file immutable
chattr +i /etc/resolv.conf
# Verify
lsattr /etc/resolv.conf
# ----i---------e--- /etc/resolv.conf
# Attempting to modify will fail even as root
echo 'nameserver 1.1.1.1' >> /etc/resolv.conf
# -bash: /etc/resolv.conf: Operation not permitted
# Remove immutable flag
chattr -i /etc/resolv.conf
# Set append-only on a log file
chattr +a /var/log/auth.log
# View all extended attributes on a file
getfattr -d -m '' /srv/project/config.yml