Users, Permissions & Capabilities
The kernel's identity and permission system: from numeric UIDs to fine-grained capabilities, this is how Linux decides who can do what.
Overview
Every process on a Linux system runs as somebody — and the kernel enforces, at every system call boundary, whether that somebody is allowed to do what they are asking. This module is about the machinery behind that enforcement: how identities are represented, how permissions flow from files to processes, how the almighty root account works (and why it is being deliberately dismantled), and how the modern capability system lets programs be powerful without being omnipotent.
Understanding permissions is not just "chmod 755" trivia. It is the foundation of every security boundary on your system. A buffer overflow becomes catastrophically worse if the vulnerable process runs as root. A misconfigured SUID binary hands an attacker a ladder to full system compromise. Conversely, a correctly-designed service that holds only the one capability it needs can be fully compromised by an attacker and still not own your machine. That gap — between "owns the machine" and "contained blast radius" — is entirely made of the concepts in this module.
We will move from the simple numeric IDs the kernel tracks, through the nine classic permission bits and the three special bits, to the 40+ fine-grained capabilities that are slicing up the old root monolith, and finally get a preview of how namespaces virtualize identity entirely. Every step will be grounded in real kernel source behavior, real shell experiments, and real C programs you can run and break apart.
By the end of this module you will look at an ls -l listing and read it like a sentence, understand exactly what happens when a SUID binary executes, be able to write a C program that drops privileges correctly, and have the vocabulary to read kernel security patches.
The permission system is the first line of defense the kernel presents, and it is also the most frequently misunderstood. Nearly every local privilege escalation CVE — the class of vulnerability where an unprivileged user gains root — exploits either a misconfigured SUID binary, a capability leak, or a namespace escape. When you read kernel security patches, they are almost always tightening one of the mechanisms described in this module: closing a path by which a process could gain a credential it was not supposed to have. You cannot reason about those patches without knowing the substrate.
On the path to kernel contribution specifically, this module unlocks the security subsystem, the credential and capability code in kernel/capability.c and security/, and the LSM (Linux Security Module) framework. Many approachable first patches live exactly here: adding a missing capability check, tightening a permission flag, or fixing a privilege check in a new driver. Understanding users, permissions, and capabilities at the level this module teaches is the prerequisite for all of that work.
Credential Flow: fork(), exec(), and SUID
How credentials transform across fork() and exec(). The SUID bit on the executable causes the kernel to replace EUID with the file owner's UID at exec time. Correct privilege dropping (left path) sets all three UIDs, making the drop permanent. Incorrect dropping (right path) leaves the saved UID as 0, allowing privilege reclamation.
The concepts
01UIDs and GIDs: How the Kernel Sees Identity
Every process and every file on a Linux system has a numeric identity. The kernel does not know your username — that is a userspace convenience maintained in /etc/passwd. What the kernel stores and checks is a UID (User ID) and GID (Group ID): plain 32-bit unsigned integers.
A process actually carries four UIDs simultaneously:
- Real UID (RUID): who you actually are — the user who started the process.
- Effective UID (EUID): who the kernel uses for permission checks. Normally equal to RUID, but changes when you execute a SUID binary (more on that shortly).
- Saved set-user-ID (SUID): a "saved" copy of the previous EUID, allowing a process to temporarily drop and then reclaim privilege.
- Filesystem UID (FSUID): used only for file permission checks. Usually equals EUID. Historically relevant for NFS servers that need to change file credentials without affecting signal permissions.
The same four-tuple exists for groups (RGID, EGID, saved GID, FSGID), plus a set of supplementary groups (up to 32 historically, up to 65536 today) that the kernel also checks against group-permission bits.
You can inspect your own credentials with id. The kernel exposes them through /proc/self/status (look for Uid: and Gid: lines) and via the getresuid(2) / getresgid(2) system calls.
The special UID 0 is root. The kernel does not hard-code the string "root" anywhere — it hard-codes the number 0. Any process with EUID 0 (on a non-capability-aware check) bypasses most permission checks.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(void) {
uid_t ruid, euid, suid;
gid_t rgid, egid, sgid;
/* These are POSIX; Linux also has getresuid/getresgid */
getresuid(&ruid, &euid, &suid);
getresgid(&rgid, &egid, &sgid);
printf("RUID=%u EUID=%u SUID=%u\n", ruid, euid, suid);
printf("RGID=%u EGID=%u SGID=%u\n", rgid, egid, sgid);
return 0;
}
/* Compile: gcc -o whoami whoami.c
Run as yourself: RUID==EUID==SUID (all your uid)
Run after 'sudo': RUID=1000, EUID=0, SUID=0 */
02File Permissions: The Nine Classic Bits
Every file and directory inode stores a mode word. The lower 12 bits of that word are the permission bits; the lower 9 of those are the classic read/write/execute bits arranged in three groups of three:
rwx rwx rwx
^^^ ^^^ ^^^
user group other
When a process tries to open a file, the kernel performs the following check in order:
- If
EUID == file_uid: apply the user (owner) bits. - Else if
EGID == file_gidOR the file's GID is in the process's supplementary groups: apply the group bits. - Otherwise: apply the other bits.
The check is first match wins — if you own the file but owner bits are ---, you are denied even if group bits would allow it.
For directories, the bits have a different meaning: r means you can list the directory (readdir), w means you can create/delete entries in it, and x means you can traverse (pass through) it — you need x on every directory in a path to reach the file at the end.
The octal representation is nearly universal in practice: rwxr-xr-x = 755, rw-r--r-- = 644. Memorize these two and you will cover 90% of files you encounter.
ls -l output format:
-rwxr-xr-x 1 alice staff 12345 Jun 1 myprogram
^\ /\ / \ /
| u g o
| type (- file, d dir, l symlink, c char dev, b block dev, ...)
The kernel's inode_permission() in fs/namei.c is where this logic lives. The VFS calls it on every path lookup step.
# See permission bits:
ls -l /etc/passwd
# -rw-r--r-- 1 root root 2847 ... (644)
# Numeric mode:
stat --format '%a %n' /etc/passwd
# 644 /etc/passwd
# Change owner bits only:
chmod u+x myfile # add execute for owner
chmod 750 mydir # rwxr-x--- in one shot
# Why can you read /etc/passwd (owned root:root, mode 644)?
# Your EUID != 0, your GID != root's GID, so 'other' bits apply.
# other = r-- = readable. Good: you can look up usernames.
# Why can't you write it?
# other = r-- = no write bit. The kernel returns EACCES.
03The Three Special Bits: SUID, SGID, and Sticky
Beyond the nine rwx bits sit three more: setuid (SUID), setgid (SGID), and the sticky bit. These are shown in ls -l by replacing the x in the owner/group/other position with s (if x is also set) or S (if x is NOT set — a warning sign).
SUID (Set-User-ID-on-execution)
Mode bit value: 04000. When set on an executable, the kernel sets the process's EUID to the file's owner UID at exec time. The RUID stays as you. This is how passwd can write /etc/shadow (owned root, mode 000 except root) even though a regular user runs it: /usr/bin/passwd is owned root with SUID set. When you run it, your EUID becomes 0 for the duration.
On scripts, SUID is ignored by the Linux kernel (it was historically abused for race conditions). Only on ELF executables does it take effect.
SGID (Set-Group-ID-on-execution)
Mode bit value: 02000. On an executable: sets EGID to the file's group GID. On a directory: new files created inside inherit the directory's group rather than the creator's primary group. This is the standard way to set up shared project directories where all files belong to the dev group.
Sticky Bit
Mode bit value: 01000. On directories today: prevents users from deleting or renaming files they do not own, even if they have write permission on the directory. /tmp uses this — you can create files in /tmp but you cannot delete someone else's file. On old UNIX it had a different meaning for executables (keep in swap) that is now obsolete.
Security implications
SUID root binaries are high-value attack targets. Any exploitable bug in passwd, sudo, ping, or any other SUID-root binary potentially gives an attacker root. The kernel has multiple mitigations: nosuid mount option, PR_SET_NO_NEW_PRIVS (prctl(2)), and seccomp filters. The modern trend is to replace SUID binaries with capability-bearing binaries (see the Capabilities concept).
# Find all SUID-root executables on your system:
find / -xdev -perm -4000 -user root 2>/dev/null
# Typical output includes:
# /usr/bin/passwd (needs to write /etc/shadow)
# /usr/bin/sudo (needs to exec as another user)
# /usr/bin/ping (historically needed CAP_NET_RAW)
# /usr/bin/su
# Demonstrate SUID: create a SUID-root binary (as root!)
gcc -o whoami_demo whoami.c
sudo chown root whoami_demo
sudo chmod u+s whoami_demo # set SUID
ls -l whoami_demo
# -rwsr-xr-x ... root ... whoami_demo
# ^ 's' = SUID + execute
./whoami_demo
# RUID=1000 EUID=0 SUID=0 <-- EUID is now root!
# SGID on a directory:
mkdir shared && sudo chgrp dev shared && sudo chmod g+s shared
touch shared/newfile && ls -l shared/newfile
# Group will be 'dev', not your primary group
04The Root User and Why It Is Dangerous
UID 0 is not just a user with more permissions — it is a user for whom most kernel permission checks are bypassed entirely. The kernel source is littered with patterns like:
if (capable(CAP_DAC_OVERRIDE))
return 0; /* allowed */
And historically capable() was almost always equivalent to current->euid == 0.
Root can:
- Open any file regardless of permissions (DAC_OVERRIDE / DAC_READ_SEARCH)
- Send signals to any process (KILL)
- Change ownership of files (CHOWN)
- Bind to ports below 1024 (NET_BIND_SERVICE)
- Load kernel modules (SYS_MODULE)
- Reboot the machine (SYS_BOOT)
- Do roughly 30 more privileged operations
The core problem is all-or-nothing: if a web server runs as root because it needs port 80, it also gets the power to read your private keys, load rootkits, and reformat your disk. One exploitable bug in that web server = total system compromise.
This is why the kernel community, over the last two decades, has been systematically breaking root's power into fine-grained capabilities, and why modern system administrators run services as dedicated unprivileged users with only the capabilities they need.
Practical note: sudo is not the same as root. sudo is a SUID-root program that checks /etc/sudoers and — if authorized — executes the requested command with EUID 0. The /etc/sudoers policy is enforced by userspace, not the kernel. The kernel only sees the resulting EUID-0 process.
/* How the kernel checks for root (simplified from include/linux/capability.h)
and kernel/capability.c in the Linux source tree: */
/* Before capabilities, checks looked like this: */
if (current_euid() == 0)
goto allowed;
/* Modern kernel uses capable() which checks the capability set: */
if (capable(CAP_CHOWN)) {
/* change file ownership */
}
/* capable() macro ultimately calls cap_capable() in security/commoncap.c
which checks the process's effective capability set.
For UID 0 processes without PR_SET_KEEPCAPS, all caps are set by default.
This is the backward-compat bridge between old and new worlds. */
05POSIX Capabilities: Slicing Up Root
Linux capabilities (defined in include/uapi/linux/capability.h) divide the privileges of root into around 40 distinct units. Each process carries three capability sets:
- Permitted (P): the maximum set the process is allowed to have in Effective.
- Effective (E): the set currently active — what the kernel checks.
- Inheritable (I): capabilities that can be inherited across
exec().
Files also carry capability sets (stored as extended attributes — security.capability xattr): a permitted set, an inheritable set, and a single effective bit (not a full set). At exec time, the kernel computes the new process capability sets using a formula from the execve(2) man page.
Some important individual capabilities:
| Capability | What it allows |
|---|---|
| CAP_CHOWN | Change file UIDs/GIDs |
| CAP_DAC_OVERRIDE | Bypass file read/write/execute permission checks |
| CAP_NET_BIND_SERVICE | Bind to ports < 1024 |
| CAP_NET_RAW | Use raw sockets (ping, packet sniffing) |
| CAP_SYS_PTRACE | ptrace any process |
| CAP_SYS_MODULE | Load/unload kernel modules |
| CAP_SYS_ADMIN | A huge grab-bag; often called "the new root" |
| CAP_SETUID | Make arbitrary setuid() calls |
| CAP_KILL | Send signals to processes of other users |
Ambient capabilities (added in Linux 4.3) solve the painful problem of unprivileged helper processes that need to inherit specific capabilities — they flow across exec even for non-SUID binaries.
Bounding set: a per-process ceiling. Even if a file grants a capability, if it is not in the bounding set it cannot enter Permitted. Used to permanently drop capabilities (e.g., a daemon that drops CAP_SYS_MODULE from its bounding set can never regain it, even if compromised code tries).
The getcap / setcap userspace tools and the capsh shell wrapper let you inspect and manipulate capabilities without writing C.
#include <stdio.h>
#include <sys/capability.h> /* link with -lcap */
#include <unistd.h>
int main(void) {
cap_t caps = cap_get_proc();
if (!caps) { perror("cap_get_proc"); return 1; }
char *text = cap_to_text(caps, NULL);
printf("capabilities: %s\n", text);
cap_free(text);
cap_free(caps);
return 0;
}
/* Compile: gcc -o showcaps showcaps.c -lcap
As a normal user: capabilities = (none)
As root: capabilities = =ep (all caps, effective+permitted)
Grant a single cap to a binary (as root):
setcap cap_net_bind_service+ep ./myserver
# Now myserver can bind port 80 without being SUID-root.
List caps on a file:
getcap /usr/bin/ping
# /usr/bin/ping cap_net_raw=ep
# (on modern distros; older ones used SUID-root instead) */
06Dropping Privileges: The Right Way
A program that starts with elevated privilege (SUID-root or running as root) should drop that privilege as soon as it no longer needs it. This is the principle of least privilege in action. Getting this right is surprisingly tricky.
The naive approach — setuid(getuid()) — has a subtle bug: on Linux, setuid() only changes the effective UID if you are not root. If you ARE root (EUID == 0), setuid(X) sets all three (RUID, EUID, SUID) to X and you correctly drop privilege. But if you are not root and only want to change EUID, you need seteuid(). And if you use seteuid() alone without changing SUID, a malicious path in the code can call seteuid(0) to reclaim root!
The correct sequence for permanently dropping to user identity:
- Call
setgroups()to clear supplementary groups. - Call
setgid()to set GID (must do before setuid while still root). - Call
setuid()to set UID. Since you are root, this sets RUID=EUID=SUID=target. - Verify by trying
setuid(0)— it must fail with EPERM.
For capability-aware code, use cap_set_proc() to zero out the effective and permitted sets, then verify.
PR_SET_NO_NEW_PRIVS (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) is a modern Linux feature (kernel 3.5+) that makes it impossible for the process or its children to gain new privileges via SUID/SGID binaries or file capabilities. It is inherited across fork() and exec() and cannot be unset. Systemd sets this for sandboxed services; seccomp-bpf also requires it to be set first.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <grp.h>
#include <sys/prctl.h>
#include <errno.h>
/* Call this in a SUID-root program after finishing privileged work.
target_uid / target_gid: the unprivileged identity to switch to. */
void drop_privileges(uid_t target_uid, gid_t target_gid) {
/* 1. Clear supplementary groups */
if (setgroups(0, NULL) != 0) { perror("setgroups"); exit(1); }
/* 2. Set GID first (while still root) */
if (setgid(target_gid) != 0) { perror("setgid"); exit(1); }
/* 3. Set UID — as root this sets RUID=EUID=SUID=target */
if (setuid(target_uid) != 0) { perror("setuid"); exit(1); }
/* 4. Verify: attempting to re-gain root must fail */
if (setuid(0) == 0) {
fprintf(stderr, "FATAL: still able to regain root!\n");
exit(1);
}
/* errno == EPERM here: good. */
/* 5. Optionally lock down further */
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
}
int main(void) {
/* ... do privileged work here ... */
drop_privileges(1000, 1000); /* uid/gid of the real user */
/* ... continue as unprivileged user ... */
printf("EUID is now %u\n", geteuid());
return 0;
}
07umask: The Permission Filter at Creation Time
When you create a file, you pass a mode argument to open(2) or mkdir(2). But the kernel does not use that mode directly. It first applies a umask (user file-creation mask): it subtracts (clears) any bits set in the umask from the requested permissions.
Formally: actual_mode = requested_mode & ~umask
The typical default umask is 022 (octal). So:
- A file created with mode 0666 becomes 0644 (world-writable bit removed).
- A directory created with mode 0777 becomes 0755.
This is why new files are not world-writable by default even if you write open(path, O_CREAT, 0666). The umask is a per-process value, inherited across fork, reset across login. You set it with umask(2) in C or with the shell builtin umask.
Common umasks:
- 022: default on most systems. Others can read but not write.
- 027: group can read, others get nothing. Good for daemons.
- 077: only owner can read/write. Good for private key generation.
A critical subtlety: the umask only applies at creation time. It does not retroactively change existing files. And chmod ignores the umask entirely.
# Show current umask:
umask
# 0022
# Temporary change for this shell session:
umask 027
touch secretfile
ls -l secretfile
# -rw-r----- ... (640, not 644 — group has read, other has nothing)
# In C:
#include <sys/stat.h>
mode_t old = umask(0077); /* set umask, get old value */
int fd = open("keyfile", O_CREAT|O_WRONLY, 0600);
/* actual mode = 0600 & ~0077 = 0600 & 0700 = 0600 (unchanged here) */
/* With umask 022: 0666 & ~022 = 0666 & 0755 = 0644 */
umask(old); /* restore */
08Access Control in Action: The exec() Transition
The most security-critical moment for credentials is execve(2): replacing the process image with a new program. This is when SUID/SGID and file capabilities take effect. Understanding this transition precisely is essential.
Here is the kernel's credential calculation during exec (simplified from fs/exec.c and kernel/capability.c):
Step 1: Read the new binary's file permissions (SUID bit, SGID bit, file capability xattr).
Step 2: Compute new capability sets using the formula:
P' = (P_file & bset) | (I_process & I_file)
E' = P' if effective_bit_in_file else E_process & P'
I' = I_process & I_file
Step 3: If the binary is SUID-root and PR_SET_NO_NEW_PRIVS is NOT set:
- New EUID = 0, so P' gets all capabilities (backward compat).
Step 4: Commit the new credentials via security_bprm_committed_creds().
This is also why the kernel prevents a traced process (ptrace) from gaining new privileges via exec: a debugger running as user A could otherwise execute an SUID binary and gain EUID 0 while still being controlled by user A. The kernel checks ptrace_may_access() and clears the SUID bit during exec if the process is being traced or if effective IDs changed.
The entire credential handling code lives in kernel/cred.c. The struct cred is reference-counted and copy-on-write: most credential manipulations allocate a new cred, modify it, then atomically install it with commit_creds().
/* Illustrating the SUID exec transition in shell */
/* 1. Create a program that reports its credentials */
// (use the whoami.c from concept 1)
gcc -o show_ids show_ids.c
/* 2. Make it SUID root */
sudo chown root:root show_ids
sudo chmod u+s show_ids
/* 3. Run it as a normal user */
./show_ids
// RUID=1000 EUID=0 SUID=0
// ^--- your uid ^--- root (via SUID) ^--- root
/* 4. The program can now call setuid(1000) to drop back:
setuid(1000) sets RUID=EUID=SUID=1000
Or it can keep EUID=0 to do privileged work. */
/* In kernel terms, this exec called:
prepare_bprm_creds() -> bprm_fill_uid() -> commit_creds()
and installed a new struct cred with euid=0 */
09Namespaces: A Preview of Virtualized Identity
Everything we have discussed so far lives in the initial user namespace — the default namespace that represents the real system. Linux namespaces are a kernel feature that virtualizes global resources; the user namespace virtualizes the UID/GID space itself.
Inside a user namespace:
- A process can have UID 0 (root inside the namespace) while being UID 1000 in the parent namespace.
- File permissions and capability checks within the namespace use the namespace-local UIDs.
- Capabilities granted inside the namespace only apply to resources owned by that namespace.
This is the foundation of rootless containers (like podman run --user): a containerized process thinks it is root and can install packages, but from the host kernel's perspective it is an unprivileged user.
User namespaces interact with capabilities in a specific way: when a new user namespace is created (via clone(CLONE_NEWUSER) or unshare --user), the creating process gets all capabilities in the new namespace. Crucially, those capabilities do NOT grant it any rights in the parent namespace.
The other namespaces (PID, mount, network, IPC, UTS, cgroup) become much more useful in combination with user namespaces, because you can create an isolated environment without any real root privilege.
This is a preview — user namespaces and container security are a full module on their own. But the key insight for this module is: UIDs and capabilities are namespace-relative. The kernel always asks 'relative to which namespace?' before performing a credential check.
# Create a new user namespace where you appear as UID 0:
unshare --user --map-root-user bash
# Inside the new namespace:
id
# uid=0(root) gid=0(root) groups=0(root) <-- looks like root!
cat /proc/self/uid_map
# 0 1000 1 (namespace-uid 0 maps to host-uid 1000, range 1)
# But you have no real power on the host:
chown root /etc/passwd
# chown: changing ownership of '/etc/passwd': Operation not permitted
# (because in the PARENT namespace you are still uid 1000)
# This is exactly what rootless Docker/Podman does.
exit # back to host namespace
10The Principle of Least Privilege in Practice
The principle of least privilege (PoLP) states: a process should hold only the minimum set of privileges necessary to perform its function, for only as long as it needs them.
This is not just theory — it is an engineering discipline with specific techniques:
1. Run services as dedicated unprivileged users. /etc/passwd has nginx, postgres, nobody for exactly this reason. A compromised nginx cannot read postgres's data files if they are owned by a different user.
2. Use file capabilities instead of SUID-root. setcap cap_net_bind_service+ep /usr/bin/myserver lets your server bind port 80 without any other root power.
3. Drop privileges early. Bind the socket (privileged), then immediately drop to unprivileged UID before entering the request-handling loop.
4. Use PR_SET_NO_NEW_PRIVS. Makes it impossible for the process or its children to gain privileges. Set this before entering untrusted work.
5. Seccomp-bpf filters. Restrict which system calls the process can make. Even if an attacker exploits a memory-corruption bug, they cannot call execve or socket if those are filtered.
6. Capability bounding set. Drop all capabilities you never need from the bounding set at startup. Even if your code has a bug that tries to re-acquire them, the kernel will refuse.
7. Systemd service hardening. NoNewPrivileges=yes, CapabilityBoundingSet=CAP_NET_BIND_SERVICE, User=myservice in a .service file — systemd handles steps 1-4 declaratively.
The historical evolution is instructive: early Linux simply ran everything as root because the system was single-user. As multi-user and networked Linux grew, the pain of 'all or nothing' became undeniable, and the capability system was introduced in kernel 2.2 (1999). Namespace virtualization of UIDs came in kernel 3.8 (2013). The direction is clear: privilege is being decomposed into ever-smaller, auditable units.
# Systemd service with least-privilege hardening:
# /etc/systemd/system/myserver.service
[Service]
User=myserver
Group=myserver
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ExecStart=/usr/local/bin/myserver --port 80
# This service:
# - Runs as the 'myserver' user (not root)
# - Can bind port 80 (via ambient capability)
# - Cannot exec SUID binaries to gain new privileges
# - Cannot write to /usr, /boot, /etc
# - Cannot access /home
# - Has a private /tmp namespace
Key insights
- ✦The kernel never knows your username — it only ever checks UIDs and GIDs (32-bit integers). '/etc/passwd' is entirely a userspace convention.
- ✦Permission checks are 'first match wins': if you own the file, only owner bits are checked — even if group bits are more permissive.
- ✦SUID on an executable changes EUID at exec time. SUID on a script is silently ignored by the Linux kernel. SUID on a directory has no effect.
- ✦Root (UID 0) does not mean 'has all capabilities' — it means the kernel historically *skipped* capability checks for UID 0. The capability system is gradually replacing this shortcut.
- ✦Dropping privileges correctly requires clearing SUID (the saved UID), not just EUID. The correct tool is setuid(target) from EUID 0, which sets all three. After that, setuid(0) must fail.
- ✦PR_SET_NO_NEW_PRIVS is the single most impactful system call for sandboxing: it makes SUID and file capabilities a no-op for the process and all descendants, permanently and irrevocably.
- ✦User namespaces virtualize the UID space: a process can be UID 0 inside a namespace while being UID 1000 on the host. This is the mechanism behind rootless containers.
- ✦CAP_SYS_ADMIN is sometimes called 'the new root' because it covers so many operations. A process with CAP_SYS_ADMIN is nearly as dangerous as a root process.
Common pitfalls
- ⚠Calling seteuid(getuid()) to 'drop privilege' but forgetting that the saved UID is still 0 — the process can call seteuid(0) at any time to reclaim root. Always use setuid() from EUID 0 to set all three.
- ⚠Setting SUID on a shell script. The kernel ignores it silently, and many developers assume it works. Worse, some older UNIX systems DID honor it, creating a class of race-condition bugs.
- ⚠Granting CAP_SYS_ADMIN to a container or process 'because it needs one specific admin operation' — CAP_SYS_ADMIN covers mount, ptrace, perf_event, and dozens of other powerful operations. Find the narrower capability instead.
- ⚠Forgetting to call setgroups(0, NULL) before setuid() when dropping privileges. If the supplementary groups are not cleared, the process may still have access via group bits.
- ⚠Confusing 'chmod 777' with 'anyone can delete this file from its parent directory'. Deletion is controlled by the parent directory's write+execute bits, not the file's own permissions.
- ⚠Assuming user namespace UID 0 has real root power. Inside a user namespace, UID 0 has full capabilities *within the namespace* but zero extra rights on the host. Confusing the two leads to either false security assumptions or unnecessary privilege grants.
- ⚠Using the sticky bit on a file (rather than a directory) — on modern Linux it has no effect on files, but some developers expect 'only root can delete this file' semantics from it.
Practice
- 01hard
Write a C program called 'minisudo' that: (1) is compiled and installed SUID-root, (2) reads a hardcoded list of allowed commands from a file it opens *before* dropping privilege, (3) drops to the caller's RUID, (4) verifies the requested command is in the allowed list, and (5) re-elevates with setuid(0) only then to exec the command. Document why step 3 matters for security, and demonstrate a case where skipping step 3 would be dangerous.
- 02medium
Build a small network server in C that: (1) binds to port 443 as root using a raw socket, (2) uses setcap to grant cap_net_bind_service instead of running as SUID-root, (3) immediately drops all credentials using the correct drop_privileges() pattern after binding, and (4) logs its EUID and capability set before and after the drop. Compare the two approaches (SUID vs setcap) in terms of what an attacker gains if they exploit a memory-corruption bug in your server.
- 03medium
Explore the /proc filesystem to understand a running process's full credential picture. For three processes on your system (bash, sshd or nginx if running, and a kernel thread), extract and explain: the Uid/Gid lines from /proc/PID/status, the CapEff/CapPrm/CapBnd lines (decode the hex bitmask using capsh --decode=), and the /proc/PID/loginuid value. Write a shell script that does this for all running processes and flags any with CapEff != 0 that are NOT running as root.
- 04easy
Implement the sticky-bit semantics manually. Create a directory /tmp/sharedtest with mode 1777. Create two user accounts (user1, user2). As user1, create a file with world-writable permissions. Verify that user2 CAN write to the file but CANNOT unlink or rename it. Then remove the sticky bit and verify user2 CAN now unlink it. Find the exact kernel source function (in fs/namei.c) that implements this check and read it.
- 05hard
Create a rootless container by hand using only shell commands and Linux namespaces: use 'unshare --user --map-root-user --mount --pid --fork bash' to enter an isolated namespace, then manually mount a minimal filesystem (using /proc from the host as read-only), and verify that: your UID inside is 0, you can create files owned by 'root' inside the namespace, those files appear owned by your real UID on the host, and you cannot access files on the host that require real root.
Resources
The authoritative kernel documentation on how credentials work, written by David Howells. Required reading before touching kernel credential code.
Complete reference for every Linux capability, the three-set model, and the exec() transformation formula. Keep this open while reading capability code.
Contains the definitive description of how SUID, SGID, and file capabilities interact during exec. The 'Capabilities and execution of programs by root' section is especially important.
Overview of all Linux namespace types; the user_namespaces(7) linked page explains UID virtualization in depth.
Foundational LWN article explaining the capability model and the problems it was designed to solve. Historical context that makes the design decisions clear.
LWN article covering the introduction of file capabilities (vs. process capabilities). Explains why the two are different and how they interact.
Chapters 8-9, 15, and 39 are the definitive reference for everything in this module. Worth every penny.
The kernel's own documentation of PR_SET_NO_NEW_PRIVS — essential for understanding modern privilege sandboxing.
The detailed reference for user namespace UID/GID mapping, capability semantics inside namespaces, and security constraints.
Good entry point to the Linux Security Module framework that sits on top of the capability and credential system described in this module.
Checkpoints — you can now…
- ✓You can read any 'ls -l' output and state exactly which permission bits apply to a given process, including the SUID/SGID/sticky special bits and what 's' vs 'S' means.
- ✓You can explain the difference between RUID, EUID, and saved UID, and predict the EUID of a process after executing a SUID-root binary from a normal user session.
- ✓You can write a C program that correctly and permanently drops root privileges, including clearing supplementary groups and verifying that setuid(0) subsequently fails.
- ✓You can use 'getcap', 'setcap', and 'capsh --decode' to inspect and modify file capabilities, and explain why granting a single capability via setcap is preferable to making a binary SUID-root.
- ✓You can look at /proc/PID/status and decode the Uid/Gid and Cap* hex fields to describe a process's complete credential state.
- ✓You can explain what PR_SET_NO_NEW_PRIVS does, why it is irreversible, and why seccomp-bpf requires it to be set first.
- ✓You can use 'unshare --user --map-root-user' to enter a user namespace, explain the UID mapping shown in /proc/self/uid_map, and explain why namespace-root cannot access host-root files.