4 min read

JavaScript Prototype Chain & Prototype Pollution

Table of Contents

Own Property vs Inherited

Let’s take an example:

const a = { nama: "Budi" };
a.umur = 25;

Does a have a property called nama? The answer is yes, they have. And this is called Own Property. Does a have an own property called toString? The answer is no. So, toString is not own property of a.

Prototype Chain

Now let’s move to Inherit, for example:

const a = { nama: "Budi" };
const b = Object.create(a);
b.umur = 25;

b has an own property with the key called umur. And what about b.nama does b automatically makes the nama as its own property? The answer is No, because those property are inherit from a.

The interesting part is, if a doesn’t have the property of “something”, it will go up to Object.prototype, then until null. The chain would be:

flowchart LR
    B["b"] --> A["a"]
    A --> C["Object.prototype"]
    C --> D["null"]

Before continue, let’s deep dive into GETTER and SETTER — you’ll need this to understand how __proto__ access works next.

Getter & Setter

Getter is a function that runs automatically every time the property is accessed. For example:

const person = {
	namaDepan: 'Budi',
	namaBelakang: 'Santoso'


	get namaLengkap(){ //This is GETTER
		return this.namaDepan + ' ' + this.namaBelakang;
	}

	// This is normal function
	namaLengkaps(){
		return this.namaDepan + ' ' + this.namaBelakang;
	}
};

// Output: 'Budi Santoso'
console.log(person.namaLengkap)


console.log(person.namaLengkaps) // Output: ƒ () { ... }  (function reference)

console.log(person.namaLengkaps()) // Output: 'Budi Santoso'

As we can see, after we called the property, it immediately execute the function. And there’s another concept called SETTER, SETTER is a function that accepts exactly one argument (the assigned value), but it’s still accessed as a property assignment (like GETTER); not called with (). For example:

const person = {
	namaDepan: 'Budi',
	namaBelakang: 'Santoso'


	set namaLengkap(value){ //This is SETTER
		const parts = value.split(' ');
		this.namaDepan = parts[0];
		this.namaBelakang = parts[1];
	}
};
person.namaLengkap = 'Adolf Hitler';

console.log(person.namaDepan); //Output: 'Adolf'

console.log(person.namaBelakang); //Output: 'Hitler'

Example Vulnerable Code 1

Here is an example of a vulnerable code (prototype pollution) regarding this:

function deepMerge(target, source) {
  for (let key in source) {
    if (
      source[key] &&
      typeof source[key] === "object" &&
      !Array.isArray(source[key])
    ) {
      if (!target[key]) target[key] = {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Attacker Flow

Here’s the twist — __proto__ is not an actual key in the object, it’s a getter/setter on Object.prototype that accesses the object’s prototype. So when the attacker sends this:

let source = {
  favoriteCorp: "wheat",
  experienceLevel: "intermediate",
  farmSize: "501",
  __proto__: { isAdmin: true },
};
let target = {
  favoriteCorp: "wheat",
  experienceLevel: "intermediate",
  farmSize: "60",
};

The first 3 iterations work as normal (copying string values). On the 4th iteration (key = "__proto__"):

Step 1 — The outer condition passes:

if({isAdmin: true} && typeof {isAdmin: true} === 'object' && !Array.isArray({isAdmin: true}))
// → true && true && true → enters the branch

Step 2 — The guard check:

if (!target[key]) target[key] = {};
// → if (!target["__proto__"]) target["__proto__"] = {};

target has no own property called "__proto__", so target["__proto__"] walks the prototype chain and resolves to Object.prototype — which is truthy.

So !target["__proto__"] is false → the if is skipped. Good, right?

Step 3 — But the recursion still runs:

deepMerge(target["__proto__"], source["__proto__"]);
//         ↕ resolves via prototype chain
//  deepMerge(Object.prototype, { isAdmin: true })

This sets Object.prototype.isAdmin = true, polluting every object. The if guard only blocked reassigning target["__proto__"], but the recursive call modifies the prototype anyway.

So why is __proto__ a getter/setter?

Because __proto__ is a getter/setter on Object.prototype, not a real key. Accessing target["__proto__"] runs the getter which returns the internal prototype (Object.prototype). That’s why it feels like “reading a property” but actually triggers hidden behavior — and why the guard fails.

Normal Flow (for contrast)

With a normal source object (no __proto__), every key is a plain string property. Each iteration hits the else branch and copies the value directly — no prototype chain shenanigans.