Have you ever seen something that felt wrong, and it just…bothered you? You just couldn’t let go?

This was CVE-2026-40175 for me. It’s another axios incident after the supply-chain attack on March 31st, but that’s not weird by itself. It’s a 10/10, which is noteworthy, but these happen.

No, this one gave me pause because it just doesn’t click. I spiralled. Spiral with me.

We have a companion higher level post on our company website

Weirdness afoot

The claims in the vulnerability advisory are both straightforward and, at once, bizarre. Let’s go over them:

Prototype pollution: This attack pre-assumes another prototype pollution that happens before axios code is called:

Imagine a scenario where a known vulnerability exists in a query parser. The attacker sends a payload that sets:

Starting off strong, but JavaScript has had plenty of prototype pollution vulnerabilities over the years, so finding and exploiting one in a random application server is certainly possible. In this case, we’re overwriting the x-amz-target header with a string containing multiple newlines. This is both (potentially) header injection and request splitting, depending on the downstream code. Let’s read on.

Gadget: The actual application code does a legitimate request:

Interesting!

Execution: The advisory then says that:

Press enter or click to view image in full size

There’s a few assumptions being made, so let’s dissect what’s going on here. We’re positing that:

  1. Axios takes the polluted property we’ve previously written and adds it to the headers
  2. Axios writes these headers into the socket verbatim, resulting in multiple requests being sent over the same socket

We’ll dive into these in a moment, but hold on to your breath, because we then, in The Impact:

This is where I cracked. What?

Deep breaths. We’ll dive into details, TOO much into the details, and the incredibly confusing journey this advisory will lead us. To start us off, let’s see how the two points above about how Axios behaves with headers are demonstrably false.

What header injection?

Looking at the implementation and reality, Axios is robust against header merging attacks from prototype pollutions by a combination of defense-in-depth techniques:

  1. Only going over own-keys of the configuration in the first place (and once more for good measure)
  2. In configuration properties themselves (like headers), only going over own-keys, limiting the effects of prototype pollution

We can show this quite easily:

$ mkdir axios-oh-nose && cd axios-oh-nose
$ npm i axios
$ cat > headers.mjs
import axios from 'axios';

async function captureHeaders(requestConfig) {    // 1
  let captured;
  await axios.request({
    ...requestConfig,
    adapter: (config) => {
      captured = config;                          // 2
      return Promise.resolve({
        data: null,
        status: 200,
        statusText: 'OK',
        headers: {},
        config,
        request: {},
      });
    },
  });
  return captured.headers;
}

Object.prototype['x-amz-target'] = 'foo';         // 3
const headers = await captureHeaders({            // 4
  method: 'GET',
  url: 'http://localhost/',
});

console.log(headers.get('x-amz-target'));         // 5

$ node headers.mjs

Prints undefined, i.e. no header injection from prototype pollution. What we’ve done here is:

  1. Defined an async function which sends an axios request
  2. Created an axios adapter which captures the configuration we gave axios
  3. Simulated a prototype pollution
  4. Sent a request
  5. Extracted the header that was passed to axios

And our new property was not present as a new header.

But let’s assume I’m wrong. Maybe there needs to be a very specific prototype pollution that we can’t immediately think of, and we manage to trick axios into believing it needs to add a header that contains newlines. Will we end up with an injected header?

Axios has three default adapters:

  1. http for node (which is used in node)
  2. xhr for the browser
  3. fetch for the cross-platform web standard

Node’s builtin http library has been preventing newline injection in headers since node 14:

$ docker run --rm -it node:14 node
> var req = http.request({ hostname: 'example.com' });
> req.setHeader('Foo', 'Bar\r\n');
Uncaught TypeError [ERR_INVALID_CHAR]: Invalid character in header content ["Foo"]
    at ClientRequest.setHeader (_http_outgoing.js:564:3)

So basic header injection on node won’t work.

What about other runtimes, which use fetch? This doesn’t really work in node or the browser, because the spec forbids it:

$ docker run --rm -it node:24 node
> fetch('https://httpbin.org/headers', { headers: { 'foo': 'a\r\nb\r\n\r\nc' } })
Uncaught TypeError: Headers.append: "a
b

c" is an invalid header value.

Node isn’t the only runtime however. There’re Deno, Bun, workers based off of v8 isolates, and so on, maybe one of them isn’t spec complaint? Maybe, but header injection in a runtime’s standard library is a lucrative CVE — not an axios CVE.

Magically vulnerable adapter

All of the problems above are supposedly covered by the first lines in the exploit chain. Remember this?

Imagine a scenario where a known vulnerability exists in a query parser.

We’re assuming a vulnerability in an axios adapter or other point of extension, not one provided by default inside axios, which is vulnerable to header injection and writes to the socket directly.

So ok, let’s assume that we’ve found this additional axios package, that does all of the above:

  1. Contrary to axios’ defaults, merges headers in a vulnerable fashion
  2. Contrary to axios’ adapters, writes to the socket in a way that bypasses the NodeJS standard library

We still have a problem. Let’s look at the post-injection payload above:

GET /pings HTTP/1.1
Host: analytics.internal
x-amz-target: dummy

PUT /latest/api/token HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token-ttl-seconds: 21600

GET /ignore HTTP/1.1

We’re sending a request to our service, analytics.internal, which is actually multiple requests, as part of one socket. It then needs to be able to actually interpret and act on these requests: Notably, sending the request to 169.254.169.254, which is another server entirely. We’ll get to this magic server in a moment!

This can be standard behaviour for http servers and proxies, but requires knowing in advance the target server to prepare the request smuggling payload for. So it’s not “just any” http request, but a specific request to a specific server.

But let’s imagine that we have this magical server that splits the request and forwards it to 169.254.169.254. We still need to read the response.

IMDS doesn’t work this way

IMDS, Instance MetaData Service, is a magical AWS service which gives compute resources access to metadata about themselves. Compute instances can access it via the ip address 169.254.169.254. Crucially, it can mint tokens for any role the instance is assigned. SSRFs into IMDS services is something that’s been covered widely in the past, for example in the 2018 CapitalOne breach.

To make exploits harder, since IMDSv2 this process requires two requests:

  1. A PUT request (like shown in the above payload) to mint a token
  2. A GET request to use the token from above to exchange with role credentials

Just one request doesn’t give us much — the token is tied to the instance it was sent from.

Returning to the attack, the above chain with the request forgery minted a token, but didn’t send it anywhere:

The response flows back from the imdsv2 service to the proxy, and back to the vulnerable application server. Without a very specific endpoint that entirely mirrors the response back to the user, this is a blind SSRF — a real vulnerability, but not full cloud takeover.

If the attacker were able to see the response, then yes: They could extract the response token from the proxied IMDSv2 response, and use the same primitive to exchange the IMDSv2 SSRF token for role-specific credentials.

We’ve looked into potential leak vectors like common logging infrastructure which can maybe be influenced to send requests to attacker-controlled destinations (such as winston) without any fruitful results.

So what do we have?

We have a vulnerability that requires:

  • An unrelated, prerequisite prototype injection
  • An ability to bypass axios’ existing prototype injections mitigations when it comes to headers
  • Another ability to bypass node’s builtin header protections
  • Or, another third party axios package which is vulnerable to both of the above
  • A known destination server that is willing to act on our weird request
  • Some way of extracting the response

Amounting to a 10/10 CVE?

What does any of this mean?

Honestly? I don’t know what to think. At face value, there’s too many prerequisites for this attack to be interesting.

We have a few theories for what happens now:

  1. This is a galaxy-brain attack chain. One of the most common axios adapters (or another axios-adjacent package) has a serious vulnerability that is yet-to-be-unearthed, or has already been fixed but has not been constructed into a larger, beautiful chain. I will concede and eat my hat
  2. This is a niche, but viable attach chain. Like scenario 1, but scoped to some esoteric usecase, and not truly exploitable in practice. The fix is just good defense-in-depth
  3. There is a flow within AWS services (maybe AWS lambdas) that sees these in-flight requests and responds to them in a way that I can’t conceive of (again, hat eating)
  4. A combination of 1/2 and 3
  5. This is a 10/10 in GHSA out of precaution, but won’t be a 10/10 once the NVD score is out
  6. I dunno? Martian space lasers? I’m out of ideas here?

Have YOU obsessed over this vulnerability? If you’ve got a better idea, we’d love to hear it.

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>