Category: Deep Dives

Long form technical deep dives into one mechanism at a time: cloud, kernel, IoT, and privacy internals.

  • 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.

  • 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.

  • How TLS Fingerprinting Works: JA3, JA4, and the ClientHello

    How TLS Fingerprinting Works: JA3, JA4, and the ClientHello

    Before a single byte of HTTP travels, before any JavaScript runs, before a cookie is set, a web client has already told the server a great deal about itself. The very first message of a TLS connection, the ClientHello, is sent in the clear, and the exact way it is built is specific to the software that built it. TLS fingerprinting is the practice of reading that first message and turning it into a short, stable identifier for the client. A real Chrome browser, a Python script using requests, and a piece of malware calling home to its controller each produce a different shape of ClientHello, and that shape gives them away. This post takes the idea apart from the packet up: why the handshake is a fingerprint at all, how the original JA3 method computed one, why JA3 broke, how JA4 fixed it, and what all of this means for catching bots and malware versus the privacy of ordinary users.

    Why the handshake is a fingerprint

    A TLS connection opens with a negotiation. The client speaks first with a ClientHello, a plaintext message that lists everything the client is willing and able to do so the server can pick a common option. That list is not a single fixed value. It is an ordered set of choices, and every TLS library makes those choices a little differently.

    The ClientHello carries, among other things, the highest TLS version the client supports, the ordered list of cipher suites it offers, a list of extensions, the elliptic curves it will accept for key exchange, and the elliptic curve point formats it understands. None of this is secret. It cannot be, because the server needs to read it to agree on parameters before encryption is set up. The values themselves are mundane. What identifies the client is the combination and the order: which ciphers, in which sequence, which extensions, advertised which way.

    This matters because the choices come from the TLS stack, not from the application on top of it. OpenSSL, BoringSSL, the schannel library on Windows, the network stack inside Chrome, and the Go standard library each assemble a ClientHello in their own house style. So the fingerprint reflects the runtime, not the label the client puts on itself. A script can set its HTTP user agent header to the exact string a real Chrome sends, but the header is added later, inside the encrypted HTTP request. The TLS handshake underneath was already built by Python’s stack, and it does not look like Chrome at all. That gap between what a client claims and what its handshake reveals is the entire reason the technique is useful.

    It helps to picture where this sits in the connection. The TCP handshake completes, then the client sends the ClientHello as the very first TLS record. The server reads it, replies with a ServerHello that picks one cipher and one set of parameters, both sides derive keys, and only then does the channel turn encrypted. So the ClientHello is the last fully readable thing the client ever sends on a healthy connection. A passive observer between the two parties cannot read the page that is requested or the data that comes back, but it can read that opening message in full. TLS fingerprinting is the discipline of getting the most identity out of that one readable message.

    The client picks a user agent string to tell you what it is. The handshake tells you what it really is, and the handshake was sent before the client had a chance to lie.

    How JA3 computes a TLS fingerprinting hash

    The first widely used method for this came from Salesforce in 2017 and is called JA3. Its idea is simple enough to follow by hand. JA3 reads five fields out of the ClientHello, always in the same order:

    • TLS version, the version number from the handshake.
    • Cipher suites, the ordered list of ciphers the client offers.
    • Extensions, the list of TLS extensions, in the order they appear.
    • Elliptic curves, the supported curves, sometimes called supported groups.
    • Elliptic curve point formats, the point format list.

    JA3 takes the decimal values from each field, joins the values inside a field with a dash, and joins the five fields with a comma. The result is one long string in a fixed layout: TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats. A real example of that intermediate string looks like this:

    769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0

    Here 769 is the TLS version, the long middle run is the cipher list, 0-10-11 is the extension list, 23-24-25 is the curve list, and the trailing 0 is the single point format. If a field is empty, JA3 keeps the comma and leaves the field blank, so a client with no extensions produces a string like 769,4-5-10-9-100-98-3-6-19-18-99,,, with the empty positions preserved. That last detail is part of the fingerprint too, because the absence of extensions is itself a property of the client.

    The final step is a hash. JA3 runs the whole comma joined string through MD5 and keeps the 32 character result. The string above becomes:

    769,47-53-5-10-49161-49162-49171-49172-50-56-19-4,0-10-11,23-24-25,0
      -> ada70206e40642a3e4461f35503241d5

    MD5 is a poor choice for security where collisions matter, but here it is only a compact label for a string, so its weakness is not the point. The point is that the same client software, run again, produces the same five fields in the same order and therefore the same hash. A different client produces a different one.

    It is worth being precise about what JA3 deliberately leaves out. It does not read the server name indication, the actual hostname being requested, even though that field is present and readable in many ClientHellos. It does not read the contents of every extension, only which extensions are present. And it does not touch anything above TLS. The aim is a fingerprint of the client stack, not of the destination or the request, so two connections from the same software to two different sites share a JA3 hash. That is the property that makes it useful for spotting one tool across many targets, and it is also why JA3 alone cannot tell you what the client was doing, only what it was.

    GREASE and the server side twin

    Two refinements are worth knowing. First, modern clients inject GREASE values, which are deliberately reserved placeholder numbers sprinkled into the cipher and extension lists to keep servers from getting rigid about what they accept. JA3 ignores GREASE values entirely so that a client which uses GREASE still maps to one stable hash rather than a new one each connection. Second, there is a mirror method called JA3S that fingerprints the server’s response from its version, chosen cipher, and extensions. Pairing the client JA3 with the server JA3S describes a whole conversation, which is handy when the same client always talks to the same controller.

    Where JA3 is genuinely useful

    The reason security teams cared about JA3 is that it identifies software by how it speaks, not by where it connects or what it claims. That property has three concrete uses.

    Malware and command and control detection. A piece of malware is usually built against one TLS library and offers one fixed handshake. It does not matter if the malware rotates its server IP every hour, uses domain generation algorithms to invent new hostnames, or even hides its controller behind a public service. The JA3 hash of the malware’s own handshake stays the same. Salesforce documented that the Trickbot sample consistently produced the JA3 hash 6734f37431670b3ab4292b8f60f29984, which means a sensor can flag that traffic by how it connects rather than by chasing an endless list of addresses. Threat intelligence feeds publish lists of JA3 hashes tied to known malware families for exactly this.

    Bot detection by mismatch. The strongest signal is a contradiction. When an HTTP request carries a user agent header that says Chrome 120, but the TLS handshake under it matches the fingerprint of Python’s requests library or a plain curl build, the two stories do not agree. A browser stack and a scripting stack assemble different ClientHellos, so a request that claims to be a browser while handshaking like a script is almost certainly automated. A web application firewall or content delivery network can compare the claimed client to the observed fingerprint and act on the gap.

    Allow listing in locked down networks. In an environment where only a known set of applications should ever make outbound TLS connections, you can record the fingerprints of the approved software and alert on anything else. A new fingerprint is a new piece of software talking, which is worth a look.

    If you want the broader picture of how servers profile clients across many layers, our writeup on how browser fingerprinting works covers the JavaScript and HTTP signals that sit above the handshake. TLS fingerprinting is the layer beneath all of that, the one that fires first.

    Why JA3 broke

    JA3 had a structural weakness, and two separate forces pushed on it until it gave way. The weakness is that JA3 reads the extension list in the order it appears in the ClientHello. Order is part of the hash. So anything that changes the order changes the hash, even when the client’s actual capabilities are identical.

    The first force was an evasion that costs almost nothing. Because order drives the hash, a client that wants to dodge a JA3 blocklist only has to shuffle its extension list. The set of extensions is the same, the handshake still works, but the bytes are reordered and the hash is new. For an attacker this is close to free. A list of sixteen extensions can be arranged in sixteen factorial ways, which is more than twenty trillion orderings, so a single piece of software can wear an effectively unlimited number of JA3 faces. A blocklist built on a fixed hash cannot keep up with a client that changes the hash on a whim.

    The second force was not an attack at all. Starting around early 2023, with the rollout landing in Chrome version 110 and the change merged a release or two earlier, Chrome began randomizing the order of its TLS extensions on purpose. The stated reason was healthy: by shuffling the order on every connection, Chrome forces servers and middleboxes to stop depending on the exact byte layout of its ClientHello, which keeps the wider TLS ecosystem flexible. The side effect was that the single common JA3 hash for Chrome shattered. Overnight a huge share of legitimate traffic stopped matching its old fingerprint, and the same twenty trillion orderings that helped attackers now scattered ordinary users too. JA3 went from a useful client label to noise for the most common browser on the internet.

    How JA4 fixes the order problem

    JA4, from FoxIO, is the answer to that breakage, and the core fix is almost obvious once you see the failure. If order is the problem, remove order from the parts where it is not meaningful. JA4 sorts the cipher list and sorts the extension list before hashing them. A shuffled ClientHello and an unshuffled one, with the same underlying capabilities, sort to the same sequence and therefore produce the same fingerprint. The evasion of reordering, and Chrome’s deliberate randomization, both stop mattering because the sorted output is identical either way.

    JA4 also changes the shape of the output to be readable rather than a single opaque hash. A JA4 fingerprint comes in three parts joined by underscores. A real example:

    t13d1516h2_8daaf6152771_b186095e22b6

    The first segment is human readable metadata. Reading it left to right: t means TLS over TCP, 13 means TLS version 1.3, d means a server name indication was present so this is a connection to a named domain, 15 is the count of cipher suites with GREASE excluded, 16 is the count of extensions, and h2 is the first and last characters of the negotiated application layer protocol, here HTTP/2 by way of ALPN. The second segment, 8daaf6152771, is a truncated SHA256 hash of the sorted cipher list. The third segment, b186095e22b6, is a truncated hash of the sorted extensions, leaving out the ones that are themselves variable, plus the signature algorithms in their original order.

    Two design choices stand out. Sorting is what defeats the shuffle, both the malicious kind and Chrome’s well meaning kind. Adding ALPN is new information that JA3 never captured, since the negotiated protocol is another property of the client stack. And because the leading segment is plain text, an analyst can group and hunt on individual pieces, for example every TLS 1.3 client that offers a certain count of extensions, without decoding a hash.

    The readable prefix earns its keep in practice. Suppose a feed of traffic is dominated by ordinary browsers and you want to find the odd one out. With a single opaque MD5 you can only test for exact matches against a known list. With JA4 you can ask coarser questions directly off the string: show every client that negotiated TLS 1.3 with no server name indication, which is unusual for a browser visiting a website and common for automated tooling. The counts and flags in that first segment give you a way to slice traffic before you ever compare a hash, so a new variant that has never been catalogued can still stand out by its shape. JA4 also extends to QUIC and HTTP/3, where the same handshake idea rides on UDP, which is something the older method was never built to cover.

    JA4 is one of a family

    JA4 by itself fingerprints the TLS client. FoxIO published it as the lead member of a suite called JA4+, where each method fingerprints a different part of a connection: JA4S for the server’s TLS response, JA4H for the HTTP client, JA4X for the certificate, JA4SSH for SSH sessions, and several more for TCP, latency, and DHCP. The JA4X variant works over the X.509 certificate the server presents, and if you want to see what fields live inside one of those certificates, our free X.509 certificate decoder breaks a certificate down into its issuer, validity dates, extensions, and public key. The stated uses for the suite read like a defender’s job list: scanning for threat actors, malware detection, session hijacking prevention, grouping related actors, and detecting reverse shells, among others. The JA4 TLS method itself is published under a BSD license, while the rest of the suite carries the FoxIO license that allows internal use but asks for a license to resell.

    The privacy and evasion angle, told honestly

    Everything that makes TLS fingerprinting good at catching bots also makes it a tracking tool. A fingerprint identifies a client before any cookie is set and survives a private browsing window, since it comes from the TLS stack rather than from stored state. Two people on the same network running the same browser build share a fingerprint, which limits how precisely it pins down one person, but it still sorts traffic into groups by software without anyone’s consent. This is the same tension that shows up across client identification, and it is the reason Chrome’s randomization was framed as ecosystem hygiene rather than as an anti tracking feature, even though it carried both effects.

    Evasion is real and worth naming plainly. There exist tools that rebuild a script’s handshake to match a real browser’s, so that a request claiming to be Chrome also handshakes like Chrome and slips past a mismatch check. The existence of these tools is the reason no serious defender treats a fingerprint as proof on its own. A fingerprint is one signal among several, strong because it fires early and is hard to fake casually, weak because a determined party can copy a known good handshake. This post will not walk through how to build such a forgery. The defensive takeaway is the useful one: combine the fingerprint with other evidence, watch for the contradiction between the claimed client and the observed one, and treat a perfect browser fingerprint from an unexpected source as a question rather than an answer. For more terms in this area, see our web security glossary.

    The assumption that breaks

    Step back from the cipher lists and the hash construction and one assumption is doing all the work. A client connecting over TLS assumes that encryption hides it. The padlock is up, the channel is private, the payload is unreadable to anyone in the middle. All of that is true for the contents of the conversation. It is not true for the handshake that set the conversation up. The ClientHello is sent in the open by necessity, and its construction is a property of the software, so the very act of asking for a private channel announces who is asking.

    That is the gap that JA3 and JA4 read. The client believed the encrypted channel covered its identity, and it was wrong, because the metadata of the handshake identifies the software before a single encrypted byte is exchanged. A real browser, a script wearing a browser’s name, and a malware sample each make the same request for privacy in a different accent, and the accent is the fingerprint. Testing that assumption, the quiet belief that the tunnel hides the traveler, is exactly where the signal lives.

    Frequently asked questions

    What is TLS fingerprinting?

    It is the practice of identifying client software from the way it builds its first TLS handshake message, the ClientHello, which is sent in the clear before any HTTP or JavaScript. Methods like JA3 and JA4 read fields such as the TLS version, the offered cipher suites, the extension list, and the supported curves, then turn that combination into a short stable identifier. Because the values come from the TLS library rather than the application, the fingerprint reflects the real runtime even when the client sets a misleading user agent string.

    How is a JA3 hash computed?

    JA3 reads five fields from the ClientHello in a fixed order: TLS version, cipher suites, extensions, elliptic curves, and elliptic curve point formats. It joins the values inside each field with dashes and the five fields with commas, producing a string like 769,47-53-5-10,0-10-11,23-24-25,0, then runs that string through MD5 to get a 32 character hash. Empty fields keep their commas, and GREASE placeholder values are ignored so a client still maps to one stable hash. The method comes from Salesforce, documented at github.com/salesforce/ja3.

    Why did JA3 stop working and how does JA4 fix it?

    JA3 hashes the extension list in the order it appears, so reordering the extensions changes the hash without changing the client. Attackers exploited that to dodge blocklists, and from Chrome 110 in 2023 Chrome began randomizing its extension order on purpose, which shattered the common Chrome JA3 hash. JA4, from FoxIO, sorts the cipher and extension lists before hashing so a shuffled and an unshuffled handshake produce the same fingerprint. The technical format is published at github.com/FoxIO-LLC/ja4.

    How does TLS fingerprinting catch bots and malware?

    Malware usually offers one fixed handshake from the TLS library it was built with, so its fingerprint stays the same even when it rotates server IP addresses or hostnames, which lets sensors flag it by how it connects. For bots, the strongest signal is a mismatch: a request whose user agent claims to be a browser but whose handshake matches a script like Python requests or curl is almost certainly automated. Fingerprints are one signal among several, since evasion tools exist that copy a real browser handshake.


    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: X.509 Certificate Decoder lets you decode a certificate and inspect its chain, extensions, and validity. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • How Browser Fingerprinting Identifies You Without a Cookie

    How Browser Fingerprinting Identifies You Without a Cookie

    Clear your cookies, open an incognito window, and most people assume they are starting fresh and anonymous. They are not. Long before you log in or accept a consent banner, the page has already read several dozen facts about your machine and combined them into a stable identifier. That technique is called browser fingerprinting, and it works without storing anything on your device at all. There is nothing to delete, because the identifier is not saved on your side. It is computed on the server from signals your browser hands over for free, every visit, by design. This post takes the method apart signal by signal: what each one is, how many bits of identifying information it carries, how a handful of medium entropy signals multiply into something unique among millions, and why that matters for tracking, fraud, and deanonymization.

    Why a fingerprint exists when nothing is stored

    A cookie is a value the server asks your browser to keep and send back later. You can see cookies, count them, and erase them. A fingerprint is the opposite. The server does not ask you to store anything. It reads attributes your browser already exposes to make legitimate web pages work, and it derives an identifier from the exact combination of those attributes. A page needs your screen size to lay itself out. It needs your language to pick a translation. It can query your graphics stack to decide whether to use hardware acceleration. Each of these is reasonable on its own. The fingerprint is what you get when a script collects all of them at once and treats the bundle as a name.

    Because nothing is written to your disk, the usual privacy reflexes do not touch it. Clearing cookies removes saved values, not the shape of your device. A private window blocks cookie persistence and history, not the screen resolution your monitor reports. The fingerprint survives both because it was never stored in the first place. It is recomputed from scratch on each visit, and as long as your machine and browser stay roughly the same, the result stays roughly the same.

    It helps to separate two jobs the fingerprint does. First, recognition: deciding whether the browser in front of the server right now is one it has seen before. Second, linkage: tying together two separate sessions that the user believed were unrelated, such as a logged in visit and an anonymous one. A cookie does both jobs only as long as it survives. A fingerprint does both jobs without ever needing your cooperation, and that is the whole point. The server is reading you, not asking you to carry a tag.

    The signals: what a page reads about you

    Open the developer console on any page and most of these are one line of JavaScript away. None of them require a permission prompt. Here is the core set, grouped by how a script gets at them.

    The easy attributes from the navigator and screen objects

    The navigator object is a grab bag of properties the browser exposes about itself. The classic one is the user agent string, read with navigator.userAgent, which spells out the browser name, version, rendering engine, and operating system. A typical value looks like this:

    Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
    AppleWebKit/537.36 (KHTML, like Gecko)
    Chrome/126.0.0.0 Safari/537.36

    Alongside it sit navigator.language and navigator.languages for your locale preferences, navigator.platform, navigator.hardwareConcurrency for the number of logical CPU cores, and navigator.deviceMemory for a rough memory figure. The screen object gives width, height, available width and height, and color depth. A single call to Intl.DateTimeFormat().resolvedOptions().timeZone returns your time zone as a clean string like America/New_York. Each of these is cheap to read and stable from one visit to the next.

    Font enumeration

    The exact set of fonts installed on a machine is surprisingly varied, because it reflects the operating system, the applications you have installed, and the language packs you have added. A script cannot ask for the full list directly anymore, but it can probe. It renders a string in a font it wants to test, measures the width and height of the result, and compares that against the measurement for a known fallback font. If the size differs, the requested font is present. Run that probe across a few hundred candidate fonts and the script reconstructs which ones you have. The presence or absence pattern is the signal.

    Canvas rendering

    This is where fingerprinting stops reading labels and starts measuring hardware. The HTML5 <canvas> element lets a script draw text and shapes, then read the resulting pixels back out. The trick, first described by Keaton Mowery and Hovav Shacham in their 2012 paper Pixel Perfect: Fingerprinting Canvas in HTML5, is that two machines asked to draw the exact same instructions do not produce the exact same pixels. A script draws a line of text, often with a mix of letters and an emoji, over a colored background, then calls toDataURL() to get the rendered image as a string and hashes it.

    const c = document.createElement('canvas');
    const ctx = c.getContext('2d');
    ctx.textBaseline = 'top';
    ctx.font = '14px Arial';
    ctx.fillText('Cwm fjordbank glyphs vext quiz 😀', 2, 2);
    const hash = sha256(c.toDataURL());

    The canvas does not even need to be visible on the page. The output differs because the work of turning instructions into pixels runs through your GPU, your graphics driver, your installed fonts, and your operating system font rasterizer. Anti aliasing, sub pixel smoothing, and how an emoji is drawn all vary across an Intel integrated chip, an Nvidia card, and an Apple GPU. The differences are invisible to your eye and consistent on the same machine, which is exactly what a tracker wants.

    This signal moved from research curiosity to mass deployment fast. In early 2014 the bookmarking company AddThis quietly ran canvas fingerprinting on a large share of the most visited sites on the web, a finding that drew attention because users had no way to see it happening and no setting to refuse it. That is the recurring shape of the problem. The data is read silently, the cost to the site is near zero, and the user is not told. A small study of canvas alone measured around 5.7 bits of entropy from the technique, which is not enough to name you by itself but plenty as one term in a larger product of signals.

    WebGL

    WebGL goes one level deeper into the graphics stack. A script can ask the WebGL context for the renderer string through the WEBGL_debug_renderer_info extension, and many browsers return the literal name of your graphics chip, something like ANGLE (Apple, Apple M2, OpenGL 4.1). Beyond the name, a script can render a 3D scene off screen and read the pixels back, the same idea as canvas but exercising more of the hardware. Shading, depth handling, and floating point rounding in the GPU pipeline differ across devices and show up in the output.

    AudioContext

    Audio fingerprinting applies the same logic to sound. A script creates an OfflineAudioContext, which processes audio as fast as it can without ever sending anything to your speakers. It generates a known waveform with an OscillatorNode, usually routes it through a DynamicsCompressorNode to magnify small differences, and reads the resulting samples back. Those samples are 32 bit floating point numbers. Because different audio stacks and CPUs round and process the signal with tiny differences, the values diverge at the far decimal places. Hash that buffer and you get another stable identifier, derived this time from your audio processing pipeline rather than your graphics. You never hear a thing.

    The math: how browser fingerprinting turns weak signals into a unique name

    No single signal here identifies you. Plenty of people use Chrome on macOS in the New York time zone. The power of browser fingerprinting comes from combining many signals that are each only moderately revealing. To see why, you need one idea from information theory: entropy, measured in bits.

    The surprisal of an observation with probability p is -log2(p) bits. If half of all browsers share some attribute value, observing that value costs an attacker -log2(0.5) = 1 bit, and it cuts the candidate pool in half. If one in eight browsers share a value, that is -log2(1/8) = 3 bits, and it cuts the pool to an eighth. The entropy of a whole attribute is the average surprisal across all its possible values, written H = -Σ p(x) log2 p(x). The key property is that bits from independent signals add together. Each bit halves the number of people you could be.

    Independence is the catch in that sentence, and it is worth being precise about. Bits add cleanly only when the signals do not correlate. In practice many do. Your operating system is implied by your user agent, hinted at by your font set, and reflected again in your canvas output. A tracker who naively sums the entropy of correlated signals overcounts, because the second signal tells them less once the first is known. Serious fingerprinting work measures the joint entropy of the whole bundle rather than adding the parts, which is why the headline numbers below come from full fingerprints, not from stacking the per signal figures.

    A fingerprint does not need any single signal that names you. It needs enough independent signals that, multiplied together, only one person on earth fits all of them at once.

    Put numbers on it. To be unique among the roughly 5 billion internet users alive today you need about log2(5,000,000,000), which is close to 33 bits of identifying information. That sounds like a lot until you tally what your browser gives away. In the original 2010 Panopticlick study, run by Peter Eckersley at the Electronic Frontier Foundation across 483,492 browsers, the measured entropy of individual signals looked like this:

    • User agent string: about 5.1 bits
    • Browser plugins: about 4.2 bits
    • Installed fonts: about 4.1 bits
    • Screen resolution and color depth: about 3.8 bits
    • Time zone: about 3.6 bits

    Those five medium strength signals already stack toward 20 bits when combined, enough to be one in roughly a million. Eckersley found that the full fingerprint carried at least 18.1 bits of entropy in that sample, which meant a randomly chosen browser had about a one in 286,777 chance of sharing its fingerprint with another. In practice 94.2 percent of the browsers that ran Flash or Java were outright unique. The dataset was a fraction of the global population, so 18.1 bits was enough to single out almost everyone in it.

    The signals have shifted since then but the conclusion got stronger. Plugins and Flash are gone, which removed two of the richest old signals. In their place, canvas and WebGL became the heavy hitters. The 2016 AmIUnique study by Pierre Laperdrix and colleagues collected 118,934 fingerprints and found 89.4 percent of them unique, with canvas rendering now one of the most discriminating attributes. The reason a script reads your GPU through canvas, WebGL, and audio is that hardware variation is a deep, stable well of entropy that survives browser updates far better than a version number does.

    Why a fingerprint stays stable

    An identifier is only useful for tracking if it is the same tomorrow. Fingerprints are not perfectly stable. You update your browser and the user agent changes. You plug in an external monitor and the screen resolution changes. Eckersley measured this churn and found fingerprints shifted often, yet a simple heuristic still relinked more than 99 percent of changed fingerprints to their previous version, because usually only one attribute moves at a time while the rest hold.

    The hardware derived signals are the anchor. Your GPU, your audio chip, and your installed fonts change far less often than your browser version. Canvas and WebGL hashes can stay identical across browser updates because they reflect silicon and drivers, not software labels. A tracker that sees most of your fingerprint stay constant while one field drifts can follow you across the change. The bundle is sticky even when its parts are not.

    Why this is a privacy and security threat

    The first harm is plain tracking. A fingerprint is a cookie that you cannot clear and did not consent to. Advertising and analytics networks use it to recognize you across sites and sessions even after you delete cookies or switch to a private window. It works in the exact moments people reach for privacy, which is what makes it worse than a cookie rather than equal to one.

    The second harm is deanonymization. Suppose you use one browser profile for an ordinary logged in account and the same browser, in incognito, for something you want kept separate. If both sessions produce the same fingerprint, a service that sees both can tie them to one device. The anonymity you expected from a fresh window evaporates, because the device itself was the identifier the whole time. The same linkage works across sites that share data with a common third party. If an advertising network is embedded on two unrelated sites and both reads return the same fingerprint, that network can join your activity on both, no cookie required and no account needed.

    There is a quieter harm that compounds the first two. Fingerprinting is not the only data that leaks about you without a prompt: a photo you share can carry hidden EXIF metadata such as the GPS coordinates where it was taken, which you can inspect and strip with our free EXIF metadata viewer and scrubber before you post it. Because a fingerprint is read passively, it can be collected before any consent dialog appears and without leaving an obvious trace in the browser. A user inspecting cookies and storage sees nothing unusual, because the identifier lives on the server side, derived from a few script calls that look like ordinary feature detection. The absence of a visible artifact is part of what makes the technique hard to govern. You cannot easily audit what you cannot see being stored.

    The third use cuts the other way, toward defense, and it is worth being honest about. The same fingerprint that tracks you also helps fraud and account takeover systems. When your bank sees a login from an account it knows, on a device whose fingerprint it has seen many times, it can wave you through. When the same account suddenly logs in from a device with a fingerprint never seen before, that is a signal worth a second factor. Fingerprinting is a tracking threat and an anti fraud tool at the same time, and which one it is depends entirely on who is doing it and why. The mechanics are identical.

    Defenses, and their honest limits

    You cannot turn off fingerprinting the way you can clear a cookie, but you can shrink your entropy or muddy the signal. The approaches split into two camps, and both have real limits.

    • Randomization. Some browsers add small noise to canvas, audio, and WebGL output so the hash differs on each read. Brave does this by default. The catch is that a fingerprint that changes every visit can itself be a recognizable trait, and a determined tracker can sometimes average the noise out across reads.
    • Uniformity. The Tor Browser takes the other path. It tries to make every user look identical by standardizing the window size, blocking or faking many signals, and prompting before a canvas can be read. If everyone in the crowd looks the same, no fingerprint stands out. The cost is a more restricted browsing experience, and the protection only holds while you behave like the standard configuration. Resize the window or install an extension and you start to stand out again.
    • Built in browser modes. Firefox ships resist fingerprinting and protection features, and Safari trims the data it exposes. These help, but vendors balance privacy against breaking real sites, so the protection is partial by design. Each blocked signal that a normal site relies on is a site that might break.

    The uncomfortable truth is that better fingerprinting protection can make you more unique, not less, if it makes your browser behave unlike anyone else’s. Privacy here is a crowd problem. You are safest when you look like everyone around you, and most hardening makes you look different. For the full vocabulary around tracking, identifiers, and the attack surface of the browser, our web security glossary is a good companion. The research mindset that exposes fingerprinting in the first place, asking what a system quietly assumes and then testing it, is the same one behind how researchers find vulnerabilities.

    The assumption that breaks

    Every privacy tool aimed at the casual user rests on one assumption: that your identity online is a thing you store, so deleting what you stored makes you anonymous again. Clear the cookies, open a private window, wipe the history, and you are someone new. Browser fingerprinting breaks that assumption at the root. There was never anything stored on your side to delete. The identifier is your device, read live from the screen, the graphics chip, the fonts, the audio stack, the clock. You did not save it and you cannot erase it, because it is not a record. It is a measurement.

    That is the gap worth sitting with. The thing people trust to make them anonymous, clearing local state, targets the wrong layer entirely. The fingerprint lives one level below, in the physical and configured reality of the machine, and that level does not reset when you clear your cookies. Anonymity online was supposed to be something you could reclaim by forgetting. Fingerprinting quietly turned it into something the device remembers for you.

    Frequently asked questions

    Does clearing cookies or using incognito stop browser fingerprinting?

    No. A fingerprint is not stored on your device, so there is nothing to clear. It is recomputed on each visit from attributes your browser exposes, like screen size, time zone, installed fonts, and how your GPU renders a canvas. A private window blocks cookie and history persistence, not these signals, so the same fingerprint reappears. The Electronic Frontier Foundation explains this in its Cover Your Tracks project.

    How many bits of information does it take to identify a browser uniquely?

    Identity is measured in entropy, in bits, where each bit halves the pool of people you could be. To be unique among roughly 5 billion internet users you need about 33 bits. In the 2010 Panopticlick study across 483,492 browsers, the full fingerprint carried at least 18.1 bits of entropy, enough to give a roughly one in 286,777 chance of a collision, and 94.2 percent of browsers running Flash or Java were outright unique in that sample.

    What is canvas fingerprinting?

    Canvas fingerprinting asks the browser to draw text and shapes onto a hidden HTML5 canvas, then reads the pixels back with toDataURL() and hashes them. Two machines given identical drawing instructions produce slightly different pixels because of differences in GPU, graphics driver, fonts, and the operating system rasterizer. The technique was first described by Keaton Mowery and Hovav Shacham in their 2012 paper Pixel Perfect, and canvas is now one of the most discriminating fingerprint signals.

    Is browser fingerprinting only used for tracking?

    No. The same signals power tracking and deanonymization on the privacy invading side, and fraud detection and account takeover protection on the defensive side. A bank can recognize a known device by its fingerprint and challenge a login from a device it has never seen. The mechanics are identical. Whether fingerprinting is a threat or a safeguard depends on who collects it and why, which Mozilla covers in its MDN guide on fingerprinting.


    Put an autonomous researcher on your own systems

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

  • How a Device Decides to Trust Its Own Firmware

    How a Device Decides to Trust Its Own Firmware

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

    What secure boot is actually deciding

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

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

    The hardware root of trust: where trust has to start

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

    eFuses and one time programmable memory

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

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

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

    Walking the chain upward, one signature at a time

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

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

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

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

    Anti rollback: blocking the downgrade trick

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

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

    Where the secure boot chain actually breaks

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

    Stages that were never signed in the first place

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

    Debug interfaces left wide open

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

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

    Fault injection: glitching the check into passing

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

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

    TOCTOU: verify one image, run another

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

    Rollback and key handling mistakes

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

    Why the secure boot chain holds or fails as a whole

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

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

    What a defender should take away

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

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

    Frequently asked questions

    What is the hardware root of trust in secure boot?

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

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

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

    How do attackers bypass secure boot without breaking the cryptography?

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

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

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


    Put an autonomous researcher on your own systems

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

  • What Actually Happens In A Kernel Use After Free

    What Actually Happens In A Kernel Use After Free

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

    The kernel heap is not one big pool

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

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

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

    What a freed object actually looks like

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

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

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

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

    The exact moment of reuse in a kernel use after free

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

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

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

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

    Heap grooming: making the right object land in the hole

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

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

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

    Cache merging widens the field

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

    Why reuse becomes power: choosing the victim object

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

    A function pointer you can aim

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

    A length or pointer field you can lie about

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

    A credential you can swap

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

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

    A real kernel use after free walked end to end

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

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

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

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

    Why the kernel cannot just notice

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

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

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

    The assumption that outlived its reference

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

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

    Frequently asked questions

    What is a kernel use after free in simple terms?

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

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

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

    How does a use after free turn into root access?

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

    Can the kernel detect a dangling pointer on its own?

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


    Put an autonomous researcher on your own systems

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

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

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

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

    Why the eBPF verifier is a security boundary

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

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

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

    What the verifier has to prove

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

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

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

    How the proof works: registers, tnums, and bounds

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

    tnum: which bits are known

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

    min and max bounds

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

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

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

    Walking the program as a graph

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

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

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

    The complexity limit and state pruning

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

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

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

    Where the proof has broken: bounds tracking CVEs

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

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

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

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

    CVE-2021-3490: ALU32 bitwise operations

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

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

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

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

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

    Why this class of bug keeps coming back

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

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

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

    The boundary that runs through a proof

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

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

    Frequently asked questions

    What does the eBPF verifier actually do?

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

    Why is the verifier a security boundary?

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

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

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

    Can the verifier stop Spectre style speculative attacks?

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


    Put an autonomous researcher on your own systems

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

  • Instance metadata service: the 169.254.169.254 credential leak

    Instance metadata service: the 169.254.169.254 credential leak

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

    Why there is a magic IP address at all

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

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

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

    What actually lives behind 169.254.169.254

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

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

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

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

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

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

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

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

    The credential flow: from role to STS to keys

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

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

    How the instance metadata service becomes an attack

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

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

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

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

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

    Capital One: the textbook case

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

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

    IMDSv1 versus IMDSv2: the token dance

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

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

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

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

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

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

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

    The hop limit, a defense at the IP layer

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

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

    The same idea on the other clouds

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

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

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

    When blocking the address is not enough

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

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

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

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

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

    How to actually lock it down

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

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

    The assumption that breaks

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

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

    Frequently asked questions

    What is the instance metadata service used for?

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

    How does SSRF lead to stealing cloud credentials?

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

    Does IMDSv2 fully prevent metadata attacks?

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

    Do Google Cloud and Azure have the same metadata risk?

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


    Put an autonomous researcher on your own systems

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

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