12. Protection and Security
Every abstraction in this book — processes, address spaces, files, devices — is also a boundary, and a boundary is only meaningful if something enforces it. This chapter covers the mechanisms operating systems use to decide who may do what (protection), the threats those mechanisms exist to resist (security), and the defensive techniques that have accumulated as attackers defeated each earlier generation of them. It matters because every other guarantee in the system rests on these: a scheduler's fairness, a file system's permissions, and a hypervisor's isolation are all worth exactly as much as the protection machinery underneath them.
Two terms are used precisely throughout. Protection is the internal mechanism: how the system controls access by programs and users to its own resources. Security is the broader question of whether the system's data and behaviour can be trusted in the presence of an adversary — which includes protection, but also authentication, cryptography, physical access, and the humans involved. Protection is a mechanism; security is a property.
<a id="121-protection-mechanisms"></a>
12.1 Protection Mechanisms
This section covers the models operating systems use to express and enforce access decisions: the guiding principle, the abstraction of a protection domain, the matrix that describes all permissions, its two practical implementations, and the two policy models that scale it to real organisations.
Principle of Least Privilege
Theory
The most useful idea in this entire chapter is also the simplest: every program, user, and process should have exactly the privileges required for its task, and no more. It is the principle of least privilege, and nearly every other mechanism in this section is a way of implementing it.
The reasoning is about damage limitation, not prevention. Assume — correctly — that some component will be compromised: a bug will be exploited, a credential will leak, an administrator will be careless. The question then becomes not "how do we make that impossible" but "how much can the attacker do afterwards". If a PDF viewer runs with full user privileges, a bug in its font parser reads every file the user owns. If it runs in a sandbox that can read one file and write nothing, the same bug yields almost nothing.
The classic illustration is the hotel key. A guest gets a key to one room, housekeeping gets a floor, the manager gets everything. Losing a guest key costs one room. If every key opened every door, losing any key would cost the whole hotel. Least privilege is the observation that the number of doors a key opens is a choice, and usually a badly made one.
Three related principles are usually stated alongside it, and they interlock:
- Fail-safe defaults. Deny by default; permit by explicit grant. A firewall rule set that ends in
DROPis safe when someone forgets a rule; one that ends inACCEPTis not. - Separation of privilege. Require more than one condition for a sensitive action — two-person authorisation, or splitting a daemon so that only a small part runs privileged.
- Complete mediation. Check every access, not just the first. Caching an authorisation decision means that revoking a right may not take effect.
The obstacle is not understanding the principle but applying it, because privilege is convenient and least privilege requires knowing exactly what a program needs. Systems therefore make it as easy as possible:
- Privilege separation splits a program so that only a tiny, auditable part runs with privilege. OpenSSH's design is the canonical example: the process handling untrusted network data runs unprivileged in a chroot, and hands narrow requests to a small monitor process.
- Privilege dropping acquires a privilege, uses it, and permanently discards it. A web server binds port 80 as root and then
setuids towww-databefore serving a single request. - Capabilities (next topics) subdivide root, so a program needing to bind a low port gets
CAP_NET_BIND_SERVICErather than everything. - Sandboxing restricts even the unprivileged case, which is Section 12.3.
The measure of success is a counterfactual: if this component were fully compromised right now, what could the attacker reach? That question, asked of each component in turn, is what least privilege operationalises.
Example
THE BLAST RADIUS OF ONE BUG
WITHOUT least privilege WITH least privilege
+---------------------------+ +---------------------------+
| web server runs as root | | root: binds :80, then |
| | | DROPS to www-data |
| bug in image parser | | bug in image parser |
| -> read /etc/shadow | | -> read only files |
| -> write /etc/cron.d | | www-data can read |
| -> load a kernel module | | -> no writes outside |
| -> FULL SYSTEM | | /var/www/cache |
+---------------------------+ +---------------------------+
one bug = total compromise one bug = limited disclosure
PRIVILEGE SEPARATION (the OpenSSH pattern)
network (untrusted input)
|
+----------v-----------+ narrow, typed requests
| UNPRIVILEGED child |------------------------+
| - parses the protocol| |
| - chroot, no creds | v
| - this is where bugs | +-------------------------+
| will be found | | PRIVILEGED monitor |
+----------------------+ | - a few hundred lines |
| - "is this password ok?"|
compromising the child gets | - "make me a pty" |
the attacker a chrooted, +--------+ - audited exhaustively |
credential-less process +-------------------------+
THE THREE COMPANION PRINCIPLES
fail-safe defaults : default DENY; a forgotten rule fails closed
separation of privilege: two conditions required for one sensitive act
complete mediation : check EVERY access, not only the first
(a cached decision survives a revocation)
THE QUESTION TO ASK OF EVERY COMPONENT
"If this were fully compromised right now, what could it reach?"
import os, pwd, grp, resource
def drop_privileges(user="www-data", group="www-data"):
"""Acquire what you need as root, then discard root PERMANENTLY.
Order matters: get the ids first, drop the group before the user,
and verify afterwards."""
if os.getuid() != 0:
return # nothing to drop
pw = pwd.getpwnam(user)
gid = grp.getgrnam(group).gr_gid
# (1) Drop supplementary groups FIRST -- otherwise root's groups persist.
os.setgroups([])
# (2) Group before user: after setuid we no longer have the right to setgid.
os.setgid(gid)
# (3) setuid (not seteuid) makes the change PERMANENT and unrecoverable.
os.setuid(pw.pw_uid)
# (4) VERIFY. A failed drop that goes unnoticed is worse than no drop,
# because the code now believes it is unprivileged.
if os.getuid() == 0 or os.geteuid() == 0:
raise RuntimeError("privilege drop failed -- refusing to continue")
# (5) Prove it is irreversible.
try:
os.setuid(0)
raise RuntimeError("privileges were recoverable -- drop was ineffective")
except PermissionError:
pass # correct: root is gone for good
def bind_then_drop(port=80):
import socket
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", port)) # requires root (or CAP_NET_BIND_SERVICE)
s.listen(128)
drop_privileges() # ... and root is needed for nothing after this
return s # the socket stays valid: the fd was already open
def restrict_self():
"""Belt and braces: shrink what this process can do even as a normal user."""
resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256)) # fd exhaustion
resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) # fork bombs
resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) # no core dumps
resource.setrlimit(resource.RLIMIT_AS, (512 << 20, 512 << 20)) # 512 MB
# Audit: what currently runs with more privilege than it needs?
ps -eo user,pid,comm --sort=user | awk '$1=="root"' | head -20
find / -perm -4000 -type f 2>/dev/null # setuid binaries: each is a risk
find / -perm -2000 -type f 2>/dev/null # setgid
# Replace setuid-root with one capability.
getcap /usr/bin/ping
# /usr/bin/ping cap_net_raw=ep <- not setuid root any more
sudo setcap cap_net_bind_service=+ep /usr/local/bin/myserver
sudo chmod u-s /usr/local/bin/myserver # and remove the setuid bit
# systemd: least privilege declaratively, per service.
systemd-analyze security # rank every unit by exposure
systemd-analyze security nginx.service
# NAME DESCRIPTION EXPOSURE
# PrivateUsers= Service has access to user db 0.3
# ...
# -> Overall exposure level for nginx.service: 6.8 MEDIUM
cat <<'EOF' | sudo tee /etc/systemd/system/myapp.service.d/hardening.conf
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes # cannot gain privilege via setuid, ever
PrivateTmp=yes # its own /tmp, invisible to others
PrivateDevices=yes # a minimal /dev
ProtectSystem=strict # the entire filesystem is read-only...
ReadWritePaths=/var/lib/myapp # ...except this
ProtectHome=yes # /home, /root, /run/user are invisible
ProtectKernelTunables=yes
ProtectKernelModules=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
SystemCallArchitectures=native
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
EOF
sudo systemctl daemon-reload && sudo systemctl restart myapp
# sudo: grant specific commands, not a shell.
sudo visudo
# BAD : alice ALL=(ALL) NOPASSWD: ALL
# BAD : alice ALL=(ALL) /bin/vi <- vi can spawn a root shell (:!sh)
# GOOD: alice ALL=(root) /usr/bin/systemctl restart nginx
sudo -l # what may I actually run?
Key takeaways
- Least privilege means granting exactly the rights a task requires, and its purpose is to bound the damage from a compromise rather than prevent one.
- The operative question is counterfactual: if this component were fully compromised now, what could it reach?
- Fail-safe defaults (deny unless permitted), separation of privilege, and complete mediation are its companions.
- Privilege separation isolates untrusted input handling in an unprivileged process that makes narrow requests of a small privileged monitor.
- Privilege dropping acquires a right, uses it, and discards it permanently — and the drop must be verified, because a silently failed drop is worse than none.
- Capabilities, sandboxing, and systemd's per-service restrictions are the practical tooling that makes least privilege cheap enough to actually apply.
🧪 Practice
- List three privileges a typical web server needs at startup but not afterwards, and say how each can be dropped.
- Run
find / -perm -4000 -type fon your machine, pick one result, and explain what an attacker would gain by exploiting a bug in it.- Interview-style: "Why is
alice ALL=(ALL) /usr/bin/viin a sudoers file effectively equivalent to giving alice full root?" Hint: think about what a text editor can do besides editing text, and whether the privilege ends when the file is saved.
Protection Domains
Theory
Before you can decide whether an access is allowed, you need to know who is asking. The abstraction for that is the protection domain: a set of (object, rights) pairs defining exactly what its occupant may do. A process always executes within some domain, and every access it attempts is checked against that domain's rights.
The idea generalises several familiar things at once. In UNIX, a domain is essentially a (uid, gid, supplementary groups) triple — which is why changing user changes what you can touch. In a system with rings, the domain is the current privilege level: ring 0 may execute privileged instructions and ring 3 may not. In a capability system, the domain is literally the set of capabilities the process holds.
Domains may overlap, and this is the normal case rather than an exception: two users both having read access to /etc/passwd means their domains share that right. What matters is that overlap is deliberate — every shared right is a potential path between domains.
The interesting question is domain switching: how does a process change domains, and who controls it? Every switch is a privilege boundary, and every privilege boundary is attack surface.
- System calls switch from the user's domain to the kernel's, but only at defined entry points with validated arguments. The kernel does the work and returns; the user never executes in ring 0.
- setuid execution is UNIX's user-space domain switch: running a setuid binary changes the effective uid to the file's owner, so
passwdcan write/etc/shadowon behalf of an unprivileged user. This is genuinely necessary and genuinely dangerous — the binary now runs with the owner's rights while processing the caller's input, which is the classic setup for privilege escalation. - Explicit transitions in mandatory access control systems (SELinux domain transitions) change the security context on
exec, governed by policy rather than by a file bit.
The crucial design property is that a domain switch must be controlled by the system, not the caller. The caller decides that a switch happens (by running the program) but never what rights result. A setuid binary's rights come from its owner, not from whoever invoked it.
UNIX complicates the picture with three user IDs per process, and the distinction matters:
| ID | Purpose |
|---|---|
| Real uid | Who the user actually is; used for signals and accounting |
| Effective uid | What is checked for access decisions — the operative domain |
| Saved uid | A parked copy, letting a process drop privilege temporarily and regain it |
A setuid-root program starts with real uid = the caller, effective uid = 0, saved uid = 0. It may seteuid(real) to drop temporarily and seteuid(saved) to come back — useful, and a frequent source of bugs when code believes it has dropped privilege and has not.
Example
A DOMAIN IS A SET OF (OBJECT, RIGHTS) PAIRS
D1 (user: alice) D2 (user: www-data) D3 (kernel)
+---------------------+ +---------------------+ +------------------+
| /home/alice: rwx | | /var/www: r-x | | ALL memory: rw |
| /etc/passwd: r-- | | /var/log/nginx: -w- | | ALL devices: rw |
| /tmp: rwx | | /etc/passwd: r-- | | priv. instrs: x |
+---------------------+ +---------------------+ +------------------+
\ /
\______________________/
OVERLAP: both may read /etc/passwd.
Every overlap is a deliberate decision -- and a path
between domains if one of them is compromised.
DOMAIN SWITCHING: THE THREE MECHANISMS
(1) SYSTEM CALL user domain --[defined entry, validated args]--> kernel
returns to the user domain when done
(2) setuid EXEC alice's domain --exec(/usr/bin/passwd)--> passwd's
domain (euid=root), because the FILE is owned by
root and has the setuid bit. Alice chose to run it;
alice did NOT choose the resulting rights.
(3) POLICY TRANSITION unconfined_t --exec(httpd)--> httpd_t
(SELinux: the POLICY decides, not a file bit)
THE THREE UNIX USER IDs, IN A setuid-root PROGRAM
alice runs /usr/bin/passwd (owner root, mode 4755)
real uid = 1000 (alice) <- who you ARE; signals, accounting
effective uid = 0 (root) <- what is CHECKED for access
saved uid = 0 (root) <- parked copy, so you can come back
seteuid(1000) -> euid 1000: privileges dropped TEMPORARILY
seteuid(0) -> euid 0: regained from the SAVED id
setuid(1000) -> all three become 1000: PERMANENT, unrecoverable
The bug pattern: code calls seteuid(), believes it is unprivileged,
then an attacker triggers a path that calls seteuid(0) again.
WHY setuid IS DANGEROUS
the binary runs with the OWNER's rights
while processing the CALLER's input:
argv, environ, fds, cwd, umask, rlimits, signal dispositions
ALL of these are attacker-controlled. Every one has been an exploit.
// The three IDs, and the difference between temporary and permanent drops.
#include <unistd.h>
#include <stdio.h>
void show_ids(const char *label)
{
uid_t r, e, s;
getresuid(&r, &e, &s); // Linux: read all three at once
printf("%-22s real=%d effective=%d saved=%d\n", label, r, e, s);
}
int main(void)
{
show_ids("at start:"); // real=1000 effective=0 saved=0
// TEMPORARY drop: euid changes, saved uid still holds root.
seteuid(getuid());
show_ids("after seteuid(real):"); // real=1000 effective=1000 saved=0
// ^^^^^^
// Privilege is NOT gone. Anything that calls seteuid(0) gets it back.
seteuid(0);
show_ids("after seteuid(0):"); // real=1000 effective=0 saved=0
// PERMANENT drop: sets all three. This is what you almost always want.
if (setresuid(getuid(), getuid(), getuid()) != 0)
return 1; // ALWAYS check: a failed drop is
// the classic escalation bug
show_ids("after setresuid:"); // real=1000 effective=1000 saved=1000
// Verify the drop is irreversible.
if (seteuid(0) == 0) {
fprintf(stderr, "FATAL: privileges were recoverable\n");
return 1;
}
return 0;
}
# Which domain is this process in?
id
# uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo)
id -u; id -g; groups # the components of the domain
grep -E '^(Uid|Gid|Groups|CapEff):' /proc/self/status
# Uid: 1000 1000 1000 1000 <- real, effective, saved, fs
# CapEff: 0000000000000000 <- capability component
# Watch a domain switch happen.
ls -l /usr/bin/passwd
# -rwsr-xr-x 1 root root ... /usr/bin/passwd
# ^ the 's' is the setuid bit: exec switches euid to the FILE'S OWNER
passwd &
grep Uid /proc/$!/status # Uid: 1000 0 0 0 -- euid is root
# SELinux domains: transitions decided by policy, not by a file bit.
id -Z # unconfined_u:unconfined_r:unconfined_t
ps -eZ | grep httpd # system_u:system_r:httpd_t
sudo sesearch -T -s unconfined_t -t httpd_exec_t
# type_transition unconfined_t httpd_exec_t : process httpd_t;
# Audit every setuid binary: each is a domain-switch point.
find / -perm -4000 -type f -exec ls -l {} \; 2>/dev/null
# Remove the bit where the program no longer needs it.
sudo chmod u-s /usr/bin/some-legacy-tool
# NoNewPrivileges: forbid domain elevation for a process and all its children.
systemd-run --uid=1000 -p NoNewPrivileges=yes --pty /bin/bash
# Inside: setuid binaries no longer elevate, at all.
grep NoNewPrivs /proc/self/status # NoNewPrivs: 1
Key takeaways
- A protection domain is a set of
(object, rights)pairs; a process always executes in one, and every access is checked against it. - In UNIX the domain is essentially the uid/gid set; with rings it is the privilege level; in capability systems it is the held capability set.
- Domains commonly overlap, and each shared right is a deliberate connection between them.
- A domain switch must be controlled by the system: the caller decides that a switch occurs, never what rights result from it.
setuidexecution is UNIX's user-space domain switch and is inherently risky, because the program runs with the owner's rights while processing the caller's fully controlled input.- The real/effective/saved uid triple allows temporary drops, and code that confuses a temporary drop with a permanent one is a recurring source of escalation bugs.
🧪 Practice
- Define a protection domain for a backup service, listing the objects it needs and the rights on each.
- Explain the difference between
seteuid(1000)andsetresuid(1000,1000,1000)for a setuid-root process, and why the second is usually correct.- Interview-style: "Why is
NoNewPrivileges=yesa strong hardening option even for a service that already runs unprivileged?" Hint: consider what a compromised unprivileged process could otherwise execute, and what property of that execution the flag removes.
Access Matrix
Theory
Once domains and objects exist, the complete protection state of a system is just a table: rows are domains, columns are objects, and each cell holds the rights that domain has on that object. This is the access matrix, and it is the reference model that every real mechanism approximates.
Its value is conceptual rather than implementational. It gives a precise vocabulary for questions like "what can this domain do?" (read a row), "who can touch this object?" (read a column), and "is this access permitted?" (look up one cell). Almost every access-control discussion becomes clearer when reduced to this form.
| file1 | file2 | printer | domain D2 | |
|---|---|---|---|---|
| D1 | read, write | read | — | switch |
| D2 | — | read, write | — | |
| D3 | read | — | — |
Note the last column: domains are themselves objects, so the matrix can express the right to switch into another domain. That is what makes it a complete model rather than just a file-permission table.
The matrix also expresses how rights change, which is the part people usually forget. Special rights govern the matrix itself:
- Owner: the holder may add or remove any right in that object's column.
- Copy (often written
r*): the holder may give this right to another domain. A copy right may be transferred (moving it) or copied (duplicating it), and the distinction matters for how far a right can propagate. - Control: the holder may remove rights from another domain's row — a right over a domain, not over an object.
Once rights can propagate, confinement becomes the hard problem: can you grant a right while guaranteeing it goes no further? In general, no. Harrison, Ruzzo, and Ullman proved in 1976 that for a general access-control model with rights that can be created and copied, the safety question — "can this right ever leak to that domain?" — is undecidable. This is not a practical difficulty; it is a theoretical impossibility. Real systems escape it by restricting the model: take-grant systems, type-enforcement systems like SELinux, and systems that simply forbid arbitrary right propagation all make safety decidable by being less general.
The matrix's problem in practice is that it is enormous and almost entirely empty. A system with 10,000 users and 10 million files has a matrix of 100 billion cells, nearly all blank. Nobody stores it. Instead it is stored by column (access control lists, attached to objects) or by row (capabilities, held by domains) — which are the next two topics, and which are best understood as two ways of compressing the same table.
Example
THE ACCESS MATRIX (domains x objects, including domains AS objects)
| file1 | file2 | printer | D1 | D2 | D3
---------------+--------+--------+---------+--------+--------+--------
D1 (alice) | rw,own | r | | | switch |
D2 (www-data) | | rw,own | print | | |
D3 (backup) | r | r | | | |
^ ^
a CELL is the rights domains are OBJECTS too,
D_i has on O_j so 'switch' is expressible
READING IT THREE WAYS
ROW (D1) -> everything alice can do = a CAPABILITY LIST
COLUMN (file1) -> everyone who can touch file1 = an ACCESS CONTROL LIST
CELL (D1,f1) -> is this specific access allowed? = the authorisation check
RIGHTS THAT CHANGE THE MATRIX ITSELF
own : may add/remove any right in that object's COLUMN
copy (*) : may pass this right to another domain
r* = "read, and may grant read to others"
transfer = the right MOVES; copy = it is DUPLICATED
control : may remove rights from another domain's ROW
Before: D1: file2 = r* After D1 grants read to D3:
D3: file2 = (none) D1: file2 = r*
D3: file2 = r <- propagated
THE SAFETY PROBLEM (Harrison-Ruzzo-Ullman, 1976)
"Given this matrix and these rules, can right R ever reach domain D?"
-> UNDECIDABLE in the general model.
Real systems regain decidability by being LESS GENERAL:
- no arbitrary copy rights (classic UNIX)
- type enforcement with a fixed policy (SELinux)
- take-grant models with restricted propagation
WHY NOBODY STORES THE MATRIX
10,000 users x 10,000,000 files = 10^11 cells, ~99.99% EMPTY
store by COLUMN -> ACL (with the object: "who may touch me")
store by ROW -> CAPABILITIES ("what may I touch")
from collections import defaultdict
class AccessMatrix:
"""The reference model, implemented sparsely -- as every real system does."""
def __init__(self):
self.cells = defaultdict(set) # (domain, object) -> {rights}
self.domains, self.objects = set(), set()
def grant(self, domain, obj, *rights):
self.cells[(domain, obj)].update(rights)
self.domains.add(domain); self.objects.add(obj)
def check(self, domain, obj, right):
"""One cell lookup: the authorisation decision."""
return right in self.cells[(domain, obj)]
def row(self, domain):
"""A domain's capability list: everything it may touch."""
return {o: r for (d, o), r in self.cells.items() if d == domain and r}
def column(self, obj):
"""An object's ACL: everyone who may touch it."""
return {d: r for (d, o), r in self.cells.items() if o == obj and r}
def copy_right(self, holder, receiver, obj, right):
"""Propagation requires the COPY right (written r*), not just r."""
if f"{right}*" not in self.cells[(holder, obj)]:
raise PermissionError(f"{holder} may not propagate '{right}' on {obj}")
self.cells[(receiver, obj)].add(right) # granted WITHOUT the star:
self.domains.add(receiver) # the receiver cannot re-grant
def transfer_ownership(self, owner, obj, receiver, *rights):
if "own" not in self.cells[(owner, obj)]:
raise PermissionError(f"{owner} does not own {obj}")
self.cells[(receiver, obj)].update(rights)
m = AccessMatrix()
m.grant("alice", "file1", "read", "write", "own")
m.grant("alice", "file2", "read*") # read, AND may grant read
m.grant("www-data", "file2", "read", "write", "own")
m.grant("backup", "file1", "read")
m.grant("alice", "D2", "switch") # a domain used as an object
print("alice's capabilities :", m.row("alice"))
print("file1's ACL :", m.column("file1"))
print("alice read file1? :", m.check("alice", "file1", "read")) # True
print("backup write file1? :", m.check("backup", "file1", "write")) # False
m.copy_right("alice", "backup", "file2", "read") # allowed: alice holds read*
print("backup read file2? :", m.check("backup", "file2", "read")) # True
try:
m.copy_right("backup", "eve", "file2", "read") # backup got read, not read*
except PermissionError as e:
print("propagation blocked :", e)
# Sparsity: why the full table is never materialised.
full = len(m.domains) * len(m.objects)
print(f"\ncells stored {len([c for c in m.cells.values() if c])} of {full} possible "
f"({len([c for c in m.cells.values() if c])/full:.0%} occupied)")
# The column view: an object's ACL.
getfacl /var/www/html/index.html
# # owner: root
# user::rw-
# user:www-data:r-- <- one cell of the matrix
# group::r--
# other::---
# The row view: what one domain can reach.
sudo -u www-data -s <<'EOF'
find / -writable -type d 2>/dev/null | head # the writable part of the row
EOF
namei -l /var/www/html/index.html # every permission check on the path
# 'own' as a right: only the owner may change the column.
touch /tmp/f && chmod 600 /tmp/f
sudo -u nobody chmod 666 /tmp/f # chmod: Operation not permitted
# Domain-as-object: the right to switch INTO another domain.
sudo -l -U alice
# User alice may run the following commands:
# (root) /usr/bin/systemctl restart nginx <- a controlled switch
sudo sesearch -T -s unconfined_t | head -3 # SELinux domain transitions
Key takeaways
- The access matrix is the reference model: rows are domains, columns are objects, and each cell holds the rights that domain has on that object.
- Domains are themselves objects in the matrix, which is what lets it express the right to switch domains.
- Reading a row gives a capability list, reading a column gives an ACL, and reading a cell is the authorisation decision.
- Rights such as
own,copy/*, andcontrolchange the matrix itself, and copy rights make propagation possible. - The safety question — can a right ever leak to a given domain — is undecidable in the general model, so real systems restrict the model to regain decidability.
- The matrix is never stored directly because it is huge and almost entirely empty; ACLs and capabilities are its column-wise and row-wise compressions.
🧪 Practice
- Draw an access matrix for two users and three files, including one
ownright and one domain-switch right, then read out one row and one column in words.- Explain the difference between the
readandread*rights, and trace what happens when a domain holding onlyreadtries to grant it onward.- Interview-style: "Why do real systems not implement the general access matrix with arbitrary copy rights?" Hint: consider what question an administrator most wants answered about a permission change, and what was proved about answering it in 1976.
Access Control Lists
Theory
An access control list stores the access matrix by column: each object carries the list of who may do what to it. This is the dominant model in practice — UNIX permissions, POSIX ACLs, NTFS ACLs, S3 bucket policies, and database GRANT statements are all ACLs.
Its appeal is that it matches how people reason about resources. "Who can read this document?" is answered by looking at the document. Revoking access is equally direct: remove the entry, and the change is immediate and complete, because every access consults the list.
UNIX uses a deliberately compressed ACL: three entries (owner, group, other) times three rights (read, write, execute), stored as nine bits plus three special ones. The compression made sense when inodes were precious, and its economy is remarkable — but the limitation is severe: you cannot express "alice and bob may read, carol may write, everyone else nothing" without creating a group, and group creation is usually an administrator's privilege. This is the "one group per sharing relationship" problem, and it is why POSIX ACLs were added.
The bit layout is worth knowing exactly, including the three special bits that behave differently on files and directories:
| Bit | On a file | On a directory |
|---|---|---|
r | Read contents | List entries |
w | Modify contents | Create/delete entries |
x | Execute | Traverse (use in a path) |
| setuid (4000) | Run as the file's owner | (no effect) |
| setgid (2000) | Run as the file's group | New entries inherit the directory's group |
| sticky (1000) | (no effect) | Only the owner may delete an entry — this is what makes /tmp safe |
POSIX ACLs extend this with named user and group entries, plus a mask that caps the rights of all named entries and the group class. The mask trips people constantly: a file may list user:alice:rwx while the mask is r--, and alice gets only read. ls -l shows a + to indicate extended ACLs are present, and the group bits it displays are actually the mask.
Two design points recur across every ACL system:
Evaluation order. When entries conflict, which wins? POSIX ACLs use a specificity order: owner, then named users, then group entries, then other — first match wins, and later entries are not consulted. NTFS evaluates deny entries before allow, so an explicit deny always overrides. AWS IAM likewise makes explicit deny final. Knowing the order is essential, because "alice is in a denied group but has an individual allow" resolves differently on different systems.
Inheritance. New objects need sensible permissions. POSIX handles this with default ACLs on a directory, which are copied onto anything created inside it. NTFS has inheritance flags propagating down the tree. Without inheritance, every new file needs manual configuration, and in practice that means most of them are wrong.
The weaknesses of ACLs are structural. They scale by object, so answering "what can alice reach?" requires scanning every object in the system — the row question is expensive in a column-wise store. And they are subject to the confused deputy problem: a privileged program checks the ACL using its own identity rather than the requester's, and can be tricked into acting on the attacker's behalf. That is precisely what capabilities, the next topic, address.
Example
ACL = THE MATRIX STORED BY COLUMN (with the object)
/var/www/index.html
+----------------------------------+
| owner : root rw- |
| user : www-data r-- | <- a named entry (POSIX ACL)
| user : deploy rw- |
| group : webadmin r-x |
| mask : r-x | <- CAPS every named/group entry
| other : --- |
+----------------------------------+
"Who can touch this?" -> read the list. Direct.
"What can deploy touch?" -> scan EVERY object on the system. Expensive.
CLASSIC UNIX MODE BITS: 12 BITS TOTAL
4 7 5 5
| | | +--- other: r-x
| | +------ group: r-x
| +--------- owner: rwx
+------------ special: setuid(4) setgid(2) sticky(1)
THE SPECIAL BITS MEAN DIFFERENT THINGS BY TYPE
setuid on a FILE : run as the file's OWNER (passwd)
setgid on a FILE : run as the file's GROUP
setgid on a DIRECTORY : new entries inherit the DIRECTORY's group
(the standard shared-project-folder setup)
sticky on a DIRECTORY : only the OWNER of an entry may delete it
drwxrwxrwt /tmp
^ this 't' is why /tmp is usable at all:
world-writable, but you cannot delete
other people's files
THE POSIX ACL MASK: THE MOST COMMON SOURCE OF CONFUSION
$ getfacl report.txt
user::rw-
user:alice:rwx <- alice appears to have rwx ...
group::r--
mask::r-- <- ... but the MASK caps her at r--
other::---
EFFECTIVE rights for alice: r--
And in `ls -l`, the GROUP triplet shown is actually the MASK:
-rw-r-----+ 1 root root report.txt
^^^ ^ the '+' means extended ACLs exist -- ls is not the whole story
CONFLICT RESOLUTION DIFFERS BY SYSTEM
POSIX : owner -> named user -> group entries -> other. FIRST MATCH wins.
NTFS : explicit DENY -> explicit ALLOW -> inherited DENY -> inherited ALLOW
AWS IAM: any explicit Deny is FINAL, regardless of any Allow
"alice is in a denied group but has an individual allow" resolves
DIFFERENTLY on each of the three. Know which one you are in.
# Classic mode bits.
ls -l /etc/shadow /usr/bin/passwd /tmp
# -rw-r----- 1 root shadow /etc/shadow <- other: nothing at all
# -rwsr-xr-x 1 root root /usr/bin/passwd <- 's': setuid
# drwxrwxrwt 1 root root /tmp <- 't': sticky
stat -c '%A %a %U:%G %n' /etc/shadow /tmp
chmod 640 report.txt # rw- r-- ---
chmod u=rw,g=r,o= report.txt # the same, symbolically
chmod g+s /srv/project # new files inherit the group
chmod +t /srv/shared # only owners may delete
# POSIX ACLs: named entries beyond owner/group/other.
setfacl -m u:alice:rw report.txt # a named user entry
setfacl -m g:auditors:r report.txt # a named group entry
setfacl -x u:alice report.txt # remove one entry
setfacl -b report.txt # strip all extended entries
getfacl report.txt
# user::rw-
# user:alice:rw-
# group::r--
# mask::rw-
# other::---
# The mask trap, demonstrated.
setfacl -m u:alice:rwx report.txt
setfacl -m m::r report.txt # set the mask to r--
getfacl report.txt | grep -A1 alice
# user:alice:rwx #effective:r-- <- getfacl tells you
chmod g=rw report.txt # NOTE: chmod on an ACL file sets
getfacl report.txt | grep mask # the MASK, not the group entry
# Default ACLs: inheritance for new files.
setfacl -d -m u:deploy:rwx /srv/app # 'd' = default, applied to new entries
setfacl -d -m o::--- /srv/app
touch /srv/app/newfile && getfacl /srv/app/newfile # inherited automatically
# Recursive application, correctly (X = execute only where already executable).
setfacl -R -m u:deploy:rX /srv/app
chmod -R u=rwX,g=rX,o= /srv/app # capital X: directories yes, files no
# Answering the ROW question with a COLUMN store -- note how expensive it is.
sudo -u alice find / -writable -type f 2>/dev/null | head
# There is no index for "what can alice reach"; the whole tree must be walked.
# Windows: deny beats allow, and inheritance is explicit.
# icacls C:\data /grant "Analysts:(OI)(CI)R"
# icacls C:\data /deny "Interns:(OI)(CI)W" <- wins over any allow
# icacls C:\data /inheritance:r <- stop inheriting
import os, stat, subprocess
def explain_mode(path):
"""Decode a mode into the decisions it encodes."""
m = os.stat(path).st_mode
print(f"{path}: {stat.filemode(m)} ({oct(m & 0o7777)})")
is_dir = stat.S_ISDIR(m)
for who, bits in (("owner", 6), ("group", 3), ("other", 0)):
r = "r" if m >> bits & 4 else "-"
w = "w" if m >> bits & 2 else "-"
x = "x" if m >> bits & 1 else "-"
meaning = ("list / create-delete / traverse" if is_dir
else "read / write / execute")
print(f" {who:<6} {r}{w}{x} ({meaning})")
if m & stat.S_ISUID: print(" setuid: runs as the file's OWNER")
if m & stat.S_ISGID: print(" setgid: " + ("new entries inherit this group"
if is_dir else "runs as the group"))
if m & stat.S_ISVTX: print(" sticky: only an entry's owner may delete it")
for p in ("/etc/shadow", "/tmp", "/usr/bin/passwd"):
explain_mode(p); print()
# The umask: what is REMOVED from the requested mode at creation time.
old = os.umask(0o077) # 0o077: owner only
os.close(os.open("/tmp/private.txt", os.O_CREAT | os.O_WRONLY, 0o666))
print("with umask 077:", oct(os.stat("/tmp/private.txt").st_mode & 0o777)) # 0o600
os.umask(0o022) # the common default
os.close(os.open("/tmp/shared.txt", os.O_CREAT | os.O_WRONLY, 0o666))
print("with umask 022:", oct(os.stat("/tmp/shared.txt").st_mode & 0o777)) # 0o644
os.umask(old)
# The requested mode is a MAXIMUM; umask can only clear bits, never set them.
Key takeaways
- An ACL stores the access matrix by column, attaching to each object the list of who may do what — which matches how people reason about resources and makes revocation immediate.
- UNIX mode bits are a compressed nine-bit ACL plus setuid, setgid, and sticky; the special bits mean different things on files and directories.
- The sticky bit on a directory restricts deletion to each entry's owner, which is what makes a world-writable
/tmpsafe. - POSIX ACLs add named user and group entries plus a mask that caps them;
ls -lshows the mask in the group position and a+to signal extended entries. - Conflict resolution differs by system — POSIX takes the first match by specificity, while NTFS and AWS IAM let an explicit deny override any allow.
- Default ACLs and inheritance flags exist because without them new objects get wrong permissions by default.
- ACLs answer "who can access this object" cheaply and "what can this subject reach" expensively, and they are vulnerable to the confused deputy problem.
🧪 Practice
- Decode mode
4755and mode1777completely, naming every bit and its effect on a file and on a directory.- Set a POSIX ACL granting one named user
rwx, then set the mask tor--and verify withgetfaclthat the effective rights changed. Explain why.- Interview-style: "A file shows
-rw-r--r--+and a user in no listed group can still write to it. Explain how." Hint: the+meansls -lis showing you a summary, not the whole policy — and one of the triplets it shows is not what it appears to be.
Capabilities
Theory
A capability is the row-wise storage of the access matrix: instead of objects listing who may access them, subjects hold unforgeable tokens that each designate an object and the rights over it. Holding the token is the authorisation — there is no separate permission check against an identity.
The mental model is a physical key versus a guest list. An ACL is a guest list at the door: you present your identity, the doorman looks you up, and you are admitted or not. A capability is a key: possessing it is sufficient, nobody needs to know who you are, and you cannot enter a door you hold no key for.
That difference produces capabilities' defining advantage: they solve the confused deputy problem. The original 1988 example is a compiler that writes a billing record to a protected file. A user asks it to write its output to the billing file's path; the compiler, checking with its own privileges, happily overwrites the billing records. The ACL check was correct — the compiler was authorised — but the authority was exercised on behalf of someone who was not. With capabilities, the user must hand the compiler a capability for the output file, and they cannot hand over one they do not hold. Designation and authority travel together, which is the property that eliminates the whole class of bug.
The requirements for a capability system are precise:
- Unforgeable. A process must not be able to fabricate one. Enforcement is by keeping them in kernel space (file descriptors), by cryptographic signing (bearer tokens, macaroons), or by a tagged architecture.
- Delegable. A holder can pass a capability to another subject, ideally attenuated — a read-write capability handed on as read-only.
- Specific. A capability names one object and a right set, not a category.
File descriptors are the everyday example and are worth recognising as such. An open fd is an unforgeable token designating an object with specific rights, obtained by a check at open() time and usable thereafter without further checks. It can be passed to another process over a UNIX socket (SCM_RIGHTS), which is delegation. Much of the security architecture of modern sandboxes rests on this: a sandboxed process holds fds it was given and cannot open anything new.
The trade-offs against ACLs are real and symmetric:
| Question | ACL | Capability |
|---|---|---|
| Who can access this object? | Read the list — cheap | Search every subject — expensive |
| What can this subject reach? | Scan every object — expensive | Read its capability set — cheap |
| Revocation | Immediate: remove the entry | Hard: copies may exist anywhere |
| Confused deputy | Vulnerable | Immune by construction |
| Delegation | Requires administrative change | Native, and can be attenuated |
Revocation is the hard problem. Once a capability is delegated, retrieving it is difficult — the usual answers are indirection (capabilities point at an entry that can be invalidated), expiry (short-lived tokens, which is what OAuth access tokens do), or generation numbers. Every practical capability system picks one.
Linux "capabilities" (CAP_NET_RAW and friends) are, confusingly, not capabilities in this sense: they are privilege flags attached to a process, not unforgeable tokens designating objects. They are still valuable — they subdivide root — but the naming causes endless confusion. True capability systems include seL4, KeyKOS, Capsicum on FreeBSD, and, at the application layer, every bearer-token scheme.
Example
ACL vs CAPABILITY: GUEST LIST vs KEY
ACL (ask the door who you are) CAPABILITY (hold the key)
subject --"I am alice"--> object subject --[key: file1, rw]--> object
| |
check identity possession IS authority
against the list no identity lookup at all
THE CONFUSED DEPUTY (the bug capabilities eliminate)
WITH ACLs
compiler runs with rights to write /var/billing
user: "compile this, output to /var/billing"
compiler: checks ITS OWN rights -> allowed -> OVERWRITES BILLING
^ the check passed; the authority was exercised for the
wrong principal. The ACL was never consulted for the USER.
WITH CAPABILITIES
user must HAND the compiler a capability for the output file
user holds no capability for /var/billing -> cannot hand one over
-> the attack is not blocked by a check; it is INEXPRESSIBLE
FILE DESCRIPTORS ARE CAPABILITIES
open("/etc/passwd", O_RDONLY) --> fd 3
- unforgeable : an integer indexing a KERNEL table; fabricating 999
gets EBADF, not access
- specific : designates ONE file, with the rights from O_RDONLY
- checked once: at open(); every later read uses the token
- delegable : sendmsg() with SCM_RIGHTS passes it to another process
ATTENUATED DELEGATION
parent holds: [/data, read+write]
passes child: [/data, read] <- strictly weaker; the child cannot
amplify it back to read+write
THE REVOCATION PROBLEM, AND THE THREE ANSWERS
problem: you gave out a key; copies may now be anywhere.
indirection : capability -> [slot] -> object; invalidate the SLOT
expiry : the capability is valid for 5 minutes (OAuth tokens)
generation : object carries a generation number; bump it to
invalidate every outstanding capability at once
NAMING WARNING
Linux "capabilities" (CAP_NET_RAW, CAP_SYS_ADMIN) are PRIVILEGE FLAGS
on a process, NOT object-designating tokens. Different concept,
unfortunate shared name.
import os, socket, array, tempfile
# ---------- file descriptors as capabilities ----------
def demonstrate_fd_capability():
path = tempfile.mktemp()
with open(path, "w") as f:
f.write("secret data")
fd = os.open(path, os.O_RDONLY) # the CHECK happens here, once
os.chmod(path, 0o000) # revoke via the ACL...
print("after chmod 000:", os.read(fd, 11)) # ...but the fd STILL WORKS
# This is the capability property AND the revocation problem, in one line:
# the token was already issued, so changing the list does not recall it.
os.close(fd)
os.unlink(path)
# ---------- delegation: passing a capability to another process ----------
def send_fd(sock, fd):
"""SCM_RIGHTS: hand an unforgeable token to another process."""
sock.sendmsg([b"x"], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
array.array("i", [fd]))])
def recv_fd(sock):
msg, ancdata, _, _ = sock.recvmsg(1, socket.CMSG_SPACE(4))
for level, typ, data in ancdata:
if level == socket.SOL_SOCKET and typ == socket.SCM_RIGHTS:
return array.array("i", data)[0] # a NEW fd in THIS process
raise RuntimeError("no capability received")
def delegation_demo():
parent, child = socket.socketpair(socket.AF_UNIX)
path = tempfile.mktemp()
with open(path, "w") as f:
f.write("delegated content")
if os.fork() == 0: # ---- child ----
parent.close()
fd = recv_fd(child) # receives authority it could not
print("child read:", os.read(fd, 17)) # have obtained on its own
os._exit(0)
child.close() # ---- parent ----
fd = os.open(path, os.O_RDONLY)
send_fd(parent, fd) # delegate
os.wait(); os.close(fd); os.unlink(path)
# ---------- attenuation: pass on strictly less than you hold ----------
class Capability:
"""A capability object: designation + rights, delegable and attenuable."""
__slots__ = ("_obj", "_rights", "_revoked")
def __init__(self, obj, rights):
self._obj, self._rights, self._revoked = obj, frozenset(rights), False
def attenuate(self, rights):
"""Delegate a STRICTLY WEAKER capability. Amplification is impossible."""
rights = frozenset(rights)
if not rights <= self._rights:
raise PermissionError(f"cannot grant {rights - self._rights}: not held")
return Capability(self._obj, rights)
def use(self, right):
if self._revoked:
raise PermissionError("capability revoked")
if right not in self._rights:
raise PermissionError(f"'{right}' not in this capability")
return self._obj
def revoke(self):
self._revoked = True # indirection-style revocation:
# invalidate the token object itself
rw = Capability("/data/report", {"read", "write"})
ro = rw.attenuate({"read"}) # hand this to a less trusted component
print("delegate reads:", ro.use("read"))
try:
ro.use("write") # attenuated: cannot be amplified back
except PermissionError as e:
print("amplification blocked:", e)
# Linux 'capabilities' -- privilege flags, NOT object capabilities.
capsh --print | head -6
getcap /usr/bin/ping # cap_net_raw=ep
sudo setcap cap_net_bind_service=+ep ./myserver
grep Cap /proc/self/status
# CapInh, CapPrm, CapEff, CapBnd, CapAmb <- inheritable/permitted/effective/
capsh --decode=0000003fffffffff # bounding/ambient sets
# True capability-style sandboxing: hand over fds, then forbid opening more.
# FreeBSD Capsicum: cap_enter() puts the process in capability mode, after
# which only operations on ALREADY-HELD descriptors are permitted.
# Linux's nearest equivalents.
systemd-run -p PrivateNetwork=yes -p ProtectSystem=strict --pty /bin/bash
# Inside: the filesystem is read-only and there is no network -- the process
# can use what it was GIVEN and acquire nothing new.
# Landlock: per-process filesystem restriction, capability-like in spirit.
grep -i landlock /proc/self/status 2>/dev/null
ls /sys/kernel/security/landlock 2>/dev/null
# Bearer tokens: capabilities at the application layer.
# a JWT is an unforgeable (signed), delegable, attenuable capability;
# note that it has the SAME revocation problem, solved the SAME way:
# short expiry plus a revocation list.
echo '{"sub":"alice","scope":"read:reports","exp":1757600000}' | base64 -w0
Key takeaways
- A capability is an unforgeable token designating an object together with rights over it; possession is the authorisation, with no identity lookup.
- Capabilities store the access matrix by row, so "what can this subject reach" is cheap and "who can reach this object" is expensive — exactly the inverse of ACLs.
- They eliminate the confused deputy problem because designation and authority travel together: an attacker cannot name an object they hold no capability for.
- File descriptors are real capabilities: unforgeable, object-specific, checked once at
open(), and delegable over UNIX sockets withSCM_RIGHTS. - Delegation can be attenuated — a holder passes on strictly weaker rights — and amplification is structurally impossible.
- Revocation is the hard part, addressed by indirection, expiry, or generation numbers; the same problem and the same solutions appear in bearer tokens.
- Linux
CAP_*"capabilities" are privilege flags on a process, not object capabilities, despite the shared name.
🧪 Practice
- Open a file, then
chmod 000it, and show that reads through the existing descriptor still succeed. Explain what this demonstrates about capabilities and revocation.- Describe the confused deputy problem in your own words and show how capability-based designation makes the attack inexpressible.
- Interview-style: "Why do OAuth access tokens expire after minutes while refresh tokens last for months?" Hint: an access token is a capability that may have been copied anywhere; ask which of the three revocation strategies this design is choosing, and what the refresh token's different exposure justifies.
Role-Based Access Control
Theory
ACLs and capabilities describe mechanism. Neither says anything about how to decide what the permissions should be — and in an organisation of any size, that decision is the hard part. Assigning permissions user by user produces a system nobody understands within months: people change jobs, permissions accumulate, and nobody dares remove anything.
Role-based access control inserts a layer of indirection. Permissions are assigned to roles, users are assigned to roles, and a user's effective permissions are the union of their roles'. The indirection is the whole idea, and it pays off because roles change far more slowly than people do.
users --many-to-many--> roles --many-to-many--> permissions
The practical consequences are immediate. Onboarding becomes "assign the accountant role" rather than reconstructing thirty individual grants. A job change becomes one role swap, which actually removes the old access instead of layering new access on top — the single biggest cause of privilege creep. And auditing becomes tractable: "what can an accountant do?" has one answer, reviewable once, rather than needing a survey of every accountant's individual grants.
The standard model (NIST RBAC) is defined in levels:
| Level | Adds |
|---|---|
| Flat (RBAC0) | Users, roles, permissions, sessions |
| Hierarchical (RBAC1) | Role inheritance — senior-dev inherits developer |
| Constrained (RBAC2) | Separation of duty constraints |
| Symmetric (RBAC3) | Both hierarchy and constraints |
Two refinements are what make RBAC more than a convenience.
Sessions let a user activate a subset of their roles rather than all of them at once. An administrator who is also a normal user works in a session with only the ordinary role active, and activates the admin role deliberately when needed. This is least privilege applied over time, and it is why sudo is better practice than logging in as root.
Separation of duty prevents dangerous combinations. Static SoD forbids a user from holding two conflicting roles at all — the person who creates vendors may not also approve payments. Dynamic SoD allows holding both but forbids activating them in the same session, so the same person cannot submit and approve one transaction even though they may do either job. These constraints encode controls that auditors and regulators require, which is why RBAC dominates in finance and healthcare.
RBAC's limitation is that it is fundamentally static: it answers "who" but not "when, where, or under what conditions". Attribute-based access control (ABAC) generalises it by evaluating a policy over attributes of the subject, resource, action, and environment — "a doctor may read a record if they are assigned to that patient and are on shift". AWS IAM conditions, XACML, and Open Policy Agent all work this way. The cost is that ABAC policies are much harder to audit: with RBAC you can read a role and know what it grants, while with ABAC you must reason about a predicate over runtime state. Most real systems end up combining them — roles for the coarse structure, attributes for the conditions.
Example
RBAC: INDIRECTION THROUGH ROLES
WITHOUT (direct assignment) WITH RBAC
alice -> read_ledger alice --+
alice -> post_journal |
alice -> run_reports bob ----+--> [ ACCOUNTANT ] --> read_ledger
bob -> read_ledger | --> post_journal
bob -> post_journal carol --+ --> run_reports
bob -> run_reports
carol -> read_ledger new accountant = ONE assignment
... x 200 accountants role change = ONE swap
... 600 grants to maintain audit = read ONE role
ROLE HIERARCHY (RBAC1)
[ security-admin ]
| inherits
[ senior-developer ] --> deploy_prod, approve_pr
| inherits
[ developer ] --> read_repo, push_branch, run_ci
| inherits
[ employee ] --> read_wiki, use_vpn
A senior-developer automatically holds everything below.
Define each permission ONCE, at the level where it belongs.
SESSIONS: LEAST PRIVILEGE OVER TIME
alice's assigned roles : { employee, developer, security-admin }
session 1 (normal work): { employee, developer } <- admin NOT active
session 2 (incident) : { employee, security-admin } <- activated on purpose
This is exactly why `sudo` beats logging in as root: the privileged
role is activated per command, not held all day.
SEPARATION OF DUTY
STATIC : a user may NEVER hold both roles
[vendor-creator] X [payment-approver]
-> enforced at ASSIGNMENT time
DYNAMIC : a user may hold both, but not ACTIVATE both in one session
alice may create vendors OR approve payments,
never both in the same session -> cannot self-approve
RBAC vs ABAC
RBAC: "accountants may read ledgers"
simple, auditable, STATIC
ABAC: "a doctor may read a record IF assigned to that patient
AND on shift AND on the hospital network AND it is not
an emergency-locked record"
expressive, CONTEXTUAL, much harder to audit
Most real systems: roles for structure, attributes for conditions.
from dataclasses import dataclass, field
@dataclass
class Role:
name: str
permissions: set = field(default_factory=set)
inherits: set = field(default_factory=set) # role hierarchy (RBAC1)
class RBAC:
def __init__(self):
self.roles, self.assignments = {}, {}
self.static_sod, self.dynamic_sod = [], [] # conflicting role pairs
def add_role(self, name, permissions=(), inherits=()):
self.roles[name] = Role(name, set(permissions), set(inherits))
def effective_permissions(self, role_name, seen=None):
"""Walk the hierarchy: a role holds its own plus all inherited."""
seen = seen or set()
if role_name in seen: # cycles would loop forever
return set()
seen.add(role_name)
role = self.roles[role_name]
perms = set(role.permissions)
for parent in role.inherits:
perms |= self.effective_permissions(parent, seen)
return perms
def assign(self, user, role):
"""STATIC separation of duty is enforced HERE, at assignment time."""
current = self.assignments.setdefault(user, set())
for a, b in self.static_sod:
if (role == a and b in current) or (role == b and a in current):
raise PermissionError(
f"static SoD violation: {user} may not hold both {a} and {b}")
current.add(role)
def start_session(self, user, activate):
"""DYNAMIC SoD is enforced HERE, at activation time."""
assigned = self.assignments.get(user, set())
activate = set(activate)
if not activate <= assigned:
raise PermissionError(f"{user} is not assigned {activate - assigned}")
for a, b in self.dynamic_sod:
if {a, b} <= activate:
raise PermissionError(
f"dynamic SoD violation: {a} and {b} in one session")
perms = set()
for r in activate:
perms |= self.effective_permissions(r)
return Session(user, activate, perms)
@dataclass
class Session:
user: str
active_roles: set
permissions: set
def can(self, permission):
return permission in self.permissions
rbac = RBAC()
rbac.add_role("employee", {"read_wiki", "use_vpn"})
rbac.add_role("developer", {"read_repo", "push_branch"}, {"employee"})
rbac.add_role("senior-developer", {"deploy_prod", "approve_pr"}, {"developer"})
rbac.add_role("vendor-creator", {"create_vendor"})
rbac.add_role("payment-approver", {"approve_payment"})
rbac.static_sod = [("vendor-creator", "payment-approver")]
rbac.dynamic_sod = [("developer", "senior-developer")]
print("senior-developer holds:", sorted(rbac.effective_permissions("senior-developer")))
# ['approve_pr','deploy_prod','push_branch','read_repo','read_wiki','use_vpn']
rbac.assign("alice", "senior-developer")
s = rbac.start_session("alice", {"senior-developer"})
print("alice can deploy_prod:", s.can("deploy_prod")) # True
rbac.assign("bob", "vendor-creator")
try:
rbac.assign("bob", "payment-approver") # static SoD
except PermissionError as e:
print("blocked:", e)
# Linux groups: the original, coarse RBAC.
groups alice
sudo usermod -aG docker,sudo alice # role assignment, effectively
# Note: 'docker' group membership == root, because the daemon runs as root.
# sudo: roles with command-level granularity and per-command activation.
sudo visudo
# User_Alias DEVS = alice, bob
# Cmnd_Alias DEPLOY = /usr/bin/systemctl restart app, /usr/local/bin/deploy
# DEVS ALL=(root) DEPLOY <- the role, and what it may do
sudo -l # which roles do I hold?
sudo -u postgres psql # activate a role for ONE command
# Kubernetes RBAC: the model implemented almost literally.
kubectl create role developer \
--verb=get,list,watch,create --resource=pods,deployments -n staging
kubectl create rolebinding alice-dev \
--role=developer --user=alice -n staging
kubectl auth can-i create deployments --as=alice -n staging # yes
kubectl auth can-i delete nodes --as=alice # no
kubectl describe clusterrole admin | head -20
# PostgreSQL: roles, inheritance, and explicit activation.
psql <<'EOF'
CREATE ROLE readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
CREATE ROLE analyst IN ROLE readonly; -- inheritance
CREATE USER alice IN ROLE analyst;
\du -- list roles and memberships
SET ROLE readonly; -- activate a subset: a session
RESET ROLE;
EOF
# AWS IAM: roles plus ABAC conditions on top.
aws iam list-attached-role-policies --role-name DeveloperRole
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123:role/DeveloperRole \
--action-names s3:DeleteBucket
# A condition block turns a role into ABAC:
# "Condition": {"StringEquals": {"aws:PrincipalTag/team": "${s3:ResourceTag/team}"}}
Key takeaways
- RBAC assigns permissions to roles and users to roles, adding an indirection layer that survives staff changes far better than direct grants.
- It makes onboarding, job changes, and auditing tractable, and it directly attacks privilege creep by replacing a role rather than adding permissions.
- Role hierarchies let each permission be defined once at the level where it belongs, with senior roles inheriting junior ones.
- Sessions allow activating a subset of assigned roles, applying least privilege over time — the principle behind
sudorather than a root login. - Static separation of duty forbids holding conflicting roles; dynamic SoD forbids activating them together, which is what prevents self-approval.
- RBAC is static and answers "who"; ABAC adds conditions on time, location, and relationship at the cost of much harder auditing, so most systems combine the two.
🧪 Practice
- Design a role hierarchy for a small engineering organisation with four roles, and list the permissions each level introduces.
- Give a concrete business process requiring static separation of duty and one requiring dynamic SoD, and explain why each needs its specific kind.
- Interview-style: "A user changed teams six months ago and still has access to their old team's systems. Which control failed?" Hint: the issue is not that new access was granted; ask what a role-based design does at a job change that direct permission assignment never does.
Mandatory Access Control
Theory
Everything so far has been discretionary: the owner of a resource decides who may access it. DAC is flexible and matches intuition, but it has a fundamental weakness — if the owner (or a program running as the owner) can grant access, then malware running with the owner's rights can grant access too. A document marked confidential can be copied to a public directory by any program the owner runs, and the system will permit it, because the owner is allowed to do that.
Mandatory access control removes the discretion. A system-wide policy, set by a security administrator, governs access, and no user can override it — not even the owner of the object, and often not even root. The policy is enforced on every operation regardless of what the owner wants.
The classical MAC model is multi-level security, built for military classification. Every subject has a clearance and every object a classification, drawn from an ordered set (unclassified < confidential < secret < top secret) plus compartments. The Bell-LaPadula model then enforces confidentiality with two rules whose names are memorable once you see the diagram:
- No read up (simple security property): a subject may not read an object above its clearance. Obvious.
- No write down (the star property): a subject may not write to an object below its clearance. Less obvious, and the important one — it prevents a secret-cleared process (or malware inside it) from copying secret data into an unclassified file.
Biba is its mirror image for integrity: no read down (do not consume untrusted data), no write up (do not contaminate trusted data). Confidentiality flows one way and integrity flows the other, which is why a system needing both must apply both models and finds them frequently in tension.
Practical operating systems implement MAC as type enforcement rather than lattices. SELinux labels every process and object with a security context — user:role:type:level — and the policy is a large set of allow rules over type pairs. A process in httpd_t may read httpd_sys_content_t and nothing else; if compromised, it cannot read shadow_t even running as root, because root is irrelevant to a type-enforcement decision. This is MAC's central promise: it confines a compromised privileged process, which is exactly what DAC cannot do.
The mechanisms in use today:
| System | Model | Notes |
|---|---|---|
| SELinux | Type enforcement plus optional MLS | Very fine-grained, complex; default on RHEL/Fedora/Android |
| AppArmor | Path-based profiles | Simpler to write and read; default on Ubuntu/SUSE |
| Smack | Simplified labels | Embedded systems |
| Windows MIC | Integrity levels | Biba-like; underlies IE/Edge sandboxing and UAC |
The operational reality is that MAC's cost is policy authorship. SELinux's default policy covers common services well; anything custom requires writing rules, and the temptation to run setenforce 0 when something breaks is the single biggest reason MAC fails in practice. The correct workflow — run permissive, collect the denials, generate a policy from them, then enforce — is well supported and worth learning, because a correctly confined service resists exactly the class of attack that everything else in this chapter is trying to contain.
Example
DAC vs MAC: WHO DECIDES
DAC: "I own this file, so I may share it."
owner's discretion -> malware running AS the owner has the same
discretion -> confidential data can be copied anywhere
MAC: "The policy says processes of type httpd_t may not read shadow_t."
no owner, no user, and NOT EVEN ROOT can override it
BELL-LaPADULA (confidentiality): NO READ UP, NO WRITE DOWN
TOP SECRET +----------+
| object | <-- a SECRET subject may NOT read up
SECRET +----------+
| SUBJECT | <-- cleared SECRET
CONFIDENTIAL+----------+
| object | <-- may NOT write down (this is the key rule:
UNCLASSIFIED+----------+ it stops secret data leaking downward,
including by malware inside the subject)
BIBA (integrity): the MIRROR IMAGE
HIGH INTEGRITY | object | <-- may NOT write up (no contamination)
| SUBJECT|
LOW INTEGRITY | object | <-- may NOT read down (no untrusted input)
Confidentiality flows DOWN-blocked; integrity flows UP-blocked.
A system needing both applies both, and they conflict constantly.
TYPE ENFORCEMENT (what SELinux actually does)
context = user : role : TYPE : level
system_u:system_r:httpd_t:s0
policy rules are over TYPE PAIRS:
allow httpd_t httpd_sys_content_t : file { read getattr open };
allow httpd_t httpd_log_t : file { append };
(no rule for shadow_t) -> DENIED, and denial is the DEFAULT
THE PAYOFF: CONFINING A COMPROMISED ROOT PROCESS
attacker achieves remote code execution in httpd, running as root
DAC : root -> reads /etc/shadow, writes /etc/cron.d, game over
MAC : the process is httpd_t; no allow rule reaches shadow_t;
the read fails. Being root is IRRELEVANT to the decision.
THE OPERATIONAL TRAP
something breaks -> `setenforce 0` -> protection gone, permanently
correct workflow : permissive -> collect denials -> generate policy
-> test -> enforcing
# --- SELinux: state and contexts ---
getenforce # Enforcing | Permissive | Disabled
sestatus
# SELinux status: enabled
# Current mode: enforcing
# Loaded policy name: targeted
ls -Z /var/www/html/index.html
# system_u:object_r:httpd_sys_content_t:s0 index.html
ps -eZ | grep httpd
# system_u:system_r:httpd_t:s0 1234 ? httpd
id -Z # this shell's context
# The classic failure: right DAC permissions, wrong SELinux label.
cp ~/index.html /var/www/html/ # copying carries the HOME label
ls -Z /var/www/html/index.html
# unconfined_u:object_r:user_home_t:s0 <- httpd_t has no rule for this
curl localhost # 403 Forbidden, despite mode 644
sudo restorecon -Rv /var/www/html/ # relabel to the policy's expectation
# Make it permanent for a non-default path:
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/web(/.*)?"
sudo restorecon -Rv /srv/web
# Reading denials -- the primary diagnostic.
sudo ausearch -m AVC -ts recent
# type=AVC avc: denied { read } for pid=1234 comm="httpd"
# scontext=system_u:system_r:httpd_t:s0
# tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file
sudo sealert -a /var/log/audit/audit.log # human-readable analysis + suggestions
# Booleans: policy switches for common variations, no policy writing needed.
getsebool -a | grep httpd | head
sudo setsebool -P httpd_can_network_connect_db on
# The correct workflow for a custom service.
sudo semanage permissive -a myapp_t # permissive for THIS type only
# ... exercise the application thoroughly ...
sudo ausearch -m AVC -c myapp | audit2allow -M myapp_policy
cat myapp_policy.te # REVIEW before installing
sudo semodule -i myapp_policy.pp
sudo semanage permissive -d myapp_t # back to enforcing
# --- AppArmor: path-based, far easier to read ---
sudo aa-status
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
cat /etc/apparmor.d/usr.sbin.nginx
# /usr/sbin/nginx {
# #include <abstractions/base>
# capability net_bind_service,
# /var/www/** r,
# /var/log/nginx/*.log w,
# deny /etc/shadow rwx, <- explicit, readable, path-based
# }
sudo aa-genprof /usr/local/bin/myapp # interactive profile generation
sudo aa-complain /usr/local/bin/myapp # this profile only, permissive
sudo aa-enforce /usr/local/bin/myapp
# --- Windows Mandatory Integrity Control (Biba-like) ---
# whoami /groups | findstr "Mandatory Label"
# Mandatory Label\Medium Mandatory Level
# icacls file.txt /setintegritylevel High
# A Low-integrity process (a browser tab) cannot write Medium objects.
# Bell-LaPadula and Biba, implemented to show why the rules are what they are.
LEVELS = {"unclassified": 0, "confidential": 1, "secret": 2, "top_secret": 3}
class MLSMonitor:
def __init__(self, model="bell-lapadula"):
self.model = model
def can_read(self, subject_level, object_level, subj_comps=frozenset(),
obj_comps=frozenset()):
s, o = LEVELS[subject_level], LEVELS[object_level]
if self.model == "bell-lapadula":
# NO READ UP, and compartments must dominate too.
return s >= o and obj_comps <= subj_comps
return s <= o # Biba: NO READ DOWN
def can_write(self, subject_level, object_level):
s, o = LEVELS[subject_level], LEVELS[object_level]
if self.model == "bell-lapadula":
return s <= o # NO WRITE DOWN: the leak-stopping rule
return s >= o # Biba: NO WRITE UP
blp = MLSMonitor("bell-lapadula")
print("BELL-LaPADULA (confidentiality), subject cleared SECRET:")
for obj in ("unclassified", "confidential", "secret", "top_secret"):
print(f" {obj:<13} read={blp.can_read('secret', obj)!s:<5} "
f"write={blp.can_write('secret', obj)}")
# unclassified read=True write=False <- cannot leak downward
# top_secret read=False write=True <- can write up ("blind write")
print("\nCompartments must also dominate:")
print(" secret[CRYPTO] subject reading secret[NUCLEAR] object:",
blp.can_read("secret", "secret", {"CRYPTO"}, {"NUCLEAR"})) # False
biba = MLSMonitor("biba")
print("\nBIBA (integrity), subject at HIGH integrity (= 'secret' slot):")
for obj in ("unclassified", "secret", "top_secret"):
print(f" {obj:<13} read={biba.can_read('secret', obj)!s:<5} "
f"write={biba.can_write('secret', obj)}")
# Note the exact inversion of the table above. That inversion is why a
# system needing BOTH confidentiality and integrity finds them in tension.
# Type enforcement: no lattice, just allowed (type, type, class, ops) tuples.
POLICY = {
("httpd_t", "httpd_sys_content_t", "file"): {"read", "open", "getattr"},
("httpd_t", "httpd_log_t", "file"): {"append", "open"},
("sshd_t", "shadow_t", "file"): {"read", "open"},
}
def te_check(scontext, tcontext, tclass, op):
"""Default DENY. Root is not a concept here -- only types matter."""
return op in POLICY.get((scontext, tcontext, tclass), set())
print("\nType enforcement (a COMPROMISED ROOT httpd process):")
for t, op in [("httpd_sys_content_t", "read"), ("shadow_t", "read"),
("httpd_log_t", "append"), ("shadow_t", "write")]:
print(f" httpd_t -> {t:<22} {op:<7} {te_check('httpd_t', t, 'file', op)}")
# shadow_t read = False, even as root: there is simply no rule.
Key takeaways
- Discretionary access control lets owners grant access, so malware running with the owner's rights inherits that discretion — the weakness MAC exists to fix.
- Mandatory access control enforces a system-wide policy that no user, owner, or even root may override.
- Bell-LaPadula protects confidentiality with no-read-up and no-write-down; the write-down rule is the one that actually stops data leaking.
- Biba is its mirror for integrity — no read down, no write up — and the two models are frequently in direct tension.
- Real systems use type enforcement rather than lattices: SELinux labels every subject and object and allows only explicitly permitted type pairs, with deny as the default.
- MAC's central value is confining a compromised privileged process, because being root is irrelevant to a type-enforcement decision.
- Its cost is policy authorship, and the correct workflow is permissive mode, collect denials, generate and review a policy, then enforce — never
setenforce 0as a fix.
🧪 Practice
- A subject cleared
secretaccesses objects at all four levels. Produce the full read/write table under Bell-LaPadula and explain the no-write-down rule.- Copy a file into a web root with
cpinstead ofmv, observe the resulting SELinux denial, and fix it withrestorecon. Explain why the DAC permissions were irrelevant.- Interview-style: "An attacker gets remote code execution as root in a web server on an SELinux-enforcing system. What can they still not do, and why?" Hint: ask what identity the access decision is actually made against, and whether uid 0 appears anywhere in that decision.
<a id="122-security-threats"></a>
12.2 Security Threats
Understanding attacks is a prerequisite for defending against them: every mitigation in Section 12.3 exists because a specific technique defeated what came before. This section covers the major threat classes at the level a system designer needs — what the mechanism is, why the vulnerable pattern arises, and what structurally prevents it.
Program Threats and Malware
Theory
A program runs with the privileges of whoever launched it. That single fact is the foundation of every malware category: if an attacker can get their code to run in your context, it inherits everything you can do. The categories below are usefully distinguished by how the code arrives and how it persists.
Trojan horse. Code that does what it advertises plus something else. The user's own decision to run it supplies the privilege, which is why this remains the most effective delivery method ever devised — no vulnerability is required. Modern forms are malicious npm or PyPI packages, cracked software, and fake installers. The defence is provenance: signed packages, reproducible builds, dependency pinning, and lockfiles.
Virus and worm. A virus attaches to a host program and spreads when that program runs, needing a carrier. A worm is self-contained and spreads on its own, typically by exploiting a network-reachable vulnerability. The distinction matters operationally: a virus spreads at human speed, a worm at network speed. Morris (1988) infected much of the early internet in hours; SQL Slammer (2003) saturated its vulnerable population in ten minutes, because a single UDP packet carried the whole payload.
Logic bomb. Code that lies dormant until a trigger condition — a date, a deleted employee record. Because it is written by an insider with legitimate commit access, code review and separation of duty are the relevant controls, not perimeter defence.
Rootkit. Malware whose purpose is to hide itself and other malware. The important variable is the layer it occupies: a user-space rootkit replaces ls and ps; a kernel rootkit hooks the syscall table so every tool lies; a bootkit or firmware implant survives OS reinstallation entirely. This is why detection should come from a layer below the compromise — offline disk analysis, network observation from a separate device, or hardware-rooted measured boot.
Ransomware. Encrypts data and sells the key. It is worth studying because it inverts the usual threat model: confidentiality is irrelevant to the attacker, availability is the target, and the defence is therefore backups rather than secrecy — specifically offline or immutable backups, since modern ransomware deliberately seeks out and encrypts reachable backups first.
Supply chain attack. Compromise the build system, the dependency, or the update channel, and the malicious code arrives with full legitimacy and a valid signature. SolarWinds and the xz-utils backdoor are the reference cases. This defeats most conventional defences by design, which is why reproducible builds, signed provenance (SLSA, Sigstore), and dependency review have become central.
The common thread across all of them is that execution is the privilege boundary. Everything hinges on whether attacker-chosen code runs in a privileged context, and the structural defences follow directly: least privilege bounds what runs, code signing controls what may run, noexec mounts and W^X control where code may run from, and application allowlisting controls which binaries may run at all.
Example
THE CATEGORIES, BY ARRIVAL AND PERSISTENCE
how it ARRIVES how it PERSISTS
--------------------------------- ------------------------------------
trojan : the user runs it cron / systemd unit / shell rc file
virus : attached to a carrier infected binaries on disk
worm : self-propagating over service that restarts on boot
the network
supply : arrives SIGNED, through already inside the trusted artifact
chain the legitimate update
channel
rootkit : any of the above, then hooks the layer BELOW your tools
hides
WORM PROPAGATION IS EXPONENTIAL -- which is why hours matter
generation: 1 2 3 4 5 6 7
infected : 1 10 100 1,000 10,000 100,000 1,000,000
SQL Slammer (2003): one 376-byte UDP packet, no TCP handshake,
-> the whole vulnerable population in about 10 MINUTES.
Patch windows are measured against this, not against human response time.
ROOTKIT LAYERS: WHY YOUR TOOLS LIE
layer what it subverts detected by
------------------ ------------------------- ----------------------
user-space binary ls, ps, netstat replaced checksums (rpm -V), or
a known-good static tool
library (LD_PRELOAD) readdir() filters results /proc vs ls comparison
kernel module the syscall table itself a layer BELOW: offline
-- EVERY tool now lies disk image, IDS on a
separate host
bootkit / firmware survives OS reinstall measured boot + TPM
attestation
Rule: never trust a compromised system's own report about itself.
RANSOMWARE INVERTS THE THREAT MODEL
usual threat : confidentiality -- keep the attacker from READING
ransomware : availability -- the attacker does not care what
it says, only that YOU cannot read it
-> the defence is BACKUPS, not secrecy
-> and modern ransomware encrypts reachable backups FIRST,
so the backup must be OFFLINE or IMMUTABLE (object lock, WORM media)
THE COMMON THREAD: EXECUTION IS THE PRIVILEGE BOUNDARY
attacker-chosen code running in a privileged context = compromise
bound what runs -> least privilege, sandboxing
control WHAT may run -> code signing, allowlisting
control WHERE from -> noexec mounts, W^X
control WHO may run it -> NoNewPrivileges, capability bounding sets
# --- integrity: does the system still match what was installed? ---
rpm -Va | head -20 # every file whose hash/mode/size changed
# S.5....T. c /etc/ssh/sshd_config
# ^ S=size 5=MD5 T=mtime -- '5' on a BINARY is the one that matters
debsums -c # the Debian equivalent
sudo aide --check # a proper file-integrity database
# --- the layer problem: compare tools against a lower layer ---
ls /proc | grep -E '^[0-9]+$' | wc -l # processes according to /proc
ps -e --no-headers | wc -l # processes according to ps
# A mismatch suggests ps (or the library beneath it) is filtering.
sudo lsmod | head # loaded kernel modules
sudo cat /proc/modules | head # ...and the raw list, for comparison
sudo kern_check 2>/dev/null || sudo rkhunter --check --sk
sudo chkrootkit
# --- persistence: where malware actually lives ---
systemctl list-unit-files --state=enabled | head -20
ls -la /etc/systemd/system/ /etc/cron.* /var/spool/cron/crontabs/ 2>/dev/null
crontab -l; sudo crontab -l
grep -rE 'curl|wget|base64|eval' ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null
ls -la ~/.config/autostart/ 2>/dev/null
sudo find / -name "*.service" -newer /etc/hostname 2>/dev/null # recently added
# --- controlling what may execute ---
mount | grep -E 'noexec' # /tmp, /dev/shm, /home should have it
sudo mount -o remount,noexec,nosuid,nodev /tmp
find /tmp /dev/shm -type f -executable 2>/dev/null # should be empty
# Signed-code enforcement.
mokutil --sb-state # Secure Boot enabled?
sudo dmesg | grep -i 'lockdown\|module verification'
# --- supply chain: verify provenance, pin versions ---
gpg --verify package.tar.gz.sig package.tar.gz
sha256sum -c SHA256SUMS
npm ci # install from the LOCKFILE, not the range
pip install --require-hashes -r requirements.txt
npm audit; pip-audit
cosign verify --key cosign.pub myregistry/myimage:v1.2.3 # signed artifacts
syft myimage:v1.2.3 -o spdx-json > sbom.json # know what is inside
grype sbom:sbom.json # and what is vulnerable
# --- ransomware: the only defence that actually works ---
aws s3api put-object-lock-configuration --bucket backups \
--object-lock-configuration 'ObjectLockEnabled=Enabled,Rule={DefaultRetention={Mode=COMPLIANCE,Days=30}}'
# COMPLIANCE mode: not even the account root can delete within the window.
sudo zfs snapshot -r tank@hourly-$(date +%H) # local snapshots: fast recovery
sudo zfs send tank@hourly | ssh offsite zfs recv backup/tank # offsite: real safety
import hashlib, os, json, stat, time
class IntegrityMonitor:
"""A minimal file-integrity monitor: the principle behind AIDE and Tripwire.
The baseline must be stored somewhere the attacker cannot reach, or it
is simply updated along with the files it is meant to protect."""
WATCHED = ["/usr/bin", "/usr/sbin", "/etc/systemd/system", "/etc/cron.d"]
def __init__(self, baseline_path="/secure/baseline.json"):
self.baseline_path = baseline_path
def _fingerprint(self, path):
st = os.lstat(path)
h = hashlib.sha256()
if stat.S_ISREG(st.st_mode):
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return {"sha256": h.hexdigest(), "mode": st.st_mode,
"uid": st.st_uid, "size": st.st_size, "mtime": st.st_mtime}
def build_baseline(self):
base = {}
for root in self.WATCHED:
for dirpath, _, files in os.walk(root):
for name in files:
p = os.path.join(dirpath, name)
try:
base[p] = self._fingerprint(p)
except OSError:
pass
with open(self.baseline_path, "w") as f:
json.dump(base, f)
os.chmod(self.baseline_path, 0o400) # and ideally store it OFF the host
return len(base)
def check(self):
with open(self.baseline_path) as f:
base = json.load(f)
findings = {"modified": [], "added": [], "removed": [], "mode_changed": []}
seen = set()
for root in self.WATCHED:
for dirpath, _, files in os.walk(root):
for name in files:
p = os.path.join(dirpath, name)
seen.add(p)
try:
cur = self._fingerprint(p)
except OSError:
continue
if p not in base:
findings["added"].append(p) # a new binary: suspect
elif cur["sha256"] != base[p]["sha256"]:
findings["modified"].append(p) # CONTENT changed
elif cur["mode"] != base[p]["mode"]:
findings["mode_changed"].append(p) # e.g. setuid added
findings["removed"] = [p for p in base if p not in seen]
return findings
def find_setuid_changes(findings):
"""A newly setuid binary is one of the highest-signal indicators there is."""
alerts = []
for p in findings["added"] + findings["mode_changed"]:
try:
if os.stat(p).st_mode & (stat.S_ISUID | stat.S_ISGID):
alerts.append(f"HIGH: {p} is setuid/setgid and was added or changed")
except OSError:
pass
return alerts
Key takeaways
- All malware rests on one fact: code runs with the privileges of its context, so getting code to execute in a privileged context is the whole objective.
- Trojans need no vulnerability at all — the user supplies the privilege — which is why provenance and signing matter more than perimeter defence.
- Worms spread at network speed rather than human speed, so patch windows must be measured in hours, not weeks.
- A rootkit's danger is determined by the layer it occupies; anything at or below the kernel makes the system's own tools unreliable, so detection must come from a lower layer or an external observer.
- Ransomware targets availability rather than confidentiality, so the defence is offline or immutable backups — reachable backups are encrypted first.
- Supply chain attacks arrive signed and legitimate, which defeats conventional defences and motivates reproducible builds, SBOMs, and provenance attestation.
- The structural controls all address execution: least privilege bounds it, signing controls what runs,
noexecand W^X control where code comes from, and allowlisting controls which binaries run.
🧪 Practice
- Distinguish a virus from a worm in terms of propagation requirement and speed, and give one historical example of each.
- Run
rpm -Vaordebsums -con your system and explain what a changed hash on a binary in/usr/binwould mean.- Interview-style: "A compromised server's
psandnetstatshow nothing unusual, but the network team sees it beaconing outbound every 60 seconds. What do you conclude and what do you do next?" Hint: consider which layer each observation comes from, and which of the two observers can still be trusted.
Buffer Overflow Attacks
Theory
A buffer overflow is what happens when a program writes more data into a fixed-size region than it can hold. The extra bytes do not vanish — they overwrite whatever is adjacent in memory. In languages without bounds checking (C and C++ above all), nothing detects this at the moment it happens, so the program continues running with corrupted state.
The reason this is a security problem rather than merely a bug is what lives next to buffers on the stack. A function's stack frame contains its local variables, the saved frame pointer, and the return address — the location execution resumes at when the function returns. Buffers grow toward higher addresses; the return address sits at a higher address than the locals. So a write past the end of a local buffer walks directly into the return address.
low addresses high addresses
[ buf[0..63] ][ saved frame ptr ][ RETURN ADDRESS ][ caller's frame ]
---- writing past here ------------> overwrites control flow
Overwrite the return address, and the ret instruction transfers control wherever the attacker chose. Control flow, not just data, is now attacker-determined. This is why a missing bounds check becomes arbitrary code execution.
The vulnerable patterns are a small, well-known set — functions that write until they find a terminator rather than until they run out of room:
| Unsafe | Why | Safer |
|---|---|---|
gets() | No length parameter at all; unusable safely | fgets() |
strcpy() | Copies until NUL, regardless of destination size | strlcpy()/snprintf |
strcat() | Same, appending | strlcat() |
sprintf() | Format output can exceed the buffer | snprintf() |
scanf("%s") | Unbounded | scanf("%63s") |
Related memory-safety bugs share the same consequence by different routes: heap overflows corrupt allocator metadata; use-after-free writes through a dangling pointer into memory that has been reallocated for another purpose; integer overflow produces a tiny allocation size followed by a large copy; and off-by-one ("<=" instead of "<") overwrites exactly one adjacent byte, which is sometimes enough.
The defences arrived in layers, each responding to the previous one being bypassed, and knowing the sequence explains why modern systems look the way they do:
- Stack canaries (
-fstack-protector-strong): a random value placed before the return address and verified on return. Detects sequential overwrites; bypassed by writes that skip over it or by leaking the canary. - Non-executable stack / W^X / NX: memory is writable or executable, never both. Defeats injecting code into the buffer; answered by return-oriented programming, which executes existing code fragments instead of new ones.
- ASLR: randomise addresses so the attacker does not know where anything is (Section 12.3). Answered by information leaks and, on 32-bit, by brute force.
- Control-flow integrity and hardware shadow stacks (Intel CET, ARM PAC/BTI): verify at runtime that control transfers go where the program's structure permits.
The structural fix, though, is not any of these. It is memory-safe languages — Rust, Go, Java, Python — where the bounds check is not optional. Microsoft and Google have each reported that roughly 70% of their severe vulnerabilities are memory-safety issues, which is why new systems code is increasingly written in Rust and why the mitigation stack above is best understood as damage control for code that already exists.
Example
STACK LAYOUT: WHY A BUFFER SITS NEXT TO CONTROL FLOW
low addresses high addresses
+-------------+--------+------------+-------------+------------------+
| char buf[64]| other | saved RBP | RETURN ADDR | caller's frame |
| | locals | | | |
+-------------+--------+------------+-------------+------------------+
^ writes start here ^ where `ret` jumps
|------------ a 64-byte buffer -------|
|------------ an 80-byte write -----------------> OVERWRITES the
return address
The bug is a missing bounds check. The CONSEQUENCE is that the
attacker chooses the next instruction pointer.
THE VULNERABLE PATTERN, IN ONE LINE
char buf[64];
strcpy(buf, user_input); // copies until NUL -- NEVER stops at 64
char buf[64];
snprintf(buf, sizeof buf, "%s", user_input); // stops at 64. Always.
THE MITIGATION ARMS RACE (each answered the previous)
1. STACK CANARY
[ buf ][ CANARY ][ saved RBP ][ ret addr ]
^ random value, checked before `ret`
a sequential overwrite must destroy the canary -> detected -> abort
bypassed by: leaking the canary, or writing non-sequentially
2. NX / W^X : a page is Writable XOR eXecutable
injecting code into buf no longer helps -- buf is not executable
bypassed by: RETURN-ORIENTED PROGRAMMING -- do not inject code,
CHAIN fragments of code that ALREADY EXIST and are
already executable
3. ASLR : randomise where everything is loaded
bypassed by: an information leak revealing one address
4. CFI / shadow stacks (Intel CET, ARM PAC+BTI)
verify at RUNTIME that each return goes where it came from
-> this is the one that actually breaks ROP
THE STRUCTURAL FIX IS NOT ON THAT LIST
~70% of severe CVEs at both Microsoft and Google are memory safety.
In Rust/Go/Java/Python the bounds check is not optional:
let buf = [0u8; 64]; buf[100] = 1; // panic, not corruption
The mitigations above are damage control for code that already exists.
// The same function written three ways. The difference is one bounds check.
// --- VULNERABLE: no bound on the write ---
void handle_vulnerable(const char *input)
{
char buf[64];
strcpy(buf, input); // BUG: writes until NUL, not until 64.
// An 80-byte input overwrites the return
// address and control flow becomes the
// attacker's choice.
process(buf);
}
// --- SAFE: bounded, truncation detected, always terminated ---
void handle_safe(const char *input)
{
char buf[64];
int n = snprintf(buf, sizeof buf, "%s", input); // never exceeds sizeof buf
if (n < 0 || (size_t)n >= sizeof buf) {
log_warn("input truncated (%d bytes needed)", n);
return; // TRUNCATION IS ALSO A BUG: silently
} // processing a shortened value has caused
process(buf); // its own security failures
}
// --- the off-by-one that people write constantly ---
void off_by_one(const char *src)
{
char dst[16];
size_t len = strlen(src);
if (len <= sizeof dst) // BUG: '<=' allows len == 16, and the
memcpy(dst, src, len); // NUL terminator then lands on byte 17
// correct: if (len < sizeof dst)
}
// --- the integer overflow that becomes a heap overflow ---
void *alloc_array_bad(size_t count, size_t size)
{
return malloc(count * size); // BUG: count * size can WRAP.
// count=2^61, size=8 -> the product is 0,
// malloc(0) succeeds, and the caller then
// writes 2^61 elements into it.
}
void *alloc_array_good(size_t count, size_t size)
{
size_t total;
if (__builtin_mul_overflow(count, size, &total)) // checked multiply
return NULL;
return malloc(total);
}
// --- use-after-free: the same consequence, a different route ---
void use_after_free(void)
{
char *p = malloc(64);
free(p);
// p is now DANGLING. The allocator may have handed this memory to
// someone else; writing through p corrupts their data -- and if the
// region now holds a struct with a function pointer, control flow again.
p = NULL; // setting the pointer to NULL after free
// turns a silent corruption into a crash
}
# What protections is this binary actually built with?
checksec --file=/usr/bin/myapp
# RELRO STACK CANARY NX PIE RPATH Symbols FORTIFY
# Full RELRO Canary found NX enabled PIE enabled No RPATH ...
hardening-check /usr/bin/myapp
readelf -l /usr/bin/myapp | grep -A1 GNU_STACK # RW (not RWE) = NX on
# Build with the full mitigation set -- these are not defaults everywhere.
gcc -o app app.c \
-D_FORTIFY_SOURCE=3 -O2 \ # compile-time + runtime bounds checks
-fstack-protector-strong \ # canaries on functions with buffers
-fstack-clash-protection \ # stack probing
-fPIE -pie \ # position independent: enables full ASLR
-Wl,-z,relro,-z,now \ # read-only GOT, resolved at load
-Wl,-z,noexecstack \ # NX stack
-fcf-protection=full \ # Intel CET: shadow stack + indirect branch
-Wall -Wextra -Wformat=2 -Werror
# System-wide ASLR (see Section 12.3).
cat /proc/sys/kernel/randomize_va_space # 2 = full randomisation
# Find the bugs before an attacker does.
gcc -fsanitize=address,undefined -g -O1 -o app app.c && ./app
# ==1234==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
# WRITE of size 80 at 0x... thread T0
# #0 0x... in handle_vulnerable app.c:12
valgrind --leak-check=full --track-origins=yes ./app
clang --analyze app.c # static analysis
cargo clippy # and in Rust, where the class
# mostly does not arise
# Fuzzing: the most effective way to find these automatically.
clang -fsanitize=fuzzer,address -o fuzz fuzz_target.c && ./fuzz -max_len=4096
# AFL++, libFuzzer, and OSS-Fuzz find memory-safety bugs at scale.
# Why memory-safe languages end the category, shown by contrast.
buf = bytearray(64)
try:
buf[100] = 1 # the bounds check is not optional
except IndexError as e:
print("Python:", e) # bytearray index out of range
data = b"A" * 80
buf[:len(data)] = data # ValueError, not silent corruption
# In C the equivalent memcpy succeeds, corrupts the frame, and the program
# continues running with an attacker-chosen return address.
# Where memory-safe languages can still get this wrong: FFI boundaries.
import ctypes
libc = ctypes.CDLL("libc.so.6")
dst = ctypes.create_string_buffer(16)
# libc.strcpy(dst, b"A"*100) # <- this WOULD overflow: ctypes hands the
# pointer straight to C, and C does not
# check. Safety stops at the FFI boundary.
libc.snprintf(dst, 16, b"%s", b"A" * 100) # bounded: the safe form
print("truncated safely:", dst.value[:16])
Key takeaways
- A buffer overflow writes past a region's end into adjacent memory; in languages without bounds checking nothing detects it as it happens.
- It becomes a control-flow vulnerability because the return address sits just above local buffers on the stack, so an overflow can choose where execution resumes.
- The vulnerable pattern is always a function that writes until a terminator rather than until it runs out of space —
strcpy,strcat,sprintf,gets. - Related bugs with the same consequence include heap overflows, use-after-free, integer-overflow-then-copy, and off-by-one errors.
- Mitigations arrived in response to each other: canaries detect sequential overwrites, NX prevents injected code, ASLR hides addresses, and CFI with shadow stacks finally addresses return-oriented programming.
- Roughly 70% of severe vulnerabilities at major vendors are memory-safety issues, so memory-safe languages are the structural fix and the mitigation stack is damage control.
- Sanitizers and fuzzing find these bugs cheaply during development, and every new C/C++ build should enable the full hardening flag set.
🧪 Practice
- Draw a stack frame containing a 32-byte buffer and label every field an overflow would reach in order. Explain why the return address is the interesting one.
- Rewrite three unsafe calls (
strcpy,sprintf,scanf("%s")) into bounded equivalents, and handle truncation explicitly in each.- Interview-style: "A team enabled NX and stack canaries, and an attacker still achieved code execution. How?" Hint: NX stops the attacker from running code they supplied; ask whether the attack requires supplying any new code at all.
Code Injection
Theory
Buffer overflows inject machine code. Code injection is the same failure at a higher level: attacker-supplied data is interpreted as code by some interpreter. The interpreter may be a SQL engine, a shell, a template renderer, a deserializer, an LDAP directory, or a browser's JavaScript engine — the vulnerability class is identical, and so is the fix.
The root cause is always the same confusion: a program builds a string that mixes trusted structure with untrusted values, then hands it to something that parses it. The parser cannot tell which parts the programmer wrote and which parts the user supplied, because by the time it arrives they are the same kind of thing — text.
"SELECT * FROM users WHERE name = '" + input + "'"
^
input = "x' OR '1'='1" changes the QUERY'S STRUCTURE,
not just a value in it
That is the whole bug. The attacker does not need to break out of a value; they need only supply text that the parser reads as syntax.
The major families all share this shape:
| Type | Interpreter | Typical entry |
|---|---|---|
| SQL injection | Database query parser | String-concatenated queries |
| Command injection | Shell | system(), popen, shell=True |
| Template injection | Template engine | User input used as a template, not as data |
| Deserialization | Object deserializer | pickle.loads, Java readObject on untrusted bytes |
| XSS | Browser JavaScript engine | Unescaped output in HTML |
| LDAP / XPath / NoSQL | Respective query parser | Concatenated filters |
| Log injection | Log processor | Unsanitised values reaching a format string (Log4Shell) |
The correct fix is structural and identical across all of them: never build code by concatenating data. Keep structure and data on separate channels.
- Parameterised queries. The SQL text is sent with placeholders and the values separately; the database compiles the structure first, so a value can never become syntax. This is not "escaping done well" — it is a different mechanism, and it is why it cannot be bypassed by a clever encoding.
- Argument arrays instead of shell strings.
subprocess.run(["ls", user_input])passesuser_inputas one argument toexecvewith no shell involved. There is no parser to confuse. - Context-aware output encoding. For HTML, the same value needs different escaping in an attribute, in a URL, and inside a
<script>block, which is why a singleescape()is insufficient and templating engines do it per context. - Safe deserialization. Use a data format that cannot express code — JSON rather than pickle — and validate against a schema.
Two things that look like fixes but are not: blocklists, which fail because encodings and alternative syntaxes are endless (an attacker only needs one that was missed), and manual escaping, which fails because it must be applied correctly at every site forever, and one omission is enough.
Finally, injection is a case where defence in depth genuinely pays. Parameterised queries prevent the bug; least privilege on the database account limits what a missed instance can do; and a web application firewall catches known patterns. But only the first is a fix — the others bound the damage.
Example
THE ONE BUG, IN THREE INTERPRETERS
SQL
"SELECT * FROM users WHERE name='" + input + "'"
input = x' OR '1'='1
-> SELECT * FROM users WHERE name='x' OR '1'='1'
^^^^^^^^^^ now part of the SYNTAX
SHELL
os.system("ping -c1 " + host)
host = "8.8.8.8; cat /etc/passwd"
-> two commands. The shell's ';' was never meant to be attacker-supplied.
HTML
"<div>" + comment + "</div>"
comment = "<script>...</script>"
-> the browser parses attacker text as script
Same shape every time: DATA crossed into the STRUCTURE channel.
WHY PARAMETERISATION IS A DIFFERENT MECHANISM, NOT BETTER ESCAPING
CONCATENATION
app ---> "SELECT * FROM users WHERE name='x' OR '1'='1'" ---> [PARSER]
one string; the parser cannot tell author from attacker
PARAMETERISED
app ---> "SELECT * FROM users WHERE name=?" --> [PARSER] -> plan fixed
---> ["x' OR '1'='1"] --> bound as a VALUE
structure is compiled BEFORE the value arrives, so the value
CANNOT become syntax. No encoding trick defeats this.
SHELL: THE SAME IDEA, WITHOUT THE SHELL
shell=True : "ping -c1 8.8.8.8; cat /etc/passwd"
-> /bin/sh parses this -> two commands
arg array : execve("/bin/ping", ["ping","-c1","8.8.8.8; cat /etc/passwd"])
-> ONE argument, no parser, ping simply fails to resolve it
WHAT DOES NOT WORK
blocklists : block "DROP" -> attacker sends "DrOp", "DR/**/OP",
char(68)||char(82)..., URL-encoded, unicode-normalised
You must block every encoding; they need ONE you missed.
manual escaping : must be correct at EVERY call site, forever.
One omission in 500 is a vulnerability.
CONTEXT MATTERS FOR OUTPUT ENCODING
<div> HERE </div> -> HTML-entity encode
<a href=" HERE "> -> URL-encode
<script>var x=" HERE ";</script> -> JavaScript string encode
<div style="HERE"> -> CSS encode
One escape() function cannot be right for all four.
import sqlite3, subprocess, json, html, shlex
# ---------------- SQL ----------------
def lookup_vulnerable(conn, name):
# BUG: the value becomes part of the query's STRUCTURE.
return conn.execute(
f"SELECT * FROM users WHERE name = '{name}'").fetchall()
def lookup_safe(conn, name):
# The '?' is a placeholder. The engine compiles the statement FIRST,
# then binds the value. The value can never be parsed as syntax.
return conn.execute(
"SELECT * FROM users WHERE name = ?", (name,)).fetchall()
def lookup_safe_named(conn, name, min_age):
return conn.execute(
"SELECT * FROM users WHERE name = :name AND age >= :age",
{"name": name, "age": min_age}).fetchall()
def sort_users_safe(conn, column, direction):
"""Identifiers CANNOT be parameterised -- only values can.
So validate them against an ALLOWLIST, never concatenate user text."""
COLUMNS = {"name", "age", "created_at"} # allowlist, not blocklist
DIRECTIONS = {"ASC", "DESC"}
if column not in COLUMNS or direction.upper() not in DIRECTIONS:
raise ValueError("invalid sort specification")
# Safe: both values came from OUR sets, not from the request.
return conn.execute(f"SELECT * FROM users ORDER BY {column} {direction}").fetchall()
# ---------------- shell ----------------
def ping_vulnerable(host):
# BUG: /bin/sh parses this string, and ';' '|' '&&' '$()' '`' are all syntax.
subprocess.run(f"ping -c1 {host}", shell=True)
def ping_safe(host):
# No shell at all. execve gets an argv array; 'host' is ONE argument,
# whatever characters it contains.
subprocess.run(["ping", "-c1", host], shell=False,
capture_output=True, timeout=5, check=False)
def ping_validated(host):
"""Better still: validate the shape before doing anything with it."""
import ipaddress
ipaddress.ip_address(host) # raises on anything else
return subprocess.run(["ping", "-c1", host], capture_output=True, timeout=5)
# ---------------- deserialization ----------------
def load_vulnerable(data: bytes):
import pickle
return pickle.loads(data) # BUG: pickle can CONSTRUCT ARBITRARY OBJECTS
# and call code during unpickling. Never use
# it on data you did not produce yourself.
def load_safe(data: bytes):
obj = json.loads(data) # JSON cannot express code or object graphs
if not isinstance(obj, dict) or "user_id" not in obj:
raise ValueError("schema violation") # validate the SHAPE too
return obj
# ---------------- output encoding is CONTEXT-DEPENDENT ----------------
def render_comment(comment):
return f"<div>{html.escape(comment)}</div>" # HTML text context
def render_attribute(value):
# quote=True escapes quotes -- essential inside an attribute, and the
# attribute MUST be quoted or escaping cannot save it.
return f'<a title="{html.escape(value, quote=True)}">link</a>'
def render_in_script(value):
# HTML escaping is WRONG here. JSON-encode for a JavaScript context.
return f"<script>var x = {json.dumps(value)};</script>"
# Find the pattern in a codebase -- these greps catch most real instances.
grep -rn "shell=True" --include=*.py .
grep -rnE 'execute\(.*[f"]|execute\(.*\+|execute\(.*%' --include=*.py .
grep -rnE 'system\(|popen\(|exec\(|eval\(' --include=*.{c,php,js,py} .
grep -rn "pickle.loads\|yaml.load(" --include=*.py . # yaml.safe_load instead
semgrep --config=p/security-audit .
bandit -r . -ll # Python security linter
# Defence in depth: limit what a missed instance can reach.
psql <<'EOF'
CREATE ROLE app_readonly LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE prod TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
REVOKE CREATE ON SCHEMA public FROM app_readonly; -- no table creation
EOF
# SQL injection against a read-only account cannot DROP, UPDATE, or write files.
# Content Security Policy: a second layer under XSS.
# Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m';
# object-src 'none'; base-uri 'none'
# Injected inline script without the nonce does not execute even if it lands.
curl -sI https://example.com | grep -i content-security-policy
# Log4Shell: injection into a LOG line, because the logger interpreted it.
grep -rn 'log4j-core' --include=pom.xml .
# The lesson generalises: any component that INTERPRETS the strings you pass
# it turns logging user input into an injection sink.
Key takeaways
- Code injection is one bug appearing in many interpreters: attacker-supplied data crosses into the structure channel and is parsed as syntax.
- The cause is always building a command or query by concatenating trusted structure with untrusted values.
- Parameterised queries are not better escaping — they are a different mechanism, compiling the structure before the value arrives so a value can never become syntax.
- Passing an argument array instead of a shell string removes the shell parser entirely, which is the equivalent fix for command injection.
- Identifiers such as column and table names cannot be parameterised, so they must be validated against an allowlist.
- Output encoding must match the context: HTML text, attributes, URLs, and JavaScript each need different treatment.
- Blocklists and manual escaping fail structurally — an attacker needs one encoding you missed, or one call site you forgot.
- Least privilege on the database account and a content security policy bound the damage of a missed instance but do not fix it.
🧪 Practice
- Rewrite a concatenated SQL query as a parameterised one, then explain what to do about a user-supplied
ORDER BYcolumn and why it differs.- Explain why
subprocess.run(cmd, shell=True)is dangerous with user input and whatshell=Falsewith a list changes mechanically.- Interview-style: "A team escapes single quotes in all SQL inputs and considers the problem solved. What is wrong with this?" Hint: consider numeric contexts where no quotes appear at all, and what happens when the database's character-set handling differs from the escaper's assumptions.
System and Network Threats
Theory
The threats so far target a single program. This topic covers attacks on the system and the network connecting systems — where the attacker is not inside your process but on the wire, or simply sending you more traffic than you can handle.
Denial of service aims at availability rather than secrecy. It divides into three mechanisms worth distinguishing because the defences differ completely:
- Volumetric: saturate the link. The pipe is full, so nothing legitimate arrives. Only upstream capacity — a scrubbing provider — helps, because by the time traffic reaches you the damage is done.
- Protocol: exhaust a state table. A SYN flood sends connection openings and never completes them, filling the half-open connection queue. SYN cookies are the elegant fix: encode the connection state cryptographically into the sequence number, so the server keeps no state until the handshake completes.
- Application: exploit asymmetric cost. One cheap request triggers expensive work — an unindexed search, a zip bomb, a regular expression with catastrophic backtracking, or a hash collision attack that turns a hash table into a linked list. The defence is to find and remove the asymmetry, plus rate limiting.
Amplification deserves separate mention because it explains modern DDoS scale. Send a small spoofed request to a server that returns a large response, and the response goes to the victim. DNS gives roughly 50x, NTP monlist gave 550x, and memcached reached 51,000x. The enabling condition is always the same: a connectionless protocol plus a network that permits source-address spoofing, which is why BCP 38 egress filtering is the real fix and why open resolvers should not exist.
Man-in-the-middle attacks place the attacker between two parties. ARP spoofing works on a local segment, DNS spoofing redirects name resolution, BGP hijacking redirects entire prefixes, and a rogue access point simply offers a more attractive network. Encryption alone does not stop this — you must also authenticate the peer, because an encrypted channel to the attacker is no protection. That is precisely what certificates do, and why certificate validation errors must never be click-through.
Replay attacks resend a captured valid message. The defences are nonces, timestamps, and sequence numbers — anything that makes each message usable exactly once.
Session hijacking steals an authenticated token. The mitigations are all about the token's exposure: Secure and HttpOnly cookie flags, SameSite to blunt CSRF, rotation on privilege change, and binding the session to something the attacker cannot easily reproduce.
Underlying most of these is a design fact worth stating plainly: the internet protocols were designed for a cooperative network. TCP/IP has no source authentication, DNS had no integrity protection until DNSSEC, BGP still largely trusts announcements, and SMTP trusts the sender. Every security layer since — TLS, DNSSEC, RPKI, SPF/DKIM/DMARC — is retrofitted. Knowing which guarantees are original and which are bolted on tells you where to expect weakness.
Example
DoS: THREE MECHANISMS, THREE DIFFERENT DEFENCES
VOLUMETRIC ####################>[ 1 Gbps link ] saturated
only UPSTREAM scrubbing helps; local filtering is
already too late -- the pipe is the bottleneck
PROTOCOL (SYN flood)
attacker ---SYN---> server allocates a half-open entry
attacker (never ACKs) -> table fills -> legitimate SYNs dropped
FIX: SYN COOKIES -- encode the state INTO the sequence number,
keep ZERO state until the handshake completes
APPLICATION (asymmetric cost)
1 cheap request -> expensive work
unindexed search, zip bomb, ReDoS, hash-collision flooding
FIX: remove the asymmetry (index it, bound it), then rate limit
AMPLIFICATION: WHY DDoS GOT SO LARGE
attacker --(60-byte query, SPOOFED src = victim)--> open DNS resolver
victim <--------- 3,000-byte response ----------- resolver
protocol factor enabling condition
DNS ANY ~50x open resolver + UDP
NTP monlist ~550x unpatched ntpd
memcached UDP ~51,000x memcached exposed to the internet
(it should never be)
ROOT CAUSE: source-address spoofing is possible.
REAL FIX : BCP 38 egress filtering at every network's edge.
MAN-IN-THE-MIDDLE: ENCRYPTION IS NOT ENOUGH
client <---encrypted---> ATTACKER <---encrypted---> server
Both legs are encrypted. Both are useless.
What is missing is AUTHENTICATION of the peer.
-> certificates bind an identity to a key
-> which is why a certificate warning must never be click-through:
it is the exact signal this attack produces
THE PROTOCOLS ASSUMED A COOPERATIVE NETWORK
original guarantee retrofitted security
---------------------- ---------------------------
IP: no source auth IPsec, BCP 38 filtering
DNS: no integrity DNSSEC, DoH/DoT
BGP: trusts announcements RPKI (partial deployment)
SMTP: trusts the sender SPF + DKIM + DMARC
HTTP: plaintext TLS, HSTS
Everything is a retrofit. Expect the seams to be where attacks live.
# --- SYN flood defence: keep no state until the handshake completes ---
sysctl net.ipv4.tcp_syncookies # 1 = on (default, keep it)
sysctl net.ipv4.tcp_max_syn_backlog
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192
sudo sysctl -w net.ipv4.tcp_synack_retries=2
ss -s | grep -i syn # half-open connections right now
netstat -ant | awk '{print $6}' | sort | uniq -c # SYN_RECV count
# --- anti-spoofing: the fix that would end amplification ---
sudo sysctl -w net.ipv4.conf.all.rp_filter=1 # reverse path filtering
# And on every network edge (BCP 38): do not let packets leave with a
# source address that does not belong to your network.
# --- do not be an amplifier ---
sudo ss -ulpn | grep -E ':53|:123|:11211|:1900' # UDP services exposed?
# memcached must NEVER be internet-reachable:
memcached -U 0 -l 127.0.0.1 # disable UDP, bind local
# DNS: recursion only for your own clients
# named.conf: allow-recursion { 10.0.0.0/8; localhost; };
dig +short test.openresolver.com TXT @your.dns.server # are you open?
# --- rate limiting, at several layers ---
sudo nft add rule inet filter input tcp dport 22 \
ct state new limit rate 10/minute accept
sudo nft add rule inet filter input tcp dport 80 \
ct count over 100 drop # per-source connections
# nginx:
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# limit_req zone=api burst=20 nodelay;
# limit_conn_zone $binary_remote_addr zone=conn:10m;
sudo fail2ban-client status sshd
# --- MITM detection and prevention ---
ip neigh show # ARP table: duplicate MACs?
sudo arpwatch -i eth0 # alert on ARP changes
sudo tcpdump -i eth0 -n arp | head
openssl s_client -connect example.com:443 -servername example.com </dev/null \
| openssl x509 -noout -subject -issuer -dates -fingerprint
# HSTS: tell browsers never to use plaintext for this host again.
curl -sI https://example.com | grep -i strict-transport-security
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# Certificate Transparency: detect misissued certificates for your domain.
curl -s "https://crt.sh/?q=example.com&output=json" | jq -r '.[].name_value' | sort -u
# --- session security: the flags that matter ---
curl -sI https://example.com/login | grep -i set-cookie
# Set-Cookie: session=...; Secure; HttpOnly; SameSite=Strict; Path=/
# Secure : never sent over plaintext
# HttpOnly : unreadable from JavaScript (blunts XSS token theft)
# SameSite : not sent on cross-site requests (blunts CSRF)
import time, hmac, hashlib, secrets, collections
# ---------- SYN cookies: the idea, which generalises well ----------
SERVER_SECRET = secrets.token_bytes(32)
def make_cookie(client_ip, client_port, timestamp):
"""Encode connection state INTO a value the client must echo back.
The server stores NOTHING, so there is no table to exhaust."""
mac = hmac.new(SERVER_SECRET,
f"{client_ip}:{client_port}:{timestamp // 60}".encode(),
hashlib.sha256).digest()
return int.from_bytes(mac[:4], "big")
def verify_cookie(client_ip, client_port, cookie, now):
# Accept the current and previous minute, to tolerate clock skew.
for window in (now, now - 60):
if hmac.compare_digest(
cookie.to_bytes(4, "big"),
make_cookie(client_ip, client_port, window).to_bytes(4, "big")):
return True
return False
# ---------- token bucket: the standard rate limiter ----------
class TokenBucket:
"""Allows bursts up to `capacity`, then a sustained `rate` per second."""
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens, self.last = capacity, time.monotonic()
def allow(self, cost=1):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False # shed load HERE, cheaply
buckets = collections.defaultdict(lambda: TokenBucket(rate=10, capacity=20))
def handle_request(client_ip, expensive=False):
# Charge expensive endpoints more: this is how you remove the asymmetry
# that application-layer DoS depends on.
if not buckets[client_ip].allow(cost=10 if expensive else 1):
return 429, "Too Many Requests"
return 200, "OK"
# ---------- replay protection: make each message usable once ----------
class ReplayGuard:
def __init__(self, window_seconds=300):
self.seen, self.window = {}, window_seconds
def check(self, nonce, timestamp):
now = time.time()
if abs(now - timestamp) > self.window:
return False, "timestamp outside the acceptance window"
if nonce in self.seen:
return False, "REPLAY: this nonce was already used"
self.seen = {n: t for n, t in self.seen.items() if now - t < self.window}
self.seen[nonce] = now
return True, "accepted"
guard = ReplayGuard()
n, t = secrets.token_hex(16), time.time()
print(guard.check(n, t)) # (True, 'accepted')
print(guard.check(n, t)) # (False, 'REPLAY: ...') -- the captured message
# is now worthless
# ---------- session tokens: generate and compare correctly ----------
def new_session_token():
return secrets.token_urlsafe(32) # CSPRNG, not random.random()
def validate(provided, stored):
# Constant-time: a plain '==' leaks the correct prefix length via timing.
return hmac.compare_digest(provided, stored)
Key takeaways
- Denial of service splits into volumetric (needs upstream scrubbing), protocol (needs stateless handshakes like SYN cookies), and application (needs the cost asymmetry removed).
- Amplification attacks depend on a connectionless protocol plus source-address spoofing; BCP 38 egress filtering is the real fix, and open resolvers and exposed memcached should not exist.
- SYN cookies are worth understanding as a general pattern: encode state into a value the client must echo, so no server-side table can be exhausted.
- Encryption without peer authentication does not prevent man-in-the-middle attacks — an encrypted channel to the attacker is no protection, which is why certificate warnings matter.
- Replay is defeated by nonces, timestamps, and sequence numbers that make each message usable exactly once.
- Session tokens need
Secure,HttpOnly, andSameSiteflags, CSPRNG generation, constant-time comparison, and rotation on privilege change. - The internet's core protocols assumed a cooperative network, so every security property is retrofitted — and the seams between original and retrofit are where attacks concentrate.
🧪 Practice
- Explain how SYN cookies allow a server to survive a SYN flood without keeping per-connection state, and what the client must do for the connection to complete.
- Compute the traffic an attacker with 100 Mbit/s of upstream can generate against a victim using a 50x DNS amplifier, and name the network control that would prevent it.
- Interview-style: "A site uses TLS everywhere and a user still had their session hijacked on public Wi-Fi. Give two plausible explanations." Hint: TLS protects data in transit between authenticated endpoints — ask what happens before the first HTTPS request, and where else the token exists besides the network.
Privilege Escalation
Theory
An attacker who gains a foothold rarely lands where they want to be. They arrive as www-data, or as a low-privileged user, or inside a container. Privilege escalation is the step from that foothold to the access they actually need, and it is the pivot that turns a limited compromise into a total one.
Two directions are distinguished:
- Vertical: gain higher privileges — user to root, user-mode to kernel-mode, container to host.
- Horizontal: gain another peer's access — reading another user's records by changing an ID in a request. Less dramatic, frequently more damaging, and often not even a bug in the OS but a missing authorisation check in an application (IDOR).
The recurring vertical routes are worth knowing as a checklist, because defending against them is largely a matter of removing each one:
Misconfiguration is the most common by a wide margin. A world-writable script executed by a root cron job; a sudo rule permitting a program that can spawn a shell (vi, less, find -exec, awk); a service running as root that need not; an over-permissive systemd unit; group memberships that are root-equivalent in disguise (docker, disk, lxd, adm).
setuid binary abuse. Any setuid-root program runs with root's rights while processing the caller's entire environment — argv, environment variables, file descriptors, working directory, umask, resource limits, signal state. Each of these has produced real exploits. The lesson is that a setuid binary must treat everything from its caller as hostile, including things it never explicitly reads.
Kernel exploits. The kernel is the ultimate target because it has no higher authority above it. Dirty COW, Dirty Pipe, and various io_uring and netfilter bugs all turned an unprivileged foothold into kernel-level control. Patching is the only real answer, along with reducing attack surface (seccomp, disabling unused subsystems, unprivileged user namespaces).
Container escape. A container is a process with restrictions, not a virtual machine. A privileged container, a mounted Docker socket, CAP_SYS_ADMIN, or a host path mount each provide a route out. Mounting /var/run/docker.sock into a container is exactly equivalent to granting root on the host, because the container can ask the daemon to start a new privileged container.
Credential reuse. Not a technical vulnerability at all: passwords in environment variables, in a world-readable config, in shell history, in a git repository, or in cloud instance metadata. Often the easiest path available.
The defences follow directly from the routes: least privilege everywhere, NoNewPrivileges to forbid setuid elevation, minimal capability bounding sets, seccomp to shrink the kernel's attack surface, prompt patching, no privileged containers, and secret management that keeps credentials out of the filesystem and environment.
Example
THE ESCALATION CHAIN
[ initial foothold ] e.g. RCE in a web app -> shell as www-data
|
| enumerate: what is misconfigured here?
v
[ local privilege escalation ]
| setuid abuse / sudo rule / writable cron / kernel bug
v
[ root on this host ]
| harvest credentials, keys, tokens
v
[ lateral movement ] -> the rest of the estate
Every defence in this chapter is about BREAKING ONE OF THESE ARROWS.
ROUTE 1: A sudo RULE THAT LOOKS FINE
alice ALL=(root) /usr/bin/find
find has -exec. -exec runs anything. This is a root shell with extra steps.
The same is true of: vi, less, more, awk, tar, zip, git, env, python,
and most of GTFOBins. "One command" is not a restriction if that
command can execute other commands.
ROUTE 2: A ROOT-EQUIVALENT GROUP
usermod -aG docker alice # looks like "can use containers"
docker run -v /:/host -it alpine chroot /host sh
# ...is root on the host. Immediately.
Same for: disk (raw block writes), lxd, adm (reads all logs),
video (framebuffer), kmem.
ROUTE 3: A WRITABLE FILE IN A PRIVILEGED PATH
-rwxrwxrwx root root /opt/scripts/backup.sh <- world-writable
/etc/cron.d/backup: */5 * * * * root /opt/scripts/backup.sh
-> anyone may edit it; root runs it within 5 minutes.
Also: writable PATH directories, writable systemd units, writable
library paths, writable $PYTHONPATH entries.
ROUTE 4: setuid + ATTACKER-CONTROLLED ENVIRONMENT
a setuid-root binary inherits from the CALLER:
argv, environ, open fds, cwd, umask, rlimits, signal dispositions
every one of these has produced a real exploit.
(LD_PRELOAD and LD_LIBRARY_PATH are stripped for setuid binaries
precisely because of this -- but the program's OWN variables are not.)
ROUTE 5: CONTAINER IS NOT A SECURITY BOUNDARY BY DEFAULT
-v /var/run/docker.sock:/var/run/docker.sock
== root on the host (ask the daemon for a privileged container)
--privileged == all capabilities, all devices
--cap-add=SYS_ADMIN == mount, and much more
-v /:/host == the entire filesystem
# ===== DEFENDER'S AUDIT: find each route before an attacker does =====
# 1. sudo rules that are effectively root shells.
sudo -l
sudo grep -rE '^[^#].*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null
# Check each permitted binary against GTFOBins. If it can exec, it is root.
# BAD : alice ALL=(root) /usr/bin/find
# GOOD: alice ALL=(root) /usr/bin/systemctl restart nginx
# And add: Defaults!SUDO_EDITOR,SETENV / Defaults env_reset
# 2. Root-equivalent group memberships.
for g in docker disk lxd adm kmem video shadow sudo wheel; do
echo "$g: $(getent group $g | cut -d: -f4)"
done
# 3. Writable files and directories in privileged paths.
find / -path /proc -prune -o -perm -0002 -type f -print 2>/dev/null | head -20
find /etc/cron* /etc/systemd/system /usr/local/bin -writable 2>/dev/null
sudo find / -name "*.service" -perm -0002 2>/dev/null
echo $PATH | tr ':' '\n' | xargs -I{} find {} -maxdepth 0 -writable 2>/dev/null
# 4. setuid/setgid inventory -- every one is a potential route.
find / -perm -4000 -type f 2>/dev/null
find / -perm -2000 -type f 2>/dev/null
# Remove the bit wherever it is no longer needed:
sudo chmod u-s /usr/bin/some-unneeded-tool
# Or replace it with a capability:
sudo setcap cap_net_raw+ep /usr/bin/myping && sudo chmod u-s /usr/bin/myping
# 5. Kernel attack surface.
uname -r; cat /proc/version
sudo unattended-upgrades --dry-run # or dnf-automatic
sysctl kernel.unprivileged_userns_clone # 0 reduces a large surface
sysctl kernel.dmesg_restrict kernel.kptr_restrict # both should be 1
sysctl kernel.yama.ptrace_scope # 1+ limits process inspection
# 6. Container hardening.
docker inspect --format='{{.HostConfig.Privileged}}' $(docker ps -q)
docker ps -q | xargs -I{} docker inspect --format='{{.Name}} {{.HostConfig.Binds}}' {}
# Run containers as a non-root user with no extra capabilities:
docker run --user 1000:1000 --read-only --cap-drop=ALL \
--security-opt=no-new-privileges:true \
--security-opt seccomp=default.json myimage
# Kubernetes equivalent:
cat <<'EOF'
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false # the NoNewPrivileges flag
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
EOF
# 7. Credentials sitting in the filesystem or environment.
sudo grep -rIE '(password|secret|api[_-]?key|token)\s*[=:]' /etc /opt/app 2>/dev/null | head
sudo cat /proc/*/environ 2>/dev/null | tr '\0' '\n' | grep -iE 'pass|secret|token' | head
git log -p --all | grep -iE 'password|api_key' | head # and in history
gitleaks detect --source . --verbose
# 8. The single highest-value hardening flag.
systemd-analyze security | sort -k2 -rn | head -10
# Add to every unit: NoNewPrivileges=yes
import os, stat, pwd, subprocess
def audit_escalation_paths():
"""The defender's version of what an attacker enumerates first."""
findings = []
# --- setuid binaries that are not in the expected set ---
EXPECTED = {"/usr/bin/passwd", "/usr/bin/sudo", "/usr/bin/su",
"/usr/bin/mount", "/usr/bin/umount", "/usr/bin/newgrp",
"/usr/bin/gpasswd", "/usr/bin/chsh", "/usr/bin/chfn"}
for root, dirs, files in os.walk("/usr"):
for name in files:
p = os.path.join(root, name)
try:
if os.stat(p).st_mode & stat.S_ISUID and p not in EXPECTED:
findings.append(("HIGH", f"unexpected setuid binary: {p}"))
except OSError:
pass
# --- world-writable files in paths that privileged code executes ---
for d in ("/etc/cron.d", "/etc/systemd/system", "/usr/local/bin", "/opt"):
for root, _, files in os.walk(d):
for name in files:
p = os.path.join(root, name)
try:
if os.stat(p).st_mode & stat.S_IWOTH:
findings.append(
("CRITICAL", f"world-writable in a privileged path: {p}"))
except OSError:
pass
# --- group memberships that are root-equivalent in disguise ---
ROOT_EQUIVALENT = {"docker": "can mount / into a container",
"disk": "can write raw block devices",
"lxd": "can create privileged containers",
"kmem": "can read kernel memory"}
import grp
for g, why in ROOT_EQUIVALENT.items():
try:
members = grp.getgrnam(g).gr_mem
if members:
findings.append(("HIGH", f"group '{g}' ({why}): {members}"))
except KeyError:
pass
return findings
def am_i_in_a_container():
"""Containers have different escalation routes; know which you are in."""
signals = []
if os.path.exists("/.dockerenv"):
signals.append("/.dockerenv present")
try:
with open("/proc/1/cgroup") as f:
if any(x in f.read() for x in ("docker", "kubepods", "containerd")):
signals.append("container cgroup on PID 1")
except OSError:
pass
if os.path.exists("/var/run/docker.sock"):
signals.append("CRITICAL: docker.sock is mounted -- this is host root")
return signals
for severity, msg in audit_escalation_paths()[:10]:
print(f"[{severity}] {msg}")
print("container signals:", am_i_in_a_container())
Key takeaways
- Privilege escalation turns a limited foothold into a full compromise; vertical escalation raises privilege level, horizontal escalation reaches a peer's data.
- Misconfiguration is the most common route: writable files in privileged paths, over-broad
sudorules, and unnecessary root services. - A
sudorule permitting any program that can execute other programs —find,vi,awk,tar,python— grants a root shell, not a single command. - Several group memberships are root-equivalent in disguise, notably
docker,disk, andlxd. - A setuid binary must treat its entire inherited environment as hostile, including fds, cwd, umask, and rlimits, not just argv.
- Containers are processes with restrictions, not security boundaries by default; a mounted Docker socket,
--privileged, or a host path mount each grant host root. NoNewPrivileges, dropped capabilities, seccomp, non-root users, and prompt kernel patching remove most routes at once.
🧪 Practice
- Explain why
alice ALL=(root) /usr/bin/findgrants full root access, and write a sudo rule for restarting a service that does not.- Audit your system for world-writable files inside
/etc/cron.d,/usr/local/bin, and anyPATHdirectory, and explain the risk of each finding.- Interview-style: "A container runs as UID 1000 with no added capabilities, but mounts
/var/run/docker.sock. Is it contained?" Hint: ask what the process can request through that socket, and what privileges the thing on the other end of it holds.
Side-Channel Attacks
Theory
Every attack so far exploited a flaw in what the system does. Side-channel attacks exploit what the system reveals while doing it — timing, power draw, cache state, electromagnetic emission, sound, or the mere fact that an operation happened. The implementation may be functionally perfect and still leak.
The analogy is a safe with a flawless lock whose dial makes a slightly different sound at the correct number. Nothing about the locking mechanism is broken; the information escapes through a channel the designer never considered part of the interface.
Timing attacks are the most accessible class. If an operation's duration depends on secret data, the duration is a measurement of that secret. The classic case is comparing a password or MAC with ==, which returns as soon as it finds a mismatching byte — so a token matching the first three bytes takes measurably longer than one matching none, and an attacker recovers the secret byte by byte, turning an exponential search into a linear one. The fix is constant-time comparison, which examines every byte regardless. The same principle applies to cryptographic implementations generally: no branch and no memory access may depend on secret data.
Cache attacks exploit shared microarchitectural state. Because cache hits are much faster than misses, an attacker sharing a cache with a victim can infer which memory the victim touched. Prime+Probe fills the cache and measures what got evicted; Flush+Reload works on shared pages such as a common library. Since table-driven AES indexes tables using key-derived values, observing which table lines were accessed recovers key material.
Speculative execution attacks (Spectre and Meltdown, 2018) were a genuine shock because they broke the boundary between "architecturally impossible" and "observable". Processors execute speculatively past branches and roll back incorrect results — but the cache effects of the speculative work are not rolled back. So a program can speculatively read memory it may not access, encode a bit of it into the cache, and then read it out through a timing measurement. The architectural rules were never violated; the microarchitecture leaked anyway. Mitigations have been costly: kernel page-table isolation, retpolines, microcode updates, and disabling SMT in the highest-assurance environments.
Other channels matter in specific contexts. Power analysis and electromagnetic analysis target smart cards and embedded devices with physical access. Acoustic and optical channels have recovered keys from CPU coil whine and from monitor reflections. Rowhammer goes beyond reading: repeatedly activating a DRAM row flips bits in adjacent rows, turning a physical phenomenon into a memory-corruption primitive.
Two general lessons are worth carrying away. First, sharing implies leakage: every shared resource — a cache, a branch predictor, a memory bus, a CPU core — is a potential channel, which is why high-assurance workloads pay for dedicated hardware. Second, compression plus secrets plus attacker influence equals leakage (the CRIME and BREACH attacks), because compressed length depends on content. Side channels are therefore not exotic: they are a systematic consequence of optimisation, and optimisation is everywhere.
Example
TIMING LEAK: WHY == IS NOT A SAFE COMPARISON
naive compare, byte by byte, returning on the first mismatch:
guess "AAAA..." -> mismatch at byte 0 -> returns after ~1 comparison
guess "SAAA..." -> mismatch at byte 1 -> returns after ~2 comparisons
guess "SEAA..." -> mismatch at byte 2 -> returns after ~3 comparisons
^ MEASURABLY LONGER
The attacker does not guess the whole secret: they learn it ONE BYTE
AT A TIME by measuring which guess took longest.
brute force (16 bytes, 256 values): 256^16 = 10^38 attempts
timing-guided, byte by byte : 256*16 = 4,096 attempts
FIX: examine every byte regardless, and combine with OR.
diff = 0
for i in range(len(a)): diff |= a[i] ^ b[i] # no early exit
return diff == 0 # no secret-dependent branch
CACHE ATTACK: SHARED STATE IS A CHANNEL
attacker and victim share an L3 cache
FLUSH+RELOAD
1. attacker FLUSHES a cache line of a shared library
2. victim runs -- and may or may not touch that line
3. attacker RELOADS it and TIMES the access
fast (~50 cycles) -> the victim touched it (cache HIT)
slow (~300 cycles) -> the victim did not (cache MISS)
Applied to table-driven AES: which table lines were accessed depends
on the KEY. Observing the access pattern recovers key bits.
SPECULATIVE EXECUTION (Spectre / Meltdown)
if (index < array_len) { // the CPU predicts TRUE and runs ahead
v = array[index]; // speculatively reads OUT OF BOUNDS
tmp = probe[v * 4096]; // and encodes v into the CACHE
} // branch resolves FALSE -> results
// discarded... but the CACHE STATE IS NOT
attacker then times probe[0..255*4096]:
whichever index is FAST is the value of v.
The architectural state was never violated. The MICROARCHITECTURE leaked.
Mitigations cost real performance: KPTI, retpolines, microcode, no SMT.
THE TWO GENERAL LESSONS
1. SHARING IMPLIES LEAKAGE
shared cache, branch predictor, memory bus, SMT sibling, TLB
-> high-assurance workloads buy DEDICATED hardware for this reason
2. COMPRESSION + SECRET + ATTACKER INPUT = LEAKAGE
compressed length depends on content; if attacker text compresses
better when it matches the secret, length reveals the secret
(CRIME, BREACH) -> do not compress secrets together with
attacker-controlled data
import hmac, secrets, time, statistics
# ---------- the vulnerable comparison and the safe one ----------
def compare_unsafe(a: bytes, b: bytes) -> bool:
if len(a) != len(b):
return False
for x, y in zip(a, b):
if x != y:
return False # EARLY EXIT: duration reveals the
return True # length of the matching prefix
def compare_safe(a: bytes, b: bytes) -> bool:
"""Constant time in the CONTENT (length is still visible, which is fine
for fixed-length tokens). No early exit, no secret-dependent branch."""
if len(a) != len(b):
return False
diff = 0
for x, y in zip(a, b):
diff |= x ^ y # accumulate; never branch on the result
return diff == 0
def compare_best(a: bytes, b: bytes) -> bool:
return hmac.compare_digest(a, b) # the library version: use THIS one
# ---------- demonstrate the leak is measurable ----------
def measure(fn, secret, guess, trials=20000):
start = time.perf_counter_ns()
for _ in range(trials):
fn(secret, guess)
return (time.perf_counter_ns() - start) / trials
secret = b"S3CR3T_TOKEN_VALUE_0123456789ABC"
guesses = {
"0 bytes match": b"X" * len(secret),
"8 bytes match": secret[:8] + b"X" * (len(secret) - 8),
"24 bytes match": secret[:24] + b"X" * (len(secret) - 24),
}
print("UNSAFE comparison (duration tracks the matching prefix):")
for label, g in guesses.items():
print(f" {label:<16} {measure(compare_unsafe, secret, g):7.1f} ns")
print("SAFE comparison (duration is flat):")
for label, g in guesses.items():
print(f" {label:<16} {measure(compare_safe, secret, g):7.1f} ns")
# ---------- other timing leaks that are easy to introduce ----------
USERS = {"alice": "hash1", "bob": "hash2"}
def login_leaky(username, password):
if username not in USERS:
return False # returns FAST for unknown users ->
return verify_hash(password, USERS[username]) # username ENUMERATION
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$" + "A" * 43
def login_safe(username, password):
# Always do the same work, whether or not the user exists.
stored = USERS.get(username, DUMMY_HASH)
ok = verify_hash(password, stored)
return ok and username in USERS # and return the SAME error message
def verify_hash(password, stored):
time.sleep(0.05) # stands in for a real KDF
return False
# ---------- length is also a channel ----------
def leaky_error(username):
# Different messages for "no such user" and "wrong password" leak
# which usernames exist, without any timing measurement at all.
return "Invalid username or password." # always the same string
# Which microarchitectural vulnerabilities does this CPU have, and what is on?
lscpu | grep -A25 Vulnerability
# Vulnerability Meltdown: Mitigation; PTI
# Vulnerability Spectre v1: Mitigation; usercopy/swapgs barriers
# Vulnerability Spectre v2: Mitigation; Retpolines, IBPB, IBRS_FW
# Vulnerability Mds: Mitigation; Clear CPU buffers; SMT vulnerable
# ^^^^^^^^^^^^^^
grep . /sys/devices/system/cpu/vulnerabilities/*
# SMT (hyperthreading) shares cache and execution units between siblings --
# a co-resident attacker's channel. High-assurance environments disable it.
echo off | sudo tee /sys/devices/system/cpu/smt/control
lscpu | grep -E 'Thread|Core'
# Cost: 10-30% throughput. Benefit: removes an entire class of channel.
# Kernel mitigations status (and the performance they cost).
cat /proc/cmdline | tr ' ' '\n' | grep -E 'mitigations|pti|spectre'
# mitigations=off is sometimes proposed for isolated HPC nodes;
# it is indefensible on anything multi-tenant.
# Microcode: many mitigations arrive here, not in the kernel.
sudo dmesg | grep -i microcode
cat /proc/cpuinfo | grep -m1 microcode
sudo apt install intel-microcode # or amd64-microcode
# Constant-time crypto: verify the library actually provides it.
openssl speed -evp aes-128-gcm # AES-NI: hardware, inherently constant-time
grep -o aes /proc/cpuinfo | head -1 # is AES-NI present?
# Table-driven software AES is the classic cache-attack target; AES-NI is not.
# Compression side channels.
curl -sI -H "Accept-Encoding: gzip" https://example.com | grep -i content-encoding
# Do not compress a response that mixes a secret (a CSRF token) with
# attacker-controlled input (a reflected parameter) -- that is BREACH.
# Cloud: the co-residency question.
# shared tenancy -> you share caches and cores with strangers
# dedicated host -> you do not. This is what you are paying for.
Key takeaways
- Side channels leak through what an implementation reveals while running — timing, cache state, power, emissions — even when it is functionally correct.
- Timing leaks turn an exponential search into a linear one: comparing secrets with early-exit equality lets an attacker recover them byte by byte.
- Constant-time comparison examines every byte with no secret-dependent branch;
hmac.compare_digestand equivalents exist for exactly this. - Cache attacks infer a victim's memory accesses from hit and miss timings, which is why table-driven cryptography is dangerous and hardware AES is preferred.
- Spectre and Meltdown showed that speculative execution leaves observable microarchitectural traces even when the architectural result is discarded.
- Any shared resource — cache, branch predictor, SMT sibling, memory bus — is a potential channel, so high-assurance workloads use dedicated hardware and disable SMT.
- Error messages, response lengths, and compression ratios are side channels too: uniform errors, constant-time paths, and not compressing secrets alongside attacker input all matter.
🧪 Practice
- Explain why early-exit string comparison reduces a 16-byte token search from 256^16 to roughly 256×16 attempts, and write a constant-time replacement.
- Run
lscpu | grep -A25 Vulnerabilityand report which mitigations are active on your machine and which vulnerabilities remain.- Interview-style: "A login endpoint returns 'user not found' in 5 ms and 'wrong password' in 80 ms. What does an attacker gain, even with identical error text?" Hint: the response body is not the only thing the attacker can measure, and knowing which accounts exist changes what the next stage of an attack costs.
<a id="123-defensive-techniques"></a>
12.3 Defensive Techniques
Each technique here answers a specific attack from the previous section, and none of them is sufficient alone — the assumption throughout is that some layer will fail, so the goal is that no single failure is total.
User Authentication
Theory
Every access control decision presupposes an answer to "who is this?". Authentication is how that answer is established, and it is the point at which most real compromises begin — not because the cryptography is weak, but because the mechanism must survive contact with humans.
Evidence comes in three classic factors, plus two that are often listed separately:
| Factor | Examples | Fails when |
|---|---|---|
| Something you know | Password, PIN | Guessed, phished, reused, leaked in a breach |
| Something you have | Security key, phone, smart card | Lost, stolen, cloned (SIM swap) |
| Something you are | Fingerprint, face, iris | Copied — and it cannot be revoked |
| Somewhere you are | IP geolocation, network | Spoofed; useful only as a signal |
| Something you do | Typing rhythm, behaviour | Probabilistic; a risk signal, not a gate |
Multi-factor authentication combines factors from different categories, and the requirement that they be different categories is the point: a password plus a security question is still one factor, because both are knowledge and both fall to the same phishing page.
Password storage is where systems most often get the fundamentals wrong, and the rules are not negotiable:
- Never store the password. Store a verifier derived from it.
- Never use a fast hash. SHA-256 is designed to be fast, which is exactly wrong here: a GPU computes billions per second. Use a password hashing function built to be slow and memory-hard — Argon2id (preferred), scrypt, or bcrypt.
- Always salt, per user. A unique random salt makes precomputed rainbow tables useless and means two users with the same password get different hashes.
- Tune the cost parameters so verification takes roughly 100 ms on your hardware, and raise them as hardware improves.
- Consider a pepper — a secret key held outside the database, so a database-only breach yields nothing crackable.
Modern password policy has also been rewritten by evidence. NIST SP 800-63B now recommends: allow long passphrases, do not impose composition rules (they produce P@ssw0rd1), do not force periodic rotation (it produces predictable increments), and do check candidates against lists of known-breached passwords. Rotation is warranted on evidence of compromise, not on a calendar.
The most important recent shift is away from shared secrets entirely. FIDO2/WebAuthn passkeys use public-key cryptography: the device holds a private key, the server holds only a public key, and the signature is bound to the site's origin. This makes them phishing-resistant by construction — a fake site gets a signature for its own origin, which is worthless. It also means a server breach discloses no credential worth stealing. This is a genuine improvement over every form of MFA that can be relayed, including TOTP codes and SMS.
Finally, authentication is not just the login moment. Session management — token generation with a CSPRNG, rotation on privilege change, absolute and idle timeouts, and constant-time validation — determines how long an authentication decision remains valid and how easily it can be stolen.
Example
THE AUTHENTICATION CHAIN, AND WHERE IT BREAKS
[ enrolment ] -> [ credential storage ] -> [ verification ] -> [ session ]
| | | |
identity proofing hash + salt + cost rate limit, MFA rotation,
(weakest link in (breach resistance) (guessing, phish) timeout,
account recovery) theft
Account RECOVERY is frequently the weakest link: MFA on login is
worthless if a support call resets it.
WHY A FAST HASH IS THE WRONG TOOL
attacker has the hash database; how fast can they guess?
SHA-256 (unsalted) : ~100,000,000,000 guesses/s on a GPU rig
bcrypt (cost 12) : ~ 20,000 guesses/s
Argon2id (64MB, t=3): ~ 1,000 guesses/s <- memory-hard, so
GPUs lose their edge
an 8-character lowercase+digit password (36^8 = 2.8 x 10^12):
SHA-256 : ~28 seconds
bcrypt : ~4.4 years
Argon2id : ~89 years
Same password. The difference is entirely the KDF choice.
SALT vs PEPPER
salt : unique per user, stored WITH the hash
-> defeats rainbow tables; forces per-user cracking
pepper : one secret, stored OUTSIDE the database (HSM, env, KMS)
-> a database-only breach yields hashes that cannot be
attacked at all without the pepper
stored: argon2id$v=19$m=65536,t=3,p=4$<SALT>$<HASH>
^ per user, random, not secret
WHY PASSKEYS ARE DIFFERENT FROM ALL OTHER MFA
TOTP / SMS / push (relayable)
user -> phishing site -> real site
the phishing site RELAYS the code in real time. MFA defeated.
WebAuthn / FIDO2 (origin-bound)
the signature includes the ORIGIN the browser actually connected to
user -> phishing-site.com -> device signs for "phishing-site.com"
-> useless at the real site
The attack is not detected; it is STRUCTURALLY IMPOSSIBLE.
And the server stores only a PUBLIC key: a breach discloses nothing
worth cracking.
MODERN POLICY (NIST SP 800-63B) -- much of the old advice was wrong
DO DO NOT
allow 64+ character passphrases require mixed character classes
check against breach corpora force periodic rotation
rate limit and lock out use knowledge-based questions
offer passkeys / hardware keys truncate or restrict paste
rotate ON EVIDENCE of compromise rely on SMS where avoidable
import os, hmac, secrets, base64, time
from argon2 import PasswordHasher, exceptions # pip install argon2-cffi
# ---------- password storage, done correctly ----------
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MiB -- the memory-hardness that defeats GPUs
parallelism=4,
hash_len=32,
salt_len=16, # a unique random salt is generated PER HASH
)
PEPPER = os.environ["AUTH_PEPPER"].encode() # kept OUT of the database
def _peppered(password: str) -> bytes:
# HMAC with a secret held outside the DB: a database-only breach
# yields hashes that cannot be attacked without also stealing this key.
return hmac.new(PEPPER, password.encode(), "sha256").digest()
def hash_password(password: str) -> str:
if len(password) < 8:
raise ValueError("password too short")
if is_breached(password): # check, do not impose rules
raise ValueError("this password appears in known breach corpora")
return ph.hash(_peppered(password)) # salt is embedded in the output
def verify_password(stored_hash: str, password: str) -> bool:
try:
ph.verify(stored_hash, _peppered(password))
except exceptions.VerifyMismatchError:
return False
except exceptions.InvalidHashError:
return False
if ph.check_needs_rehash(stored_hash): # cost parameters raised since
return "rehash" # this hash was made
return True
def is_breached(password: str) -> bool:
"""k-anonymity range query: send only the first 5 hash chars, never the
password or its full hash."""
import hashlib, urllib.request
h = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = h[:5], h[5:]
with urllib.request.urlopen(
f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3) as r:
return any(line.split(":")[0] == suffix
for line in r.read().decode().splitlines())
# ---------- login: uniform timing, uniform errors, rate limited ----------
DUMMY = ph.hash(b"dummy-to-equalise-timing")
def login(username, password, attempts: dict):
if attempts.get(username, 0) >= 5:
return False, "Account temporarily locked."
stored = lookup_hash(username) # None if no such user
# Always perform the SAME work, so timing does not reveal existence.
ok = verify_password(stored or DUMMY, password) is True and stored is not None
if not ok:
attempts[username] = attempts.get(username, 0) + 1
# The SAME message either way: no username enumeration.
return ok, "Signed in." if ok else "Invalid username or password."
# ---------- TOTP: correct, but note the phishing caveat ----------
def totp(secret_b32: str, when=None, step=30, digits=6):
import hashlib, struct
key = base64.b32decode(secret_b32, casefold=True)
counter = int((when or time.time()) // step)
mac = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = mac[-1] & 0x0F # dynamic truncation
code = struct.unpack(">I", mac[offset:offset+4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
def verify_totp(secret, submitted, used: set):
now = time.time()
for drift in (-1, 0, 1): # +/- one step for clock skew
if hmac.compare_digest(totp(secret, now + drift * 30), submitted):
key = (secret, int((now + drift * 30) // 30))
if key in used:
return False # single-use: block replay
used.add(key)
return True
return False
# NOTE: a TOTP code can be RELAYED by a phishing site in real time.
# WebAuthn cannot, because the signature is bound to the origin.
# ---------- sessions ----------
def new_session():
return secrets.token_urlsafe(32) # CSPRNG, 256 bits
def validate_session(provided, stored):
return hmac.compare_digest(provided, stored) # constant time
# How passwords are stored on this system.
sudo getent shadow root | cut -d: -f2 | head -c 40
# $y$j9T$... $y$ = yescrypt, $6$ = SHA-512-crypt, $2b$ = bcrypt
sudo grep -E '^(ENCRYPT_METHOD|SHA_CRYPT_MIN_ROUNDS)' /etc/login.defs
cat /etc/pam.d/common-password | grep -v '^#'
# password [success=1 default=ignore] pam_unix.so obscure yescrypt rounds=11
# Policy, via PAM.
sudo apt install libpam-pwquality
grep -E '^(minlen|dictcheck|enforcing)' /etc/security/pwquality.conf
# minlen = 12
# dictcheck = 1 <- check against a dictionary
# Note what modern guidance says NOT to set: ucredit/lcredit/dcredit/ocredit
# (composition rules) and maximum age.
sudo chage -l alice # password ageing for one account
sudo faillock --user alice # failed attempts and lockout state
# SSH: keys and hardware tokens instead of passwords.
ssh-keygen -t ed25519 -a 100 -C "alice@laptop"
ssh-keygen -t ed25519-sk -O resident -O verify-required # FIDO2 hardware key
sudo grep -E '^(PasswordAuthentication|PubkeyAuthentication|PermitRootLogin)' \
/etc/ssh/sshd_config
# PasswordAuthentication no <- the single highest-value SSH setting
# PubkeyAuthentication yes
# PermitRootLogin prohibit-password
# Two-factor for local and SSH login.
sudo apt install libpam-google-authenticator
google-authenticator -t -d -f -r 3 -R 30 -w 3
# /etc/pam.d/sshd: auth required pam_google_authenticator.so
# Check your own exposure to credential stuffing.
curl -s https://api.pwnedpasswords.com/range/5BAA6 | head -3
# k-anonymity: only the first 5 SHA-1 characters leave your machine.
Key takeaways
- Authentication establishes the identity every access decision depends on, and the factors must come from different categories for MFA to mean anything.
- Never store passwords; store a verifier from a deliberately slow, memory-hard KDF — Argon2id, scrypt, or bcrypt — never a fast hash like SHA-256.
- Salts are per-user and public and defeat precomputation; a pepper held outside the database makes a database-only breach uncrackable.
- Tune KDF cost to roughly 100 ms per verification and rehash when parameters are raised.
- Modern policy allows long passphrases, drops composition rules and forced rotation, and checks candidates against breach corpora instead.
- Passkeys (FIDO2/WebAuthn) are phishing-resistant by construction because the signature is bound to the origin, and the server stores only a public key.
- Login must use uniform timing and uniform error messages to avoid username enumeration, plus rate limiting and lockout.
- Account recovery is frequently the weakest link, and session management determines how long an authentication remains valid.
🧪 Practice
- Explain why SHA-256 is unsuitable for password storage and compute the cracking-time difference against bcrypt for an 8-character password.
- Distinguish a salt from a pepper, stating where each is stored and which attack each defeats.
- Interview-style: "A site enforces TOTP two-factor authentication and users are still being phished successfully. How, and what fixes it?" Hint: ask what the phishing site can do with a code during the 30 seconds it is valid, and what property of WebAuthn signatures makes the same relay useless.
Cryptography in Operating Systems
Theory
Cryptography is how an operating system provides guarantees that access control cannot. Permissions protect data while the OS is running and enforcing them; they protect nothing on a stolen disk, a copied backup, or a network cable. Cryptography protects the data itself.
Four distinct properties are worth separating, because confusing them causes real design errors:
| Property | Meaning | Provided by |
|---|---|---|
| Confidentiality | Nobody else can read it | Encryption |
| Integrity | Modification is detectable | MAC, digital signature |
| Authenticity | It came from who it claims | MAC (shared key), signature (public key) |
| Non-repudiation | The sender cannot deny it | Digital signature only |
The most common mistake is assuming encryption provides integrity. It does not: with many modes an attacker who cannot read the ciphertext can still flip bits in it and change the plaintext predictably. This is why authenticated encryption (AES-GCM, ChaCha20-Poly1305) is the only acceptable default — it provides confidentiality and integrity together, and it fails closed if either is violated.
The primitives an OS uses:
- Symmetric encryption (AES, ChaCha20): fast, one shared key. Used for bulk data — disk encryption, TLS record traffic.
- Asymmetric encryption (RSA, ECC): slow, but solves key distribution. Used to establish keys and to sign, never for bulk data.
- Hashes (SHA-256, BLAKE2): fingerprints for integrity. Note that a plain hash is not a MAC — it provides no authenticity unless keyed.
- KDFs (HKDF for key material, Argon2 for passwords): derive keys correctly, and note that the two categories are not interchangeable.
- Random number generation: the foundation.
/dev/urandomandgetrandom()are correct for everything;rand()and language-levelrandommodules are not.
Where the OS actually applies them:
Full-disk encryption (LUKS/dm-crypt, BitLocker, FileVault) protects data at rest against physical theft. The key architecture is worth knowing: a master key encrypts the volume, and that master key is itself encrypted by each of several key slots, so a passphrase can be changed or a recovery key revoked without re-encrypting a terabyte. It protects nothing while the system is running and unlocked, which is the limit people most often misunderstand.
File-level encryption (fscrypt, EFS) encrypts per file or directory with per-user keys, so different users' data stays separate on a running system — something FDE cannot do.
Key storage is the hard part everywhere. A key in a file next to the data protects very little. Hardware answers exist: a TPM can seal a key to the system's measured boot state so it is released only if the boot chain is unmodified; an HSM performs operations without exposing the key at all; Secure Enclave and TrustZone isolate keys from the main OS. Cloud KMS services provide the same properties as a managed offering.
Two rules carry most of the practical weight. Do not implement cryptography yourself — use libsodium, the platform's API, or a vetted library, because the failure modes are silent and the correct constructions are subtle. And plan for key rotation and algorithm agility from the start, because every algorithm eventually weakens; the migration away from MD5, SHA-1, and RSA-1024 was painful precisely where agility was absent, and post-quantum migration is the same problem arriving again.
Example
WHAT ACCESS CONTROL CANNOT DO
scenario permissions encryption
------------------------------ ----------- ----------
running system, another user PROTECTS protects
stolen laptop, disk removed useless PROTECTS
backup tape copied useless PROTECTS
data in transit on the wire useless PROTECTS
compromised root on a live system useless useless (key is in RAM)
The last row is the limit people forget: FDE protects data AT REST.
An unlocked running machine has the key in memory.
ENCRYPTION ALONE IS NOT INTEGRITY
CTR-mode ciphertext, attacker cannot read it but CAN modify it:
plaintext : "transfer $100 to bob"
ciphertext : flip one bit at the right offset
plaintext' : "transfer $900 to bob" <- no error, no detection
AUTHENTICATED ENCRYPTION (AES-GCM, ChaCha20-Poly1305)
ciphertext + AUTH TAG; any modification fails verification
-> decryption REFUSES rather than returning wrong plaintext
This is why AEAD is the only acceptable default.
LUKS KEY ARCHITECTURE: WHY PASSPHRASE CHANGES ARE INSTANT
+-----------------------------------------------------+
| LUKS header |
| keyslot 0: Argon2id(passphrase A) -> unwraps -----+ |
| keyslot 1: Argon2id(passphrase B) -> unwraps -----+-+--> MASTER KEY
| keyslot 2: recovery key -> unwraps -----+ | |
| keyslot 3: TPM-sealed -> unwraps -----+ | |
+-----------------------------------------------------+ |
v
the whole volume is encrypted with
the MASTER KEY -- which never changes
change a passphrase -> rewrite ONE KEYSLOT (instant)
NOT re-encrypt 2 TB. And revoking a slot revokes that credential only.
TPM SEALING: A KEY THAT ONLY EXISTS ON AN UNMODIFIED SYSTEM
boot -> firmware measures each stage into PCRs -> TPM compares
-> PCRs match the sealed policy -> key RELEASED
-> bootloader or kernel modified -> PCRs differ -> key WITHHELD
So moving the disk to another machine, or booting a tampered kernel,
yields ciphertext and nothing else.
THE FOUR PROPERTIES, AND WHAT PROVIDES EACH
confidentiality : encryption
integrity : MAC or signature <- NOT encryption
authenticity : MAC (shared key) or signature (public key)
non-repudiation : SIGNATURE ONLY -- a MAC cannot provide it, because
both parties hold the same key and either could
have produced the tag
import os, secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes, hmac as chmac
# ---------- authenticated encryption: the only acceptable default ----------
def encrypt(key: bytes, plaintext: bytes, associated: bytes = b"") -> bytes:
aead = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce. NEVER reuse one with a key:
# GCM nonce reuse is catastrophic -- it
# leaks the authentication key itself.
ct = aead.encrypt(nonce, plaintext, associated)
return nonce + ct # the tag is appended by the library
def decrypt(key: bytes, blob: bytes, associated: bytes = b"") -> bytes:
aead = AESGCM(key)
# Raises InvalidTag on ANY modification: it fails closed rather than
# returning corrupted plaintext.
return aead.decrypt(blob[:12], blob[12:], associated)
key = AESGCM.generate_key(bit_length=256)
blob = encrypt(key, b"transfer $100 to bob", associated=b"account:12345")
print("decrypted:", decrypt(key, blob, b"account:12345"))
tampered = bytearray(blob); tampered[20] ^= 0x01
try:
decrypt(key, bytes(tampered), b"account:12345")
except Exception as e:
print("tampering detected:", type(e).__name__) # InvalidTag
# Associated data is authenticated but NOT encrypted -- it binds the
# ciphertext to its context, preventing a valid blob being replayed elsewhere.
try:
decrypt(key, blob, b"account:99999")
except Exception as e:
print("context binding enforced:", type(e).__name__)
# ---------- key derivation: one master key, many purpose-specific keys ----------
def derive(master: bytes, purpose: str, length=32) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length,
salt=None, info=purpose.encode()).derive(master)
# Distinct 'info' values give independent keys. NEVER use one key for
# two purposes: cross-protocol attacks exist precisely because of that.
master = secrets.token_bytes(32)
enc_key = derive(master, "file-encryption-v1")
auth_key = derive(master, "file-authentication-v1")
print("keys are independent:", enc_key != auth_key)
# NOTE: HKDF is for HIGH-ENTROPY input (another key). For a PASSWORD,
# use Argon2/scrypt/bcrypt -- HKDF is fast and offers no brute-force resistance.
# ---------- randomness ----------
good = secrets.token_bytes(32) # CSPRNG -> getrandom() -> kernel pool
also = os.urandom(32) # the same source
# import random; random.getrandbits(256) # Mersenne Twister: PREDICTABLE
# after observing ~624 outputs.
# NEVER for anything security-related.
# ---------- constant-time comparison of tags and tokens ----------
def verify_mac(key, message, tag) -> bool:
h = chmac.HMAC(key, hashes.SHA256())
h.update(message)
try:
h.verify(tag) # constant-time internally
return True
except Exception:
return False
# --- full-disk encryption with LUKS ---
sudo cryptsetup luksFormat --type luks2 \
--cipher aes-xts-plain64 --key-size 512 \
--pbkdf argon2id --pbkdf-memory 1048576 /dev/sdb1
sudo cryptsetup luksOpen /dev/sdb1 securedata
sudo mkfs.ext4 /dev/mapper/securedata && sudo mount /dev/mapper/securedata /mnt/secure
sudo cryptsetup luksDump /dev/sdb1
# Keyslots:
# 0: luks2 Key: 512 bits PBKDF: argon2id Memory: 1048576
# 1: luks2 ...
sudo cryptsetup luksAddKey /dev/sdb1 # add a slot (instant)
sudo cryptsetup luksKillSlot /dev/sdb1 1 # revoke one credential
sudo cryptsetup luksHeaderBackup /dev/sdb1 --header-backup-file luks.img
# Lose the header and the data is gone FOREVER. Back it up, off the disk.
# --- bind the key to an unmodified boot chain (TPM) ---
sudo systemd-cryptenroll --tpm2-device=auto \
--tpm2-pcrs=0+2+4+7 /dev/sdb1
# PCR 0: firmware 2: option ROMs 4: bootloader+kernel 7: Secure Boot state
# Modify any of those and the TPM refuses to release the key.
sudo tpm2_pcrread sha256:0,2,4,7
# --- per-file/per-directory encryption (different users, one running system) ---
sudo tune2fs -O encrypt /dev/sda2
fscrypt setup && fscrypt encrypt ~/private
fscrypt status ~/private
# FDE cannot separate users on a running machine; this can.
# --- verify what TLS is actually negotiating ---
openssl s_client -connect example.com:443 -tls1_3 </dev/null 2>/dev/null \
| grep -E 'Protocol|Cipher'
# Protocol : TLSv1.3
# Cipher : TLS_AES_256_GCM_SHA384 <- AEAD
nmap --script ssl-enum-ciphers -p443 example.com | head -30
testssl.sh --severity HIGH example.com
# --- entropy and randomness health ---
cat /proc/sys/kernel/random/entropy_avail
sudo dmesg | grep -i 'random: crng init done' # the pool is seeded
head -c 32 /dev/urandom | xxd
# /dev/urandom is correct for ALL uses once initialised; the old advice to
# prefer /dev/random for "important" keys is obsolete.
# --- key management: keep keys out of the filesystem ---
systemd-creds encrypt --name=dbpass plain.txt cred.enc # TPM-sealed
vault kv get -field=password secret/db
aws kms decrypt --ciphertext-blob fileb://key.enc --query Plaintext
# A key in a file beside the data it protects is barely protection at all.
Key takeaways
- Cryptography provides guarantees access control cannot: permissions are meaningless on a stolen disk, a copied backup, or a network wire.
- Confidentiality, integrity, authenticity, and non-repudiation are four separate properties, and only signatures provide the last one.
- Encryption alone does not provide integrity, so authenticated encryption (AES-GCM, ChaCha20-Poly1305) is the only acceptable default.
- Never reuse a nonce with the same key in GCM — it is catastrophic, leaking the authentication key itself.
- LUKS encrypts a master key in several key slots, so passphrase changes and credential revocation are instant rather than requiring re-encryption.
- Full-disk encryption protects data at rest only; an unlocked running system holds the key in memory.
- A TPM can seal a key to the measured boot state so it is released only on an unmodified system, defeating disk removal and tampered bootloaders.
- Use HKDF for deriving keys from keys and Argon2 for deriving keys from passwords — they are not interchangeable — and never use a non-cryptographic RNG.
🧪 Practice
- State which of the four cryptographic properties each of these provides: AES-CTR, HMAC-SHA256, AES-GCM, and an Ed25519 signature.
- Explain why changing a LUKS passphrase is instantaneous on a 2 TB volume, referring to the key slot architecture.
- Interview-style: "A laptop with full-disk encryption is stolen while suspended and the attacker reads its memory. What went wrong, and what would have prevented it?" Hint: ask where the volume key must be while the system is running, and what suspend-to-RAM does differently from a full power-off.
Address Space Layout Randomization
Theory
Memory-corruption exploits need addresses. An attacker overwriting a return address must know what to overwrite it with: the location of a function, a gadget, or a buffer. Historically these were completely predictable — the same binary loaded at the same address on every machine every time — so an exploit written once worked everywhere.
ASLR removes that assumption by randomising where things are placed in the address space at each execution: the stack, the heap, memory-mapped regions, shared libraries, and — with a position-independent executable — the program image itself. The attacker must now guess, and a wrong guess crashes the process rather than exploiting it.
The crucial parameter is entropy: how many bits of randomness there are, since that determines how many guesses are needed. And the difference between architectures is decisive:
| Architecture | Typical mmap entropy | Expected guesses |
|---|---|---|
| 32-bit x86 | 8-16 bits | 256 - 65,536 |
| 64-bit x86-64 | 28-32 bits | ~268 million - 4 billion |
On 32-bit systems ASLR was brute-forceable in seconds; the Shacham et al. result in 2004 demonstrated exactly this. On 64-bit it is a genuine obstacle, which is one of the underappreciated security benefits of the 64-bit transition.
Three limitations matter in practice:
PIE is required for the executable itself. Without -fPIE -pie, the main binary loads at a fixed address even when everything else is randomised — and that fixed region contains plenty of useful code. Distributions now build PIE by default, but it must be checked for locally built software.
Randomisation happens at load, not continuously. A forking server such as classic Apache's prefork model gives every child the parent's layout, so an attacker can probe repeatedly against the same randomisation — crashing children until one guess lands. This turns a 28-bit search into a feasible one. Servers that exec a fresh binary per connection avoid it.
One leak defeats it entirely. ASLR hides addresses; it does not prevent their disclosure. A format-string bug, an uninitialised memory read, or an out-of-bounds read that returns a pointer gives the attacker a known address, and since regions are randomised as units, one leaked address reveals everything at a fixed offset from it. Modern exploit chains are therefore almost always "information leak, then corruption" — and this is why the leak primitive is as valuable to an attacker as the write primitive.
ASLR is best understood as one layer that raises cost, not as a boundary. It combines with NX (so the attacker cannot execute injected code), stack canaries (so sequential overwrites are detected), RELRO (so the GOT is read-only and cannot be redirected), and CFI with shadow stacks (which finally attacks return-oriented programming directly). Kernel ASLR (KASLR) applies the same idea to the kernel image, and function-granular randomisation (FG-KASLR) goes further by shuffling individual functions so one leaked kernel address does not reveal the rest.
Example
WITHOUT ASLR: THE SAME ADDRESSES, EVERY RUN, EVERY MACHINE
run 1: stack 0x7fffffffe000 libc 0x7ffff7a0d000 heap 0x602000
run 2: stack 0x7fffffffe000 libc 0x7ffff7a0d000 heap 0x602000
-> an exploit written once works everywhere. "Write once, own everywhere."
WITH ASLR: DIFFERENT EVERY EXECUTION
run 1: stack 0x7ffd4a2b1000 libc 0x7f8c3d400000 heap 0x55e8a1c2f000
run 2: stack 0x7ffe91f07000 libc 0x7fa1b2e00000 heap 0x561f3d8b1000
-> the attacker must GUESS; a wrong guess = SIGSEGV, not a shell
ENTROPY IS EVERYTHING
32-bit: 16 bits -> 65,536 possibilities
a forking server at 1,000 attempts/s -> ~33 seconds. Broken.
64-bit: 28-32 bits -> 268,000,000+ possibilities
the same rate -> ~3 days of constant crashing, highly visible.
WHAT REMAINS FIXED WITHOUT PIE
non-PIE binary PIE binary
0x400000 .text <- FIXED 0x55f3a2c00000 .text <- randomised
0x601000 .got <- FIXED randomised
libc randomised randomised
^ everything else is randomised, but the attacker only needs
gadgets from the fixed region. ASLR is largely neutered.
THE FORK PROBLEM
parent (randomised ONCE at exec)
|-- fork() --> child 1: SAME layout
|-- fork() --> child 2: SAME layout
|-- fork() --> child 3: SAME layout
an attacker crashes children repeatedly, probing the SAME randomisation
-> 28 bits of entropy becomes a feasible online search.
exec() per connection re-randomises; fork() alone does not.
ONE LEAK DEFEATS IT COMPLETELY
leaked: printf("%p") discloses 0x7f8c3d4a1234 inside libc
attacker knows the OFFSET of every libc symbol from its base
-> libc base = 0x7f8c3d4a1234 - known_offset
-> EVERY libc address is now known. The whole region falls at once.
This is why modern exploit chains are: INFO LEAK -> then corruption.
THE LAYERS TOGETHER (none is sufficient alone)
ASLR : you do not know WHERE the code is
NX/W^X : you cannot run code you SUPPLIED
canary : a sequential overwrite is DETECTED
RELRO : the GOT is read-only, so it cannot be redirected
CFI+CET : returns and indirect calls must go where the program allows
^ this is the one that actually breaks ROP
# Is ASLR on, and at what strength?
cat /proc/sys/kernel/randomize_va_space
# 0 = off 1 = stack/mmap/vdso 2 = those PLUS the heap (the default)
sudo sysctl -w kernel.randomize_va_space=2
# Observe randomisation directly.
for i in 1 2 3; do
setarch $(uname -m) -R true 2>/dev/null # -R DISABLES it, for comparison
cat /proc/self/maps | grep -m1 '\[stack\]'
done
# Run twice without -R: the addresses differ every time.
# Measure the actual entropy on this machine.
for i in $(seq 200); do
cat /proc/self/maps | awk '/\[stack\]/{print substr($1,1,12)}'
done | sort -u | wc -l # distinct values seen in 200 runs
# Is a binary PIE? (Without it, the main image is not randomised.)
file /usr/bin/ls
# ELF 64-bit LSB pie executable, x86-64, dynamically linked <- 'pie': good
readelf -h /usr/bin/ls | grep Type
# Type: DYN (Position-Independent Executable file) <- DYN = PIE
# Type: EXEC <- NOT PIE
checksec --file=/usr/bin/ls
# RELRO STACK CANARY NX PIE
# Full RELRO Canary found NX enabled PIE enabled
# Audit every binary on the system for missing PIE.
for f in /usr/bin/*; do
[ -f "$f" ] && readelf -h "$f" 2>/dev/null | grep -q 'Type:.*EXEC' && echo "no PIE: $f"
done | head
# Build with the full set.
gcc -fPIE -pie -Wl,-z,relro,-z,now -fstack-protector-strong \
-D_FORTIFY_SOURCE=3 -O2 -fcf-protection=full -o app app.c
# Kernel ASLR.
cat /proc/cmdline | grep -o 'nokaslr' # present = KASLR DISABLED
sudo grep -E 'CONFIG_RANDOMIZE_BASE|CONFIG_RANDOMIZE_MEMORY' /boot/config-$(uname -r)
sudo dmesg | grep -i 'kaslr\|kernel offset'
# Prevent leaking kernel addresses through /proc, which would defeat KASLR:
sysctl kernel.kptr_restrict # 1 or 2
sysctl kernel.dmesg_restrict # 1
# Hardware control-flow integrity (the layer that breaks ROP).
grep -o -m1 'user_shstk\|ibt' /proc/cpuinfo # Intel CET support
ld.so --help 2>/dev/null | grep -i cet
import subprocess, re, collections, os
def region_addresses(region, runs=500):
"""Sample where a region lands across many executions."""
seen = []
for _ in range(runs):
out = subprocess.run(
["python3", "-c",
f"print(open('/proc/self/maps').read())"],
capture_output=True, text=True).stdout
for line in out.splitlines():
if region in line:
seen.append(int(line.split("-")[0], 16))
break
return seen
def estimate_entropy(addresses):
"""Entropy ~= log2(number of distinct positions), estimated from the
spread of observed base addresses at page granularity."""
import math
pages = {a >> 12 for a in addresses} # 4 KB page granularity
span = (max(addresses) - min(addresses)) >> 12
return {
"samples": len(addresses),
"distinct pages": len(pages),
"observed span (pages)": span,
"approx entropy bits": round(math.log2(span), 1) if span > 1 else 0,
}
addrs = region_addresses("[stack]", runs=200)
print("stack:", estimate_entropy(addrs))
# 64-bit Linux typically shows ~30 bits of stack entropy.
# Brute-force feasibility, as a function of entropy and attempt rate.
def time_to_brute_force(entropy_bits, attempts_per_second):
expected = 2 ** (entropy_bits - 1) # expected, not worst case
seconds = expected / attempts_per_second
for unit, div in (("seconds", 1), ("minutes", 60), ("hours", 3600),
("days", 86400), ("years", 31_536_000)):
if seconds < div * 1000:
return f"{seconds/div:,.1f} {unit}"
return f"{seconds/31_536_000:,.0f} years"
print(f"\n{'entropy':>9}{'at 1k/s':>18}{'at 100k/s':>18}")
for bits in (8, 16, 24, 28, 32):
print(f"{bits:>7} b{time_to_brute_force(bits, 1_000):>18}"
f"{time_to_brute_force(bits, 100_000):>18}")
# 16 bits (32-bit systems): seconds. 28+ bits (64-bit): impractical online.
# But note: ONE information leak reduces every row above to zero.
Key takeaways
- ASLR randomises the placement of the stack, heap, libraries, and (with PIE) the executable, so an attacker must guess addresses and a wrong guess crashes the process.
- Entropy determines everything: 32-bit systems offered 8-16 bits and were brute-forceable in seconds, while 64-bit systems offer 28-32 bits and are not.
- Without
-fPIE -piethe main executable loads at a fixed address, leaving a reliable supply of gadgets and largely neutering ASLR. - Forking servers hand every child the parent's layout, so repeated probing attacks one randomisation; re-
execper connection restores the protection. - A single information leak reveals a whole region's base, which is why modern exploit chains pair a leak primitive with a corruption primitive.
- ASLR raises cost rather than providing a boundary, and works with NX, canaries, RELRO, and CFI/shadow stacks, the last of which actually breaks ROP.
- KASLR extends the idea to the kernel, and
kptr_restrictanddmesg_restrictexist to stop kernel addresses leaking through ordinary interfaces.
🧪 Practice
- Run a program twice and compare
/proc/self/mapsfor the stack and libc base addresses. Then repeat withsetarch -Rand explain the difference.- Compute the expected number of guesses to defeat 16-bit and 28-bit ASLR, and the time each takes at 1,000 attempts per second.
- Interview-style: "Why do modern exploits almost always chain an information leak with a memory-corruption bug?" Hint: consider what a corruption primitive needs to know before it can be aimed, and what one leaked pointer reveals about everything near it.
Sandboxing and Isolation
Theory
Least privilege says a component should hold only the rights it needs. Sandboxing is the enforcement: a restricted environment in which code runs with access to only what it was explicitly given, so that a compromise is contained rather than total.
The motivating observation is that some code is inherently exposed. A browser renders untrusted HTML and executes untrusted JavaScript. A document viewer parses attacker-supplied files. A media player decodes untrusted streams. These will have bugs — the input is adversarial and the parsers are complex. The only durable answer is to assume the bug and constrain what it yields.
Isolation mechanisms form a spectrum, trading strength against overhead:
| Mechanism | Isolates | Overhead | Typical strength |
|---|---|---|---|
chroot | Filesystem view only | ~0 | Weak — not a security boundary |
| Namespaces | PID, net, mount, user, IPC, UTS | ~0 | Moderate |
| seccomp-BPF | System call surface | Very low | Strong, and composable |
| Capabilities | Root's individual powers | 0 | Moderate |
| Landlock | Filesystem access, unprivileged | Very low | Moderate-strong |
| Containers | All of the above combined | Low | Good, shared kernel |
| gVisor / Kata | Syscalls via a user-space kernel or a VM | Medium | Strong |
| Virtual machine | Full hardware virtualisation | Higher | Strongest common |
Two points about this table matter more than the rest. First, chroot is not a security mechanism — a process with CAP_SYS_CHROOT or root can escape it with a few lines of code, and it was never designed to resist an adversary. Second, containers share the kernel, so a kernel vulnerability is a shared failure domain; where genuinely untrusted code must run, gVisor, Kata Containers, Firecracker microVMs, or full virtualisation are the appropriate tools.
seccomp-BPF deserves particular attention because it is the highest-value mechanism per unit of effort. It filters system calls with a BPF program: a process declares which calls it may make, and the kernel enforces it. The value is arithmetic — Linux has around 350 system calls, and most programs use fewer than 50, so a filter removes roughly 85% of the kernel's attack surface for that process. Many kernel vulnerabilities become unreachable simply because the syscall that triggers them is denied.
The architectural pattern that makes sandboxing practical is privilege separation with a broker. The sandboxed process cannot open files or sockets; when it needs one, it asks a small, trusted broker over an IPC channel, and the broker applies policy and passes back a file descriptor. Because file descriptors are capabilities (Section 12.1), the sandboxed process ends up holding exactly the authority the broker granted and no way to acquire more. Chrome's renderer processes, OpenSSH, and modern document viewers all work this way.
Three practical cautions. Sandboxes are frequently escaped through what they are given rather than what they block — a mounted socket, a shared directory, an over-broad IPC interface. The kernel remains a shared attack surface unless virtualisation is used. And a sandbox that breaks the application will be disabled, so profiles must be developed against real usage rather than guessed.
Example
THE ISOLATION SPECTRUM
weaker, cheaper stronger, costlier
|--------------------------------------------------------------------|
chroot -> namespaces -> seccomp -> container -> gVisor -> Kata -> VM
chroot : changes the ROOT DIRECTORY VIEW. That is all it does.
Escapable in ~10 lines with CAP_SYS_CHROOT. NOT a boundary.
namespaces : separate PID/net/mount/user/IPC/UTS views
seccomp : restricts the SYSTEM CALL surface -- the kernel's front door
container : all of the above, plus cgroups. SHARED KERNEL.
gVisor : a user-space kernel intercepts syscalls; the host kernel
sees very few
Kata/VM : a real hypervisor boundary; a separate kernel per workload
SHARED KERNEL = SHARED FAILURE DOMAIN
containers virtual machines
+-------+-------+-------+ +--------+--------+
| app A | app B | app C | | guest | guest |
+-------+-------+-------+ | kernel | kernel |
| ONE SHARED KERNEL | +--------+--------+
+-----------------------+ | hypervisor |
a kernel LPE escapes ALL +-----------------+
three containers at once a guest kernel bug stays in the guest
seccomp: THE ARITHMETIC THAT MAKES IT WORTHWHILE
Linux system calls : ~350
a typical network service uses : ~40
filtered away : ~310 (~88% of the kernel's front door)
-> every vulnerability reachable only through those 310 is now unreachable
THE BROKER PATTERN (how real sandboxes stay usable)
+------------------------+ +-------------------------+
| SANDBOXED process | IPC | BROKER (small, trusted) |
| - parses untrusted data| <-------> | - applies policy |
| - no open(), no socket | "may I | - opens the file |
| - seccomp: ~30 calls | read | - sends back an FD |
| - no filesystem | X?" | |
+------------------------+ +-------------------------+
|
the sandboxed side holds only the FDs it v
was GIVEN -- and file descriptors ARE the real filesystem
capabilities, so it cannot widen them
WHERE SANDBOXES ACTUALLY FAIL
not in what they block, but in what they are GIVEN:
-v /var/run/docker.sock -> host root
--privileged -> everything
a shared /tmp -> a channel
an over-broad IPC surface -> the broker becomes the vulnerability
import os, sys, socket, struct, resource, ctypes
# ---------- seccomp: declare the syscalls you need, deny the rest ----------
def apply_seccomp_filter():
"""Allow only what a simple worker needs. Everything else is killed.
(pyseccomp / libseccomp wraps the BPF generation.)"""
import seccomp # pip install pyseccomp
f = seccomp.SyscallFilter(defaction=seccomp.KILL_PROCESS)
for call in ("read", "write", "exit", "exit_group", "rt_sigreturn",
"brk", "mmap", "munmap", "futex", "clock_gettime"):
f.add_rule(seccomp.ALLOW, call)
# Argument-level filtering: allow fstat only on already-open descriptors,
# and close only on fds we expect. Denying by ARGUMENT is what makes
# seccomp precise rather than merely coarse.
f.add_rule(seccomp.ALLOW, "close", seccomp.Arg(0, seccomp.LE, 1024))
f.load()
# After this point, open(), socket(), execve(), ptrace() and ~300 others
# terminate the process immediately. A kernel bug behind any of them is
# now unreachable from here.
# ---------- NoNewPrivileges: forbid ever gaining privilege ----------
PR_SET_NO_NEW_PRIVS = 38
def no_new_privs():
libc = ctypes.CDLL("libc.so.6", use_errno=True)
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
raise OSError(ctypes.get_errno(), "prctl(NO_NEW_PRIVS) failed")
# setuid binaries no longer elevate for this process or ANY descendant.
# This is also a prerequisite for unprivileged seccomp.
# ---------- the broker pattern ----------
import array
def send_fd(sock, fd):
sock.sendmsg([b"F"], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
array.array("i", [fd]))])
def recv_fd(sock):
_, ancdata, _, _ = sock.recvmsg(1, socket.CMSG_SPACE(4))
for level, typ, data in ancdata:
if (level, typ) == (socket.SOL_SOCKET, socket.SCM_RIGHTS):
return array.array("i", data)[0]
raise RuntimeError("no fd received")
ALLOWED_PREFIX = "/srv/documents/"
def broker(sock):
"""Trusted, small, and the ONLY component that touches the filesystem."""
while True:
req = sock.recv(4096)
if not req:
return
path = os.path.realpath(req.decode()) # resolve BEFORE checking,
# or ../ and symlinks win
if not path.startswith(ALLOWED_PREFIX):
sock.send(b"DENIED") # policy lives HERE
continue
try:
fd = os.open(path, os.O_RDONLY)
send_fd(sock, fd) # hand over a CAPABILITY
os.close(fd)
except OSError:
sock.send(b"ERROR")
def sandboxed_worker(sock):
"""Untrusted parsing happens here, with no ability to open anything."""
no_new_privs()
os.chdir("/var/empty")
resource.setrlimit(resource.RLIMIT_NOFILE, (32, 32))
resource.setrlimit(resource.RLIMIT_NPROC, (0, 0)) # cannot fork
apply_seccomp_filter() # no open(), no socket()
sock.send(b"/srv/documents/report.pdf")
fd = recv_fd(sock) # the ONLY way to obtain a file
data = os.read(fd, 65536) # read() is allowed; open() is not
parse_untrusted(data) # a bug here yields: this fd, and
# nothing else
def parse_untrusted(data):
pass
# --- seccomp: what is a process actually restricted to? ---
grep Seccomp /proc/self/status
# Seccomp: 0 (disabled) | 1 (strict) | 2 (filter)
grep Seccomp_filters /proc/$(pgrep -n nginx)/status
# Generate a profile from observed behaviour, rather than guessing.
sudo strace -c -f -e trace=all ./myapp 2>&1 | awk 'NR>2{print $NF}' | sort -u
# Feed that list into the filter. Guessed profiles break applications,
# and broken sandboxes get disabled.
# --- systemd: the most accessible sandboxing on Linux ---
systemd-analyze security myapp.service
sudo systemd-run --pty \
-p PrivateTmp=yes -p PrivateDevices=yes -p ProtectSystem=strict \
-p ProtectHome=yes -p NoNewPrivileges=yes \
-p SystemCallFilter=@system-service -p SystemCallFilter=~@privileged \
-p CapabilityBoundingSet= -p RestrictAddressFamilies=AF_INET \
/bin/bash
# Inside: read-only filesystem, no /home, no devices, ~300 syscalls denied.
# --- namespaces directly ---
unshare --user --map-root-user --pid --fork --mount-proc --net --ipc --uts \
/bin/bash
# 'root' inside, unprivileged outside; no network; its own PID namespace.
ls /proc/self/ns/ # the namespaces this process belongs to
lsns # all namespaces on the system
# --- Landlock: unprivileged filesystem restriction ---
ls /sys/kernel/security/landlock 2>/dev/null
grep -i landlock /proc/self/status 2>/dev/null
# A process can restrict ITSELF without any privilege -- unlike chroot.
# --- containers: the flags that decide whether it is a sandbox at all ---
docker run --rm -it \
--user 1000:1000 --read-only --tmpfs /tmp \
--cap-drop=ALL --security-opt=no-new-privileges:true \
--security-opt seccomp=/etc/docker/seccomp.json \
--security-opt apparmor=docker-default \
--network=none --pids-limit=100 --memory=512m \
myimage
# Compare with: docker run --privileged (all of the above, discarded)
docker inspect --format='{{.HostConfig.Privileged}} {{.HostConfig.Binds}}' $(docker ps -q)
# --- stronger isolation for genuinely untrusted workloads ---
docker run --runtime=runsc myimage # gVisor: user-space kernel
kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: { name: kata }
handler: kata # each pod gets its own kernel
EOF
# --- verify a sandbox actually holds ---
docker run --rm --cap-drop=ALL alpine sh -c 'mount -t proc proc /mnt 2>&1'
# mount: permission denied <- good
docker run --rm alpine sh -c 'cat /proc/1/environ' | tr '\0' '\n' | head
docker run --rm -v /:/host alpine ls /host # if this works, it is NOT a sandbox
Key takeaways
- Sandboxing enforces least privilege at runtime, containing the compromise of code that necessarily processes adversarial input.
- Isolation mechanisms trade strength against overhead;
chrootis not a security boundary and never was. - Containers share the host kernel, so a kernel vulnerability is a shared failure domain — genuinely untrusted workloads need gVisor, Kata, or full virtualisation.
- seccomp-BPF offers the best security return for the effort, removing roughly 85% of the kernel's system-call surface for a typical process.
NoNewPrivilegesforbids privilege gain for a process and all descendants, and is a prerequisite for unprivileged seccomp.- The broker pattern makes strict sandboxes usable: the sandboxed process holds no ability to open resources and receives file descriptors, which are capabilities, from a small trusted component.
- Sandboxes usually fail through what they are given — a mounted socket, a shared path, an over-broad IPC surface — rather than through what they block.
- Profiles must be built from observed behaviour, because a sandbox that breaks the application gets disabled.
🧪 Practice
- Explain why
chrootis not a security boundary and name two mechanisms that actually are.- Use
strace -cto list the system calls a small program makes, then write the allowlist a seccomp filter would need.- Interview-style: "A container runs with
--cap-drop=ALL, a read-only filesystem, and a non-root user, but mounts a host directory read-write. Is it isolated?" Hint: consider what lives in host directories that a privileged process later reads or executes, and whether the container needs any capability at all to write a file.
Auditing and Logging
Theory
Every mechanism so far tries to prevent something. Auditing accepts that prevention will sometimes fail and asks a different question: can you tell what happened? Without that, a compromise is not merely undetected — it is uninvestigable, and you cannot answer the questions that matter after the fact: what did they reach, what did they change, and is it still happening.
Logs serve three distinct purposes, and conflating them leads to systems that serve none well:
- Detection — noticing an attack in progress. Requires real-time processing and alerting.
- Forensics — reconstructing what happened after the fact. Requires completeness and retention.
- Compliance — demonstrating control to an auditor. Requires integrity and defined retention.
What to log is decided by asking what a future investigator will need. The consistent answer is the authentication and authorisation events, privileged actions, and changes — not application debug output:
- Authentication: successes and failures, source, and method.
- Authorisation failures: someone attempting what they may not do is a high-signal event.
- Privilege use:
sudo,su, role activation, capability use. - Account and permission changes: creation, group membership, key addition.
- Configuration and security-policy changes.
- Access to sensitive data, where feasible.
- Process execution and network connections, on systems where that volume is affordable.
Equally important is what not to log: passwords, session tokens, API keys, full payment details, and personal data beyond what is necessary. Logs are widely readable, widely copied, and long-lived, so a secret in a log is a secret disclosed. This has been the root cause of several large incidents on its own.
Three properties determine whether logs are worth having.
Integrity. An attacker's first act after gaining root is often to edit the logs. Local logs on a compromised host cannot be trusted. The answers are remote shipping in real time (so the record leaves before it can be edited), append-only or WORM storage, and cryptographic chaining, where each entry includes a hash of the previous one so any deletion or modification breaks the chain detectably.
Time. Correlating events across hosts requires synchronised clocks and unambiguous timestamps. NTP plus UTC in RFC 3339 format is the minimum; without it, reconstructing a sequence across three machines becomes guesswork.
Signal. Logs nobody reads are storage costs, not security. The failure mode is alert fatigue: too many low-value alerts train people to ignore all of them. Alerting should be reserved for things a human must act on, with everything else queryable but silent.
On Linux the relevant facilities are the kernel audit subsystem (auditd, which records syscall-level events and file access via watch rules), journald/syslog for system and service logs, and shipping to a central SIEM for correlation and retention. Structured logging (JSON) matters more than it appears to: it turns an investigation from text-wrangling into querying, and that difference decides whether the logs get used under time pressure.
Example
THE THREE PURPOSES NEED DIFFERENT THINGS
DETECTION : real-time, low latency, high signal -> alerting pipeline
FORENSICS : complete, long retention, queryable -> centralised store
COMPLIANCE : tamper-evident, defined retention -> WORM / signed
A single "log everything to a file" satisfies none of them well.
WHY LOCAL LOGS ON A COMPROMISED HOST ARE WORTHLESS
attacker gains root
-> sed -i '/192.0.2.5/d' /var/log/auth.log (edit)
-> : > /var/log/auth.log (truncate)
-> touch -r /etc/passwd /var/log/auth.log (fix the mtime)
The only record that survives is the one that ALREADY LEFT THE HOST.
ship in real time: host --syslog/TLS--> central collector (append-only)
the attacker would have to compromise the collector too, and the
collector should not be reachable from the hosts it collects from.
CRYPTOGRAPHIC CHAINING: TAMPER-EVIDENT WITHOUT SPECIAL HARDWARE
entry 1: { data, prev_hash: 0000 } hash -> a1b2
entry 2: { data, prev_hash: a1b2 } hash -> c3d4
entry 3: { data, prev_hash: c3d4 } hash -> e5f6
delete entry 2 -> entry 3's prev_hash no longer matches entry 1's hash
modify entry 2 -> its hash changes -> entry 3's prev_hash mismatches
-> deletion and modification are both DETECTABLE (though not preventable)
WHAT TO LOG, AND WHAT NEVER TO LOG
LOG NEVER LOG
auth success + failure + source passwords (even failed ones)
authorisation DENIALS session tokens, API keys
sudo / su / privilege use full card numbers
account + group changes private keys
config + policy changes personal data beyond necessity
sensitive data access full request bodies by default
A secret in a log is a secret DISCLOSED: logs are copied, shipped,
indexed, and kept for years, and many people can read them.
ALERT FATIGUE IS A SECURITY FAILURE
500 alerts/day -> all ignored -> the real one is ignored too
5 alerts/day -> each investigated
alert on : root login from a new IP, auditd rule triggered on /etc/shadow,
a new setuid binary, log shipping STOPPING
do not alert on : every failed password, every 404
(but keep both queryable)
# --- the kernel audit subsystem: syscall-level, tamper-resistant rules ---
sudo systemctl enable --now auditd
sudo auditctl -s
# Watch rules: -w path -p permissions -k key
sudo auditctl -w /etc/shadow -p wa -k shadow_access
sudo auditctl -w /etc/passwd -p wa -k passwd_changes
sudo auditctl -w /etc/sudoers -p wa -k sudoers_changes
sudo auditctl -w /var/log/audit -p wa -k audit_log_tampering
sudo auditctl -w /usr/bin/docker -p x -k docker_exec
# Syscall rules: catch a whole class of action.
sudo auditctl -a always,exit -F arch=b64 -S execve -k exec_trace
sudo auditctl -a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \
-F auid>=1000 -F auid!=unset -k perm_changes
sudo auditctl -a always,exit -F arch=b64 -S mount -k mount_events
# Lock the rules so even root cannot change them until reboot:
sudo auditctl -e 2
# Persist in /etc/audit/rules.d/hardening.rules, then:
sudo augenrules --load
# Query.
sudo ausearch -k shadow_access -ts today -i
sudo ausearch -m USER_AUTH,USER_LOGIN -sv no -ts recent # failed logins
sudo aureport --summary
sudo aureport --auth --failed --summary
sudo aureport -u --failed -i # by user
# --- journald / syslog ---
journalctl -u sshd -p err --since "1 hour ago"
journalctl _SYSTEMD_UNIT=nginx.service --since today -o json | jq -c 'select(.PRIORITY<="3")'
journalctl --verify # FSS integrity check
sudo journalctl --setup-keys # enable Forward Secure Sealing
# -> journald seals past entries so tampering is detectable
# Ship immediately, so the record leaves before an attacker can edit it.
cat <<'EOF' | sudo tee /etc/rsyslog.d/50-remote.conf
$DefaultNetstreamDriver gtls
$ActionSendStreamDriverMode 1
$ActionSendStreamDriverAuthMode x509/name
*.* @@logs.internal:6514 # @@ = TCP, with TLS above
$ActionQueueType LinkedList # spool if the collector is down
$ActionQueueFileName remotefwd
$ActionResumeRetryCount -1 # never give up
EOF
sudo systemctl restart rsyslog
# --- time: without it, cross-host correlation is guesswork ---
timedatectl status
# System clock synchronized: yes
# NTP service: active
chronyc tracking | grep -E 'System time|Stratum'
# --- the alert nobody configures, and should ---
# Log shipping STOPPING is itself a high-signal event.
# Prometheus: absent_over_time(log_lines_shipped_total[10m])
# Silence is not the same as nothing happening.
# --- high-signal detections worth alerting on ---
sudo ausearch -k perm_changes -ts today | grep -i 'suid\|4755'
find / -perm -4000 -newer /var/log/audit/audit.log 2>/dev/null # new setuid
journalctl -u ssh --since today | grep -i 'Accepted' | awk '{print $11}' | sort -u
last -f /var/log/wtmp | head -20
lastb | head -20 # failed logins
import json, hashlib, hmac, time, os, re
class TamperEvidentLog:
"""Hash-chained append-only log. Cannot PREVENT tampering on a
compromised host, but makes deletion and modification DETECTABLE."""
def __init__(self, path, hmac_key: bytes):
self.path, self.key = path, hmac_key
self.prev = self._last_hash()
def _last_hash(self):
try:
with open(self.path, "rb") as f:
last = None
for line in f:
last = line
return json.loads(last)["hash"] if last else "0" * 64
except FileNotFoundError:
return "0" * 64
def append(self, event: dict):
entry = {
# RFC 3339 in UTC: unambiguous across hosts and time zones.
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"host": os.uname().nodename,
"prev": self.prev,
**redact(event), # never log secrets
}
body = json.dumps(entry, sort_keys=True, separators=(",", ":"))
# HMAC, not a bare hash: an attacker without the key cannot
# recompute a valid chain after editing an entry.
entry["hash"] = hmac.new(self.key, body.encode(), hashlib.sha256).hexdigest()
with open(self.path, "a") as f:
f.write(json.dumps(entry, sort_keys=True) + "\n")
f.flush(); os.fsync(f.fileno()) # durable before returning
self.prev = entry["hash"]
def verify(self):
"""Walk the chain: any deletion or modification breaks it."""
prev = "0" * 64
with open(self.path) as f:
for n, line in enumerate(f, 1):
entry = json.loads(line)
claimed = entry.pop("hash")
if entry["prev"] != prev:
return False, f"chain broken at line {n}: entry deleted or reordered"
body = json.dumps(entry, sort_keys=True, separators=(",", ":"))
actual = hmac.new(self.key, body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(actual, claimed):
return False, f"entry {n} was MODIFIED"
prev = claimed
return True, "chain intact"
SECRET_FIELDS = re.compile(
r"(password|passwd|secret|token|api[_-]?key|authorization|cookie|private_key)",
re.I)
def redact(event: dict) -> dict:
"""Strip secrets BEFORE they reach the log. Logs are copied, shipped,
indexed and retained for years -- a secret here is a secret disclosed."""
out = {}
for k, v in event.items():
if SECRET_FIELDS.search(k):
out[k] = "[REDACTED]"
elif isinstance(v, dict):
out[k] = redact(v)
elif isinstance(v, str) and len(v) > 512:
out[k] = v[:512] + "...[truncated]"
else:
out[k] = v
return out
log = TamperEvidentLog("/var/log/app/audit.jsonl", os.environ["LOG_HMAC_KEY"].encode())
# Log the security-relevant events, structured so they can be QUERIED.
log.append({"event": "auth.success", "user": "alice", "src_ip": "10.0.0.5",
"method": "webauthn", "session": "s_abc123"})
log.append({"event": "authz.denied", "user": "bob", "action": "delete",
"resource": "/admin/users/42", "reason": "insufficient_role"})
log.append({"event": "privilege.use", "user": "alice", "command": "systemctl restart nginx",
"via": "sudo", "password": "hunter2"}) # <- automatically redacted
print(log.verify()) # (True, 'chain intact')
Key takeaways
- Auditing assumes prevention will fail and asks whether you can reconstruct what happened; without it a compromise is uninvestigable.
- Detection, forensics, and compliance need different properties, and one undifferentiated log file serves none of them well.
- Log authentication events, authorisation denials, privilege use, and account and configuration changes — the events an investigator will need.
- Never log passwords, tokens, keys, or unnecessary personal data: logs are copied, shipped, indexed, and retained, so a secret in a log is disclosed.
- Local logs on a compromised host cannot be trusted, so ship in real time to an append-only collector the hosts cannot modify.
- Hash or HMAC chaining makes deletion and modification detectable even though it cannot prevent them.
- Synchronised clocks and unambiguous UTC timestamps are prerequisites for correlating events across hosts.
- Alert fatigue is a security failure: alert only on what requires human action, and treat log shipping stopping as itself a high-signal event.
🧪 Practice
- Write three
auditctlrules watching/etc/shadow,/etc/sudoers, and allexecvecalls by regular users, and explain what each would catch.- Explain why hash chaining detects log tampering but does not prevent it, and name the control that addresses the gap.
- Interview-style: "After an incident you find the attacker had root for three weeks, but
auth.logshows nothing unusual. What do you conclude?" Hint: consider who had write access to that file during those three weeks, and what property a log must have to remain evidence after the host is compromised.
Secure Boot and Trusted Computing
Theory
Every defence in this chapter runs on the operating system, which means every one of them assumes the operating system is the one you installed. If an attacker modifies the bootloader or the kernel, all of it evaporates: a compromised kernel can lie to every tool, hide every process, and hand over every key. So the final question is the foundational one — how do you establish trust in the software that establishes everything else?
The answer is a chain of trust anchored in hardware. Each stage verifies the next before transferring control, starting from something that cannot be modified:
- The root of trust is immutable boot ROM or fused keys in the CPU or chipset. It cannot be reflashed, which is what makes it an anchor rather than just another link.
- It verifies the firmware's signature before running it.
- Firmware verifies the bootloader.
- The bootloader verifies the kernel and initramfs.
- The kernel verifies modules, and optionally the root filesystem (dm-verity) and userspace binaries (IMA).
The property this gives is transitive: if every link verifies the next, and the first link is immutable, then running code is what was signed. Break any link and everything above it is unverified — which is why a signed kernel that loads unsigned modules provides much less than it appears to.
Two distinct approaches are often confused:
Secure Boot (verified boot) enforces. A stage whose signature does not verify is not executed. This blocks bootkits outright, but it also means the machine will not boot software the platform owner has not authorised — which is why user-controlled key enrolment (MOK on Linux) matters for anyone running their own kernels.
Measured Boot records. Each stage hashes the next into a TPM Platform Configuration Register before running it. PCRs can only be extended (PCR = hash(PCR || measurement)), never set directly, so the final values are a tamper-evident summary of exactly what was loaded in what order. Measured boot does not block anything — it produces evidence.
That evidence enables two powerful capabilities:
- Sealing. Encrypt a secret such that the TPM will release it only when the PCRs match a specified policy. A disk encryption key sealed to the boot state is simply unavailable on a tampered system or after the disk is moved to another machine. No password is involved; the hardware enforces it.
- Remote attestation. The TPM signs a quote of its PCR values with a key that never leaves the chip, so a remote party can verify what a machine booted before granting it access. This is how network access control and confidential computing establish trust in a machine they do not physically control.
Beyond boot, the same hardware-rooted idea extends upward. dm-verity gives a Merkle tree over the entire root filesystem so any modified block is detected on read — this is what makes Android and ChromeOS system partitions tamper-evident. IMA/EVM measures and appraises files as they are opened. Confidential computing (AMD SEV-SNP, Intel TDX) encrypts VM memory so that even the hypervisor cannot read it, extending the trust boundary to exclude the cloud operator.
The limits are worth stating honestly. Secure boot protects the boot path, not a running system — it does nothing about a vulnerable service. Firmware itself has bugs, and firmware compromise is below the anchor. And key management is a real operational burden: lose control of the signing keys and the mechanism protects the attacker's software instead of yours.
Example
THE CHAIN OF TRUST
[ ROOT OF TRUST ] immutable boot ROM / fused keys -- CANNOT be reflashed
| verifies
[ FIRMWARE / UEFI ]
| verifies (db keys)
[ BOOTLOADER (shim/GRUB) ]
| verifies
[ KERNEL + initramfs ]
| verifies
[ MODULES, dm-verity root fs, IMA-appraised binaries ]
|
[ APPLICATIONS ]
Transitive: if every link verifies the next and the first is immutable,
what runs is what was signed.
Break ONE link -> everything above it is unverified.
(A signed kernel that loads UNSIGNED modules protects much less than
it appears to -- hence kernel lockdown mode.)
SECURE BOOT vs MEASURED BOOT: ENFORCE vs RECORD
SECURE BOOT MEASURED BOOT
verify signature hash into a TPM PCR
bad signature -> REFUSE TO BOOT bad hash -> boots anyway, but the
PCR VALUE DIFFERS
blocks bootkits produces EVIDENCE
requires key management enables sealing and attestation
They are complementary, not alternatives. Most systems use both.
PCR EXTENSION: WHY THE VALUE CANNOT BE FORGED
PCR_new = SHA256( PCR_old || measurement )
PCRs can only be EXTENDED, never written directly. So:
- the final value depends on EVERY measurement AND their ORDER
- malicious code cannot "reset" a PCR to a good value
- it cannot pre-compute its way to the expected value without
actually being the expected software
SEALING: A KEY THAT ONLY EXISTS ON THE EXPECTED SYSTEM
seal(disk_key, policy = {PCR0, PCR2, PCR4, PCR7 must equal X,Y,Z,W})
normal boot -> PCRs match -> TPM RELEASES the key -> boots
modified GRUB -> PCR4 differs -> TPM WITHHOLDS the key -> ciphertext
disk moved -> PCR0 differs -> withheld
Secure Boot off -> PCR7 differs -> withheld
No password is involved. The HARDWARE enforces the condition.
REMOTE ATTESTATION
verifier --nonce--> machine
machine: TPM signs { PCR values, nonce } with an attestation key
that NEVER LEAVES THE CHIP
machine --quote--> verifier
verifier checks the signature and compares PCRs against known-good
-> grants or denies network access / secrets / workload placement
WHAT IT DOES NOT PROTECT
a vulnerable service on a correctly-booted system (unaffected)
a running system already compromised (unaffected)
firmware bugs BELOW the anchor (below the root)
your own signing keys, if leaked (signs their code too)
# --- Secure Boot state ---
mokutil --sb-state
# SecureBoot enabled
bootctl status | head -20
# Secure Boot: enabled (user)
# TPM2 Support: yes
efibootmgr -v
ls /sys/firmware/efi/efivars/ | grep -E 'SecureBoot|PK|KEK|db'
# Enrol your own key, to run your own kernels under Secure Boot.
openssl req -new -x509 -newkey rsa:4096 -keyout MOK.key -out MOK.crt \
-nodes -days 3650 -subj "/CN=My Kernel Signing Key/"
openssl x509 -in MOK.crt -outform DER -out MOK.der
sudo mokutil --import MOK.der # confirmed at the next reboot
sudo sbsign --key MOK.key --cert MOK.crt --output vmlinuz-signed vmlinuz
sudo kmodsign sha256 MOK.key MOK.crt module.ko
# Kernel lockdown: without it, a signed kernel can be subverted from userspace
# via /dev/mem, kexec, or unsigned modules.
cat /sys/kernel/security/lockdown
# none [integrity] confidentiality
sudo dmesg | grep -i lockdown
# --- TPM and measured boot ---
sudo tpm2_getcap properties-fixed | grep -i manufacturer
ls /dev/tpm* # /dev/tpm0, /dev/tpmrm0
sudo tpm2_pcrread sha256:0,1,2,4,7
# 0 : firmware code
# 1 : firmware configuration
# 2 : option ROMs / drivers
# 4 : bootloader and kernel <- changes if either is modified
# 7 : Secure Boot policy state <- changes if Secure Boot is disabled
sudo tpm2_eventlog /sys/kernel/security/tpm0/binary_bios_measurements | head -40
# The event log explains WHY each PCR has its value: every measurement, in order.
# Seal a disk key to the boot state (no passphrase at boot, but tamper-proof).
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+2+4+7 /dev/sda3
sudo systemd-cryptenroll --recovery-key /dev/sda3 # ALWAYS keep a fallback:
# a legitimate firmware update changes PCR0 and the key stops being released.
# Remote attestation.
sudo tpm2_createek -c ek.ctx -G rsa -u ek.pub
sudo tpm2_createak -C ek.ctx -c ak.ctx -u ak.pub -n ak.name
sudo tpm2_quote -c ak.ctx -l sha256:0,2,4,7 -q $(openssl rand -hex 16) \
-m quote.msg -s quote.sig -o pcr.out
# The verifier checks quote.sig against ak.pub and compares PCRs to a baseline.
# --- integrity above the boot chain ---
sudo veritysetup format /dev/sdb1 /dev/sdb2 # Merkle tree over a rootfs
sudo veritysetup create verified /dev/sdb1 /dev/sdb2 <root_hash>
# Any modified block is detected when it is READ. Android and ChromeOS use this.
sudo cat /sys/kernel/security/ima/ascii_runtime_measurements | head
# 10 <hash> ima-ng sha256:<file hash> /usr/bin/bash
# IMA measures files as they are opened; EVM protects their metadata.
# --- confidential computing: exclude even the hypervisor ---
dmesg | grep -iE 'SEV|TDX|Memory Encryption'
lscpu | grep -iE 'sev|tdx'
import hashlib, secrets, json
# ---------- PCR extension: why the value cannot be forged ----------
class PCR:
"""PCRs can only be EXTENDED, never set. The final value depends on
every measurement AND their order."""
def __init__(self):
self.value = b"\x00" * 32
def extend(self, measurement: bytes):
self.value = hashlib.sha256(self.value + measurement).digest()
return self.value.hex()
def boot_sequence(components):
pcr = PCR()
for name, content in components:
h = hashlib.sha256(content).digest()
pcr.extend(h)
return pcr.value.hex()
good = [("firmware", b"UEFI v2.7 ..."), ("bootloader", b"GRUB 2.06 ..."),
("kernel", b"vmlinuz 6.1.0 ..."), ("initramfs", b"initrd ...")]
tampered = [("firmware", b"UEFI v2.7 ..."), ("bootloader", b"GRUB 2.06 BACKDOOR"),
("kernel", b"vmlinuz 6.1.0 ..."), ("initramfs", b"initrd ...")]
reordered = [good[1], good[0], good[2], good[3]]
print("expected :", boot_sequence(good)[:32])
print("tampered :", boot_sequence(tampered)[:32], " <- differs")
print("reordered :", boot_sequence(reordered)[:32], " <- order matters too")
# An attacker cannot reach the expected value without BEING the expected
# software, because extension is one-way and cannot be rewound.
# ---------- sealing ----------
class SimulatedTPM:
def __init__(self):
self.pcrs = {}
self._sealed = {}
self._ak = secrets.token_bytes(32) # never leaves the "chip"
def extend(self, index, measurement: bytes):
cur = self.pcrs.get(index, b"\x00" * 32)
self.pcrs[index] = hashlib.sha256(cur + measurement).digest()
def _policy_digest(self, indices):
return hashlib.sha256(
b"".join(self.pcrs.get(i, b"\x00" * 32) for i in sorted(indices))
).hexdigest()
def seal(self, secret: bytes, pcr_indices):
"""Bind a secret to the CURRENT boot state."""
policy = self._policy_digest(pcr_indices)
handle = secrets.token_hex(8)
self._sealed[handle] = (secret, policy, tuple(sorted(pcr_indices)))
return handle
def unseal(self, handle):
secret, policy, indices = self._sealed[handle]
if self._policy_digest(indices) != policy:
raise PermissionError(
"PCR policy not satisfied -- the boot state changed; key WITHHELD")
return secret
def quote(self, nonce: bytes, indices):
"""Sign the PCR state for a remote verifier."""
body = json.dumps({"pcrs": {i: self.pcrs.get(i, b"\x00"*32).hex()
for i in indices},
"nonce": nonce.hex()}, sort_keys=True)
import hmac
return body, hmac.new(self._ak, body.encode(), hashlib.sha256).hexdigest()
# Normal boot: seal the disk key.
tpm = SimulatedTPM()
for i, (_, content) in zip([0, 4, 4, 4], good):
tpm.extend(i, hashlib.sha256(content).digest())
handle = tpm.seal(b"DISK-ENCRYPTION-KEY", pcr_indices=[0, 4])
print("\nnormal boot, unsealed:", tpm.unseal(handle))
# Tampered boot: same sealed blob, different PCRs.
tpm2 = SimulatedTPM()
for i, (_, content) in zip([0, 4, 4, 4], tampered):
tpm2.extend(i, hashlib.sha256(content).digest())
tpm2._sealed = tpm._sealed
try:
tpm2.unseal(handle)
except PermissionError as e:
print("tampered boot :", e)
# The disk stays encrypted. No password was involved -- the hardware decided.
Key takeaways
- Every software defence assumes the OS is the one you installed, so trust must be anchored below it in immutable hardware.
- A chain of trust has each stage verify the next from an immutable root; breaking any link leaves everything above it unverified.
- Secure Boot enforces by refusing to run unsigned stages; Measured Boot records by extending hashes into TPM PCRs, and the two are complementary.
- PCRs can only be extended, so the final values are a tamper-evident summary of exactly what was loaded and in what order.
- Sealing binds a secret to a PCR policy so a disk key is simply unavailable on a tampered system or a different machine, with no password involved.
- Remote attestation lets a TPM sign a quote of its PCRs with a key that never leaves the chip, so a remote party can verify what a machine booted.
- dm-verity, IMA/EVM, and confidential computing extend hardware-rooted integrity above the boot chain and, in the last case, exclude the hypervisor.
- Secure boot protects the boot path only — it does nothing for a vulnerable service on a correctly booted system — and always requires a recovery key, since legitimate firmware updates change PCR values.
🧪 Practice
- Explain the difference between Secure Boot and Measured Boot, and give one security goal that only the second can achieve.
- Read your machine's PCR 4 and 7 values with
tpm2_pcrread, then state what would change each one.- Interview-style: "A disk key is sealed to the TPM with no passphrase, and after a routine firmware update the machine will not boot. What happened and how should the design have anticipated it?" Hint: ask which PCR a firmware update changes, what the sealing policy then evaluates to, and what fallback every sealed-key deployment needs enrolled in advance.
<a id="13-virtualization"></a>