Gadget chains: How low-severity bugs combine across dependencies to become critical
How a prototype pollution bug in one library and a missing header check in another nearly chained into AWS credential theft.
A few days ago, a CVE was published against Axios, the JavaScript HTTP client used in virtually every Node.js project. It described a chain where a prototype pollution bug in one library could combine with a missing header validation in Axios to escalate into AWS credential theft. No single library in the chain was doing anything obviously wrong. The danger was in how they composed.
This is a gadget chain: a sequence of low-severity issues across separate dependencies that, connected together, produce a high-severity outcome. They're hard to catch because conventional tooling evaluates each vulnerability in isolation. And they're especially common in the Node.js ecosystem, where deep dependency trees and dynamic object handling create a lot of surface area for unexpected interactions.
This post uses CVE-2026-40175 as a worked example. We'll walk through the chain step by step, look at why it was ultimately blocked, and talk about why the pattern still matters.
Quick refresher on prototype pollution
If you already know how prototype pollution works, skip ahead. If not, here's the short version.
In JavaScript, almost every object inherits from Object.prototype. Prototype pollution is a bug where an attacker can inject properties into that shared prototype, usually through a library that recursively merges user-controlled input into an object without filtering keys like __proto__ or constructor.
Dozens of popular npm packages have had prototype pollution vulnerabilities over the years, including older versions of qs, minimist, lodash, and body-parser. They're common enough that most teams have seen them show up in npm audit output. They usually get triaged as low or medium severity because on their own, polluting a prototype is rarely enough to do something dangerous.
That "on their own" qualifier is where gadget chains come in.
What's a gadget chain?
The term "gadget" comes from deserialization exploit research. A gadget is a piece of existing, trusted code that does something useful to an attacker when fed unexpected input. A gadget chain is a sequence of these pieces, connected so that the output of one feeds the input of the next, ultimately achieving something none of them were designed to do.
In the context of Node.js dependency trees, a gadget chain works like this:
- An attacker exploits a prototype pollution bug in Library A to inject properties onto
Object.prototype. - Library B, completely unrelated to Library A, reads from an object that now carries those polluted properties.
- Because Library B doesn't validate or sanitize what it reads, the attacker's values flow into a sensitive operation: an HTTP request, a file path, a shell command.
No single library is "the vulnerability." The bug is in the composition.
The Axios chain, step by step
CVE-2026-40175 is a textbook example of this pattern. Here's how the attack was designed to work.
Step 1: Pollute the prototype (somewhere else)
The attacker needs a prototype pollution bug anywhere in your dependency tree. Not in Axios itself. It could be in a query string parser, a configuration loader, a form handler. Any library that does an unsafe recursive merge on user-controlled input will do.
At this point, Object.prototype has been polluted. Every plain object created from here on will inherit the attacker's properties unless it's created with Object.create(null).
Step 2: Axios merges the polluted properties into a request
When Axios builds an HTTP request, it merges configuration from multiple sources: defaults, instance config, and per-request options. This merging happens through AxiosHeaders.set(), which iterates over object properties.
Before the patch, AxiosHeaders didn't distinguish between properties that legitimately belonged to the config object and properties inherited from a polluted prototype. So the attacker's injected header values got folded into the outgoing HTTP request.
The key code path was in lib/core/AxiosHeaders.js. The set method accepted header values without checking for carriage return and line feed (CRLF) characters:
This is the gadget. Axios is trusted code doing what it's supposed to do (setting headers), but it becomes dangerous when the input has been tampered with through prototype pollution.
Step 3: CRLF injection turns a header into a new request
Because Axios wasn't stripping CRLF characters from header values, an attacker could craft a polluted value like:
When Axios wrote this to the socket, the HTTP parser on the receiving end would interpret everything after the \r\n\r\n as a separate request. This is HTTP request smuggling: one logical request at the application layer becomes two at the protocol layer.
Step 4: Steal cloud credentials via SSRF
The smuggled request targets 169.254.169.254, the AWS instance metadata service. The crafted PUT request is specifically designed to request an IMDSv2 token, which can then be used to retrieve IAM role credentials from the metadata endpoint.
If this chain completed, an attacker who found a prototype pollution bug in any dependency could escalate it into full AWS credential theft, without any direct user input or interaction.
Why it doesn't work (and why that's interesting)
Here's the twist. Node.js has validated outgoing HTTP headers since version 14 (released in 2020). The http.OutgoingMessage class rejects any header value containing CRLF characters before the request is ever sent:
This means Step 3 of the chain, the CRLF injection, is blocked at the runtime level. Axios tries to set the malicious header, Node.js throws an error, and the request never goes out. The chain is broken.
The Snyk advisory notes one edge case: if you're using a custom Axios adapter that bypasses Node's built-in HTTP client, the runtime protection doesn't apply. This is uncommon but not impossible. Some applications use custom adapters for testing, proxying, or working with non-standard transports.
So the Axios code was genuinely vulnerable (it should have been validating header values itself), and the fix in version 1.15.0 was the right call. But in the vast majority of real deployments, the exploit chain was already dead on arrival.
Why gadget chains still matter
It's tempting to look at this specific CVE and conclude that gadget chains are mostly theoretical. That would be the wrong takeaway.
Node.js happened to block this particular chain because CRLF injection in headers is a well-understood attack and the runtime added validation years ago. But not every gadget chain ends with a CRLF injection. Consider what happens when the final step is:
- A polluted property that changes a file path, redirecting a write operation
- A polluted
constructorproperty that alters control flow - A polluted
envproperty that gets passed to a child process viaspawn
These don't hit the same runtime guardrails. The general pattern (low-severity pollution bug + unsanitizing downstream library = high-severity outcome) is alive and well.
Here are some examples from recent history:
- class-transformer had a gadget where prototype pollution could manipulate object instantiation, letting attackers control which class got constructed during deserialization.
- knex.js had a path where polluted prototype properties could alter SQL query construction.
- express-fileupload combined with prototype pollution could overwrite arbitrary files on disk.
In each case, the pollution source and the gadget were in different libraries. If you only looked at either one in isolation, the severity seemed low.
What this means for how you evaluate risk
Most vulnerability management workflows treat each CVE as an independent item. npm audit gives you a flat list. You triage each one based on its CVSS score, maybe check if there's a known exploit, and prioritize accordingly. Prototype pollution findings tend to sit near the bottom of that list.
Gadget chains break this model. The risk of a prototype pollution bug depends entirely on what other code is in the process, and that changes with every dependency you add or update. A pollution bug that's harmless today could become exploitable next week when you add a new library that happens to be a good gadget.
There's no perfect tooling for this yet. Some things that help:
- Understand which of your dependencies do object merging with user input. These are your potential pollution sources. Libraries that parse query strings, process form data, or deep-merge configuration are the usual suspects.
- Look at how your HTTP client, ORM, and file-handling libraries consume configuration. Do they use
Object.assignor spread operators on objects that might carry prototype properties? Do they validate inputs before using them in sensitive operations? These are your potential gadgets. - Use
Object.create(null)for security-sensitive objects. Objects created this way don't inherit fromObject.prototype, so they're immune to prototype pollution. Some libraries have started adopting this pattern internally. - Freeze the prototype in sensitive contexts. Calling
Object.freeze(Object.prototype)is a blunt instrument that can break some libraries, but in controlled environments like serverless functions, it can be an effective defense-in-depth measure. - Don't dismiss low-severity findings without context. A "low" prototype pollution finding in a query string parser deserves a second look if your application also uses a library with known gadget potential.
The fix in Axios, and what good mitigation looks like
The patch in Axios 1.15.0 added a validation function called assertValidHeaderValue that runs inside AxiosHeaders.set():
This is defense in depth done right. Even though Node.js already blocks CRLF in headers, Axios now enforces the constraint at the library level too. If someone uses Axios with a custom adapter or a runtime that doesn't validate headers, the protection still holds.
The broader lesson: libraries that build HTTP requests, SQL queries, file paths, or shell commands should validate their inputs independently of what the runtime or framework might catch. Trusting the layer below you is how gadget chains form.
Wrapping up
CVE-2026-40175 isn't a practical emergency for most teams. The exploit chain requires prototype pollution in a separate dependency, the CRLF injection is blocked by Node.js, and the cloud metadata exfiltration only applies in AWS environments with metadata access enabled. Upgrading Axios to 1.15.0 is still the right move, but you probably don't need to drop everything.
The more durable takeaway is the pattern itself. Gadget chains turn low-severity bugs into high-severity outcomes by exploiting the seams between libraries. They're hard to detect with conventional tooling because no single library is "the problem." And they're especially common in the Node.js ecosystem, where deep dependency trees and dynamic object handling create fertile ground for unexpected interactions.
If you do one thing after reading this, go look at how your most security-sensitive dependencies handle object merging and input validation. That's where the next chain will form.