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):

$ 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 (execveld-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

Practice

  1. A file's ls -l output begins with -rw-r--r--. What is the octal representation of its permission bits?
  2. Which command sequence correctly creates a Set-UID root program from a compiled binary named prog?
  3. User seed (UID 1000) runs a root-owned Set-UID program. What are the Real UID (RUID) and Effective UID (EUID) of the resulting process?
  4. A root-owned Set-UID program opens /etc/shadow (writable only by root), then calls setuid(getuid()) to drop its privilege, and then calls execve("/bin/sh", ...). The file descriptor for /etc/shadow is never closed. What security problem does this illustrate?
  5. A root-owned Set-UID program calls system("cal"). An attacker sets PATH=.:$PATH and places a malicious executable named cal in the current directory. What happens?
  6. Why does the Linux dynamic linker ignore LD_PRELOAD when a Set-UID program is executed?
  7. A shell session has umask 0022. A user creates a new regular file with touch newfile. What permissions does newfile have?
  8. Explain why execve("/bin/cat", args, env) is safer than system("/bin/cat " + filename) when used inside a Set-UID root program, even if the filename is user-supplied.
  9. A Set-UID root program correctly calls setuid(getuid()) to permanently drop privilege before spawning a shell. However, the shell can still write to /etc/zzz, a file that is only writable by root. Walk through why this is possible and describe the fix.