Processes & Privileges

Process Model

Processes, credentials, and how privileges pass from parent to child.

Before you can confine a process you have to know what a process’s identity actually is. On Linux that identity is a small set of numeric credentials the kernel checks on every privileged operation. Getting these credentials wrong is the root cause of a large share of local privilege escalation bugs: a daemon that forgets to drop its saved UID, a setuid helper that trusts the environment it inherited, a worker that keeps root’s supplementary groups. This page pins down exactly which credentials exist, how fork and exec pass them along, and how to read them straight from the kernel.

The credential triples

Every process carries three copies of its user identity and three of its group identity. The reason there are three is historical but the security model depends on the distinction.

CredentialPurpose
Real UID (ruid)Who owns the process; who may signal it.
Effective UID (euid)The identity used for permission checks right now.
Saved UID (suid)A stash the effective UID can be restored from.
Filesystem UID (fsuid)Used only for filesystem access checks; normally tracks euid.

A setuid program is the classic case. When you run passwd, it starts with your real UID but an effective UID of 0 (because the binary is owned by root with the setuid bit). It uses root privilege briefly to edit /etc/shadow, then can lower its effective UID back. The saved UID is what lets it flip between the two. A poorly written setuid program that drops euid but leaves suid at 0 can be tricked into restoring root, which is why the drop order and the choice of setresuid() over seteuid() matters.

The same three way split exists for groups, plus a supplementary group list. Forgetting to call setgroups() when dropping privilege is a common bug: the process lowers its UID but keeps root’s group memberships and can still read group-restricted files.

fork, exec, and inheritance

Two system calls create the process tree. fork() (today usually clone()) makes a near-identical copy of the calling process. The child inherits the parent’s UID/GID triples, capability sets, open file descriptors, namespaces, cgroup, and any seccomp filter. execve() then replaces the program image while keeping the process ID and most credentials.

The security-relevant rules at exec time are:

  • Credentials normally carry over unchanged, so a root process that execs a shell hands that shell root.
  • If the target binary is setuid or setgid, the effective (and saved) IDs are raised to the file owner at exec.
  • If NoNewPrivs is set on the process, setuid bits and file capabilities are ignored: exec can never grant more privilege than the caller already had. This one flag closes off an entire class of escalation and is why systemd’s NoNewPrivileges=yes is such a cheap, powerful hardening setting.

Because inheritance is the default, a hardened service does its privilege dropping in the narrow window between fork and exec, or relies on the init system to set the credentials before the daemon ever runs. The Linux Capabilities page covers the capability half of this inheritance in detail, and the Seccomp page covers how a filter installed in the parent binds the child.

Hands-on: read a process’s credentials

The kernel publishes every credential through /proc/<pid>/status. Start a shell whose identity you control and inspect it.

# Run a sleep as a specific user and inspect its credentials
sudo -u www-data sleep 300 &
pid=$!
grep -E '^(Uid|Gid|Groups):' /proc/"$pid"/status

Expected output:

Uid:	33	33	33	33
Gid:	33	33	33	33
Groups:	33

The four columns are real, effective, saved, and filesystem IDs. Here they are all 33 (www-data), meaning no privilege juggling is going on. Contrast that with a setuid binary caught mid-operation, where the effective column would read 0 while the real column stays at your login UID.

You can also watch inheritance directly. ps with a custom format shows the parent/child relationship and the identities side by side.

ps -eo pid,ppid,euser,ruser,comm --sort=ppid | grep -A2 sshd | head -5

Expected output:

    PID    PPID EUSER    RUSER    COMMAND
    812       1 root     root     sshd
   3140     812 root     root     sshd
   3148    3140 adi      adi      sshd

That chain is a login in progress: the privileged listener (PID 812) forks a per-connection child, which after authentication execs a session process that has dropped from root to the logging-in user adi. The EUSER/RUSER columns making the transition are the process model in action.

Practical Guidance

  1. When dropping privilege in your own code, use setresgid() and setgroups() before setresuid(), then verify with getresuid(). Dropping the UID first can leave you unable to drop the groups.
  2. Set NoNewPrivileges=yes on every systemd service that does not legitimately need setuid helpers. It neutralises setuid escalation at almost zero cost.
  3. Never assume a lowered effective UID means privilege is gone. Check the saved UID too; /proc/<pid>/status shows all four columns.
  4. Prefer having the init system set the final UID/GID (systemd User=, Group=, SupplementaryGroups=) over dropping privilege inside the daemon. Less code between root and the drop means fewer places to get it wrong.
  5. When triaging a suspicious process, read its /proc/<pid>/status first: mismatched real and effective UIDs, or root supplementary groups on a service account, are immediate red flags.