Why this subject matters now
Three things changed in the last decade, and together they moved security from a specialist concern to a baseline competence for anyone who ships software. The first is that the industry finally has hard data on where the bugs come from, and it points at the language rather than at the programmer. Microsoft's Security Response Center reported that roughly 70 percent of the CVEs it assigned each year from 2006 through 2018 were memory-safety issues, a fraction that stayed flat across a decade of training, code review, static analysis and mitigation work. The Chromium security team published the same figure independently for serious Chrome bugs. That flatness is the important part. It means the residual rate is a property of writing C and C++ at scale, not a property of any particular team's discipline. Google's Android security group then produced the counterfactual by writing new code in Rust, Java and Kotlin while leaving the old C and C++ in place, and watched the memory-safety share of Android vulnerabilities fall from about 76 percent in 2019 to about 24 percent in 2024 without rewriting the legacy code, because vulnerability density is dominated by new code. In 2024 the United States cybersecurity agencies published joint guidance urging memory-safe languages for new development. An engineer today is expected to know why that argument is structural rather than fashionable.
The second change is that the attack surface moved up the stack. A modern product is a browser application talking to a fleet of services over HTTP, deployed from a build system that assembles hundreds of third-party dependencies, running in containers on shared hardware. Broken access control, not buffer overflows, sits at the top of the OWASP Top 10, and broken object-level authorization tops the API-specific list. The SolarWinds build-system compromise in 2020, the Log4Shell remote code execution in 2021, and the XZ Utils backdoor discovered in 2024 were all failures of trust boundaries rather than of bounds checks. Meanwhile Spectre and Meltdown in 2018 showed that the isolation guarantees everything above depends on, process separation, user and kernel separation, the JavaScript sandbox, were not guarantees of the hardware at all but properties of an abstraction the hardware only approximated.
The third change is machine learning. Models are now decision components inside security-relevant systems, and language models in particular introduced a vulnerability class with no clean fix, prompt injection, which is what happens when instructions and untrusted data share one channel. Anyone building agents that read web pages and call tools is doing security design whether they use the word or not, and the confused-deputy problem described in 1988 is the exact shape of the failure. This page treats ML security as a first-class section rather than an appendix, because that is where the novel risk currently sits.
The security mindset
Threat modeling, what is defended and against whom
Security claims are meaningless without a threat model, which has four parts, the assets worth protecting, the security properties required of them, the adversary's capabilities, and the assumptions under which the claim holds. "This system is secure" is not a proposition. "An attacker who can send arbitrary HTTP requests but cannot execute code on the host and cannot observe memory-bus traffic cannot read another tenant's rows, assuming the database enforces row-level policies and the token-signing key is not disclosed" is a proposition, and it can be attacked by attacking any clause in it. Anderson's Security Engineering makes the point that most real failures are not broken cryptography but broken assumptions. The model said the adversary was outside the perimeter, and the adversary was a contractor.
STRIDE is a checklist for generating threats systematically from a data-flow diagram. For every element of the diagram, process, data store, data flow, external entity, and for every trust boundary the flows cross, ask what each of six categories would look like there. Spoofing violates authenticity, tampering violates integrity, repudiation violates non-repudiation, information disclosure violates confidentiality, denial of service violates availability, and elevation of privilege violates authorization. The value is not the taxonomy but the forced enumeration. A data flow from the browser to the payments service gets six questions instead of whatever the reviewer happened to think of that afternoon.
trust boundary
browser --HTTPS--> | API gateway --> order service --> ledger DB
(S,T,I) | (S,E) (T,E,R) (I,D)
|
S spoofing: can a caller claim another user's identity?
T tampering: can the request body be altered in flight or at rest?
R repudiation: can the actor later deny the action? is the log append-only?
I information disclosure: what does an error message reveal?
D denial of service: what is the cost ratio of request to response?
E elevation of privilege: what does this component run as, and why?
Attack trees, popularized by Schneier, are the complementary top-down tool. The root is the adversary's goal. Children are alternative ways to achieve it, combined with OR, and a node whose children must all hold is an AND node. Annotating leaves with cost, required skill, or detectability and propagating upward (minimum over OR children, sum over AND children) turns the tree into a cheapest-path calculation, which is what an economically rational attacker computes. The discipline it enforces is that spending must go where the cheapest path is. Hardening a 100,000-dollar branch while a 200-dollar branch reaches the same goal buys nothing.
GOAL: read another tenant's records
+-- OR exploit the application
| +-- OR broken object-level authorization on GET /orders/{id} cost 200
| +-- OR SQL injection in the reporting endpoint cost 2k
| +-- AND stored XSS in admin console + admin visits the page cost 5k
+-- OR exploit the platform
| +-- OR container escape via a kernel bug cost 150k
| +-- OR steal cloud credentials via SSRF to the metadata service cost 1k
+-- OR attack the humans
+-- OR phish an operator with push-fatigue MFA bypass cost 3k
+-- OR compromise a dependency the build system trusts cost 20k
cheapest path = 200; every dollar spent elsewhere first is misallocated
The trusted computing base
The trusted computing base is the set of components whose failure breaks the security policy. It is not the set of components trusted in the colloquial sense. It is the set one is forced to trust, and it is a liability to be minimized rather than an asset. For a web application the TCB typically includes the CPU and its microcode, the hypervisor, the host kernel, the container runtime, the language runtime, the TLS library, the authentication middleware, the query builder, every build-time dependency that can execute code, and the continuous-integration system's credentials. Writing this list down is the single most clarifying exercise in practical security, because it usually reveals that the TCB includes several things nobody audits, a YAML parser, a logging library that interprets its own input, a base image nobody rebuilt in a year.
Two properties matter. The first is size, because the probability that the TCB contains an exploitable defect grows with code volume, which is the argument for microkernels, for unikernels, for seL4's machine-checked proof over roughly nine thousand lines, and for pushing the parsing of untrusted input out of privileged components. The second is verifiability, because a small TCB nobody can inspect is worse than a larger one that can be rebuilt and diffed, which is the argument for open designs and reproducible builds. Note the direction of the reasoning. Reducing the TCB is worth real performance cost, because a component outside the TCB can be wrong without being fatal.
Policy versus mechanism
A policy states what should be allowed, and a mechanism enforces some class of policies. The separation is not academic hygiene. Systems that entangle the two, an access check written inline in each handler, a firewall rule set that encodes business logic, a hardcoded list of administrator identifiers, cannot answer the question "what is the policy?" without reading all the code, which means nobody can audit it and every new code path is a fresh chance to omit a check. Systems that separate them state the policy once, in a form a human can review, and let a single mechanism apply it everywhere. Row-level security in the database, a policy engine evaluated at a chokepoint, and capability-passing APIs are all instances of putting policy in data and mechanism in one auditable place. Complete mediation, below, is the property that the mechanism actually sees every access, and it is impossible to establish when the checks are scattered across a thousand handlers.
The design principles, and how each is violated today
Saltzer and Schroeder's 1975 survey stated eight principles for protection in information systems. They have aged extraordinarily well, in the sense that a large fraction of modern incidents can be classified as a violation of exactly one of them. What follows is each principle with a contemporary failure mode.
| Principle | Statement | A modern violation |
|---|---|---|
| Economy of mechanism | Keep the design as small and simple as possible, because only small designs can be reviewed exhaustively. | A logging library that resolves directory-service URLs found inside log messages. Log4Shell was not a memory bug. It was a feature almost nobody needed, reachable from attacker-controlled strings, inside a component that every service on earth had placed in its TCB. |
| Fail-safe defaults | Base access decisions on permission rather than exclusion, so the default is deny. | Object storage and database images that shipped world-readable unless configured otherwise, and orchestrators that mount a service-account token into every pod by default. Every default-allow surface has produced a public breach. |
| Complete mediation | Every access to every object must be checked, with no cached or bypassable path. | An authorization check in the API gateway that a newly added internal path bypasses, a permission evaluated at page load and trusted for the rest of the session, or a pre-signed URL that is never revalidated after the object's ACL changes. |
| Open design | The design must not be secret. Only the keys are secret. | Proprietary "secure" protocols in embedded, automotive and building-access products that fall in an afternoon once someone dumps the firmware. The counterexample that proves the principle is TLS, whose design is fully public and which is therefore repaired quickly when broken. |
| Separation of privilege | Require two independent conditions rather than one. | A production deploy that a single credential can trigger. Two-person review for infrastructure changes and hardware-backed second factors are the practical forms. SMS one-time codes are not independent of control over the phone number, so they do not separate. |
| Least privilege | Every program and user operates with the minimum privileges needed. | A build job with an organization-wide write token because scoping it was tedious, a container running as UID 0, or a database role that owns its schema, so an injection becomes a DROP TABLE. This is the principle that most directly limits blast radius after any other failure. |
| Least common mechanism | Minimize mechanisms shared by more than one user, since shared state is a channel. | Shared CPU caches, branch predictors and store buffers across sibling threads. Spectre-class attacks are exactly this principle failing in hardware, which is why cloud providers disabled simultaneous multithreading across tenants and why the fix is expensive. |
| Psychological acceptability | The interface must make the secure path the easy path. | Certificate warnings users click through, push notifications that train users to approve anything (the entire basis of MFA fatigue attacks), and secret management so awkward that engineers paste credentials into environment files. A control that is routinely bypassed provides zero security and negative auditability. |
Defense in depth is a ninth principle by adoption rather than by the original list, and it is a statement about probability. If a system has \(k\) controls on the path to an asset, each failing independently with probability \(p_i\), the probability of full compromise is \(\prod_{i=1}^{k} p_i\). The word carrying the weight is independently. Three layers that all depend on the same identity provider, or on the same parser, are one control with extra steps, and their joint failure probability is \(\max_i p_i\), not the product. Practical defense in depth therefore means choosing layers with different failure modes, such as a network policy that fails to configuration error, an application authorization check that fails to logic bugs, a database row-level policy that fails to schema drift, and an audit log that fails to almost nothing because it only observes.
Memory safety, and what actually goes wrong
Memory-safety bugs are all instances of the same root cause. The language permits a memory access whose validity depends on a program invariant, and the compiler is not required to check that invariant. Szekeres, Payer, Wei and Song's 2013 systematization framed the whole class as a two-step process. An attacker first causes a pointer to become invalid (out of bounds, or dangling after a free, or of the wrong type), and then causes that invalid pointer to be dereferenced for a read or a write. Every defense in the literature interrupts one of the two steps. Memory-safe languages prevent step one, and mitigations such as canaries, DEP, ASLR and CFI try to make step two survivable. Keeping that split in mind is what lets a defender predict which mitigations a given bug class actually defeats, and which it walks straight past.
The stack frame, drawn out
On x86-64 with the System V ABI, a call pushes the return address onto the stack and jumps. The callee typically pushes the caller's frame pointer, subtracts from the stack pointer to make room for locals, and, when compiled with stack protection, stores a per-process random value between the locals and the saved registers. Local arrays live inside that region, and writes into an array proceed toward higher addresses, which is the same direction as the saved registers. This is the structural fact from which the entire buffer-overflow story follows.
STACK OF A FUNCTION WITH A LOCAL ARRAY higher addresses +------------------------------+ | caller's frame | +------------------------------+ | return address (saved RIP) | <- what `ret` pops into the program counter +------------------------------+ | saved frame pointer (RBP) | +------------------------------+ | stack canary (random word) | <- only if -fstack-protector* is enabled +------------------------------+ | char name[16] | <- strcpy writes upward, starting here | | +------------------------------+ | other locals, register spill| +------------------------------+ <- RSP lower addresses A copy of N > 16 bytes into name[] does not stop at the array. It keeps writing through the canary, the saved frame pointer, and the saved return address. The function then returns: `ret` loads whatever now occupies the return-address slot into RIP. Control flow has been handed to a value that came from input, and from the compiler's point of view no rule of C was broken, because C does not check array bounds.
Two consequences deserve to be stated explicitly, because they explain why "just check the
lengths" is harder than it sounds. First, the corruption is silent until the return. The write
succeeds, the function continues normally, and the crash or the hijack happens later, which
makes the bug hard to localize without a tool that checks at the moment of the write. Second,
the return address is only the most convenient target. Overwriting the saved frame pointer, a
function pointer stored in a neighbouring struct, a length field used by a subsequent copy, or a
boolean such as is_admin all work, and the last of those defeats every control-flow
mitigation on this page, because control flow is never diverted at all. That family is known as
data-only attacks, systematized as data-oriented programming by Hu and colleagues in 2016.
The vulnerable pattern and its repair, in C, alongside the same logic in Rust where the check is not optional. The fixed C version is still a manual argument. It is correct because a human reasoned about it, and the reasoning has to be redone at every call site.
/* VULNERABLE: the destination size never enters the computation. */
void greet_bad(const char *user) {
char name[16];
strcpy(name, user); /* writes strlen(user)+1 bytes, unbounded */
printf("hello %s\n", name);
}
/* STILL WRONG: strncpy does not guarantee termination, and the length
argument here is a number the programmer guessed rather than the
destination capacity. A 16-byte user leaves name[] unterminated and the
printf below reads past the array. */
void greet_worse(const char *user) {
char name[16];
strncpy(name, user, 16);
printf("hello %s\n", name);
}
/* FIXED: capacity is derived from the object itself, truncation is
detected rather than ignored, and the result is always NUL-terminated. */
int greet_ok(const char *user) {
char name[16];
int n = snprintf(name, sizeof name, "%s", user);
if (n < 0 || (size_t)n >= sizeof name)
return -1; /* caller decides: reject, do not truncate */
printf("hello %s\n", name);
return 0;
}
/* Better still: do not copy at all. Bound the view instead of the buffer,
and build with -D_FORTIFY_SOURCE=3 -fstrict-flex-arrays=3 so the libc
copies that remain get compile-time and run-time size checks. */
// The same operation in Rust. There is no unchecked variant to reach for:
// slices carry their length, indexing is bounds-checked, and String owns
// and grows its buffer. The failure mode of the C version has no spelling.
fn greet(user: &str) -> String {
format!("hello {user}")
}
// If a fixed-capacity buffer really is required (embedded, no allocator),
// the capacity check is explicit and still enforced:
fn greet_fixed(user: &str, out: &mut [u8; 16]) -> Result<usize, ()> {
let bytes = user.as_bytes();
if bytes.len() > out.len() {
return Err(()); // reject rather than truncate
}
out[..bytes.len()].copy_from_slice(bytes);
Ok(bytes.len())
}
// The escape hatch exists and is greppable, which is the point: an audit
// can enumerate every place where memory safety is asserted by a human
// instead of by the compiler.
// #![forbid(unsafe_code)] // crate-level ban
// cargo geiger, cargo audit // inventory unsafe, check advisories
Mitigations for stack overflow, and how to verify them. Compile with
-fstack-protector-strong so that every function with a local array or an
address-taken local gets a canary, with -D_FORTIFY_SOURCE=3 -O2 so that libc copy
functions with a computable destination size become checked variants, and with
-fstack-clash-protection so that a large stack allocation cannot jump over the
guard page. Verify by looking for the checked symbols in the binary rather than trusting the
build script. Compiling the vulnerable function above with those flags on this machine produced
these undefined symbols, which are the observable evidence that the transformations happened.
$ gcc -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -pie \
-fcf-protection=full -Wl,-z,relro,-z,now -Wl,-z,noexecstack -o hardened prog.c
$ readelf -sW hardened | grep -E 'stack_chk_fail|strcpy_chk'
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __strcpy_chk@GLIBC_2.3.4
$ ./hardened AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
*** buffer overflow detected ***: terminated (exit 134, SIGABRT)
The last line is the important one. With fortification enabled, the checked strcpy
aborted the process at the moment of the overlong copy, before the return address was ever
reached. With fortification disabled but the canary still on, the same input instead produced
*** stack smashing detected *** at function exit, also exit 134. Both are
controlled crashes, which converts a potential code execution into a denial of service. That
trade is the entire business model of mitigation.
Integer overflow and signedness
Integer bugs are the most common way a correctly written bounds check gets defeated, because the check is performed on a value that has already wrapped. In C, signed overflow is undefined behavior, which means the compiler may assume it never happens and delete the branch that tests for it. Unsigned overflow is defined to wrap modulo \(2^n\), which is worse in a different way, because the wrap is silent and entirely legal. Three patterns cover most real cases.
Allocation size wrap. A computation such as malloc(n * sizeof(elem)) with
attacker-controlled n can wrap. With sizeof(elem) = 16 on a 64-bit
platform, choosing \(n = 2^{60}\) makes the product \(2^{64} \equiv 0\), so a zero-sized
allocation is followed by a loop that writes \(n\) elements. Signed-to-unsigned
promotion. A length compared as if (len > max) where len is
int and max is size_t promotes len to
unsigned, so a negative length passes the check and then becomes an enormous count inside
memcpy. Truncation. Storing a 64-bit length in a 32-bit or 16-bit field
discards the high bits. The classic case is a protocol field declared uint16_t
while the buffer accounting uses size_t.
The undefined-behavior sanitizer catches these at runtime. A deliberately buggy program compiled
here with -fsanitize=undefined -fno-sanitize-recover=all produced exactly this on
the wrapping addition, then aborted.
intovf.c:12:5: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
/* VULNERABLE: three separate integer hazards in six lines. */
void *read_records_bad(int count) { /* signed, attacker-controlled */
struct rec *v = malloc(count * sizeof(struct rec)); /* may wrap to 0 */
for (int i = 0; i < count; i++)
read_one(&v[i]);
return v;
}
/* FIXED: unsigned type, explicit policy cap, overflow-checked multiply,
allocation failure handled. */
void *read_records_ok(size_t count) {
const size_t MAX_RECORDS = 1u << 20; /* a policy bound, not a guess */
if (count > MAX_RECORDS)
return NULL;
size_t bytes;
if (__builtin_mul_overflow(count, sizeof(struct rec), &bytes))
return NULL; /* intrinsic, no UB anywhere */
struct rec *v = malloc(bytes);
if (!v)
return NULL;
for (size_t i = 0; i < count; i++)
read_one(&v[i]);
return v;
}
/* Prefer calloc(count, size): the multiplication happens inside libc and
is required to detect overflow. Build test configurations with
-fsanitize=signed-integer-overflow,unsigned-integer-overflow. */
// Rust's debug builds panic on overflow; release builds wrap. Neither is a
// memory-safety hole, because the allocation and the indexing are both
// checked, but silent wrapping is still a logic bug, so state the intent.
fn read_records(count: usize) -> Result<Vec<Rec>, Error> {
const MAX_RECORDS: usize = 1 << 20;
if count > MAX_RECORDS {
return Err(Error::TooMany);
}
// checked_mul turns the overflow case into a value, not a surprise
let bytes = count
.checked_mul(core::mem::size_of::<Rec>())
.ok_or(Error::Overflow)?;
let _ = bytes;
let mut v: Vec<Rec> = Vec::new();
v.try_reserve(count).map_err(|_| Error::Alloc)?; // fallible allocation
for _ in 0..count {
v.push(read_one()?);
}
Ok(v)
}
// The audit rule for parsers of untrusted input: ban bare arithmetic
// operators on lengths. checked_*, saturating_* and wrapping_* each state
// the intended behavior at the call site, where the reviewer is looking.
Mitigations and verification. Use size_t consistently, never
int, for lengths and counts. Use calloc or the
__builtin_*_overflow intrinsics for size arithmetic. Enable
-fsanitize=undefined in continuous integration with
-fno-sanitize-recover=all, so that a finding fails the job rather than printing a
line nobody reads. For security-critical production C, add the trapping integer sanitizers, and
verify with a unit test that feeds the boundary value \(2^{31}-1\) and asserts the process
aborts.
Format string bugs
printf(user_input) is a vulnerability and printf("%s", user_input) is
not, which is one of the cleanest examples in the field of a bug that lives entirely in an
interface rather than in an algorithm. The reason is that printf is variadic. The
format string tells the implementation how many arguments to fetch and how to interpret each,
and the implementation has no way to discover how many were actually passed. A format string
containing \(k\) conversion specifiers therefore causes \(k\) fetches from the argument-passing
registers and then from the stack, whatever happens to be sitting there. Read specifiers such as
%p and %s disclose memory, which is the classic route to defeating
ASLR because one leaked code or stack pointer reveals an entire randomized base. The write
specifier %n, which stores the number of bytes emitted so far through an
int* argument, turns disclosure into a memory write.
Mitigation and verification. The fix is total and costs nothing. The format
string must be a compile-time constant. Enforce it with -Wformat -Wformat-security
-Werror=format-security, which makes any call with a non-literal format an error, and put
__attribute__((format(printf, m, n))) on wrapper functions so the checking
propagates through the codebase's own logging layer, which is where these bugs actually hide.
Verify by adding a file containing printf(argv[1]) to a test build and confirming
the build fails. Modern glibc also refuses %n in a writable format string, but that
is a backstop, not the control.
Use-after-free, double free, and type confusion
Temporal safety errors are now more common than spatial ones in mature C++ codebases, because bounds checking is at least easy to reason about locally while object lifetime is a global property. A use-after-free arises when a pointer outlives the allocation it names. What makes it exploitable rather than merely a crash is the allocator. After the free, the same address is handed to the next allocation in a compatible size class, so anyone who can cause an allocation between the free and the use controls the bytes that the stale pointer now reads or writes. If the freed object contained a function pointer or a C++ vtable pointer, the stale pointer's virtual call reads a pointer that came from input.
A double free is the same problem one level down, inside the allocator's own bookkeeping. Freeing a chunk twice places it on a free list twice, so two subsequent allocations return the same address, giving two objects of possibly different types the same storage. Heap metadata corruption generalizes this. Classic allocators store linked-list pointers and size fields inline next to user data, so a heap buffer overflow overwrites the allocator's own structures and the allocator's later unlink operation performs an attacker-chosen write. Modern allocators harden this with pointer obfuscation, size-class segregation and integrity checks on unlink. The structural fix, adopted by the hardened allocators in Chrome and Android, is to move metadata out of line entirely so that a data overflow cannot reach it at all.
Type confusion is the third class. An object is created as one type and later accessed through a pointer of an incompatible type, typically via an unchecked downcast, a union whose discriminant was never consulted, or a deserializer that reconstructs objects from a tag in the input. The memory is valid and the pointer is live, so bounds checking and lifetime checking both pass. What fails is that field offsets and, critically, vtable slots mean something different under the other type. This is the dominant bug class in browser JavaScript engines, where a compiler that speculates on a value's type and guards incorrectly hands over exactly this primitive.
AddressSanitizer detects use-after-free directly and reports the allocation site and the free site as well as the use, which is what makes it usable in practice. Running a deliberately buggy program here produced this report.
==4169005==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
#0 in main uaf.c:8
0x502000000010 is located 0 bytes inside of 16-byte region [...0010,...0020)
freed by thread T0 here:
#0 in __interceptor_free
#1 in main uaf.c:7
previously allocated by thread T0 here:
#0 in __interceptor_malloc
#1 in main uaf.c:5
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:8 in main
Shadow bytes around the buggy address:
=>0x0a047fff8000: fa fa[fd]fd fa fa fa fa fa fa fa fa fa fa fa fa
The shadow byte fd is ASan's encoding for a freed heap region and fa
for a redzone. The mechanism is described under the sanitizers below. The three-stack report,
use site plus free site plus allocation site, is what turns a heisenbug into a fifteen-minute
fix.
// VULNERABLE: ownership is implicit, so the lifetime argument lives in a
// comment, and the comment is wrong after the next refactor.
class Session {
public:
Connection *conn; // who owns this? unclear
void close() { delete conn; } // ... and conn is not nulled
void ping() { conn->send("p"); } // use-after-free if close() ran
};
// FIXED (1): express ownership in the type system. unique_ptr makes the
// double free unrepresentable and the liveness check mechanical.
class Session2 {
std::unique_ptr<Connection> conn_;
public:
void close() { conn_.reset(); } // idempotent
void ping() { if (conn_) conn_->send("p"); } // explicit liveness
};
// FIXED (2): where sharing is genuinely required, make the observer's
// weakness explicit instead of storing a raw pointer.
class Watcher {
std::weak_ptr<Connection> conn_;
public:
void ping() {
if (auto c = conn_.lock()) c->send("p"); // fails safe if gone
}
};
// Type confusion: never static_cast down a polymorphic hierarchy on
// attacker-influenced data. dynamic_cast returns nullptr on mismatch;
// better still, carry a tagged variant so the compiler enforces the check.
// auto *d = dynamic_cast<Derived *>(base); if (!d) return Error;
// Build test configurations with -fsanitize=address,undefined and, for
// downcasts specifically, clang's -fsanitize=cfi-derived-cast.
// The borrow checker rejects the vulnerable version at compile time:
// `conn` cannot be used after it is dropped and cannot be dropped twice,
// because ownership is a static property of the program text.
struct Session {
conn: Option<Connection>,
}
impl Session {
fn close(&mut self) {
self.conn = None; // drop happens here, exactly once
}
fn ping(&mut self) -> Result<(), Error> {
// the Option forces the liveness question to be answered
self.conn.as_mut().ok_or(Error::Closed)?.send(b"p")
}
}
// Shared ownership when it is genuinely needed: Arc for strong references,
// Weak for observers. Weak::upgrade returns None if the target is gone,
// the same fail-safe shape as weak_ptr, except that it is not optional.
use std::sync::Weak;
struct Watcher { conn: Weak<Connection> }
impl Watcher {
fn ping(&self) {
if let Some(c) = self.conn.upgrade() { c.send(b"p"); }
}
}
// Type confusion has no analogue in safe code: downcasting goes through
// `dyn Any::downcast_ref`, which compares TypeId and returns an Option,
// and deserializing into an enum forces every variant to be handled.
Mitigations and verification. Make ownership explicit in types
(unique_ptr, shared_ptr with weak_ptr observers, or
Rust's borrow checker). Null the pointer at the free site so a stale use becomes a null
dereference rather than a controlled write. Prefer arena or region allocation for object graphs
with a common lifetime, and deploy a hardened allocator with quarantine and delayed reuse, or
hardware-assisted memory tagging (ARM MTE) where the hardware offers it, so that reuse is not
immediate. Verify with ASan in every test job, with LeakSanitizer enabled (it runs by default
alongside ASan on Linux and reported a deliberate leak here as Direct leak of 1024 byte(s)
in 1 object(s)), and by running fuzz targets under ASan, since fuzzing reaches the
interleavings that unit tests do not.
The mitigation arms race, in deployment order
The defenses below were deployed roughly in the order given, each in response to the previous one being routinely bypassed. Reading them as a sequence is the fastest way to understand what any single mitigation does and does not promise, and it makes clear why the industry eventually concluded that the sequence has an asymptote.
Stack canaries (1998)
A canary is a random word placed between the local variables and the saved return address, read
back and compared just before ret. A contiguous overflow that reaches the return
address must cross the canary first, so the mismatch is detected and the process aborts. The
value is per-process, drawn at startup, and on Linux is kept in thread-local storage reached
through the %fs segment so that it is not itself on the stack. The "terminator
canary" variant embeds bytes such as NUL, CR and LF that string functions stop at, which blocks
naive string-based overflows even without randomness.
How it was circumvented. Three ways, all structural rather than clever. An overflow that does not proceed contiguously, for instance one that corrupts a pointer and then writes through it, never touches the canary. Any information leak that discloses the canary value lets it be rewritten unchanged. And targets other than the return address, such as function pointers in locals, the saved frame pointer, or plain data, are never protected by a check that only runs at return. Canaries also do nothing for heap corruption, which is where the bugs migrated. The honest description is that canaries raise the cost of the single most common 1990s bug shape and do nothing for the general case, which is a good trade for roughly one to three percent of performance.
DEP / NX and W^X (2003 onward)
The next step removed the attacker's ability to execute injected bytes. Hardware support for a no-execute bit in the page tables let the kernel enforce that a page is either writable or executable but never both, so data pages, including the stack and heap, are non-executable. Getting a controlled value into the program counter is no longer sufficient, because the memory it points to cannot be fetched as instructions.
How it was circumvented. By reusing code that is already executable.
Return-to-libc, described publicly in 1997, points the return address at an existing library
function so that the program itself performs the useful operation. Shacham's 2007 work
generalized this into return-oriented programming. Instead of whole functions, use short
instruction sequences ending in ret, called gadgets, and chain them by laying a
sequence of addresses on the stack, so that each gadget's return transfers to the next. The
paper's substantive result is that on x86, whose instructions are variable-length and unaligned,
the gadget set found in a standard libc is sufficient to express arbitrary computation. Later
work extended this to fixed-width architectures and to jump-oriented variants. The defensive
lesson is precise. DEP changes the currency of exploitation from injected code to existing code,
and any process containing a large amount of executable library code offers a rich gadget
corpus. It does not reduce the number of bugs by one.
BEFORE DEP AFTER DEP
corrupt return address corrupt return address
| |
v v
jump into attacker bytes jump into an *existing* code fragment
on the stack, execute them that ends in `ret`, which pops the next
address off the corrupted stack, and so
on: the stack becomes a program counter
over pre-existing code.
Consequence for defenders: shrink the executable surface (LTO, --gc-sections,
-Wl,-z,separate-code), never map W+X pages, and treat any JIT as a special
case that needs its own W^X discipline.
ASLR (2001 onward) and the information-leak requirement
Code reuse requires knowing addresses. Address space layout randomization removes that knowledge
by placing the executable, the libraries, the heap and the stack at randomized bases each run.
Position-independent executables extend randomization to the main binary. Without
-pie the program's own code sits at a fixed address and supplies gadgets for free.
The security ASLR provides is exactly the entropy of the randomized base, and it fails when the
entropy is small or when a single pointer leaks.
How it was circumvented. Low entropy on 32-bit systems made blind guessing feasible. The classic analysis by Shacham and colleagues in 2004 observed 16 bits of mmap entropy on 32-bit Linux and derandomized a forking server in minutes. Information leaks are the general answer on 64-bit. A format-string bug, an uninitialized-memory disclosure, an over-read such as Heartbleed, or a JavaScript-visible pointer all reveal one address, and one address reveals the whole module base, because everything within a module is at a fixed offset from it. Servers that fork without re-exec share the parent's layout, converting independent guesses into sampling without replacement. Partial overwrites of the low bytes of a pointer stay within the same page and so need no knowledge of the base at all. The practical conclusion, which drives modern defensive design, is that ASLR is one information leak deep, so memory-disclosure bugs must be treated as critical, not as informational.
A network service accepts a request that triggers a return-address overwrite. The service is
32-bit with 16 bits of library-base entropy, and the target address must be guessed exactly.
(a) If the service re-execs after each crash so that every attempt sees an independent uniform
layout, what is the expected number of attempts to guess correctly? (b) If instead the service
fork()s children from a parent that is never re-exec'd, so that all children
share one layout, what is the expected number of attempts? (c) At 100 attempts per second,
convert both to wall-clock time. (d) The service is rebuilt for 64-bit with 28 bits of
entropy. Recompute (a). (e) What single change to the attacker's capabilities collapses all of
these numbers to one attempt, and what does that imply about the severity ranking of
memory-disclosure bugs?
Solution. (a) Each attempt is an independent success with probability \(p = 2^{-16}\), so the number of attempts is geometric with mean \(1/p = 65{,}536\).
(b) With one fixed layout, the attacker enumerates candidates without replacement. The correct value is uniform over \(N = 65{,}536\) possibilities, so the expected number of trials is \((N+1)/2 = 32{,}768.5\), a factor of two better, and, more importantly, the variance collapses. The attack is guaranteed to finish within \(N\) tries rather than merely probably.
(c) At 100 attempts per second, case (a) takes \(65{,}536/100 = 655.4\) seconds, about 11 minutes. Case (b) takes \(32{,}768/100 = 327.7\) seconds, about 5.5 minutes. Both are trivially within an attacker's patience, which is the empirical point made by the 2004 derandomization work.
(d) With 28 bits, \(1/p = 2^{28} = 268{,}435{,}456\) attempts. At 100 per second that is \(2.68 \times 10^{6}\) seconds, or about 31 days of continuous crashing, each crash logged and each restart visible. Blind brute force is no longer the cheap path.
(e) A single memory-disclosure primitive. One leaked pointer into a module reveals that module's base exactly, because intra-module offsets are fixed at link time, so the number of guesses drops from \(2^{28}\) to 1. The severity implication is the operational lesson. An out-of-bounds read that "only" prints a few bytes of memory is not an informational-severity bug. It is the enabling half of a two-part exploit, and in a codebase with any memory-corruption bug it should be triaged at the same level as the corruption itself.
Control-flow integrity (2005) and its coarse-grained gap
Abadi, Budiu, Erlingsson and Ligatti's CFI states the defense directly. Compute the control-flow graph ahead of time, and enforce at runtime that every indirect transfer goes to a target the graph permits. Forward edges (indirect calls and jumps) are checked against the set of address-taken functions with a matching type signature. Backward edges (returns) are checked against the actual call site. The insight is that a code-reuse attack must, by definition, take an edge the compiler never emitted, so enforcing the graph invalidates the technique rather than any particular gadget.
How it was circumvented. Precision is the whole game. Coarse-grained
implementations that allow any indirect call to reach any function entry leave equivalence
classes large enough to build useful chains, a point made by several 2014 analyses that
constructed working chains against deployed coarse CFI. Even perfectly precise forward-edge CFI
leaves the backward edge to shadow stacks, and even perfect control-flow enforcement leaves
data-only attacks untouched, since those never violate the graph. The current practical position
is that fine-grained forward-edge CFI (clang's -fsanitize=cfi with link-time
optimization, Microsoft's Control Flow Guard, hardware indirect-branch tracking) plus a hardware
shadow stack is a genuinely strong combination against code reuse, and that data-only attacks
are the residual risk.
Shadow stacks, Intel CET, and ARM pointer authentication
A shadow stack keeps a second copy of return addresses in memory the program cannot write
through ordinary stores. On return, the two are compared. Software implementations paid for this
in registers and instructions. Intel's Control-flow Enforcement Technology puts it in hardware,
with a shadow stack the page tables mark specially and an indirect branch tracking mode that
requires every indirect branch target to begin with an ENDBR64 instruction, which
shrinks the legal target set enormously at near-zero cost. Compiling with
-fcf-protection=full emits the markers and records the property in an ELF note.
Verifying it is a one-line check, and on this machine the hardened build reported the following.
$ readelf -nW hardened | grep -i 'x86 feature' Properties: x86 feature: IBT, SHSTK, x86 ISA needed: x86-64-baseline $ readelf -dW hardened | grep -iE 'bind_now|flags' 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE $ readelf -lW hardened | grep -E 'GNU_STACK|GNU_RELRO' GNU_STACK 0x000000 ... RW <- no E: the stack is non-executable GNU_RELRO 0x002da8 ... R <- relocations read-only after startup $ readelf -hW hardened | grep Type: Type: DYN (Position-Independent Executable file) <- PIE, so ASLR applies
ARM took a different route with pointer authentication, introduced in ARMv8.3. A pointer
occupies fewer bits than the 64-bit register holding it, and the spare bits carry a truncated
message authentication code over the pointer value and a context value (typically the stack
pointer), keyed by a register the process cannot read. Instructions sign a pointer before
storing it and authenticate it before use. A corrupted pointer authenticates to a value
guaranteed to fault. This makes return addresses and function pointers unforgeable without the
key rather than merely unknown, which is a strictly stronger property than randomization, and it
costs only a few cycles. Compile with -mbranch-protection=standard to get pointer
authentication on return addresses plus branch target identification. It is not unbreakable. The
signature is short, so a signing oracle inside the process can be reused, and the PACMAN work in
2022 showed that speculative execution can be used to test candidate signatures without the
faults that would otherwise make guessing detectable, which is a nice illustration of two
mitigations interacting badly.
Memory-safe languages as the structural fix
Everything above is a mitigation. It accepts that the bug exists and tries to make the second step of Szekeres et al.'s two-step model fail. Each one is worth deploying, each has a known bypass, and each costs performance. The alternative is to make step one impossible by construction, which is what a memory-safe language does. Bounds are checked, lifetimes are enforced (by a garbage collector, by reference counting, or by Rust's affine type system checked at compile time), and casts are validated.
The empirical case is the strongest argument in applied security today, and it is worth stating carefully because it is often overstated. Memory-safety bugs are roughly 70 percent of serious vulnerabilities in large C and C++ codebases, as measured independently by Microsoft's response center over 2006 to 2018 and by the Chromium security team. Rewriting existing code is usually not the cost-effective move. Google's Android data showed that writing new code in safe languages drove the memory-safety share of vulnerabilities from about 76 percent in 2019 to about 24 percent in 2024, because vulnerability density is concentrated in recently written code and decays as code ages. The strategic recommendation that follows, and which the 2024 joint government guidance adopted, is not "rewrite everything" but "write new components, and especially new parsers of untrusted input, in a memory-safe language, and interpose safe wrappers at the boundaries of the old ones".
Two caveats a defender must hold. First, Rust's unsafe blocks reintroduce every bug
in this section, which is why the community publishes an advisory database and why crates with
large unsafe surfaces deserve the same scrutiny as C. Second, memory safety does not touch logic
bugs, injection, access control, side channels, or supply chain, which is most of the rest of
this page. It removes one very large, very well-understood class and leaves the others exactly
where they were.
The hardening flags that actually matter
This is the actionable core of the section. Each row is a flag, the property it establishes, and the command that proves it landed in the binary rather than in the build file.
| Flag | What it does | How to verify |
|---|---|---|
-O2 -D_FORTIFY_SOURCE=3 | Replaces libc calls with size-checked variants where the destination size is computable. Level 3 handles dynamically sized objects. | readelf -sW bin | grep _chk shows __memcpy_chk, __strcpy_chk, and friends. |
-fstack-protector-strong | Canary in functions with arrays or address-taken locals. | readelf -sW bin | grep __stack_chk_fail |
-fstack-clash-protection | Probes each page when growing the stack, so a huge frame cannot skip the guard page. | Disassemble a function with a large VLA and look for the probe loop. |
-fPIE -pie | The main executable is relocatable, so ASLR randomizes it too. | readelf -hW bin | grep Type: reports DYN. |
-Wl,-z,relro,-z,now | Resolves all relocations at load and makes the GOT read-only, removing a classic write target. | readelf -dW bin shows BIND_NOW, and readelf -lW bin shows GNU_RELRO. |
-Wl,-z,noexecstack | Marks the stack non-executable. | readelf -lW bin | grep GNU_STACK shows RW, not RWE. |
-fcf-protection=full | x86 CET, indirect branch tracking plus shadow stack support. | readelf -nW bin | grep 'x86 feature' reports IBT, SHSTK. |
-mbranch-protection=standard | AArch64 pointer authentication on return addresses plus BTI landing pads. | readelf -nW bin shows the AArch64 feature property, and objdump shows paciasp. |
-Wformat -Werror=format-security | Non-literal format strings become compile errors. | A test file with printf(argv[1]) must fail the build. |
-ftrivial-auto-var-init=zero | Zero-initializes locals, killing uninitialized-memory disclosure and its use as an info leak. | Compare disassembly of a function with a large uninitialized local. |
-fsanitize=address,undefined | Test-only. Catches the bugs rather than mitigating them. | Run the test suite, and a finding must fail the job (-fno-sanitize-recover=all). |
-fsanitize=cfi -flto -fvisibility=hidden | clang forward-edge CFI, requires LTO to know the call graph. | Call an indirect function through a wrong-typed pointer in a test and confirm the trap. |
Two operational notes. The flags interact. Fortification requires optimization to compute sizes,
CFI requires link-time optimization, and CET requires the whole dependency chain to be built
with it or the shadow stack is disabled at load time for the process. And hardening is only
meaningful when it is verified per artifact in continuous integration. A check that runs
readelf over the release binary and fails on a missing property is perhaps thirty
lines of shell and catches the day somebody adds a dependency built the old way.
The browser security model and the web attack surface
Origins and the same-origin policy, stated precisely
The browser runs code from mutually distrusting parties in one process tree and must keep them
apart. The unit of separation is the origin, the triple (scheme, host, port). Two URLs
are same-origin if and only if all three components are equal, so
https://a.example:443 and http://a.example:443 differ (scheme), and
https://a.example and https://b.example differ (host), and no amount
of shared parent domain changes that. The same-origin policy is then the rule that script from
one origin may not read the DOM, cookies, storage, or response bodies of another.
The precision matters because the policy is a patchwork of historical exceptions, and every
exception is a place bugs live. Writes are frequently allowed where reads are not. A form or an
image tag may issue a cross-origin request, and the browser will attach cookies, but script
cannot read the response. That asymmetry, send-but-not-read, is exactly the gap CSRF lives in.
Embedding is allowed but reading is not. A cross-origin <img>,
<script>, stylesheet or iframe renders, yet script cannot inspect its
contents, which is why clickjacking works and why a cross-origin script, once included, runs
with the including page's origin and authority. And several mechanisms use a coarser
notion, the registrable domain or "site", rather than the origin. Cookies historically ignore
the port and can be scoped to a parent domain, which is why a.example.com and
b.example.com are not fully isolated from each other in the cookie jar even though
they are different origins.
https://app.example.com:443/page compare against:
scheme host port
| | |
https app.example.com 443 -> same origin only if ALL THREE match
http://app.example.com:443 different scheme -> different origin
https://api.example.com:443 different host -> different origin
https://app.example.com:8443 different port -> different origin
ALLOWED cross-origin without consent: navigation, form POST, <img>,
<script>, <link rel=stylesheet>, <iframe> embedding, and the cookies
those requests carry.
FORBIDDEN without consent: reading the response body, reading the framed
DOM, reading another origin's storage.
Consent mechanisms: CORS (server opts in per origin), postMessage
(explicit channel with an origin check), CSP frame-ancestors.
Cross-site scripting, and why sanitizing input cannot work
XSS is the execution of attacker-supplied script in the origin of the target site, which grants
the attacker everything the origin has, DOM access, session cookies not marked HttpOnly, and the
ability to issue authenticated requests that pass every CSRF check because they originate from
the page. It comes in three shapes. Stored XSS persists the payload server-side (a
comment, a profile field, a log line rendered in an admin console) so every viewer is attacked.
Reflected XSS echoes part of the request into the response, so the attacker must get
the victim to follow a crafted link. DOM-based XSS never involves the server's HTML at
all. Client-side script reads from a source it does not control (the URL fragment,
postMessage data, localStorage) and passes it to a sink that parses
markup or code (innerHTML, document.write, eval, or a
framework's HTML-injection escape hatch).
The essential defensive insight is that XSS is not an input problem, it is an output problem,
and this is where most teams get it wrong. Whether a string is dangerous depends entirely on the
context it is inserted into, and one input can flow to several contexts. HTML text
nodes need &, <, > escaped. A quoted attribute
needs the quote character escaped, and an unquoted attribute needs a much larger set because
whitespace or slash ends it. Inside a <script> block the content is
JavaScript, so HTML escaping is meaningless and the correct transform is JSON or JavaScript
string escaping. A URL attribute needs percent-encoding plus a scheme allowlist, because
javascript: is a valid URL, and CSS contexts have their own escaping. A sanitizer at
the input boundary cannot know which of these the value will meet, because that is decided later
and possibly in several places at once. Worse, input sanitization corrupts data (a user
legitimately named O'Brien, a code snippet containing <) and creates a false
sense of safety that suppresses the correct fix.
The correct defense is contextual output encoding applied automatically by the template engine at the point of insertion, because only there is the context known. Every modern framework does this by default and offers exactly one way to opt out, which is the thing to grep for during review. Where HTML must genuinely be accepted from users, parse it and rebuild it from an allowlist with a maintained sanitizer rather than filtering with regular expressions. DOMPurify exists precisely because the set of parser quirks that turn benign-looking markup into script is far larger than any hand-written filter's author expects.
// VULNERABLE: string concatenation into markup. The value crosses three
// different contexts and is escaped for none of them.
function renderBad(user: {name: string; site: string; id: string}) {
el.innerHTML =
'<div title="' + user.name + '">' +
'<a href="' + user.site + '">' + user.name + '</a>' +
'<script>init("' + user.id + '")</script></div>';
}
// FIXED: build nodes, never markup. textContent cannot create an element;
// setAttribute cannot escape its attribute; the URL is validated by
// parsing it rather than by pattern-matching it.
function renderOk(user: {name: string; site: string; id: string}) {
const div = document.createElement("div");
div.title = user.name; // attribute, not markup
const a = document.createElement("a");
a.textContent = user.name; // text node, never parsed
const url = safeHttpUrl(user.site);
if (url) a.href = url; // omit rather than guess
div.append(a);
el.replaceChildren(div); // no innerHTML anywhere
}
function safeHttpUrl(raw: string): string | null {
let u: URL;
try { u = new URL(raw, location.origin); } catch { return null; }
// allowlist the scheme: javascript:, data: and blob: are all executable
return (u.protocol === "https:" || u.protocol === "http:") ? u.href : null;
}
// If user-supplied HTML is a genuine product requirement, parse and
// rebuild from an allowlist instead of filtering:
// import DOMPurify from "dompurify";
// el.innerHTML = DOMPurify.sanitize(userHtml, {USE_PROFILES: {html: true}});
// and keep the library updated, because bypasses are found in parsers
// regularly and the fix ships in the library, not in your code.
"""Server-side rendering. The rule is identical: encode at the sink, in
the sink's own language, and let the template engine do it."""
from markupsafe import escape # HTML-context escaping
import json
# VULNERABLE: manual string building, no context awareness.
def page_bad(name: str, payload: dict) -> str:
return f"<h1>Hello {name}</h1><script>var d = {payload};</script>"
# FIXED: two different contexts, two different encoders.
def page_ok(name: str, payload: dict) -> str:
# HTML text context: & < > " ' become entities
safe_name = escape(name)
# JavaScript context: JSON-encode, then neutralize the sequences that
# would terminate the script element early inside an HTML parser.
blob = (json.dumps(payload)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026"))
return f"<h1>Hello {safe_name}</h1><script>var d = {blob};</script>"
# In practice: use a template engine with autoescaping ON by default
# (Jinja2 with autoescape=True, Django templates, React's JSX) and treat
# every opt-out as a reviewable event. The greppable escape hatches are:
# Jinja2: |safe , {% autoescape false %}
# Django: |safe , mark_safe(), {% autoescape off %}
# React: dangerouslySetInnerHTML
# Vue: v-html
# A CI rule that fails the build when these appear outside an allowlisted
# module is cheap and catches the regression that review misses.
Mitigations and verification. Layer three controls. First, contextual autoescaping in the template engine, verified by a lint rule that forbids the escape hatches outside an allowlist. Second, a Content Security Policy that removes the attacker's ability to execute even if a payload lands. A modern policy is nonce- or hash-based rather than host-based, because host allowlists are routinely bypassed through JSONP endpoints and open redirects on allowlisted CDNs.
Content-Security-Policy: default-src 'none'; script-src 'nonce-r4nd0mPerResponse' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; require-trusted-types-for 'script'; report-uri /csp-report Reading it: 'nonce-...' is a fresh unpredictable value per response, echoed on each legitimate <script> tag, so injected script (which cannot know the nonce) does not run. 'strict-dynamic' lets an already-trusted script load its own dependencies, which is what makes nonces workable with bundlers. The trailing https: and 'unsafe-inline' are ignored by browsers that understand nonces and act as fallbacks for those that do not. base-uri and object-src close two classic bypasses; frame-ancestors replaces X-Frame-Options; form-action stops exfiltration by form injection.
Third, Trusted Types, which is the structural fix for DOM-based XSS. With
require-trusted-types-for 'script' the browser refuses to accept a plain string at
a dangerous sink such as innerHTML, and requires an object produced by a named
policy. This converts an unbounded review problem ("is every assignment to every sink safe?")
into a bounded one ("are the three policies correct?"). Verify by deploying the policy in
report-only mode first, collecting violation reports for a release cycle, then enforcing, and by
adding a test that asserts the response header is present on every route, since the most common
CSP failure is a policy that exists on the main page and is absent on an error page or a legacy
endpoint.
Cross-site request forgery
CSRF exploits the asymmetry noted above. A cross-origin request can be sent with the victim's cookies even though its response cannot be read. If the server authorizes by cookie alone, a page on an unrelated site can cause a state-changing request to be made on the victim's behalf, and the attacker never needs to see the answer, only to cause the effect. The root cause is ambient authority. The credential is attached by the browser automatically, based on the destination, without regard to who initiated the request.
Three defenses, best deployed together. SameSite cookies attack the ambient
authority directly. SameSite=Lax withholds the cookie from cross-site subrequests
while still sending it on top-level navigations, which preserves the ordinary "click a link to a
logged in site" experience, and SameSite=Strict withholds it even then. Browsers
now default to Lax when the attribute is absent, which eliminated the majority of naive CSRF,
but Lax still permits cross-site top-level GET navigations to carry the cookie, so any state
change reachable by GET remains exposed. Second, a synchronizer token, a random value bound to
the session, delivered in the page, echoed in the request body or a custom header, and compared
server-side. The attacker cannot read the token (the same-origin policy blocks reading the
page), so cannot forge the request. Third, origin verification. Check the Origin
header, which browsers attach to state-changing cross-origin requests and which script cannot
forge.
Set-Cookie: session=<opaque, high-entropy value>;
HttpOnly; no script access, blunts XSS cookie theft
Secure; never sent over plaintext HTTP
SameSite=Lax; withheld from cross-site subrequests
Path=/; scope
Max-Age=1209600 bounded lifetime, and rotate on privilege change
Prefix the name with __Host- (so: __Host-session) to make the browser
enforce Secure, Path=/ and no Domain attribute, which prevents a
compromised sibling subdomain from setting a cookie your origin will trust.
Verification. Write an integration test that issues a state-changing request
with a valid session cookie but no CSRF token and an Origin header from another
site, and assert a 403. Assert the cookie flags in a test rather than reading them off a running
server once. And enumerate every route that mutates state on GET. There should be none.
SQL injection, and why parameterization is a structural fix
Injection of every kind, SQL, shell, LDAP, XPath, template, log, has one shape. A string is assembled from a trusted template and untrusted data, and then handed to an interpreter that parses the whole string. The parse is where the damage happens, because characters in the data are given syntactic meaning they were never intended to have. Escaping tries to prevent that by transforming the data so the parser treats it as literal, which works only if the escaping function agrees exactly with the parser about quoting, character sets, comment syntax and every dialect quirk. That agreement is fragile, and the history of injection is a history of it failing.
Parameterized queries, also called prepared statements, remove the problem rather than mitigating it. The query text goes to the database first and is parsed into a statement with typed placeholders. The parameters are then transmitted separately, out of band, and bound to those placeholders as values. There is no point at which the data can influence the parse, because the parse already happened. This is the same structural argument as Trusted Types and as prompt injection's absence of a fix. The vulnerability is caused by data and control sharing a channel, and the fix is to separate the channels, not to filter the channel.
# VULNERABLE: the value becomes part of the statement text.
def find_bad(conn, email):
cur = conn.cursor()
cur.execute("SELECT id, email FROM users WHERE email = '" + email + "'")
return cur.fetchall()
# FIXED: placeholders. The driver sends statement and parameters
# separately; the value can never be parsed as SQL.
def find_ok(conn, email):
cur = conn.cursor()
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
return cur.fetchall()
# The part parameterization does NOT cover: identifiers and syntax.
# A sort column or table name cannot be a bound parameter, so it must be
# mapped through an allowlist. Never interpolate it, even "after
# validating" with a regex.
SORTABLE = {"created": "created_at", "email": "email", "id": "id"}
def list_users_ok(conn, sort_key: str, descending: bool, limit: int):
column = SORTABLE.get(sort_key)
if column is None:
raise ValueError("unsupported sort") # fail closed
direction = "DESC" if descending else "ASC" # from a bool, not a string
sql = f"SELECT id, email FROM users ORDER BY {column} {direction} LIMIT %s"
cur = conn.cursor()
cur.execute(sql, (min(int(limit), 1000),)) # cap, and force to int
return cur.fetchall()
# Defense in depth for the day a query is still built wrong:
# - the application's DB role has no DDL rights and no access to other
# schemas, so an injection cannot DROP or read the audit tables;
# - row-level security policies scope every row to the tenant, so an
# injected OR 1=1 still returns only the caller's rows;
# - query timeouts and row caps bound the damage of a blind extraction.
// VULNERABLE: template literals feel safe and are not. The value lands
// inside the statement text exactly as concatenation would.
async function findBad(db: Pool, email: string) {
return db.query(`SELECT id, email FROM users WHERE email = '${email}'`);
}
// FIXED: placeholders, with the values passed as a separate array.
async function findOk(db: Pool, email: string) {
return db.query("SELECT id, email FROM users WHERE email = $1", [email]);
}
// Tagged templates that build a parameterized query are safe *because*
// the tag function extracts the interpolations into bind parameters;
// the shape looks like concatenation but is not. Confirm the library
// actually does this before relying on it:
// const rows = await sql`SELECT id FROM users WHERE email = ${email}`;
// ORM/query-builder usage: the raw escape hatch is the thing to audit.
// Prisma: $queryRawUnsafe (safe variant: $queryRaw tagged template)
// TypeORM: query() (safe variant: parameters object)
// Knex: knex.raw(str) (safe variant: knex.raw(str, [bindings]))
// A lint rule banning the unsafe variants outside a reviewed module is
// the practical control, because the safe path is already the default.
-- What the database actually sees, and why the ordering matters.
-- 1. Parse and plan happen with placeholders in place of values.
PREPARE find_user (text) AS
SELECT id, email FROM users WHERE email = $1;
-- 2. Values arrive later, already typed, and are bound into the plan.
-- No lexing of the value ever occurs, so no character in it can
-- change the statement's structure.
EXECUTE find_user ('alice@example.com');
EXECUTE find_user ($$' OR '1'='1$$); -- matches literally zero rows:
-- it is an email that happens to
-- contain quotes, nothing more.
-- Defense in depth at the schema level: least privilege for the app role
CREATE ROLE app_rw LOGIN;
REVOKE ALL ON SCHEMA public FROM app_rw;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE ON app.orders TO app_rw; -- no DELETE, no DDL
-- and row-level security so tenancy is enforced by the database, not by
-- every query author remembering to add a WHERE clause.
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON app.orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Verification. Run a static analysis rule that flags any execution path where a query string is built by concatenation or interpolation from a non-constant. This is one of the few rules with a genuinely low false-positive rate. Confirm row-level security is active by connecting as the application role and selecting without a tenant filter. The correct result is zero rows, not an error. Confirm the application role cannot create or drop tables. And log queries that return more than a threshold number of rows, since blind extraction looks distinctive.
Command injection and template injection
The same shape, different interpreter. Command injection occurs when a string is handed to a
shell. system("convert " + filename) gives every shell metacharacter its meaning,
and quoting is not a fix because the quoting rules are baroque and differ per shell. The
structural fix is again channel separation. Invoke the program directly with an argument vector,
never through a shell, so the operating system passes each argument as a separate string and no
parsing occurs. In Python that means subprocess.run([...], shell=False), in Node it
means execFile rather than exec, and in C it means
posix_spawn with an argv array rather than system. Where a filename or
path is involved, also validate that the resolved path is inside the intended directory, and
prefer passing a file descriptor over passing a name.
Server-side template injection is the version that surprises people. If user input reaches the template rather than the template's data, a modern template language is a programming language with access to the object graph, and rendering it is arbitrary code execution rather than mere XSS. The rule is absolute. Templates are code, they come from the repository, and user input is only ever data passed to a template. Any feature that lets users supply templates (customizable email bodies, report formats) needs a sandboxed engine with no attribute traversal, or a non-Turing-complete substitution language.
SSRF and the cloud metadata problem
Server-side request forgery is the vulnerability where an application fetches a URL supplied by a user. The attacker's gain is not the fetch itself but the position. The request originates inside the network perimeter, from a host that internal services trust. In a cloud environment this is acute, because instance metadata services live at a fixed link-local address and, in their original design, returned temporary credentials for the instance's role to any process that asked, with no authentication beyond being on the host. A single SSRF therefore converted into cloud credentials, and from there into whatever the instance role could do. The 2019 breach of a large financial institution followed exactly that chain and is the canonical case study.
The mitigations, in order of strength. Do not fetch user-supplied URLs at all where a design
alternative exists. Where a webhook or an image import genuinely requires it, route the request
through a dedicated egress proxy in a network segment with no route to internal ranges and no
instance credentials. Enforce metadata service version 2, which requires a PUT to obtain a
session token and sets a low IP TTL so the response cannot be relayed through a proxy. This
single configuration change closes the classic path. Validate the URL after resolution, not
before, because DNS is attacker-controlled. Resolve, check that every resulting address is
public, and connect to that address, or the check and the connect will resolve differently and
the classic time-of-check to time-of-use rebinding wins. Disable redirects or re-validate at
each hop, because a redirect to 169.254.169.254 defeats a check on the original
URL. Allowlist schemes, since file:, gopher: and dict:
reach further than anyone intends.
import ipaddress, socket
from urllib.parse import urlsplit
# VULNERABLE: validating the hostname string, then letting the HTTP client
# resolve it again. Between the two resolutions the DNS answer can change
# (DNS rebinding), and redirects are followed blindly.
def fetch_bad(url: str, http):
host = urlsplit(url).hostname
if host in ("localhost", "127.0.0.1"):
raise ValueError("blocked") # a denylist, and a stale one
return http.get(url) # resolves again, follows redirects
BLOCKED = [
ipaddress.ip_network(n) for n in (
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16",
"::1/128", "fc00::/7", "fe80::/10",
)
]
def resolve_public(host: str) -> str:
"""Resolve once, reject any non-public answer, return the literal IP."""
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
addrs = {info[4][0] for info in infos}
if not addrs:
raise ValueError("unresolvable")
for a in addrs:
ip = ipaddress.ip_address(a)
if ip.is_private or ip.is_loopback or ip.is_link_local or \
ip.is_reserved or ip.is_multicast or \
any(ip in net for net in BLOCKED):
raise ValueError(f"blocked address {ip}")
return sorted(addrs)[0] # connect to THIS address
# FIXED: scheme allowlist, resolve-then-pin, redirects handled manually so
# every hop is validated, response size and time bounded.
def fetch_ok(url: str, http, max_redirects: int = 3, max_bytes: int = 5_000_000):
for _ in range(max_redirects + 1):
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
raise ValueError("scheme not allowed")
ip = resolve_public(parts.hostname)
# connect to the pinned IP, carry the original Host header for vhosts
# and TLS SNI; this is what closes the rebinding window.
resp = http.request("GET", url, connect_to=ip, allow_redirects=False,
timeout=5, stream=True)
if resp.status_code not in (301, 302, 303, 307, 308):
return resp.raw.read(max_bytes + 1)[:max_bytes]
url = resp.headers["location"] # loop: validate the next hop too
raise ValueError("too many redirects")
# Non-negotiable platform controls that make the above a second layer:
# - instance metadata service v2 only (token-required, hop limit 1)
# - the fetching workload runs in a subnet whose route table has no path
# to internal services, and its role has no permissions worth stealing
Verification. Add tests that submit http://169.254.169.254/, a
hostname that resolves to a private address, a URL that redirects to a private address, and a
file:///etc/passwd URL, and assert all four are rejected. Confirm at the
infrastructure level that the metadata service requires a token, by making an unauthenticated
request from the instance and expecting a 401.
Path traversal, deserialization, clickjacking, CORS, and SRI
Path traversal is injection into a filesystem path, through ../ sequences,
absolute paths, symlinks, and, on case-insensitive or Unicode-normalizing filesystems, encodings
that differ before and after normalization. Filtering ../ is the wrong fix because
the check happens before the operating system normalizes. The right fix is to resolve the
candidate path fully (realpath, or Path.resolve()) and then assert
that the result is a descendant of the intended root, comparing resolved paths. Better still, do
not use user input as a path at all, but as a key into a table of identifiers that map to
storage locations the application chose.
Insecure deserialization is the vulnerability where a format that can reconstruct
arbitrary object graphs is fed untrusted bytes. Python's pickle, Java's native
serialization, PHP's unserialize and Ruby's Marshal all invoke constructors or
magic methods during decoding, so an attacker who controls the byte stream controls which
objects come into existence and which of their methods run. The fix is not to filter the stream
but to change the format. Use a data-only format (JSON, protocol buffers, msgpack without
extension types), decode into a schema-checked structure, and never accept a serialized object
graph across a trust boundary. When a legacy format cannot be removed, sign the payload so only
the application's own serialization is accepted, and understand that this is authentication
rather than safety.
Clickjacking frames the target site invisibly over attacker content so the victim's
click lands on a real control in a real authenticated session. The defense is
Content-Security-Policy: frame-ancestors 'none' (or an explicit list), with the
legacy X-Frame-Options: DENY for older agents. For genuinely consequential actions,
add a confirmation step that requires typed input or re-authentication, which a single stolen
click cannot supply.
CORS misconfiguration converts the same-origin policy from a protection into a
formality. The two dangerous patterns are reflecting the request's Origin header
into Access-Control-Allow-Origin and combining a permissive origin with
Access-Control-Allow-Credentials: true, which tells the browser to send cookies and
let the caller read the response. A third is a sloppy origin check such as a suffix match, which
evil-example.com satisfies against example.com. The correct
implementation compares the Origin against an exact allowlist, echoes only a
matched value, sends Vary: Origin so caches do not mix responses, and never uses
the wildcard together with credentials.
Subresource integrity addresses the fact that including a third-party script grants it
the including origin's full authority. An integrity="sha384-..." attribute makes
the browser verify the fetched bytes against a hash and refuse to execute on mismatch, so a
compromised CDN cannot silently swap the file. It requires crossorigin="anonymous"
to make the response readable for hashing, and it does not help if the script is versionless and
expected to change, which is the real reason it is underused. The alternative is self-hosting
the dependency, which is usually the better answer for anything on a login or payment page.
Authentication, sessions, and delegated authorization
Password storage, derived rather than asserted
A password database will eventually be read by someone who should not have it. The design goal is therefore not to prevent that but to make the stolen file worthless for as long as possible. Three properties are required, and each answers a specific attack.
One-wayness means storing \(H(\text{password})\), not the password, so a reader cannot simply use them. Per-user salt means storing \(H(s_u \| p_u)\) with a random \(s_u\) unique per user, so a single guess cannot be tested against all users at once and precomputed tables are useless. Quantitatively, with \(n\) users and no salt, one hash evaluation tests one candidate against all \(n\) accounts, so the attacker's work to find the password of some user is \(n\) times cheaper, and salting restores the factor of \(n\). Cost means the function must be deliberately slow, and slow in a way that does not become fast on the attacker's hardware.
The third property is where SHA-256 fails, and the failure is not about the algorithm's cryptographic strength. SHA-256 is designed to be fast and to parallelize perfectly, so it maps beautifully onto GPUs and onto custom silicon. Published benchmarks put a single high-end consumer GPU on the order of \(10^{10}\) SHA-256 evaluations per second, and a rack of them is \(10^{12}\). Against that rate, human-chosen passwords are simply enumerable, and the salt only forces the attacker to spend the enumeration per user rather than once.
Password hashing functions attack this economically. Provos and Mazières's bcrypt (1999) is built on a modified Blowfish key schedule with a cost parameter \(c\). The key setup is repeated \(2^c\) times, so the defender's cost per verification and the attacker's cost per guess both scale as \(2^c\), and \(c\) can be raised as hardware improves. Its second property matters more. bcrypt's inner loop repeatedly rewrites a roughly 4 KiB S-box table, so each instance needs 4 KiB of fast random-access memory, which is the scarce resource in a GPU shader core. That is why bcrypt rates on GPUs are orders of magnitude below hash rates for SHA-256.
Percival's scrypt (2009) generalized the argument. It defines a sequential memory-hard function. Computing it requires \(N\) blocks of memory held for the duration, and any attempt to use less memory forces recomputation, so the product of area and time, which is the right cost metric for custom hardware, stays roughly constant. Its core, ROMix, fills a table of \(N\) blocks by iterated hashing and then performs \(N\) pseudorandom, data-dependent reads from that table. Storing only \(N/f\) of the blocks means a random read misses with probability \(1 - 1/f\) and must be recomputed at expected cost \(O(f)\), so time grows by about \(f\) while memory falls by \(f\), and the area-time product is unchanged. The defender picks a memory footprint the attacker cannot cheaply replicate across thousands of parallel cores. Argon2 (Biryukov, Dinu and Khovratovich), the winner of the 2015 Password Hashing Competition, parameterizes memory, time and parallelism independently and comes in three variants, Argon2d with data-dependent addressing (maximum resistance to time-memory tradeoffs, but the memory access pattern depends on the secret, which is a side-channel concern), Argon2i with data-independent addressing (side-channel safe, weaker against tradeoffs), and Argon2id, which does one data-independent pass and then data-dependent passes, and is the recommended default.
A password file is stolen. Assume the attacker has hardware that performs \(2 \times 10^{10}\) salted SHA-256 evaluations per second, and \(2 \times 10^{3}\) bcrypt evaluations per second at cost factor 12. A target user's password is drawn uniformly from a keyspace of \(2^{40}\) candidates (roughly a three-word passphrase from a 10,000-word list, or a random 8-character alphanumeric string). (a) Expected time to find that password under each scheme. (b) The defender wants verification to take about 250 ms on a server core. Show why cost 12 is roughly the right parameter and what raising it to 14 does to both sides. (c) The site has \(10^{7}\) users and no salt. How much cheaper is finding some user's password? (d) An attacker with a 10-million-entry dictionary of leaked passwords targets all \(10^{7}\) accounts. How long does that take under bcrypt cost 12, and what does the answer say about where the remaining defense must come from?
Solution. (a) The expected number of guesses is half the keyspace, \(2^{39} = 5.4976 \times 10^{11}\). Under SHA-256 that is \(5.4976\times 10^{11} / 2\times 10^{10} = 27.5\) seconds. Under bcrypt cost 12 it is \(5.4976\times 10^{11} / 2\times 10^{3} = 2.749 \times 10^{8}\) seconds \(= 8.7\) years. The ratio is \(10^{7}\), which is the entire point. The defender pays a quarter second once per login, and the attacker pays a factor of ten million on every one of \(10^{11}\) guesses.
(b) Cost \(c\) means \(2^{c}\) iterations of the key schedule. If a single core performs roughly \(1.6\times 10^{4}\) of those iterations per millisecond, then at \(c = 12\), \(2^{12} = 4096\) iterations take on the order of \(0.25\) s on the server, which is the target. Raising to \(c = 14\) multiplies both sides by 4. The server spends about 1 s per verification, which is usually unacceptable for a login path under load, and the attacker's 8.7 years becomes 34.8 years. The asymmetry does not improve. Only the absolute cost moves, which is why cost tuning is a capacity-planning decision (how many logins per second must a core sustain) rather than a security-maximization one.
(c) With no salt, one hash evaluation is tested against all \(10^{7}\) stored digests with a hash-table lookup, which is free relative to the hash. Finding some user's password within a dictionary of size \(D\) therefore costs \(D\) hash evaluations instead of \(D\) per user, a saving of \(10^{7}\). Salting is worth the same order of magnitude as moving from SHA-256 to bcrypt, and costs nothing.
(d) With unique salts, each of the \(10^{7}\) dictionary entries must be tried against each of the \(10^{7}\) accounts, \(10^{14}\) bcrypt evaluations, or \(10^{14}/2\times 10^{3} = 5 \times 10^{10}\) seconds, which is about 1,585 years on that one machine. But the attacker will not do that. They will try the top few thousand dictionary entries against all accounts, which is \(10^{3} \times 10^{7} = 10^{10}\) evaluations, about 58 days, and will harvest every account whose owner chose a common password. The conclusion is the operationally important one. Slow hashing protects strong passwords and buys time for average ones, but it cannot protect a password that appears in the first thousand dictionary entries. The remaining defense must come from elsewhere, from blocking known breached passwords at registration, rate limiting and credential-stuffing detection at login, and a second factor that a password guess does not satisfy.
Mitigations and verification. Use Argon2id with parameters tuned on the
deployment hardware (a common starting point is 19 MiB of memory, 2 iterations, parallelism 1,
then raise memory until verification takes the latency the login path can afford), or bcrypt
with cost tuned to the same latency where a mature library matters more than the algorithm.
Store the algorithm and parameters alongside the hash, in the standard encoded string format, so
that parameters can be upgraded transparently. On a successful login with an old encoding,
rehash with the new one. Check candidate passwords against a breached-password corpus at
registration and at change time. Verify by asserting in a test that the stored value starts with
the expected identifier ($argon2id$v=19$m=...), by timing a verification in
continuous integration and failing if it drops below a floor, which catches the day someone
lowers the cost to speed up the test suite, and by confirming that a login with the correct
password against a legacy-format hash triggers a rehash.
Credential stuffing, rate limiting, and MFA
Credential stuffing is not password guessing. The attacker has valid username-password pairs from someone else's breach and is testing password reuse, so the per-account success rate is a reuse rate rather than a guessing rate, empirically in the fractions of a percent, and the attack is profitable because the volume is enormous and the marginal cost per attempt is near zero. If the reuse rate is \(r\) and the attacker tries \(n\) accounts, the expected number of compromised accounts is \(rn\), so blocking it is fundamentally about raising the cost per attempt, not about making any single attempt fail.
That leads to a specific set of controls. Rate limit per account, not only per IP, since stuffing is distributed across residential proxies precisely to defeat IP limits. A global limit on failed authentications per account per hour is the one an attacker cannot dilute. Rate limit per credential pair as well, so that the same wrong password is not retried indefinitely. Use exponential backoff with jitter rather than hard lockout, because hard lockout is a denial-of-service primitive against your own users. Detect at the population level. A sudden rise in the failure rate, or in the number of distinct accounts touched per source, is far more visible than any single request. Feed known-breached credentials into a check at login so that a correct password that appears in a public corpus triggers a forced reset. And return identical responses and timings for "no such user" and "wrong password", or the login endpoint becomes a user-enumeration oracle.
Multi-factor authentication changes the economics because a stolen password is no longer
sufficient. Its forms are not equivalent. SMS codes are phishable and vulnerable to SIM swap.
Time-based one-time codes are not tied to the site, so a real-time phishing proxy relays the
code within its validity window. Push notifications are subject to fatigue attacks, where an
attacker triggers prompts repeatedly until the victim approves one. Number matching mitigates
this partially. WebAuthn and passkeys are structurally different. The authenticator signs a
challenge together with the origin that requested it, and the browser will not release
a credential registered for example.com to a phishing site, so relay attacks fail
not because the user was careful but because the protocol does not permit it. Origin binding is
what makes this the first genuinely phishing-resistant factor to see wide deployment. The
residual attack surface moves to account recovery, which is why "reset via email link" quietly
becomes the weakest factor in most systems and deserves the same scrutiny as the primary path.
Session token lifecycle
After authentication, the session token is the credential, and every property required of a
password applies to it. It must be generated by a cryptographically secure random generator with
at least 128 bits of entropy, so that online guessing is hopeless (see Problem 3). It must be
opaque, carrying no data the client could usefully modify. It must be regenerated at every
privilege change, especially at login, which is what defeats session fixation, where the
attacker plants a known session identifier before the victim authenticates and then uses the
now-authenticated session. It must be revocable server-side, which is the property that
stateless token schemes give up. It must expire on two clocks, an idle timeout and an absolute
lifetime, because an idle timeout alone lets an active attacker hold a session forever. It must
be transported and stored with the flags shown earlier, HttpOnly, Secure, SameSite, and a
__Host- prefix. And logout must invalidate it server-side, not merely clear the
cookie.
OAuth 2.0 and OpenID Connect, and what PKCE actually protects
OAuth 2.0 is a delegated authorization framework. It lets a user grant a third-party application limited access to a resource they control, without giving that application their password. OpenID Connect is a thin identity layer on top of it that adds an ID token, a signed statement about who the user is, plus a standard userinfo endpoint and discovery. The distinction is worth being pedantic about, because using a plain OAuth access token as proof of identity is a well-known error. An access token says "the bearer may act on some resource", not "the bearer is Alice", and a token issued to a different client can be replayed into an application that treats it as identity.
AUTHORIZATION CODE FLOW WITH PKCE (the only flow to use for new work)
client browser authorization server
| | |
| 1. generate code_verifier | |
| (random, >=43 chars) | |
| challenge = S256(verifier)| |
|-- 2. redirect: response_type=code, client_id, redirect_uri, |
| scope, state, code_challenge, code_challenge_method=S256|
| |----------------------------> |
| | 3. user authenticates, |
| | consents to scope |
| |<---------------------------- |
|<----- 4. redirect back with code + state --------------------|
| | |
|-- 5. POST /token: code, code_verifier, client_id ----------> |
| | server recomputes |
| | S256(verifier) and |
| | compares to the stored |
| | challenge |
|<----- 6. access_token, refresh_token, id_token --------------|
Why each piece exists:
state binds the callback to the session that started it: CSRF
defense for the authorization request itself.
code a one-time, short-lived value; the powerful credential
(the token) never travels through the browser or the logs.
code_verifier proves that whoever redeems the code is whoever started
the flow. Without it, an attacker who steals the code, by
registering a competing custom URL scheme on a mobile
device, by reading a Referer header, by a redirect_uri
mismatch, can redeem it themselves.
redirect_uri must be pre-registered and matched exactly, not by prefix;
prefix matching plus an open redirect on the site is the
classic code-leak chain.
PKCE, Proof Key for Code Exchange, was designed for public clients (mobile and single-page
applications) that cannot keep a client secret, but the current guidance is to use it
everywhere, because it protects confidential clients against authorization code injection as
well. The mechanism is a commitment. The client commits to a random verifier by sending its
SHA-256 hash at the start, and reveals the verifier only at redemption, so possession of the
code alone is useless. Note what PKCE does not do. It does not authenticate the client, it does
not protect the redirect from an open redirector, and it does not help if the authorization
server accepts code_challenge_method=plain, which an attacker who can see the
request can trivially satisfy. Require S256.
The implicit flow, which returned tokens directly in the URL fragment, is deprecated for exactly these reasons. Tokens end up in browser history, in Referer headers, and in server logs, and there is no redemption step at which anything can be proven. The resource owner password credentials grant, where the application collects the user's password directly, is deprecated because it defeats the entire purpose of delegation. On the validation side, a resource server must check the token's issuer, audience, expiry and scope, in that order of frequency-of-omission. The audience check is the one most often skipped, and skipping it means a token issued for any other service in the ecosystem is accepted.
The specific ways JWT is misused
A JSON Web Token is a signed (or encrypted) statement, a header naming the algorithm, a payload of claims, and a signature, each base64url-encoded and joined by dots. It is a good format for short-lived, stateless assertions between services, and a poor choice for browser sessions. The failures are well catalogued in RFC 8725, and every one of them is worth recognizing on sight.
Algorithm confusion. The token itself names the algorithm in its header, and a verifier
that trusts that field lets the attacker choose it. Setting alg: none against a
library that honors it removes the signature entirely. More subtly, switching an RS256 token to
HS256 makes the verifier treat the public key as an HMAC secret, and the public key is
public. The fix is to pin the algorithm and the key in the verifier and to ignore the header's
claim entirely. Missing or partial validation. Decoding is not verifying. Libraries
offer both, and the decode function is shorter to type. Beyond the signature, exp,
nbf, iss and aud must all be checked, and
kid must be looked up in a fixed key set rather than used as a path or a database
key, since kid has been a path-traversal and SQL-injection sink in real
implementations. Confidentiality confusion. A signed JWT is readable by anyone. The
payload is encoding, not encryption. Putting personal data in a token that lives in browser
storage is a disclosure.
The structural argument against JWT for sessions is revocation. Statelessness is the selling
point, and revocation is precisely the thing statelessness forbids. There is no server-side
record to delete, so a stolen token remains valid until it expires. The usual repair, a
server-side denylist checked on every request, reintroduces exactly the state the design was
avoiding, at which point an opaque session identifier in a __Host--prefixed,
HttpOnly, Secure, SameSite cookie is simpler, revocable, invisible to script, and not vulnerable
to any of the failures above. Reserve JWTs for what they are good at, short-lived
service-to-service assertions with a well-defined audience, where a two-minute expiry makes
revocation moot.
import jwt # PyJWT
# VULNERABLE: the algorithm comes from the attacker-controlled header, no
# audience or issuer check, and `verify_signature` quietly disabled to
# "make the tests pass".
def read_bad(token: str, key: str) -> dict:
return jwt.decode(token, key, options={"verify_signature": False})
# ALSO VULNERABLE: passing a list that contains both an asymmetric and a
# symmetric algorithm enables the RS256 -> HS256 confusion, because the
# public key is then usable as an HMAC secret.
def read_bad2(token: str, pubkey: str) -> dict:
return jwt.decode(token, pubkey, algorithms=["RS256", "HS256"])
# FIXED: exactly one algorithm, a key selected by the verifier rather than
# by the token, every registered claim checked, and a clock-skew bound.
EXPECTED_ISS = "https://issuer.example.com/"
EXPECTED_AUD = "https://api.example.com/orders"
def read_ok(token: str, pubkey: str) -> dict:
return jwt.decode(
token,
pubkey,
algorithms=["RS256"], # pinned; header 'alg' is not consulted
issuer=EXPECTED_ISS,
audience=EXPECTED_AUD, # the check most often omitted
leeway=30, # seconds of tolerated clock skew
options={
"require": ["exp", "iat", "iss", "aud", "sub"],
"verify_signature": True,
"verify_exp": True,
"verify_aud": True,
"verify_iss": True,
},
)
# For browser sessions, prefer this shape instead of a token at all:
# session_id = secrets.token_urlsafe(32) # 256 bits of entropy
# store[session_id] = {"sub": user_id, "created": now, "csrf": ...}
# set-cookie: __Host-session=...; HttpOnly; Secure; SameSite=Lax; Path=/
# Revocation is then a DELETE, which is the property JWT cannot offer.
import { jwtVerify, createRemoteJWKSet } from "jose";
// The JWKS is fetched from the issuer's well-known endpoint and cached.
// Key selection happens inside the library against this fixed set, so a
// hostile "kid" cannot point the verifier at a key of its choosing.
const JWKS = createRemoteJWKSet(
new URL("https://issuer.example.com/.well-known/jwks.json"),
);
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["RS256"], // pinned
issuer: "https://issuer.example.com/",
audience: "https://api.example.com/orders",
clockTolerance: 30,
maxTokenAge: "10m", // bound replay window
});
// Claim checks the library does not do for you:
if (typeof payload.sub !== "string") throw new Error("missing sub");
const scopes = String(payload.scope ?? "").split(" ");
if (!scopes.includes("orders:read")) throw new Error("insufficient scope");
return payload;
}
// Anti-patterns to grep for in review:
// jwt.decode(...) // decodes without verifying
// algorithms: ["none"] // or an empty/absent algorithms list
// secret derived from a public key, or a shared symmetric key across
// services (any holder can then mint tokens for any other service)
// storing the token in localStorage: readable by any injected script,
// whereas an HttpOnly cookie is not.
A service has \(10^{7}\) simultaneously valid sessions. (a) If session identifiers are drawn
uniformly from a 32-bit space, what is the probability that a single random guess names a live
session, and how many guesses are expected before the first hit? (b) Repeat for a 128-bit
identifier, and express the result as an expected time at \(10^{6}\) guesses per second. (c) A
colleague proposes deriving the identifier as SHA-256(user_id ||
timestamp_seconds) to avoid storing state. Quantify how many candidates an attacker
must actually search, assuming the user identifiers are sequential up to \(10^{7}\) and the
attacker knows the signup time to within a day. (d) State the design rule the three answers
imply.
Solution. (a) The space has \(2^{32} = 4.295 \times 10^{9}\) values, of which \(10^{7}\) are live, so the per-guess hit probability is \(p = 10^{7}/4.295\times 10^{9} = 2.33 \times 10^{-3}\). The expected number of guesses until the first hit is \(1/p = 429.5\). Four hundred requests is not an attack, it is a warm-up. A 32-bit session identifier on a large site is effectively no identifier at all.
(b) With 128 bits, \(p = 10^{7}/3.403\times 10^{38} = 2.94\times 10^{-32}\), so \(1/p = 3.4 \times 10^{31}\) guesses. At \(10^{6}\) guesses per second that is \(3.4 \times 10^{25}\) seconds, about \(10^{18}\) years. The margin is so extreme that rate limiting on this endpoint is about log noise and load, not about the guessing risk.
(c) The output is 256 bits wide, which is irrelevant. The attacker searches the input space, not the output space. The inputs are \(10^{7}\) user identifiers times \(86{,}400\) seconds in the known day, so \(8.64 \times 10^{11}\) candidates, and if the target user is known the search collapses to \(86{,}400\). At \(10^{6}\) hashes per second the full sweep takes about 10 days and the targeted one takes under a tenth of a second. The entropy of a derived identifier is the entropy of its inputs, and a hash cannot create entropy that was not there.
(d) Session identifiers must come from a cryptographically secure random generator with at least 128 bits of entropy, and must never be derived from predictable data, however wide the hash. Everything else (rotation on privilege change, server-side revocation, expiry) is layered on top of that property, not a substitute for it.
Access control
Four models and what each is for
Discretionary access control lets the owner of an object set its permissions. Unix file modes and object-storage ACLs are DAC. It is flexible and it composes badly. Permissions drift, and because a program runs with the full authority of the invoking user, any program the user runs can change anything the user could, which is why DAC alone cannot contain malicious code. Mandatory access control puts the policy in the hands of a system-wide authority that the object owner cannot override. Multi-level security models (Bell-LaPadula for confidentiality, no read up and no write down, and Biba for integrity, its dual) are the classic formulations, and SELinux and AppArmor are the deployed general-purpose forms. MAC is what makes "even root cannot do this" expressible.
Role-based access control interposes roles between users and permissions. Users are assigned roles, roles are granted permissions, and the assignment relation is what administrators manage. It scales organizationally, and its failure mode is role explosion, where every exception spawns a role and the model loses the property that made it reviewable. Attribute-based access control evaluates a policy over attributes of the subject, the object, the action and the environment, for example "a clinician may read a record if they are on the care team and the request is from a managed device during a shift". It expresses fine-grained and context-dependent rules that RBAC cannot, at the cost of a policy language that is much harder to reason about. The practical question for any ABAC deployment is whether anyone can still answer "who can read this object?" without running the engine.
The confused deputy
Norm Hardy's 1988 note described a compiler that ran with the privilege to write to a billing file and accepted an output filename from its caller. A user with no right to the billing file passed its name as the output path, and the compiler, holding authority the caller lacked, dutifully overwrote it. The compiler was not malicious. It was confused about whose authority it was exercising. This is the general shape of a very large fraction of security failures, and recognizing it is more useful than memorizing any list of vulnerabilities.
The same shape appears in CSRF, where the browser is the deputy attaching the user's cookie to a request the user did not intend, in SSRF, where the server is the deputy making a request from inside the perimeter, in a build system that runs an untrusted pull request with credentials belonging to the repository, and, most currently, in an LLM agent that holds a user's API tokens and reads a web page containing instructions. In every case the flaw is that authority is ambient, attached to the identity of the actor, rather than being carried by the request itself.
Capability systems are the structural answer. A capability is an unforgeable reference that
is the permission. Holding it authorizes the action, and there is no separate lookup of
who the holder is. A Unix file descriptor is a capability, which is why passing a descriptor
over a socket is safer than passing a filename, and why openat with a directory
descriptor is safer than a path. The design rule that follows is to pass the authority you intend
to delegate rather than a name the deputy will resolve under its own authority. Where that is
not possible, the deputy must explicitly evaluate the caller's rights, not its own,
which is the design of setuid-style checks and of a correctly written API gateway.
Broken object-level authorization
The single most common serious flaw in modern APIs, at the top of the OWASP API list, is simple.
The endpoint authenticates the caller, and then acts on the object identifier in the request
without checking that this caller may act on that object. GET /api/orders/1042
returns order 1042 to anyone with a valid session. It survives because it is invisible to the
tests (every test uses the correct user), invisible to the type system, and invisible to a code
reviewer who is reading the handler for what it does rather than for what it fails to do.
The mitigations are structural rather than vigilant. Make the authorization check impossible to omit by scoping every query to the caller. The data-access layer takes the subject as a required argument and no query can be constructed without it, or the database enforces it with row-level security so a forgotten application check still returns nothing. Use unpredictable identifiers (UUIDv4 or a random string) so that enumeration is not free, understanding clearly that this is defense in depth and not an access control, because identifiers leak. Centralize the decision in one function that takes (subject, action, object) and returns a decision, so there is exactly one place to audit. And test it. For every resource-returning endpoint, add a test where user B requests user A's object and expects 404, generated mechanically from the route table rather than written by hand, since the whole failure mode is the case nobody thought to write.
// VULNERABLE: authenticated, but not authorized. The session proves who
// the caller is and is then never used again.
app.get("/api/orders/:id", requireSession, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.sendStatus(404);
res.json(order); // any session can read any order
});
// FIXED (1): make the subject part of the query. There is no way to
// express "find by id" without also expressing "belonging to whom".
app.get("/api/orders/:id", requireSession, async (req, res) => {
const order = await db.orders.findOwnedBy(req.session.userId, req.params.id);
if (!order) return res.sendStatus(404); // 404, not 403: do not confirm
res.json(order); // that the id exists at all
});
// FIXED (2): a single decision point, so there is one function to audit
// and one place to add logging.
type Decision = { allow: boolean; reason: string };
async function can(
subject: Subject, action: Action, object: Resource,
): Promise<Decision> {
if (object.tenantId !== subject.tenantId)
return { allow: false, reason: "cross-tenant" };
if (action === "read" && object.ownerId === subject.userId)
return { allow: true, reason: "owner" };
if (subject.roles.includes("support") && action === "read")
return { allow: true, reason: "support-role" };
return { allow: false, reason: "no-grant" };
}
// FIXED (3): generate the negative tests from the route table, so the
// coverage does not depend on anyone remembering.
for (const route of resourceRoutes) {
test(`${route.method} ${route.path} denies cross-user access`, async () => {
const res = await asUser(userB).request(route.method, route.pathFor(objectOfUserA));
expect(res.status).toBe(404);
});
}
# The same idea enforced one layer lower, where it cannot be forgotten:
# the database refuses to return rows outside the caller's tenant, so a
# missing application check is a bug rather than a breach.
# 1. Every request sets the tenant on its connection, inside the same
# transaction as the queries, and it is reset afterwards.
async def with_tenant(conn, tenant_id: str):
await conn.execute("SELECT set_config('app.tenant_id', $1, true)", tenant_id)
# 'true' scopes the setting to the transaction, so it cannot leak to
# the next request that borrows this pooled connection.
# 2. The policy lives once, in the schema (see the SQL block above):
# CREATE POLICY tenant_isolation ON app.orders
# USING (tenant_id = current_setting('app.tenant_id')::uuid);
# 3. The data-access layer makes the subject a required argument, so an
# unscoped query is a type error rather than a review finding.
class OrderRepo:
def __init__(self, conn, subject: Subject):
self._conn, self._subject = conn, subject
async def get(self, order_id: str):
row = await self._conn.fetchrow(
"SELECT * FROM app.orders WHERE id = $1 AND owner_id = $2",
order_id, self._subject.user_id,
)
return row # None for both "absent" and "not yours"
# Verification: connect as the application role with no tenant configured
# and run `SELECT count(*) FROM app.orders`. The correct answer is 0.
# Run it again with a tenant set and confirm the count is that tenant's.
Isolation, and what each boundary is actually worth
Every isolation mechanism is a bet that a particular interface is small enough and well-enough implemented to be trustworthy. Ranking them by the size of that interface is the most useful way to hold them in mind, because the size of the interface is the size of the attack surface.
boundary what enforces it interface an attacker attacks
-----------------------------------------------------------------------------
language/type compiler, runtime unsafe blocks, FFI, JIT bugs
(weakest: same address space)
process MMU page tables, kernel ~350 syscalls, /proc, shared fds
seccomp-filtered kernel + BPF filter the syscalls you allowed (tens)
container namespaces + cgroups + the whole kernel, minus what
seccomp + capabilities seccomp and caps removed
gVisor-style userspace kernel + seccomp a small host syscall set
microVM / VM hypervisor + EPT/NPT virtual devices, hypercalls
(tens of interfaces)
physical separate hardware the network only
-----------------------------------------------------------------------------
Orthogonal to all of the above: shared microarchitecture (caches, branch
predictors, DRAM rows) is a channel that no software boundary closes.
Processes are the classical boundary and remain a good one. Separate page tables mean a bug in one process cannot read another's memory directly. The interface is the system call table plus everything reachable through it, which is large. A kernel bug reachable from an unprivileged process defeats the boundary entirely. The standard hardening is privilege separation in the application itself. Split the program so the part that parses untrusted input runs in a process with almost no privilege and talks to the privileged part over a narrow, well-typed channel. OpenSSH's privilege-separated design is the canonical example, and browser renderer processes are the same pattern at scale.
seccomp shrinks the kernel interface directly. In SECCOMP_MODE_FILTER a
BPF program inspects the syscall number and its register arguments and returns allow, error,
trap, or kill. A parser that needs only read, write,
exit_group and sigreturn can be locked to those four, so a memory
corruption that achieves code execution has almost no kernel to attack. The filter is installed
after setup and is irrevocable, which is what makes it trustworthy. Its limits are worth
knowing. Filters see register values, not the memory they point to, so argument checks are
limited to scalars, and a filter written against syscall numbers must handle the multiplexed and
architecture-specific variants or it is trivially bypassed.
Containers are not a security boundary of the same class, and treating them as one causes most container incidents. A container is a process with namespaced views (PID, mount, network, user, UTS, IPC), cgroup resource limits, a reduced capability set, and usually a seccomp profile. The kernel is shared and its full syscall surface remains reachable, so a kernel privilege-escalation bug is a container escape. The hardening list is concrete. Run as a non-root user, drop all capabilities and add back only what is needed, use a read-only root filesystem, keep the default seccomp profile rather than disabling it, enable user namespaces so container root maps to an unprivileged host UID, never mount the container runtime's socket into a container, and do not run privileged containers. Where the workload is genuinely untrusted, use a stronger boundary, a userspace kernel that intercepts syscalls and implements them itself, or a microVM.
Virtual machines interpose a hypervisor with hardware support for nested paging, so the guest kernel is no longer in the host's TCB. The remaining interface is the set of virtual devices and hypercalls, which is much smaller than a syscall table but not empty. Historically most VM escapes have come from emulated device models rather than from the CPU virtualization itself, which is why microVMs deliberately implement only a handful of virtio devices and drop everything else.
WebAssembly is a language-level sandbox with unusually good structural properties. Linear memory is a single contiguous region addressed from zero, and every access is bounds-checked against its size (in practice, on 64-bit hosts, by reserving guard regions so the check is free). The call stack is not in linear memory, so no store can overwrite a return address, and indirect calls go through a table and are type-checked at the call site. The result is that a memory-corruption bug inside a Wasm module corrupts only that module's own linear memory, which is a genuinely different property from native code, though it is worth being clear that it also means classic C bugs inside the module still corrupt the module's own data. The host interface is capability-shaped. A module can do nothing except call imports the embedder provided, which is why WASI hands out preopened directory handles rather than a filesystem namespace.
Browser site isolation was the response to Spectre. Before it, a renderer process could host documents from several sites, so a same-address-space information leak crossed the security boundary. Site isolation puts each site in its own process, which turns a memory-disclosure primitive back into a process-boundary-crossing problem, and it is complemented by Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy, which let a document assert that it is not sharing a browsing context group or embedding non-consenting resources, and thereby earn access to high-resolution timers and shared memory.
Side channels
Every mechanism so far has assumed that a computation reveals only its output. Side channels are the failure of that assumption. The time a computation takes, the power it draws, the cache lines it touches, the branches it predicts, and the electromagnetic radiation it emits all depend on the data it processed. Kocher's 1996 paper on timing attacks against implementations of Diffie-Hellman and RSA opened the field by showing that a key-dependent execution time is a key-dependent oracle, and the field has produced a steady stream of results since, culminating in 2018 with attacks that read memory across every software boundary at once.
Timing attacks and the derivation of the sample count
The model is straightforward. Suppose a secret-dependent branch makes the operation take \(T_0\) when a guessed byte is wrong and \(T_1 = T_0 + \Delta\) when it is right, and that each measurement is corrupted by independent noise of standard deviation \(\sigma\). One measurement distinguishes the cases only if \(\Delta \gg \sigma\), which it usually is not. Averaging \(n\) measurements reduces the standard error of the mean to \(\sigma/\sqrt{n}\), so the two hypotheses are separated by \(z\) standard errors when
$$ \frac{\Delta}{\sigma/\sqrt{n}} \ge z \quad\Longleftrightarrow\quad n \ge \left(\frac{z\,\sigma}{\Delta}\right)^{2}. $$The quadratic dependence is the whole story. The attacker's cost grows only as the square of the noise-to-signal ratio, and measurements are cheap and parallelizable. Halving the signal \(\Delta\) costs the attacker a factor of four, which is nothing. This is why "the timing difference is only a few nanoseconds and the network jitter is milliseconds" is not a defense, and why the correct engineering response is to make \(\Delta\) exactly zero rather than small.
To make the signal concrete, a measurement on this machine. Two 32-byte tag comparisons were benchmarked, one written the obvious way with an early return on the first mismatch, one written constant-time by accumulating differences with XOR and OR and branching only at the end. Each configuration was timed over 20 million iterations after a warm-up, varying how many leading bytes of the guess were correct.
| Matching prefix (bytes) | Early-exit compare (ns) | Constant-time compare (ns) |
|---|---|---|
| 0 | 2.128 | 22.210 |
| 8 | 5.416 | 22.201 |
| 16 | 7.158 | 22.203 |
| 24 | 9.934 | 22.204 |
| 32 (full match) | 12.085 | 22.213 |
The early-exit version leaks a clean, monotone signal, \((12.085 - 2.128)/32 = 0.311\) nanoseconds per additional correct byte. That is the \(\Delta\) an attacker gets to work with, and it reduces recovering a 32-byte authentication tag from a \(2^{256}\) search to 32 independent 256-way searches, because each byte can be solved before moving to the next. The constant-time version is slower in absolute terms, 22.2 ns regardless, and flat to within 0.013 ns across the entire range, which is the property that matters. Being slower is not a defect. Being data-independently slower is the entire specification.
Using the measured \(\Delta = 0.311\) ns per correct byte from the table above, and requiring \(z = 3\) standard errors of separation. (a) How many timing samples per byte position are needed when the attacker is a co-located process whose measurement noise is \(\sigma = 20\) ns? (b) When the attacker is across a wide-area network with \(\sigma = 100\) microseconds? (c) Total measurements to recover all 32 bytes in each case, and the wall-clock time at 10,000 measurements per second. (d) The team proposes "adding a random delay of up to 1 ms to every response" as the fix. Model the added delay as uniform on \([0, 1\text{ ms}]\), compute its standard deviation, and determine the new sample count for the local attacker. Is this an adequate defense?
Solution. (a) \(n \ge (z\sigma/\Delta)^2 = (3 \times 20 / 0.311)^2 = (192.9)^2 = 3.72 \times 10^{4}\) samples per byte position, so about 37,000 measurements to resolve one byte.
(b) \(\sigma = 10^{5}\) ns gives \(n \ge (3 \times 10^{5}/0.311)^2 = (9.646\times10^{5})^2 = 9.30 \times 10^{11}\) samples per byte. That is nine hundred billion requests for one byte, not feasible against a service that notices load. Note carefully what this does and does not say. It says a naive single-hop remote attack on this particular 0.3 ns signal is impractical. It does not say remote timing attacks are impractical in general, because signals in real code are often microseconds rather than nanoseconds, and because an attacker on the same LAN, the same host, or the same CPU has a far smaller \(\sigma\).
(c) The local attacker needs \(32 \times 3.72\times10^{4} = 1.19 \times 10^{6}\) measurements, which at 10,000 per second is 119 seconds, two minutes. The remote attacker needs \(32 \times 9.30\times10^{11} = 2.98\times 10^{13}\) measurements, \(2.98 \times 10^{9}\) seconds, about 94 years. The gap between the two is nine orders of magnitude and comes entirely from \(\sigma\), which is why "who can measure?" is the first question in any timing threat model.
(d) A uniform distribution on \([0, T]\) has standard deviation \(T/\sqrt{12}\). With \(T = 10^{6}\) ns that is \(2.887 \times 10^{5}\) ns, so the total noise becomes \(\sigma' = \sqrt{20^2 + (2.887\times10^{5})^2} \approx 2.887 \times 10^{5}\) ns, and \(n \ge (3 \times 2.887\times10^{5}/0.311)^2 = 7.76 \times 10^{12}\) samples per byte, \(2.48\times10^{14}\) in total. That sounds like a fix, and it is not, for three reasons. First, the cost to the attacker grew by \(2\times10^{8}\) while the cost to every legitimate user grew by 500 microseconds of added median latency, which is a bad exchange rate. Second, the noise is zero-mean and independent, so it averages away exactly as the formula says. A defense that can be integrated out is a delay, not a barrier. Third, if the random delay is generated by a non-cryptographic generator, or is applied per-request rather than per-measurement, an attacker who can model it removes it. The correct fix is to eliminate \(\Delta\). A constant-time comparison costs 10 extra nanoseconds and makes \(n\) infinite, since no amount of averaging separates two identical means.
The constant-time programming rule set
"Constant time" does not mean the function always takes the same number of nanoseconds. It means the execution time and the microarchitectural footprint are independent of secret data. The operational rules are short and absolute, and they are enforced by discipline plus tooling, since compilers are permitted to undo them.
| Rule | Why | What to write instead |
|---|---|---|
| No branch on a secret | Branch direction is visible through timing and through the branch predictor, even when both paths are equally long. | Compute both sides and select with a mask, r = (m & a) | (~m & b) where m is 0 or all-ones derived arithmetically. |
| No memory index derived from a secret | The cache line touched is visible to any process sharing the cache. This is what broke naive AES table implementations. | Touch every element and select, or use bitsliced or hardware instructions (AES-NI) that have no tables. |
| No early return, no variable-length loop on a secret | Trip count is the most direct possible leak. The measured table above is exactly this. | Fixed iteration count over the maximum, accumulating into a mask. |
| No secret-dependent division or variable-latency instruction | Integer division and some floating-point operations have data-dependent latency on many cores. | Use multiplication and shifts, or a constant-time modular reduction. |
| Compare with an accumulator, not a loop that exits | See the measurement above. | d |= a[i] ^ b[i] over the full length, then test d once. |
| Do not let the compiler help | An optimizer may reintroduce a branch when it recognizes a select, or vectorize a masked loop into a branch. | Use the platform's verified primitives (CRYPTO_memcmp, subtle::ConstantTimeEq, hmac.compare_digest), and check the generated assembly for the hot ones. |
/* VULNERABLE: early exit leaks the length of the matching prefix.
Measured on this machine: 2.128 ns at 0 matching bytes rising to
12.085 ns at 32, a 0.311 ns/byte oracle. */
int tag_eq_bad(const unsigned char *a, const unsigned char *b, size_t n) {
for (size_t i = 0; i < n; i++)
if (a[i] != b[i]) return 0;
return 1;
}
/* FIXED: every byte is always examined, the result is folded into one
accumulator, and the single branch depends only on the final value.
Measured: 22.20 ns flat across every prefix length, spread 0.013 ns. */
int tag_eq_ok(const unsigned char *a, const unsigned char *b, size_t n) {
unsigned char d = 0;
for (size_t i = 0; i < n; i++)
d |= (unsigned char)(a[i] ^ b[i]);
/* map 0 -> 1 and anything else -> 0 without a branch:
(d - 1) has its high bit set only when d == 0 */
return (int)(1 & ((d - 1) >> 8));
}
/* Branch-free select, the workhorse of constant-time code. mask must be
0 or ~0, produced arithmetically rather than by a comparison. */
static inline uint32_t ct_select(uint32_t mask, uint32_t a, uint32_t b) {
return (mask & a) | (~mask & b);
}
static inline uint32_t ct_mask_eq(uint32_t x, uint32_t y) {
uint32_t d = x ^ y; /* 0 iff equal */
return (uint32_t)(((int32_t)(d | -(int32_t)d)) >> 31) ^ 0xFFFFFFFFu;
}
/* In production, do not hand-roll: use the library primitive, which is
maintained against compiler changes. OpenSSL: CRYPTO_memcmp.
And verify: run the binary under a tool such as ctgrind/valgrind with
secrets marked undefined, or dudect-style statistical testing, which
detects a timing dependence without needing to know where it is. */
// The `subtle` crate exists because Rust's optimizer is as willing as
// C's to turn a masked select back into a branch. Its types carry the
// constant-time requirement in the type system and use optimization
// barriers internally.
use subtle::{Choice, ConstantTimeEq, ConditionallySelectable};
// VULNERABLE: `==` on slices short-circuits, exactly like the C loop.
fn tag_eq_bad(a: &[u8], b: &[u8]) -> bool {
a == b
}
// FIXED: ct_eq examines every byte and returns a Choice (0 or 1 in a
// wrapper that resists branch reintroduction).
fn tag_eq_ok(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false; // length is public; comparing it is fine
}
a.ct_eq(b).into()
}
// Branch-free conditional assignment over secret data:
fn ct_select_u32(cond: Choice, a: u32, b: u32) -> u32 {
u32::conditional_select(&b, &a, cond)
}
// Hygiene for secret material: zeroize on drop so the value does not
// linger in freed memory or in a core dump, and avoid Vec growth, which
// copies the old buffer and leaves a stale plaintext copy behind.
use zeroize::Zeroizing;
fn derive(secret: &[u8]) -> Zeroizing<[u8; 32]> {
let mut out = Zeroizing::new([0u8; 32]);
// ... fill out ...
out
}
import hmac, secrets
# VULNERABLE: Python's == on bytes short-circuits at the first differing
# byte, and the interpreter's overhead does not hide it: the signal is
# smaller relative to the noise, and the attack formula only needs more
# samples, which are cheap.
def check_bad(expected: bytes, provided: bytes) -> bool:
return expected == provided
# FIXED: compare_digest is the standard-library constant-time comparison.
def check_ok(expected: bytes, provided: bytes) -> bool:
return hmac.compare_digest(expected, provided)
# The same rule applies far beyond cryptography. These are all timing
# oracles in ordinary application code:
# - "user not found" returns in 2 ms, "wrong password" runs bcrypt for
# 250 ms: a user-enumeration oracle. Fix: always run the hash, against
# a dummy hash when the user is absent, then compare results.
# - an authorization check that queries the database only for objects
# that exist: an existence oracle. Fix: identical work either way.
# - a coupon/API-key lookup that returns early on a prefix mismatch.
DUMMY_HASH = "$argon2id$v=19$m=19456,t=2,p=1$" + "A" * 22 + "$" + "B" * 43
def login(username: str, password: str, store) -> bool:
record = store.get(username)
stored = record.hash if record else DUMMY_HASH # same work either way
ok = verify_password(stored, password)
return bool(record) and ok
# And for generating tokens, always use `secrets`, never `random`:
token = secrets.token_urlsafe(32) # 256 bits from the OS CSPRNG
Cache side channels
Caches are shared, and a shared resource whose state depends on secret data is a channel. The two canonical techniques are worth understanding structurally because they are the measurement apparatus that speculative-execution attacks depend on.
Prime+Probe, formalized in the mid-2000s work of Osvik, Shamir and Tromer, requires no shared memory. The attacker fills a chosen cache set with its own lines (prime), lets the victim run, then re-reads its own lines and times them (probe). Lines that are now slow were evicted, which means the victim touched an address mapping to that set. The output is a set-granularity map of the victim's memory accesses, which is enough to recover keys from table-driven ciphers and, more generally, to distinguish control-flow paths.
Flush+Reload, described by Yarom and Falkner in 2014, requires shared read-only memory, which page deduplication and shared libraries routinely provide. The attacker flushes a specific line of the shared page from the cache, waits, then reloads it and times the access. Fast means the victim brought it back, slow means it did not. The resolution is a single cache line rather than a set, and the noise is far lower, which is why it became the standard covert-channel readout.
Mitigations. For cryptographic code, remove secret-dependent memory access
entirely. Use hardware instructions or bitsliced implementations rather than lookup tables. For
platforms, do not co-schedule mutually distrusting workloads on sibling hyperthreads (cloud
providers disable simultaneous multithreading for this reason), consider cache partitioning
where the hardware supports it, and disable memory deduplication across tenants, which is what
makes Flush+Reload possible in the first place. For browsers, reduce timer resolution and remove
the primitives that let script build a high-resolution clock, which is why
SharedArrayBuffer became conditional on cross-origin isolation. Verification is
mostly negative. Assert that deduplication is off, that SMT is off for the sensitive pool, and
that cryptographic libraries are compiled with their constant-time backends selected.
Speculative execution, Spectre and Meltdown, structurally
Modern processors execute instructions out of order and speculatively. When the outcome of a branch or the result of a load is not yet known, the core predicts and proceeds, and if the prediction was wrong it discards the architectural effects. The 2018 results, from teams spanning Graz, Michigan, Adelaide, industry research groups and Google's Project Zero, established that "discards the architectural effects" is not the same as "leaves no trace". The microarchitectural state, above all the data cache, retains the footprint of work that was rolled back. That residue is measurable with Flush+Reload. Speculation therefore became a way to perform an access that the architecture forbids, and then read the result out through a channel the architecture does not model.
SPECTRE VARIANT 1, bounds check bypass, structurally
if (i < array1_size) // 1. the predictor has been trained by many
y = array2[array1[i]*L]; // in-bounds calls to predict "taken"
What happens with an out-of-bounds i:
- array1_size is not in cache, so resolving the condition takes ~100 ns
- the predictor says "taken", so the core speculatively executes the body
- array1[i] reads OUT OF BOUNDS -- speculatively, so no fault is raised
- array2[secret * L] is fetched into the CACHE, and that is a real,
persistent side effect
- the branch resolves as mispredicted; registers are rolled back; the
cache line is NOT rolled back
- the attacker times array2[k*L] for each k and finds the fast one: k is
the secret byte
The security-relevant conclusion is not "there is a bug in this code".
It is that the bounds check, which is correct as written, does not hold
during the speculation window, so *every* bounds check in every sandbox
became advisory rather than mandatory.
MELTDOWN, structurally
A user-mode load from a kernel address raises a fault, but on affected
cores the fault was delivered at retirement while the loaded value was
already forwarded to dependent instructions. A dependent access then
brought a secret-dependent line into the cache before the fault took
effect. The permission check and the data forwarding were not ordered
with respect to each other. This is a narrower, more implementation-
specific flaw than Spectre, which is why it was fixable in silicon and
in software (KPTI: unmap the kernel from user page tables, so the
address is not translatable at all), while Spectre-class issues are
properties of speculation itself.
Mitigations and their cost. Meltdown is addressed by kernel page-table
isolation, which unmaps almost all kernel memory while running in user mode. The cost is an
address-space switch on every system call and interrupt, measured at the time between roughly 5
and 30 percent for syscall-heavy workloads, with PCID support recovering much of that on newer
cores. Spectre variant 1 is addressed per-site by placing a speculation barrier
(lfence) or, better, by masking the index so that even speculative execution stays
in bounds, since a mask has no predicted outcome to get wrong. Compilers offer
__builtin_speculation_safe_value and the kernel uses
array_index_nospec. Variant 2, branch target injection, is addressed by hardware
controls (IBRS, STIBP, IBPB) or by retpolines, which replace indirect branches with a
return-based construct the predictor cannot steer, though later work at ETH Zurich showed that
return instructions themselves can be mispredicted on some cores, requiring further mitigation.
The microarchitectural data-sampling family, from Graz and others, is addressed by
buffer-clearing instructions on transitions plus disabling simultaneous multithreading across
trust boundaries.
The lasting engineering consequence is architectural rather than tactical. Before 2018, "different process" and "different JavaScript origin in the same process" were both considered security boundaries. After, only address-space separation was considered reliable, which is why browsers shipped site isolation, why cloud providers stopped sharing physical cores between tenants, and why any design that relies on in-process sandboxing of untrusted code now needs an explicit argument about speculation. For application engineers the practical guidance is narrow. Keep microcode and kernels current, do not disable mitigations for benchmark numbers on multi-tenant hosts, do not put secrets in the same address space as attacker-controlled code, and use the compiler's speculation-safe helpers in any code that indexes an array with a value that crosses a trust boundary.
Network and infrastructure security
What TLS provides, and what the certificate model actually assumes
TLS provides confidentiality and integrity for a channel and authenticates the server (and optionally the client) to that channel. The handshake negotiates parameters, performs an authenticated key exchange, and derives traffic keys. TLS 1.3 removed the negotiation flexibility that produced a decade of downgrade attacks, cut the handshake to one round trip, and made forward secrecy mandatory by requiring ephemeral key exchange. The primitives are derived under cryptography. What belongs here is the trust model, because that is where the systems-level weaknesses live.
The model is that a client trusts a set of root certificate authorities, and any of them may vouch for any name. That is an any-trust model, and its security is that of the weakest of several hundred organizations. The consequences have been concrete. A Dutch CA was compromised in 2011 and issued certificates for major domains that were used in a mass interception campaign, and it went out of business. Another CA mis-issued certificates the same year, and a long-running pattern of mis-issuance at a large CA led browsers to distrust its certificates over 2017 and 2018. Pinning a specific key in the client was tried and largely abandoned for the web, because a pinning mistake bricks a domain for the pin's lifetime with no recovery path.
Certificate Transparency is the response, and it is a good example of a security design that replaces prevention with detection when prevention is structurally unavailable. Every certificate is submitted to public append-only logs implemented as Merkle trees. The log returns a signed timestamp, and browsers require one for a certificate to be trusted. The logs support two proofs, an inclusion proof that a given certificate is in the log, and a consistency proof that a newer log state is an extension of an older one rather than a rewrite. A domain owner can therefore monitor the logs and discover a mis-issued certificate for their name within hours, and a CA cannot issue in secret. Nothing stops the mis-issuance. The change is that it cannot be hidden, which converts an undetectable compromise into a detectable and attributable one. The practical action for an operator is to subscribe to CT monitoring for their domains and to publish a CAA record naming the only CAs permitted to issue for them.
DNS, segmentation, and the zero-trust argument
DNS is a trust dependency of almost everything, and in its original form it is unauthenticated UDP. Cache poisoning attacks, where a forged response arrives before the legitimate one, were made much harder by source-port randomization and 0x20 encoding but not eliminated. DNSSEC signs records so a resolver can verify origin and integrity, but it does not encrypt, its deployment is partial, and its complexity has produced its own outages. DNS over TLS and DNS over HTTPS encrypt the query between client and resolver, which removes on-path observation and tampering for that hop while moving trust to the resolver operator. For an application engineer the practical items are narrow and worth doing. Enforce CAA records so a wrong CA cannot issue, lock registrar accounts with strong MFA since domain hijacking bypasses every other control, and treat "delete the DNS record but leave the cloud resource claimable" as a real risk, since dangling records are the mechanism of subdomain takeover.
Segmentation is the network-level expression of least privilege. Partition the network so that compromise of one host does not yield reachability to everything. The traditional perimeter model, a hard outside and a soft inside, fails because the inside is not soft, it is enormous, and because remote work, cloud services and third-party integrations mean there is no coherent perimeter to defend. Zero trust replaces location-based trust with per-request authentication and authorization. Every request carries a verified identity for both the workload and the user, every request is authorized against policy, the network position confers nothing, and the transport is mutually authenticated regardless of whether it crosses the internet. Service meshes implement the workload half with short-lived certificates issued to each service identity. Device posture and user identity supply the other half. The argument for it is not that perimeters are useless but that they are one control, and the assumption "traffic from inside is trustworthy" is the assumption every lateral-movement incident falsifies.
Supply chain security
A build takes source, dependencies, tools and a build environment and produces an artifact. Every one of those inputs is a place to insert code, and the inserted code inherits the artifact's authority, which is usually total. The industry learned this the hard way through a series of incidents, each of which is a distinct failure mode worth naming.
| Failure mode | Documented case | What it teaches |
|---|---|---|
| Compromised build system | SolarWinds, 2020. Build infrastructure was modified so that the compiler inserted a backdoor into a signed product update, which then shipped to thousands of customers. | Signing proves the artifact came from the vendor's pipeline. It says nothing about whether the pipeline was honest. Integrity must extend to the build environment, not just to the output. |
| Maintainer handover | event-stream, 2018. A popular npm package was transferred to a new maintainer who added a dependency that targeted one specific application's users. | Trust in a package is trust in whoever currently holds the publishing key, which can change silently. Pin versions, review diffs of updates, and prefer packages with multiple maintainers. |
| Dependency confusion | Demonstrated at scale in 2021. Publishing a package to a public registry under the same name as an organization's internal package, with a higher version, caused build systems to prefer the public one. | Resolution order is a security decision. Scope internal names, configure the client to never fall back to the public registry for internal scopes, and use a proxy that refuses public packages matching internal names. |
| Typosquatting | Recurring on npm and PyPI, with packages named one character away from a popular library, often with install-time code execution. | Install-time scripts are arbitrary code execution at install time. Disable them where possible and vet the first use of any new direct dependency. |
| Compromised distribution tooling | Codecov, 2021. A widely used uploader script was modified to exfiltrate environment variables, harvesting credentials from many organizations' build jobs. | Anything curl-piped into a shell in continuous integration is part of your TCB, and build environments are full of credentials. Pin by digest and restrict what the job can see. |
| Long-game source compromise | XZ Utils, 2024. A contributor built up maintainer trust over years, then landed an obfuscated backdoor through binary test fixtures and build-script logic that activated only in distribution builds. | Review covers source diffs. It rarely covers build scripts, generated files, or binary test data. Reproducible builds and scrutiny of non-source inputs are what would have caught this earlier. |
The defensive program has four parts, in rough order of value per unit of effort. Know what is in the artifact. Generate a software bill of materials at build time, in a standard format, and store it with the artifact, so that when an advisory lands the question "are we affected, and where" takes minutes instead of days. Pin and verify. Lock files with cryptographic digests, not version ranges. Pull base images and actions by digest rather than by tag, since tags are mutable, and verify signatures on what you consume. Sign what you produce. Artifact signing with a transparency log, as implemented by Sigstore's keyless flow, binds an artifact to the identity of the workflow that built it and records the binding in a public log, so a signature cannot be issued invisibly. This is the same detection-over-prevention reasoning as Certificate Transparency. Harden the build. Ephemeral, isolated build environments with no persistent credentials, secrets scoped to the minimum, no network access during the build where possible, and provenance attestation describing exactly which source and which parameters produced the artifact, which is what the SLSA framework formalizes across levels.
Reproducible builds are the deepest of these ideas. If building the same source with
the same declared toolchain always produces bit-identical output, then anyone can rebuild and
compare, and a compromised build machine is detectable by a third party rather than trusted by
everyone. Achieving it requires eliminating every source of nondeterminism, such as timestamps, file
ordering from the filesystem, absolute paths, locale, random seeds in the linker, and
parallelism-dependent output. The practical steps are known, SOURCE_DATE_EPOCH,
sorted inputs, -ffile-prefix-map to strip build paths, and a deterministic
archiver, and the payoff is that the build system leaves the TCB, or at least stops being a
single point of trust. Ken Thompson's 1984 "Reflections on Trusting Trust" is the reason this
matters more than it looks. A compiler can insert a backdoor into programs it compiles,
including into itself, and the only defenses are diverse double compilation and independent
reproduction.
PRACTICAL SUPPLY-CHAIN CHECKLIST (each item is verifiable)
[ ] lock file committed, with digests, and CI fails if it changes
unexpectedly during a build
[ ] dependency review on every version bump for direct dependencies;
automated advisory scanning for the full transitive set
[ ] internal package names scoped and the public registry excluded for
those scopes (defeats dependency confusion)
[ ] install-time scripts disabled by default; exceptions listed
[ ] base images and CI actions pinned by digest, not tag
[ ] build runs in an ephemeral environment with no long-lived credentials
[ ] artifacts signed, with provenance attestation, recorded in a
transparency log
[ ] SBOM generated per build and retained with the artifact
[ ] a documented, rehearsed path to rebuild and redeploy every artifact
within hours (this is what an advisory actually tests)
Security of machine-learning systems
A model is a program whose behavior was learned rather than written, which changes both the attack surface and the available defenses. The training data is an input channel, the parameters are an asset that can be stolen and that memorizes its inputs, the inference interface is an oracle, and, for language models, the prompt is a channel where instructions and data are not separated. Each of these has a literature, and the practical engineering responses differ sharply in maturity.
Adversarial examples and the threat model
Szegedy and colleagues observed in 2014 that imperceptible perturbations flip a network's classification, and Goodfellow, Shlens and Szegedy argued in 2015 that the cause is not exotic nonlinearity but the opposite, local linearity in high dimension. The argument is short enough to give in full. Take a classifier whose logit is locally \(f(x) \approx w^\top x + b\), and perturb by \(\delta\) with \(\|\delta\|_\infty \le \varepsilon\). The change in logit is \(w^\top \delta\), and by Hölder's inequality
$$ |w^\top \delta| \le \|w\|_1 \|\delta\|_\infty \le \varepsilon \|w\|_1, $$with equality when \(\delta = \varepsilon\,\mathrm{sign}(w)\). If the input has \(d\) coordinates and the weights have typical magnitude \(m\), then \(\|w\|_1 \approx md\), so the achievable logit shift grows linearly in the dimension while each individual pixel moves by only \(\varepsilon\). High-dimensional inputs therefore make small per-coordinate perturbations powerful, which is why the phenomenon is generic rather than a quirk of a particular architecture. The same computation applied to the loss gives the fast gradient sign method, \(x' = x + \varepsilon\,\mathrm{sign}(\nabla_x \L(f(x), y))\), which is one step of the first-order maximization of loss inside the \(\ell_\infty\) ball.
Madry and colleagues in 2018 recast robustness as a saddle-point problem, which is the formulation the field now uses.
$$ \min_{\theta}\ \E_{(x,y)\sim \D}\Big[\ \max_{\|\delta\|_p \le \varepsilon} \L\big(f_\theta(x+\delta),\, y\big)\ \Big]. $$The inner maximization is approximated by projected gradient descent. Take a step of size \(\alpha\) in the sign of the gradient, project back onto the \(\varepsilon\)-ball, repeat, with random restarts to avoid poor local maxima. Training on those examples is adversarial training, currently the only defense that has survived sustained scrutiny, at a real cost, several times the training compute and a measurable loss of clean accuracy. Carlini and Wagner showed in 2017 that many published defenses fell to stronger optimization, and the 2018 analysis of obfuscated gradients showed that a large class of them worked only by making gradients uninformative, which a different attack routes around. The methodological lesson generalizes far beyond ML. A defense evaluated only against the attacks its authors thought of is evaluated against nothing.
The threat model deserves as much care as the algorithm. Is the attacker white-box (parameters known) or black-box (query access only)? Are queries limited, and is the label or the full score vector returned? Is the perturbation budget a norm ball, which is a mathematical convenience, or a physical realizability constraint, which is what matters for a camera-based system. Work from Michigan, Washington and Berkeley in 2018 produced sticker patterns on road signs that survived printing, viewing angle and distance, which is a very different constraint from an \(\ell_\infty\) ball. Groups at Tsinghua contributed momentum-based transferable attacks that made black-box transfer far more effective than had been assumed, which matters because transferability means an attacker does not need your model to attack it.
A binary linear classifier over \(d\) inputs predicts class 1 when \(w^\top x + b > 0\). For a particular input the margin is \(w^\top x + b = 0.5\). Every weight has magnitude exactly \(0.1\) with arbitrary sign. (a) What is the smallest \(\ell_\infty\) perturbation that flips the prediction when \(d = 100\), and what is the optimal \(\delta\)? (b) The same question for \(d = 10{,}000\). (c) What is the smallest \(\ell_2\) perturbation for \(d = 100\)? (d) Interpret the ratio between (a) and (b) in terms of image inputs scaled to \([0,1]\), and state what the result implies about defending by "just checking whether the input looks unusual".
Solution. (a) The perturbation reduces the margin by at most \(\varepsilon\|w\|_1\) (Hölder, with equality at \(\delta = -\varepsilon\,\mathrm{sign}(w)\) for a decrease). With \(d = 100\) and each \(|w_i| = 0.1\), \(\|w\|_1 = 10\). Flipping requires \(\varepsilon \|w\|_1 \ge 0.5\), so \(\varepsilon \ge 0.5/10 = 0.05\). The optimal perturbation moves every coordinate by \(0.05\) in the direction that reduces the margin, \(\delta_i = -0.05\,\mathrm{sign}(w_i)\).
(b) \(\|w\|_1 = 0.1 \times 10{,}000 = 1000\), so \(\varepsilon \ge 0.5/1000 = 5 \times 10^{-4}\). The required per-coordinate change fell by a factor of 100, exactly the ratio of the dimensions.
(c) For the \(\ell_2\) ball the relevant inequality is Cauchy-Schwarz, \(|w^\top\delta| \le \|w\|_2\|\delta\|_2\), with equality when \(\delta \propto -w\). Here \(\|w\|_2 = \sqrt{100 \times 0.01} = 1\), so \(\|\delta\|_2 \ge 0.5/1 = 0.5\), achieved by \(\delta = -0.5\,w/\|w\|_2\). Note the norms disagree about which perturbation is "small", which is precisely why a robustness claim must name its threat model.
(d) On pixels scaled to \([0,1]\), \(\varepsilon = 5\times 10^{-4}\) is about \(0.13\) of one 8-bit level, below the quantization of the image format, so it may not even be representable, let alone visible. The defensive implication is that detecting adversarial inputs by "does this look normal" is close to hopeless in high dimension, because the attacker's required deviation shrinks as \(1/d\) while the natural variation of the input does not. Defenses must change the decision boundary (adversarial training, certified smoothing) or change the threat model (limit query access, require physical realizability), not filter the input.
Data poisoning and backdoors
If training data is an input, then an attacker who can influence it is programming the model. Two goals are distinguished. Availability poisoning degrades overall accuracy, which is noisy and easily detected. Targeted poisoning and backdoors leave clean accuracy intact and cause a specific misbehavior on a specific trigger, which is what makes them dangerous. The model passes every evaluation. The BadNets work of Gu, Dolan-Gavitt and Garg in 2017 demonstrated the pattern with a small visual trigger that flipped classification whenever present, at a poisoning rate of well under one percent of the training set.
The threat became concrete for large models when Carlini and colleagues showed in 2023 that poisoning web-scale datasets is practical rather than theoretical, via two mechanisms. Split-view poisoning exploits the fact that a dataset is a list of URLs collected at one time and downloaded at another, so buying an expired domain that appears in the list lets an attacker control what future downloaders receive. Frontrunning poisoning exploits snapshot schedules of crowd-edited sources. Edit shortly before the snapshot, and the malicious version is what gets captured. Both require modifying only a tiny fraction of a corpus, and both are cheap. Related work showed that contrastive and multimodal training pipelines, which use noisier and less curated data, are more exposed rather than less.
Mitigations. Provenance is the first line. Pin dataset contents by cryptographic digest rather than by URL, so that what was audited is what is trained on. This single change defeats split-view poisoning entirely. Snapshot integrity, freezing and hashing crowd-edited sources with a delay for review, addresses frontrunning. Beyond provenance, deduplicate aggressively, since memorization and poisoning both concentrate in repeated examples. Run influence-function or loss-trajectory analysis to find examples with outsized effect on specific behaviors, hold out a trusted evaluation set that never touches the training pipeline, and, for backdoor detection specifically, test with randomized triggers and examine the model's sensitivity to small patch-shaped perturbations. For fine-tuning on user-supplied data, treat the data as untrusted input and apply the same review you would apply to code, because functionally it is code.
Model extraction, inversion, and membership inference
A prediction interface is an oracle, and oracles leak. Tramèr and colleagues showed in 2016 that models behind prediction APIs can be extracted with a number of queries that is modest relative to the cost of training, particularly when the API returns confidence scores rather than labels. Scores give far more information per query, and for some model families allow exact recovery by solving equations rather than by fitting. The 2024 result that part of a production language model, specifically the final embedding projection and hence the hidden dimension, can be recovered through an ordinary API by exploiting the low-rank structure of the logit outputs shows the concern is current rather than historical, and that the leak came from an interface detail (returning full or top-k logits with a bias parameter) rather than from the model.
Model inversion reconstructs representative inputs from the model, which matters when the training data was sensitive. Membership inference, formalized by Shokri, Stronati, Song and Shmatikov in 2017, asks a narrower and more damaging question, was this particular record in the training set? The attack exploits the generalization gap. A model is systematically more confident, and incurs lower loss, on data it trained on. The strongest modern version is a likelihood-ratio test. Train many shadow models with and without the target record, estimate the distribution of the record's loss under each, and compare.
$$ \Lambda(x,y) = \frac{p\big(\ell(f(x),y) \mid \text{in}\big)}{p\big(\ell(f(x),y) \mid \text{out}\big)} \gtrless \tau. $$Reporting such attacks by average accuracy understates them badly. The meaningful metric is the true-positive rate at a very low false-positive rate, because an attacker who can identify even one percent of members with high confidence has produced a real privacy harm. Carlini and colleagues showed in 2021 that language models memorize and can be made to emit verbatim training sequences, including personal data, and later work extended this to production systems, so extraction of training data is not limited to overfitted toy models.
Differential privacy, stated properly
Differential privacy gives a defensible definition of "the output does not depend much on any one individual". A randomized mechanism \(\mathcal{M}\) is \((\varepsilon,\delta)\)-differentially private if for all datasets \(D, D'\) differing in one record and all measurable sets \(S\) of outputs,
$$ \P[\mathcal{M}(D) \in S] \le e^{\varepsilon}\,\P[\mathcal{M}(D') \in S] + \delta. $$Read it as a bound on what any observer can learn. Whatever prior an adversary held about whether a record was present, their posterior odds change by at most a factor of \(e^{\varepsilon}\) (with probability \(1-\delta\) of the bound failing entirely, which is why \(\delta\) should be far smaller than \(1/n\), or a mechanism that simply publishes a random record satisfies the definition). The definition is a property of the mechanism, holds against adversaries with arbitrary auxiliary information, and degrades gracefully under composition, which is what makes it useful rather than merely quotable.
The Laplace mechanism achieves it for a numeric query \(f\) with \(\ell_1\)-sensitivity \(\Delta_1 f = \max_{D\sim D'} \|f(D)-f(D')\|_1\) by releasing \(f(D) + \mathrm{Lap}(\Delta_1 f/\varepsilon)\). The proof is one line of algebra. The density ratio at any output \(z\) is \(\exp\!\big(-\varepsilon|z-f(D)|/\Delta_1 f\big) / \exp\!\big(-\varepsilon|z-f(D')|/\Delta_1 f\big) = \exp\!\big(\varepsilon(|z-f(D')|-|z-f(D)|)/\Delta_1 f\big) \le e^{\varepsilon}\) by the triangle inequality, since \(|f(D)-f(D')| \le \Delta_1 f\). The Gaussian mechanism gives \((\varepsilon,\delta)\)-DP with noise scaled to the \(\ell_2\)-sensitivity, which is the better fit for high-dimensional releases such as gradients.
Composition is where budgets are actually spent. Basic composition gives \(k\varepsilon\) for \(k\) mechanisms. Advanced composition gives, for any \(\delta' > 0\),
$$ \varepsilon_{\text{total}} = \sqrt{2k\ln(1/\delta')}\,\varepsilon + k\varepsilon(e^{\varepsilon}-1), $$which is much tighter for large \(k\) because the errors partially cancel rather than accumulate. DP-SGD applies this to training. Clip each per-example gradient to a fixed \(\ell_2\) norm, which bounds sensitivity, add Gaussian noise to the sum, and account the privacy loss across steps with a tighter accountant than either composition theorem. The engineering costs are real and should be stated plainly. Accuracy drops, especially for underrepresented groups whose signal is closest to the noise floor, per-example gradient clipping is expensive, and a plausible \(\varepsilon\) for deep learning is often in the single digits, which is a much weaker guarantee than the definition's clean statement suggests. It remains the only defense that provides a guarantee rather than an empirical resistance, and it directly bounds membership inference. An \(\varepsilon\)-DP mechanism caps the attacker's advantage, which is exactly the relationship one wants between a definition and an attack.
An analytics service publishes counts over a dataset where each person contributes at most one record, using the Laplace mechanism with \(\varepsilon_0 = 0.1\) per query. (a) What is the sensitivity of a count, and what noise scale and standard deviation does the mechanism use? (b) A single query returns a noisy count of 1,204. Give a 90 percent interval for the true count. (c) The service answers 200 such queries. Compute the total budget under basic composition and under advanced composition with \(\delta' = 10^{-5}\). (d) Interpret \(\varepsilon_{\text{total}}\) from (c) as a bound on an adversary's posterior odds about one person's membership, and say whether the deployment is meaningfully private.
Solution. (a) Adding or removing one person changes any count by at most 1, so \(\Delta_1 f = 1\). The Laplace scale is \(b = \Delta_1 f/\varepsilon_0 = 1/0.1 = 10\), and a Laplace distribution with scale \(b\) has standard deviation \(b\sqrt{2} = 14.14\).
(b) For Laplace noise, \(\P[|X| > t] = e^{-t/b}\). Setting this to \(0.10\) gives \(t = b\ln 10 = 10 \times 2.3026 = 23.03\). So a 90 percent interval is \(1204 \pm 23\), that is \([1181, 1227]\). At a count of 1,204 that is under two percent relative error, which is why DP is practical for aggregate counts over large populations and impractical for small cells.
(c) Basic composition gives \(200 \times 0.1 = 20\). For advanced composition, \(\ln(1/\delta') = \ln(10^{5}) = 11.5129\), so \(\sqrt{2 \times 200 \times 11.5129} = \sqrt{4605.2} = 67.86\), and the first term is \(67.86 \times 0.1 = 6.786\). The second term is \(200 \times 0.1 \times (e^{0.1}-1) = 20 \times 0.10517 = 2.103\). Total \(\varepsilon_{\text{total}} = 8.89\), against 20 for basic composition. Advanced composition saves a factor of 2.25 here, and the saving grows as \(\sqrt{k}\) versus \(k\).
(d) \(e^{8.89} = 7{,}260\). The guarantee is that an adversary's posterior odds on any individual's membership may shift by up to a factor of about 7,000, which is not a meaningful privacy guarantee. Prior odds of 1 in 1,000 become posterior odds of about 7 to 1. The lesson is the one practitioners most often miss. The budget is a global, cumulative property of everything ever released about the dataset, not a per-query setting. A deployment answering 200 queries at \(\varepsilon_0 = 0.1\) must either reduce the per-query budget by roughly an order of magnitude, restrict the number of queries, or use a mechanism designed for many queries, such as one that answers from a single noisy synopsis rather than paying per query.
Federated learning's threat model
Federated learning keeps raw data on devices and shares model updates instead, which sounds like a privacy solution and is better described as a change of threat model. Gradients are functions of the data, and functions of the data leak the data. Work from MIT in 2019 and follow-ups in 2020 showed that individual training examples, including recognizable images and text, can be reconstructed from a single shared gradient in realistic settings, especially with small batches. So the server must be treated as an adversary. Secure aggregation addresses this by having devices mask their updates with pairwise-cancelling random values so that the server can compute the sum but not any individual contribution. Combining it with differential privacy at the appropriate granularity (user-level, not example-level) is what produces an actual guarantee. The client side has its own threat. A malicious participant can submit crafted updates to poison the global model or to install a backdoor, and defenses based on robust aggregation (coordinate-wise median, trimmed mean, norm clipping) trade accuracy for resistance. The honest summary is that federated learning removes the "raw data in one place" risk and adds a distributed poisoning risk and a gradient-leakage risk, and it is private only when secure aggregation and user-level DP are both present.
Prompt injection, and why it is structural
A language model receives one token stream. The system's instructions, the user's request, and any retrieved content, a web page, an email, a code comment, a PDF, a tool result, arrive in that same stream and are processed by the same mechanism. There is no privileged channel, no memory protection bit, no parse-then-bind separation. Consequently text that appears in the data can be interpreted as instruction, and the model has no reliable basis for distinguishing them, because "which of these tokens are authoritative" is not a property of the tokens.
This is exactly the structure of SQL injection, and comparing the two makes the defensive situation clear. SQL injection has a structural fix, prepared statements, because the SQL grammar is fixed and the database can parse the statement before the data exists, so no character in the data can change the parse. The equivalent move is not available for natural language. There is no grammar, the model's "parse" is the same computation as its execution, and instruction-following is the capability being sold. The direct form of the attack, where the user tries to override the system prompt, is a policy problem. The indirect form, described by Greshake and colleagues in 2023, is the dangerous one. The instructions arrive in content the model retrieves, so the attacker never talks to the system at all, and the victim is the user whose agent read the poisoned page.
CLASSIC INJECTION PROMPT INJECTION
query template + user data system prompt + user turn + retrieved doc
| |
v v
SQL parser: grammar is fixed model: no grammar, no parse/execute split
| |
FIX: parse first, bind values NO EQUIVALENT FIX EXISTS TODAY
later; data can never |
change the parse tree v
MITIGATE by removing what a successful
injection can *do*:
- the agent's tools, scoped down
- the data it can reach
- the actions it can take unattended
- the channels it can exfiltrate through
Why filtering does not solve it:
1. The input space is natural language: unbounded paraphrase, encoding
(base64, homoglyphs, other languages), indirection ("follow the
instructions in the file you just read"), and multi-step setups.
2. The classifier is itself a model, so it inherits the same problem, and
an adversary can optimize against it directly.
3. A filter must be perfect to be a boundary; a boundary that holds 99% of
the time is not a boundary, it is a rate limiter. Compare: a bounds
check that passes 99% of the time is a vulnerability.
Jailbreaking is the related but distinct failure of the model's safety training rather than of channel separation. Wei, Haghtalab and Steinhardt's 2023 analysis attributes it to two structural causes, competing objectives, where following instructions and being helpful pull against refusing, so framing that maximizes the first overrides the second, and mismatched generalization, where safety training covers a narrower distribution than capability training, so inputs in unusual encodings, languages or formats fall outside the safety distribution while remaining inside the capability distribution. The 2023 work from CMU on universal transferable suffixes showed that gradient-based optimization finds adversarial strings that work across models, which connects this directly to the adversarial examples literature and implies the same conclusion. This is an optimization problem, and defenses evaluated only against handwritten attacks are not evaluated.
Agents, tools, and the confused deputy again
An agent is a model with tools, credentials and a loop. That is a deputy holding the user's authority and taking instructions from whatever it reads, which is Hardy's 1988 problem restated with a new component in the middle. The severity is the product of two things the designer controls, what the model can be made to say, which is not controllable, and what the system does with what it says, which is entirely controllable. All practical agent security follows from putting effort into the second.
Insecure output handling is the first place this bites. A model's output is untrusted input to whatever consumes it. Rendering it as HTML without encoding is XSS, passing it to a shell is command injection, interpolating it into a query is SQL injection, using it as a filename is path traversal, and feeding it to another agent is injection propagation. The rule is uniform. Model output crosses a trust boundary, so it is validated and encoded exactly like any other untrusted input, at the sink, in the sink's own language.
The architectural mitigations follow, in order of effectiveness.
| Control | What it does | How to verify |
|---|---|---|
| Privilege separation per context | An agent that reads untrusted content gets a different, minimal credential set from one that acts on the user's behalf. Two models with different tool sets, communicating over a narrow typed channel, is the same design as a privilege-separated daemon. | Enumerate each agent's credentials and assert in a test that the untrusted-content agent has no write-capable tool. |
| Least privilege on tools | Scope every tool to the minimum, read-only where possible, a single tenant, a rate limit, a spend cap. The question is not "can the model be tricked" but "what is the worst outcome if it is". | For each tool, write down the worst-case action and check it is acceptable, and test that the credential cannot perform out-of-scope actions. |
| Human confirmation for consequential actions | Irreversible or high-value actions (sending mail, moving money, deleting data, granting access) require an out-of-band confirmation showing the exact action, not a summary the model wrote. | Test that the action cannot execute without the confirmation token, and that the displayed text comes from the tool call arguments rather than from model prose. |
| Output validation and schema enforcement | Tool arguments are parsed into a strict schema with allowlisted values, and free-text arguments that become commands or paths are rejected by construction. | Fuzz the tool interface with adversarial argument strings, and assert the schema rejects them before execution. |
| Sandboxed execution | Code the model writes runs in a container or microVM with no credentials, no network, a filesystem containing only the task inputs, and a timeout. | Run a task that attempts network access and confirm it fails, and confirm no ambient credentials are present in the environment. |
| Egress control | Exfiltration usually needs a channel, an outbound request, a rendered image URL, a link the user clicks. Restrict outbound destinations and disallow model-controlled URLs in rendered output. | Test that a response containing an image tag with an attacker-controlled host does not cause a fetch. |
| Provenance markers in context | Label retrieved content clearly as data and keep it structurally separate in the prompt. This measurably reduces success rates and is worth doing. It is a mitigation, not a boundary, and must never be the only control. | Red-team with indirect injections in retrieved documents and track the success rate over releases. |
| Logging and anomaly detection | Log every tool call with its arguments and the context that produced it, so that an injection is investigable after the fact and detectable in aggregate. | Confirm tool-call logs are complete, immutable and cover arguments, not just names. |
"""Agent tool layer with the controls above made explicit. The design
assumption is that the model WILL at some point be induced to request a
harmful action; the layer's job is to make that request harmless."""
from dataclasses import dataclass
from typing import Literal
import re, subprocess
# 1. Tool arguments are a schema, not a string. Anything the model sends
# that does not parse is rejected before any side effect happens.
@dataclass(frozen=True)
class SendEmail:
to: str
subject: str
body: str
ALLOWED_RECIPIENTS = re.compile(r"^[a-z0-9._%+-]+@example\.com$")
def validate_send_email(args: dict) -> SendEmail:
to = str(args.get("to", ""))
if not ALLOWED_RECIPIENTS.match(to):
raise PermissionError("recipient outside the allowed domain")
if len(args.get("body", "")) > 10_000:
raise ValueError("body too large")
return SendEmail(to=to, subject=str(args["subject"])[:200],
body=str(args["body"]))
# 2. Consequential actions require an out-of-band confirmation that shows
# the ACTUAL arguments. The model never sees or supplies the token.
CONSEQUENTIAL = {"send_email", "transfer_funds", "delete_records", "grant_access"}
def execute(tool: str, args: dict, ctx: "Context"):
if tool in CONSEQUENTIAL:
approval = ctx.request_human_approval(
tool=tool,
rendered=render_exact_arguments(tool, args), # not model prose
)
if not approval.granted:
return {"status": "denied"}
return TOOLS[tool](args, ctx.credentials_for(tool)) # scoped credential
# 3. Model output is untrusted input at every sink. Never build a shell
# command from it; never render it as markup without encoding.
def run_analysis_bad(model_output: str):
return subprocess.run(model_output, shell=True) # command injection
def run_analysis_ok(script_path: str, dataset_id: str):
if not re.fullmatch(r"[a-z0-9-]{1,64}", dataset_id): # allowlist
raise ValueError("bad dataset id")
return subprocess.run(
["/usr/bin/python3", script_path, "--dataset", dataset_id],
shell=False, # argv vector: no shell parsing at all
cwd="/sandbox", timeout=60, capture_output=True,
env={"PATH": "/usr/bin"}, # no credentials in the environment
)
# 4. Privilege separation: the agent that reads untrusted web content has
# a tool registry with no write-capable entries at all. Its output is
# passed to the acting agent as DATA, in a field, never concatenated
# into the acting agent's instruction block.
READER_TOOLS = {"fetch_url", "search_index"} # read-only
ACTOR_TOOLS = {"send_email", "create_ticket"} # never sees raw web text
assert READER_TOOLS.isdisjoint(ACTOR_TOOLS)
// Rendering model output in a browser. The model is an untrusted source,
// so the same rules as user-generated content apply, plus one more: any
// URL the model produces is an exfiltration channel.
import DOMPurify from "dompurify";
// VULNERABLE: markdown to HTML with no sanitization. An injected page can
// make the model emit an image tag whose URL encodes the conversation,
// and the browser will fetch it with no user interaction at all.
function renderBad(md: string, el: HTMLElement) {
el.innerHTML = markdownToHtml(md);
}
// FIXED: sanitize, then forbid model-controlled network references
// entirely. Images and links are rewritten to go through a proxy that
// only serves an allowlisted set, so no request carries data outward.
const SAFE_CONFIG = {
ALLOWED_TAGS: ["p", "br", "strong", "em", "code", "pre", "ul", "ol",
"li", "blockquote", "h1", "h2", "h3", "a"],
ALLOWED_ATTR: ["href"],
FORBID_TAGS: ["img", "video", "audio", "iframe", "object", "embed",
"style", "script", "form", "input"],
};
function renderOk(md: string, el: HTMLElement) {
const clean = DOMPurify.sanitize(markdownToHtml(md), SAFE_CONFIG);
el.innerHTML = clean;
// Links: visible, but never auto-fetched, and shown with their real
// destination so a user can see where a click would go.
el.querySelectorAll("a").forEach((a) => {
const href = a.getAttribute("href") ?? "";
if (!/^https?:\/\//.test(href)) { a.removeAttribute("href"); return; }
a.setAttribute("rel", "noopener noreferrer nofollow");
a.setAttribute("target", "_blank");
a.textContent = `${a.textContent} (${new URL(href).host})`;
});
}
// And at the HTTP layer, a CSP that makes exfiltration structurally
// impossible rather than merely discouraged:
// Content-Security-Policy: default-src 'none'; script-src 'nonce-...';
// img-src 'self'; connect-src 'self'; form-action 'none';
// base-uri 'none'; frame-ancestors 'none'
// img-src 'self' is the line that stops the classic markdown-image
// exfiltration channel even if sanitization is bypassed.
The summary a designer should carry is that prompt injection cannot currently be solved at the model layer, so it must be contained at the system layer. Assume the model will eventually do the worst thing its tools permit, and design so that the worst thing is acceptable. That is the same reasoning that says a parser should run in a seccomp-confined process, and it is the reason the security engineering of agents is more familiar than it first appears.
Finding the bugs first with fuzzing, sanitizers, and analysis
Coverage-guided fuzzing and why it works
Random testing of a program with a structured input format almost never gets past the parser. The probability that random bytes form a valid header is astronomically small. Coverage-guided fuzzing fixes this with one idea, an evolutionary loop over a corpus, kept honest by cheap coverage instrumentation. The fuzzer maintains a set of inputs. It picks one, mutates it (bit flips, splices, dictionary token insertion, arithmetic on integer-looking fields), runs the target, and asks whether that execution reached any control-flow edge no previous input reached. If so, the input is added to the corpus. Coverage acts as a fitness signal that turns a search over a space of size \(256^n\) into a hill climb, because each newly discovered branch is a partial solution that gets preserved and built upon.
The reason it is so effective in practice is the combination with sanitizers. Fuzzing alone finds crashes, but a memory bug frequently does not crash, it corrupts something that matters later. Under ASan and UBSan the bug becomes an immediate, localized abort, so the fuzzer's oracle changes from "did it segfault" to "did it violate memory safety or the language rules", which is a far denser signal. That pairing is why continuous fuzzing infrastructure has found tens of thousands of bugs in widely used open-source software.
Writing a good fuzz target is a skill with a few rules. The target must be fast (milliseconds, no I/O, no network, no sleeps), deterministic (fixed seeds, no time or randomness dependence), and stateless across runs, or the corpus stops being meaningful. It should exercise a real API boundary rather than an internal helper. Structure-aware fuzzing, where the mutator understands the input grammar or where a library turns raw bytes into typed values, is what gets past deep validation. And the corpus is an asset. Seed it with real files, minimize it, and keep it in version control.
/* A libFuzzer-style entry point. The contract: consume the bytes, return
0, never exit, never abort on invalid input (invalid input is the
normal case), and be fast. */
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "parser.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
/* Bound the work so one pathological input cannot stall the loop. */
if (size > 64 * 1024)
return 0;
/* Copy into an exactly sized heap buffer: with ASan, a one-byte
over-read past the end is then a hard error rather than a read of
adjacent corpus memory that happens to be mapped. */
uint8_t *buf = malloc(size ? size : 1);
if (!buf)
return 0;
memcpy(buf, data, size);
struct doc *d = parse_document(buf, size); /* the API under test */
if (d) {
/* Exercise the round trip too: serializers have bugs, and a
parse/serialize/parse identity is a cheap extra oracle. */
size_t out_len = 0;
uint8_t *out = serialize_document(d, &out_len);
if (out) {
struct doc *d2 = parse_document(out, out_len);
free_document(d2);
free(out);
}
free_document(d);
}
free(buf);
return 0;
}
/* Assertions inside the library are a feature here: an assert that fires
under the fuzzer is a found bug, so compile fuzz builds with
assertions ON (-UNDEBUG), which is the opposite of the release build. */
// cargo-fuzz target. Arbitrary turns raw bytes into typed values, which
// is what gets past validation that random bytes never satisfy.
#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;
#[derive(Debug, Arbitrary)]
struct Input {
version: u8,
flags: u16,
records: Vec<(u32, String)>,
}
fuzz_target!(|input: Input| {
// The oracle is not only "does it panic": check invariants that must
// hold for every input, so that silent logic bugs are caught too.
if let Ok(doc) = mycrate::Document::from_parts(
input.version, input.flags, &input.records)
{
let bytes = doc.to_bytes();
let round = mycrate::Document::parse(&bytes)
.expect("serialized output must parse");
assert_eq!(doc, round, "round trip must be lossless");
}
});
// Rust fuzzing finds panics (index out of range, unwrap on None,
// arithmetic overflow in debug), logic violations expressed as asserts,
// and, in unsafe blocks, genuine memory unsafety. Run the unsafe-heavy
// paths under Miri as well, which interprets MIR and detects undefined
// behavior that ASan cannot see.
# Build and run a fuzz target with the sanitizers that give it a useful
# oracle. -fsanitize=fuzzer supplies the driver and coverage feedback.
clang -g -O1 -fsanitize=fuzzer,address,undefined \
-fno-sanitize-recover=all \
-fprofile-instr-generate -fcoverage-mapping \
fuzz_parse.c parser.c -o fuzz_parse
# Seed corpus matters more than runtime: start from real inputs.
mkdir -p corpus && cp testdata/*.doc corpus/
# -max_len bounds input size, -dict supplies format keywords, -jobs runs
# parallel workers, -rss_limit_mb catches runaway allocation.
./fuzz_parse corpus/ -dict=doc.dict -max_len=65536 -jobs=8 -rss_limit_mb=2048
# Minimize the corpus periodically so the loop stays fast:
./fuzz_parse -merge=1 corpus_min/ corpus/
# Measure what was reached; if coverage plateaus, the target or the
# dictionary is the problem, not the runtime.
llvm-profdata merge -sparse *.profraw -o f.profdata
llvm-cov report ./fuzz_parse -instr-profile=f.profdata
# In CI: run each target for a bounded time on every pull request, and
# continuously on a fleet. Any crash reproducer becomes a regression test
# committed to the corpus, so the bug cannot come back silently.
The sanitizers, and what each one catches
Sanitizers are compiler instrumentation that turns undefined behavior into a deterministic, well-located report. They are test tools, not production mitigations. The overhead is too high and, in the case of ASan, the instrumentation itself is not designed to be attack-resistant. Their value is that they change the oracle. The table gives what each detects and its approximate cost. The numbers are the commonly cited ranges from the tools' own documentation.
| Sanitizer | Catches | Mechanism | Rough cost |
|---|---|---|---|
ASan (-fsanitize=address) | Heap and stack buffer overflow, use-after-free, use-after-return and use-after-scope, double free, memory leaks (via LeakSanitizer). | Shadow memory, where one byte per 8 bytes of application memory records how many of those 8 are addressable, plus poisoned redzones around allocations and a quarantine that delays reuse of freed memory. | ~2x time, ~3x memory |
UBSan (-fsanitize=undefined) | Signed integer overflow, shifts past the width, misaligned or null pointer use, out-of-bounds on arrays with known bounds, invalid enum and bool values, bad casts, division by zero. | Inline checks emitted at each operation that has undefined cases, calling a runtime handler on violation. | ~20% time, checks are selectable |
TSan (-fsanitize=thread) | Data races, that is, two accesses to the same location from different threads with at least one write and no happens-before ordering, and also some lock-order inversions. | Shadow state per memory word recording recent accesses with vector clocks, and interception of the synchronization primitives to build the happens-before graph. | ~5-15x time, ~5-10x memory |
MSan (-fsanitize=memory) | Reads of uninitialized memory, which is the classic information-disclosure bug (padding bytes and short reads leaking to the network). | Shadow bits tracking initialization per bit, propagated through arithmetic, reported only when an uninitialized value affects control flow or output. | ~3x time, requires all libraries instrumented |
The reports produced on this machine make the difference concrete. First, a one-byte heap overflow that a normal build ignores entirely.
==4169406==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000020
WRITE of size 1 at 0x502000000020 thread T0
#0 in main heap_oob.c:6
0x502000000020 is located 0 bytes to the right of 16-byte region
allocated by thread T0 here:
#0 in __interceptor_malloc
#1 in main heap_oob.c:4
SUMMARY: AddressSanitizer: heap-buffer-overflow heap_oob.c:6 in main
Next, a stack overflow, where the report names the specific variable and the byte offset at which the write left it.
==4169414==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc1bbec480
WRITE of size 33 at 0x7ffc1bbec480 thread T0
#0 in __interceptor_strcpy
#1 in copy_name stackovf.c:5
#2 in main stackovf.c:10
Address 0x7ffc1bbec480 is located in stack of thread T0 at offset 48 in frame
#0 in copy_name stackovf.c:3
This frame has 1 object(s):
[32, 48) 'name' (line 4) <== Memory access at offset 48 overflows this variable
And a data race, where the report gives both conflicting accesses and the creation stacks of both threads, which is the information a log line can never provide.
WARNING: ThreadSanitizer: data race (pid=4169727)
Read of size 4 at 0x555555558014 by thread T2:
#0 bump race.c:3
Previous write of size 4 at 0x555555558014 by thread T1:
#0 bump race.c:3
Location is global 'counter' of size 4 at 0x555555558014
Thread T2 created by main thread at: #1 main race.c:7
Thread T1 created by main thread at: #1 main race.c:7
Operational notes worth having in advance. ASan and TSan cannot be combined in one binary, so
run separate jobs. ASan's default behavior is to continue after some errors, so set
ASAN_OPTIONS=halt_on_error=1:detect_leaks=1:abort_on_error=1 in continuous
integration so a finding is a failure. TSan requires actually exercising the concurrent paths,
so it pairs with stress tests rather than unit tests. MSan requires that every library in the
process be instrumented, including the standard library, which is why it is the least deployed
of the four. And running the test suite under sanitizers is worth far more than running it once
under a fuzzer, because the test suite already reaches code the fuzzer will take days to find.
Static and dynamic analysis in the pipeline
Static analysis reasons about code without running it, and its useful forms differ in what they
trade away. Linters and pattern matchers find known-bad shapes (a concatenated SQL string, a
disabled certificate check, a hardcoded credential) with almost no false positives and no depth.
Semantic pattern tools that match on the syntax tree rather than on text are the sweet spot for
enforcing a team's own rules. Taint analysis tracks flows from sources (request parameters, file
contents) to sinks (query execution, command execution, markup) and is where the genuinely
interesting findings come from, at the cost of false positives when the analysis cannot see
through a framework. Abstract interpretation and model checking prove properties and scale
poorly, so they are reserved for small critical components. Type systems are the static analysis
with the best return. A newtype that distinguishes UserInput from
SafeHtml makes an entire vulnerability class a compile error, and this is the
mechanism behind Trusted Types and behind Rust's approach generally.
The pipeline that works in practice, ordered by how early it runs, is format and lint on commit, type check and unit tests with ASan and UBSan on every pull request, semantic pattern rules for the team's own security invariants that fail the build, dependency advisory scanning against a maintained database, a bounded fuzzing run per pull request on the parsers plus continuous fuzzing on a fleet, dynamic scanning of a deployed staging environment, and periodic manual review focused on authorization logic and trust boundaries, which is the part no tool finds. The ordering matters because the cost of a finding rises by roughly an order of magnitude at each stage.
Worked problems
Six problems appear inline with the sections they belong to (ASLR entropy, password-cracking economics, session-token entropy, timing-attack sample counts, adversarial perturbation size, and the differential-privacy budget). Two more are collected here, one numerical, on why detection systems disappoint, and one derivation, on why differential privacy composes and what it buys against a concrete attack.
A detection system inspects \(10^{7}\) authentication events per day. It flags a true intrusion attempt with probability 0.99 and flags a benign event with probability \(10^{-3}\). On a typical day there are 10 genuine intrusion attempts. (a) How many alerts are generated per day, and what fraction of them are real? (b) An analyst can investigate 100 alerts per day. What fraction of the genuine attempts is investigated if alerts are sampled uniformly? (c) The vendor proposes reducing the false-positive rate to \(10^{-5}\). Recompute (a). (d) A second, independent signal with the same characteristics is added and alerts require both to fire. Recompute (a), and state the precision-recall trade this makes. (e) What does this analysis say about where detection engineering effort should go?
Solution. (a) True positives are \(10 \times 0.99 = 9.9\) per day, and false positives are \((10^{7} - 10)\times 10^{-3} \approx 10^{4}\) per day. Total alerts \(\approx 10{,}010\), of which the fraction that are real is \(9.9/10{,}010 = 9.9\times10^{-4}\), about one in a thousand. This is the base-rate fallacy in its operational form. A test with a 99 percent detection rate produces a haystack because the prior is tiny.
(b) Sampling 100 of 10,010 alerts uniformly covers 1.0 percent of them, so the expected number of genuine attempts investigated is \(9.9 \times 0.01 = 0.099\), roughly one every ten days. The system detects almost everything and the organization sees almost nothing, which is exactly the failure mode post-incident reviews keep rediscovering, that the alert was in the queue.
(c) False positives fall to \(10^{7}\times10^{-5} = 100\) per day, total alerts \(\approx 110\), and precision rises to \(9.9/110 = 9.0\) percent. Investigating all 110 is now feasible, and 9.9 of the 10 attempts are seen. A hundredfold reduction in false-positive rate bought a hundredfold increase in precision, while the recall did not change at all.
(d) With two independent signals both required, the true positive rate becomes \(0.99^2 = 0.9801\), giving 9.8 detected attempts, and the false-positive rate becomes \((10^{-3})^2 = 10^{-6}\), giving 10 false alerts per day. Precision is \(9.8/19.8 = 49.5\) percent. Recall fell by 1 percent and precision rose by a factor of 500. That exchange rate is what makes correlation across independent signals the highest-value move in detection engineering, and the word independent is doing the same work it does in defense in depth. Two signals derived from the same log source fail together and give none of this benefit.
(e) Effort should go into precision and into correlation, not into recall. A detector with 99 percent recall and a 0.1 percent false-positive rate is operationally useless at this base rate, while the same detector combined with one independent corroborating signal is excellent. This also explains why detections anchored on rare, high-signal events (a service account authenticating from a new country, a credential used from two continents within an hour, a build job reading a secret it has never read) outperform volumetric detections. They change the base rate rather than the classifier.
(a) Prove that if \(\mathcal{M}_1\) is \(\varepsilon_1\)-DP and \(\mathcal{M}_2\) is \(\varepsilon_2\)-DP, then releasing both outputs is \((\varepsilon_1+\varepsilon_2)\)-DP, where \(\mathcal{M}_2\) may depend on \(\mathcal{M}_1\)'s output. (b) Let an adversary run a membership-inference test against an \((\varepsilon,\delta)\)-DP mechanism, predicting "in" when the output lands in a set \(S\). Define the advantage as \(\mathrm{Adv} = \mathrm{TPR} - \mathrm{FPR}\) and derive an upper bound in terms of \(\varepsilon\) and \(\delta\). (c) Evaluate the bound at \(\varepsilon = 1\), \(\varepsilon = 4\), and \(\varepsilon = 8\), and comment on what \(\varepsilon = 8\) actually promises.
Solution. (a) Fix neighbouring datasets \(D \sim D'\) and an output pair \((z_1, z_2)\). The joint density factors as \(p(z_1,z_2\mid D) = p_1(z_1 \mid D)\, p_2(z_2 \mid D, z_1)\). The ratio is
$$ \frac{p(z_1,z_2 \mid D)}{p(z_1,z_2 \mid D')} = \frac{p_1(z_1 \mid D)}{p_1(z_1 \mid D')} \cdot \frac{p_2(z_2 \mid D, z_1)}{p_2(z_2 \mid D', z_1)} \le e^{\varepsilon_1} \cdot e^{\varepsilon_2} = e^{\varepsilon_1 + \varepsilon_2}, $$where the first factor is bounded because \(\mathcal{M}_1\) is \(\varepsilon_1\)-DP and the second because, for each fixed \(z_1\), the mechanism \(\mathcal{M}_2(\cdot, z_1)\) is \(\varepsilon_2\)-DP on its own. Integrating the pointwise bound over any measurable set gives the definition for the pair. The privacy loss random variable is additive, which is the intuition. Each release adds at most \(\varepsilon_i\) of evidence, and evidence adds in log-odds.
(b) Let \(D_{\text{in}}\) contain the target record and \(D_{\text{out}}\) be the same dataset without it, so they are neighbours. Then \(\mathrm{TPR} = \P[\mathcal{M}(D_{\text{in}}) \in S]\) and \(\mathrm{FPR} = \P[\mathcal{M}(D_{\text{out}}) \in S]\). The definition applied to the set \(S\) gives directly \(\mathrm{TPR} \le e^{\varepsilon}\,\mathrm{FPR} + \delta\). Therefore
$$ \mathrm{Adv} = \mathrm{TPR} - \mathrm{FPR} \le (e^{\varepsilon} - 1)\,\mathrm{FPR} + \delta \le e^{\varepsilon} - 1 + \delta, $$using \(\mathrm{FPR} \le 1\). The bound is tight in the sense that a mechanism can be built to achieve it, and it is unimprovable without further assumptions because it uses only the definition.
(c) At \(\varepsilon = 1\), \(e^1 - 1 = 1.718\), which exceeds 1 and so is vacuous as a bound on an advantage that cannot exceed 1. The useful reading at small \(\varepsilon\) is the first-order one, \(\mathrm{Adv} \lesssim \varepsilon\) for \(\varepsilon \ll 1\), so \(\varepsilon = 0.1\) caps the advantage near 0.105. At \(\varepsilon = 4\) the bound is \(e^4 - 1 = 53.6\), and at \(\varepsilon = 8\) it is \(e^8 - 1 = 2980\). Both are vacuous. The honest statement is that \(\varepsilon = 8\), a value that appears in real deployments because smaller values cost too much accuracy, provides no worst-case guarantee against membership inference at all. What it provides is a mechanism whose empirical leakage, measured by running the strongest known attacks, is usually far below the worst case. That gap between the formal guarantee and the measured leakage is the honest reason practitioners still deploy large-\(\varepsilon\) DP, and it is also the reason such deployments must be accompanied by empirical privacy auditing rather than by citing the \(\varepsilon\) alone.
How it is done in practice
Logging, detection, and the questions logs must answer
Logging for security is not logging for debugging, and the difference is the design goal. Debug logs answer "what did this request do", while security logs answer "who did what, to which object, when, from where, and was it allowed", for every security-relevant event, in a form that survives the compromise of the system that produced it. The concrete requirements follow from that sentence. Authentication events (success, failure, MFA challenge, factor enrolment, password change), authorization denials, privilege changes, access to sensitive objects, configuration and policy changes, key use, and administrative actions must all be recorded with a stable actor identifier, the target object, the source, the decision, and a trustworthy timestamp. Logs go to an append-only store the producing system cannot rewrite, because the first thing an intruder with privileges does is clean up. Retention has to exceed realistic detection latency, which is measured in months rather than days. And secrets, tokens, passwords, full card numbers, session identifiers, must never be logged, because the log pipeline is broadly readable by design and turns into the softest copy of the credential store.
Detection design follows Problem 7: precision beats recall, and correlated independent signals beat either. The detections that earn their keep are the ones anchored on events that are rare when nothing is wrong, such as a service account authenticating interactively, a credential used from two distant locations within an implausible interval, a build job reading a secret it has never read before, a database returning far more rows than that endpoint ever returns, an outbound connection from a host that has never made one. Canary tokens, credentials and files that no legitimate process should ever touch, are the extreme version of this idea. The base rate is zero, so any use is an incident.
Incident response
The standard phases are preparation, detection and analysis, containment, eradication, recovery, and post-incident review, and the interesting content is in what each requires that teams typically lack. Preparation is the phase that determines everything else. It requires named roles with a single decision-maker, an out-of-band communication channel that does not depend on the possibly-compromised infrastructure, current asset and data inventories, retained logs, and rehearsal, since a plan that has never been executed is a document rather than a capability. Detection and analysis establishes scope and timeline, and its output is an evidence-backed narrative rather than a hypothesis. Containment is where the difficult trade lives. Isolating immediately stops the damage and destroys the volatile evidence needed to determine scope, so short-term containment (network isolation while leaving the system running) usually precedes long-term containment. Eradication removes the access, all of it, which means every credential the intruder could have touched, not merely the one known to have been used, and rebuilding rather than cleaning, because trust in a compromised host is not recoverable by inspection. Recovery restores service with heightened monitoring, since re-entry attempts are common. Review asks what made the incident possible and what made it slow to detect, and the discipline that makes reviews useful is blamelessness. The goal is a list of system changes, not a list of people.
Disclosure and CVEs
Coordinated disclosure is the norm. A reporter notifies the vendor privately, the vendor investigates and fixes, and both publish after a fix is available or after an agreed deadline, commonly 90 days, with shorter timelines when a vulnerability is already being exploited. The deadline exists because vendor-controlled indefinite embargo produced fixes that never shipped. Full disclosure without notice is now rare, and silent patching, fixing without an advisory, is actively harmful because downstream users cannot tell which release matters. A CVE identifier gives a stable global name to a vulnerability, and CVSS gives a severity score whose base metrics describe the vulnerability in isolation. The score is widely misused as a prioritization order, when the input that actually matters is whether the affected code path is reachable in your deployment and whether exploitation is observed in the wild. On the receiving side, the practical requirements for any project are a documented reporting channel that a stranger can find in under a minute, an acknowledgement service-level objective, a triage process that can produce an affected-versions statement, and the ability to build and ship a patched release quickly, which is the capability that advisories actually test.
The secure development lifecycle in practice
The activities that survive contact with a real delivery schedule are the ones automated into the pipeline, plus a small number of deliberately manual ones. The automated ones are dependency advisory scanning, secret scanning on every commit and in history, semantic pattern rules encoding the team's own invariants, sanitizer-enabled tests, continuous fuzzing of parsers, infrastructure-as-code policy checks (no public buckets, no wildcard IAM, no privileged containers), and artifact signing with provenance. Manual and worth the time are threat modeling at design time for anything that introduces a new trust boundary, review of authorization logic specifically, since it is the class tools find worst, and periodic red-teaming of the assumptions rather than the code. The organizational pattern that works is a small security team that builds paved roads (a library that does authorization correctly, a service template with hardened defaults, a build pipeline that signs) rather than reviewing everything, because reviewing everything does not scale and the paved road makes the secure path the easy path, which is Saltzer and Schroeder's eighth principle applied to the organization instead of to the user interface.
The current research frontier
Hardware-enforced memory safety. The most consequential open line is CHERI, developed at Cambridge with SRI, which replaces integer pointers with hardware capabilities carrying bounds and permissions, enforced by the processor. Arm's Morello prototype board made it testable on real silicon, and the open question is deployment economics rather than feasibility. Arm's Memory Tagging Extension is the shipped, cheaper cousin. Allocations and pointers carry four-bit tags that the hardware compares on each access, giving probabilistic detection of both spatial and temporal errors at a cost low enough for production use, which is why Android has been enabling it. The research question is how to get deterministic rather than probabilistic guarantees at that cost.
Rust in systems that were C. The Linux kernel accepted Rust for new drivers in 2022, Windows and Android ship Rust components, and the interesting problems have moved from "can it work" to the boundary, how to verify unsafe blocks, how to model existing C APIs soundly, and how to reason about the safe/unsafe interface. Verification tools that interpret Rust's intermediate representation to detect undefined behavior, and formal work on the semantics of the unsafe subset, are where the effort is. A parallel line asks what can be automated in translation, with several groups and companies attempting machine-assisted C-to-Rust conversion. The honest status is that mechanical translation produces unsafe Rust that is no safer than the original, and making it idiomatic is the unsolved part.
Microarchitectural security. The post-2018 line has not slowed. Work at ETH Zurich has repeatedly found that mitigations deployed against one variant leave adjacent paths open, including return-instruction mispredictions on cores where returns were assumed safe. Groups at Graz continue to produce new data-sampling and cache-attack variants, and the Rowhammer line, from the original 2014 characterization through work at ETH Zurich defeating the target-row-refresh mitigations, shows that DRAM disturbance remains an unsolved reliability-and-security problem. The forward-looking question is whether formal, checkable statements about what a microarchitecture may leak, contracts between hardware and software of the sort several academic groups are proposing, can replace the current cycle of attack and patch.
Confidential computing. Hardware enclaves and encrypted VMs aim to remove the cloud operator from the TCB. The first generation was repeatedly broken by side channels, notably by transient-execution attacks from groups in Leuven, Graz, Michigan and Adelaide. The second generation, VM-level isolation with memory encryption and integrity, plus remote attestation, is deployed and now underpins confidential inference offerings. The open questions are attestation supply chains, side channels that memory encryption does not address, and whether the trust model (trusting the silicon vendor instead of the cloud operator) is an improvement for a given threat model.
Security of ML systems. This is the least settled area on the page. Certified defenses such as randomized smoothing give provable robustness radii but at accuracy and compute costs that keep them out of production. Machine unlearning, removing a training example's influence without full retraining, is an active area with a hard evaluation problem, since proving something was forgotten is harder than forgetting it. Watermarking of model outputs is deployed in some form and contested in the literature, with results showing that determined adversaries can remove or forge marks. And on the agent side, the field is where web security was in about 2003. The vulnerability classes have names, the structural fix for the central one does not exist, and the practical work is architectural containment. Several groups have proposed designs in which a planner model never sees untrusted content while a separate quarantined model processes it and returns only typed values, which is privilege separation rediscovered, and which is probably the direction that will matter.
Supply chain. Reproducible builds have moved from a niche project to a stated goal for major distributions, and artifact signing with transparency logs is deployed at scale. The frontier is the semantic gap between "this artifact came from this source" and "this source is not malicious", which attestation cannot close. Work on build provenance frameworks, on automated review of dependency updates, and on detecting the specific shape of the 2024 XZ compromise (build-script logic and binary test fixtures that differ from what review sees) is where the effort is going.
Open source to read
Reading real security-relevant code is worth more than reading about it. Each of these is maintained, widely deployed, and has a natural entry point.
-
google/oss-fuzz is the continuous fuzzing
infrastructure that runs targets for hundreds of open-source projects. Open
docs/getting-started/new_project_guide.mdfirst. It is the concrete recipe for what a fuzz target, a build script and a seed corpus have to look like, and reading the per-project directories afterwards shows how real projects structure targets for parsers, codecs and protocol stacks. -
google/sanitizers holds the documentation
and tooling for the sanitizer family. Start at
README.md, which indexes the wiki pages. The AddressSanitizer algorithm page is the one to read closely, because understanding the shadow-memory encoding (one shadow byte per eight application bytes, redzones, the quarantine) explains both what ASan can detect and what it structurally cannot. -
AFLplusplus/AFLplusplus is the
actively maintained descendant of AFL and the best place to see how a coverage-guided fuzzer
actually works. Open
src/afl-fuzz-one.c. It is the mutation and scheduling core, and the deterministic-then-havoc stage structure makes the evolutionary loop concrete in a way no description does. -
OWASP/CheatSheetSeries is the
reference to check before writing any defensive control. Open
cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.mdfirst for the contextual-encoding rules stated precisely, then the SQL injection, session management, and password storage sheets. It is opinionated, current, and short enough to actually read. -
rustsec/advisory-db is the Rust
ecosystem's security advisory database. Start with
README.mdfor the advisory format, then browsecrates/. Reading a few dozen advisories is the fastest way to learn what actually goes wrong in memory-safe code, which is mostly unsoundunsafe, logic errors in cryptographic code, and denial of service through unbounded allocation. -
sigstore/cosign implements artifact signing
with keyless certificates and a transparency log. After
README.md, read the verification path undercmd/cosign/cli/verify/. The interesting content is what a verification policy has to specify (which identity, which issuer, which log) for a signature to mean anything, which is the part teams get wrong. -
google/tink is a cryptographic library designed
so misuse is difficult. There are no raw primitives, keys carry their type and intended use, and
rotation is a first-class concept. Open
docs/PRIMITIVES.mdto see the API-design argument, which is a good model for any security-sensitive library. Pair it with cryptography for what the primitives do. -
cure53/DOMPurify is the HTML sanitizer to
use when user-supplied markup is a real requirement. Read the single implementation file under
src/and then the test fixtures. The fixture corpus is an education in how many ways a parser can be persuaded to produce script from markup that looks inert, and it is the best argument available against hand-written sanitizers. -
semgrep/semgrep performs semantic pattern
matching over the syntax tree, which is the practical tool for enforcing a team's own security
invariants. Start with
README.mdand the rule-writing documentation, then write one rule against your own codebase. The exercise of encoding "no query built by concatenation" as a matchable pattern is what teaches where the invariant is actually ambiguous.
Common misconceptions
"We use HTTPS, so the data is secure." TLS protects a channel between two endpoints. It says nothing about authorization at the far end, nothing about what the application does with the data, nothing about storage, and nothing about an attacker who is one of the endpoints. Most breaches involve no network interception whatsoever. They involve an endpoint that answered a well-formed request it should have refused.
"Input validation prevents injection." Validation is a correctness control and a useful defense in depth, and it is not the fix. Whether a value is dangerous depends on the interpreter it eventually reaches, and a single value may reach several. The fix is separation at the sink. Use parameterized queries for SQL, argument vectors for processes, contextual encoding for markup, and typed sinks for the DOM. Filtering a value at the boundary optimizes for the interpreter the author happened to imagine.
"Memory-safe languages solve security." They eliminate roughly 70 percent of critical vulnerabilities in large C and C++ codebases, which is the largest single improvement available, and they do nothing for broken access control, injection, logic errors, side channels, supply chain, or prompt injection. The remaining classes are the majority of this page.
"Hashing passwords with SHA-256 and a salt is fine." The salt is necessary and insufficient. SHA-256 is fast by design and parallelizes onto GPUs, so the attacker's cost per guess is about \(10^{-10}\) seconds. Problem 2 works the arithmetic and finds a factor of \(10^{7}\) between SHA-256 and bcrypt at cost 12. Use Argon2id or bcrypt or scrypt, and store the parameters with the hash so they can be raised.
"ASLR and DEP make exploitation infeasible." They raise the cost and change the technique. DEP converted code injection into code reuse, and ASLR converted code reuse into "first obtain an information leak". Both remain worth deploying, and neither reduces the number of bugs. The practical corollary is that a memory-disclosure bug is a critical finding, because it is the half of the exploit that the mitigations were supposed to supply.
"A container isolates untrusted code." A container is a process with namespaced views and a reduced capability set, sharing one kernel. The kernel's full syscall surface is reachable, so a kernel privilege-escalation bug is an escape. For genuinely untrusted workloads the boundary needs to be a virtual machine or a userspace kernel, and in every case the container should still run unprivileged, non-root, read-only, and seccomp-filtered.
"Timing differences of a few nanoseconds cannot be exploited over a network." The required sample count grows only as the square of the noise-to-signal ratio, and samples are cheap and parallel. Problem 4 works the numbers with a measured 0.311 ns per byte signal. The attack is hopeless from across the internet and takes two minutes from a co-located process. Since the fix costs about ten nanoseconds per comparison, arguing about the exponent is not worth anyone's time.
"Prompt injection will be fixed by a better filter or a better model." The vulnerability is that instructions and data share one channel with no privileged marking, and the model's capability to follow instructions in text is the product. A filter is a classifier that an adversary optimizes against, and a boundary that holds 99 percent of the time is not a boundary. The engineering response is containment. Scope the tools, separate the privileges, require human confirmation for consequential actions, validate outputs at every sink, and control egress.
"We passed a penetration test, so we are secure." A test establishes that a particular team, in a particular time box, with a particular scope, found a particular set of issues. It is a sample, not a proof, and its most valuable output is usually the classes of issue found rather than the individual findings, because those indicate which systematic control is missing.
Self-check
References
- Anderson, R. Security Engineering: A Guide to Building Dependable Distributed Systems, 3rd edition. Wiley, 2020. The single best book on the subject. The chapters on psychology, economics and assurance are what distinguish it. Author's page
- Bishop, M. Computer Security: Art and Science, 2nd edition. Addison-Wesley, 2018. The formal treatment, covering policy models, Bell-LaPadula and Biba, assurance, and the mathematics of access control.
- Dwork, C. and Roth, A. The Algorithmic Foundations of Differential Privacy. Foundations and Trends in Theoretical Computer Science, 2014. The definitions, mechanisms and composition theorems used above. doi:10.1561/0400000042
- Saltzer, J. H. and Schroeder, M. D. "The Protection of Information in Computer Systems." Proceedings of the IEEE 63(9), 1975. The eight design principles. doi:10.1109/PROC.1975.9939
- Hardy, N. "The Confused Deputy (or why capabilities might have been invented)." ACM SIGOPS Operating Systems Review 22(4), 1988.
- Thompson, K. "Reflections on Trusting Trust." Communications of the ACM 27(8), 1984. The reason reproducible builds matter. doi:10.1145/358198.358210
- Szekeres, L., Payer, M., Wei, T. and Song, D. "SoK: Eternal War in Memory." IEEE Symposium on Security and Privacy, 2013. The two-step model of memory corruption and a systematic evaluation of every mitigation.
- Shacham, H. "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)." ACM CCS, 2007. Return-oriented programming.
- Shacham, H., Page, M., Pfaff, B., Goh, E.-J., Modadugu, N. and Boneh, D. "On the Effectiveness of Address-Space Randomization." ACM CCS, 2004. The entropy analysis behind Problem 1.
- Abadi, M., Budiu, M., Erlingsson, U. and Ligatti, J. "Control-Flow Integrity." ACM CCS, 2005.
- Hu, H., Shinde, S., Adrian, S., Chua, Z. L., Saxena, P. and Liang, Z. "Data-Oriented Programming: On the Expressiveness of Non-Control Data Attacks." IEEE Symposium on Security and Privacy, 2016. Why control-flow defenses are not sufficient.
- Serebryany, K., Bruening, D., Potapenko, A. and Vyukov, D. "AddressSanitizer: A Fast Address Sanity Checker." USENIX ATC, 2012. The shadow-memory design whose output appears above.
- Kocher, P. "Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems." CRYPTO, 1996. The paper that started side-channel analysis of software.
- Osvik, D. A., Shamir, A. and Tromer, E. "Cache Attacks and Countermeasures: the Case of AES." CT-RSA, 2006. Prime+Probe against table-driven ciphers.
- Yarom, Y. and Falkner, K. "FLUSH+RELOAD: A High Resolution, Low Noise, L3 Cache Side-Channel Attack." USENIX Security, 2014.
- Kocher, P., Horn, J., Fogh, A., Genkin, D., Gruss, D., Haas, W., Hamburg, M., Lipp, M., Mangard, S., Prescher, T., Schwarz, M. and Yarom, Y. "Spectre Attacks: Exploiting Speculative Execution." IEEE Symposium on Security and Privacy, 2019. arXiv:1801.01203
- Lipp, M., Schwarz, M., Gruss, D., Prescher, T., Haas, W., Fogh, A., Horn, J., Mangard, S., Kocher, P., Genkin, D., Yarom, Y. and Hamburg, M. "Meltdown: Reading Kernel Memory from User Space." USENIX Security, 2018. arXiv:1801.01207
- Kim, Y., Daly, R., Kim, J., Fallin, C., Lee, J. H., Lee, D., Wilkerson, C., Lai, K. and Mutlu, O. "Flipping Bits in Memory Without Accessing Them: An Experimental Study of DRAM Disturbance Errors." ISCA, 2014. Rowhammer.
- Provos, N. and Mazières, D. "A Future-Adaptable Password Scheme." USENIX Annual Technical Conference, 1999. bcrypt and the cost-factor argument.
- Percival, C. "Stronger Key Derivation via Sequential Memory-Hard Functions." BSDCan, 2009. scrypt and the area-time cost model. Paper
- Biryukov, A., Dinu, D. and Khovratovich, D. "Argon2: New Generation of Memory-Hard Functions for Password Hashing and Other Applications." IEEE EuroS&P, 2016. Reference implementation
- Hardt, D. (ed.) "The OAuth 2.0 Authorization Framework." RFC 6749, IETF, 2012, with the current security best-practice guidance in RFC 9700. doi:10.17487/RFC6749
- Sheffer, Y., Hardt, D. and Jones, M. "JSON Web Token Best Current Practices." RFC 8725, IETF, 2020. The catalogue of JWT failure modes. doi:10.17487/RFC8725
- Laurie, B., Langley, A. and Kasper, E. "Certificate Transparency." RFC 6962, IETF, 2013. Detection where prevention is unavailable. doi:10.17487/RFC6962
- Szegedy, C., Zaremba, W., Sutskever, I., Bruna, J., Erhan, D., Goodfellow, I. and Fergus, R. "Intriguing Properties of Neural Networks." ICLR, 2014. arXiv:1312.6199
- Goodfellow, I., Shlens, J. and Szegedy, C. "Explaining and Harnessing Adversarial Examples." ICLR, 2015. The linearity argument and FGSM. arXiv:1412.6572
- Madry, A., Makelov, A., Schmidt, L., Tsipras, D. and Vladu, A. "Towards Deep Learning Models Resistant to Adversarial Attacks." ICLR, 2018. The saddle-point formulation and PGD training. arXiv:1706.06083
- Carlini, N. and Wagner, D. "Towards Evaluating the Robustness of Neural Networks." IEEE Symposium on Security and Privacy, 2017. arXiv:1608.04644
- Shokri, R., Stronati, M., Song, C. and Shmatikov, V. "Membership Inference Attacks Against Machine Learning Models." IEEE Symposium on Security and Privacy, 2017. arXiv:1610.05820
- Carlini, N., Tramer, F., Wallace, E., Jagielski, M., Herbert-Voss, A., Lee, K., Roberts, A., Brown, T., Song, D., Erlingsson, U., Oprea, A. and Raffel, C. "Extracting Training Data from Large Language Models." USENIX Security, 2021. arXiv:2012.07805
- Tramèr, F., Zhang, F., Juels, A., Reiter, M. K. and Ristenpart, T. "Stealing Machine Learning Models via Prediction APIs." USENIX Security, 2016. arXiv:1609.02943
- Greshake, K., Abdelnabi, S., Mishra, S., Endres, C., Holz, T. and Fritz, M. "Not What You've Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection." ACM AISec, 2023. arXiv:2302.12173
- Wei, A., Haghtalab, N. and Steinhardt, J. "Jailbroken: How Does LLM Safety Training Fail?" NeurIPS, 2023. Competing objectives and mismatched generalization. arXiv:2307.02483
- Zou, A., Wang, Z., Kolter, J. Z. and Fredrikson, M. "Universal and Transferable Adversarial Attacks on Aligned Language Models." 2023. arXiv:2307.15043
- Abadi, M., Chu, A., Goodfellow, I., McMahan, H. B., Mironov, I., Talwar, K. and Zhang, L. "Deep Learning with Differential Privacy." ACM CCS, 2016. DP-SGD and the moments accountant. arXiv:1607.00133
- OWASP. Top 10 Web Application Security Risks (2021) and Top 10 for LLM Applications. The consensus lists this page's web and LLM sections are organized against. owasp.org/Top10
readelf rather than in the build file, and write new
parsers in a safe language. Everything else on this page is
arithmetic a defender should be able to do on demand, such as the expected
guesses against \(n\) bits of entropy, the factor of \(10^{7}\)
between a fast hash and a tuned password hash, the
\((z\sigma/\Delta)^2\) samples a timing attack needs, the
\(\varepsilon\|w\|_1\) logit shift available inside an
\(\ell_\infty\) ball, and the composed \(\varepsilon\) after 200
queries. Do that arithmetic before choosing a control, and the
choices stop being matters of taste.