Protection, Access Control, and OS Security Basics
Imagine a shared server running processes for a thousand different users. Each user has private files. Some processes run untrusted code downloaded from the internet. Nothing stops any process from reading or overwriting any byte of memory. The same goes for any file on disk. Only deliberate engineering prevents this: the operating system's protection mechanisms. Without them, a single buggy or malicious process could cause harm. It could read another user's tax returns. It could corrupt the payroll database. It could crash the whole machine. Protection is the machinery that mediates every access. A subject (a user or process) accesses an object (a file, a memory segment, a device). Security is the policy question of which accesses should be allowed. The two are inseparable in practice. A perfectly enforced but poorly designed policy is just as dangerous as a well-designed policy with sloppy enforcement. This article builds the conceptual foundation for reasoning about protection precisely. We start from the access matrix, the abstract model. Every real mechanism is an engineering compromise of this model. From there we derive the two dominant implementations. These are access control lists (ACLs) and capabilities. We show exactly what each one is good and bad at. We then descend to a mechanism nearly every reader has already touched: Unix permission bits. We show how they are a deliberately impoverished, extremely fast special case of the access matrix. Along the way we formalize the principle of least privilege. This is the single design rule that shows up in every hardened system, from microkernels to sandboxed browsers. Building on Inodes and File Metadata and The File Abstraction, this article assumes some prior knowledge. You should already know that a file is represented by an inode. That inode carries owner, size, and pointers to data blocks. We now ask who is allowed to touch that inode, and how does the kernel know. The connective tissue between "a file exists" and "a file is safe" is exactly the access-control layer described here. Finally we survey the classic threat model of an operating system. This includes privilege escalation, confused deputies, and race conditions in permission checks (TOCTOU). It also includes buffer overflows that hijack control flow. We then cover the standard defenses. These are privilege separation, setuid discipline, capability confinement, and mandatory access control. By the end you should be able to look at any access-control scheme and immediately place it on the ACL/capability spectrum. You should be able to compute what it can and cannot express. You should also be able to spot the classic bugs that break it.
- The access matrix `M[subject, object] = {rights}` is the ground-truth model; every real system stores it sparsely, either by column (ACLs) or by row (capabilities).
- ACLs answer "who can access this object?" cheaply; capabilities answer "what can this subject access?" cheaply — each is the other's weak point.
- Unix rwx bits are a compressed, 9-bit-per-file approximation of an ACL that trades expressiveness for O(1) storage and O(1) checks.
- The principle of least privilege says every subject should hold exactly the rights it needs, no more, for exactly as long as it needs them.
- Classic vulnerabilities — confused deputy, TOCTOU race conditions, privilege escalation via setuid — are structural failures of protection reasoning, not just "bugs."
- Modern systems layer mandatory access control (SELinux, AppArmor), capability sandboxes (seccomp, Capsicum), and least-privilege daemons on top of the classic Unix model rather than replacing it.
Every access-control mechanism is a storage trick for the same abstract access matrix — the engineering choice is only which slice (rows, columns, or bits) you can check quickly.
The idea
The access matrix is the reference model of protection. Let be the set of subjects (users, processes, or protection domains) and be the set of objects (files, memory segments, devices, even other processes). The matrix has one row per subject and one column per object; the entry is the set of operations may perform on — for example or . Access control is then simply: before honoring subject 's request to perform operation on object , the reference monitor checks whether . The access matrix is never stored as a literal 2-D array, because it is enormous and almost entirely empty — a system with a million files and a thousand users has cells, nearly all . Every real protection mechanism is a compression strategy for this sparse matrix, and the compression axis you choose determines the mechanism's whole character:
- Store the matrix by column: for each object, keep the (short) list of subjects with nonzero rights. This is an Access Control List (ACL). It is cheap to answer "who can touch this file?" and cheap to revoke a user everywhere the object appears once — but expensive to answer "what can this process do, across the whole system?" without scanning every object.
- Store the matrix by row: for each subject, keep the (short) list of objects it may touch and with which rights, bundled as unforgeable tokens called capabilities. This is cheap to answer "what can this process do?" (just enumerate its capability list) and naturally supports delegation (hand a capability to another process) — but expensive to answer "who can touch this object?" and famously hard to revoke a single capability once copies have propagated.
A third, still-important idea is the principle of least privilege (Saltzer & Schroeder, 1975): a subject should be granted exactly the rights necessary for its current task, and those rights should be dropped the moment the task is done. Least privilege is not a mechanism but a design discipline — it tells you how to fill in the access matrix, whereas ACLs and capabilities tell you how to store it. A system can implement least privilege badly (a `sudo` script that grants full root for a one-line task) or well (a container that gets exactly one open file descriptor and nothing else); the mechanism does not save you from a sloppy policy.
How it works
The access-matrix operations
Beyond static lookup, an access-control model needs operations that change the matrix itself: granting, revoking, and creating subjects/objects. A minimal command language: ``` matrix M[Subjects][Objects] = all entries ∅
# Create a new object (e.g., a new file); owner gets full rights CREATE-OBJECT(owner, o): Objects.add(o) M[owner][o] = {read, write, execute, own}
# Grant a right — only allowed if the granter itself possesses "own"
# or the specific right with grant-permission on that object GRANT(granter, grantee, o, right): if "own" in M[granter][o] or right + ":grant" in M[granter][o]: M[grantee][o] = M[grantee][o] ∪ {right} else: deny("granter lacks authority")
# Revoke a right REVOKE(revoker, revokee, o, right): if "own" in M[revoker][o]: M[revokee][o] = M[revokee][o] \ {right} else: deny("revoker lacks authority")
# The reference-monitor check performed on EVERY access CHECK-ACCESS(subject, o, op): if op in M[subject][o]: allow() else: deny() ``` The `own` right is what makes the matrix dynamic: it lets a subject who created an object propagate and later withdraw rights to others, which is exactly how a Unix file owner can `chmod` and `chown` their own files.
ACL realization
Storing by column, each object carries a list of `(subject, rights)` pairs: ``` struct ACL_Entry: subject_id rights: bitset # e.g. {READ, WRITE, EXECUTE} struct Object: acl: list CHECK-ACCESS-ACL(subject, obj, op): for entry in obj.acl: if entry.subject_id == subject and op in entry.rights: return ALLOW return DENY # default-deny ``` Real ACL systems (NTFS, POSIX ACLs, AFS) add groups as a level of indirection, so a single ACL entry can name a group instead of every member individually — this is precisely a compression of the compression, because otherwise adding one employee to "Engineering" would require editing every file's ACL.
Capability realization
Storing by row, each subject (a process, or a protection domain) carries a list of capabilities, each naming an object and a right set: ``` struct Capability: object_ref # unforgeable pointer/handle, kernel-protected rights: bitset struct Process: cap_list: list CHECK-ACCESS-CAP(subject, cap_index, op): cap = subject.cap_list[cap_index] if op in cap.rights: return ALLOW_ON(cap.object_ref) return DENY ``` The crucial property is unforgeability: a user-level process must never be able to synthesize a capability out of thin air (e.g., by guessing a bit pattern) — capabilities must live in kernel memory, be tagged in hardware, or be cryptographically signed. This is what separates a capability system from "just an integer file descriptor a program could fake."
Unix permission bits, as a compressed ACL
Unix compresses the access matrix down to 9 bits per inode by pre-partitioning subjects into exactly three classes relative to each file: owner, group, other — plus three special bits (setuid, setgid, sticky). This trades expressiveness (you cannot list five arbitrary users with five different rights) for O(1) storage per file and O(1) check time, independent of how many users exist on the system. ``` struct Inode: owner_uid, group_gid mode: 9 bits # rwxrwxrwx (owner | group | other) special: {setuid, setgid, sticky} CHECK-ACCESS-UNIX(process, inode, op): if process.euid == inode.owner_uid: bits = mode.owner_bits else if process.egid in group_memberships(inode.group_gid): bits = mode.group_bits else: bits = mode.other_bits return op in bits # r, w, or x ``` Note the semantics: Unix checks the first matching class only — a directory owner who is accidentally excluded from `rwx` in the owner triad does not fall back to the (possibly more permissive) "other" bits. This single-class, first-match rule is what keeps the check to three comparisons and a bitmask test, regardless of file size or user-base size. `setuid` and `setgid` are the mechanism by which Unix punches a controlled hole in least privilege: when set on an executable, the process runs with the file owner's identity rather than the invoker's, e.g. `passwd` runs as root so an ordinary user can edit `/etc/shadow` through a narrow, audited interface. The sticky bit on a directory (`/tmp`) restricts deletion to the file's owner even though the directory itself is world-writable — a narrow patch for a specific attack (users deleting each other's temp files).
The confused-deputy and revocation problems, mechanically
Two structural failure modes recur across both models and deserve their own algorithmic framing.
Confused deputy. A subject (the "deputy," e.g. a compiler) holds a right to object (its own log file) that a caller lacks. If tricks into using its own authority on 's behalf — e.g. by naming a different file as "the log file" via a parameter — unwittingly writes over an object could never touch directly. The bug is that ACL-style systems authorize based on ambient identity (who is ?) rather than on which specific right was actually passed for this call — capabilities fix this by making the deputy operate only on the exact object handle it was explicitly handed, with no implicit "my own privileges apply to whatever filename you typed."
Revocation. With ACLs, revoking alice's access to `f1` is one list deletion: amortized, and immediate for every future check. With naively copied capabilities, once alice has handed a copy of the `f1` capability to five other processes, there is no central list to edit — you must either use indirection (all copies point through a revocable indirection object) or generation counters (each capability carries an object generation number; bumping the counter on the object invalidates all outstanding capabilities at once). Both fixes reintroduce a small ACL-like structure at the object, which is a fundamental theorem-in-practice: pure capability systems cannot get selective revocation without some column-oriented bookkeeping.
Complexity & correctness
Storage. A dense access matrix is , intractable for real systems ( each in the millions). ACLs cost , i.e., proportional to actual grants, typically where is the average number of distinct subjects per object — small in practice because most files are touched by a handful of accounts or one group. Capabilities cost symmetrically, where is average capabilities held per subject. Unix bits are flat — 9 bits per file, independent of user count, because the compression discards subject-level granularity entirely in exchange for a fixed-size record.
Time. ACL lookup is in the number of entries on that object's list (or / with a hash map keyed by subject), plus group-membership resolution, which is itself for groups the subject belongs to. Capability lookup is if the capability is presented directly by the caller (no search needed — the caller names the exact capability), which is why capability systems are attractive for performance-critical kernels: no per-call linear scan. Unix lookup is strictly : one identity comparison, one group-membership test bounded by a small constant, one bitmask AND.
Correctness argument. The essential invariant every reference monitor must uphold is:
Three properties are necessary for a mechanism to satisfy this invariant in practice, and each maps to a design requirement: 1. Complete mediation — every access path to must go through the check; if a process can reach the raw disk block bypassing the file system, the ACL/capability layer is vacuous. This is why kernels, not libraries, enforce file permissions: a user-mode library check can be skipped by calling the syscall directly. 2. Unforgeability of the identity/token used in the check — for ACLs, the subject's claimed UID must be attested by the kernel (not self-reported); for capabilities, the capability handle itself must be un-fabricable. Violating this collapses the whole model, since an attacker who can forge either side trivially satisfies for any . 3. Atomicity relative to time — the check and the use must be atomic, or the matrix entry that was true at check-time may differ from the entry true at use-time (this is exactly the TOCTOU race discussed below). Formally we need
Given these three properties, correctness of any compression (ACL, capability, or Unix bits) reduces to showing the compressed structure is a faithful, queryable encoding of : for ACLs, ; for Unix bits, correctness holds only relative to the coarsened matrix where all non-owner, non-group subjects are unioned into a single "other" row — meaning Unix bits are provably correct for the policy they can express, but many policies (e.g., "alice and bob may write, carol may only read, everyone else denied") are simply inexpressible, not incorrectly enforced. This is the honest tradeoff: Unix bits sacrifice expressive power for a constant-time, constant-space guarantee, and no amount of clever engineering recovers the lost expressiveness without adding real ACL/capability machinery (POSIX ACLs, extended attributes) back on top.
Worked example
Consider a small file server. Users: `alice` (uid 501), `bob` (uid 502, group `staff`), `carol` (uid 503, group `staff`). File `payroll.csv`, owned by alice, group `staff`, mode `640` (`rw-r-----`).
Step-by-step access-matrix view first, the ground truth we want:
| Subject | payroll.csv rights |
|---|---|
| alice | read, write |
| bob | read |
| carol | (none — policy: staff can read except carol, who is excluded for a conflict-of-interest reason) |
This exact matrix is not expressible in 9 Unix bits, because Unix cannot exclude one member of a group while admitting the rest. Let's trace what actually happens under each mechanism.
Unix bits (mode 640, group staff). ``` CHECK-ACCESS-UNIX(carol, payroll.csv, READ): carol.euid (503) != owner_uid (501) -> not owner class carol.egid includes staff, file group = staff -> GROUP class applies group_bits = "r--" READ in "r--"? -> YES => ALLOW ``` Carol is granted read access even though the intended policy excludes her — the mechanism is correctly implementing Unix semantics but incorrectly implementing the desired policy, because the policy simply cannot be represented. The fix requires either a POSIX ACL (`setfacl -m u:carol:--- payroll.csv`, which adds an explicit deny-equivalent entry checked before the group bits) or moving carol out of `staff` (which may break other, unrelated permissions she legitimately needs) — illustrating why least privilege is often defeated not by weak enforcement but by coarse policy granularity.
ACL-based system (e.g., NTFS or POSIX ACL), storing the matrix column for `payroll.csv` directly: ``` payroll.csv.acl = [ (alice, rw), (bob, r), (carol, deny-all) ] CHECK-ACCESS-ACL(carol, payroll.csv, READ): scan list -> match (carol, deny-all) found first deny-all excludes READ -> DENY ``` The list is scanned in specificity order (explicit user entries checked before group entries), so carol's individual deny entry overrides the group-level allow that `staff` would otherwise confer — this is exactly the extra column-oriented bookkeeping an access matrix needs to express exceptions, and it costs one extra list entry, not a redesign.
Capability-based system. Suppose the file server hands out capabilities at login instead. Alice's process is minted `cap(payroll.csv, {read, write})`; bob's process gets `cap(payroll.csv, {read})`; carol's process is simply never minted a capability naming `payroll.csv` at all. ``` carol_process.cap_list = [ cap(other_file, {r,w}), ... ] # no entry for payroll.csv CHECK-ACCESS-CAP(carol, payroll.csv, READ): search cap_list for object_ref == payroll.csv -> not found return DENY # default-deny by absence, not by explicit rule ``` This achieves the same outcome as the ACL deny-entry, but via a different mechanism: absence of a capability, rather than an explicit denial rule. It is arguably safer against a class of bugs (there is no "forgotten deny entry to disable" — you cannot accidentally leave carol a capability she was never given), but it moves the burden onto whoever mints capabilities at login to get the initial grant set exactly right, and onto revocation infrastructure if carol is later untrusted after already holding a capability.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| TOCTOU (time-of-check to time-of-use) race | Checking `access()` then later `open()`-ing the same path lets an attacker swap a symlink between the two calls | Use `open()` with `O_NOFOLLOW`, or open first and check the file descriptor's permissions (`fstat`), never re-resolve the path |
| Confused deputy | A privileged helper trusts a filename/parameter from an unprivileged caller and uses its own ambient authority on it | Pass explicit capabilities/handles, not names, or have the deputy re-derive rights from the caller's identity, not its own |
| setuid privilege creep | A setuid-root binary drops privileges "eventually" but a bug (buffer overflow) executes before the drop | Drop privileges as the very first statement after the minimum required setup; audit every setuid binary as a trust boundary |
| Group over-inclusion | Adding a user to a broad group (e.g. `staff`) to grant one needed permission, silently granting dozens of unrelated ones | Prefer narrow, task-specific groups or explicit ACL entries over reusing large legacy groups |
| Capability leakage via `fork`/`exec` | Child processes inherit all open file descriptors (capabilities) by default unless explicitly closed | Set `close-on-exec` (`FD_CLOEXEC`) on every descriptor not meant for the child; whitelist, don't blacklist |
| Forgetting default-deny | A reference monitor that fails "open" (allows access when its own check errors out) turns every bug into a vulnerability | Always fail closed: any exception, timeout, or malformed entry in the check path must result in DENY |
| Sticky/setgid bit confusion | Assuming setgid on a directory behaves like setuid on a binary; they mean different things (inherited group vs. effective identity) | Read the exact semantics per bit per object type — do not pattern-match across contexts |
Pioneers & history
Origins · 1 minButler W. Lampson and how this was discovered
Lampson's 1971 paper "Protection" introduced the access matrix as the formal model underlying nearly all subsequent work on operating system security, unifying earlier ad hoc mechanisms into one abstraction subjects, objects, and rights.
Their paper "The Protection of Information in Computer Systems" distilled a decade of Multics engineering experience into design principles — least privilege, economy of mechanism, fail-safe defaults, complete mediation — that remain the checklist for auditing any protection system today.
Where this matters
Every commodity operating system layers its security on the ideas above. Unix/Linux/macOS enforce the rwx model at the VFS layer for every file access; Linux additionally supports POSIX ACLs (`setfacl`/`getfacl`) for the cases 9 bits cannot express, and capabilities(7) (`CAP_NET_ADMIN`, `CAP_SYS_ADMIN`, etc.) which split root's monolithic superpower into fine-grained, individually grantable rights — a direct least-privilege refinement of the historically all-or-nothing setuid-root model. SELinux and AppArmor implement mandatory access control (MAC): a separate, kernel-enforced policy layer that constrains even root, addressing the classic weakness that discretionary ACLs let any owner grant away access arbitrarily. Android's permission model is a capability-flavored ACL hybrid: apps declare needed permissions, and the OS mediates every sensitive API call as a check against the granted set. Web browsers sandbox each tab/process with capability-style file-descriptor confinement (Chrome's `seccomp-bpf`, Capsicum on FreeBSD), so a compromised renderer cannot open arbitrary files — a textbook least-privilege deployment. Cloud IAM systems (AWS IAM, GCP IAM) are, structurally, ACLs at planetary scale: policies attached to resources (columns) and roles attached to principals (rows), with exactly the same revocation and confused-deputy failure modes described above appearing in real incident post-mortems (e.g., the "confused deputy" pattern recurring as cross-account role assumption bugs).
Ask the tutor: How would you redesign a specific vulnerable setuid program to follow least privilege correctly?
Why not just store the full access matrix directly instead of compressing it?
A dense matrix needs cells — for a university with 50,000 students and 10 million files, that is 500 billion entries, nearly all empty, since a typical file is touched by only its owner and maybe one group. Storing it densely wastes essentially all the space on ∅ entries and makes every grant/revoke operation touch a huge fixed structure. Both ACLs and capabilities exploit the sparsity of real-world matrices: only the nonzero entries are ever materialized, along one axis. The tradeoff is that whichever axis you didn't choose to store directly becomes expensive to query — this is the core engineering decision, not an oversight.
If capabilities are so elegant, why do most operating systems still use ACL-like permission bits instead?
Capabilities require every object reference in the system to be an unforgeable, kernel-mediated handle, which historically meant either special hardware tagging (as in the Cambridge CAP computer) or careful kernel discipline that most commodity operating systems weren't built around from day one. ACLs and Unix bits, by contrast, piggyback naturally on filenames and path lookup, which Unix already needed for other reasons. Capabilities also make "who can access this file?" queries expensive, which matters for auditing and compliance. Modern systems increasingly add capability-style mechanisms (file descriptors passed between processes, `seccomp`, Capsicum) as a sandboxing layer on top of the ACL-ish base, getting benefits of both.
What exactly does setuid do, and why is it dangerous?
When the setuid bit is set on an executable file, the kernel runs the resulting process with its effective UID set to the file owner's UID, not the invoking user's — regardless of who ran it. This lets an ordinary user run `passwd`, which needs to write to root-owned `/etc/shadow`, without being handed general root access. It is dangerous because the entire program now executes with elevated privilege for its whole runtime unless it explicitly drops privileges; any exploitable bug (buffer overflow, format-string bug, unsanitized environment variable) inside that binary is instantly a full privilege-escalation vulnerability, since the attacker's injected code inherits the elevated effective UID too.
How does mandatory access control (MAC) differ from the discretionary model described here?
Everything above — Unix bits, ACLs handed out by file owners — is discretionary access control (DAC): whoever owns an object can grant or revoke access to it at will. MAC adds a second, non-bypassable policy layer enforced by the kernel or a security module (SELinux, AppArmor) that constrains access regardless of what the owner wants, based on system-wide labels (e.g., "processes labeled `httpd_t` may never read files labeled `shadow_t`, even if root explicitly `chmod`s them world-readable"). MAC exists because DAC's core weakness is that a compromised or careless owner (or root) can grant away access arbitrarily; MAC caps that discretion with a policy the owner cannot override.
Why is revocation harder for capabilities than for ACLs?
An ACL is stored at the object, so revoking a subject's rights is a single, localized edit — delete one entry from one list, and every subsequent check at that object reflects it immediately. A capability, once minted and handed to a process, can be freely copied to other processes (that is the point — it enables delegation), so there is no single list to edit; the same right may exist as dozens of independent copies scattered across the system. Practical fixes reintroduce object-side bookkeeping: an indirection object every capability points through (revoke by invalidating the indirection), or a generation counter on the object that all capabilities must match, incrementing it to invalidate every outstanding copy at once.
Does the principle of least privilege ever have a real performance or usability cost?
Yes — least privilege trades convenience and sometimes latency for containment. Dropping privileges immediately after the minimal required operation means more context switches between privilege levels, more careful (and slower-to-write) code that re-checks and re-acquires narrow permissions per task rather than grabbing broad access once. Fine-grained capability or ACL entries also multiply the number of objects an administrator must manage, raising the chance of misconfiguration (the classic "over-broad group" pitfall). The tradeoff is deliberate: a smaller blast radius when something does go wrong is judged worth the extra bookkeeping, especially for any code handling untrusted input.
How does a "confused deputy" attack actually get exploited in practice?
A textbook real-world example: a compiler service (the deputy) runs with permission to write to its own billing/log file, and accepts an output-filename parameter from an untrusted caller. If the caller passes the path of the system password file as the "output filename," the compiler — using its own ambient write permission, not the caller's — happily overwrites it. The caller never had write access to that file directly and never needed to; it exploited the deputy's authority via a parameter the deputy trusted implicitly. The general fix is to make authority explicit and object-specific (a capability naming exactly the intended log file) rather than implicit and identity-based (the deputy just "has write access" as a blanket ambient right).