We closed Module 4 with three assumptions left hanging in the air: that UID 990 is meteora, that whoever logs in is who they claim to be, and that a process running as meteora does what meteora would do. The first two we will settle in the next lesson. This one is devoted entirely to the third, which is the most uncomfortable of the three, because it is the one that remains false even when every permission is perfect.

Think of it this way. meteo-api has exactly the permissions it needs: it reads /var/lib/meteora/readings/*.dat, it reads /etc/meteora/meteora.conf, it writes to /var/log/meteora/meteo-api.log. Not one more. Now imagine someone finds a flaw in the code that parses HTTP requests and gets the process to run instructions of their own. That attacker inherits the whole process, with its permissions intact. And with those permissions — the correct ones, the minimal ones, the ones the security review signed off on — they can open a socket to the Internet, read the day's 17,280,000 lines of data, run /bin/sh, spawn a child process that outlives a restart of the service, and mount /dev/md0 somewhere else if the kernel lets them. The nine bits said nothing about any of that, because the nine bits only talk about files.

This lesson builds the conceptual framework that is missing. First the theory, which is surprisingly small and explains everything else: subjects, objects, rights, domains and the access matrix. Then the eight design principles that in 1975 fixed the vocabulary of systems security and are still the best checklist in existence. Then the four access control models you will see named in any professional document — DAC, MAC, RBAC, ABAC. And finally the three mechanisms Linux uses today to implement those ideas: capabilities, which slice up the power of root; SELinux and AppArmor, which impose rules not even root can sidestep; and seccomp, which shrinks the set of system calls a process can even attempt. By the end you will have the answer to the question in the previous paragraph: what do you put in front of a compromised meteo-api so that its correct permissions are not enough?

Contents

  1. Protection versus security: why the distinction matters
  2. The formal model: subjects, objects, rights and protection domains
  3. The access matrix and its two real implementations
  4. The eight Saltzer and Schroeder principles, applied to Meteora
  5. Access control models: DAC, MAC, RBAC and ABAC
  6. The "all or nothing" problem of root
  7. Linux capabilities: slicing up the power of root
  8. Mandatory access control: SELinux and AppArmor
  9. Confinement and sandboxing: seccomp and the syscall surface
  10. Trusted computing base and attack surface
  11. The confused deputy problem

Protection versus security: why the distinction matters

In everyday language they are synonyms. In operating systems they are two different things, and it pays to separate them from the outset, because they determine what you can expect from a mechanism.

Protection Security
What it is An internal mechanism of the OS that controls how processes and users reach resources The global property of the system in the face of an adversary who wants to violate it
Scope Inside the machine The machine, the network, the people, the procedures
Question it answers Can this subject perform this operation on this object? Can someone get what they want, by whatever route?
Example in Meteora The r bit of 2026-08-31.dat, a capability, an AppArmor profile That nobody steals the data: it includes the above, plus encryption, backups, the firewall and nobody leaving the password on a sticky note
How it is verified With rules and tests: the mechanism either grants or denies With a threat model: who attacks, with what resources and with what goal
It fails when There is a bug in the mechanism There is a bug in the mechanism, or in the design, or in the operation, or in the people

The distinction is not academic; it has a very concrete practical consequence. Protection can be reasoned about and demonstrated; security cannot. You can state with certainty that a process with UID 990 cannot write to a root:root 0644 file, because that is a kernel rule. You cannot state with the same certainty that Meteora's data is secure, because that depends on the whole system and on an adversary you only have hypotheses about.

That is where the working rule for the entire module comes from:

A protection mechanism is never a security solution; it is a layer. Security is built by stacking independent layers, so that the failure of one does not cancel out the others. This is what we call defense in depth.

The permissions from 04-06 are one layer. Capabilities are another. AppArmor is another. Seccomp another. None is sufficient on its own, and precisely for that reason none is useless.

The formal model: subjects, objects, rights and protection domains

All access control, in any operating system, is described with four concepts. Learning them saves a lot of time later, because every concrete mechanism is nothing more than a particular implementation of this scheme.

Subject. The active entity that wants to do something. In Linux, the real subject is the process, not the user: the user is a label the process carries. When the aggregator opens a file, the subject is that specific process with its PID, its effective UID 990, its supplementary groups, its capabilities and its SELinux label.

Object. The passive entity being acted upon. On a UNIX system the list is long and is not limited to files: files and directories, processes (signals are sent to them), sockets, shared memory segments such as /dev/shm/meteora-cache, devices, semaphores, message queues, /proc entries, and even the system clock itself or the routing table.

Access right. Permission to perform a specific operation on a specific object: read, write, execute, delete, send a signal, mount, connect. The set of rights depends on the type of object.

Protection domain. This is the concept that usually causes trouble and the one that pays off most. A domain is the set of (object, rights) pairs a subject can use at a given moment. That is: not "who you are", but "what you can do right now".

Domain D_ingestor = {
    (/var/lib/meteora/readings/*.dat, {read, write, create}),
    (/run/meteora/readings.fifo,      {read}),
    (/etc/meteora/meteora.conf,       {read}),
    (/dev/shm/meteora-cache,          {read, write}),
    (TCP socket port 9010,            {listen, accept})
}

The important thing is that a process changes domain over its lifetime, and those changes are the interesting part of security. Recall the passwd case from 04-06: the process starts in user joan's domain, and on execve of a setuid binary it jumps into root's domain. That is a domain change, and it is exactly where attacks concentrate: if you manage to trigger a domain change that was not planned for, you have escalated privileges.

graph LR
    A["Domain 'joan'<br/>UID 1000<br/>reads their own files"] -->|"execve of<br/>/usr/bin/passwd<br/>(setuid root)"| B["Domain 'root'<br/>effective UID 0<br/>writes /etc/shadow"]
    B -->|"the process ends"| C["The domain<br/>disappears"]
    A -->|"execve of<br/>/bin/ls"| D["Domain 'joan'<br/>no change"]

In Linux, a domain is defined in practice by the combination of: effective UID and GID, supplementary groups, capability set, SELinux label or AppArmor profile, active seccomp filter, resource limits and — from Module 6 onwards — namespaces. Everything we see from here on is a way of making domains smaller and controlling the jumps between them better.

The access matrix and its two real implementations

If you put subjects in rows and objects in columns, you get the access matrix, which is the complete, abstract representation of a system's protection policy:

readings/*.dat meteora.conf meteo-api.log /usr/bin/meteo-api
ingestor read, write read
aggregator read read
meteo-api read read write (append) execute
nuria (analysis) read
root everything everything everything everything

The matrix is an excellent model to think with and a disaster to implement: with 500 users and 200,000 files it would have 100 million cells, almost all of them empty. No real system stores it whole. They all slice it up, and there are only two ways to slice a matrix.

By columns: access control lists (ACLs). Each object records who can do what with it. This is what the nine UNIX bits do (a compressed ACL of three entries) and what the POSIX ACLs of 04-06 do (a real list). The list lives with the object, in its inode.

Object: /var/lib/meteora/readings/2026-08-31.dat
  ├── meteora       : rw-
  ├── group meteora : r--
  ├── nuria         : r--      (POSIX ACL entry)
  └── others        : ---

By rows: capability lists. Each subject carries a list of "keys", and each key names an object and the rights over it. The key travels with the subject; the object does not know who holds it.

Subject: meteo-api process (PID 1481)
  ├── key → fd 3 : /var/lib/meteora/readings/2026-08-31.dat {read}
  ├── key → fd 4 : /var/log/meteora/meteo-api.log {append}
  └── key → fd 5 : TCP socket 8080 {accept}

And here is a revelation that puts everything you have learned in order: the file descriptors of 04-04 are capabilities. When open() returns descriptor 3, the kernel has handed you a key: an unforgeable token — you cannot make up a valid descriptor — granting specific rights over a specific object. That is why permissions are checked in open() and not in read(): the check happens when the key is manufactured, not when it is used. And that is why a later chmod 000 does not cut off whoever already holds it. It is exactly the characteristic behavior of a capability system, and now you know why it works that way.

The full comparison:

Criterion ACL (by columns) Capabilities (by rows)
Where the information lives In the object (inode, xattr) In the subject (descriptor table)
Question that is quick to answer "Who can access this file?" "What can this process access?"
Cost of the check Walk the list on every open(): O(entries) O(1): the key is already in hand
Auditing an object Easy: getfacl file Hard: you have to inspect every subject
Auditing a subject Hard: walk the whole file system Easy: ls -l /proc/<pid>/fd/
Revocation Easy: edit the list and it affects future accesses Hard: you have to chase down the keys already handed out
Delegation Needs privilege to edit the ACL Natural: you pass the key (SCM_RIGHTS over a UNIX socket)
Characteristic risk That the list grows and nobody understands it That a key leaks to someone who should not have it
Real-world examples UNIX permissions, POSIX ACLs, NTFS ACLs File descriptors, pidfd, OAuth tokens, seL4, Capsicum

The two characteristic risks deserve a comment, because you will meet them in real life. In the ACL world, the problem is accumulation: nobody ever removes permissions, the access list on a shared folder ends up with thirty entries and nested groups, and nobody knows who gets in any more. In the capability world, the problem is leakage: if meteo-api carelessly inherits an open descriptor for /etc/meteora/meteora.conf that its parent left open, it holds the key even though the file's permissions would have forbidden it. That is why execve closes descriptors marked FD_CLOEXEC, and why it pays to open with O_CLOEXEC by default (04-04): it is capability hygiene.

A vocabulary warning, so you do not get confused later on: the Linux capabilities of section 7 are named that way by historical inheritance, but they are not capabilities in this sense. They do not name a specific object; they are global permissions of the form "may do X anywhere on the system". It is an unfortunate name collision worth keeping in mind.

The eight Saltzer and Schroeder principles, applied to Meteora

In 1975, Jerome Saltzer and Michael Schroeder published eight design principles for protected systems. Half a century later there has been no need to add any, and most of the security disasters you read about in the news are the violation of one of them. We go through them one by one, with their exact application on meteo-01.

  1. Least privilege

Every subject should operate with the minimum set of privileges needed for its task, and for the minimum amount of time.

It is the principle from which almost all the others derive. In Meteora it translates into concrete decisions we have already taken: meteora is an account with no shell (nobody logs in with it), /etc/meteora/meteora.conf belongs to root and the service only reads it, /usr/bin/meteo-api belongs to root so that the service cannot rewrite its own binary, and meteo-api listens on 8080 so it does not need low-port privilege. And the one we will see in section 7: if it had to listen on 443, the correct answer is CAP_NET_BIND_SERVICE, not root.

The trailing clause "and for the minimum amount of time" is the one most often forgotten. A process that needs privilege only at startup must drop it afterwards: open the port, open the files, and then step down to UID 990 and discard the capabilities. From that instant on, a compromise no longer has them.

  1. Economy of mechanism

The protection mechanism should be as small and simple as possible, so that it can be reviewed exhaustively.

Code that does not exist has no bugs, and code nobody understands cannot be audited. This is the argument in favor of the nine UNIX bits over an ACL with thirty inherited entries: a comprehensible mechanism can be verified at a glance. In Meteora, applying it means preferring a forty-line AppArmor profile that a whole team understands to a six-hundred-line SELinux policy that only its author understands — and that will end up in permissive mode the day it gets in the way.

  1. Fail-safe defaults (deny by default)

What is allowed must be listed explicitly; everything else is denied. The baseline must be the absence of access, not its presence.

The difference between a blacklist ("forbid these paths") and a whitelist ("allow only these") is that the first fails silently for everything its authors did not imagine. Applied to Meteora, it is what other::--- does on the data, what the default drop policy of the firewall we will build in 05-03 does, and what an AppArmor profile does: it enumerates what meteo-api may touch and denies the rest of the file system, including whatever gets installed tomorrow.

  1. Complete mediation

Every access to every object must be checked, always, with no exceptions and no caches that can be bypassed.

If the mechanism checks the permission the first time and then trusts, there is a window. This is the point where UNIX file descriptors make a deliberate concession: the check happens in open() and not on every read(), for performance. The concession is a considered one — the key is only handed over if the permission existed — but it explains the effect of the chmod 000 that cuts off nobody. And the attack against complete mediation has a name and you already know it: TOCTOU (04-04), where the attacker changes the object between the check and the use. The countermeasure is always the same: check and act on the same descriptor, not on the same name.

  1. Open design

Security must not depend on the secrecy of the design, but on the secrecy of the keys.

This is Kerckhoffs's principle, and it explains why the password hashing algorithm of /etc/shadow is published down to the last detail and the system still works. Its opposite is security through obscurity: moving SSH to port 2222, obfuscating the code, hiding the admin URL. These are not useless as noise — they keep automated scans off your back — but they are not a security control, because their value evaporates the moment someone looks. Operational rule for Meteora: changing the SSH port is fine; doing it instead of configuring public keys and fail2ban is not.

  1. Separation of privilege

Where possible, require two independent conditions to grant an access, rather than just one.

Two keys to launch the missile. Two signatures for a transfer. In Meteora, it is the reason the logs have group adm and not meteora: administering and running are two different privileges and we do not give them to the same identity. It is also the reason for splitting the service into three processes (ingestor, aggregator, meteo-api) instead of just one: compromising the one that talks to the Internet does not give you what the one that writes to disk can do. And it is what the two-factor authentication of 05-02 does.

  1. Least common mechanism

Minimize the mechanisms shared between users, because every shared resource is a potential channel for leakage or interference.

Everything two subjects share is a route by which one can affect or spy on the other: a common /tmp, a common database, a CPU with a shared cache — hence attacks like Spectre — a shared memory segment. In Meteora, /dev/shm/meteora-cache is exactly a common mechanism between the three processes, and that is why its permissions and its size matter so much: it is shared surface. The systemd directive PrivateTmp=yes (05-03) is this principle turned into configuration: each service gets its own /tmp.

  1. Psychological acceptability

The mechanism must be easy to use correctly, or users will work around it.

It is the most ignored principle and the one that causes the most breaches. A policy that forces a password change every 30 days produces Meteora2026! followed by Meteora2026!!, and sticky notes. A sudo that asks for the password every thirty seconds produces a NOPASSWD: ALL. An AppArmor profile that breaks the service every time a version is deployed produces permanent aa-complain. A control that gets in the way gets switched off, and a switched-off control protects nothing. When you design security for Meteora, always ask yourself what the easy path is, and make sure the easy path is the secure one.

Access control models: DAC, MAC, RBAC and ABAC

With the vocabulary in hand, we can now name the four models that structure access control in any professional system.

Model Who decides the policy Central idea Typical example Weak point
DAC
Discretionary
The owner of the object If the file is yours, you decide who gets in UNIX permissions, POSIX ACLs, NTFS ACLs The owner can give access away; a compromised process inherits that discretion
MAC
Mandatory
The administrator, through a global policy Not even the owner can bypass the policy; the system enforces it SELinux, AppArmor, military classification levels Complex to write, to debug and to maintain
RBAC
Role-based
The administrator, by assigning roles Permissions are granted to roles, and people are assigned to roles sudo with groups, Kubernetes roles, Meteora's adm group Role explosion if the granularity is fine
ABAC
Attribute-based
A rules engine using attributes of subject, object and context "Allow if role=analyst AND time∈working hours AND origin=internal network" Cloud IAM policies, Open Policy Agent Hard to audit: the outcome depends on the context

The key to understanding why MAC exists is in the "weak point" cell for DAC, and it deserves a concrete example:

With DAC, meteo-api runs as meteora and meteora owns the .dat files. If an attacker controls the process, they can chmod o+r the data, or copy it to /tmp, or send it over a socket. They have the owner's discretion, because they are the owner.

With MAC, there is additionally a system policy that says: "a process labeled meteo_api_t may read files labeled meteora_data_t and nothing else; it may not write to /tmp, may not execute /bin/sh, may not open outbound sockets". That rule cannot be changed by the process, not even if it obtains UID 0, because it is not applied by the file's owner but by the kernel with its loaded policy.

That is the whole difference, and it is the reason the module does not end at 04-06. In practice, a modern Linux server combines all four: DAC in the file permissions, MAC in AppArmor or SELinux, RBAC in the organization of groups and sudo rules, and ABAC in the cloud policies surrounding it.

The "all or nothing" problem of root

UNIX was born with a binary privilege model: UID 0 can do everything, any other UID can do nothing special. The kernel was full of checks of the form if (uid == 0) allow;. It has admirable economy of mechanism, and it is also a serious problem.

The problem shows up in an everyday example. ping needs to open a raw ICMP socket, something a normal user cannot do. The classic solution was to make it setuid root. Result: a program anyone can run, which runs with all the system's privileges, when the only thing it needed was one. If ping has a bug, the attacker does not obtain "the ability to send ICMP packets": they obtain the entire machine. The disproportion between the privilege needed and the privilege granted is several orders of magnitude, and it is a textbook violation of the principle of least privilege.

Multiply that by the fifteen or twenty setuid binaries on a system and you have the historical attack surface of UNIX. Linux's solution, since 1998, is to slice up the power of root into pieces.

Linux capabilities: slicing up the power of root

Linux divides root's privileges into around forty independent capabilities. Every kernel check that used to say "is this UID 0?" now says "does it have such-and-such capability?".

Capability What it allows Legitimate use
CAP_NET_BIND_SERVICE Listen on ports < 1024 A web server or meteo-api on 443
CAP_NET_RAW Raw sockets ping, tcpdump
CAP_NET_ADMIN Configure network, interfaces, routes, firewall ip, VPN daemons
CAP_CHOWN Change a file's owner Package managers
CAP_DAC_OVERRIDE Bypass all file permissions Backups
CAP_DAC_READ_SEARCH Bypass read and traversal permissions Read-only backups
CAP_KILL Send signals to any process Supervisors
CAP_SETUID / CAP_SETGID Change identity Servers that drop privilege
CAP_LINUX_IMMUTABLE Remove chattr +i / +a Administration (04-06)
CAP_SYS_TIME Change the system clock NTP
CAP_SYS_PTRACE Debug and inspect the memory of other processes gdb, strace
CAP_SYS_MODULE Load kernel modules Almost never
CAP_SYS_ADMIN Mount, pivot_root, and dozens more operations The "junk drawer"

The last four are marked for a reason. CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE and CAP_DAC_OVERRIDE are practically equivalent to root, and must be treated as such:

  • CAP_SYS_MODULE: if you can load a module, you can run code in the kernel. That is more than root.
  • CAP_SYS_PTRACE: if you can inspect and modify the memory of other processes, you can take control of a privileged process.
  • CAP_DAC_OVERRIDE: if you bypass all file permissions, you can rewrite /etc/shadow and /etc/sudoers.
  • CAP_SYS_ADMIN: it became the drawer where everything that did not fit another category was dumped, and today it covers mounting file systems, manipulating namespaces and much more. Granting it is, in practice, granting root with a few extra steps.

The four sets, demystified

Every process carries several capability sets. The ones you need to understand are these:

Set What it means
Permitted The upper bound: what the process could activate
Effective What the kernel checks right now on every operation
Inheritable What can survive an execve (together with the ambient set, in practice)
Bounding set A ceiling that can never be raised: what is removed from here never comes back, not even for children
Ambient The modern mechanism that lets an unprivileged process keep capabilities when it executes another binary

The usage logic is simple and follows the principle of least privilege over time: a service starts with what is permitted, activates in the effective set only what it needs at the instant it needs it, and empties the bounding set of everything else so that neither it nor any of its children can ever get it back. That emptying is what CapabilityBoundingSet= does in a systemd unit (05-03).

In practice: meteo-api on port 443

# Without capabilities: a non-root service can NOT listen below 1024
$ sudo -u meteora /usr/bin/meteo-api --port 443
error: bind(0.0.0.0:443): Permission denied

# Option A — grant the capability to the BINARY (file)
sudo setcap 'cap_net_bind_service=+ep' /usr/bin/meteo-api
getcap /usr/bin/meteo-api
# /usr/bin/meteo-api cap_net_bind_service=ep

# Now it works, without being root and without setuid
sudo -u meteora /usr/bin/meteo-api --port 443    # starts correctly

# View the capabilities of a running process
grep Cap /proc/$(pgrep -f meteo-api)/status
# CapInh: 0000000000000000
# CapPrm: 0000000000000400
# CapEff: 0000000000000400
# CapBnd: 0000003fffffffff
capsh --decode=0000000000000400
# 0x0000000000000400=cap_net_bind_service

What exactly happened. setcap writes a security.capability extended attribute into the binary's inode — the xattrs of 04-06 — and when it is executed the kernel grants it that capability and only that one. The process runs as UID 990, with no root privilege whatsoever, except the pinpoint ability to bind to a low port. Compare it with the classic alternative — chmod u+s and root: the privilege granted goes from "the whole system" to "one operation". capsh --decode translates the hexadecimal mask from /proc/<pid>/status into readable names, and a CapBnd with all bits set indicates that the bounding set is intact, something that should not happen in a well-configured service.

There is an option B that is better than A, and it is worth knowing now even though we will develop it in 05-03: instead of marking the file, you request the capability in the systemd unit:

[Service]
User=meteora
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

It is superior for three reasons. The on-disk binary is not marked, so copying it elsewhere does not drag privilege along. The bounding set is reduced to that single capability, so neither the process nor its children can ever obtain any other. And NoNewPrivileges=yes prevents any later privilege gain, including the effect of any setuid binary the process might execute.

Auditing capabilities

# Which system binaries have capabilities assigned?
sudo getcap -r /usr /bin /sbin 2>/dev/null
# /usr/bin/ping cap_net_raw=ep
# /usr/bin/meteo-api cap_net_bind_service=ep

# View the capabilities of the current shell
capsh --print | head -5

Just like the list of setuid binaries in 04-06, the list of binaries with capabilities is an inventory you must know and compare periodically. A binary with cap_dac_override or cap_sys_admin appearing out of nowhere is as red an alarm as a new setuid root binary, and it draws less attention because ls -l does not show it: there is no s to give the privilege away. It only shows up with getcap.

Mandatory access control: SELinux and AppArmor

Let us go back to the opening scenario. meteo-api is compromised, runs as meteora, and all the permissions are correct. With DAC it can: read every .dat, write to /tmp, execute /bin/sh, open a socket to the Internet and send the data out. None of that violates a single permission bit.

Mandatory access control adds a second check, after the DAC one and completely independent of it:

graph TB
    A["The process requests an operation<br/>(open, connect, exec...)"] --> B{"DAC check<br/>UID, GID, bits, ACL"}
    B -->|Deny| Z["EACCES"]
    B -->|Allow| C{"MAC check<br/>SELinux / AppArmor policy"}
    C -->|Deny| Y["EACCES + entry in the<br/>audit log"]
    C -->|Allow| X["Operation granted"]

The key property is in the diagram: you have to pass both. DAC may allow and MAC deny; MAC never grants what DAC denies. And the MAC policy is set by the system administrator, not by the file's owner, so a process with UID 0 does not get around it either.

The two usual implementations in Linux:

SELinux AppArmor
Origin NSA; default on Red Hat, Fedora, Android Canonical/SUSE; default on Ubuntu, available on Debian
Unit of policy Labels on the object (meteora_data_t) and on the subject (meteo_api_t) File system paths
Where the label lives In the inode's security.selinux xattr Nowhere: the profile names paths
Practical consequence Moving a file keeps its label; copying it may change it Moving a file changes the rule that applies to it
Expressiveness Very high: types, roles, MLS/MCS, transitions Medium: enough to confine services
Learning curve Steep Gentle: a profile reads like a list
Typical diagnosis ausearch -m AVC, sealert, restorecon dmesg, journalctl, aa-logprof
Modes enforcing, permissive, disabled enforce, complain (per profile)

Since meteo-01 is Debian, the natural example is AppArmor. Here is the skeleton of a profile for meteo-api — deliberately incomplete, because writing a full policy is not the object of this lesson:

# /etc/apparmor.d/usr.bin.meteo-api   (illustrative sketch, not complete)
#include <tunables/global>

/usr/bin/meteo-api {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  network inet stream,                        # TCP sockets: yes
  network inet6 stream,

  /etc/meteora/meteora.conf            r,     # configuration: READ ONLY
  /var/lib/meteora/readings/*.dat      r,     # data: READ ONLY
  /var/log/meteora/meteo-api.log       aw,    # log: APPEND only
  /run/meteora/api.sock                rw,
  /dev/shm/meteora-cache               rw,

  deny /etc/shadow                     rwx,   # explicit, even though DAC already blocks it
  deny /home/**                        rwx,
  deny /**/.ssh/**                     rwx,
  # There is no 'ix' or 'px' rule: this profile does NOT allow executing anything
}

Read the profile carefully, because every line is a decision. The rules are a whitelist: what does not appear is denied, including any file installed tomorrow — principle 3, fail-safe defaults. meteora.conf is r and not rw, so the process cannot rewrite its own configuration even if the DAC permissions get broken one day. The log is aw (append), the MAC version of the chattr +a from 04-06. And the decisive part is what is not there: no execution rule at all. A compromised meteo-api cannot launch /bin/sh, or curl, or python3, because AppArmor denies the execve of anything not listed. That breaks most automated attack chains, which take for granted that a shell follows the bug.

The realistic workflow for arriving at a profile like that, without breaking production:

sudo aa-status                              # which profiles exist and in what mode?
sudo aa-complain /usr/bin/meteo-api         # COMPLAIN mode: logs, does not block
# ... let the service run for a few days under real load ...
sudo journalctl -k | grep -i apparmor       # see what it would have blocked
sudo aa-logprof                             # refine the profile with what was observed
sudo aa-enforce /usr/bin/meteo-api          # switch on real blocking

The order matters and is the practical application of the psychological acceptability principle: you start in complain mode, which logs without blocking, you observe for several days with real traffic — including month-end, log rotation and a version deployment — and only then do you switch to enforce. A profile written in one sitting and enabled in production breaks the service, and a service broken by security gets switched off in twenty minutes and never comes back.

Confinement and sandboxing: seccomp and the syscall surface

There is one more layer, and it is the deepest. In 01-06 we saw that everything a process can ask of the system goes through a system call: around 350 of them on Linux x86-64. That is, literally, the complete interaction surface between a program and the kernel.

Now ask yourself: how many of those 350 does the ingestor need? It receives readings over a socket, validates them and writes them to a file. Its real list is around forty: read, write, openat, close, accept4, recvfrom, fsync, clock_gettime, mmap, exit_group and little else. The remaining 310mount, ptrace, init_module, keyctl, bpf, kexec_load, execve… — it never uses, but they are available, and each one is attack surface: kernel code a compromised process can try to reach, including the kernel's own bugs.

seccomp (secure computing mode) solves that by letting a process voluntarily and irreversibly renounce a set of system calls. From that moment on, any attempt to use them ends in EPERM, in SIGSYS or in the death of the process, depending on the configuration. The renunciation is irreversible — a process cannot remove its own filter — and it is inherited across fork and execve, so no shell launched from there escapes.

/* Sketch of the ingestor's startup, in commented pseudocode.
   In production you use libseccomp, which avoids writing BPF by hand. */
#include <seccomp.h>

int main(void) {
    init_station_socket();   /* 1. EVERYTHING that needs  */
    open_todays_file();      /*    privilege, done FIRST  */
    drop_privileges();       /*    before dropping to 990 */

    /* 2. Policy: deny by default, allow what is enumerated */
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ERRNO(EPERM));

    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read),     0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write),    0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(fsync),    0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(accept4),  0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(recvfrom), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    /* ... the rest of the whitelist ... */
    /* NOT allowed: execve, ptrace, mount, init_module, outbound socket */

    seccomp_load(ctx);                    /* 3. Irreversible from here on */

    main_loop();                          /* 4. Now confined */
}

The structure of this startup is the canonical pattern of a hardened service, and it is worth memorizing: first do everything that requires privilege (open the socket, open the files), then drop the privileges, and finally install the filter. The order is not negotiable: installing seccomp before opening the socket would make startup fail. From seccomp_load onwards, even if an attacker takes complete control of the process's execution flow, they cannot call execve, and therefore cannot get a shell; they cannot call ptrace, and therefore cannot inject themselves into another process; they cannot mount anything. They have inherited a process that is missing half the operating system.

Viewing the seccomp state of a running process:

grep Seccomp /proc/$(pgrep -f ingestor)/status
# Seccomp:	2          → 0 = disabled, 1 = strict mode, 2 = BPF filter

And the good practical news: you do not need to write C to have seccomp. Systemd exposes predefined lists that cover 95% of cases, and we will use them in 05-03:

[Service]
SystemCallFilter=@system-service        # sensible whitelist for a daemon
SystemCallFilter=~@privileged @mount @module @debug @reboot @swap
SystemCallArchitectures=native

The first line starts from a predefined set designed for services; the following ones, with ~, subtract whole groups of dangerous calls; and SystemCallArchitectures=native closes a classic hole, that of invoking calls through the 32-bit ABI to dodge a 64-bit filter.

Trusted computing base and attack surface

Two concepts that put all of the above in order and give you a criterion for deciding.

The trusted computing base (TCB) is the set of components the system's security depends on, and whose failure compromises it. It is not what you "trust" in the colloquial sense: it is what you are forced to trust because you have no way of verifying it from outside. On meteo-01 the TCB includes the firmware and the bootloader, the Linux kernel with its modules and its MAC policy, the processes running as root or with dangerous capabilities, the setuid binaries, and also — this one surprises people — the package update infrastructure and the keys those packages are signed with.

The golden rule is direct: the smaller the TCB, the more credible the system's security, because there is less code to audit and fewer things that can fail. It is principle 2 — economy of mechanism — turned into a design criterion, and it is the technical argument in favor of the microkernels of 01-05: moving the network driver out of the kernel takes it out of the TCB, and a bug in it stops being a total takeover.

The attack surface is the set of points through which an attacker's input enters. For Meteora, enumerating it is a half-hour exercise that pays off enormously:

Surface What makes it up on meteo-01 How to reduce it
Network Station port, HTTP API, SSH Firewall, listen only on the interface you need (05-03)
System calls ~350 available per process seccomp: down to ~40
File system Everything UID 990 can open AppArmor, ProtectSystem=strict
Privilege Setuid binaries, files with capabilities nosuid, NoNewPrivileges, periodic auditing
Installed software Every package and every dependency Minimize packages; manage dependencies (05-03)
People Accounts with access, sudo rules Account lifecycle, least privilege (05-02)

Each mechanism in this lesson attacks a different row of that table, and none covers two. That is, exactly, what defense in depth means.

The confused deputy problem

We finish with a classic problem from 1988 that explains a whole family of vulnerabilities and that, once you understand it, you see everywhere.

The original setup: a compiler that runs as a service with privileges of its own. Besides compiling, it keeps usage accounting in a file /var/lib/compiler/billing, which it has write permission on and users do not. The compiler accepts one argument: the name of the output file.

A user asks it to compile, giving as the output file… /var/lib/compiler/billing. The compiler, obediently, writes there. And it can, because it does have permission. The user has just destroyed the accounting records without holding any permission on them.

The confused deputy is a privileged program that is tricked into using its own authority on behalf of someone who does not have it. The program is not compromised: it does exactly what it is asked. The flaw is that it confuses two authorities: its own and that of whoever is asking.

Applied to Meteora, with a case anyone could write:

# ❌ meteo-api with a confused deputy
@app.route("/export")
def export():
    destination = request.args.get("destination")    # it comes from outside!
    data = read_todays_readings()
    with open(f"/var/lib/meteora/export/{destination}", "w") as f:
        f.write(data)                                # writes with meteora's permissions
    return "OK"

A request with destination=../../../../etc/meteora/meteora.conf makes meteo-api write with its own authority to a place the requester does not control. The process has not been compromised: it has used its legitimate permission on behalf of a stranger. It is the same structure as CSRF on the web (the browser is the confused deputy, and its cookies the authority), as SSRF (the server is the deputy, and its position inside the network the authority) and as many setuid binary abuses.

The correct version separates the authority from the request:

# ✔ A deputy that does not get confused
import os, re
BASE = "/var/lib/meteora/export"

@app.route("/export")
def export():
    destination = request.args.get("destination", "")
    # 1. Whitelist of shape, not blacklist of forbidden characters
    if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}\.csv", destination):
        return "Invalid name", 400
    # 2. Resolve and check that it is still inside BASE
    path = os.path.realpath(os.path.join(BASE, destination))
    if os.path.commonpath([path, os.path.realpath(BASE)]) != os.path.realpath(BASE):
        return "Path out of range", 400
    # 3. Write with O_NOFOLLOW and O_EXCL: no links, no overwrites
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o640)
    with os.fdopen(fd, "w") as f:
        f.write(read_todays_readings())
    return "OK"

Three defenses, and each one plugs a different hole. Whitelist validation accepts only the expected shape, instead of trying to enumerate what is forbidden — which always forgets something, like ..%2f or some exotic encoding. Resolution with realpath plus the prefix check neutralizes .. sequences and symbolic links pointing outside, and it is done after resolving, not before. And O_NOFOLLOW | O_EXCL closes the TOCTOU window of 04-04: if the destination already exists or is a link, the operation fails instead of following the link wherever the attacker wants. Above all three sits the design: the AppArmor profile from section 8 only allows writing to specific paths, so even if all three validations failed, the kernel would deny the write to /etc/meteora/. Four independent layers for a single bug: that is defense in depth genuinely applied.

A necessary warning. Everything in this lesson is explained for defending your own systems. Any security testing — even something as innocent as checking whether a service validates a parameter properly — must be carried out exclusively on systems you own or with express written authorization from the person responsible. Testing other people's systems without permission is a criminal offense in most jurisdictions, regardless of intent. And when a security decision has legal or regulatory implications — personal data, retention, breach notification — consult a compliance professional or legal counsel.

Common Mistakes and Tips

Confusing "it has the correct permissions" with "it is protected". Permissions limit which files a process can reach. They say nothing about which network it connects to, which programs it runs or which system calls it uses. A compromised meteo-api with perfect permissions can still open a shell if nothing stops it.

Using chmod u+s to solve a privilege problem. It grants all of the owner's privileges when almost always one is needed. The correct alternative is a specific capability — better still, via AmbientCapabilities in the systemd unit — a group, or a socket with the right permissions.

Believing Linux capabilities are "capabilities" in the theoretical sense. They are not: they do not name an object. CAP_DAC_OVERRIDE is not "may read this file", it is "may read all of them". And precisely because of that there are capabilities equivalent to root: CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE and CAP_DAC_OVERRIDE.

Auditing only setuid binaries and forgetting getcap. A binary with capabilities shows no mark at all in ls -l. If your privilege inventory only looks for the s, you have a blind spot. Also run getcap -r /usr /bin /sbin and keep the reference list.

Writing a MAC profile in one sitting and enabling it in production. It breaks the service, and a control that breaks the service gets switched off and never comes back. Always start in complain or permissive mode, observe for several days under real load — including deployments and log rotation — and only then switch on enforcing mode.

Putting the system into permissive or complain "temporarily" to debug. That "temporarily" lasts for years. If you need to debug, disable a single profile, write it down with the date and set yourself a reminder.

Relying on security through obscurity. Changing the SSH port reduces the noise from automated scans, and that is fine. It is not a security control, and it does not replace public keys or the firewall. Open design: the security is in the key, not in the secrecy of the design.

Tip: order any security decision with the domain question. Faced with any component, ask yourself: what exactly is its protection domain? When does it change domain? And what happens if an attacker inherits that entire domain? The three answers take you straight to the list of mechanisms you need.

Exercises

Exercise 1: Meteora's access matrix and its implementation

Build the complete access matrix of meteo-01 for the subjects ingestor, aggregator, meteo-api, nuria (analyst) and carlos (system administrator), over the objects /var/lib/meteora/readings/*.dat, /etc/meteora/meteora.conf, /etc/meteora/secrets.conf, /var/log/meteora/meteo-api.log, /dev/shm/meteora-cache and /run/meteora/readings.fifo. Then: (a) state, for each non-empty cell, which concrete mechanism implements it today (bits, ACL, group, capability); (b) point out which cell would be impossible to express with the nine bits alone and why; (c) explain what changes in the matrix if meteo-api turns out to be compromised, and which row does not change.

Exercise 2: shrinking meteo-api's domain

meteo-api must listen on port 443, read the .dat files and the configuration, and write to its log. Nothing else. Design the complete protection in three independent layers and, for each one, write the concrete configuration and explain which specific attack that layer stops and the others do not: (1) DAC, with owners and modes; (2) capabilities, deciding between setcap on the file and AmbientCapabilities in the unit, and justifying the choice; (3) MAC, with the skeleton of an AppArmor profile. Finish by stating which system calls it should not be able to use and which systemd directive would achieve that.

Exercise 3: identifying violated principles

For each of these five real-world situations, say which Saltzer and Schroeder principle or principles are violated, which concrete attack takes advantage of it and what the fix is:

  1. A deployment script runs chmod -R 777 /var/lib/meteora "so that permissions stop causing trouble".
  2. Company policy forces a password change every 30 days and forbids password managers.
  3. meteo-api runs as root "because that way it definitely works".
  4. The administration API is undocumented and lives at /admin-x7f3, with no authentication, "because nobody will know about it".
  5. Meteora's three processes share /tmp and pass temporary files to each other with predictable names such as /tmp/meteora-cache-today.

Solutions

Solution 1

The matrix (R = read, W = write, A = append, X = execute, — = no access):

*.dat meteora.conf secrets.conf meteo-api.log meteora-cache readings.fifo
ingestor R, W R R R, W R (reads from the FIFO)
aggregator R R R, W
meteo-api R R R A R
nuria R
carlos R (via sudo) R, W (via sudo) R, W (via sudo) R (group adm)

(a) Mechanisms. The cells for the three processes are implemented with classic DAC: all three run with UID 990 and GID 990, and the files are meteora:meteora 0640, except the configuration, which is root:meteora 0640 — group read, root-only write. The nuria cell is a POSIX ACL entry (setfacl -m u:nuria:r) plus its default ACL for future files. The carlos cell over the logs is membership of the adm group, and the others are sudo rules (05-02), not direct access. If meteo-api listened on 443, a capability would appear, CAP_NET_BIND_SERVICE, which is not a cell of this matrix but a global privilege over the system: exactly the anomaly we discussed in section 7.

(b) The cell that is impossible with nine bits is nuria's over the .dat files. The nine bits only express permissions for one user, one group and the rest; the user is already meteora and so is the group, so the only way to give a fourth identity access would be to put it in the meteora group — which would additionally give it meteora.conf and secrets.conf — or to open up other — which would give it to the whole system. It is exactly the overflow of the three-class model that motivates ACLs, and that is why a named entry is needed.

(c) If meteo-api is compromised, its row does not change formally: the attacker inherits exactly those rights. And that is the serious part, because it includes secrets.conf with the API keys, all the .dat files and meteora-cache. But the attacker additionally obtains everything the matrix does not represent: executing /bin/sh, opening outbound sockets, writing to /tmp, reading /etc/passwd, and persisting. The row that does not change is carlos's: his privileges go through sudo and require an additional authentication the attacker does not have. That is the lesson of the exercise: the access matrix describes DAC, and DAC is not the whole story; to bound what the matrix cannot see you need AppArmor and seccomp.

Solution 2

Layer 1 — DAC:

sudo chown root:root  /usr/bin/meteo-api      && sudo chmod 0755 /usr/bin/meteo-api
sudo chown root:meteora /etc/meteora/meteora.conf && sudo chmod 0640 /etc/meteora/meteora.conf
sudo chown -R meteora:meteora /var/lib/meteora
sudo find /var/lib/meteora -type d -exec chmod 2750 {} +
sudo find /var/lib/meteora -type f -exec chmod 0640 {} +
sudo chown meteora:adm /var/log/meteora/meteo-api.log && sudo chmod 0640 /var/log/meteora/meteo-api.log
sudo chattr +a /var/log/meteora/meteo-api.log

What this layer stops and the others do not: access by other users and other system services to Meteora's data. If another application with its own service account is installed tomorrow, it will not be able to read a single .dat. It is the layer that separates tenants inside the same machine, and neither capabilities nor AppArmor replace it.

Layer 2 — capabilities. The correct choice is AmbientCapabilities in the unit, not setcap on the file:

[Service]
User=meteora
Group=meteora
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

Reasons for the choice: with setcap, the privilege is recorded in the binary's inode, so anyone who runs it gets it and a copy of the file could drag it along; with AmbientCapabilities, the privilege is granted by systemd to this service only, the on-disk binary stays clean, and CapabilityBoundingSet additionally prevents forever the process or its children from acquiring any other capability.

What this layer stops and the others do not: escalation to root. Without it, the "easy" way to listen on 443 would be to run as root or with setuid, and then a bug in request parsing would hand over the entire system. With it, the compromised process cannot load modules, or mount, or bypass file permissions, or debug other processes. NoNewPrivileges=yes finishes the job: even if the process executed a setuid binary, it would gain no privilege.

Layer 3 — MAC (AppArmor sketch):

/usr/bin/meteo-api {
  #include <abstractions/base>
  network inet stream,
  /etc/meteora/meteora.conf       r,
  /var/lib/meteora/readings/*.dat r,
  /var/log/meteora/meteo-api.log  aw,
  /run/meteora/api.sock           rw,
  deny /etc/shadow  rwx,
  deny /home/**     rwx,
  deny /tmp/**      wx,
  # no execution rules: it cannot launch ANY program
}

What this layer stops and the others do not: the abuse of legitimate permissions. DAC lets meteora write to /tmp, read /etc/passwd and execute /bin/sh; capabilities say nothing about that because those are not root privileges. AppArmor denies it point by point, and above all cuts off getting a shell, which is the first step of almost any automated attack chain.

System calls it should not be able to use: execve/execveat (nothing to execute), ptrace (nothing to debug), mount/umount2, init_module/finit_module/delete_module, kexec_load, keyctl, bpf, setuid/setgid after startup, and unshare/setns. In systemd:

SystemCallFilter=@system-service
SystemCallFilter=~@privileged @mount @module @debug @reboot @swap @obsolete
SystemCallArchitectures=native

The three layers are independent: if the AppArmor profile is unloaded by mistake, DAC and the capabilities are still there; if the permissions get broken in a deployment, AppArmor keeps denying. That is what makes the sum worth more than the parts.

Solution 3

1. chmod -R 777. It violates least privilege (it grants the whole system the maximum possible right), fail-safe defaults (it inverts the policy: instead of denying by default, it allows everything) and separation of privilege (it erases the distinction between whoever runs the service and everyone else). The attack: any account on the machine — including that of another compromised service — can modify or delete the data, and the files are additionally left executable, which enables the "I write a binary and wait for someone to launch it" vector. Fix: chmod -R u=rwX,g=rX,o= or separating by type with find -type d/-type f, and diagnosing the real permission with namei -l instead of throwing the doors wide open.

2. 30-day expiry with no password manager. It violates psychological acceptability and, as a knock-on effect, least privilege — because it produces passwords reused across systems. The observable result is Meteora2026!Meteora2026!!Meteora2026!!!, passwords written on sticky notes and in shared documents, and reuse across services. Fix, aligned with modern guidance: long passwords rather than complex ones, no mandatory periodic expiry, a change only when there is an indication of compromise, checking against leaked-password lists, and encouraging a password manager plus a second factor. This is detailed in Users, Authentication and Privilege Escalation.

3. meteo-api as root. It violates least privilege in the most direct way possible, and also economy of mechanism — all the security now depends on there not being a single bug in the application code — and least common mechanism — it shares the most powerful identity with the rest of the system. The attack: any parsing bug in an HTTP request turns into total control of the machine, with no intermediate step. Fix: User=meteora, the specific capability if a low port is needed, NoNewPrivileges=yes and MAC confinement.

4. The API at /admin-x7f3 with no authentication. It violates open design (security depends on the secrecy of the path, not on a credential) and complete mediation (there is a route that passes through no access check at all). The attack requires guessing nothing: the path leaks through the proxy logs, through the browser history, through a screenshot in a ticket, through the Referer of a request or through a path scan. Fix: real authentication at that point — the path may stay unobvious, that does no harm — restriction by source network and logging of every access.

5. Shared /tmp with predictable names. It violates least common mechanism (the three processes share a resource that they also share with the whole system) and sets up a textbook TOCTOU: any user can create /tmp/meteora-cache-today before Meteora does, or replace it with a symbolic link to another file, making the service write wherever the attacker wants — with meteora's authority, which additionally turns it into a confused deputy. Fix on three fronts: PrivateTmp=yes in the systemd units, so each service gets its own isolated /tmp; temporary files created with mkstemp() instead of fixed names; and, for the real communication between the processes, the IPC mechanisms of Module 3 — the FIFO /run/meteora/readings.fifo and /dev/shm/meteora-cache, with permissions 0750 and 0660 in a directory of their own — instead of loose files in a public directory.

Conclusion

Protection is the internal mechanism with which the OS controls accesses; security is the global property in the face of an adversary. The first can be demonstrated, the second can only be reasoned about with a threat model, and hence the rule that governs the whole module: no mechanism is a solution, all of them are layers.

All access control is described with four pieces: subjects (processes, not users), objects (files, but also sockets, processes and shared memory), rights and protection domains — the set of (object, rights) pairs in force right now. The interesting part is the domain changes: the execve of a setuid binary is one, and that is where privilege escalation concentrates. Laid out in a table, subjects and objects form the access matrix, which no system stores whole and everyone slices in one of two ways: by columns gives you ACLs — the information lives in the object, "who accesses this?" is easy to audit and revocation is easy; by rows gives you capabilities — the key lives in the subject, the check is O(1), delegation is natural and revocation is hard. UNIX file descriptors are capabilities, and that finally explains why the permission is checked in open() and not in read().

The eight Saltzer and Schroeder principles are still the best checklist in existence: least privilege (and for the minimum amount of time), economy of mechanism, fail-safe defaults, complete mediation — whose enemy has a name, TOCTOU — open design as against security through obscurity, separation of privilege, least common mechanism and psychological acceptability, the most ignored and the one that causes the most breaches, because a control that gets in the way gets switched off. On top of them the four models are built: DAC (the owner decides; its weak point is that a compromised process inherits that discretion), MAC (the administrator decides and not even root gets around it), RBAC (permissions to roles) and ABAC (rules with attributes and context). A real server combines them all.

And the three Linux mechanisms, each attacking a different surface. Capabilities slice root's "all or nothing" into around forty pieces: CAP_NET_BIND_SERVICE lets meteo-api listen on 443 without being root, while CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE and CAP_DAC_OVERRIDE are practically equivalent to root and must be treated that way; they are better granted with AmbientCapabilities than with setcap, and they are audited with getcap because ls -l does not show them. SELinux and AppArmor add a second, mandatory check after DAC — labels versus paths — that turns correct permissions into something insufficient for the attacker: a profile with no execution rules stops a compromised meteo-api from getting a shell. And seccomp reduces the deepest surface of all, from ~350 system calls to the ~40 the ingestor really uses, with the canonical startup pattern: privilege first, drop next, filter last. All of it is organized with two ideas: keep the TCB small and enumerate the attack surface. And the confused deputy reminds us that a privileged program can be abused without being compromised, simply by confusing its authority with that of whoever is asking it for something.

With this we have the framework. But notice that all of it rests on a word we have not defined: identity. A protection domain is assigned to a subject, and the subject is identified by a UID. We have spoken of meteora, of nuria and of carlos as if the system knew who they are, when all the kernel knows are the numbers 990, 1002 and 1001. Where do those numbers come from? What happens exactly between typing a password and having a shell? How is that password stored so that not even root can read it? And how do you move up from one domain to another in a controlled and auditable way, which is what sudo does fifty times a day?

That is Users, Authentication and Privilege Escalation, where we will dissect /etc/passwd, /etc/shadow and /etc/group field by field, see how PAM chains together the modules that decide whether you get in, how SSH public key authentication works, and which categories of configuration error turn a normal account into root without anyone noticing.

Operating Systems Fundamentals

Module 1: Introduction to Operating Systems

Module 2: Resource Management

Module 3: Concurrency

Module 4: File Structures

Module 5: System Protection and Security

Module 6: Virtualization and Containers

Module 7: Administration and Troubleshooting in Practice

© Copyright 2026. All rights reserved