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.
Why you should care
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 tochild_process. - XSS — if a template/framework reads a polluted property and puts it into HTML.
- Authorisation bypass — if the code checks
if (user.isAdmin)andisAdminis polluted totrueon 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.
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.
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 point | Poison key | Note |
|---|---|---|
Deep merge (_.merge, hand-written) | __proto__, constructor.prototype | The most common route |
Set-by-path (_.set(obj, path, v)) | __proto__.x in the path | The path is user-supplied |
JSON.parse then merge | {"__proto__": {...}} | JSON.parse does NOT pollute; the later merge does |
| A query-string parser | ?__proto__[isAdmin]=true | Some 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.
Concrete example
A settings-update endpoint using a hand-written deep-merge function.
# 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}}// 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 fieldThe 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".
// 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).// 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.// 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 });});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.
How to defend
Block `__proto__`, `constructor`, `prototype` in every merge/set-by-path
mandatoryThe 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
structuredClonefor 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).
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);Use prototype-free structures for user data
mandatoryIf 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
Mapinstead of an object for user-controlled key-value data: aMaphas no prototype chain for keys, somap.get("__proto__")is just a key, not a write toObject.prototype. Object.create(null)when you need a plain object: it has no prototype, so readingobj.isAdmindoes not climb toObject.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
themeandlocale, 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.
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
optsobject tochild_process,require, or a template engine, ensureoptsisObject.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.
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/prototypekey 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
({}).pollutedis 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.
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:
# 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:
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:
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):
npm audit --audit-level=high 2>&1 | grep -i 'prototype pollution' \ && { echo "a dependency with known prototype pollution is present"; exit 1; }exit 0import { 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); });});Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Sanitise __proto__ out of the JSON string before JSON.parse | JSON.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 yourself | This is the most common entry point. Use structuredClone/a patched library, or schema validation |
| Rely on a "recent" lodash version | True 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 object | If the prototype is polluted, every object returns isAdmin=true. Use Object.create(null) or check hasOwnProperty |
| Read sensitive opts from a merged object | opts.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.
Comments
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.
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…