setuid setgid and the Sticky Bit

Above the nine ordinary permission bits, a Unix file mode carries three special bits: set-user-ID (S_ISUID, octal 04000), set-group-ID (S_ISGID, 02000), and the sticky bit (S_ISVTX, 01000). Each one changes the meaning of the file rather than simply granting access. On an executable, setuid makes the program run with the file owner’s effective user ID — the classic privilege-elevation mechanism behind passwd, sudo, and mount. Setgid on an executable does the same for the group; setgid on a directory instead makes new files inherit the directory’s group. The sticky bit on a directory enables restricted deletion — the /tmp model, where anyone may create files but only a file’s owner may remove it. These bits are simultaneously the most useful and the most dangerous corner of Unix discretionary access control: a setuid-root binary is, by construction, a deliberate hole in the “you are only your UID” wall, and a single bug in one becomes a local root exploit. This note traces exactly how execve() turns these bits into new credentials against the Linux v6.12 source (bprm_fill_uid() in fs/exec.c, cap_bprm_creds_from_file() in security/commoncap.c, GPL-2.0), and how file capabilities and [[no_new_privs and Privilege Escalation Control|no_new_privs]] shrink reliance on setuid-root.

Mental Model

The nine ordinary mode bits answer “who may read/write/execute this?”. The three special bits answer a different question: “what identity or rule applies when this file is used?”. That is why they are easy to misread — chmod 4755 looks like a permission, but the leading 4 is a behavior switch, not an access grant.

The cleanest way to hold all five cases in your head is a 2×3 grid: each of the three bits has a meaning on a regular/executable file and a (sometimes very different) meaning on a directory.

flowchart TB
  subgraph FILE["On a regular / executable file"]
    SU["setuid 04000<br/>run as file's OWNER (euid)<br/>e.g. /usr/bin/passwd → root"]
    SG["setgid 02000 + group-exec<br/>run as file's GROUP (egid)<br/>e.g. /usr/bin/wall → tty"]
    ST["sticky 01000<br/>historically: keep text in swap<br/>(no-op on modern Linux)"]
  end
  subgraph DIR["On a directory"]
    SUD["setuid 04000<br/>(ignored on Linux)"]
    SGD["setgid 02000<br/>new files inherit DIR's group;<br/>new subdirs also get setgid"]
    STD["sticky 01000<br/>RESTRICTED DELETION:<br/>only file owner / dir owner /<br/>privileged may unlink — the /tmp rule"]
  end

The five meanings of the three special bits, split by whether they sit on a file or a directory. What it shows: the same bit value (e.g. 02000) does one thing on an executable — change the running process’s group — and a completely unrelated thing on a directory — control group inheritance of newly created files. The insight to take: never reason about a special bit without first asking “file or directory?”; the bit pattern alone is ambiguous, and most setuid/setgid confusion comes from applying the executable meaning to a directory or vice versa.

How execve() computes the new euid/egid from the file bits

When a setuid binary is executed, the moment the identity changes is inside execve(), while the kernel is building the new program’s credentials from the linux_binprm (“binary parameters”) structure. The relevant function in v6.12 is bprm_fill_uid(), reached via bprm_creds_from_file() (fs/exec.c v6.12). It first short-circuits the common case:

	mode = READ_ONCE(inode->i_mode);
	if (!(mode & (S_ISUID|S_ISGID)))
		return;

If neither special bit is set on the executable’s inode, there is nothing to do and the new process simply keeps its inherited credentials. That covers the vast majority of programs.

If a special bit is present, two gates can still cancel the elevation before it happens:

	if (!mnt_may_suid(file->f_path.mnt))
		return;
 
	if (task_no_new_privs(current))
		return;

The first checks whether the filesystem the binary lives on permits setuid — a mount flagged nosuid (the MS_NOSUID mount option) makes mnt_may_suid() false, so setuid binaries on such mounts run with no elevation. This is why mounting /tmp, removable media, and untrusted filesystems nosuid is a standard hardening step: it neutralizes any setuid binary planted there. The second gate is no_new_privs: if the calling thread has that attribute set, the elevation is silently skipped (detailed below).

Past those gates, the actual identity change happens:

	if (mode & S_ISUID) {
		bprm->per_clear |= PER_CLEAR_ON_SETID;
		bprm->cred->euid = vfsuid_into_kuid(vfsuid);
	}
 
	if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) {
		bprm->per_clear |= PER_CLEAR_ON_SETID;
		bprm->cred->egid = vfsgid_into_kgid(vfsgid);
	}

This is the heart of the mechanism. For setuid: the new credential’s effective UID (bprm->cred->euid) is set to the file’s owner UID (vfsuid, derived from inode->i_uid). That is the entire magic of /usr/bin/passwd — owned by root, setuid, so any user running it gets euid == 0 for the duration. For setgid, note the compound condition: (mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP). Both the setgid bit and the group-execute bit must be set for the egid change to apply. S_ISGID without S_IXGRP is not a setgid executable at all — it is the mandatory-locking marker (the same carve-out described in File Ownership and the chown Model), so the kernel deliberately does not change the group for it. The PER_CLEAR_ON_SETID flag in per_clear causes certain process personality bits (like address-space randomization disables) to be reset on the setid transition, closing a class of “weaken the target’s environment then exploit it” attacks.

This matches the user-facing contract in execve(2): “If the set-user-ID bit is set on the program file referred to by path, then the effective user ID of the calling process is changed to that of the owner of the program file. Similarly, if the set-group-ID bit is set on the program file, then the effective group ID of the calling process is set to the group of the program file” (execve(2)).

What happens to the saved-set IDs

Crucially, after the euid/egid are set from the file, the kernel copies them into the saved set-user-ID and saved set-group-ID: “The effective user ID of the process is copied to the saved set-user-ID; similarly, the effective group ID is copied to the saved set-group-ID. This copying takes place after any effective ID changes that occur because of the set-user-ID and set-group-ID mode bits” (execve(2)). The saved-set IDs are what let a setuid program drop privilege temporarily and regain itseteuid(real) to run unprivileged, then seteuid(saved) to restore. The full real/effective/saved triad and the dropping discipline live in Real Effective and Saved User IDs; here the takeaway is that the saved slot is seeded from the file’s setuid identity at exec time, which is what makes safe privilege-bracketing possible.

The conditions under which setuid is honored vs ignored

The execve(2) manual enumerates exactly when the transformations do not happen — the bits are ignored if any of the following is true: “the no_new_privs attribute is set for the calling thread; the underlying filesystem is mounted nosuid (the MS_NOSUID flag for mount(2)); the calling process is being ptraced” (execve(2)). The ptrace exclusion exists because a debugger that could attach to a process and watch it gain privileges via setuid could trivially read the privileged process’s memory and registers — so the kernel refuses the elevation when the execer is traced (unless the tracer itself is sufficiently privileged). File capabilities are ignored under these same conditions.

The kernel ignores setuid/setgid on scripts

A point that surprises many: setuid bits on a shell script or other interpreted program have no effect. “Linux (like most other modern UNIX systems) ignores the set-user-ID and set-group-ID bits on scripts” (execve(2)). When you execve() a file beginning with #!/bin/sh, the kernel actually executes /bin/sh (the interpreter) with the script as an argument — and /bin/sh is an ordinary, non-setuid binary, so no elevation occurs. Even if the kernel did honor the script’s setuid bit, classic setuid-script race conditions (the interpreter re-opens the script by path after the kernel checked it, opening a TOCTOU window where an attacker swaps the file) made setuid scripts a notorious vulnerability class. The blanket “ignore setuid on scripts” rule eliminates the whole category. The practical consequence: to run a script with elevated privilege you must wrap it in a compiled setuid helper, or — far better — use sudo with a tightly scoped rule, or grant the interpreter invocation a narrow capability.

Why setuid-root is a major attack surface

A setuid-root binary runs attacker-supplied input through code that holds full root authority. Every such binary is a deliberate, audited hole in the privilege wall, and the wall is only as strong as the binary’s worst bug. The historical record is brutal: privilege-escalation CVEs in sudo (e.g. the 2021 “Baron Samedit” heap overflow, CVE-2021-3156), pkexec (CVE-2021-4034 “PwnKit”, an argv mishandling that gave instant root from any local account), and countless mount/ping/passwd bugs over the decades all share the same shape: a setuid-root program, fed crafted input or environment, executes attacker-controlled code as root. The attack surface is not just the obvious code path — it includes environment variables the program trusts, the dynamic linker’s behavior, signal handlers, and any library the binary loads.

The core problem is granularity: setuid-root is all of root or nothing. ping needs only the ability to open a raw socket, but a setuid-root ping holds the power to do literally anything — load kernel modules, rewrite /etc/shadow, reboot the machine — for the entire duration it runs. A single exploitable bug therefore yields full root, not “the ability to send one ICMP packet.”

How file capabilities and no_new_privs reduce reliance on setuid-root

The modern answer to setuid-root’s all-or-nothing problem is file capabilities (covered in depth in File Capabilities and Ambient Capabilities). Instead of marking ping setuid-root, you attach exactly the one capability it needs — setcap cap_net_raw+ep /usr/bin/ping — and run it as the invoking user. Now an exploited ping holds CAP_NET_RAW and nothing else; it cannot read /etc/shadow or load a module. This is the design rationale Linux capabilities were built for: “Linux divides the privileges traditionally associated with superuser into distinct units, known as capabilities, which can be independently enabled and disabled” (capabilities(7)).

The capability transformation at execve() follows a precise formula (capabilities(7)):

P'(permitted) = (P(inheritable) & F(inheritable)) |
                (F(permitted) & P(bounding)) | P'(ambient)
P'(effective) = F(effective) ? P'(permitted) : P'(ambient)

where P is the calling process’s capability set before exec, F is the file’s capability set, and P' is the new process’s set after exec. The F(permitted) & P(bounding) term is the one that replaces setuid-root: the file grants its permitted capabilities, intersected with the process’s bounding set (a ceiling that can never be exceeded). The mechanics of each set live in File Capabilities and Ambient Capabilities and Capability Transitions Across execve — the point here is that this gives exactly the privilege the binary needs, the antithesis of setuid-root’s blank check.

The kernel actively discourages mixing the two models. In cap_bprm_creds_from_file() (security/commoncap.c v6.12), a binary that is both setuid-root and carries file capabilities triggers a warning and the capabilities are not raised, with the kernel message “has both setuid-root and effective capabilities. Therefore not raising all capabilities.” — the two mechanisms are meant as alternatives, not a combination.

no_new_privs is the complementary control. Setting the PR_SET_NO_NEW_PRIVS prctl on a thread guarantees that “execve() will not grant the privilege to do anything that could not have been done without the execve call” — concretely, “the setuid and setgid bits will no longer change the uid or gid; file capabilities will not add to the permitted set” (no_new_privs kernel doc, v6.12). This is exactly the if (task_no_new_privs(current)) return; gate seen earlier in bprm_fill_uid(). The flag “is inherited across fork, clone, and execve and cannot be unset” — once on, it is on for the whole process tree forever. Its original purpose was to make unprivileged seccomp safe: a sandbox can install a syscall filter and know a child can never escape it by execing a setuid binary, because under no_new_privs that setuid binary gains nothing. The full treatment is in no_new_privs and Privilege Escalation Control. The corresponding piece in cap_bprm_creds_from_file() is the privilege-downgrade clause: “if (!ns_capable(…CAP_SETUID) || (bprmunsafe & LSM_UNSAFE_NO_NEW_PRIVS)) { neweuid = newuid; newegid = newgid; }” — under the unsafe/no-new-privs condition the effective IDs are forced back to the real IDs, cancelling any setuid elevation.

Configuration and Inspection

Inspecting and setting the bits in practice:

# Symbolic and octal both work; the leading digit is the special bits.
chmod u+s /path/to/binary    # set setuid  -> mode 4xxx
chmod g+s /path/to/binary    # set setgid  -> mode 2xxx
chmod +t  /shared/dir        # set sticky  -> mode 1xxx
chmod 4755 prog              # rwsr-xr-x : setuid, owner rwx, others r-x
 
# ls shows them in place of the execute bit:
ls -l /usr/bin/passwd        # -rwsr-xr-x  → the 's' in owner-exec slot = setuid
ls -l /usr/bin/wall          # -rwxr-sr-x  → 's' in group-exec slot = setgid
ls -ld /tmp                  # drwxrwxrwt  → trailing 't' = sticky bit
 
# Capital S/T means the special bit is set but the underlying execute bit is NOT:
#   -rwSr--r--  setuid set, owner-execute OFF (usually a misconfiguration)
#   drwxrwxr-T  sticky set, other-execute OFF

Reading ls -l: the special bits overload the execute-bit columns. A lowercase s/t means “special bit set and the execute bit is also set”; an uppercase S/T means “special bit set but execute bit is off” — which for a setuid file is almost always a mistake (a non-executable setuid file does nothing useful). The auditing workhorse for finding every setuid/setgid binary on a system is:

find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -printf '%M %u %p\n' 2>/dev/null

-perm -4000 matches files with at least the setuid bit set (the leading - means “all these bits set”); -o -perm -2000 adds setgid; -xdev keeps the scan on one filesystem. Security baselines (CIS benchmarks) expect this list to be short and every entry justified — an unexpected setuid-root binary is a red flag for a backdoor.

Failure Modes and Common Misunderstandings

“My setuid bit disappeared.” Writing to or chown-ing a setuid file (without CAP_FSETID) strips the bit — see File Ownership and the chown Model. A build pipeline that chmod u+s’s a binary and then chown’s it ends up with no setuid bit. Apply mode last, or use install -m 4755 -o root.

“setuid on my script does nothing.” Correct and by design — the kernel ignores setuid/setgid on interpreted scripts (above). Use sudo, a compiled wrapper, or capabilities instead.

Sticky bit confused with “stays in memory.” On executables, the sticky bit historically meant “keep the program text in swap for faster restart” — long obsolete and a no-op on modern Linux. The only live meaning is on directories: restricted deletion. “The sticky bit (S_ISVTX) on a directory means that a file in that directory can be renamed or deleted only by the owner of the file, by the owner of the directory, and by a privileged process” (inode(7)). This is why /tmp (mode 1777) is world-writable yet you cannot delete another user’s temp files — without it, any user could rm everyone’s /tmp files, a classic denial-of-service and a vector for symlink attacks.

setgid-directory vs setgid-executable mixed up. On a directory, g+s controls group inheritance of new files (and is the basis of shared project dirs); it has nothing to do with running anything as a group. On an executable it changes the running egid. Same bit, unrelated effects — see the mental-model grid.

nosuid mounts silently neutralize everything. A setuid binary on a nosuid-mounted filesystem runs with no elevation and no error — it just behaves as an ordinary program (and often fails with a permissions error deep inside). This is intended hardening, but it surprises admins who copy a setuid tool to a nosuid /home and find it “broken.”

Alternatives and When to Choose Them

For granting a specific kernel privilege to a program, file capabilities (File Capabilities and Ambient Capabilities) are strictly better than setuid-root: least privilege, smaller blast radius. For granting a particular user the ability to run a particular command as another user with logging and policy, sudo is the right tool — it is itself a (carefully audited) setuid-root binary, but it centralizes policy in /etc/sudoers and produces an audit trail. For sandboxing rather than elevating, seccomp and Landlock combined with no_new_privs go the other direction — shrinking what a process may do. The decision rule: need a narrow kernel power → file capability; need delegated admin with policy → sudo; need to run a script privileged → sudo or a compiled helper, never a setuid script.

Production Notes

The long-run industry trend is deprecating setuid binaries wherever capabilities or alternative designs allow. Fedora and other distributions have progressively shrunk their setuid-root footprint — moving ping from setuid-root to cap_net_raw, replacing setuid helpers with privileged D-Bus/systemd services that a confined client talks to over IPC. systemd services routinely set NoNewPrivileges=yes (which is exactly PR_SET_NO_NEW_PRIVS), RestrictSUIDSGID=yes (which makes creating setuid/setgid files fail), and mount /tmp and other paths nosuid via the sandboxing directives — a layered approach that assumes setuid is a liability to be contained. Container runtimes go further: the default behavior is to run with no_new_privs set and a dropped capability set, so a setuid binary inside a container image gains nothing, and --security-opt no-new-privileges makes that explicit. The recurring lesson from the pkexec/sudo CVE history is that every setuid-root binary is a standing liability; the engineering goal is to have as few as possible, each doing as little as possible, ideally replaced by a capability or a privilege-separated service.

Uncertain

Verify: the exact text of the kernel warning string in cap_bprm_creds_from_file() (“has both setuid-root and effective capabilities…”) and the precise new->euid = new->uid downgrade clause condition in v6.12. Reason: these were extracted from security/commoncap.c via a summarizing fetch rather than read line-by-line from the raw blob in full context. To resolve: read cap_bprm_creds_from_file() and handle_privileged_root() in security/commoncap.c v6.12 directly. uncertain

See Also