Author: UnboundCompute

  • How NTLM Relay Works and Why a Portable Authentication Breaks Active Directory

    How NTLM Relay Works and Why a Portable Authentication Breaks Active Directory

    An ntlm relay attack works because NTLM proves you know a password to one server but never ties that proof to the server you meant to reach. A machine authenticates to a host the attacker controls, and the attacker forwards that authentication, byte for byte, to a completely different server, where it lands as a valid login from the victim. Nothing is cracked. No password crosses the wire in either direction. The attacker is a relay sitting in the middle, taking an authentication that was meant for them and spending it somewhere it was never meant to go. This post walks the whole mechanism one step at a time: how the three message NTLM handshake actually works, why the proof it produces is portable to the wrong destination, how an attacker gets a victim to authenticate in the first place, where they relay it, and how each defense closes a different part of the gap.

    What an ntlm relay actually is

    NTLM is the older challenge response authentication protocol that Windows still falls back to across an Active Directory network, especially when a client reaches a server by IP address or by a name Kerberos cannot resolve to a service principal. It is a question and answer ritual. The server asks a hard question only someone who knows the password could answer, and the client answers it without ever stating the password. That property, no password on the wire, is genuinely good. The problem is everything the protocol forgets to check around it.

    The core flaw is a missing binding. When a client proves it knows a secret, that proof does not say which server it was meant for. It does not name the channel it traveled over. It is a free floating token of authentication that any server will accept as long as the math checks out. So a man in the middle who receives one valid authentication can carry it, unchanged, to a different server and be treated as the victim there. That relayed identity is the whole attack, and on a domain it routinely escalates from one captured login to full control of Active Directory.

    It helps to be precise about what this attack is not, because the name invites confusion. It is not a pass the hash attack, where the attacker already holds a stolen password hash and replays it. In a relay the attacker never possesses the hash at all; they only move a one time signed answer between two parties. It is not a brute force or a crack, because nothing offline happens to the response. And it is not a Kerberos attack, because Kerberos tickets are scoped to a named service and resist this kind of redirection by design. NTLM relay is its own thing: a live, in the moment forwarding of a genuine authentication to an unintended destination, exploiting a gap that lives in the protocol rather than in any one machine’s configuration.

    The NTLM handshake, message by message

    NTLM authenticates a client to a server in three messages. The Windows internals and most tooling call them Type 1, Type 2, and Type 3, but they map cleanly onto NEGOTIATE, CHALLENGE, and AUTHENTICATE. Picture a workstation in the Acme domain, acme.local, connecting to a file server.

    NEGOTIATE, the opening offer

    The client opens by sending a NEGOTIATE message. This is the Type 1 packet. It announces that the client wants to authenticate with NTLM and lists the options it supports, things like which NTLM version and which cryptographic flags it can handle. It carries no proof of identity yet. It is the client saying, here is how I would like to do this, what is your challenge.

    CHALLENGE, the hard question

    The server answers with a CHALLENGE message, the Type 2 packet. The important content is a randomly generated eight byte number called the server challenge. The server makes up a fresh random value every time and sends it down. The point of the randomness is that the answer to last time’s challenge is useless this time, which is meant to stop a simple replay of a recorded response. The server keeps a copy of the challenge it just sent so it can check the answer.

    AUTHENTICATE, the proof

    Now the client proves itself. It takes the server challenge and combines it with the cryptographic hash derived from the user’s password, which the client already holds, and with NTLMv2 it mixes in its own client challenge and a timestamp as well. It runs that combination through a keyed hash, HMAC-MD5 with the password derived key, and the output is the challenge response. That response goes back inside the AUTHENTICATE message, the Type 3 packet, alongside the username and domain.

    Here is the elegant part and the dangerous part at once. The password never travels. The client demonstrates that it holds the password hash by signing the server’s specific random challenge with it. The server, which can compute the same answer because it can ask a domain controller to validate the response against the stored hash, checks whether the client’s answer matches. If it does, the client has proven knowledge of the secret without ever transmitting the secret. Authentication succeeds.

    Why the proof is portable to the wrong server

    Walk back through what the client actually signed. It signed the server’s eight byte challenge. It did not sign the hostname it was connecting to. It did not sign the IP address, the service, or the network channel underneath. By default the AUTHENTICATE message contains a proof of password knowledge that is bound to a random number and to nothing else about the destination.

    So put an attacker in the middle. The victim machine starts authenticating to a server the attacker controls, call it the rogue endpoint. The attacker does not answer as a normal server. Instead the attacker opens its own NTLM connection to a real target server somewhere else on the network, a domain controller, say. The real target sends back its own CHALLENGE. The attacker takes that challenge and passes it straight back to the victim as if it were the rogue endpoint’s own challenge. The victim dutifully signs it with its password hash and returns the AUTHENTICATE message. The attacker forwards that AUTHENTICATE message, verbatim, to the real target. The target validates it, sees a correct answer to the exact challenge it issued, and grants the attacker a fully authenticated session in the victim’s name.

    The attacker never learns the password and never cracks a hash. They are a courier, carrying one server’s question to the victim and carrying the victim’s answer to a different server, and both ends believe they are talking to who they expected.

    This is why it is called a relay rather than a crack. The authentication is genuine. It is simply spent against a server the victim never intended to reach. Everything downstream is built on that single substitution.

    One detail matters for understanding the defenses later. The reason the substitution succeeds is that the victim’s signed response is computed over the challenge the attacker handed it, and that challenge is the real target’s challenge. The attacker is not generating challenges of their own; they are a conduit passing the target’s question through to the victim. That is what keeps the math consistent at the far end. It also explains why any defense that gives the victim a way to notice it is signing for the wrong server, or that ties the response to something the attacker cannot also forward, breaks the relay cleanly. The attacker controls the routing but not the content, and the content is where the cure lives.

    Step one for the attacker, getting an authentication to relay

    A relay needs an inbound authentication to forward. The attacker has two broad ways to make one appear: wait for it by poisoning name resolution, or force it by coercing a machine to authenticate on demand.

    Poisoning name resolution

    Windows networks are chatty and trusting about names. When a machine cannot resolve a name through DNS, it falls back to broadcast protocols that ask the whole local segment, who is this. LLMNR, NBT-NS, and mDNS are exactly that fallback. They are unauthenticated broadcasts, so any machine on the segment can answer. A user fat fingers a share name, or an application looks up a host that no longer exists, and the broadcast goes out asking the network to identify it.

    The tool Responder listens for those broadcasts and answers all of them, claiming to be whatever name was requested. The victim believes it found the host, connects, and begins authenticating with NTLM to the attacker’s machine. That is the inbound authentication the relay needs, harvested passively just by answering questions nobody was authorized to answer. The attacker does not have to provoke anything; on a busy network these mistyped names and stale lookups happen on their own throughout the day, and Responder simply scoops up whatever wanders by. The quality of the catch is a matter of patience and luck, which is why poisoning is often the opening move rather than the finishing one.

    Coercing authentication on demand

    Waiting is unreliable, so attackers prefer to compel a specific machine, ideally a high value one like a domain controller, to authenticate to them whenever they like. Several Windows protocols can be tricked into making an outbound authenticated connection to an attacker chosen host.

    The best known is PetitPotam, which abuses the Encrypting File System Remote Protocol, MS-EFSRPC. Discovered by Gilles Lionel, it is tracked as CVE-2021-36942, a Windows LSA spoofing vulnerability that Microsoft addressed in its August 2021 updates. An attacker calls an MS-EFSRPC method such as EfsRpcOpenFileRaw against a target and supplies an attacker controlled path. The target, including a domain controller, then reaches out and authenticates to that path over NTLM using its powerful machine account. The original PetitPotam variant could be triggered without authentication, which is what made it so sharp.

    It is one of a family. The PrinterBug, exploited by the SpoolSample technique, abuses the Print System Remote Protocol to make a machine’s spooler authenticate to an attacker host. PrivExchange abused a Microsoft Exchange feature to make the Exchange server authenticate with its highly privileged account. Different doors, same result: a chosen, often privileged machine account hands the attacker an NTLM authentication ready to relay.

    Step two, relaying it with ntlmrelayx

    Capturing the authentication is only half. The other half is forwarding it to a useful target before it expires, and the standard tool for that is ntlmrelayx, an example script in the Impacket toolkit. It is often paired with Responder or a coercion trigger: one component produces the inbound NTLM authentication, ntlmrelayx forwards it to a target server and then does something with the authenticated session. Where it points decides the outcome.

    Relay to LDAP, granting RBCD

    If the relay target is a domain controller’s LDAP service and the relayed identity has the rights, ntlmrelayx can write to Active Directory as the victim. A favored move is configuring resource based constrained delegation, RBCD. The attacker writes the msDS-AllowedToActOnBehalfOfOtherIdentity attribute on a victim computer object so that an account the attacker controls is allowed to impersonate any user to that computer. With RBCD in place the attacker can later request Kerberos tickets impersonating a domain admin to the victim machine and take it over. The relay grants the delegation; the delegation grants the takeover.

    Relay to SMB, reading and running

    Relayed to the SMB service on a target where the victim is a local administrator, the authenticated session lets the attacker act as an admin on that host: dump the local secrets, read the SAM, or execute commands. This is the classic relay outcome and the reason SMB signing exists.

    Relay to AD CS HTTP enrollment, the ESC8 path

    The most damaging target is Active Directory Certificate Services. Many AD CS deployments expose a web enrollment endpoint over plain HTTP that accepts NTLM authentication. The SpecterOps research that catalogued AD CS abuses, the paper titled Certified Pre-Owned, named this relay scenario ESC8. The attacker coerces a domain controller with PetitPotam, then relays the DC’s machine account authentication with ntlmrelayx to that AD CS web enrollment endpoint and requests a certificate for the domain controller. AD CS issues one. Now the attacker holds a certificate that authenticates as the domain controller. They use it to request a Kerberos ticket as the DC, and from there they can perform a directory replication and dump every credential in the domain. One coerced authentication becomes full domain compromise.

    What makes this chain so potent is how little the attacker needs to start it and how durable the prize is. The PetitPotam trigger could fire without any prior foothold in its original form, so an unauthenticated attacker on the network could begin the whole sequence. And a certificate is not a session that times out in minutes; it is a credential the attacker can hold and reuse for as long as it remains valid, surviving password resets of the account it impersonates. That combination, a low cost trigger feeding a long lived credential for the most privileged account in the domain, is why the PetitPotam to ESC8 path drew so much attention and so many emergency patches. It compresses the distance from outsider to domain owner into a handful of network calls, none of which involve guessing or cracking a single secret.

    That escalation from a single relayed login to total control is a textbook case of privilege escalation: each step trades a small foothold for a larger one until the attacker holds the keys to the whole directory.

    Defending against NTLM relay

    Each defense closes a specific part of the gap. None of them alone is the whole answer, which is why they are usually layered.

    Signing, so the relay cannot stay silent in the middle

    Message signing binds the authenticated session to a key both legitimate parties share, so a man in the middle who merely forwards packets cannot tamper with or sustain the session. SMB signing, when required rather than merely offered, defeats SMB relay. The equivalent for the directory is LDAP signing, which protects relayed LDAP connections. Requiring signing turns a relayed session into a session the relay cannot actually use.

    Channel binding and Extended Protection for Authentication

    Signing still leaves protocols that ride inside TLS, like LDAPS and the AD CS web endpoint. The fix there is channel binding, delivered as Extended Protection for Authentication, EPA. Channel binding ties the NTLM authentication to the specific TLS channel it was sent over. When the attacker relays the authentication to a target over a different TLS channel, the binding no longer matches and the target rejects it. That is precisely the protection that closes ESC8: enabling and requiring EPA on the Certificate Authority web enrollment and certificate enrollment web services makes the relayed authentication fail the channel check. LDAP channel binding does the same for LDAPS.

    Disabling NTLM and mitigating coercion

    The most thorough fix is to stop using NTLM at all and rely on Kerberos, which does bind tickets to the target service. Disabling NTLM where it is no longer needed removes the relayable authentication entirely, though it takes auditing to find every dependency first. Alongside that, blunt the coercion triggers: apply the patch for CVE-2021-36942 to mitigate PetitPotam, disable the Print Spooler service on domain controllers where it is not needed to shut the PrinterBug, and filter the RPC traffic the coercion protocols ride on. Removing the trigger means the attacker cannot summon an authentication to relay even where NTLM still exists.

    It is worth placing this attack against its neighbors. NTLM relay is a failure of authentication binding, not of authorization. The victim’s identity is genuine and the target’s permission check is working correctly; the flaw is that the genuine identity arrived at a server it never meant to authenticate to, a distinction the boundary between authentication and authorization makes precise. The relay corrupts the who, and the rightful permissions of that who do the rest.

    The assumption that breaks

    Strip away the tools and the protocols and one assumption is left holding the whole thing up. NTLM assumes that proving you know a secret to one server means you meant to authenticate to that server. The handshake is careful about the secret and careless about the destination. It binds the proof to a random challenge and to nothing about where the proof is headed, so the proof is portable. A man in the middle does not need to break the cryptography, defeat the hash, or learn the password. They only need to move a valid answer from the server that was meant to receive it to a server that was not, and the second server, checking only that the answer is mathematically correct, lets the victim in.

    The bug is not a weak cipher or a careless administrator. It is a missing link between an authentication and its intended target, an assumption that the proof and the destination are the same thing when in fact one travels and the other does not. That kind of flaw does not show up by scanning for a known bad signature. It shows up by asking what each component assumes about identity and why it still trusts a credential that arrived from somewhere it did not expect. That is the kind of question an autonomous researcher built to test assumptions is meant to ask. Require signing, bind authentication to its channel, retire NTLM where you can, and shut the coercion triggers that feed the relay. Learn more about that approach on our about page.

    Frequently asked questions

    What is an NTLM relay attack in plain terms?

    It is a man in the middle attack on Windows authentication. NTLM is a challenge response protocol where a client proves it knows a password by signing the server’s random challenge, without the password ever crossing the wire. The catch is that the signed proof is not bound to the server it was meant for, so an attacker who receives one authentication can forward it verbatim to a different server and be accepted as the victim there. The MS-NLMP specification documents the three message handshake the relay abuses.

    How does an attacker get a machine to authenticate to them?

    Two ways. Passively, the tool Responder answers broadcast name resolution requests over LLMNR, NBT-NS, and mDNS, so a victim looking for a host connects to the attacker and authenticates. Actively, the attacker coerces a chosen machine. PetitPotam abuses the MS-EFSRPC protocol to force a target, even a domain controller, to authenticate over NTLM, and it is tracked as CVE-2021-36942. The PrinterBug and PrivExchange achieve the same coercion through other protocols.

    What can an attacker do once the authentication is relayed?

    It depends on the target. Relayed to LDAP on a domain controller, the attacker can configure resource based constrained delegation to later impersonate an admin. Relayed to SMB where the victim is a local admin, they can run code or dump secrets. The most severe target is the AD CS web enrollment endpoint: the ESC8 attack documented in SpecterOps’ Certified Pre-Owned paper relays a coerced domain controller authentication to AD CS, obtains a certificate for the DC, and escalates to full domain compromise. The relay itself is usually performed with the ntlmrelayx tool.

    How do you defend against NTLM relay?

    Layer the controls. Require SMB signing and LDAP signing so a man in the middle cannot use the forwarded session. Enable Extended Protection for Authentication, which binds the authentication to its TLS channel and is what closes the AD CS ESC8 path. Disable NTLM where it is no longer needed so there is no relayable authentication, and apply the patch for PetitPotam plus disable the Print Spooler on domain controllers to remove the coercion triggers. The ntlmrelayx tool ships in the Impacket toolkit, which is useful for testing whether these defenses actually hold.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What Is a Hash Flooding Attack and Why It Stalls a Server With Bytes

    What Is a Hash Flooding Attack and Why It Stalls a Server With Bytes

    A hash flooding attack is a low bandwidth denial of service that turns a data structure your server relies on against itself. A hash table promises constant time lookups, but that promise only holds when keys scatter evenly across buckets. If an attacker knows the hash function, they can craft hundreds of keys that all land in the same bucket, collapsing the table into a single long chain. Average case O(1) becomes worst case O(n) per operation, and building the table from n such keys costs O(n^2). A few hundred kilobytes of carefully chosen form fields or JSON keys, parsed automatically by the framework before any of your code runs, can pin a CPU core for seconds. This post walks the mechanism one step at a time: how a hash table actually stores keys, how an attacker engineers the collisions, why request parsing amplifies the damage, the 2011 wave that hit every major web platform at once, and the keyed hashing fix that closed the door.

    How a hash table earns its O(1) reputation

    A hash table is the workhorse behind every dictionary, map, and associative array you have ever used. The idea is simple. You have some keys, say the names of form fields, and you want to find the value for any key fast. Instead of scanning a list, the table keeps an array of buckets and runs each key through a hash function, a small piece of math that turns a string of bytes into a number. That number, taken modulo the number of buckets, tells the table which bucket the key belongs in.

    When two keys land in the same bucket, that is a collision, and it is normal. Real hash functions cannot map an unlimited set of strings to a fixed array without overlaps. The standard way to handle a collision is chaining: each bucket holds a small linked list, and colliding keys are appended to it. To look up a key, the table hashes it to find the bucket, then walks that bucket’s chain comparing keys until it finds a match.

    The reason this is fast is entirely about distribution. If a good hash function spreads n keys roughly evenly across n buckets, every chain is one or two entries long. Finding a key means hashing once and comparing once or twice, regardless of how many keys are in the table. That is the constant time, O(1), behavior everyone counts on. Inserting n keys one after another costs O(n) total, because each insert is O(1). The whole edifice of fast lookups rests on that even spread.

    The catch is that O(1) is an average, not a guarantee. It assumes the keys arriving at the table are not chosen by someone who wants them to collide. Drop that assumption and the same data structure behaves very differently.

    How a hash flooding attack engineers collisions on purpose

    Now suppose every key you insert hashes to the exact same bucket. The table never spreads anything. One chain grows longer with every insert while every other bucket sits empty. Looking up a key now means walking a chain of length n, comparing against every key already there. A single lookup is O(n) instead of O(1).

    Inserting is worse, because insertion has to check whether the key is already present before adding it. When you insert the kth colliding key, the table walks the existing chain of length k minus one to confirm the key is new. So the first insert does zero comparisons, the second does one, the third does two, and the nth does n minus one. The total work is 0 plus 1 plus 2 and so on up to n minus 1, which is n times n minus one over two. That is the quadratic blowup: building a table from n colliding keys costs on the order of n^2 comparisons rather than n.

    The math is what makes the attack so cheap for the attacker and so expensive for the server. Double the number of colliding keys and you quadruple the work. Ten thousand colliding keys is not ten thousand units of work, it is on the order of fifty million. A hundred thousand colliding keys is on the order of five billion comparisons, all to insert a payload that fits comfortably in a single request body. The attacker spends a few kilobytes of upload; the server spends seconds of one core grinding through a linked list.

    It helps to walk the asymmetry concretely. A parameter name like field0000 is about ten bytes on the wire. Ten thousand such names, separated by ampersands, is roughly a hundred kilobytes, a request body smaller than many images on a typical web page. An honest hundred kilobyte form with ten thousand distinct fields would insert into the table in about ten thousand operations, finishing in microseconds, because each key lands in its own bucket and the chains stay short. The same hundred kilobytes of colliding keys forces about fifty million comparisons, because every key has to be checked against the entire growing chain before it is added. The wire cost is identical. The CPU cost differs by a factor of five thousand. That ratio is the entire point of the attack: the work the server does is not proportional to the work the attacker does, and the gap between them widens with every key.

    The same quadratic curve also explains why the cap based mitigation discussed later actually works. The pain lives in the n^2 term, and n^2 is gentle for small n and brutal for large n. A thousand colliding keys is only about half a million comparisons, finished in a blink. Ten thousand is fifty million. A hundred thousand is five billion. Cutting the maximum n the parser will accept does not slow the attack by a constant factor, it moves you back down the steep part of the curve, where even adversarial input is cheap to process.

    Why knowing the hash function is the whole game

    None of this works if the attacker cannot predict where keys land. The collisions have to be engineered, and to engineer them you need to know the function. Many of the platforms hit in 2011 used a well known, fixed, non keyed hash. PHP arrays and a number of Java systems used Daniel Bernstein’s DJBX33A and DJBX33X functions, which are short, fast, and completely public. The function multiplies a running value by 33 and adds the next byte, so its behavior is easy to reason about and easy to reverse.

    With a fixed function and no secret, an attacker can compute, offline and ahead of time, large sets of distinct strings that all produce the same hash value. For DJBX33A there are well known short building blocks, pairs of two character strings that collide, and you can concatenate them to manufacture as many colliding keys as you like. The strings look like ordinary parameter names. There is nothing malformed about them. They simply happen to be chosen so the cheap public hash maps every one of them to the same number. The attacker does the hard combinatorial work once and reuses the result against every server running that function.

    The construction is worth understanding because it shows how little effort the attack takes once the function is known. Suppose you find two short strings, call them Aa and BB, that the hash maps to the same value. Because the hash processes a string one byte at a time, building the running value as it goes, any longer string built by gluing these blocks together in any order produces the same final value as long as the blocks are interchangeable at each position. Two interchangeable two byte blocks give you four colliding strings of four bytes, eight of six bytes, sixteen of eight bytes, and in general 2 to the power of the number of slots. A handful of base collisions, concatenated, yields an effectively unlimited supply of distinct keys that all hash to one bucket. The attacker never has to brute force the full set. They find a few small collisions and let concatenation multiply them. This is why the payload is cheap to generate and why every server running the same unseeded function is vulnerable to the same precomputed list.

    The request parsing amplifier

    An attacker still needs a way to get a server to insert thousands of attacker chosen keys into a hash table without writing any code on the server. Web frameworks hand them exactly that, for free, on every request.

    When a browser or a client sends a POST request with a form body or a JSON document, the framework parses it before your handler ever sees it. A body like a=1&b=2&c=3 is split on the ampersands and equals signs, and each name is inserted as a key into a dictionary so your code can read request.params["a"]. The same happens for JSON objects, for query string parameters, for multipart form fields, and in many stacks for HTTP headers and cookies. Parsing untrusted request data into a hash table is not an edge case. It is the single most common thing a web framework does, and it happens automatically, on the parsing path, with the keys taken verbatim from the request.

    That is the amplifier. The attacker does not need authentication, a vulnerable endpoint, or any application logic at all. They send one POST request whose body is nothing but colliding parameter names, a few hundred kilobytes of key1=&key2=&key3= where every key name is one of the precomputed collisions. The framework dutifully parses each one and inserts it into a single overloaded bucket, paying the quadratic cost on the way. One ordinary looking request, well under a megabyte, pins a core while the parser grinds. At demonstration bandwidth on the order of a slow home connection, a steady trickle of these requests was enough to keep a modern CPU core fully busy. The bandwidth to attack is trivial; the bandwidth to absorb the attack is the server’s entire core.

    The attacker never overwhelms the network or floods the server with volume. They send one small, well formed request and let the server’s own data structure do the expensive work, turning a few kilobytes of input into seconds of CPU.

    Consider our invented app, Acme Notes, which exposes a JSON API. A client posts a note as a JSON object, and the framework parses that object into a dictionary keyed by field name before validation. An attacker posts a single note whose JSON body has a hundred thousand keys, all engineered to collide. Acme Notes never gets to reject the note for being malformed, because the denial of service happens during parsing, inside the framework, before a line of Acme Notes code runs. The application looks blameless. The vulnerability lives one layer down, in the assumption that request keys are not adversarial.

    The 28C3 wave of 2011

    This stopped being theoretical at the end of 2011. At the 28th Chaos Communication Congress, Alexander Klink and Julian Walde presented Efficient Denial of Service Attacks on Web Application Platforms, and the impact was that it hit nearly every major web stack at the same time. PHP, Java based servers, Python, Ruby, and ASP.NET all parsed request parameters into hash tables built on predictable, non keyed hash functions. One technique, slightly retargeted per language, took them all down.

    The coordinated disclosure was tracked as oCERT-2011-003, which assigned a row of CVE identifiers across the affected platforms. PHP before 5.3.9 was CVE-2011-4885: it computed hash values for form parameters without restricting predictable collisions, letting a remote attacker burn CPU with many crafted parameters. Python was assigned CVE-2012-1150 under the same oCERT advisory. Ruby was CVE-2011-4815. On the Java side, Apache Tomcat was CVE-2011-4858, with sibling identifiers for Jetty, Glassfish, Geronimo, and the Rack middleware, among others. The point of the wave was not any single language. It was that an entire industry had independently reached for the same cheap public hash and the same automatic parameter parsing, and so shared the same flaw.

    It was not a new idea

    The class of attack was already eight years old in 2011. In 2003, Scott Crosby and Dan Wallach published Denial of Service via Algorithmic Complexity Attacks at the USENIX Security Symposium. They named the general category, algorithmic complexity attacks, where an attacker feeds an input crafted to drive a data structure or algorithm into its worst case rather than its average case. They demonstrated it against the hash tables in Perl and against the Bro intrusion detection system and the Squid proxy, knocking a Bro server over with less bandwidth than a dialup modem. They also pointed to the fix: universal hashing, where the hash function is parameterized by a secret the attacker does not know. The 2011 wave was the same attack the 2003 paper had described, finally cashed in against the web at scale.

    SipHash and the real fix

    The patches came in two flavors, and only one of them addresses the root cause.

    Capping the number of parameters

    The immediate, pragmatic mitigation was to limit how many parameters a request is allowed to carry. PHP’s fix for CVE-2011-4885 added a configuration directive, max_input_vars, defaulting to 1000, that caps the number of input variables parsed from a single request. If the quadratic cost only becomes painful past tens of thousands of keys, refusing to parse more than a thousand keeps any single request cheap. Other stacks added equivalent caps on parameter counts, header counts, and body sizes.

    This works, but it treats the symptom. The hash function is still predictable, so an attacker who finds any path that inserts more than the cap of attacker chosen keys, or any code that builds a large dictionary from untrusted input outside the parameter parser, can still trigger the blowup. A cap narrows the attack surface. It does not remove the property the attack depends on.

    Keyed hashing with SipHash

    The real fix is to make the hash function unpredictable, so the attacker can no longer compute colliding keys ahead of time. You introduce a secret key, chosen randomly at process startup, and mix it into the hash. The function still spreads keys evenly and runs fast, but its exact mapping is different in every process and unknown to anyone outside it. An attacker cannot precompute collisions for a function whose seed they cannot see. This is the universal hashing idea from the 2003 paper, made practical.

    The algorithm the ecosystem settled on is SipHash, a keyed hash function designed in 2012 by Jean Philippe Aumasson and Daniel Bernstein specifically in response to the hash flooding wave. SipHash is fast on the short strings that hash tables actually use as keys, and it takes a 128 bit secret key, so without that key you cannot find collisions. It was adopted as the keyed hash behind the default hash table implementations in Perl, Python, Ruby, and Rust, among others. Python exposed the seed through the PYTHONHASHSEED environment variable while it stabilized the change. Rust ships SipHash as the default hasher for its standard library hash map out of the box.

    The thing to hold onto is why a non keyed hash was the actual bug. A fixed public hash function is a contract the attacker can read. Once they know the function, the set of colliding keys is just a calculation, and the data structure has no defense because it cannot tell an adversarial key from an honest one. Adding a secret key changes the function from something public into something private, and the entire attack rests on the function being public. Cap the parameters if you like, but the function being predictable is the root, and a keyed hash is what pulls it out.

    If you want to see where this sits among related classes of bug, it shares DNA with the rest of the most common web vulnerabilities: a server trusting attacker controlled input to behave the way honest input does. Here the betrayed assumption is not about the content of a value but about the statistical shape of a set of keys. The closest relative is regular expression denial of service, where crafted input drives a backtracking regex into its worst case time instead of its average case, the same algorithmic complexity class seen from the regex engine; our free ReDoS regex analyzer checks a pattern for the runaway backtracking that makes that possible.

    The assumption that breaks

    Step back from the buckets and the chains and one assumption is holding the whole thing up. Every hash table assumes its keys are not chosen by an adversary who knows the hash function. That assumption is invisible in the textbook, where O(1) is stated as a fact rather than as an average over honest inputs. It is invisible in the framework, where parsing a request body into a dictionary looks like plumbing rather than a security boundary. And it was invisible across an entire industry that reached for the same fast public hash, until one conference talk made the cost visible everywhere at once.

    The bug was never a slow hash table or a careless parser. The bug was a data structure whose performance guarantee quietly depended on the goodwill of whoever supplied its keys, deployed on the one path where the keys come straight from an attacker. The fix was not to make the table faster. It was to remove the attacker’s ability to predict it, by making the function secret. That gap, between what a system assumes about its inputs and what an adversary can actually arrange, is the kind of flaw you find by asking what each component takes for granted and whether that thing can be chosen against it, rather than by scanning for a known bad string. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to catch. Question the average case, key your hashes, and cap what you parse. Learn more about that approach on our about page.

    Frequently asked questions

    What is a hash flooding attack?

    It is a denial of service that abuses how hash tables store keys. A hash table gives O(1) average lookups only when keys spread across buckets. If an attacker knows the hash function, they can craft many keys that all collide into one bucket, so each operation degrades to O(n) and inserting n keys costs O(n^2). A small request of colliding keys then burns a CPU core. The class was first named by Scott Crosby and Dan Wallach in Denial of Service via Algorithmic Complexity Attacks at USENIX 2003.

    How does a small request cause so much load?

    Web frameworks parse request bodies, query strings, JSON keys, and headers into a hash table before your code runs. An attacker sends one POST request whose parameter names are all engineered to collide, often a few hundred kilobytes of key1=&key2= pairs. The framework inserts each name into a single overloaded bucket, paying the quadratic cost during parsing. The talk that demonstrated this across platforms was Efficient Denial of Service Attacks on Web Application Platforms at 28C3 in 2011.

    Which platforms were affected and what were the CVEs?

    The 2011 disclosure hit PHP, Java based servers, Python, Ruby, and ASP.NET at once, coordinated as oCERT-2011-003. PHP before 5.3.9 was CVE-2011-4885, Python was CVE-2012-1150, Ruby was CVE-2011-4815, and Apache Tomcat on the Java side was CVE-2011-4858, alongside identifiers for Jetty, Glassfish, Geronimo, and Rack. The shared cause was a predictable, non keyed hash function applied to attacker controlled request keys.

    How do you fix hash flooding?

    The real fix is keyed hashing: mix a secret seed chosen at startup into the hash so an attacker cannot precompute collisions. The ecosystem adopted SipHash, designed in 2012 by Jean Philippe Aumasson and Daniel Bernstein, as the default keyed hash in Perl, Python, Ruby, and Rust. Capping the number of parameters per request, such as PHP’s max_input_vars directive defaulting to 1000, helps as a mitigation, but a non keyed hash is the root problem because its collisions can be computed in advance.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How Rowhammer Works: Flipping Bits in Memory You Were Never Allowed to Touch

    How Rowhammer Works: Flipping Bits in Memory You Were Never Allowed to Touch

    Rowhammer is a hardware level attack that flips bits in memory the attacker was never allowed to touch. It works because DRAM stores each bit as a tiny charge in a cell, the cells are packed extremely close together, and repeatedly activating one row of cells leaks charge into the physically adjacent rows. Do it fast enough, often enough, and a bit in a neighboring row changes from a one to a zero or back, with no read or write permission on that row required. The bug lives below the software stack entirely, in the silicon, which is what makes it so unsettling. This post walks the mechanism one step at a time: how a DRAM cell holds a bit, what the activate and precharge cycle is, why the disturbance appears, how an attacker turns a random flip into a broken security boundary, and what the later browser and mobile variants and the ECC and TRR defenses actually do.

    What rowhammer is, in one paragraph

    A DRAM chip is a grid of cells, each a capacitor that holds a charge and a transistor that gates access to it. Charge present means one logical value, charge absent means the other. The cells are organized into rows, and reading or writing any cell means activating the whole row it sits in. The discovery behind rowhammer, published by Yoonsung Kim and colleagues at ISCA 2014, is that hammering one row over and over, activating it thousands of times in a short window, disturbs the charge in the rows next to it enough to corrupt their stored bits. The attacker reads and writes only rows they are allowed to touch. The damage lands in a row they are not. That gap between what the attack accesses and what it corrupts is the whole story, and it is why the original paper is titled Flipping Bits in Memory Without Accessing Them.

    How a DRAM cell stores a bit

    Start at the bottom. A single DRAM cell is one capacitor and one transistor. The capacitor either holds a charge or it does not, and that presence or absence is the bit. The transistor is a switch that connects the capacitor to a wire called a bitline when you want to read or write it. Because a capacitor leaks charge over time, DRAM is dynamic: every cell has to be refreshed periodically, read out and written back, or the bit decays into noise. On commodity hardware that refresh happens on a fixed interval, traditionally every 64 milliseconds for the whole array.

    Cells do not stand alone. They are wired into a grid of rows and columns. All the cells in one row share a wire called a wordline, and all the cells in one column share a bitline. To touch any cell, the chip raises the voltage on that cell’s wordline, which switches on every transistor along the row at once and connects all of those capacitors to their bitlines. You cannot read a single cell in isolation. You read its entire row into a buffer, then pick the column you wanted.

    The activate and precharge cycle

    Getting at a row is a two step dance. First the memory controller issues an activate command for the row. That raises the wordline, dumps the row’s charges onto the bitlines, and latches the result into a strip of sense amplifiers called the row buffer. Now the row is open and its columns can be read or written quickly. When you are done with that row and want a different one in the same bank, the controller issues a precharge, which closes the open row, writes its contents back into the cells, and resets the bitlines to a neutral level so the next activate can begin cleanly.

    Every activate sends a voltage swing down a wordline that runs right past its neighbors. A single activate is harmless. The trouble is what happens when you force the same row through the activate and precharge cycle again and again, as fast as the chip allows, thousands of times before the next scheduled refresh comes around to repair the neighbors. That is the literal hammering in rowhammer.

    Why shrinking process nodes made the disturbance appear

    This was not a problem on older, larger memory. As DRAM makers shrank the process node to pack more capacity into the same die, the cells moved physically closer together and each capacitor got smaller, holding less charge to begin with. Closer cells mean stronger electrical coupling between a wordline and its neighbors, and smaller charge means a flip needs less disturbance to push a cell across its threshold. Past a certain density the repeated voltage activity on one row started leaking enough into adjacent rows to corrupt them before the periodic refresh could top them back up. The ISCA 2014 study tested modules from the three major vendors and found a large majority of recent DRAM modules vulnerable to these disturbance errors. The defect is not a manufacturing mistake in one batch. It is a consequence of how dense modern DRAM has to be, and it gets harder to avoid, not easier, as each generation packs the cells tighter.

    From a hammer to a bit flip: single sided and double sided

    Knowing that hammering corrupts neighbors, the next question is how to hammer effectively. Two refinements matter, and both come down to a detail of the activate cycle: a row only disturbs its neighbors while it is being opened and closed, so you have to keep forcing fresh activates rather than reading the same already open row.

    To guarantee that, an attacker accesses two different rows in the same bank in a tight loop and flushes them from the CPU cache between accesses, so each loop iteration forces a real activate down to the chip instead of being served from cache or the row buffer. This is single sided hammering: pick a couple of aggressor rows, pound them, and hope the disturbance lands on whatever victim row happens to sit beside one of them. The cache flush is the subtle part. Modern CPUs cache memory aggressively, so a naive loop that reads the same address repeatedly never reaches the DRAM at all; the second read onward is served from cache and the chip is never activated. Early proofs of concept used an explicit cache flush instruction, on x86 the clflush instruction, to evict the line after each access and guarantee the next read goes all the way down to the memory chip. Two aggressor rows in the same bank also help here, because alternating between them forces the row buffer to close one and open the other every time, which is exactly the activate and precharge churn that produces disturbance.

    Double sided hammering is sharper. A row has two immediate neighbors, the one above and the one below. If the attacker can hammer both of a victim row’s neighbors, the rows at position N minus one and N plus one, the victim row in the middle absorbs disturbance from both sides at once. Project Zero found this technique flipped vastly more bits and was necessary to get results on many of the machines they tested. Double sided hammering needs the attacker to know which physical rows are adjacent, which takes some reverse engineering of how addresses map to rows, but the payoff is a much higher flip rate on a chosen target.

    Weaponizing a flip: the page table entry attack

    A random bit flip somewhere in physical memory is, on its own, just a crash or a glitch. Turning it into a security boundary break is the hard and clever part, and the canonical demonstration is the 2015 Google Project Zero post Exploiting the DRAM rowhammer bug to gain kernel privileges, by Mark Seaborn and Thomas Dullien. They built two working exploits on real Linux machines.

    The first targets page table entries. On a modern system the operating system keeps page tables that map a process’s virtual addresses to physical memory, and each page table entry, a PTE, names a physical page and the permissions on it. The attack works like this. The exploit first sprays memory so it is filled almost entirely with the process’s own page tables, then hammers until it finds a flip that lands inside a PTE. If the flipped bit changes the physical page number that the PTE points at, there is a good chance the PTE now points at a page that is itself one of the attacker’s page tables. The moment that happens, the process has a writable mapping of its own page table. It can edit page table entries directly, point them at any physical page it likes, and from there it has read and write access to all of physical memory, including the kernel. That is full privilege escalation driven by a single well placed flip in a structure the attacker was never allowed to modify.

    The attacker never writes to the page table. The hardware changes it for them, one bit at a time, from a row next door.

    The second Project Zero exploit escapes the Native Client sandbox, NaCl, which was a way to run untrusted native code safely in the browser by validating that the code only used a restricted set of instruction sequences. The attack hammers the sandboxed code itself. NaCl enforces safe indirect jumps by masking the target address with a fixed instruction sequence, and a bit flip that changes a register number inside one of those sequences can turn a safe, validated jump into an unsafe one that lands on an unaligned address. From that misaligned landing the attacker reaches instruction bytes the validator never checked, including hidden syscall instructions, and breaks out of the sandbox. Two different boundaries, the kernel and the sandbox, both broken by the same physical effect.

    Browser and mobile variants

    The early proofs of concept needed special conditions, a native binary and often a cache flush instruction. The research that followed steadily stripped those requirements away, which is the part of the story that turned rowhammer from a lab curiosity into a broad concern.

    Rowhammer.js: from the browser, no native code

    Rowhammer.js, by Daniel Gruss and colleagues, showed that the attack could be triggered from plain JavaScript running in a browser, with no native binary and no special CPU instruction to flush the cache. The researchers built a memory access pattern that evicts cache lines using ordinary accesses alone, so that the hammering reaches DRAM even without a flush instruction available to scripts. That made rowhammer a remote concern: a flip could in principle be induced by visiting a web page, narrowing the gap between the hardware defect and an ordinary attacker.

    Drammer: deterministic flips on Android and ARM

    Drammer, from the VUSec group, carried the attack to mobile. It demonstrated rowhammer on ARM based Android phones and, importantly, made the exploit deterministic rather than probabilistic. It did this by abusing the phone’s memory allocator to land a page table in a physical location the attacker had already found to be flippable, so the flip reliably hit a useful target. Drammer was a root privilege escalation that relied on no software vulnerability at all, only the hardware bug, on a class of devices many people assumed were out of reach.

    One location hammering

    Later work showed that on some systems you do not even need two aggressor rows. One location hammering repeatedly activates a single row, relying on the memory controller’s row policy to keep closing and reopening it so each access becomes a fresh activate. It works where the controller uses a closed page or adaptive policy, and it further trimmed the conditions an attacker needs to satisfy.

    Defeating the defenses: ECC and TRR

    Two mitigations were widely treated as the answer to rowhammer. Research has shown both can be defeated, which is the honest state of the field, even though both still raise the bar.

    Error correcting code memory and ECCploit

    ECC memory adds redundant bits so the controller can detect and correct errors, typically correcting a single bit flip in a word and detecting two. The intuition was that rowhammer flips would simply be corrected away. ECCploit, from VUSec, showed this is not a clean defense. By using timing side channels to learn how the ECC scheme behaves and carefully arranging multiple flips in the same word, an attacker can engineer corruption that slips past correction. ECC raises the cost and the number of flips required, but it does not make a vulnerable module safe.

    Target Row Refresh, TRRespass, and Half-Double

    Target Row Refresh, TRR, is a defense built into DDR4 memory. The idea is that the chip watches for rows being activated unusually often and proactively refreshes their neighbors before a flip can develop, repairing the victim before the disturbance accumulates. It was marketed as the fix that closed rowhammer for good. It did not. TRRespass, from VUSec, showed that TRR implementations track only a limited number of aggressor rows at once, so an attacker who hammers many rows at the same time, a many sided pattern, can overwhelm the tracker and still flip bits on DDR4 modules that TRR was supposed to protect. Half-Double, demonstrated by Google, exploits a different gap: as cells shrink further the disturbance reaches beyond the immediate neighbor to rows two steps away, and the very act of TRR refreshing a near neighbor can itself contribute disturbance to a row further out. Both results say the same thing. The in chip mitigations narrowed the attack but did not end it.

    What actually helps

    No single mitigation closes rowhammer cleanly, so defense is layered. Increasing the refresh rate, refreshing the whole array more often than the standard interval, gives disturbance less time to accumulate before a victim row is repaired, at a cost in performance and power. ECC and TRR each raise the number of flips or the precision an attacker needs, even though neither is sufficient alone. The deeper fixes are in hardware: probabilistic or counter based schemes that track how often each row is activated and refresh threatened neighbors accurately, and successor memory standards that build stronger row activation tracking into the specification rather than leaving it to a vendor’s opaque, limited TRR logic. The direction of travel is to move the defense into the silicon where the bug lives, because nothing in software can stop a charge from leaking between two cells the manufacturer placed a few nanometers apart. There are also operating system and allocator level mitigations that try to keep security sensitive structures like page tables physically away from memory an attacker can hammer, which raises the difficulty of the targeting step even when the underlying flip is still possible.

    It is worth being precise about what is demonstrated versus theoretical. The bit flips themselves, the PTE and NaCl exploits, the JavaScript and Android variants, and the bypasses of ECC and TRR are all demonstrated on real hardware in published research. What any given attacker can do against a specific deployed machine depends heavily on the exact memory modules, the controller policy, and the mitigations in place, and reliable exploitation in the wild is harder than a lab proof of concept. The bug is real and the exploits are real; the difficulty is in the targeting.

    Rowhammer also sits near other low level boundary breaks. Once an attacker flips a PTE and gains arbitrary physical memory access, what follows is privilege escalation in the classic sense, climbing from an unprivileged process to kernel level control. The difference is where the leverage comes from. Here it does not come from a logic bug in code. It comes from the memory itself betraying the software running on top of it.

    The assumption that breaks

    Step back from the wordlines and the page tables and one assumption is holding everything up. Every piece of software running on a computer trusts that memory it did not write cannot change underneath it. A program reads back what it stored. The kernel assumes its page tables say what it set them to say. The whole edifice of memory protection, of one process being walled off from another, rests on the substrate being inert, a passive box that holds bits faithfully until something with permission changes them. Rowhammer is the discovery that the substrate is not inert. Charge leaks between cells that were supposed to be independent, and an attacker with no permission on a row can reach into it through the silicon and change what it holds. The boundary everyone drew at the permission check actually ran somewhere lower, in the physics of how the bits are stored, and that lower boundary was never enforced at all.

    The bug is not a coding mistake you can find by reading the source. It is an assumption baked so deep into the model of computing that almost nobody thought to question it, that the hardware keeps your bits the way you left them. That kind of flaw, the unstated premise that the layer below you is trustworthy, is exactly what you find by asking what each layer trusts and why, rather than by scanning for a known bad pattern. It is the kind of assumption an autonomous researcher built to test assumptions is meant to catch, the ones nobody wrote down because they seemed too obvious to fail. Learn more about that approach on our about page.

    Frequently asked questions

    What is rowhammer and how does it flip bits?

    Rowhammer is a hardware level defect in DRAM. Each bit is a charge in a tiny capacitor, the cells are packed very close together, and repeatedly activating one row leaks charge into the physically adjacent rows until a bit in those neighbors flips. The attacker reads and writes only rows they are allowed to touch, but the corruption lands in a row they are not. The seminal study by Kim and colleagues, Flipping Bits in Memory Without Accessing Them, first characterized this disturbance error across DRAM from all three major vendors.

    What is the difference between single sided and double sided hammering?

    Single sided hammering pounds a small set of aggressor rows and hopes the disturbance lands on whatever victim row sits beside one of them. Double sided hammering targets both immediate neighbors of a chosen victim, the rows at N minus one and N plus one, so the victim in the middle absorbs disturbance from both sides at once. Google Project Zero reported in Exploiting the DRAM rowhammer bug to gain kernel privileges that double sided hammering flipped vastly more bits and was necessary on many machines they tested.

    How does a random bit flip become a kernel privilege escalation?

    An attacker sprays memory with their own page tables, then hammers until a flip lands inside a page table entry and changes the physical page it points at. With luck the entry now maps one of the attacker’s own page tables as writable, giving them direct edit access to address translation and from there read and write access to all of physical memory, including the kernel. The full Project Zero writeup walks both this PTE attack and a Native Client sandbox escape.

    Do ECC and Target Row Refresh stop rowhammer?

    They raise the bar but neither is a clean fix. ECCploit showed that carefully arranged multiple flips in one word can slip past error correction, and TRRespass showed that Target Row Refresh tracks only a limited number of aggressor rows, so a many sided hammering pattern can overwhelm it and still flip bits on DDR4. The VUSec TRRespass project page documents how built in TRR defenses were bypassed on real modules from all three major vendors.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How Bluetooth LE Pairing Breaks: KNOB, BLESA, Just Works, and Sniffed Keys

    How Bluetooth LE Pairing Breaks: KNOB, BLESA, Just Works, and Sniffed Keys

    Bluetooth LE pairing is the handshake where two devices agree on a secret key so the rest of their conversation can be encrypted. Done right, that key is fresh, strong, and tied to the two devices that actually meant to talk. Done wrong, the key is weak, unauthenticated, or trivially guessed, and a third radio in range can read everything or pretend to be one of the endpoints. The mechanism is small, it runs in milliseconds, and most users never see it. That is exactly why its failures are so quiet. This post walks the pairing flow one step at a time, then walks the real attacks that break it: an entropy downgrade that shrinks a key to a single byte, a spoofing trick that abuses reconnection, an association model that encrypts without authenticating, and a passive sniff that recovers the key offline.

    What bluetooth le pairing actually does

    When two Bluetooth Low Energy devices first meet, they have no shared secret. A smartphone and an Acme smart lock are radios shouting into the same crowded band, and anyone nearby can hear the same packets. Pairing is the procedure that turns that open exchange into a private channel. It does three jobs in sequence: the devices announce what input and output hardware they have, they pick an association model based on those capabilities, and they run that model to establish a key. Everything that follows, every encrypted read and write, rests on the key that pairing produced.

    There are two generations of this procedure, and the difference matters for every attack below.

    LE legacy pairing versus LE Secure Connections

    LE legacy pairing is the original mechanism, shipped with the first Low Energy specification. In legacy pairing the two devices first agree on a Temporary Key, or TK. They use the TK to derive a Short Term Key, the STK, which protects the rest of the exchange long enough to hand over a Long Term Key, the LTK, that gets stored and reused on later connections. The weakness baked into this design is the TK. Depending on the association model, the TK is either a six digit number the user typed, or it is simply zero. A small number space is a guessable number space, and that is the thread an attacker pulls.

    LE Secure Connections arrived in Bluetooth 4.2 and replaces the heart of the exchange with Elliptic Curve Diffie Hellman key agreement on the P-256 curve. Instead of agreeing on a tiny Temporary Key and stretching it, both sides contribute to a shared secret that an eavesdropper cannot reconstruct from the packets alone, because the private scalars never go over the air. The same four association models exist in both generations, but under Secure Connections they protect a real key agreement rather than a six digit guess. If you remember one defensive fact from this entire post, it is that requiring LE Secure Connections removes the foundation that the legacy attacks stand on.

    The four association models

    An association model is how the two devices authenticate the key they are agreeing on, given whatever buttons and screens they happen to have. The specification defines four, and which one runs is decided automatically from the input and output capabilities each side advertises.

    • Just Works is the model for devices with no screen and no keypad, which describes most cheap sensors, tags, and beacons. It runs the key agreement with no human in the loop and no value to compare. In legacy pairing the Temporary Key under Just Works is set to zero. It provides encryption, but it authenticates nothing.
    • Passkey Entry has one device display a six digit number and the user types it into the other, or the same number is keyed into both. That shared six digit value feeds the authentication, so a passive listener who never sees the digits cannot complete the handshake the same way.
    • Numeric Comparison, available only under LE Secure Connections, shows a six digit value on both screens and asks the user to confirm they match. Because the value is derived from both sides of an authenticated key agreement, confirming it rules out a radio sitting in the middle. This is the strongest model when both devices have a display.
    • Out of Band moves the authentication data over a different channel entirely, commonly NFC. If a strong secret travels over that side channel, the over the air handshake can be authenticated against it, and a remote attacker who only hears the Bluetooth radio has nothing to work with.

    The split that runs through all four is whether the model provides authentication, often called MITM protection, or only encryption. Just Works gives encryption with no authentication. The other three, used correctly, give both. An attacker reads that capability advertisement as a menu, and Just Works is the cheapest item on it.

    How the pairing breaks

    Four weaknesses, each attacking a different assumption in the flow above. The first downgrades the key. The second skips authentication on reconnect. The third never had authentication to begin with. The fourth recovers the key by listening.

    1. The KNOB attack: negotiating the key down to one byte

    The Key Negotiation of Bluetooth attack, KNOB, was published by Daniele Antonioli, Nils Ole Tippenhauer, and Kasper Rasmussen, and it targets a step most descriptions of pairing skip entirely: the negotiation of how long the encryption key will be. Before encryption starts on a Bluetooth BR/EDR link, the two devices agree on the entropy of the key, anywhere from 16 bytes down to a legacy minimum of 1 byte. The problem is that this entropy negotiation is itself unauthenticated. It happens before the strong key protects anything, and nothing signs or checks the proposed length.

    An attacker positioned between the two devices rewrites that negotiation in flight. When one side proposes 16 bytes, the attacker lowers the proposal to 1 byte before it reaches the other side, and does the same in reverse. Both devices believe they negotiated honestly and both accept a key with only 8 bits of entropy. That is 256 possible keys. The attacker brute forces it, decrypts the traffic, and injects valid ciphertext, all without either victim noticing, because the downgrade is invisible at the application layer.

    The devices agreed on a key. They never agreed on how hard that key would be to guess, and the one negotiation they trusted to set that was the one nobody was protecting.

    This is tracked as CVE-2019-9506, scored 8.1 High, and its official description is precise: the specification up to and including version 5.1 permits a sufficiently low encryption key length and does not prevent an attacker from influencing the key negotiation. The researchers tested more than 14 chips from Intel, Broadcom, Apple, and Qualcomm, and nearly all accepted 1 byte of entropy. The full method is documented on the KNOB attack project page. The fix the Bluetooth SIG shipped is a floor: their security notice on key negotiation recommends enforcing a minimum encryption key length of 7 octets so the downgrade has nowhere low to go.

    2. BLESA: spoofing a device on reconnect

    Pairing is the expensive first meeting. Reconnection is the cheap reunion. Once two devices have stored a Long Term Key, every later session is supposed to skip the full handshake and just resume encryption using that stored key. The Bluetooth Low Energy Spoofing Attack, BLESA, comes from researchers at Purdue’s PurSec Lab with EPFL, and it lives entirely in that reconnection step.

    The researchers analyzed the reconnection procedure as written in the specification and found that authentication on reconnect is effectively optional and, worse, poorly enforced by real stacks. When a previously paired device reappears, the client is supposed to insist the connection actually use the keys they share. Instead, several implementations would accept data from a peer that claims to be the known device without the peer proving it holds the Long Term Key. An attacker who has observed the earlier pairing can therefore impersonate the server, the Acme lock or a fitness sensor, and feed the client spoofed data on reconnect. The client trusts it because reconnection is the step everyone designed to be frictionless.

    The work won a Best Paper award at the USENIX Workshop on Offensive Technologies in 2020, and the paper estimates it could affect well over a billion devices. The reconnection assumption is the dangerous part: the whole point of storing an LTK was to avoid re proving identity, and skipping that re proof is exactly the hole. The full analysis is in the BLESA paper at USENIX WOOT 2020.

    3. Just Works: encryption with nobody on the other end verified

    Just Works is not a bug. It is a documented model that does precisely what its design says, which is to encrypt without authenticating. The trouble is what that combination means against an active attacker. Because no value is compared and no secret is shared out of band, there is nothing in the handshake that distinguishes the real Acme lock from a radio impersonating it. An attacker who is present during pairing can sit between the phone and the lock, pair with each side separately, and relay between them. Both ends get an encrypted channel. Both encrypted channels terminate at the attacker.

    This is the classic man in the middle, and Just Works is open to it by construction. The reason it is everywhere is hardware economics: a sensor with no screen and no keypad cannot run Passkey Entry or Numeric Comparison, so the capability negotiation falls through to Just Works as the only option both devices support. The defense is not to disable encryption but to refuse Just Works where it matters, by requiring the MITM protection flag during pairing so a device that can only offer Just Works is rejected for sensitive functions rather than silently accepted.

    It helps to be precise about what Just Works does and does not give you, because the marketing word is encryption and people stop reading there. The channel is encrypted, so a purely passive listener under LE Secure Connections cannot simply read the plaintext off the air. What is missing is any guarantee about who sits at the other end of that encrypted channel. Encryption answers the question is this traffic readable by an outsider. Authentication answers the question am I talking to the device I think I am. Just Works answers only the first, and an active attacker exploits the gap between the two by being the device you think you are. The phone encrypts faithfully to the attacker, and the attacker encrypts faithfully to the lock, and both sides see a green padlock the whole time.

    4. Passive sniffing of LE legacy pairing

    The first three attacks need an active radio in the conversation. The last one just listens. Mike Ryan’s tool crackle attacks LE legacy pairing offline, and it works because of the Temporary Key. Under Just Works the TK is zero. Under the six digit Passkey models the TK is a value in the range 0 to 999999, padded out to 128 bits, which sounds like a lot until you count it: one million possibilities is a number a laptop chews through in under a second.

    An attacker captures the legacy pairing exchange off the air, including the confirm values the two devices send. Then they compute the confirm value for every candidate TK and keep the one that matches what they captured. Recovering the TK unwinds the rest: the TK yields the Short Term Key, the STK protects the handover of the Long Term Key, and once the LTK is in hand the attacker decrypts the entire session and every future session that reuses it. No injection, no jamming, just a recording and a brute force. The technique is described in the crackle project on GitHub. The single line of defense is the generational one: LE Secure Connections replaces the guessable Temporary Key with a Diffie Hellman exchange that produces nothing for crackle to brute force.

    Why this lands hard on IoT

    These attacks would be academic if the affected devices were just headphones. They are not. Bluetooth Low Energy is the radio of choice for the cheapest, longest lived, least patched hardware in circulation, and that population maps almost exactly onto the weaknesses above.

    Smart locks are the sharpest example. A lock that uses Just Works, or that does not enforce authenticated reconnection, can be spoofed or relayed by an attacker in radio range, and the failure mode is a door that opens. Medical devices such as glucose monitors and insulin pumps carry data and sometimes control that a spoofing or eavesdropping attack turns into a safety problem, not just a privacy one. Wearables and fitness sensors leak a continuous stream of personal data over links that frequently fall back to Just Works because the band on your wrist has no keypad. And trackers, the small tags people attach to keys and bags, are designed to be silent and to reconnect automatically, which is precisely the reconnection behavior BLESA abuses.

    The common thread is constraint. These devices are too small for a screen, too cheap for careful firmware, and too long lived to be reliably updated, so they default to the weakest models and the oldest pairing generation. The economics that make them cheap are the same economics that make them vulnerable.

    There is a patching problem layered on top. When CVE-2019-9506 landed, the operating system vendors that ship general purpose devices, phones and laptops, pushed enforcement of a minimum key length fairly quickly. A standalone Acme lock or a budget fitness band has no such pipeline. Its firmware was flashed once at the factory and may never be touched again, and many such products have no mechanism to update the Bluetooth stack at all. So a weakness in the specification does not just affect devices for a patch cycle; it affects them for the entire service life of hardware that was never built to be fixed. An attacker does not need a fresh vulnerability against this population. The old ones never closed.

    Closing the gaps

    Every attack above has a corresponding control, and they stack. None of them requires inventing anything; they require refusing the weak defaults the specification still permits for compatibility.

    Require LE Secure Connections

    This is the single highest leverage change. Secure Connections mode replaces the legacy Temporary Key and STK chain with ECDH key agreement, which removes the guessable secret that crackle brute forces and strengthens the foundation under every association model. Devices can refuse to pair in legacy mode, and security sensitive products should. The legacy fallback exists for old peers; a lock or a medical device has no business honoring it.

    Enforce a minimum key length

    KNOB works because the entropy floor sits at 1 byte. Following the Bluetooth SIG guidance and enforcing a minimum encryption key length, 7 octets for BR/EDR, means an attacker who rewrites the negotiation cannot push it down to a brute forceable size. Platform vendors shipped exactly this enforcement after CVE-2019-9506, and devices should reject any negotiated key below the floor rather than accept whatever the negotiation lands on.

    Mandate authenticated reconnection

    BLESA exists because reconnection skipped the proof that the peer still holds the shared key. The fix is to make that proof mandatory: on every reconnect, require the link to actually use the stored Long Term Key and reject a peer that cannot demonstrate it. The convenience of a frictionless reunion is not worth accepting data from a device that never proved it is the one you paired with.

    Set the MITM protection flags

    During pairing, devices exchange authentication requirement flags, and one of them requests MITM protection. Setting it forces the capability negotiation toward an authenticated model, Passkey Entry, Numeric Comparison, or Out of Band, instead of letting it slide into Just Works. A device that can only offer Just Works then fails closed for sensitive operations rather than getting an unauthenticated channel by default. Pair this with Out of Band where you have a side channel like NFC, and the over the air handshake gets anchored to a secret the remote attacker never hears.

    These controls reinforce one another. Secure Connections kills the offline brute force, the key length floor kills the entropy downgrade, authenticated reconnection kills the spoof, and the MITM flag keeps the whole thing from quietly falling back to the model that authenticates nobody. The same discipline that protects a Bluetooth lock applies to the firmware underneath it; if the device boot chain is also worth trusting, the way the secure boot process verifies each stage is the embedded sibling of these radio side defenses.

    The assumption that breaks

    Strip the four attacks down and they share one root. Pairing assumes that the two devices negotiating the key are the only two in the conversation, and that the negotiation about the key is itself trustworthy. Both halves of that assumption fail. Just Works and weak reconnection break the first half, because a third radio can insert itself into a handshake that never proves who is on the other end. KNOB breaks the second half directly: the one negotiation that decides how strong the key will be is the one nobody bothered to authenticate, so an attacker edits it in transit and both victims sign off on a key they would never have chosen.

    The flaw is never a broken cipher. The ciphers are fine. The flaw is a step that was trusted without being checked, an entropy field nobody signed, a reconnection nobody re proved, a model that encrypts to whoever shows up. That gap between what a protocol assumes about its participants and what an attacker can actually arrange in the same radio band is the kind of weakness you find by asking what each step trusts and why it still trusts it, rather than by scanning for a known bad signature. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to surface. Require Secure Connections, floor the key length, prove the peer on reconnect, and never let the handshake fall back to trusting a stranger. Learn more about that approach on our about page.

    Frequently asked questions

    What is the KNOB attack and what CVE tracks it?

    KNOB, the Key Negotiation of Bluetooth attack, lets a nearby attacker rewrite the encryption key length negotiation between two BR/EDR devices because that negotiation is unauthenticated, forcing a key with as little as 1 byte (8 bits) of entropy that is then trivially brute forced. It is tracked as CVE-2019-9506, scored 8.1 High, and the full method is documented on the KNOB attack project page.

    How does BLESA spoof a Bluetooth Low Energy device?

    BLESA, the Bluetooth Low Energy Spoofing Attack, abuses reconnection. After two devices pair and store a Long Term Key, later sessions resume without a full handshake, and the researchers found that authentication on reconnect is optional and poorly enforced in real stacks. An attacker can impersonate a previously paired device and feed spoofed data to the client. The analysis is in the BLESA paper from USENIX WOOT 2020.

    Why is the Just Works association model insecure?

    Just Works is the pairing model for devices with no screen or keypad, and it provides encryption without authentication. Because no value is compared and no secret travels out of band, nothing in the handshake distinguishes the real device from an impostor, so an attacker present during pairing can sit in the middle, pair with each side, and relay between them. The model is described in the Bluetooth SIG security overview.

    Can someone decrypt Bluetooth LE by just listening?

    Yes, against LE legacy pairing. Mike Ryan’s tool crackle recovers the Temporary Key, which is zero under Just Works or a value from 0 to 999999 under the six digit models, by brute forcing all candidates against captured confirm values in under a second. The recovered key unwinds the Short Term Key and then the Long Term Key, decrypting the whole session. The fix is LE Secure Connections. See the crackle project on GitHub.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What Is Sigreturn Oriented Programming and Why One Gadget Owns the CPU

    What Is Sigreturn Oriented Programming and Why One Gadget Owns the CPU

    Sigreturn oriented programming is a binary exploitation technique that turns one tiny piece of borrowed code into total control of the CPU. On Linux, when a signal is delivered, the kernel writes a snapshot of every register onto the user stack and trusts that snapshot completely when the handler returns. An attacker who can write to that stack forges the snapshot, triggers the return path, and the kernel obediently loads attacker chosen values into rax, rdi, rsp, rip, and the rest, all in a single step. Where a normal exploit hunts for a dozen scattered gadgets to set up one system call, this one needs almost nothing. This post walks the mechanism one step at a time: how a signal frame gets onto the stack, why the kernel never checks whether it is genuine, how a forged frame becomes a syscall chain that spawns a shell, and what actually stops it.

    What sigreturn oriented programming actually exploits

    The whole technique rests on one feature of how Unix systems deliver signals, and on one assumption the kernel makes about that feature. When a process receives a signal, the kernel does not just jump to the handler and forget where it was. It first saves the entire interrupted execution state so that, after the handler runs, the process can pick up exactly where it left off. That saved state is the signal frame, and it lives on the user stack, in memory the process can read and write like any other.

    The frame is not a vague summary of the process. It is a full register dump. On x86-64 Linux the saved context, a structure the kernel calls a ucontext wrapping a sigcontext, holds the values of the general purpose registers, the stack pointer, the instruction pointer, and the flags. Every register that defines what the CPU will do next is sitting there in plain memory, written by the kernel, waiting to be put back. The technique was first described in full by Erik Bosman and Herbert Bos of Vrije Universiteit Amsterdam in their 2014 paper, which named the saved frame as the entire attack surface.

    How signal delivery sets up the frame

    Walk the normal, benign path first so the abuse is obvious later. A process is running. A signal arrives, say SIGALRM or SIGSEGV. The kernel pauses the process, builds the signal frame on the user stack, and arranges for the registered handler to run. The handler does its work. When it returns, it does not return like an ordinary function. Instead, control flows to a small trampoline that invokes a special system call named rt_sigreturn.

    That syscall has exactly one job: take the signal frame currently on top of the stack and restore the process from it. The kernel reads the saved ucontext, copies every saved register back into the live CPU registers, restores the signal mask, and resumes execution at the saved instruction pointer. As the manual for sigreturn(2) puts it, the call restores the process context, meaning the processor flags and registers, including the stack pointer and the instruction pointer. After it runs, the process is bit for bit back where it was before the signal, and none the wiser.

    Here is the load bearing detail. The kernel does not keep its own private, trusted copy of that frame. It put the frame on the user stack, and when rt_sigreturn runs, it reads the frame back from the user stack. It does not check a cookie. It does not verify that a signal was ever actually delivered. It does not confirm that the bytes it is about to load are the same bytes it wrote. It reads whatever is at the top of the stack, interprets those bytes as a saved register set, and loads them into the CPU. The kernel assumes that a frame on the stack is one the kernel itself placed there. That assumption is the whole game.

    The forged frame

    Now suppose an attacker has a stack write, the classic precondition for any return oriented attack: a buffer overflow, a format string write, or any primitive that lets them lay out bytes on the stack and steer the return address. Instead of building a long chain of return addresses the way classic return oriented programming does, they write something simpler. They write a fake signal frame.

    The layout is fixed and public, so forging it is mechanical rather than clever. The attacker fills in the saved register slots with the exact values they want the CPU to hold: a chosen rip to control where execution goes, a chosen rsp to control the stack, a chosen rax to select a system call, and chosen argument registers rdi, rsi, and rdx to fill in that call’s parameters. They do not have to find a gadget that loads each register one at a time. They just write the value they want into the slot that the kernel will copy into that register. Tooling makes this trivial in practice. The pwntools exploitation library ships a SigreturnFrame class that builds the byte layout for you, so a practitioner writes frame.rdi = ... and frame.rip = ... rather than memorizing offsets.

    With the fake frame in place, the attacker needs only to make the program execute rt_sigreturn. On x86-64 that means getting the syscall number 15, which is 0xf, into rax and then reaching a syscall instruction. The kernel sees the syscall, treats the top of the stack as a genuine signal frame, and loads every forged register at once. One step, and the entire CPU state belongs to the attacker.

    The kernel does not ask whether the signal frame is real. It reads the stack, trusts the bytes, and loads them into every register the CPU has.

    Why one gadget is enough

    To appreciate why this technique matters, contrast it with the attack it descends from. Classic return oriented programming chains together short instruction sequences that already exist in the target binary, each ending in a ret, to assemble behavior the attacker wants without injecting any new code. To set up a single system call that way, you typically need a gadget to load rdi, another for rsi, another for rdx, another for rax, and then a syscall. If the binary is small or stripped down, some of those gadgets may simply not exist, and the whole approach stalls. Gadget availability is the limiting factor.

    Sigreturn oriented programming collapses all of that into one move. It does not load registers one at a time from scattered gadgets. It loads the entire register set in a single rt_sigreturn, sourced from a frame the attacker fully controls. That has three consequences that make it unusually strong.

    It is close to universal

    The rt_sigreturn path is part of the kernel’s signal machinery, not a quirk of any one program. The two ingredients the attacker needs, a way to set rax to 15 and a syscall instruction, are minimal and turn up almost everywhere. Bosman and Bos titled their paper around portability for this reason: an exploit built on signal frames travels across different binaries with little or no change, because it leans on a syscall convention rather than on whatever odd gadgets a particular binary happens to contain. Where classic chains are bespoke to each target, a sigreturn payload is close to write once.

    It barely cares about gadget scarcity

    Because the register values come from the forged frame rather than from gadgets, a binary that is too lean for a normal return oriented chain can still fall to this one. You are no longer searching the binary’s instruction stream for a way to control rsi. You wrote rsi directly into the frame. The attack sidesteps the exact scarcity that defeats classic chains, which is why it is so often the answer when a target offers almost nothing to work with.

    It hands you full register control

    Setting registers precisely is the hard part of many exploits, and here it is free. One rt_sigreturn sets all of them to known values in one shot, which makes the next step, invoking a system call with carefully chosen arguments, completely deterministic.

    Chaining syscalls into a shell

    The payoff of full register control is the ability to make any system call you like with any arguments you like. The canonical goal is a shell, which on x86-64 means calling execve("/bin/sh", NULL, NULL). The syscall number for execve is 59, which is 0x3b.

    The attacker forges a signal frame whose saved registers describe that call exactly. They set rax to 59 to select execve. They set rdi to the address of the string /bin/sh in memory. They set rsi and rdx to zero for the empty argument and environment pointers. Crucially, they set the saved rip to the address of a syscall instruction. When rt_sigreturn restores this frame, every one of those registers snaps into place and execution jumps straight to the syscall, which now runs execve with the attacker’s arguments. A shell pops.

    Often a single sigreturn is not the end but a stage. A common pattern when there is nowhere known to put the /bin/sh string, or no executable place to land, is to chain frames. The first forged frame calls a syscall like read or mmap to write attacker data into a known, writable, executable location, and it sets the saved rsp so that when that syscall returns, the stack is positioned at the next forged frame, which performs the next step. Each rt_sigreturn both performs a syscall and repositions the stack for the one after it, so a series of frames becomes a syscall chain that does setup work and then spawns the shell.

    This chaining is why the technique is so flexible in practice. The saved rsp in each frame is the thread that ties the stages together: it lets the attacker walk the stack pointer forward through a prepared sequence of frames without needing any gadget that adjusts the stack. A frame can call mprotect to make a writable region executable, then the next frame can jump into freshly written shellcode, then a final frame can clean up. The attacker is, in effect, scripting the kernel’s own restore path into a small virtual machine where each instruction is one forged frame and one syscall. That is a long way from the brittle, binary specific gadget hunting that classic chains demand.

    Where the syscall and the string come from

    The technique still needs two concrete addresses: somewhere to find a syscall instruction to put in the saved rip, and somewhere to find or place the /bin/sh string. This is where a known fixed location matters. Historically the vsyscall page on x86-64 Linux sat at the constant address 0xffffffffff600000 and was executable, which gave attackers a syscall gadget at a hardcoded spot regardless of address randomization. An mmap region created with a fixed address, or any leaked address that reveals where executable bytes and writable memory live, serves the same purpose. The sigreturn frame supplies the registers, but the attacker still has to point rip at real executable code, so a stable or leaked location is the other half of the recipe. This dependence on a known address is the same kind of memory layout problem you see in a kernel use after free, where control of where a stale object lives is what turns a dangling reference into a write primitive.

    How it relates to and differs from classic ROP

    It helps to be precise about the family relationship. Both classic return oriented programming and the sigreturn variant are code reuse attacks. Neither injects new executable code into the process, which is the point: they defeat the no execute protections that made plain shellcode on the stack stop working. Both rely on the attacker controlling the stack and the return address. So far they are siblings.

    The difference is where the register values come from. Classic chains source each register value from a separate gadget already present in the binary, then string those gadgets together with return addresses, so the chain’s power is bounded by what gadgets the binary contains. The sigreturn variant sources every register value from a single forged data structure that the kernel itself will faithfully load, so its power is bounded only by the attacker’s ability to write a frame and trigger one syscall. One is a sequence of borrowed instructions. The other is a single borrowed kernel mechanism that hands over the whole CPU at once. In CTF and real exploitation practice the two are routinely combined: a short classic chain sets rax to 15 and reaches a syscall, and that single act detonates the forged frame.

    Mitigations

    Because the flaw is an assumption rather than a memory bug, the defenses are a layered set rather than a single patch. None of them is a silver bullet, and one common belief about defense is simply wrong.

    Signal cookies, the direct fix

    The most targeted defense is the one Bosman and Bos proposed in the original paper: a signal cookie, sometimes called a sigreturn cookie. The idea is to make the kernel able to tell its own frames from forged ones. When the kernel writes a real signal frame, it also stores a random value derived from a secret combined with the address where the frame sits, in effect a canary bound to that stack location. On rt_sigreturn the kernel recomputes and checks that value before trusting the frame. An attacker who forges a frame cannot produce the right cookie without knowing the secret, so the forged frame is rejected. This directly attacks the trusted bytes problem at its root, and variants of this mitigation have appeared in some kernels. The elegance of the approach is that it changes the trust model rather than the layout: the kernel stops assuming that a frame on the stack is its own and starts proving it, which is exactly the assumption the attack abused.

    Vsyscall emulation and reduced fixed locations

    The old executable vsyscall page at its constant address was a gift to attackers, so modern kernels emulate it rather than letting code execute there directly. Since Linux 3.3 an attempt to run instructions in that page traps instead of executing, which removes one reliable, ASLR proof source of a syscall gadget. This does not stop the technique, but it removes a convenient fixed foothold and forces the attacker to find an executable address some other way.

    Control flow integrity and hardware shadow stacks

    Control flow integrity aims to ensure that indirect control transfers only land at intended targets, which constrains the return oriented building blocks the attacker stitches together. At the hardware level, Intel’s Control flow Enforcement Technology adds a shadow stack: a protected copy of return addresses that the CPU checks, so a corrupted return address on the normal stack no longer redirects execution unnoticed. These raise the cost of the surrounding chain that gets you to the syscall in the first place, though they target control flow hijacking broadly rather than the signal frame trust specifically.

    Full RELRO and hardening the rest of the path

    Defenses that close off the primitives an attacker uses to reach rt_sigreturn matter too. Full RELRO maps the global offset table read only after startup, removing a popular write target that exploits use to hijack control flow, which makes the initial stack write or redirect harder to obtain. Hardening the overflow or write primitive that the attack depends on shrinks the opening before signal frames ever enter the picture.

    The ASLR misconception

    It is tempting to assume address space layout randomization stops this. It does not, not on its own. Randomization hides where code and the stack live, which raises the bar, but the sigreturn technique only needs the attacker to write a frame to a stack they already control and to point rip at one executable address. Once any information leak gives up a single address, the layout is known and randomization is spent. Treat ASLR as one delaying layer that a leak cancels, not as a defense against forged signal frames. This is the same trap as treating a leak resistant looking design as safe: the moment one address escapes, the assumption underneath collapses.

    The assumption that breaks

    Strip away the frame layouts and the syscall numbers and one assumption is left holding everything up. The kernel assumes that a signal frame sitting on the user stack is one the kernel itself put there. The rt_sigreturn path was designed as the kernel’s private return road, a way to undo a context switch it had performed moments earlier, and it was built on the premise that only the kernel ever lays a frame down. So it reads the bytes and loads them into every register without a second look. The attacker never breaks that mechanism. They simply place a frame of their own on the stack and let the kernel do exactly what it was always going to do.

    The bug is not a corrupt syscall or a broken handler. The bug is a trust boundary drawn in the wrong place: the kernel trusted the contents of memory it had already handed to the process, and that memory is precisely what an attacker controls. That gap, between what a system assumes about who wrote some bytes and who actually can, is the kind of flaw you find by asking what each component trusts and why it still trusts it, rather than by scanning for a known bad pattern. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to surface. Verify the frames you restore, narrow the fixed locations an attacker can lean on, and remember that a leak turns randomization back into a known address. Learn more about that approach on our about page.

    Frequently asked questions

    What makes sigreturn oriented programming so powerful?

    When a signal is delivered, the Linux kernel saves a full register snapshot, the signal frame, onto the user stack, and the rt_sigreturn syscall restores every register from it without checking that the frame is genuine. An attacker who controls the stack forges that frame and sets rax, rdi, rsp, and rip all at once with a single gadget, instead of hunting for one gadget per register the way classic chains do. The technique was introduced by Erik Bosman and Herbert Bos in Framing Signals: A Return to Portable Shellcode.

    How does SROP differ from classic ROP?

    Both are code reuse attacks that need a stack write and never inject new code, so they survive no execute protections. The difference is where register values come from. Classic return oriented programming sources each value from a separate gadget already in the binary, so it is limited by gadget availability. The sigreturn variant sources every value from one forged signal frame the kernel faithfully loads, so it works even on lean binaries. See the overview of sigreturn oriented programming for the comparison.

    How does a forged frame spawn a shell?

    The attacker builds a signal frame whose saved registers describe execve("/bin/sh", NULL, NULL): rax set to 59, rdi pointing at the /bin/sh string, rsi and rdx set to zero, and the saved rip aimed at a syscall instruction. Triggering rt_sigreturn with syscall number 15 loads the whole frame and runs the call. The SigreturnFrame helper in pwntools builds this byte layout automatically.

    Does ASLR stop sigreturn oriented programming?

    Not on its own. Address randomization hides where code and the stack live, but the attack only needs to write a frame to a stack the attacker already controls and to point rip at one executable address. A single information leak reveals that address and the layout is known. The real fix is a signal cookie that binds a secret to the frame’s stack location, the mitigation described in the sigreturn(2) manual and the original research, so the kernel can reject forged frames.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How a Container Escape Works: The cgroups v1 release_agent Technique

    How a Container Escape Works: The cgroups v1 release_agent Technique

    A container escape happens when a process running inside a container breaks out of its restricted view of the system and starts acting on the host directly, usually as root. The reason this is even possible comes down to one fact people forget: a container is not a virtual machine. It is an ordinary Linux process that the kernel has wrapped in restricted namespaces and cgroups, and that process shares the exact same kernel as the host and every other container on the box. There is no hypervisor in between. This post walks one specific, real escape end to end, the cgroups v1 release_agent technique tracked as CVE-2022-0492, and then steps back to the wider family of escapes that all rely on the same shared kernel boundary.

    Why a container escape is possible at all

    Start with what a container actually is, because the whole escape hinges on it. When you run docker run or start a pod, you do not boot a second machine. The kernel takes a normal process and changes what it can see. Namespaces give it a private view of process IDs, mount points, network interfaces, and user IDs, so from inside it looks like the process owns the system. Cgroups (control groups) cap how much CPU, memory, and IO it can use. Capabilities and seccomp filters trim which privileged operations and syscalls it is allowed to make. Stack those together and you get isolation that feels like a separate machine.

    But every one of those layers is enforced by the same kernel the host runs. A virtual machine gets a virtual CPU and virtual hardware from a hypervisor, and the guest kernel is genuinely separate; to escape a VM you have to defeat the hypervisor itself. A container has none of that. The isolation is just bookkeeping inside one shared kernel. So if you can reach a kernel interface that was never namespaced, or you hold a capability that the kernel trusts more than it should, or the kernel has a bug you can hit from inside, the boundary is not a wall. It is a convention, and conventions can be talked out of.

    That is the mental model for the rest of this post. The container assumes the kernel will keep enforcing its restricted view. CVE-2022-0492 is what happens when one kernel interface forgets to check who is allowed to touch it.

    It is worth being precise about capabilities here, because the whole vulnerability turns on a subtlety in how they work. A capability is a slice of root’s power that the kernel can hand out one piece at a time instead of granting everything at once. CAP_NET_BIND_SERVICE lets a process bind a low port. CAP_SYS_ADMIN is the grab bag that covers mounting filesystems, setting hostnames, and a long list of other administrative actions, which is why it is often described as the new root. A default container runtime hands a container a deliberately small set and drops the dangerous ones. The kernel then checks, at the moment of each privileged action, whether the calling process holds the capability that action requires. The escape we are about to walk is, at bottom, a story about the kernel checking that the caller holds CAP_SYS_ADMIN but checking it in the wrong place.

    The cgroup v1 release_agent mechanism

    To understand the escape you first have to understand a perfectly legitimate cgroups feature that was never meant to be reachable from inside a container.

    What release_agent and notify_on_release do

    In cgroups version 1, every control group can carry two special files. The first is notify_on_release, a flag set to 0 or 1. The second is release_agent, which lives at the root of a cgroup hierarchy and holds a path to a program. The deal is simple. When notify_on_release is set to 1 on a cgroup and the last process in that cgroup exits, leaving it empty, the kernel runs the program named in release_agent to clean up. This is a real housekeeping mechanism documented in the kernel cgroups manual page. It exists so userspace can react when a group empties out.

    The critical detail is who runs that program and where. The kernel invokes the release_agent binary itself, from the host context, as a fully privileged root process with all capabilities, in the host’s namespaces. It is not run inside the container. It is run by the kernel on the host. So if an attacker inside a container can write a path of their choosing into a release_agent file and then cause a cgroup to empty, the kernel will execute their chosen program as root on the host. That is the entire escape in one sentence. Everything else is about getting permission to write that file.

    The capability that was supposed to guard it

    Writing to release_agent is obviously dangerous, so the kernel gates it behind a capability. The relevant capability is CAP_SYS_ADMIN, the broad administrative capability that container runtimes strip from containers by default precisely because it is so powerful. A normal Docker or Kubernetes container does not hold CAP_SYS_ADMIN, so under default settings it cannot write release_agent, and the housekeeping feature stays a housekeeping feature.

    For years that was the assumed boundary. If you wanted to abuse release_agent, you needed CAP_SYS_ADMIN, and if you had CAP_SYS_ADMIN you were already a heavily privileged container that could do plenty of damage anyway. The interesting question, and the one CVE-2022-0492 answers, is whether a container could obtain a working CAP_SYS_ADMIN over a cgroup mount without the host ever granting it.

    The classic escape with a privileged container

    It helps to see the abuse in its original, non vulnerability form first, because the vulnerability simply removes the precondition. Picture our invented note taking service, Acme Notes, which runs each customer’s background jobs in a container. Suppose an attacker has found a way to run as root inside one of those job containers, and the container was started privileged so it does hold CAP_SYS_ADMIN. The escape is a short sequence:

    mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp
    mkdir /tmp/cgrp/x
    echo 1 > /tmp/cgrp/x/notify_on_release
    host_path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
    echo "$host_path/cmd" > /tmp/cgrp/release_agent
    echo '#!/bin/sh' > /cmd
    echo "cat /etc/shadow > $host_path/output" >> /cmd
    chmod a+x /cmd
    sh -c "echo 0 > /tmp/cgrp/x/cgroup.procs"

    Read it top to bottom. You mount a cgroup v1 controller (the rdma controller is a common pick) so its hierarchy is writable. You make a child cgroup x and turn on notify_on_release for it. You find the container’s path on the host filesystem by reading the overlay mount info, then write host_path/cmd into release_agent, so the kernel will look for the agent at a path that resolves to a file inside your container. You drop a small script at /cmd that does whatever you want, here dumping the host’s /etc/shadow back to a place you can read. Finally you write a PID into the child’s cgroup.procs and let it exit, emptying the cgroup. The kernel sees the empty group, reads release_agent, and runs /cmd as root on the host. You just executed code outside the container.

    The kernel was never tricked into running the wrong file. It ran exactly the file it was told to, as root, on the host, because nothing checked that the process which named that file had any business naming it.

    CVE-2022-0492: the missing check

    The classic technique above needs a privileged container with CAP_SYS_ADMIN. CVE-2022-0492 is the discovery that an unprivileged container could reach the same write through a back door, because the kernel’s permission check on release_agent was wrong.

    What Unit 42 found

    The vulnerability was disclosed in early 2022 by Yiqi Sun and Kevin Wang, with the most detailed public writeup published by Palo Alto Networks’ Unit 42 research team. The flaw lived in the cgroup_release_agent_write function in kernel/cgroup/cgroup-v1.c. That function is what runs when something writes to a release_agent file, and it was supposed to confirm the writer was sufficiently privileged before accepting the new path. It did not. The function failed to verify that the writing process held CAP_SYS_ADMIN in the initial user namespace. The official CVE record for CVE-2022-0492 describes it as allowing the cgroups v1 release_agent feature to escalate privileges and bypass namespace isolation, and NVD scores it CVSS v3.1 7.8, high severity.

    What makes the finding sharp is that the underlying release_agent abuse was already public and understood as a privileged container trick. The contribution was noticing that user namespaces had quietly changed the threat model: a feature whose guard assumed only a genuinely privileged process could ever reach it was now reachable by any process that could spin up its own user namespace and call its bluff. The bug had reportedly been present since the relevant code path was introduced years earlier, sitting in plain sight, dangerous only once unprivileged user namespaces became common enough to weaponize. That is the recurring texture of this class of flaw. Nothing crashed, nothing leaked, the code did exactly what it said. It just trusted the wrong namespace.

    Why an unprivileged user namespace is the key

    This is the part that turns a missing check into a real escape. Linux user namespaces let an unprivileged process create a new user namespace in which it is root and holds a full set of capabilities, including CAP_SYS_ADMIN, but only over resources owned by that new namespace. The whole point of user namespaces is that this capability is local and fake from the host’s point of view. You are root in your little bubble; the host still sees you as nobody. Inside that new user namespace you are allowed to create a new mount namespace and mount a fresh cgroup v1 hierarchy, and within that hierarchy you have a writable release_agent file.

    Now the two pieces meet. The attacker holds CAP_SYS_ADMIN, but only in the new user namespace, which should not count for a host level action like setting a release agent. The kernel’s job in cgroup_release_agent_write was to notice that and refuse. Because the check was missing, the kernel accepted the write from a process whose CAP_SYS_ADMIN was the local, namespaced, supposed to be harmless kind. The attacker then runs the same notify_on_release sequence, empties the cgroup, and the kernel dutifully executes their script as real root on the host. An unprivileged container, given that user namespaces are enabled and no extra hardening blocks the steps, escapes to the host.

    The distinction the kernel missed is the difference between two functions with very similar names. ns_capable asks whether the caller holds a capability in some particular user namespace, which a process that just created its own user namespace always satisfies, because it minted itself a full capability set when it created the namespace. capable asks whether the caller holds the capability in the host’s original user namespace, the one no unprivileged process can fake its way into. The release agent write must demand the second kind, because the program it stores gets run as host root. The vulnerable code effectively settled for the first kind, or for no kind at all, which is why a process that was root only inside its own bubble could set a file that the kernel would then honor with the real thing. The gap between those two questions is the entire CVE.

    One more nuance makes the escape practical rather than theoretical. The attacker has to name a program the kernel can actually find and run from the host context. Because the kernel resolves the release_agent path on the host, the attacker reads the container’s location on the host filesystem out of the mount information, usually the overlay upperdir, and writes a path that lands inside files they already control from within the container. So the script the kernel executes as root is a file the attacker wrote inside the container, reached by its true host path. No file is smuggled across the boundary; the same bytes are simply addressed two ways.

    This is fundamentally a privilege escalation dressed as a container escape. The container gains an authority it was never assigned by exploiting an interface that trusted a capability it should have distrusted.

    The kernel fix

    The fix is almost anticlimactically small, which is what makes it instructive. The patch landed in mainline as commit 24f6008564183aa120d07c03d9289519c2fe02af and added the check that should always have been there. Before accepting a write to release_agent, the function now confirms the caller is operating in the initial user namespace and holds genuine CAP_SYS_ADMIN, using capable(CAP_SYS_ADMIN) against the host’s init_user_ns rather than the namespace local ns_capable check that a user namespace could satisfy. If the writer’s user namespace is not init_user_ns, or it lacks real CAP_SYS_ADMIN, the write is rejected with EPERM. That single distinction, host capability versus namespaced capability, is the whole bug and the whole fix. The fix shipped in 5.17 and was backported to the maintained stable trees.

    The broader family of container escapes

    The release_agent trick is one entry in a catalog, and it is worth knowing the neighbors, because they all share the shared kernel premise even when the specific door differs.

    Privileged containers

    A container started with --privileged is barely a container at all. It keeps almost all capabilities, including CAP_SYS_ADMIN, and it can see host devices. The classic release_agent escape works directly from such a container with no vulnerability required, and so do many other tricks, because a privileged container is one short step from being a host root shell. The lesson is that privileged is a decision to drop the boundary, not a convenience flag.

    A mounted docker.sock

    Mounting the Docker daemon socket, /var/run/docker.sock, into a container hands that container the ability to talk to the Docker daemon, which runs as root on the host. From inside, the process can ask the daemon to start a new container that mounts the host’s root filesystem and runs as root, then read or write anything on the host. There is no kernel bug here at all. The container was simply given a control channel to a privileged host service.

    Exposed host mounts

    Bind mounting sensitive host paths into a container, the host root, /etc, the Docker directory, or device nodes, gives the container direct reach into host state. Write access to the right host file, such as a script the host runs on a schedule or a configuration the host trusts, is escape enough. The boundary leaks wherever a writable path crosses it.

    A vulnerable shared kernel

    Because the kernel is shared, any kernel memory corruption bug reachable from inside a container is a candidate escape. Dirty COW (CVE-2016-5195) and Dirty Pipe (CVE-2022-0847) are the famous examples: both let an unprivileged process overwrite files it should only be able to read by abusing a flaw in how the kernel handles copy on write or pipe page memory, and both can be fired from inside a container to overwrite a host owned file and gain root. A different flavor of the same family is a kernel use after free, where freed kernel memory is reclaimed and reused to corrupt state the attacker controls. The common thread is unmistakable: one kernel, shared by host and container, so a kernel bug is a host bug.

    Defending against the release_agent escape

    The good news is that the same hardening that blocks most of this family blocks the release_agent path too. Patch the kernel so cgroup_release_agent_write enforces the real capability check. Keep the default seccomp and the default AppArmor or SELinux profiles in place, because they deny the mount and write steps the exploit needs; Unit 42 noted the escape only works against containers running without those protections. Drop CAP_SYS_ADMIN and run unprivileged. Where you do not need them, disabling unprivileged user namespaces removes the mechanism that hands an unprivileged container its local CAP_SYS_ADMIN in the first place. And prefer cgroups v2, which does not carry the release_agent and notify_on_release interface in the form this exploit abuses. None of these is exotic. They are the defaults, and the escape mostly works where the defaults were removed.

    The assumption that breaks

    Underneath all of it sits one assumption, and it is the same assumption every time. A container assumes the kernel boundary holds. It behaves as though its namespaces and cgroups are a wall around it, as solid as the virtual hardware around a VM. But the kernel is not a wall around the container. It is the floor under both the container and the host, one shared surface, and the container is standing on it right next to everything it is supposed to be isolated from. The moment a single capability is trusted too far, or a single kernel interface forgets to ask who is calling, or a single host path is left writable across the line, the boundary was never there. It was a set of checks, and one missing check, the absent CAP_SYS_ADMIN test in cgroup_release_agent_write, was enough to collapse the whole thing into a root shell on the host.

    That is the shape of bug you only find by asking what each layer trusts and why it still trusts it, rather than by scanning for a known bad pattern. The vulnerability was not a crash or a corrupted pointer. It was an interface that assumed a capability meant what it used to mean before user namespaces made capabilities local and cheap. Finding it meant questioning a boundary everyone treated as settled. That is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to catch: not the malformed input, but the quiet premise that the wall is a wall. Read more about that approach on our about page.

    Frequently asked questions

    What is a container escape?

    A container escape is when a process inside a container breaks out of its restricted namespaces and cgroups and acts on the host directly, usually as root. It is possible because a container is not a virtual machine; it is an ordinary process that shares the same kernel as the host, so one over trusted capability or one writable host interface can collapse the boundary. The Unit 42 analysis of CVE-2022-0492 walks a real example end to end.

    How does the cgroups v1 release_agent escape work?

    In cgroups v1 a hierarchy can hold a release_agent file naming a program the kernel runs as root on the host when a cgroup with notify_on_release set to 1 becomes empty. If an attacker can write a path into release_agent and then empty a cgroup, the kernel executes their chosen script as root outside the container. The man7 cgroups documentation describes the legitimate release agent and notify_on_release mechanism this abuses.

    What did CVE-2022-0492 actually break?

    The cgroup_release_agent_write function in kernel/cgroup/cgroup-v1.c failed to verify that the process writing release_agent held real CAP_SYS_ADMIN in the initial user namespace. An unprivileged container could create a user namespace where it holds a local, supposed to be harmless CAP_SYS_ADMIN, mount a writable cgroupfs, and write the file the kernel trusted. The flaw is documented at CVE-2022-0492 and scored CVSS 7.8 high by NVD.

    How do you defend against this container escape?

    Patch the kernel so cgroup_release_agent_write enforces the real capability check (the fix landed in commit 24f6008564183aa120d07c03d9289519c2fe02af), keep the default seccomp and AppArmor or SELinux profiles, drop CAP_SYS_ADMIN, avoid privileged containers and mounted docker.sock, and disable unprivileged user namespaces where you do not need them. The Sysdig writeup on CVE-2022-0492 covers detection and mitigation.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What Is Web Cache Deception and How a Crafted URL Leaks Private Pages

    What Is Web Cache Deception and How a Crafted URL Leaks Private Pages

    Web cache deception is an attack where a CDN or caching proxy is tricked into storing a victim’s authenticated, private response under a URL the attacker can fetch for themselves. The attacker lures a logged in victim to a crafted link like https://app.acmenotes.com/account/settings/nonexistent.css. The origin server ignores the extra suffix and serves the victim’s real account page, full of personal data and tokens. The cache, looking at the same URL, sees a .css ending and decides this must be a harmless stylesheet worth saving. It stores the private page under that key. The attacker then requests the very same URL, the cache serves its stored copy, and the victim’s private response lands in the attacker’s browser. This post walks the mechanism one step at a time: why the origin and the cache read the same URL differently, how a cache decides what to store, what the attacker actually walks away with, how this differs from web cache poisoning, and how to close the gap.

    The disagreement at the heart of web cache deception

    A cache sits between your users and your origin server to make things fast. When many people ask for the same stylesheet, script, or image, there is no reason to bother the origin every time. The cache keeps a copy of the response and hands it out to everyone who asks for that URL. This works beautifully for content that is the same for every visitor and stays the same for a while. Static assets are the textbook case.

    The whole arrangement rests on one quiet assumption: that a given URL means the same thing to the cache as it does to the origin. Web cache deception is what happens when that assumption is false. The cache and the origin both look at /account/settings/nonexistent.css and reach different conclusions about what it is. The origin routes by path prefix and decides this is the account settings page. The cache classifies by file extension and decides this is a CSS file. One of them is serving private, per user content. The other is treating that content as a public asset safe to store and replay. The attack lives entirely in that gap.

    How the origin reads the path

    Most application servers do not match a request against a literal file on disk. They route. A framework looks at the leading part of the path, matches it to a handler, and treats whatever trails behind as a parameter, a path variable, or simply noise it can ignore. A request to /account/settings hits the settings handler. A request to /account/settings/nonexistent.css very often hits the exact same handler, because the router matched on /account/settings and never cared about the /nonexistent.css tacked on the end. The origin happily renders the logged in user’s settings page and returns it with a 200 OK. As Omer Gil described the condition in his original 2017 research, the requirement is simply that the server returns the content of the real page for the decorated URL rather than a 404. The suffix is invisible to the application but very visible to everything downstream.

    How the cache reads the same path

    The cache makes its decision on different grounds. A common and reasonable cache rule says: anything ending in a known static extension is cacheable. CSS, JS, PNG, GIF, ICO, WOFF, and a long tail of others. The logic is that files with those extensions are assets, assets do not contain secrets, and caching them is pure speed with no downside. So when the response to /account/settings/nonexistent.css comes back, the cache looks at the URL, sees .css, and stores the response. It often does this even when the origin’s own caching headers said not to, because an extension based rule can be configured to override or ignore Cache-Control. The cache is not reading the body. It does not know it just filed a page full of one specific user’s data under a public key. Omer Gil’s PayPal report listed more than forty extensions that PayPal’s cache would store this way, from css and js down to ico and swf.

    Two independent, defensible decisions have now combined into a vulnerability. The origin decided the suffix was meaningless. The cache decided the suffix was authoritative. Neither component is broken on its own. The bug is the disagreement between them.

    It helps to see why each side made the choice it did. The origin’s router is built for flexibility. Modern frameworks encourage clean, expressive routes, and matching on a leading prefix while ignoring trailing junk is a feature, not an oversight. It lets developers write one handler for /account/settings and not worry about every odd thing a browser or proxy might append. The cache, for its part, was tuned for a world where the URL is an honest signal of content type. For most of the web’s history, a path ending in .css really was a stylesheet, and trusting the extension was a cheap, reliable shortcut. Each component optimized for its own job under a reasonable assumption about the other. The attacker simply found the one input where those two reasonable assumptions point in opposite directions.

    The cache key is not the whole URL

    To see why the attacker can retrieve what the victim triggered, you have to look at the cache key. A cache does not index its stored responses by the full request. It builds a key, usually from the URL path and some chosen query parameters, and crucially that key does not include the victim’s session cookie. Cookies are exactly the thing that makes a response personal, and they are normally left out of the key so that the cache can serve one stored copy to many users.

    That omission is the engine of the attack. The victim’s request to /account/settings/nonexistent.css carried their session cookie, so the origin rendered their private page. But the cache filed that private response under a key built only from the path. When the attacker later requests the identical URL, with no cookie or with their own, they produce the same cache key. The cache matches the key, sees a stored response, and serves it without ever consulting the origin. The session that authorized the content is long gone from the picture. The attacker did not need the victim’s cookie because the cache already stripped the cookie out of the key and kept the response.

    The victim’s credentials fetch the private page once. The cache then serves that page to anyone who knows the URL, because the thing that made it private was never part of the key.

    Beyond the file extension trick

    The clean .css suffix is the original and most intuitive form, but the same disagreement shows up in subtler shapes. The PortSwigger Web Security Academy catalogs several, and they all reduce to the cache and the origin parsing the URL by different rules.

    Static directory rules

    Caches are often told to store anything under a particular directory prefix, like /static, /assets, or /resources. The intent is to cache the asset folder wholesale. If the origin’s router is loose about where that prefix appears, an attacker can craft a path that the cache sees as living under /assets while the origin still routes it to a dynamic, authenticated handler. No file extension is needed at all. The cacheable signal is the directory, and the path confusion smuggles private content into it.

    Delimiter and path parameter discrepancies

    Different stacks disagree about which characters end a path and which are just data. A semicolon is a meaningful path parameter delimiter in some Java servers and inert punctuation elsewhere. An encoded character like %2f may be decoded to a slash by one component and left literal by another. When the cache truncates the URL at a delimiter the origin ignores, or matches an extension the origin treats as part of an earlier path segment, the two views split apart again. The attacker’s job is to find a single character or encoding that the origin reads one way and the cache reads another, then build the gap from that seam. OWASP files this whole family under path confusion, and its testing guide points testers at exactly these decorated URLs.

    Normalization gaps

    Caches and origins also resolve path traversal and normalize sequences differently. If a cache collapses ..%2f before keying but the origin resolves it after routing, or the reverse, an attacker can present a path that appears to sit under a static prefix to one and under a dynamic route to the other. Same root cause, different mechanical lever: the two parsers do not agree on what the bytes in the path mean.

    What the attacker actually walks away with

    The stored response is whatever the victim would have seen on that authenticated page, and that is rarely just cosmetic. Account pages routinely embed the things that matter most. Personally identifiable information sits in the page body: names, email addresses, postal addresses, phone numbers, partial card numbers, balances. Gil’s PayPal disclosure noted the leak could expose exactly this class of data, names, account balances, card digits, transaction history, and more. That alone is a serious data breach with no further work.

    It frequently gets worse, because authenticated pages also carry security tokens in their markup. A CSRF token printed into a hidden form field is meant to prove that a request came from the real user. If the page holding that token gets cached and handed to an attacker, the token leaks, and a defense against forged requests becomes a gift to the forger. Some pages expose session identifiers, API keys, or single use links in the same way. Once any of those land in the cached copy, the attacker can escalate from reading the victim’s data to acting as the victim, which is the path to full account takeover.

    The delivery is also low effort for the attacker. There is no malware and no exploit chain to detonate; there is a link. The attacker sends the victim a crafted URL through email, a chat message, or any page the victim will click, exactly the way a phishing link travels. The victim does not have to type anything, log in again, or approve a prompt. They are already authenticated, and clicking the link silently fires off the request that primes the cache. From the victim’s point of view nothing dramatic happens; the page they land on may even look normal or show a missing stylesheet for a fraction of a second. The damage is invisible until the attacker fetches the stored copy. This is part of why the attack is so durable: the visible footprint on the victim’s side is close to nothing.

    The reach of the attack is not narrow. Gil reported that when he tested high profile sites, a meaningful fraction were exploitable. Later academic work has kept confirming the prevalence at scale. A 2024 study, Hidden Web Caches Discovery by Matteo Golinelli and Bruno Crispo, used a timing based method to find caches that do not even announce themselves through response headers, measuring roughly 5.8 percent of the Tranco top 50,000 sites running such hidden caches, of which over a thousand were susceptible to web cache deception. A cache you cannot see in the headers is still a cache that can store and leak a private page. That last point is worth dwelling on, because it undercuts a common defensive instinct. Teams often reason about caching by reading response headers, assuming that if they do not see a cache status header they are not being cached. A hidden cache breaks that assumption outright. The infrastructure may cache silently, and the only way to know is to probe its behavior rather than trust what it advertises.

    What separates this from cache poisoning

    Web cache deception is constantly confused with web cache poisoning, and they are genuinely different attacks pointed in opposite directions. PortSwigger draws the line cleanly: poisoning manipulates the cache key to inject malicious content into a cached response that is then served to other users, while deception exploits cache rules to trick the cache into storing sensitive content that the attacker then retrieves for themselves.

    Read that again by the direction of harm. In cache poisoning, the attacker is the source of bad content and the victims are everyone else. The attacker finds an unkeyed input, some header or parameter the origin reflects into the response but the cache leaves out of the key, and they use it to plant a malicious payload under a popular URL. The next thousand visitors who request that URL get the attacker’s poisoned response. The flow runs from attacker, into the cache, out to the crowd.

    In web cache deception, the direction reverses. The victim is the source of the sensitive content and the attacker is the single beneficiary. The attacker lures one logged in victim to fetch their own private page, the cache stores it, and the attacker pulls that one stored copy back out. Nothing malicious is injected. The response is completely legitimate; it is simply the wrong person’s response, served to the wrong person. Poisoning is about controlling what a shared cache serves. Deception is about reading what a shared cache should never have stored. The mechanics rhyme, because both exploit a mismatch between what the cache keys and what actually varies the response, but the payload, the victim, and the goal are inverted.

    This origin versus intermediary disagreement is a recurring pattern rather than a one off. It is the same shape as HTTP request smuggling, where the front end and the back end disagree about where one request ends and the next begins. In both cases there is no single broken component, only two components that parse the same bytes by different rules, and an attacker who lives in the gap between their interpretations.

    Closing the gap

    Because the root cause is a disagreement, the fixes all work by removing the disagreement or refusing to act on it.

    The strongest move is to make caching decisions on what the origin actually returns, not on what the URL looks like. A cache that respects the origin’s Cache-Control: no-store and private directives will not store an authenticated page no matter what extension is glued to the path, because the page that produced it asked not to be stored. Send those headers on every response that contains per user data, and configure the cache to honor them rather than override them with a blanket extension rule.

    Next, close the parsing gap at the origin. If a request decorated with /nonexistent.css or a stray delimiter is not a real route, the application should return a 404 or a redirect, not silently serve the underlying page. A router that rejects the decorated path denies the cache anything worth storing. Verify that the response Content-Type matches the extension the cache thinks it is caching; a page served as text/html under a .css URL is the exact contradiction the cache should refuse.

    Finally, narrow the cache rules and align the two parsers. Prefer caching by explicit, known safe paths over broad extension or directory rules that match anything ending a certain way. Where the cache and the origin must both parse a URL, make sure they normalize delimiters, encodings, and traversal sequences identically, so there is no seam for an attacker to pry open. Each of these turns the two disagreeing views back into one.

    It is worth testing for this directly rather than assuming you are safe. Pick an authenticated page, request it with a static suffix like /nonexistent.css appended, and watch what comes back. If the origin still returns the private page with a 200 OK, request the same decorated URL a second time without any session cookie and see whether the private content comes back from the cache. If it does, you have reproduced the attack against your own application, and you know precisely which of the fixes above is missing. Run the same probe against the delimiter and directory variants, because a site can be hardened against the plain extension trick while still leaking through a semicolon or a static directory prefix. This kind of hands on probing is what OWASP’s path confusion guidance asks testers to do, and it surfaces the disagreement far more reliably than reading configuration files and hoping the cache and the origin agree.

    The assumption that breaks

    Strip away the extensions and the delimiters and one assumption is holding the whole thing up. The cache assumes that a URL means the same thing to it as it does to the origin, and that anything dressed as a static asset is safe to store and replay to anyone. The origin assumes the cache will only keep what is genuinely public. Neither side ever checks the other, and the request itself never carries a signal that says this response was personal. So the two views drift apart on a single crafted path, and the gap between them is exactly wide enough to slip one user’s private page into a public slot.

    The bug is not a broken cache or a careless framework. The bug is two correct components trusting that they agree on what a URL means when they do not, and a trust boundary everyone assumed sat at the response when it actually sat in the disagreement over the path. That kind of flaw does not show up by scanning for a known bad string. You find it by asking what each component assumes about the request, and whether the component on the other side shares that assumption. It is exactly the kind of question an autonomous researcher built to test assumptions is meant to ask. Honor the origin’s caching headers, make your router reject the decorated path, and keep the cache and the origin reading the same URL the same way. Learn more about that approach on our about page.

    Frequently asked questions

    What causes a web cache deception vulnerability?

    It is caused by the cache and the origin server disagreeing about what a URL means. The origin routes by path prefix and serves a private page for a decorated URL like /account/settings/nonexistent.css, ignoring the suffix, while the cache classifies the same URL by its .css extension and stores the response as a public asset. Neither component is broken alone; the bug is the mismatch. Omer Gil first described this condition in his 2017 Web Cache Deception Attack research.

    How does the attacker retrieve the victim’s private data?

    Through the cache key. A cache indexes stored responses by the URL path, not by the victim’s session cookie, since cookies are normally excluded so one copy can be served to many users. The victim’s authenticated request stores their private page under a cookieless key, and the attacker then requests the identical URL, produces the same key, and the cache serves the stored copy without consulting the origin. The PortSwigger Web Security Academy explains how cache keys and cache rules combine to enable this.

    How is web cache deception different from web cache poisoning?

    They point in opposite directions. Web cache poisoning manipulates the cache key to inject malicious content into a cached response that is then served to many other users, so the attacker is the source and the crowd is the victim. Web cache deception tricks the cache into storing one victim’s sensitive response, which the attacker alone retrieves, so the victim is the source and nothing malicious is injected. PortSwigger draws this exact distinction in its web cache deception writeup.

    How do you prevent web cache deception?

    Make caching decisions on what the origin returns, not on what the URL looks like. Send Cache-Control: no-store and private on every authenticated response and configure the cache to honor them rather than override them with an extension rule. Have the router return a 404 for decorated paths like /account/settings/nonexistent.css, verify the Content-Type matches the extension, and align how the cache and origin normalize delimiters and encodings. OWASP covers testing for this under Test for Path Confusion.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • How DNS Rebinding Works and Reaches Inside Your Private Network

    How DNS Rebinding Works and Reaches Inside Your Private Network

    A dns rebinding attack is a trick where a web page you open in your browser quietly turns into a client for a device on your own home or office network. The page is served from a name the attacker controls, say rebind.acmeattacker.com, and your browser treats every request to that name as belonging to one origin. The attacker also controls the DNS server for that name, so a moment after the page loads they change the answer. The same name that first resolved to a public server now resolves to a private address like 192.168.1.1 or 127.0.0.1. Your browser keeps thinking it is talking to the same origin, because the hostname never changed, and the attacker’s JavaScript starts speaking directly to your router, your media server, or a service bound to localhost that was never meant to face the internet. This post walks the mechanism one step at a time: why the same origin policy trusts the hostname, how a short DNS time to live lets the attacker swap the IP underneath it, why DNS pinning only partly closes the gap, the answer tricks attackers use, the real devices this has hit, and the defenses that actually hold.

    Why the browser trusts a name it cannot pin down

    The same origin policy is the rule that keeps one site’s JavaScript from reading another site’s data. Two pages share an origin when their scheme, host, and port all match. A script on https://app.acmenotes.com can read responses from https://app.acmenotes.com and is blocked from reading https://api.bank.example. The host comparison is a string comparison on the hostname. As the MDN same origin policy reference describes it, origin is defined by scheme, host, and port, and the host part is the textual name. Nowhere in that comparison does the browser ask which IP address the name currently resolves to.

    That choice is deliberate and mostly reasonable. A single hostname legitimately moves across IP addresses all the time. Load balancers rotate backends, content networks return the nearest edge, failover swaps a dead server for a live one. If the same origin policy were pinned to an IP address, ordinary sites would break every time DNS handed back a different answer. So the policy trusts the name and assumes the name keeps meaning the same thing for the life of the page.

    It is worth being precise about what the origin actually is, because the whole attack lives in the definition. An origin is the triple of scheme, host, and port. The host is a registered domain name or an IP literal, and when it is a name, the browser stores and compares the name itself. The browser does resolve that name to an address in order to open a socket, but the resolved address is an implementation detail of the network layer, not part of the security identity. Two requests to https://rebind.acmeattacker.com are same origin with each other by definition, no matter what each one resolved to at the moment it was sent. The attacker is not breaking the comparison. They are feeding it two different machines under one honest name.

    DNS is the part of the system that turns a name into an address, and it was built to be changeable on purpose. A record carries a time to live, the number of seconds a resolver may cache the answer before it has to ask again. Set that number to one second and you have told every resolver on the path that this answer expires almost immediately. The attacker who runs the authoritative DNS server for their own domain decides that number. They can answer one way now and a completely different way a second later, and the protocol considers both answers correct.

    Put those two facts side by side and the gap appears. The browser fixes the origin on the name. DNS lets the attacker change what the name points at. The browser never rechecks.

    The time of check versus time of use gap

    The cleanest way to see dns rebinding is as a time of check to time of use bug, the classic shape where a system validates something once and then relies on that validation after the thing has changed. Here the check is the initial DNS lookup and the same origin decision that rides on it. The use is every later request the page makes to that same hostname.

    Walk the sequence. The victim visits rebind.acmeattacker.com. Their browser asks the attacker’s DNS server for the address and gets back a normal public IP, say the attacker’s own web server, with a time to live of one second. The page loads, the malicious script runs, the origin is now fixed on that hostname. So far nothing is unusual and nothing private has been touched.

    The script then waits, or makes a request that it knows will force a fresh lookup once the one second cache entry expires. The browser asks the attacker’s DNS server again. This time the answer is 192.168.1.1, the victim’s own router. From the browser’s point of view nothing about the origin has changed. The scheme is the same, the host string is the same, the port is the same. So it sends the request, including any work the script wants done, straight to the router. The check happened against the public server. The use lands on the private one. The window between them is the whole attack.

    The reason the attacker bothers with this dance, rather than just pointing their page at 192.168.1.1 directly, is that the same origin policy would stop the direct approach cold. A page served from https://rebind.acmeattacker.com cannot read responses from http://192.168.1.1, because those are plainly different origins. The browser would let the request go out but hide the response from the script, which is useless to the attacker. Rebinding exists precisely to make the private address wear the attacker’s hostname, so the response comes back to a script that is allowed to read it. The attacker is borrowing the victim’s own browser as a proxy that sits inside the network and, crucially, is trusted to read what it gets back.

    What the attacker needs from the victim is almost nothing. There is no exploit of the browser, no malware, no breached account. The victim only has to open a tab, which an attacker arranges with an advertisement, a link, or any embedded frame on a page the victim already visits. The page can keep the victim busy with ordinary looking content while the script quietly cycles through internal addresses in the background. By the time anything is noticeable, the requests have already been made and the responses already read.

    The browser never lied about the origin. The origin simply stopped meaning what it meant at the instant the browser decided to trust it.

    DNS pinning and why it is incomplete

    Browsers noticed this years ago and added a countermeasure called DNS pinning. The idea is simple. Once the browser has resolved a hostname and started using it, hold onto that first IP address for the lifetime of the page even if the DNS record’s time to live says the answer has expired. If the browser refuses to follow the rebind, the second lookup never reaches the router, and the attack dies.

    Pinning helps, but it was never a complete fix, for reasons that are structural rather than bugs to be patched away. The browser cannot pin forever. A page can stay open for hours, connections drop and get reestablished, and a pin that lasted indefinitely would break legitimate failover. So pins expire. An attacker who is willing to wait, or who can make the original connection fail, gets a fresh lookup and a fresh chance to rebind.

    Pinning also lives in only one place. The browser may pin, but it is not the only component resolving names and caching answers. The operating system has its own resolver cache, the local network may run its own, and these layers do not coordinate their pins. An answer that one layer considers expired another may serve fresh. The gaps between independent caches are exactly where a patient rebind slips through.

    There is a deeper limit too. Pinning binds a name to an address inside one browser process for one page session, but the attacker controls time. A rebinding script does not have to win in the first second. It can hold the tab open, throttle its own requests, and simply outlast whatever pin the browser is willing to maintain. The economics here favor the attacker the same way they do elsewhere in security. The defender has to keep the pin perfect across every cache and every reconnect. The attacker only needs the pin to lapse once.

    Multiple A records and the 0.0.0.0 trick

    Attackers found ways to make rebinding faster and more reliable than waiting on a cache to expire. One is to return multiple A records in a single answer. The attacker’s DNS server replies with two addresses for the name at once, their public server and the target’s private address. The browser connects to the public one first because that is where the page is served. Then the attacker makes their own server stop answering on that port. The browser, holding a name that still has a valid private address in the same record set, fails over to the private address without any new lookup at all. The rebind happens inside one cached answer, so pinning on the time to live buys nothing.

    A related family of tricks abuses how some systems treat special addresses. The address 0.0.0.0 is not a normal destination. On many operating systems a connection to 0.0.0.0 is routed to localhost, so a service bound to 127.0.0.1 can be reached through it. This has been the basis of a long running class of issues, often discussed as the 0.0.0.0 problem, where a public page reaches a service the developer believed was safely bound to localhost only. Combine that with rebinding and a service that listens on the loopback interface, assuming nothing on the wider network can talk to it, is suddenly reachable from a tab the user opened by accident.

    What dns rebinding actually reached in the wild

    This is not theoretical. The most thoroughly documented modern survey is the NCC Group researcher Brannon Dorsey’s writeup, Attacking Private Networks from the Internet with DNS Rebinding, which walked the full chain against consumer hardware and named the devices. The pattern across all of them is the same. Each device exposed an HTTP control interface on the local network with no authentication, on the unstated assumption that only software already inside the home would ever reach it.

    Google Home and Chromecast devices exposed an undocumented REST API on port 8008 that required no authentication and could launch apps, play content, reboot the device, and scan for nearby WiFi networks, which in turn enabled rough geolocation of the home. Roku devices exposed an External Control API on port 8060 with the same no authentication shape, tracked as CVE-2018-11314. Sonos WiFi speakers exposed debugging endpoints and a UPnP server that allowed network reconnaissance commands, tracked as CVE-2018-11316. Radio Thermostat CT50 and CT80 units exposed a completely unauthenticated control API, CVE-2018-11315, where the demonstrated impact was setting the temperature in a victim’s home to 95 degrees. WiFi routers were the highest value target, because the same UPnP and admin interfaces let an attacker rewrite the router’s own DNS server or add port forwarding rules, which turns a single accidental page view into a lasting foothold on the whole network.

    The Transmission case and a real CVE

    The starkest single example is CVE-2018-5702, found by Tavis Ormandy of Google Project Zero in the Transmission BitTorrent client. Transmission exposes a remote procedure call interface over HTTP for its web and desktop front ends. Its access control relied on a custom header, X-Transmission-Session-Id, which is not on the browser’s list of forbidden headers and so could be obtained and replayed by a malicious page. Through a rebinding attack a web page could reach the local Transmission daemon and issue RPC commands. As Ormandy described the impact, an attacker could set script-torrent-done-enabled and have an arbitrary command run when a torrent finished, or set download-dir to the user’s home directory and upload a torrent named to overwrite a file like .bashrc. That is remote code execution reached from an ordinary browser tab. Ormandy reported it and supplied a fix the following day, which landed as a Host header validation patch on the Transmission project. The fix is worth noting because it points straight at the right defense.

    Rebinding reaches more than home gadgets

    The technique generalizes to anything that trusts the network it sits on. Internal admin panels that skip authentication because they are only reachable on a corporate subnet are reachable through a rebind from any employee’s browser. In cloud environments the same idea targets the instance metadata service, the link local endpoint at 169.254.169.254 that hands out credentials to a workload. A rebind that lands on that address from inside a victim’s network or browser context is closely related to the broader class of server side request forgery, where a trusted client is steered into making a request it should never make. The rebind is the steering mechanism. The metadata service is the prize.

    Finding the target from inside the browser

    Before an attacker can rebind onto a useful service they have to know it is there, and the same browser that runs their script can also do the scouting. A script can try to load resources from a range of private addresses and ports and watch how long each attempt takes or whether it errors. A closed port fails fast, an open one behaves differently, and the timing alone leaks which internal hosts and services are alive. The private IP space is small and predictable. Home networks cluster on 192.168.0.0/16 and 10.0.0.0/8, routers sit on the first usable address, and well known services answer on well known ports. The attacker does not need to guess much.

    Once a live service is mapped, the rebind is aimed at exactly that address and port, and the generic page becomes a targeted client. This is why rebinding pairs so naturally with browser based port scanning. The scan tells the attacker where to point the rebind, and the rebind turns a discovered service into one the script is allowed to read from. Neither half requires anything beyond an open tab.

    A worked example on Acme Notes

    Make it concrete with our invented app. Suppose Acme Notes ships a small desktop helper that runs a local sync agent, listening on 127.0.0.1:7000 to talk to the Acme Notes app at app.acmenotes.com. The team bound it to localhost on the reasonable belief that only software already on the machine could reach it, so they did not add authentication to its control endpoint. A user installs the helper and later, on an unrelated tab, opens a page that an attacker controls.

    That page is served from sync.acmeattacker.com, resolves first to the attacker’s public server, and loads a script. The script waits for the one second pin to lapse, the name rebinds to 127.0.0.1, and now the script is talking to the local sync agent under a hostname the browser trusts. Because the agent never checked who was calling, it answers, and the script can read sync state, change settings, or point the agent at a server the attacker runs. The takeaway is not that Acme Notes wrote bad code. It is that binding to localhost was treated as authentication when it is only a filter on connection origin, and rebinding is built specifically to defeat that filter.

    Defenses that actually hold

    Because rebinding works by changing the IP under a trusted name, the durable defenses are the ones that stop trusting the network position and start checking something the attacker cannot forge.

    Validate the Host header

    This is the single most important server side fix, and it is the one the Transmission patch used. A service that is meant to answer only for localhost or its own hostname should inspect the Host header on every request and reject anything that does not match an allowlist of expected values, returning a 403 Forbidden. When the rebind lands, the browser still sends Host: rebind.acmeattacker.com, because that is the name in the address bar. The service sees a host it does not serve and refuses. The attacker cannot change the Host header to a forged value from JavaScript, because the browser sets it from the URL. This check costs almost nothing and defeats the core of the attack.

    Require real authentication, not network position

    A service that demands a credential the attacker’s page does not have is safe from rebinding even if the request reaches it. Binding to 127.0.0.1 is not authentication. It is a filter on where connections may originate, and rebinding is precisely a way to originate from there. Bind to localhost if you like, but also require an authenticated session, and do not rely on a header that scripts can obtain and replay. The whole Transmission issue was a custom header standing in for real access control.

    Filter private answers at the resolver

    A DNS resolver can refuse to return private addresses in answers for public names. If a name under a public domain tries to resolve to 192.168.x.x, 10.x.x.x, 127.0.0.1, or the link local metadata address, the resolver drops or rewrites that answer, and the rebind never completes. Tools like dnsmasq and several home and enterprise resolvers offer exactly this rebinding protection, and some public resolvers strip private ranges out of responses by default. It is a network level safety net rather than a per service fix, but it stops a large fraction of attacks before they reach any device.

    Adopt Private Network Access in the browser

    The browser platform itself is closing the gap. The Private Network Access specification, formerly known as CORS-RFC1918, restricts a public page from making requests into a private network unless the private service explicitly opts in with a CORS preflight. A request from a public origin to a private IP triggers a preflight that the target must answer with the right header, and an attacker’s router or media server will not. This shifts the default from quietly allowing public to private requests toward refusing them, which is exactly the assumption rebinding has always exploited.

    The assumption that breaks

    Underneath every variation of this attack sits one assumption. The same origin policy trusts the hostname, and it assumes the hostname keeps pointing at the same machine for as long as the page lives. That assumption is convenient and almost always true, which is why it survived. DNS was designed to let a name move between addresses, and the browser cannot tell a legitimate move from a malicious one, because both look like a name resolving to a new IP. The browser is not broken. It is honoring a contract that DNS never promised to keep.

    So the defenses that last are the ones that stop deciding trust from where a request appears to come from. Check the Host header, demand a real credential, and refuse private answers for public names. Each of those replaces a trust in network position with a check the attacker cannot satisfy. The gap between what a component assumes about a name and what an attacker can actually arrange with DNS is the kind of flaw you find by asking what each layer trusts and why it keeps trusting it after the situation has moved, rather than by scanning for a known bad string. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to catch. Learn more about that approach on our about page.

    Frequently asked questions

    What is DNS rebinding in simple terms?

    It is an attack where a web page served from a hostname the attacker controls swaps that name’s IP address right after the page loads. The browser keeps treating requests as one origin because the hostname never changed, so attacker JavaScript can reach private services like a router or a localhost daemon. It works because the same origin policy compares the host as a name and never rechecks which IP that name currently resolves to.

    Why does a short DNS TTL matter for the attack?

    The time to live tells resolvers how many seconds they may cache an answer before asking again. The attacker sets it to about one second so the browser quickly performs a fresh lookup and receives a private address like 192.168.1.1 in place of the original public one. The result is a time of check to time of use gap, walked step by step in the NCC Group writeup Attacking Private Networks from the Internet with DNS Rebinding.

    Has DNS rebinding led to a real vulnerability?

    Yes. CVE-2018-5702 was a remote code execution flaw in the Transmission BitTorrent client found by Tavis Ormandy, where a page could reach the local RPC interface through a rebind and run commands. Researchers also documented unauthenticated control of Google Home, Chromecast, Roku, Sonos, and routers via the same technique. The Transmission fix added Host header validation to reject requests that do not match the expected name.

    How do you defend against DNS rebinding?

    Validate the Host header on every request and reject names you do not serve, since the browser still sends the attacker’s hostname after the rebind. Require real authentication instead of trusting that a request came from localhost or a private subnet, and have resolvers drop private IPs from answers for public names. Browsers are also restricting public to private requests through the Private Network Access specification, formerly CORS-RFC1918.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What Is Subdomain Takeover and Why a Forgotten DNS Record Is Dangerous

    What Is Subdomain Takeover and Why a Forgotten DNS Record Is Dangerous

    A subdomain takeover happens when a DNS record on a domain you own keeps pointing at a cloud resource that no longer exists, and an attacker registers that same resource name on the provider to serve their own content from your trusted subdomain. The record is still there. The thing it pointed at is gone. Somebody else claims the empty slot, and now status.acmenotes.com answers with a page the attacker wrote, on a name your users already trust. This post walks the mechanism one step at a time: how a DNS record outlives the resource behind it, why the gap is claimable, what control of a trusted subdomain actually unlocks, and how to close the window for good.

    The dangling pointer at the heart of a subdomain takeover

    A domain name is a tree. acmenotes.com is the apex, and below it you hang names like www, blog, status, and app. Each of those names needs a DNS record to tell the world where it lives. The most common kind for a hosted service is a CNAME, which is an alias. It says, in effect, do not look here, look over there instead.

    Say your team puts the marketing status page on a managed host. You create:

    status.acmenotes.com.  CNAME  acme-status.someprovider.io.

    Now any browser that asks for status.acmenotes.com is told to go ask acme-status.someprovider.io, and the provider serves the page. This works because you registered the resource name acme-status on that provider, and the provider mapped it back to your content. Two things are now linked: the DNS alias you control, and the resource slot the provider holds for you.

    Months later the status page is retired. An engineer deletes the resource on the provider, closes the account, and moves on. The provider releases the name acme-status back into its pool of available names. But the CNAME in your DNS zone is never touched. It still says status.acmenotes.com aliases to acme-status.someprovider.io. The alias now points at a slot that belongs to nobody. That is a dangling DNS record, and OWASP describes the condition plainly: a DNS record, typically a CNAME, points to a cloud resource or third party service that has been deprovisioned or no longer exists.

    The trouble is structural, not careless. Cloud resources are short lived and DNS records are persistent. Teams spin up and tear down services constantly, and the records that point at them tend to pile up unless somebody deletes them on purpose. The pointer outlives the thing it pointed at.

    Why the empty slot is claimable

    An attacker enumerating your subdomains looks for exactly this shape. They resolve status.acmenotes.com, follow the alias to acme-status.someprovider.io, and ask for the page. Instead of your content they get a provider error that says the resource is not configured. Each provider has a recognizable fingerprint for that state. On Amazon S3 the bucket returns The specified bucket does not exist. On GitHub Pages the response reads There isn't a GitHub Pages site here. On Heroku it is No such app. On some Azure endpoints the name simply fails to resolve at all and the DNS layer returns NXDOMAIN. That distinctive error is the signal that the alias is dangling and the slot is open.

    From there the takeover is just a registration. The attacker creates their own account on the provider and registers the resource name your record still points at, acme-status. The provider has no memory that this name was once yours. It hands the name to whoever asks first. The moment the attacker holds acme-status.someprovider.io, your CNAME resolves their content. They did not touch your DNS. They did not breach your account. They claimed the address your own record was still advertising. The can I take over xyz project catalogs which providers leave this door open and the exact error string each one shows when a slot is unclaimed.

    Two conditions have to line up for this to work, and both are common. First, your external DNS server has a subdomain record configured to point at a resource or endpoint that is no longer active. Second, the provider hosting that endpoint does not handle ownership verification properly, so it lets a new account register the name without proving any connection to your domain. When a provider does verify ownership, the second condition fails and the slot stays safe even though the record dangles. When it does not, the dangling record is enough on its own.

    It is not only CNAME records

    The alias case is the most frequent, but the same shape appears across record types, and the impact climbs as you move up the tree. A dangling A record that pins a subdomain to an IP address can be taken over if that address is released back into a cloud provider’s shared pool and the attacker manages to acquire it. A dangling MX record can route mail for the subdomain to a host the attacker controls, which lets them receive password resets and verification mails sent to that name. The worst case is a dangling NS record. Nameserver delegation hands authority for a whole zone to another server. If that server is deprovisioned and the delegation is left in place, an attacker who claims it gains control over the entire DNS zone under that name, not just one page. An NS takeover is less likely but has the highest impact, because it is full control of the subtree rather than a single endpoint.

    The attacker never breaks into your domain. Your domain keeps pointing at an address you abandoned, and the attacker simply moves into it.

    What control of a trusted subdomain unlocks

    Serving a page from status.acmenotes.com sounds like vandalism, a defacement at worst. It is far more than that, because the rest of your application has been built to trust names under acmenotes.com. The browser, your cookies, your login flow, and your content policy all make decisions based on the domain. A taken over subdomain steps inside that trust boundary and quietly inherits a pile of privileges it was never supposed to have.

    Phishing that passes every glance test

    The simplest payoff is a login page. The attacker serves a pixel perfect copy of your sign in form at status.acmenotes.com and mails the link to your users. Everything a careful user checks holds up. The domain is really yours. The TLS certificate is valid, because the attacker controls the subdomain and can request one from any certificate authority on the spot. There is no typosquatting tell, no lookalike character, no foreign domain. The credentials users type go straight to the attacker. This is the same trust that makes phishing on a controlled subdomain so much more effective than a random external link.

    Cookies scoped to the parent domain

    Cookies are where this turns from convincing into mechanical. A cookie set with Domain=.acmenotes.com is sent by the browser to every subdomain under it, including the one the attacker now owns. If a session cookie or a preference cookie is scoped to the parent domain and is not marked HttpOnly, JavaScript running on the attacker’s page can read it directly with document.cookie. The attacker did not need to defeat your login. The browser handed them the session cookie because, as far as it can tell, the request came from a legitimate part of acmenotes.com. Parent domain cookie scoping was a convenience for sharing sessions across app and www. It now shares them with the attacker too.

    Even cookies marked HttpOnly are not fully out of reach. The attacker can set their own cookies on the parent domain from the controlled subdomain, which opens session fixation, and they can read any cookie that scripts are allowed to see. The boundary everyone assumed sat at the domain edge actually ran between subdomains, and one of those subdomains just changed hands.

    OAuth and SSO redirect abuse

    Login flows lean on a list of trusted return addresses. When a user signs in through OAuth or single sign on, the identity provider sends the token or authorization code back to a redirect_uri, and it will only send it to a destination on an approved allowlist. Teams frequently approve patterns rather than exact addresses, allowlisting anything under *.acmenotes.com so they do not have to update the list every time they add a subdomain. A taken over subdomain matches that wildcard. The attacker starts an authentication flow with redirect_uri=https://status.acmenotes.com/callback, the identity provider sees a host that passes the allowlist, and it delivers the authorization code or token to a page the attacker controls. The fix the standards push is exact match redirect URIs precisely because wildcard allowlists turn any one weak subdomain into a token leak.

    Bypassing a Content Security Policy allowlist

    A Content Security Policy is a list of sources a browser is allowed to load scripts and other content from. Many policies list a wildcard like script-src https://*.acmenotes.com so that internal subdomains can host assets. The policy is meant to be a wall against injected scripts from anywhere else. A taken over subdomain sits inside the wildcard, so a script served from status.acmenotes.com satisfies the policy. If the attacker also has an HTML injection or cross site scripting foothold on the main app, the CSP that should have blocked their payload now waves it through, because the source is an allowlisted subdomain they happen to own. The same wildcard that bypasses the OAuth allowlist bypasses the script allowlist. To see how a policy like that grades, and to spot a wildcard before an attacker does, paste your response headers into our free security headers and CSP analyzer.

    Defeating same site assumptions

    A lot of web security quietly rests on the idea that everything under one registrable domain is one trust zone. Same site cookie rules, CORS allowlists that permit any origin under the parent, frames that are trusted because they share the domain, internal tools that skip a permission check for requests coming from a sibling subdomain. Each of those is a reasonable shortcut right up until one subdomain is controlled by someone outside the organization. After the takeover the attacker speaks from inside the same site, and every assumption built on that sameness now works in their favor.

    How one weak subdomain chains into a full compromise

    The individual effects above are bad, but the real danger is that they combine. Walk a plausible chain on our invented app, Acme Notes. The main app at app.acmenotes.com sets a session cookie scoped to .acmenotes.com so the marketing site and the app can share a login. It also ships a Content Security Policy that allowlists script-src https://*.acmenotes.com for shared widgets, and its single sign on flow approves any redirect_uri under *.acmenotes.com. None of those three choices is reckless on its own. Each one is a normal convenience.

    Now the attacker takes over the retired status.acmenotes.com. They host a script there. Because the subdomain matches the CSP wildcard, that script loads inside the main app whenever they find a place to reference it, and it reads the parent domain session cookie that the browser cheerfully attaches to the controlled subdomain. If a cookie is marked HttpOnly and stays out of reach, they pivot to the login flow instead, starting an authentication request with redirect_uri=https://status.acmenotes.com/callback, which the wildcard allowlist accepts, and the identity provider delivers the authorization code to their page. Three separate trust shortcuts, each defensible alone, become one path from a forgotten DNS record to a stolen session. That is why a single dangling subdomain rarely stays a small problem.

    How attackers find a dangling record before you do

    None of this requires luck. The reconnaissance is routine. An attacker collects the subdomains of a target from certificate transparency logs, which publicly record every TLS certificate ever issued for a name, from passive DNS datasets, and from brute forcing common names. Then they resolve each one and check where the alias lands. Any subdomain whose CNAME points at a provider and returns one of the known not configured fingerprints is a candidate. Tooling automates the whole sweep, matching responses against the same fingerprint list that the can I take over xyz project maintains. The economics favor the attacker. They scan thousands of names cheaply, and they only need one forgotten record. You have to remember all of them.

    It is worth naming where this sits relative to neighboring bugs. A subdomain takeover is not server side request forgery, where a server is tricked into making a request on the attacker’s behalf, and it is not the credential theft path from a cloud instance metadata service. But it rhymes with both. All three come from a component trusting a name or a location more than the situation deserves. Here the trusted thing is the domain label, and the betrayal is that the label kept its meaning after the resource behind it disappeared.

    Preventing a subdomain takeover

    The good news is that this class of bug has a clean root cause, which means it has a clean fix. The window only exists because of an ordering mistake during decommissioning. Close that ordering and the window never opens.

    Deprovision in the right order

    The single most important habit is sequencing. When you retire a service, the order is fixed:

    • First serve a maintenance page or redirect from the subdomain, so nothing breaks abruptly.
    • Then update or remove the DNS record so the name no longer points at the provider slot.
    • Allow time for DNS to propagate so caches expire.
    • Only then decommission the cloud resource.

    The common mistake is doing these steps in reverse, deleting the cloud resource first. That creates an immediate window for takeover that persists until someone notices the dangling record. Delete the pointer before you release the thing it points at, and there is never an empty slot for anyone to claim.

    Inventory every record and tie it to an owner

    You cannot protect records you do not know you have. Keep a live inventory of every DNS record in every zone, and link each one to the resource and the team that owns it. When a resource is torn down, that link is what tells you which record has to go with it. Records without a known owner are exactly the ones that rot into dangling aliases, so treat an unowned record as a finding, not a footnote.

    Claim and verify the resource you point at

    Wherever a provider offers domain verification, use it. A claimed and verified resource cannot be silently re registered by a stranger, because the provider checks ownership before handing the name out. This shrinks the set of providers where a free registration is enough to steal the slot, and it is the difference between an alias that is merely unused and one that is actually open for the taking.

    Monitor for the dangling state continuously

    Treat detection as ongoing, not a one time audit. For every CNAME in your zone, resolve the target on a schedule and check that it still exists and returns the content you expect, rather than a provider error page. Watch for two signals in particular. The first is NXDOMAIN, where the aliased target no longer resolves at all. The second is a known service fingerprint in the response body, one of those distinctive not configured error strings that says the resource has been removed. A weekly automated scan that flags either condition turns a silent dangling record into an alert before an attacker finds it. If you already have a CNAME target and the error page it serves, our free subdomain takeover fingerprint checker matches them against the known service fingerprints so you can confirm a dangling slot fast. Certificate transparency logs help here too, since they reveal subdomains you may have forgotten you ever created.

    The assumption that breaks

    Step back from the records and the fingerprints and one assumption is left holding everything up. DNS assumes that the record still points at something you own. A CNAME is a promise about a relationship between two names, and the relationship is only safe while you control both ends. The system has no way to notice when one end quietly slips away. The provider forgets you the instant you delete the resource. Your zone keeps advertising the alias as if nothing changed. Nothing in the protocol reconciles those two views, so the gap between them sits open, advertised to the whole internet, waiting.

    The bug is not a broken DNS server or a sloppy provider. The bug is a pointer that outlived the resource it pointed at, and a trust boundary that everyone drew at the domain edge when it actually ran between the subdomains. That gap between what a system assumes about a name and what an attacker can actually arrange is the kind of flaw you find by asking what each component trusts and why it still trusts it, rather than by scanning for a known bad string. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to catch. Delete the record before you release the resource, verify what you point at, and watch your aliases for the day one of them stops pointing home. Learn more about that approach on our about page.

    Frequently asked questions

    What causes a subdomain takeover?

    It is caused by a dangling DNS record. A subdomain has a CNAME aliasing it to a cloud resource, and when that resource is deleted or the account is closed, the provider releases the name but the DNS record is never removed. The alias now points at an empty slot anyone can register. The OWASP Subdomain Takeover Prevention Cheat Sheet describes this dangling record as the core condition.

    How does an attacker claim the dangling subdomain?

    They enumerate your subdomains, follow each alias to its provider target, and look for a not configured error such as The specified bucket does not exist on S3 or There isn't a GitHub Pages site here. on GitHub Pages. That error means the slot is free. The attacker then registers the same resource name on the provider, and your unchanged CNAME immediately serves their content. The can I take over xyz project catalogs the vulnerable providers and their exact fingerprints.

    Why is a taken over subdomain so dangerous?

    Because the subdomain sits inside the trust boundary of your domain. The attacker can host a convincing phishing login on a real name with a valid certificate, read cookies scoped to the parent domain, match wildcard OAuth redirect allowlists to steal tokens, and satisfy a Content Security Policy that allowlists *.yourdomain.com. Every assumption built on names being under one trusted domain now works in the attacker’s favor.

    How do you prevent a subdomain takeover?

    Deprovision in the right order: remove or update the DNS record before you delete the cloud resource, never the reverse. Keep an inventory of every DNS record tied to its owner, use provider domain verification to claim the resources you point at, and monitor every CNAME on a schedule for NXDOMAIN or a known service fingerprint. This maps to the weakness MITRE tracks as CWE-350, relying on a name resolving to something you still control.


    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: Subdomain Takeover Checker lets you check a CNAME against the fingerprints of services that allow a takeover. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What Is HTTP Request Smuggling

    What Is HTTP Request Smuggling

    An HTTP request looks like one clean unit of work: a method, a path, some headers, and a body. But on the modern web your request almost never reaches a single server. It passes through a front end first, a proxy or load balancer or content delivery network, which then forwards it to a back end. http request smuggling is what happens when those two servers read the same bytes and disagree about where one request stops and the next one begins. When they disagree, an attacker can hide a second request inside the first, and the back end will glue it onto whatever victim request arrives next. This post walks the mechanics precisely: why two length headers fight, what one smuggled prefix does to the next person in line, how HTTP/2 reopens the wound through downgrades, and how to shut it.

    One connection, two readers, two opinions

    The front end and the back end usually keep a connection open between themselves and reuse it for many requests from many users. This is normal and efficient. It also means the back end is reading a continuous stream of bytes and slicing it into requests on its own. The front end already sliced the same stream. As long as both slice it at the same byte, everything is fine and nobody notices the machinery underneath.

    The attack lives in the moment they slice at different bytes. If the front end thinks request A ended at byte 100 but the back end thinks it ended at byte 80, then 20 bytes the front end believed were part of A are sitting at the front of the back end’s buffer, waiting. Those 20 bytes are attacker chosen. When the next real request arrives, the back end reads the leftover 20 bytes first, then the victim’s bytes, and treats the whole thing as one request. The victim’s request has been prefixed with the attacker’s smuggled content, and the victim never sent it.

    Request smuggling is not a parsing bug in one server. It is a disagreement between two servers about a question they both think has an obvious answer: where does this request end?

    Why a request has two ways to say how long it is

    To send a body in HTTP/1.1 you have to tell the server how many bytes to read. The protocol gives you two ways to do that, and that redundancy is the whole problem.

    The first way is Content-Length. You count the bytes of the body and put the number in a header. Content-Length: 11 means read exactly eleven bytes after the blank line, and that is the body. Simple and exact.

    The second way is Transfer-Encoding: chunked. Instead of declaring the total up front, you send the body as a series of chunks. Each chunk starts with its own size written in hexadecimal on its own line, then the chunk data, then a blank line. A chunk of size zero marks the end of the body. So a chunked body that carries the text q=smuggling looks like this:

    Transfer-Encoding: chunked
    
    b
    q=smuggling
    0
    
    

    The b is hexadecimal for 11, the length of q=smuggling. The 0 on its own line is the terminator. The reader is supposed to stop there. Everything before the 0 chunk is the body, and everything after it is the start of the next request.

    Two ways to declare length is one way too many. What is a server supposed to do when a single request arrives carrying both a Content-Length and a Transfer-Encoding: chunked header that point at different boundaries? The standard has an answer. RFC 9112 section 6.3 says that when both are present, Transfer-Encoding wins and Content-Length is ignored. The same standard warns that a request carrying both may be an attempt at request smuggling. The trouble is that not every server in the chain obeys the rule, and the ones that disagree are the ones you can attack.

    Walking one http request smuggling example byte by byte

    The cleanest way to see http request smuggling is to follow one example slowly. The variants are named after which header each server trusts. CL.TE means the front end honors Content-Length and the back end honors Transfer-Encoding. Watch what that mismatch does to one crafted request.

    The attacker sends a single request that includes both length headers on purpose:

    POST / HTTP/1.1
    Host: acme-notes.example
    Content-Length: 6
    Transfer-Encoding: chunked
    
    0
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    Now read it twice, once as each server.

    The front end trusts Content-Length: 6. It counts six bytes of body after the blank line. Those six bytes are the 0, then the line ending, then the blank line that follows. As far as the front end is concerned the body is the short chunked terminator and nothing more. It decides the request ends right there and forwards the whole thing, every byte, to the back end on the shared connection. The front end believes it forwarded one ordinary POST.

    The back end trusts Transfer-Encoding: chunked and ignores the Content-Length entirely. It reads the body as chunks. The very first chunk it sees is 0, the terminator. So the back end decides the body is empty and the POST is finished at that point. But the bytes after the 0 chunk did not vanish. The back end now has this still sitting in its buffer, unread:

    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    The back end treats those leftover bytes as the beginning of the next request on the connection. It does not get attributed to the attacker. It gets stitched onto whatever arrives next. The smuggled GET /admin is the prefix, and it is missing a final piece, the rest of its headers, which is why the attacker leaves Foo: x dangling with no value terminated. That dangling header swallows the first line of the next victim’s request so the smuggled request stays valid.

    What the prefix does to the next victim

    Say an ordinary user sends a normal request a moment later:

    GET / HTTP/1.1
    Host: acme-notes.example
    Cookie: session=victim-session-here
    ...

    The back end already had the smuggled prefix waiting. So what it actually parses is the attacker’s lines followed by the victim’s lines fused together. The Foo: header absorbs the victim’s request line, and the request the back end runs is the attacker’s GET /admin carrying the victim’s session cookie. The victim asked for the home page and instead drove a request the attacker authored. Depending on the app, this poisons the response queue so the victim gets back a page meant for someone else, or it captures the victim’s own request data into a place the attacker can read, or it slips a request past the front end’s access rules because the front end only ever saw the harmless looking POST.

    That last point is the sharp one. Front ends are often where access control and request filtering live. They block /admin, strip dangerous headers, enforce rate limits. A smuggled request never passes the front end as a request at all. It rides inside the body of a request the front end approved, then becomes a request only after it is already past the gate. The control was real. It was just looking at the wrong bytes. This is the same shape of problem we describe in our web security glossary: a check that runs on a different view of the data than the action it is meant to protect.

    It helps to be precise about the three ways a smuggled prefix turns into damage, because they are not the same attack and they do not need the same conditions.

    • Bypassing front end controls. The smuggled request reaches paths and methods the front end was supposed to refuse. The attacker smuggles a request to a restricted route, and because the front end only inspected the approved outer request, the inner one runs with no filter between it and the back end.
    • Capturing another user’s request. The attacker smuggles a prefix that ends with a header expecting a long value, like a comment field or a search parameter, so the victim’s incoming request, cookies and all, is captured as that value and stored where the attacker can later read it back.
    • Poisoning the response queue. Once the boundary between requests is off by one, the back end’s responses fall out of step with who asked for them. The attacker’s smuggled request consumes a response slot, and the next user receives a response meant for a different request. Chain this with a reflected input or a cached page and a single smuggle can serve a poisoned response to many users.

    The mirror image, and the obfuscation trick

    TE.CL is the same idea flipped. The front end honors Transfer-Encoding and the back end honors Content-Length, so the attacker crafts a chunked body whose declared size leaves bytes the back end reads as a new request. The roles swap but the outcome is identical: a prefix left in the back end’s buffer.

    TE.TE is sneakier. Both servers support Transfer-Encoding, so in theory they agree. The attacker breaks that agreement by obfuscating the header so that one server recognizes it and the other does not. A header written as Transfer-Encoding: xchunked, or with odd spacing, or duplicated, or with a tab in a place a strict parser rejects but a lenient one accepts, can make one server fall back to Content-Length while the other still reads chunks. The instant one server stops honoring Transfer-Encoding, you are back to a CL versus TE split, and the smuggle works again. The lesson is that small differences in how strictly each server parses a header name are enough to desync the chain.

    HTTP/2 was supposed to fix this, and then it did not

    HTTP/2 removes the ambiguity at its root. It does not send headers and bodies as a text stream you have to slice. Each message body is carried in binary data frames, and every frame has a built in length field. The protocol knows exactly where a message ends because the framing tells it, not because two text headers happen to agree. End to end HTTP/2 has no place for a length disagreement to hide. If the whole chain spoke HTTP/2 from the browser to the back end, this class of bug would mostly be over.

    The chain does not speak HTTP/2 the whole way. Most front ends accept HTTP/2 from the internet and then rewrite each request as HTTP/1.1 before handing it to the back end, because the back end still speaks the older protocol. That rewrite is called a downgrade, and it is where James Kettle’s research, presented as HTTP/2: The Sequel is Always Worse, showed the bug coming back to life.

    When the front end downgrades, it has to invent the HTTP/1.1 length headers from the HTTP/2 frame data. It writes a Content-Length, or it copies across a Transfer-Encoding the request carried. If the front end does this carelessly, the back end is once again reading length from a text header that may not match reality.

    H2.CL and H2.TE

    H2.CL is the downgrade version of a Content-Length desync. In HTTP/2 the true body length is fixed by the data frames, so the content-length field a client sends is just a claim the server is supposed to validate against the frames. If the front end fails to check it and trusts the attacker supplied value during the downgrade, it writes that wrong Content-Length into the HTTP/1.1 request it forwards. The back end then reads too few or too many bytes, and the leftover becomes a smuggled prefix, exactly as in CL.TE.

    H2.TE is the Transfer-Encoding version. The HTTP/2 standard says a request carrying a transfer-encoding header should be treated as malformed and rejected, because chunked encoding has no meaning inside HTTP/2 framing. A front end that forwards that header anyway hands the back end a Transfer-Encoding: chunked on a downgraded request. The back end honors it, reads the body as chunks regardless of the front end’s idea of the length, and desyncs. Same prefix, same poisoned queue, reached through a header the front end should have thrown away.

    The reason the downgrade case is worth so much attention is that it widened the target list. Pure HTTP/1.1 smuggling needs two HTTP/1.1 servers that parse length differently, which careful operators had started to fix. The downgrade reopened the bug on chains that looked modern and safe from the outside, where the public facing server speaks HTTP/2 and only the hop you cannot see still speaks HTTP/1.1. Kettle’s research also showed that HTTP/2 carries its own smuggling surface beyond length, because attackers can smuggle through header names, header values, and even the pseudo headers that HTTP/2 uses for the method and path, all of which have to be flattened into a single text line during a downgrade. Anywhere a special character survives that flattening, a new request boundary can be forged.

    Defenses that actually hold

    The fixes are not clever payloads to block. There is no signature for a smuggled request, because every byte in it is valid on its own and the attack is purely in how two servers slice the stream. So the defenses do not try to spot bad content. They are about making the two servers agree on boundaries, or refusing to forward anything the two of them might read differently.

    • Reject ambiguous requests instead of guessing. A request that carries both Content-Length and Transfer-Encoding is not a request to interpret, it is a request to refuse. RFC 9112 lets a server reject it outright, and it requires the server to close the connection after responding to such a request so no leftover bytes can poison the next one. Closing the connection is the part that breaks the smuggle, because the prefix has nowhere to wait.
    • Make the front end normalize and own the framing. The front end should rewrite every request into one unambiguous form before forwarding, with exactly one length header that it computed itself, so the back end never has to choose. If the front end will not honor a Transfer-Encoding it should strip it, not pass it along for the back end to honor differently.
    • Reject the Transfer-Encoding you will not honor. A front end that does not implement chunked the way the back end does should reject requests that use it, including obfuscated spellings, rather than forwarding a header it parses loosely.
    • Use HTTP/2 end to end where you can. If the connection to the back end also speaks HTTP/2, there is no downgrade and no place to forge a length header. When you must downgrade, validate the content-length against the real frame data and drop any transfer-encoding the HTTP/2 standard says is malformed.
    • Reuse back end connections carefully. Much of the impact comes from one shared connection carrying many users. Some deployments reduce blast radius by not pooling back end connections across users, so a leftover prefix cannot land on a stranger’s request.

    These are not hypothetical. In March 2025 Akamai disclosed CVE-2025-32094, a request smuggling flaw James Kettle reported in their edge platform. It chained an HTTP/1.x OPTIONS request, an Expect: 100-continue header, and obsolete line folding so that two in path Akamai servers read one request two different ways. Akamai fixed it across the platform with no known exploitation, but the cause is the same one this post has circled the whole time: two servers, one stream, two opinions about where a request ends.

    The assumption underneath

    Every link in this chain is built by people doing something reasonable. The front end forwards requests to be fast. The back end reads length from a header because that is how the protocol works. The standard offers two ways to declare length because both are genuinely useful. None of those choices is wrong on its own. The bug is the assumption that connects them: that the front end and the back end will always agree on where a request ends, because the question feels like it has one obvious answer.

    It does not. The attack lives entirely in the gap between two readers of the same bytes, a gap nobody put there on purpose and nobody tested for, because each server was certain the other saw what it saw. That is the kind of flaw you find by asking what each component assumes about the one next to it, then arranging for the assumption to be false, rather than by scanning for a known bad string. It is the same trust in a validated view of a request that powers bugs like server side request forgery, and it is exactly the class of bug an autonomous researcher that tests assumptions is built to surface. The two servers think they agree. The whole exploit is proof that they do not.

    Frequently asked questions

    What is HTTP request smuggling in simple terms?

    It is an attack that works when a front end server and a back end server read the same bytes on a shared connection but disagree about where one request ends and the next begins. The attacker crafts a request that the front end treats as finished while the back end thinks part of it is the start of a new request. Those leftover bytes wait in the back end buffer and get stitched onto the next user’s request, so the back end runs a request the attacker wrote. The PortSwigger Web Security Academy covers the mechanics in depth in its request smuggling guide.

    Why do the two length headers cause the problem?

    HTTP/1.1 gives two ways to declare how long a body is. Content-Length states the byte count up front, while Transfer-Encoding: chunked sends the body as sized chunks ending in a zero length chunk. When one request carries both headers and they point at different boundaries, servers can disagree. RFC 9112 section 6.3 says Transfer-Encoding wins and warns the request may be a smuggling attempt, but not every server obeys, and the ones that disagree are the ones an attacker chains against.

    What are CL.TE, TE.CL, and TE.TE?

    They name which header each server trusts. CL.TE means the front end honors Content-Length and the back end honors Transfer-Encoding, so the back end stops at the zero chunk and leaves the rest as a smuggled prefix. TE.CL is the reverse. TE.TE is when both support chunked, so the attacker obfuscates the Transfer-Encoding header, for example with odd spacing or a misspelling, so one server stops honoring it and the chain desyncs again.

    Doesn’t HTTP/2 prevent request smuggling?

    End to end HTTP/2 mostly does, because it carries each body in binary frames with a built in length, leaving no room for two text headers to disagree. The risk returns when a front end accepts HTTP/2 from the internet and downgrades each request to HTTP/1.1 for the back end. If it forges a wrong content-length (H2.CL) or forwards a transfer-encoding it should have rejected as malformed (H2.TE), the back end desyncs just like before. Using HTTP/2 to the back end too, or validating length against the real frames, removes the gap.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.