The earliest computers did not have access control mechanisms, because they had nothing to protect. One program ran at a time, loaded from cards or tape, and when it finished the next user took the machine. Disks changed that. A file on a disk stays there after its owner leaves, where the next user can read it or write over it. Time-sharing in the 1960s put several users on one machine at the same time, each with files the others could reach. MIT’s Compatible Time-Sharing System, first demonstrated in 1961, was among the first, and every one of its users needed assurance that the others could not read or alter their files.
The model those systems settled on is discretionary access control: the user who creates a file owns it, and the owner decides who else may use it. The operating system enforces the owner’s decision.
Multics, the system that MIT, Bell Labs, and General Electric began building in 1965, was the first operating system to attach an access control list to every file: a list of the users and projects allowed to use it, and what each was allowed to do. Unix, begun at Bell Labs in 1969 by researchers who had worked on Multics and wanted a far simpler and lighter-weight system, kept the idea and reduced the list to a fixed form that fits in a few bits.
What follows describes how Unix stores and checks those decisions, how a process acquires an identity, what happens when a program needs more authority than the user who ran it, and the two design principles that limit the damage when such a program is wrong. After that come the full access control lists that Linux and macOS added on top of the original model and that Windows NT and its successors have used from the start, and the last section describes what discretionary control cannot do.
The mechanisms that follow are the same on Linux, macOS, Android, and the other systems in the Unix family. What a user ID stands for differs: on a server it identifies a person, while Android gives each app its own user ID, so the same permission bits keep apps apart rather than people.
The Unix Permission Model
Every file in a Unix file system has an owner, a group, and nine permission bits, all stored in the file’s inode, the on-disk record that holds a file’s metadata. The nine bits are three sets of three. The first set applies to the file’s owner, the second to members of the file’s group, and the third to everyone else, which Unix calls “other.” Within each set, one bit grants permission to read the file, one to write it, and one to execute it.
The ls -l command displays all of this in one line (in the sequence owner, group, other):
$ ls -l /bin/ls
-rwxr-xr-x 1 root wheel 252512 Sep 24 03:10 /bin/ls
The first character gives the type of file: - for a regular file, d for a directory, l for a symbolic link, and b or c for a device. The next nine characters are the permission bits as three groups of rwx, with a - where a bit is off. This file is owned by root and belongs to the group wheel. The owner may read, write, and execute it. Members of the group wheel, and everyone else, may read and execute it but not write it.
This is a compressed access control list. The column of the access matrix for this file has been reduced to three entries, one for the owner, one for a single group, and one for the world, and each entry holds three bits. The compression is what made the check fast enough for a 1970s minicomputer. Unlike a full list, the bits take a fixed amount of space, so they fit inside the fixed-size inode, and the kernel reads the permissions along with the rest of the file’s metadata without a second disk read to find them. The design has outlasted every system that was designed to replace it.
How a Permission Check Runs
When a process opens a file, the kernel compares the process’s identity with the file’s and picks exactly one of the three sets of bits:
-
If the process’s user ID matches the file’s owner, the owner bits apply.
-
Otherwise, if the process belongs to the file’s group, the group bits apply.
-
Otherwise, the “other” bits apply.
Only the selected set is consulted. The others are ignored, even when they would have granted the access. An owner whose own bits deny reading is denied, even if the file is readable by the group and by the world. The rule has two uses. It lets an owner lock themselves out of a file as a precaution while leaving it readable to others, and it means the kernel never has to combine entries.
The check runs once, when the file is opened, and not on each read or write. An open file descriptor is a capability, and a process that holds one keeps the access it was granted even if the file’s permissions change afterward.
Execute Without Read
Execute permission is separate from read permission, and a file can have one without the other. A program marked -r-x--x--x can be run by anyone but read only by its owner:
$ ls -l secretprog
-r-x--x--x 1 root staff 8492 Feb 4 19:22 secretprog
$ cat secretprog
cat: secretprog: Permission denied
$ ./secretprog
The second command fails and the third succeeds, because the kernel loads an executable itself and does not need the process to be able to read it. This keeps the contents of a binary from being read or copied through the file system, although it is not a guarantee against every means of inspection. For example, it does not apply to a script. A script is executed by an interpreter such as /bin/sh or python3, which runs as the user and has to read the file to run it, so a script that is not readable cannot be run either.
Directory Permissions
A directory is a file that maps names to inode numbers, and the same three bits apply to it with different meanings:
| Bit | Meaning for a directory |
|---|---|
| r | List the names in the directory. |
| w | Create, delete, or rename entries in the directory. |
| x | Traverse the directory: use it in a path to reach something inside it. |
The execute bit is the least intuitive of the three since it does not make sense to execute a directory. It provides permissions to search through the directory without reading its contents. Without it, nothing inside the directory can be reached, even by a process that knows a file’s exact name. With execute but not read, a process can open a file whose name it already knows but cannot list the directory to discover names. That combination is used for directories that hold files meant to be shared by name without being browsed.
Deleting a file requires write and execute permission on the directory that contains it, not write permission on the file. Deletion removes a name from the directory. The file’s inode says nothing about its name (and multiple names can refer to the same file). A user with write and execute permission on a directory can delete or rename every file in it, including files owned by someone else that the user cannot read. Shared directories such as /tmp set one more bit, the sticky bit, which limits deletion and renaming to the file’s owner, the directory’s owner, and root. Removing a file’s last name also does not destroy the file while any process still holds it open; the storage is released when the last descriptor closes.
Changing Permissions
The chmod command changes a file’s permission bits. The owner of a file and the superuser (root, the administrator) may run it. The symbolic form names the sets to change (u for the owner, g for the group, o for other, a for all) and the bits:
$ chmod u=rw,g=r,o= notes.txt
$ ls -l notes.txt
-rw-r----- 1 paul staff 2048 Oct 8 10:14 notes.txt
The older form gives the bits as an octal number, one digit per set of owner-group-other, with read as 4, write as 2, and execute as 1. The same permissions are chmod 640 notes.txt. The octal form is still common in scripts and in system calls, which take the bits as a number.
chown changes a file’s owner and chgrp changes its group. On Linux and most other systems, only the superuser may change a file’s owner. Ownership is the right to set the file’s permissions, so giving a file away would let a user hand their files to someone else’s account to escape a disk quota or to plant a file that another user appears to have created.
Default Permissions and the umask
A program creates a file by calling open() or creat() with a mode argument that gives the permission bits the new file should have. Most programs never make that choice. They create files through a library routine, such as C’s fopen(), Python’s open(), or Java’s FileOutputStream, or through shell redirection, and all of those pass 0666, read and write for everyone, and rely on the environment to restrict it.
The restriction is the umask, a per-process value that lists bits to remove from every file the process creates. The umask is written in the same octal form as chmod, one digit per set with read as 4, write as 2, and execute as 1, but each bit that is set in the umask removes that permission rather than granting it. A umask of 022 removes the write bit from the group and other sets, so a file created with mode 0666 ends up as 0644, readable by all and writable only by the owner. Child processes inherit the umask, so the value set in a shell applies to every program run from it.
Nearly every system ships with a default umask of 022, and some use 027 to remove all access by “other” as well. The umask exists because the alternative is to trust every program to choose a safe mode, and most programs never choose one.
The Check Happens at Open
The default umask leaves read permission in place for everyone, so a file that a shell script or a Python or Java program creates comes out readable by every user on the machine. A program that wants the file to be private usually fixes that one line later with a chmod to 0600, which removes all access by the group and everyone else.
The fix leaves a window. Between the creation and the chmod, the file is readable by everyone, and a process that opens it during that window keeps its descriptor after the chmod, because permissions are checked at open and never again on that descriptor. Once an attacker has the file open, changing its permissions accomplishes nothing. The window is short, but an attacker who runs a loop that keeps trying to open the file will eventually land inside it.
The way to avoid it is never to create the file with the wrong permissions in the first place. A program can pass the restrictive mode, 0600, in the open() call itself, or run under a umask of 077, which removes all access by the group and others from every file the program creates. The umask has one exception on Linux: when the parent directory carries a default access control list, described later, the list supplies the new file’s permissions and the umask is ignored. A mode of 0600 in the open() call still limits what the file inherits, which is one more reason to pass the restrictive mode at creation.
The permission check protects the act of opening, not the name. Anything that was already opened stays open, and a check performed at one moment says nothing about a later one. We will cover attacks built on that gap in detail later, and one more instance of it appears below.
Users, Groups, and Root
A principal on a Unix system is a numeric user ID. On Linux, the file /etc/passwd maps login names to user IDs, along with each user’s primary group, home directory, and shell (macOS and Android keep account records differently, but the user ID plays the same role on both):
root:x:0:0:System Administrator:/root:/bin/sh
paul:x:1000:1000:Paul K:/home/paul:/bin/bash
The x in the second field once held a hashed password. Password hashes now live in /etc/shadow, which only the superuser can read, so that the rest of the account information can stay world-readable. Groups are defined in /etc/group, and a user may belong to many of them. A process has its user’s primary group ID and a list of supplementary groups, and the permission check treats membership in any of them as membership in the file’s group.
User ID 0 is root, the superuser. The kernel skips nearly every permission check for a process running as root. Root can read and write any file, change any file’s owner, kill any process, and bind to any network port. A few limits remain: a read-only file system stays read-only, and the mandatory controls described later, such as SELinux, apply to root as well. Every administrative task on a traditional Unix system required root, which is why so much of what follows is about limiting how much code runs with that identity.
How a Process Gets Its Identity
A process inherits its user ID from its parent, so the question is where the first one comes from. When a user logs in, the login program, which runs as root, performs these steps:
-
Read
/etc/shadowand verify the password the user typed. -
Read
/etc/passwdand/etc/groupto find the user’s ID, primary group, supplementary groups, home directory, and shell. -
Change to the home directory with
chdir(). -
Set the supplementary groups with
initgroups()and the primary group ID withsetgid(). -
Set the user ID with
setuid(). -
Replace itself with the user’s shell with
execve().
The order is important. The group changes need root privileges, so they come first. setuid() is the step that gives up root: when a root process calls it with an ordinary user’s ID, all of the process’s user IDs change to that value, and the process cannot get root back. Everything the user runs from that shell inherits the shell’s user ID. In the terms of the access matrix, login performed a domain transfer from root’s row to the user’s.
A process has more than one user ID. Its real user ID identifies the account that started it, and its effective user ID is the one the kernel uses for permission checks. For nearly every process the two are the same. They differ for programs that run with another user’s authority, which come next.
Running with Someone Else’s Authority
Some operations need more authority than the user who performs them. Changing a password means writing /etc/shadow, which only root may write. A user has every right to change their own password but not the right to write that file. Mounting a file system, opening a privileged network port, and recording a login session all have the same structure: a legitimate action that requires a privilege the user should not hold in general.
Unix solves this with one more permission bit. If a program’s setuid bit is set, a process that executes the program runs with the file owner’s user ID as its effective user ID, rather than the user ID of whoever ran it. The real user ID is unchanged, so the kernel still knows who started the process. The passwd program is owned by root with the setuid bit set, so it runs as root, verifies that the user is changing their own entry, and writes the file. A setgid bit does the same for the group ID. In ls -l, the setuid bit appears as an s in place of the owner’s x:
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 68208 Feb 6 2024 /usr/bin/passwd
In the access matrix, running a setuid program is a domain transfer. The process moves from the user’s row to root’s row, with root’s rights, and the only protection against misuse is the program’s own code. The program is a deputy: it holds root’s authority and acts on behalf of an unprivileged caller. It becomes a confused deputy the moment it fails to check what that caller is entitled to, and making that check is the program’s whole job.
The Cost of Getting It Wrong
On January 25, 2022, Qualys, a security company, disclosed a flaw in pkexec, a setuid-root program from the polkit package that Qualys described as installed by default on every major Linux distribution. The program mishandled an empty argument list, a condition that a normal caller never produces, and the result was a memory corruption bug that let any local user run code as root. Qualys confirmed the attack on default installations of Ubuntu, Debian, Fedora, and CentOS, and named the flaw PwnKit. The bug had been present since the program’s first version in May 2009. For twelve years, every user on every one of those systems had been one short program away from root.
PwnKit is one example of a class of programs that run with elevated privileges. Setuid programs are where a great many of the local privilege escalation bugs on Unix systems have been found, because a bug in a program that runs as root is a bug in root. These defensive rules help minimize potential damage:
-
Install as few setuid programs as possible, and audit the ones that remain.
-
Give up the privilege temporarily when it is not in use. A process can lower its effective user ID to its real one with
seteuid(), and a third identity, the saved user ID, keeps a copy of the privileged ID so that the process can raise its effective ID again later. A setuid program that does its privileged work at startup and then runs as its real user for the rest of its life, raising the privilege only for the moments that need it, exposes root to far less of its code. -
Give up the privilege for good once it is no longer needed. Dropping to the real ID permanently with
setuid()is safer than a temporary drop, but the call has to be made while the process is still privileged:setuid()clears the saved ID only when the caller has root’s authority, so a program that has already lowered its effective ID withseteuid()keeps a way back to root. The program should also check the return value, since a failed drop leaves it running as root, and give up supplementary groups it does not need. -
Concentrate the privilege in one program. The
sudocommand runs a command as root under a policy file that says who may run what. It is setuid root itself, but it replaces a shelf of setuid programs with one that is heavily reviewed. -
Split root’s power so a program does not need all of it. Linux divides the superuser’s authority into about forty separately grantable capabilities, such as the right to bind a low-numbered port, so a web server can be given that one right instead of root. The word collides with the capability lists described earlier, and the two ideas are unrelated. We will cover capabilities later.
A Check and a Use That Disagree
A setuid program often needs to check whether the real user may read a particular file. The access() system call exists for exactly that. It checks a path against the process’s real user ID rather than its effective one. A setuid program that is asked to copy a file might do this:
if (access(path, R_OK) == 0) {
fd = open(path, O_RDONLY);
...
}
The two calls refer to the same name, but there is no guarantee that the name refers to the same file both times. Between the access() and the open(), the user who invoked the program can delete path and replace it with a symbolic link to /etc/shadow. The check passed on the user’s harmless file. The open, performed with root’s effective ID, follows the link and succeeds on the file the user could never have read. The window is only a few microseconds wide, but an attacker who can run the program in a loop will eventually win it.
The flaw is in checking a name and then using the name, when what the program needed was to check and use the same object. We will cover this family of attacks, known as time-of-check to time-of-use, in detail later, along with the ways around it. For now, the hazard in any program that checks a thing it does not hold is enough to recognize.
The Principle of Least Privilege
Every setuid program and every service that runs as root has the same problem: a mistake in it is a mistake with full authority to do anything to the system. In 1975, Jerome Saltzer and Michael Schroeder of MIT published a list of design principles for protection systems that is still cited, and the one most quoted is the principle of least privilege: every program and every user should operate with the smallest set of privileges needed to complete the job.
The principle limits damage rather than preventing mistakes. A web server that can only read the directory of files it serves cannot leak the password file when it is compromised, however badly it is compromised. A process that cannot kill other processes cannot take down its neighbors by accident. The Capital One firewall is the counterexample. It did not need access to customer records, and it had been given a role that could read the storage buckets that held them. Least privilege would have given the firewall’s role only the rights a firewall needs, and the stolen credentials would have reached none of the customer data.
On a Unix system the principle is applied with identities. Each service runs as its own user, created for that purpose and able to touch only that service’s files, so that a compromised mail server cannot read the web server’s data. The nobody user exists for programs that do not need files of their own. A server that must start as root to bind a privileged port binds it and then calls setuid() to become its service user before it reads a single byte from the network. It is considered good practice for administrators to log in as themselves rather than as root and to use sudo to run individual privileged commands, which limits the damage a mistyped command can do.
Privilege Separation
Some programs cannot give up root, because part of their work requires it throughout. A login server has to create processes as any user who authenticates. A mail server has to deliver into any user’s mailbox. The structure that handles this is privilege separation: divide the program into a small privileged part that does only the operations that need privilege, and a larger unprivileged part that does everything else, and connect the two with a narrow channel.
On a Unix system the sequence looks like this:
-
The program starts as root.
-
It creates a pipe or a socket pair for the two halves to talk over.
-
It calls
fork()to create a child process. -
The child drops its privilege with
setuid()to an unprivileged user and does the untrusted work: parsing input from the network, running the protocol, handling the user’s requests. -
The parent keeps its privilege and performs the few operations that need it, only when the child requests them over the pipe, and only after checking the request.
A bug in the child, which handles everything an attacker can send, yields a compromised process that runs as an unprivileged user and can request from the parent only what the pipe’s protocol allows. The parent’s code is small enough to be reviewed. The structure reduces the attack surface of the privileged code: instead of every byte that arrives from the network, the only input the privileged part accepts is the small set of requests the pipe’s protocol defines, and each of those can be checked before it is acted on.
The design was worked out for OpenSSH, the login server that nearly every Unix machine runs, by Niels Provos, Markus Friedl, and Peter Honeyman, and shipped in OpenSSH 3.2.2 in May 2002. The privileged monitor process holds the server’s private key and authenticates users. An unprivileged child performs the key exchange and parses every packet from the network. Once a user has authenticated, the monitor creates a new child running as that user. Six weeks after the release, in June 2002, a remotely exploitable bug was found in the server’s authentication code, and the project’s advisory said that systems with privilege separation enabled were protected against the privilege escalation.
Browsers use the same structure. Chrome renders web content in sandboxed renderer processes that are confined by the operating system and cannot open files or sockets on their own. A renderer that needs something from the file system sends a request to the browser process, which decides whether to grant it. The attacker who finds a bug in the code that parses a web page lands in a process that can do almost nothing, and has to find a second bug to get further.
Full Access Control Lists
The nine permission bits express only three entries, and the limit shows as soon as sharing gets specific. A research group that wants to let two colleagues read a file, a third edit it, and nobody else see it needs a group for each combination, and only an administrator can create groups. Linux and macOS therefore added an access control list with any number of entries alongside the original bits, and Windows NT and its successors have used such lists from the start. This section describes the lists on all three.
POSIX ACLs on Linux
Linux implements the ACLs from a draft standard, POSIX.1e, that was never completed but was adopted anyway. An ACL is a list of entries, each naming a user or a group and the rwx bits it has:
$ getfacl report.txt
# file: report.txt
# owner: paul
# group: staff
user::rw-
user:alice:r--
group::r--
group:students:rw-
mask::rw-
other::---
The entries user::, group::, and other:: without a name are the original owner, group, and other bits. The named entries, user:alice and group:students, are the additions. The mask entry caps what the named user entries, the named group entries, and the file’s own group entry can grant. An entry may say rw-, but if the mask is r--, that entry grants only read: the kernel ANDs the entry’s bits with the mask. The mask does not apply to the owner entry or the other entry. It gives an administrator one place to restrict all of the covered entries at once. Setting the mask to r-- makes every named user and group read-only without editing their entries, and setting it back to rw- restores what the entries say.
When a file has an ACL, the group bits that ls -l displays are the mask rather than the group entry, and chmod on the group digit changes the mask. The setfacl command adds and removes entries, and a directory can have default entries that are copied to every file created inside it.
The check runs in an order that keeps the original semantics for a file without an ACL:
-
If the process’s user ID matches the owner, the owner entry applies.
-
Otherwise, if a named user entry matches, it applies, limited by the mask.
-
Otherwise, if the file’s group or any named group entry matches, those entries apply, limited by the mask.
-
Otherwise, the other entry applies.
Because an ACL takes a variable amount of space and is not guaranteed to fit in a fixed-size inode, it is stored in an extended attribute, a named block of data attached to the file outside its contents. On the ext4 file system, a short attribute fits in spare space inside the inode, and a longer one goes to a separate block. A + after the permission bits in ls -l indicates that a file has one.
Access Control Lists on Windows
Windows took a different path. Early Windows, like MS-DOS before it, did not have access control at all: one user owned the machine, and any program could read, modify, or delete any file. Windows NT, released in July 1993 as a new system designed for networks and servers, was built with a security reference monitor and full access control lists from the start, without fixed permission bits underneath them.
The pieces correspond to the Unix ones. A security identifier, or SID, plays the role of a user ID or group ID. When a user logs in, the system builds an access token that holds the user’s SID and the SIDs of every group the user belongs to, and every process the user starts carries a copy; it is the counterpart of a Unix process’s user ID and group list. Every securable object, which includes files, directories, registry keys, and processes, has a discretionary access control list, or DACL, made of access control entries. An entry names a SID, a set of rights, and whether the entry allows or denies them. The rights are finer than rwx: read, write, execute, delete, change permissions, and take ownership are separate rights.
Windows ACLs add explicit deny entries and more elaborate inheritance controls. A deny entry refuses a right, and the check walks the list in order, collecting allowed rights and stopping with a refusal at the first deny entry that covers a right not yet granted, so the order of entries decides the outcome. Windows keeps each list in what it calls canonical order. Explicit entries, those set directly on the object, come first, with denies before allows. Inherited entries follow, from the nearest ancestor folder to the farthest, with denies before allows at each level. A deny set on the object itself therefore always wins, while an allow set on the object takes precedence over a deny inherited from above.
Inheritance is what makes permissions on a large tree administrable: an entry set on a folder applies to everything created below it, an object can be marked to stop inheriting, and an entry can be set to apply to subfolders only, to files only, or to one level down only. NTFS, the Windows file system, stores each distinct list once in a hidden system file, and files with identical permissions share that one copy, so even thousands of files that inherited the same entries share one list.
A second list, the system access control list, or SACL, records which accesses to write to the audit log and holds the object’s integrity label, which the mandatory controls described later use. The owner of a file still decides its DACL, so the model is discretionary. In an organization, though, SIDs and group memberships come from Active Directory, Microsoft’s directory service, and domain administrators can push permissions to thousands of machines and override an owner, so the line between discretionary and mandatory control blurs.
Access Control Lists on macOS
macOS keeps the Unix bits and adds an ACL whose entries resemble the Windows ones, with allow and deny entries and inheritance. The chmod command manages them:
$ chmod +a "alice allow read" notes.txt
$ chmod +a "guests deny read,write" notes.txt
$ ls -le notes.txt
-rw-r-----+ 1 paul staff 2048 Oct 8 10:14 notes.txt
0: group:guests deny read,write
1: user:alice allow read
ACL entries are evaluated before the permission bits, in order. Allow entries accumulate rights, a deny entry for a right not yet granted refuses the request, and any right the entries leave undecided falls through to the nine permission bits. The + after the bits means the file has an ACL, and ls -le lists it.
What ACLs Cost
Full ACLs can express any column of the access matrix. The cost is that nobody can see the matrix. A directory tree with inherited entries, named users, masks, and deny entries at several levels has an effective permission for each user that is not visible in any listing, and finding out who can read a file means simulating the check for each user, which is what the Effective Access tab in Windows does for one user at a time. Misconfigured ACLs are a standard finding in security audits.
Why Discretionary Control Is Not Enough
Discretionary access control gives users a mechanism that is easy to understand and easy to use, and for a group of people who trust each other it is enough. It has three limits that cannot be fixed by any setting of the bits:
-
A program runs with the authority of the user who runs it. A Trojan horse, a program that does something its user did not intend, can read every file its user can read and send the contents anywhere. The permission system cannot tell the user’s intent from the program’s.
-
Rights propagate. A user who can read a file can copy it, and the copy is a new file whose permissions the copier sets. Nothing stops a user with read access to a confidential document from making it world-readable.
-
The owner’s policy is the only policy. Every object’s permissions are set by its owner. An organization that wants a rule such as “nothing marked confidential leaves this group” cannot state it, because any owner can undo it with a
chmod.
These limits are what mandatory access control was invented to remove. In the mandatory models, the system assigns labels, compares them on every access, and does not let an owner relax the result.
Next: Part 3: Mandatory Access Control
Lecture 6: Part 1 | Part 2 | Part 3
Lecture 6 Study Guide | List of terms