A setuid binary runs with the identity of its owner rather than of whoever started it. The kernel swaps credentials at exec time, which is how passwd rewrites /etc/shadow without an ordinary user holding write permission on it. The bit belongs to the file, and it grants the owner’s entire identity, not one narrow ability.
That is an odd trade to make on purpose: a whole program is trusted with an entire account, so any bug inside it is a privilege bug. Capabilities, user namespaces and socket activation now cover most of what setuid was for.
Identity follows the file
execve(2) states the rule directly: if the set-user-ID bit is set on the program file, the effective user ID of the calling process becomes that of the file’s owner. The real UID and real GID are unchanged, and the new effective UID is copied into the saved set-user-ID. Real, effective and saved are the whole mechanism.
The bits are visible before anything runs: stat reports /usr/bin/sudo as mode 4511 (-r-s--x--x) and /usr/bin/su as 4755, both owned by uid 0, while /usr/bin/passwd is plain 0755. Which binaries carry the bit is a per-host fact, so read it rather than assume it.
Four ways the bit does nothing
execve(2) lists the conditions under which the transition is skipped: no_new_privs is set on the calling thread, the filesystem is mounted nosuid, or the process is being ptraced. For a nosuid mount the man page warns that behaviour varies by kernel version — some refuse the exec with EPERM, others ignore the bits and run the file anyway. File capabilities are ignored under those same conditions.
Scripts are the fourth case: “Linux (like most other modern UNIX systems) ignores the set-user-ID and set-group-ID bits on scripts.” A shell script can list as -rwsr-xr-x forever and elevate nothing, because #! hands control to the interpreter and only the interpreter’s own bits count. On the macOS host here, chmod 4755 as owner returned EPERM while chmod 1755 set the sticky bit.
Dropping privilege is not symmetric
The setuid(2) syscall is not the file bit. A privileged caller holding CAP_SETUID moves real, effective and saved together, so a set-user-ID-root program calling setuid(1000) ends with all three at 1000 — and regaining root then fails with EPERM, because 0 is no longer the real UID or the saved set-user-ID. The man page says such a program must use seteuid(2) instead.
seteuid(2) moves the effective UID alone, and an unprivileged caller may only target the real UID, the effective UID, or the saved set-user-ID. The saved UID is therefore a deliberate escape hatch: the same process can drop to 1000 with seteuid and climb back to 0 afterwards. Permanent drops need setuid(2); reversible ones need seteuid(2).
The narrower replacements
Capabilities split one identity into about forty named powers. setcap cap_net_bind_service=+ep /usr/bin/webserver lets that binary bind port 80 with no setuid bit and no ability to read /etc/shadow. capabilities(7) notes that exec of a set-user-ID-root program still results in the full capability set being treated as enabled — exactly the blast radius the narrower grant avoids. User namespaces map uid 0 to the caller inside a namespace and grant nothing outside it, and socket activation keeps the listener owned by PID 1 while the service runs unprivileged.
When it breaks
The classic failure is the setuid script: someone rewrites a helper in shell or Python, keeps the mode, and the tool silently loses the privilege it needed. The listing still shows s, an audit still flags it, and nothing elevates. Prove elevation with id -u printed from inside the running program, never from the mode string.
The dangerous failure is a shell escape inside a setuid binary. The C library scrubs LD_PRELOAD and its relatives, but application-level inputs stay yours to sanitise: an unscrubbed PATH turns system("thumbnail ...") into an attacker’s binary, and a user-controlled config path redirects file reads. Because the whole process carries the owner’s identity, one such bug is a root hole. Keep the list short and give each binary an owner that owns nothing else of value.
Check it quickly
Run these on the host you are auditing:
find / -xdev -perm -4000 -type f -printf '%m %u %p\n' 2>/dev/null
findmnt -no TARGET,OPTIONS / /home /tmp /var | grep -o nosuid
getcap -r /usr/bin /usr/sbin 2>/dev/null
grep -E 'NoNewPrivs|CapEff' /proc/self/status
stat -c '%A %a %U %n' /usr/bin/passwd
The first command should return a short list; on a stock Debian host it is roughly a dozen paths, and every one should be a program you would trust with its owner’s whole identity. A nosuid on a filesystem holding a setuid binary explains a privilege that vanished, and anything in the getcap output has already migrated off setuid.
Sources
- man7: execve, the set-user-ID transition at exec and the four conditions that skip it, checked 2026-09-16.
- man7: setuid, CAP_SETUID, the saved set-user-ID, and why seteuid is the reversible path, checked 2026-09-16.
- man7: capabilities, narrow capability grants as the replacement for a setuid-root binary, checked 2026-09-16.