skip to content

You run `chmod 4755` on a root-owned shell script, and it still executes with the caller's own privileges rather than root's. Why does Linux ignore the set-user-ID bit here, when it honours it on a compiled binary?

level: middleimportance: nice to knowfreq 34%

answer

  1. what does the kernel actually exec here
  2. two files, one process image
  3. the interpreter is what gets loaded
  4. the bit shows up but does nothing
  5. environment and a swap window

basics

~20 s

Linux deliberately ignores set-user-ID and set-group-ID bits on interpreted scripts. The kernel applies them only when exec'ing a real executable image, so the interpreter named on the #! line starts unprivileged and the script runs as the caller.

solid answer

~50 s

When you exec a file starting with `#!`, the kernel does not run the script — it runs the **interpreter** named on that line, handing it the script path as an argument. Honouring the script's set-user-ID bit would mean starting an interpreter with root's effective UID while the caller still controls its environment, its arguments and, historically, a race window in which the path could be swapped between the kernel reading the `#!` line and the interpreter opening the file. Rather than try to make that safe, Linux simply ignores set-user-ID and set-group-ID on scripts; the bits remain visible in `ls -l`, which is why people think the change worked. The bits only take effect when the kernel loads an actual executable image. If a script genuinely needs privilege, the answer is a small audited compiled wrapper, or a privileged service the unprivileged script asks to act on its behalf — not the mode bit.

code

bash · 4 lines
bash
printf '#!/bin/sh\nid -u\n' > /tmp/whoami.sh
chmod 4755 /tmp/whoami.sh
ls -l /tmp/whoami.sh
/tmp/whoami.sh

go deeper

for a junior

Know that setting the setuid bit on a shell script does nothing on Linux, and that the mode still shows in ls -l even though the script gains no privilege.

for a middle

Explain the mechanism: for a #! file the kernel execs the interpreter and passes the script as an argument, so the credential change would have to apply to a generic interpreter the caller can influence.

for a senior

Articulate why the refusal is correct — caller-controlled environment plus the re-open race between the kernel reading #! and the interpreter opening the path — and steer the design toward a narrow privileged service rather than a wrapper that shells out.

for a principal

Treat requests for privileged scripts as an interface-design question: define the narrow operations that may be performed with privilege, put them behind reviewed code that validates the requester, and keep general-purpose interpreters out of the privileged path.

## What exec really does with a `#!` file The `#!` line is a kernel feature, not a shell one. When `execve()` is given a file whose first two bytes are `#!`, the kernel reads the interpreter path from that line, and then **executes the interpreter**, passing it (optionally one literal argument from the `#!` line, then) the original file's path. Your `argv[0]` handling and the script name arrive as ordinary arguments. So two files are involved: the script whose bit you set, and the interpreter that actually becomes the process image. The credential change on exec is decided by the file the kernel loads as the process image. Since that file is the interpreter — an ordinary, non-privileged executable — no privilege transition occurs. ``` $ printf '#!/bin/sh\nid -u\n' > /tmp/whoami.sh $ sudo chown root /tmp/whoami.sh $ sudo chmod 4755 /tmp/whoami.sh $ ls -l /tmp/whoami.sh # -rwsr-xr-x, the bit is clearly there $ /tmp/whoami.sh # still prints your own UID ``` The bit is visible, which is exactly why this trips people up. Nothing warns you; the script just quietly does not gain privilege. ## Why the rule exists This is a deliberate refusal, not an oversight. Running interpreters with borrowed privilege is genuinely hard to do safely: - **The interpreter obeys the caller's environment.** A shell reads variables that change how it resolves commands and files; other interpreters read variables that add module search paths or preload code. A privileged interpreter under the caller's environment is a privileged program the caller partly configures. - **There is a race between the kernel's read and the interpreter's open.** The kernel opens the script to read the `#!` line, then execs the interpreter, which opens the script *by path* a second time. On a directory the caller can write, the path can be pointed at different content in between — a privileged interpreter would then execute the substitute. Some Unixes added a special file-descriptor mechanism to close this; Linux chose not to honour the bits at all. - **The interpreter is generic.** A compiled setuid program can drop privilege for most of its work; a general-purpose interpreter has no idea which lines of the script need root and will happily run whatever the file contains. So Linux ignores set-user-ID and set-group-ID on scripts. Some other Unix systems permit it, sometimes behind a mount or kernel option — one more reason not to assume a shell script's privilege behaviour ports between platforms. ## What people do instead **A small compiled wrapper.** A short C program that is itself set-user-ID root, hard-codes the absolute path of the script or command it may run, sanitises the environment rather than inheriting it, and validates its arguments before doing anything. The privilege lives in a tiny, reviewable file. Written carelessly — inheriting the environment, taking the target path from an argument, launching a command by bare name — the wrapper is simply a slower way to hand out root. **Ask a privileged service instead.** The unprivileged script sends a request over a socket or a queue to a long-running privileged component that performs a narrow, well-defined operation and validates it against the requester. This inverts the trust: the privileged code decides what is allowed instead of trusting whoever invoked it. **Grant the narrow power rather than the identity.** Where the task needs one specific ability rather than root wholesale, granting exactly that is far better than an identity swap — the general principle being that set-user-ID is all-or-nothing and almost always over-grants. ## Two related gotchas worth knowing - **`chmod 4755` on a script is not harmless just because it does nothing.** It leaves a file that *looks* privileged, which will confuse the next reviewer and will trip a security scanner. Clear it with `chmod u-s`. - **The bit is equally inert if the execute bit is missing.** `ls` then shows `S` instead of `s` — a set-user-ID file that cannot be executed at all. - **A compiled binary that merely *calls* a shell reintroduces every problem.** Setting the bit on a compiled program that shells out to run your script gets you the interpreted-privilege problem back, with the wrapper providing only the illusion of a boundary. The crisp answer in an interview: the kernel applies the bits to the image it loads, the image it loads for a `#!` file is the interpreter, and Linux refuses the transition because a privileged interpreter under the caller's control is not defensible.

  • Can you get around it by writing a small setuid C wrapper that runs the script?
    You can, but you inherit the whole problem unless the wrapper is careful. It must hard-code the absolute path of what it runs, build a clean environment rather than passing the caller's through, refuse to take the target from an argument, and validate what it is being asked to do. Otherwise it is just a slower way to hand out root.
  • Do other Unix systems behave the same way?
    Not uniformly. Some allow set-user-ID scripts, occasionally behind a kernel or mount option, and some address the re-open race by passing the interpreter an already-open file descriptor instead of a path. Linux simply ignores the bits. Never assume a script's privilege behaviour ports between platforms — verify it on the target system.
  • If the bit does nothing on a script, is leaving it set harmless?
    Not really. The file looks privileged to anyone reading `ls -l`, so it misleads reviewers, and security tooling that inventories set-user-ID files will flag it. It also suggests someone intended privilege and believed they had it, which is worth investigating. Clear it with `chmod u-s` and solve the underlying need properly.

saying these in an interview costs you the question

  • Assumes the script simply needs an execute bit added.
  • Says Linux honours setuid on scripts if the shell supports it.
  • Suggests wrapping the script in a setuid binary that shells out.
  • Thinks the bit failed to apply because chmod did not take effect.
  • Cannot say which file the kernel actually loads for a #! file.

context