SecLab

Prototype pollution

A08CWE-1321
01

What it is

Prototype pollution is a JavaScript-specific flaw: the attacker sets a property on Object.prototype — the prototype shared by EVERY object — so a field they inject appears on every object in the application. The entry point is usually a recursive merge/clone/set-path function accepting a __proto__ key from user data.

02

Why you should care

Relevance: CoreExpected: L2

The point to grasp: prototype pollution is not itself RCE or XSS — it is an amplifier that turns a seemingly harmless merge bug into almost any other vulnerability, depending on where the app reads the polluted property.

The amplification mechanism: after Object.prototype.x = "evil", EVERY object without its own x returns "evil" when you read obj.x. So depending on what property the code reads, the outcome is:

  • RCE in Node.js — if a library reads an option (like shell, NODE_OPTIONS) from a polluted object and passes it to child_process.
  • XSS — if a template/framework reads a polluted property and puts it into HTML.
  • Authorisation bypass — if the code checks if (user.isAdmin) and isAdmin is polluted to true on every object that does not set it explicitly.
  • DoS — corrupting a property the runtime relies on.

Three things that make it hard:

  • It lives in a dependency, not your code. Many library merge/clone functions (old lodash, old jQuery, hand-written deep-merge functions) are the entry point — you did not write them.
  • It acts at a distance. The object is poisoned in one place, the consequence fires elsewhere reading that property, so it is hard to trace.
  • The gadget depends on the classpath, like deserialization: the same harmless prototype pollution on one app is RCE on another because it has a library reading the exact polluted property.
03

How the attack works

The mechanism rests on JavaScript's prototype chain: reading obj.x where obj has no x makes JS climb to obj.__proto__ (i.e. Object.prototype). Writing there writes to the prototype shared by every object.

Diagram source
flowchart TD    P["User JSON:<br/>{ \"__proto__\": { \"isAdmin\": true } }"] --> M{Recursive merge function}    M -->|"Recurses into the __proto__ key<br/>without filtering"| W["Object.prototype.isAdmin = true"]    W --> A["EVERY object without its own isAdmin<br/>now returns true when reading .isAdmin"]    A --> B1["if (user.isAdmin) → authorisation bypass"]    A --> B2["opts.shell → RCE if it reaches child_process"]    A --> B3["a polluted property → XSS/DoS"]    M -->|"Blocks __proto__, constructor,<br/>prototype"| G["Merges only own keys → safe"]

The key point: the object is poisoned at the merge layer, but the consequence fires elsewhere reading the property. This "action at a distance" is what makes it hard to trace — and why prototype pollution is an amplifier, not a target vulnerability on its own.

A table of entry points and poison keys:

Entry pointPoison keyNote
Deep merge (_.merge, hand-written)__proto__, constructor.prototypeThe most common route
Set-by-path (_.set(obj, path, v))__proto__.x in the pathThe path is user-supplied
JSON.parse then merge{"__proto__": {...}}JSON.parse does NOT pollute; the later merge does
A query-string parser?__proto__[isAdmin]=trueSome old parsers build nested objects

A subtle point: JSON.parse('{"__proto__":{"x":1}}') does NOT pollute on its own — it creates an own property literally named __proto__. Pollution happens when a later merge/set-path function recurses into that key and assigns. So the fix is in the merge function, not in JSON.parse.

Diagram description: A branching diagram for a user JSON containing a __proto__ key with isAdmin true. A recursive merge function recurses into the __proto__ key without filtering, so it assigns Object.prototype.isAdmin to true. From there every object without its own isAdmin returns true when reading the isAdmin property, leading to three consequences: an if user.isAdmin bypasses authorisation, an opts.shell reaching child_process becomes RCE, and a polluted property causes XSS or DoS. The safe branch is a merge function that blocks the __proto__, constructor and prototype keys so it merges only the object's own keys.

04

Concrete example

A settings-update endpoint using a hand-written deep-merge function.

HTTP
# The poisoning payload. __proto__ enters the merge function and writes to Object.prototype.POST /api/settings HTTP/1.1Content-Type: application/json {"theme":"dark","__proto__":{"isAdmin":true}}
JavaScript
// The merge function recurses into the "__proto__" key and assigns → Object.prototype.isAdmin = true.// From now on, EVERY object that does not set its own isAdmin returns true:({}).isAdmin            // → true  (!!)someUser.isAdmin        // → true, though someUser has no such field

The object is poisoned at the merge layer, but the consequence fires EVERYWHERE that reads .isAdmin — including an authorisation check in a completely different request from a different user. This is "action at a distance".

JavaScript
// RCE variant (Node.js): if a library reads an option from a polluted object.// Payload: {"__proto__":{"shell":"/bin/sh","argv0":"..."}}// then somewhere: child_process.spawn("ls", [], opts)  ← opts.shell is now "/bin/sh"// → turns a harmless spawn into shell execution (intersects the command-injection topic).
JavaScript
// The subtle point: JSON.parse does NOT pollute on its own.const o = JSON.parse('{"__proto__":{"x":1}}');  // o has an OWN property named "__proto__"({}).x   // → undefined  (not polluted yet!)// Pollution happens only when a later MERGE function recurses into that key and assigns. The fix is in the merge.
# After the fix: the merge function blocks __proto__/constructor/prototype, and uses a Map/null-proto# object for user-controlled data.
TypeScriptA hand-written deep-merge recurses into the __proto__ key and writes to the shared prototype.
// Endpoint cập nhật cài đặt, dùng một deep-merge tự viết.function deepMerge(target: any, source: any): any {  for (const key in source) {    if (typeof source[key] === "object" && source[key] !== null) {      if (!target[key]) target[key] = {};      // ❌ Đệ quy vào MỌI khoá, kể cả "__proto__". Với source = {"__proto__":{"isAdmin":true}},      //    target["__proto__"] là Object.prototype, nên dòng dưới ghi isAdmin=true LÊN      //    Object.prototype — và từ đó MỌI object trả isAdmin=true.      deepMerge(target[key], source[key]);    } else {      target[key] = source[key];    }  }  return target;} app.post("/api/settings", (req, res) => {  const settings = deepMerge({}, req.body);   // req.body do người dùng kiểm soát  saveSettings(req.user.id, settings);   // Ở một request KHÁC, của một người dùng KHÁC, một phép kiểm quyền:  //   if (someUser.isAdmin) { ... }  // giờ trả true vì Object.prototype.isAdmin = true. "Tác dụng từ xa".  res.json({ ok: true });});
05

What happened in the wild

Lodash CVE-2019-10744 (_.defaultsDeep) and the family of lodash prototype-pollution CVEs. One of the most-used libraries on npm had several merge/set functions vulnerable to prototype pollution, so hundreds of thousands of projects inherited the flaw without writing a line of related code. Memorable because it illustrates block 2's point: the entry point is in a DEPENDENCY, not your code.

Prototype pollution → RCE in Node tooling (many CVEs). Research (notably by Michał Bentkowski and Posix) showed the chain from prototype pollution to RCE via gadgets like child_process options, template engines, and require. It is the source of the "gadget depends on the classpath" concept in block 2 — exactly like deserialization.

Client-side prototype pollution → XSS (many reports). In the browser, a prototype pollution via the query string (?__proto__[...]) plus a gadget in a client library (a sink reading a polluted property and writing it to the DOM) is XSS. It illustrates the amplifier nature: the same pollution, different consequences depending on which gadget is present.

06

How to defend

Layer 1

Block `__proto__`, `constructor`, `prototype` in every merge/set-by-path

mandatory

The entry point is a function that recurses into a user-supplied key and assigns. The fix is to never assign to the three poison keys__proto__, constructor, prototype.

  • In a hand-written merge/set-by-path function: check each key and skip those three. But better: do not write your own — use structuredClone for cloning, and a patched merge library.
  • Use a patched library. lodash after CVE-2019-10744 is patched, but only if you are on a current version — so this is also a dependency problem (the dependencies-sbom topic). dotnet/C# does not have this flaw, but SecLab's Node/TypeScript (web, agentic) does.
  • Object.freeze(Object.prototype) at application startup: it makes every assignment to the prototype fail (throwing in strict mode). A one-line, strong control that rarely breaks anything — except code that deliberately mutates the prototype (usually bad code).

The important point: JSON.parse is NOT the place to fix (block 3) — it does not pollute. Fix in the MERGE function, and grep for those functions (block 7).

TypeScript · Layer 1Schema validation at the boundary, blocking the three poison keys, and Object.freeze(Object.prototype).
import { z } from "zod"; // ── Biện pháp mạnh nhất · schema validation ở biên ───────────────────────────// DTO chỉ khai các trường được phép. Một __proto__ trong body không có chỗ đi vào,// cùng nguyên tắc mass assignment ở topic api-security: dữ liệu người dùng bị ép về// đúng hình dạng đã khai TRƯỚC KHI chạm tới bất kỳ hàm merge nào.const SettingsSchema = z.object({  theme: z.enum(["light", "dark", "system"]),  locale: z.string().regex(/^[a-z]{2}$/),  notifications: z.object({ email: z.boolean(), push: z.boolean() }).optional(),}).strict();   // .strict() → trường lạ (kể cả __proto__) bị TỪ CHỐI app.post("/api/settings", (req, res) => {  const parsed = SettingsSchema.safeParse(req.body);  if (!parsed.success) return res.status(422).json({ error: "invalid_settings" });   // parsed.data chỉ có theme/locale/notifications. Không có đường nào cho __proto__.  saveSettings(req.user.id, parsed.data);  res.json({ ok: true });}); // ── Nếu buộc phải merge · chặn ba khoá độc ───────────────────────────────────const POISON = new Set(["__proto__", "constructor", "prototype"]); function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>) {  for (const key of Object.keys(source)) {    // Chặn cả BA khoá, không chỉ __proto__: constructor.prototype là đường khác.    if (POISON.has(key)) continue;     const val = source[key];    if (val && typeof val === "object") {      // Object.create(null): object đích không có prototype, nên đầu độc không tới nó.      if (typeof target[key] !== "object") target[key] = Object.create(null);      safeMerge(target[key] as Record<string, unknown>, val as Record<string, unknown>);    } else {      target[key] = val;    }  }  return target;} // ── Biện pháp một dòng · đóng băng prototype lúc khởi động ────────────────────// Sau dòng này, mọi lần gán vào Object.prototype THẤT BẠI (ném ở strict mode). Nó// biến một pollution im lặng thành một exception thấy được — vừa là bản vá vừa là// bộ phát hiện (lớp 3). Hiếm khi phá gì, trừ code cố tình sửa prototype.Object.freeze(Object.prototype);
Layer 1b

Use prototype-free structures for user data

mandatory

If an object is not on the shared prototype chain, prototype poisoning does not affect it, and a __proto__ key is just an ordinary key.

  • A Map instead of an object for user-controlled key-value data: a Map has no prototype chain for keys, so map.get("__proto__") is just a key, not a write to Object.prototype.
  • Object.create(null) when you need a plain object: it has no prototype, so reading obj.isAdmin does not climb to Object.prototype — even if the prototype is poisoned, this object is immune.
  • Schema validation with a field allowlist (Zod, etc.) at the boundary: if the DTO declares only theme and locale, a __proto__ in the body has nowhere to go — the same principle as mass assignment in the api-security topic (the input DTO contains only allowed fields).

The crucial point: schema validation is the strongest fix because it closes prototype pollution AND mass assignment AND injection in one place — user data is coerced to the declared shape before it reaches any merge function.

Layer 2

Reduce gadgets: least privilege and a small classpath

Prototype pollution is an amplifier; the consequence depends on the gadgets present. This layer reduces gadgets and bounds the consequence — like deserialization layer 2, for the same reason (RCE depends on the classpath).

  • Reduce dependencies: each library is a set of potential gadgets. Removing an unused dependency removes the sinks that read a polluted property (the dependencies-sbom topic).
  • Sandbox the process handling untrusted data: non-root user, readOnlyRootFilesystem, closed egress — if prototype pollution reaches RCE, it runs in an empty sandbox (command-injection layer 2).
  • Do not read sensitive options from a possibly-polluted object: if code passes an opts object to child_process, require, or a template engine, ensure opts is Object.create(null) or built explicitly, not the result of a merge with user data.

This is layer 2 because it does not close the entry point (layer 1 does that) — it makes a successful pollution less consequential.

Layer 3

Detection: a `__proto__` key in input is a low-noise signal

A legitimate client almost never sends a field named __proto__, constructor, or prototype, so their appearance in a body/query is an attempt.

  • Log requests with a __proto__/constructor/prototype key in the JSON body or query string. Alert on the first — it is almost always a prototype-pollution payload.
  • Object.freeze(Object.prototype) is also a detector: after freezing, an assignment to the prototype throws in strict mode, so it turns a silent pollution into a visible exception in the logs.
  • A regression test: keep a set of known prototype-pollution payloads and run them through every JSON-accepting endpoint in CI, asserting ({}).polluted is still undefined afterwards.

Layer 3 because layer 1 already blocks; but a poison key in input is a clean signal, so logging it is cheap and it tells you who is trying.

07

Verifying the fix

1. A unit test: the poisoning payload does NOT pollute the prototype. This is the core check, and the assertion must be right: after calling the merge function with {"__proto__":{"polluted":1}}, assert ({}).polluted is still undefined. See the typescript / test tab.

2. Grep for merge/set-by-path functions and JSON feeding them:

Shell
# Hand-written deep merge and pollution-prone APIs.grep -rnE '_\.(merge|mergeWith|defaultsDeep|set|setWith)\(|Object\.assign\(.*JSON\.parse' \  --include='*.ts' --include='*.tsx' --include='*.js' src/ \  && echo "WARNING: a merge/set may take user input — check the version and data source"# A hand-written deep merge (recursively assigning by key) is the most common entry point.grep -rnE 'for .* in .*\)[^{]*{[^}]*\[key\] *=' --include='*.ts' src/ \  && echo "WARNING: possibly a hand-written deep merge — check it blocks __proto__"

3. An end-to-end test through a JSON-accepting endpoint — a real body payload:

Shell
B=https://staging.example.comcurl -s -X POST "$B/api/settings" -H 'Content-Type: application/json' \  -d '{"theme":"dark","__proto__":{"isAdmin":true}}' > /dev/null# Then check an authorisation-gated endpoint — an ordinary user must NOT have become admin.curl -s "$B/api/admin/users" -b "session=$ORDINARY_USER" -o /dev/null -w '%{http_code}\n'# Must be 403. If 200 → prototype pollution bypassed authorisation.

4. Check Object.freeze(Object.prototype) is called at startup:

Shell
grep -rn "Object.freeze(Object.prototype)" --include='*.ts' src/ \  || echo "WARNING: no Object.freeze(Object.prototype) found — the one-line control is missing"

5. Check dependencies have no known prototype pollution (intersects the dependencies-sbom topic):

Shell
npm audit --audit-level=high 2>&1 | grep -i 'prototype pollution' \  && { echo "a dependency with known prototype pollution is present"; exit 1; }exit 0
TypeScriptAssert the prototype is NOT polluted — check ({}).polluted, not "does not throw".
import { describe, it, expect, afterEach } from "vitest"; describe("prototype pollution", () => {  // Dọn sau mỗi test: nếu một test làm nhiễm prototype, các test sau không được thừa hưởng.  afterEach(() => {    delete (Object.prototype as any).polluted;    delete (Object.prototype as any).isAdmin;  });   // Payload đầu độc phải KHÔNG nhiễm prototype. Khẳng định đúng thứ cần: ({}).polluted  // vẫn undefined — KHÔNG phải "hàm không throw" (một bản vá tồi cũng không throw).  it.each([    { "__proto__": { polluted: "yes" } },    { "constructor": { "prototype": { polluted: "yes" } } },    { a: { "__proto__": { polluted: "yes" } } },   // lồng sâu  ])("safeMerge does not pollute for %o", (payload) => {    safeMerge({} as any, payload as any);     // Đây là khẳng định cốt lõi: một object MỚI, hoàn toàn không liên quan, không được    // có thuộc tính "polluted". Nếu có → prototype đã bị đầu độc.    expect(({} as any).polluted).toBeUndefined();  });   // JSON.parse tự nó KHÔNG nhiễm — test này ghi lại điểm tinh vi ở khối 3, để không ai  // "sửa" bằng cách lọc JSON string (sai chỗ).  it("JSON.parse alone does not pollute", () => {    const o = JSON.parse('{"__proto__":{"x":1}}');    expect(({} as any).x).toBeUndefined();   // chưa nhiễm    expect(Object.prototype.hasOwnProperty.call(o, "__proto__")).toBe(true);  // khoá thường  });   // End-to-end: payload qua endpoint không được biến một người dùng thường thành admin.  it("a settings payload cannot escalate to admin", async () => {    await request(app).post("/api/settings")      .set("Cookie", ordinaryUserCookie)      .send({ theme: "dark", "__proto__": { isAdmin: true } });     // Object mới bất kỳ KHÔNG được có isAdmin=true.    expect(({} as any).isAdmin).toBeUndefined();     // Và một endpoint chỉ-admin vẫn từ chối người dùng thường.    const res = await request(app).get("/api/admin/users").set("Cookie", ordinaryUserCookie);    expect(res.status).toBe(403);  });   // Object.freeze(Object.prototype) đã được gọi — biện pháp một dòng phải có mặt.  it("Object.prototype is frozen", () => {    expect(Object.isFrozen(Object.prototype)).toBe(true);  });});
08

Common mistakes

The "fix"Why it is wrong
Sanitise __proto__ out of the JSON string before JSON.parseJSON.parse does not pollute; the later MERGE does. And constructor.prototype is another route
Block only __proto__There is still constructor and prototype. Block all three, or use a prototype-free structure
Rate it low "it only dirties an object"It is an AMPLIFIER: it becomes RCE, XSS, or an authorisation bypass depending on the gadget reading the polluted property
Write a "careful" deep-merge yourselfThis is the most common entry point. Use structuredClone/a patched library, or schema validation
Rely on a "recent" lodash versionTrue only if you are actually on a patched version. This is also a dependency problem (the dependencies-sbom topic)
Check if (user.isAdmin) on a plain objectIf the prototype is polluted, every object returns isAdmin=true. Use Object.create(null) or check hasOwnProperty
Read sensitive opts from a merged objectopts.shell is polluted → RCE via child_process. Build opts explicitly

The classification mistake, and it is the central one: treating prototype pollution as "dirties an object, low risk". It is an AMPLIFIER — like deserialization, the consequence depends on the gadgets in the classpath, and on an app with the right gadget it is RCE. Rate it by the worst possible gadget, not by the dirtying itself.

The location mistake: looking for the fix at JSON.parse. JSON.parse does not pollute (block 3). The entry point is the MERGE/SET-BY-PATH function — usually in a dependency — so the way to find it is to grep those functions and check they block the three poison keys, not to filter the JSON string.

09

References

Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…