Feb 12, 2026·6 min read·19 visits
The `set-in` library used a mutable global method (`.includes()`) to validate input against prototype pollution. Attackers can hijack this method to bypass the check, allowing full prototype pollution.
A critical prototype pollution vulnerability in the `set-in` npm package exposes a fundamental misunderstanding of JavaScript's mutable environment. The library attempted to blacklist dangerous keys like `__proto__` and `constructor` to prevent pollution, but it implemented this check using `Array.prototype.includes`. In a hostile environment—or via a prior gadget—an attacker can monkey-patch this method to always return `false`, effectively turning off the security camera before robbing the bank. This allows arbitrary property injection into the global `Object.prototype`, leading to Denial of Service (DoS) or Remote Code Execution (RCE).
We all love helper libraries. Why write ten lines of defensive code to safely set a nested property when you can just npm install set-in and do it in one? It’s the classic developer trade-off: convenience for dependency hell. The set-in package does exactly what it says on the tin: it takes an object, a path (like ['users', 'admin', 'permissions']), and a value, and it sets it deep inside the structure. It creates the intermediate objects if they don't exist. It's elegant, simple, and prior to version 2.0.5, fundamentally broken.
The maintainers weren't negligent; they were trying to be secure. They knew about the horrors of Prototype Pollution—the vulnerability class where an attacker modifies the base Object.prototype, causing every object in the application to inherit malicious properties. They even had a blacklist. They had a specific array of forbidden keys: __proto__, constructor, and prototype.
But here's the punchline: they trusted the JavaScript runtime environment to be honest. In the world of exploit development, trusting the environment is like trusting a starving lion to hold your steak. The vulnerability isn't just that they missed a key; it's that the mechanism they used to check the keys was itself susceptible to modification. It’s a meta-vulnerability.
The root cause of CVE-2026-26021 is a lesson in JavaScript's terrifying flexibility. The library defines a constant array of forbidden keys: const POLLUTED_KEYS = ['__proto__', 'constructor', 'prototype']. When the setIn function iterates through the user-provided path, it checks if the current key is in that list. So far, so good, right?
Wrong. The implementation looked like this:
assert.ok(!POLLUTED_KEYS.includes(key), ...)See the problem? It relies on Array.prototype.includes. In JavaScript, almost everything is mutable, including the definitions of core language methods. If an attacker—or a malicious third-party script, or a previous clumsy exploit—can modify Array.prototype.includes, they control the outcome of this security check.
Imagine a bank vault that checks your ID card. But instead of looking at the card, the guard calls a phone number on the wall labeled "Verify ID". If you can change that phone number to route to your accomplice, the guard will let you in every time. Here, the includes method is that phone number. By redefining it to always return false, the library happily accepts __proto__ as a valid key, believing it's safe because the hijacked check said so.
Let's dissect the code changes in commit b8e1dabfdbd35c8d604b6324e01d03f280256c3d. The difference is subtle but changes the logic from "user-land trust" to "language-spec enforcement".
The Vulnerable Code:
// Relying on a method that exists on the Array prototype
var key = path[index];
assert.ok(!POLLUTED_KEYS.includes(key), `setIn: ${key} is disallowed...`);This is vulnerable because POLLUTED_KEYS inherits from Array.prototype. If I do Array.prototype.includes = () => false, the assertion passes regardless of what key is.
The Fixed Code:
// Relying on primitive equality operators
if(key == "constructor" || key == "prototype" || key == "__proto__"){
throw `setIn: ${key} is disallowed...`
}The fix abandons the elegant array lookup for ugly, hard-coded logic using the == operator. Why is this better? Because you cannot monkey-patch the == operator. It is a fundamental syntactic construct of the language, not a property of an object. The attacker can poison every prototype in the heap, but they cannot change the fact that "__proto__" == "__proto__" evaluates to true.
To exploit this, we don't just throw data at the library; we have to prepare the battlefield. This is a two-stage attack. First, we compromise the environment. Second, we trigger the vulnerability.
Step 1: The Monkey Patch
We need to override the includes method. In a real-world scenario, this might happen via a separate, lower-severity vulnerability that allows executing simple assignments, or via a "gadget" chain where we can define properties on prototypes.
// The heist setup: blind the security guard
const originalIncludes = Array.prototype.includes;
Array.prototype.includes = function() {
return false; // "Everything is fine, officer."
};Step 2: The Injection
Now we call set-in. The library asks, "Is __proto__ in the blacklist?" Our hijacked method replies, "No."
const setIn = require('set-in');
const target = {};
// The payload
setIn(target, ['__proto__', 'polluted'], 'PWNED');
// The proof
console.log({}.polluted); // Outputs: 'PWNED'Once Object.prototype is polluted, every object created subsequently will have the polluted property set to 'PWNED'. This persists until the process restarts. If you change polluted to toString or toJSON, you can crash the application or alter API responses globally.
Why should you care if I can set a property on an object? Because JavaScript developers rely on "undefined" being truly undefined.
Remote Code Execution (RCE):
Many server-side template engines (like Handlebars or EJS) or process-spawning libraries (like child_process) inspect option objects. If they check for a property like shell or eval and find it missing, they use a safe default. Prototype pollution allows us to define those properties globally. Suddenly, a child_process.exec call that was supposed to run ls is running /bin/sh because we polluted the options object.
Denial of Service (DoS):
If we pollute __proto__.toString with a string instead of a function, any code attempting to cast an object to a string will throw a TypeError and crash the Node.js process. It's a kill switch for the entire server.
Authentication Bypass:
Imagine code like if (user.isAdmin) { grantAccess() }. If user.isAdmin is undefined, access is denied. But if we pollute Object.prototype.isAdmin = true, then every user is an admin. The logic inversion is trivial and devastating.
The immediate fix is to upgrade set-in to version 2.0.5. This version removes the reliance on mutable methods for its security checks. But the broader lesson here is about defensive coding.
1. Freeze Your Prototypes: If you are running a Node.js service, you should consider freezing the built-in prototypes at application startup. This prevents dependencies from monkey-patching globals, intentionally or accidentally.
// Make the environment immutable
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);2. Map over Object:
When storing key-value pairs where keys are user-controlled, use the Map structure instead of plain Objects. Map does not inherit from Object.prototype and is immune to these key-collision attacks.
3. Input Validation:
Never assume a library handles validation perfectly. Use a schema validator like Joi or Zod to ensure that input paths do not contain restricted keywords before they even reach utility functions like set-in.
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H| Product | Affected Versions | Fixed Version |
|---|---|---|
set-in ahdinosaur | >= 2.0.1, < 2.0.5 | 2.0.5 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-1321 |
| Attack Vector | Local (via library usage) |
| CVSS Score | 9.4 (Critical) |
| Affected Versions | < 2.0.5 |
| Exploit Status | PoC Available |
| Patch | Commit b8e1dab |