Real Effective and Saved User IDs
A Linux process does not carry one user identity but a triple: the real UID (
ruid— who you are), the effective UID (euid— what the kernel checks when you touch a resource), and the saved set-user-ID (suid— a parking slot that lets a privileged program drop its privilege and later pick it back up). The same triple exists for groups (rgid/egid/sgid). The split exists so a set-user-ID program — one launched with the file owner’s identity rather than the caller’s — can temporarily shed its power, do untrusted work as the unprivileged caller, and restore the power afterward, all while leaving an audit trail of who originally invoked it. Linux adds a fourth pair, the file-system UID/GID (fsuid/fsgid), which the Virtual File System actually consults for file access and which automatically tracks the effective ID (credentials.7). Getting the rules right — which calls move which IDs, what an unprivileged caller may set, and the precise order in which to drop privilege — is the difference between a daemon that is genuinely de-privileged and one that an attacker re-escalates with a singlesetuid(0).
Mental Model — Who You Are, What’s Checked, What’s Parked
Think of the three UIDs as three distinct questions:
- Real UID answers “who launched this process?” It is inherited across
fork()and preserved acrossexecve(). It is whatgetuid()returns. It changes rarely. - Effective UID answers “whose permissions apply right now?” The kernel checks
euid(reallyfsuid, which mirrors it — see below) for nearly every privileged operation. It is whatgeteuid()returns. A set-user-ID binary’s whole point is to makeeuid != ruidatexecve. - Saved set-user-ID answers “what privileged identity may I return to?” When a set-user-ID program starts, the kernel copies the just-set
euidintosuidso the program can flipeuiddown to the real user and back up to the saved value at will. It is a capability to re-acquire, not an active permission.
flowchart TB EXEC["execve() of a binary owned by root<br/>with the set-user-ID bit set"] EXEC --> SET["kernel sets:<br/>ruid = caller (e.g. 1000)<br/>euid = 0 (file owner)<br/>suid = 0 (saved copy of new euid)"] SET --> DROP["seteuid(1000):<br/>euid 0 to 1000<br/>(ruid=1000, suid=0 unchanged)"] DROP --> WORK["do untrusted work as 1000<br/>euid checks now deny root-only ops"] WORK --> RESTORE["seteuid(0):<br/>allowed because suid still = 0<br/>euid 1000 to 0"] RESTORE --> PERM["permanent drop:<br/>setresuid(1000,1000,1000)<br/>clears suid so 0 is unreachable"]
The lifecycle of a set-user-ID-root program’s identity. What it shows: execve of a setuid-root binary parks 0 in the saved UID; seteuid toggles the effective UID between the real user and the saved root value, drawing its right to do so from suid; a final setresuid to all-1000 erases the saved 0 and makes re-escalation impossible. The insight: the saved UID is the hinge — temporary drops keep root in suid and are reversible; permanent drops must overwrite suid too, or the privilege is still latently present.
The Effective ID Is What’s Checked — via fsuid
A point that trips people up: on Linux the kernel does not consult euid directly for file access — it consults fsuid. The file-system UID is a Linux-specific fourth identity “used to determine permissions for accessing files,” and the kernel keeps it glued to the effective UID: “Whenever a process’s effective user (group) ID is changed, the kernel also automatically changes the filesystem user (group) ID to the same value” (credentials.7). So in practice fsuid == euid at all times unless a program deliberately desynchronizes them with setfsuid(). You reason about euid; the VFS reads fsuid; they are equal because every euid change drags fsuid along.
The kernel makes this explicit in __sys_setuid(): after deciding the new value, it always executes new->fsuid = new->euid = kuid — effective and FS UID move together in a single assignment (sys.c, v6.12). The fsuid/fsgid fields live right next to the others in struct cred (cross-link Process Credentials and struct cred).
Why fsuid Exists at All
fsuid is a historical Linux wart. The Linux NFS server, running in userspace, needed to access files as whatever remote user was making a request — i.e. change the identity used for file checks — without changing its euid, because at the time changing euid would have made the server receivable to signals from that user, a denial-of-service hole. setfsuid() let it assume a user’s file-access identity while keeping its euid (and thus its signal-sending/receiving identity) put: “the Linux NFS server needed to change what user and group ID is used for file access without a corresponding change in the real and effective user and group IDs” (setfsuid.2).
That need evaporated: “Since Linux 2.0, signal permission handling is different … with the result that a process can change its effective user ID without being vulnerable to receiving signals from unwanted processes. Thus, setfsuid() is nowadays unneeded and should be avoided in new applications” (setfsuid.2). A second reason to avoid it: setfsuid() returns the previous fsuid on both success and failure, so “it is impossible to directly determine whether the call succeeded or failed” — there is no error return to check.
The Setting Calls and Their Exact Rules
There are four families that move the user-ID triple, ordered from least to most explicit. The group versions (setgid, setegid, setregid, setresgid) follow identical rules over the GID triple. Every rule below distinguishes a privileged caller — one holding CAP_SETUID (for UIDs) or CAP_SETGID (for GIDs) in its user namespace — from an unprivileged one.
setuid() — the blunt instrument
setuid() behaves differently by privilege. For a privileged caller, “if the process has the CAP_SETUID capability … the real UID and saved set-user-ID are also set” — i.e. all three of ruid/euid/suid (and fsuid) become the new value (setuid.2). For an unprivileged caller, only the effective UID changes, and the new value must equal either the current real UID or the saved set-user-ID — otherwise EPERM.
The asymmetry is the famous footgun: a set-user-ID-root program that calls setuid(unprivileged) sets all three IDs to the unprivileged value (because it is still privileged at the moment of the call), thereby permanently discarding root — “it is impossible for the program to regain root privileges” (setuid.2). That is correct for a permanent drop but disastrous if you wanted a temporary one. For temporary drops, “seteuid(2) should be used instead.”
seteuid() — temporary, reversible
seteuid() changes only the effective UID. An unprivileged caller “may only change its effective UID to its real UID, effective UID, or saved set-user-ID”; a privileged caller may set it to any value (seteuid.2). Crucially it leaves ruid and suid untouched: “Under glibc 2.1 and later, it is equivalent to setresuid(-1, euid, -1) and hence does not change the saved set-user-ID” (seteuid.2). That untouched suid is exactly what lets a setuid-root program toggle euid down to the user and back up to root repeatedly — the saved 0 remains a valid target.
setreuid() — BSD-style, with a subtle saved-UID side effect
setreuid(ruid, euid) sets real and effective UID together (a -1 leaves a field unchanged). Unprivileged rules: “Unprivileged processes may only set the effective user ID to the real user ID, the effective user ID, or the saved set-user-ID,” and “Unprivileged users may only set the real user ID to the real user ID or the effective user ID” (setreuid.2). Its trap is an implicit saved-UID update: “If the real user ID is set (i.e., ruid is not -1) or the effective user ID is set to a value not equal to the previous real user ID, the saved set-user-ID will be set to the new effective user ID” (setreuid.2). So setreuid() can silently overwrite suid as a side effect of changing euid — which is why it is hard to reason about and why setresuid() is preferred.
setresuid() — the cleanest, most explicit
setresuid(ruid, euid, suid) sets all three independently, with -1 for “leave unchanged.” The rule is the simplest of the lot. Unprivileged: each of the three may be set to “one of: the current real UID, the current effective UID, or the current saved set-user-ID.” Privileged (CAP_SETUID): “may set its real UID, effective UID, and saved set-user-ID to arbitrary values” (setresuid.2). And as with all the others, “the filesystem UID is always set to the same value as the (possibly new) effective UID.” Because it names all three explicitly and has no hidden side effects, setresuid() is the recommended call for both temporary and permanent privilege changes.
In the kernel, __sys_setresuid() aggregates the permission check: it works out which of the three requested values is “new” (matches none of the current ruid/euid/suid), and if any of them is new it requires CAP_SETUID for the whole call; only then does it assign each non--1 field and call commit_creds() (sys.c).
Dropping Privileges Correctly — the Classic Pitfalls
De-privileging a root daemon is deceptively easy to get wrong; the failure mode is a process that looks unprivileged but can be re-escalated. The correct sequence, and the reasons behind it:
/* Drop from root to user `uid`/`gid`, permanently. Check EVERY return. */
if (setgroups(0, NULL) != 0) /* 1. drop supplementary groups FIRST */
fatal("setgroups");
if (setgid(gid) != 0) /* 2. drop GID before UID */
fatal("setgid");
if (setuid(uid) != 0) /* 3. drop UID last */
fatal("setuid");
/* 4. VERIFY: prove the privilege is actually gone */
if (setuid(0) != -1) /* must FAIL now */
fatal("regained root!");
if (getuid() != uid || geteuid() != uid)
fatal("uid not fully dropped");Order matters: gid before uid. Changing the GID requires CAP_SETGID. After you drop the UID to a non-root user you generally lose the capability to change the GID — so if you call setuid() first, the subsequent setgid() fails with EPERM and your process keeps root’s group memberships. The setgid manual gives the unprivileged rule that makes this concrete: an unprivileged process can only set its effective GID to “values matching either the real GID or saved set-group-ID” (setgid.2). Always drop groups, then GID, then UID — privileges are shed in the order that each step still has the right to perform the next.
Drop the supplementary groups. setuid/setgid do not touch the supplementary group list in group_info. A daemon that started as root inherited root’s supplementary groups (often including powerful groups), and they survive the UID/GID drop. You must explicitly call setgroups(0, NULL) (which needs CAP_SETGID) to clear them (setgroups.2) — or initgroups() to set them to exactly the target user’s groups. Forgetting this leaves the “de-privileged” process with access to files group-readable by, say, shadow or docker.
Use the all-three call and overwrite suid. For a permanent drop, prefer setresgid(gid,gid,gid) then setresuid(uid,uid,uid). Setting the saved ID equal to the real/effective ID is what makes re-escalation impossible — if suid still held 0, an unprivileged setuid(0) would succeed (recall: an unprivileged caller may set euid to its saved UID). The whole danger of a sloppy temporary-style drop is that suid keeps the privileged value alive.
Check every return value. “There are cases where setuid() can fail even when the caller is UID 0; it is a grave security error to omit checking for a failure return” (setuid.2) — the identical warning appears for seteuid() and setresuid(). The canonical failure is EAGAIN: dropping to a UID that is already at its RLIMIT_NPROC process limit makes setuid() fail, leaving you still root if you didn’t check. In the kernel this is set_user() returning -EAGAIN when flag_nproc_exceeded()/the per-user process count would be exceeded (sys.c). A program that ignores the error proceeds believing it dropped privilege when it did not — the exact shape of CVE-class privilege-retention bugs.
Verify, don’t assume. After dropping, prove it: call setuid(0) and assert it fails, and read back getuid()/geteuid()/getgid()/getegid() (and getgroups()) to confirm. This belt-and-suspenders check catches mistakes the return-value checks miss (e.g. a forgotten supplementary group). This is the central recommendation of the well-known “Setuid Demystified” analysis of cross-OS setuid semantics.
Uncertain
Verify: the specific “Setuid Demystified” (Chen, Wagner, Dean — USENIX Security 2002) recommendations on drop ordering and verification. Reason: the primary PDF (usenix.org) returned HTTP 403 during this research and could not be read directly; the ordering/verification guidance above is corroborated from the Linux man pages (
setuid.2,setgid.2,setgroups.2) which are primary, but the paper’s exact wording was not consulted. To resolve: fetch the paper from an accessible mirror and confirm its phrasing. uncertain
Failure Modes and Common Misunderstandings
“I called setuid(), so I’m de-privileged.” Only if you were privileged and it succeeded and you also dropped GID and supplementary groups first. An unprivileged-style setuid() (e.g. via seteuid) leaves suid holding the old privileged value, so the drop is reversible.
Mixing setreuid and setresuid. setreuid()’s hidden saved-UID update means a program that drops with setreuid() and assumes suid is untouched can be surprised. Pick setresuid() and state all three explicitly.
Assuming euid alone gates file access. It is fsuid (which mirrors euid) that the VFS checks. Code that calls setfsuid() to desync them — almost always a mistake in new code — can produce a process whose euid says one thing and whose file access says another, with no error return to flag a failed setfsuid().
Threads and credentials. The kernel tracks UIDs per-thread, but POSIX requires them to be process-wide. glibc’s NPTL wrappers “use a signal-based technique to ensure that when one thread changes credentials, all of the other threads in the process also change their credentials” (setresuid.2). Calling the raw syscall(SYS_setresuid, ...) directly bypasses this and changes only the calling thread’s IDs — a subtle way to end up with a multi-threaded process whose threads disagree about who they are.
Set-Group-ID, Saved Set-Group-ID, and the GID Triple
Everything above mirrors for groups. setgid() for a privileged caller sets real, effective, and saved set-group-ID together; for an unprivileged caller it sets only the effective GID, and only to the real GID or saved set-group-ID, else EPERM (setgid.2). setresgid()/setregid()/setegid() parallel their UID counterparts. The set-group-ID bit on a binary is what makes egid differ from rgid at execve — see setuid setgid and the Sticky Bit for how the file-mode bits trigger these ID changes in the first place.
Capabilities Are the Modern Alternative
The entire ruid/euid/suid dance exists because historically the only way to grant a program a single privilege was to make it setuid-root — handing it all of root’s power and trusting it to drop most of it correctly. POSIX Capabilities are the modern, finer-grained answer: a binary can be granted just CAP_NET_BIND_SERVICE (to bind a low port) via file capabilities without ever being UID 0, so there is no all-powerful identity to drop in the first place. Where you still run a daemon as root and de-privilege it, do both — drop to a non-root UID and retain only the specific capabilities needed (cross-link File Capabilities and Ambient Capabilities). The UID triple remains the substrate; capabilities ride on top of the same struct cred.
See Also
- Process Credentials and struct cred — the kernel object that holds this triple plus fsuid/fsgid, capabilities, and the LSM blob
- setuid setgid and the Sticky Bit — the file-mode bits that set
euid/egidatexecve - POSIX Capabilities — the fine-grained alternative to the setuid-root model
- File Capabilities and Ambient Capabilities — granting a single privilege without UID 0
- Discretionary Access Control — the file-permission check that consumes
fsuid/fsgidand the supplementary groups - no_new_privs and Privilege Escalation Control — the switch that prevents a child from re-gaining privilege via setuid binaries
- User Namespaces — how these IDs are remapped per namespace
- Linux Security MOC — parent map