Linux Permissions, Set-UID & Environment Variables
Every privilege-escalation attack ultimately relies on one question: what can this process do that the attacker's own account cannot? The answer almost always flows back to Linux's permission model and the Set-UID mechanism that deliberately punches a controlled hole in it. Understanding both is essential before you can exploit — or defend — a Set-UID root binary.
Users, Groups, and Identity
Linux assigns every user a numeric User ID (UID) stored in /etc/passwd. Root is always UID 0. Every process also carries a Group ID (GID); a user can belong to multiple groups (check with id or groups).
root:x:0:0:root:/root:/bin/bash
seed:x:1000:1000:SEED,,,:/home/seed:/bin/bash
The third and fourth colon-separated fields are UID and primary GID respectively. Secondary group memberships live in /etc/group. usermod -a -G groupname username appends a user to a group without removing existing memberships.
File Permission Bits
Every file has three permission triplets — user (owner), group, other — each encoding read (r=4), write (w=2), and execute (x=1):
-rw-r--r-- 1 root root 2.1K Aug 22 16:37 /etc/passwd
└──┘└──┘└──┘
u g o octal: 6 4 4
| Octal | Symbolic | Meaning |
|---|---|---|
| 7 | rwx |
read + write + execute |
| 6 | rw- |
read + write |
| 5 | r-x |
read + execute |
| 4 | r-- |
read only |
| 0 | --- |
no access |
chmod changes permissions; chown changes ownership:
chmod u+x prog # add execute for owner (symbolic)
sudo chmod 4755 prog # set permissions + Set-UID bit (octal)
sudo chown root prog # transfer ownership to root
umask masks permissions off newly created files. With umask 0022, a file that would default to 0666 (rw-rw-rw-) ends up as 0644 (rw-r--r--) — the umask bits are cleared, not set.
Access Control Lists (ACLs) extend the basic model to grant or deny individual named users or groups without changing the owner/group fields. setfacl -m u:alice:r-- file grants alice read-only access; getfacl file shows all ACL entries. When ACLs are present, ls -l shows a trailing + on the permission string.
The Set-UID Mechanism
The password dilemma
/etc/shadow (hashed passwords) is owned by root and writable only by root. Yet ordinary users must be able to change their own passwords. How?
The answer is the Set-UID bit: when set on an executable, the kernel runs that program with the file owner's effective privilege, not the caller's. /usr/bin/passwd is owned by root and has the Set-UID bit set (-rws r-xr-x), so any user who runs it temporarily becomes root for the duration of that program, enabling it to write /etc/shadow.
Real UID vs. Effective UID
Every process carries two user identifiers:
| Name | Purpose |
|---|---|
| Real UID (RUID) | Who actually launched the process |
| Effective UID (EUID) | What privilege the kernel enforces for access checks |
For a normal program, RUID == EUID == the caller's UID.
For a Set-UID root program launched by user seed (UID 1000):
- RUID = 1000 (seed)
- EUID = 0 (root) — all access checks pass as root
$ sudo chmod 4755 myid # set Set-UID bit; owner is root
$ ./myid
uid=1000(seed) gid=1000(seed) euid=0(root) ...
A program becomes Set-UID by combining chown root prog with chmod 4755 prog (the leading 4 sets the Set-UID bit). The s in the permission string (-rwsr-xr-x) confirms it.
Capability leaking
A Set-UID program that opens a privileged resource and then downgrades its privilege (via setuid(getuid())) must close any open file descriptors to that resource first. If it forgets, the low-privilege shell it later spawns can still write through those open descriptors — a capability leak. The OS X Yosemite (10.10) privilege escalation of July 2015 exploited exactly this flaw in the dyld dynamic linker.
Principle of least privilege
A well-written privileged program should acquire privilege only when needed and discard it immediately afterward — either temporarily (seteuid()) or permanently (setuid(getuid())). Never hold more privilege than the current task requires.
Environment Variables
Environment variables are named string values inherited by child processes. They affect program behavior without modifying the binary itself. In C, they are accessible as the third argument to main (char* envp[]) or via the global extern char** environ.
execve() lets the caller explicitly pass an environment to the new process; if you pass NULL, the child gets no environment at all.
The attack surface
Because users control their own environment, every environment variable is attacker-supplied input from the perspective of a privileged program. The attack surface has two main branches:
1. Dynamic linker variables
The runtime linker (ld-linux.so) reads several environment variables before main() is called:
| Variable | Effect |
|---|---|
LD_PRELOAD |
Load these shared libraries first, overriding standard symbols |
LD_LIBRARY_PATH |
Additional directories to search for shared libraries |
An attacker who sets LD_PRELOAD=./libevil.so can replace any libc function (sleep, printf, …) with their own implementation. For a normal program this is just surprising; for a Set-UID root program it would be catastrophic — so the dynamic linker ignores LD_PRELOAD and LD_LIBRARY_PATH when EUID ≠ RUID. This is a built-in countermeasure.
2. The PATH variable and system()
system("cal") forks a shell and asks it to run cal. Because no absolute path is given, the shell searches PATH directories in order. If an attacker prepends . to PATH:
export PATH=.:$PATH
and places a malicious executable named cal in the current directory, the shell runs the attacker's cal instead. If the caller is a Set-UID root program, the attacker's code runs as root — a root shell in one step.
The fix is to call execve() directly with a hard-coded absolute path for the command. execve() separates the command name (always supplied by the program) from user-controlled data (the argument array), so there is no way for data to become code. system() mixes both into a shell command string, which is why it is unsafe in privileged programs.
Compile → Link → Load
Understanding when the dynamic linker runs explains the attack window:
prog.c →(cpp)→ prog.i →(cc)→ prog.s →(as)→ prog.o →(ld)→ prog →(loader/execve)→ running process
At load time (execve → ld-linux.so), the dynamic linker resolves shared-library symbols using LD_PRELOAD and LD_LIBRARY_PATH. Static linking bakes the library code directly into the binary at compile time, avoiding this runtime dependency — but produces binaries ~100× larger and is rarely used for system programs.
Set-UID vs. service approach
Android completely removed Set-UID/Set-GID because environment variables are fundamentally untrusted in that model. The alternative service approach keeps a permanently running privileged daemon; unprivileged processes send it requests over a socket. The daemon controls its own environment and is never exposed to the caller's environment variables — a narrower attack surface.
Key takeaways
- Linux permissions use nine bits across user / group / other, each encoding rwx; octal shorthand maps directly (e.g.,
755=rwxr-xr-x). umaskclears bits from new files;chmod/chownchange them after creation.- The Set-UID bit (
chmod 4755) makes a program run with the file owner's EUID, not the caller's; kernel access checks use EUID, not RUID. - Set-UID root programs are high-value targets: any bug that lets an attacker control the program's behavior (overflow, environment manipulation, code/data confusion) can yield a root shell.
- Capability leaking happens when a privileged program drops its UID but neglects to close privileged file descriptors first.
LD_PRELOAD/LD_LIBRARY_PATHlet attackers hijack shared-library calls; the dynamic linker ignores these variables when EUID ≠ RUID as a countermeasure.system()is dangerous in privileged code because an attacker can manipulatePATHto redirect unqualified command names; preferexecve()with absolute paths.- The principle of isolation (never mix code and data) and the principle of least privilege (hold privilege only as long as needed) are the two security principles most directly at stake in this topic.