Web Security for Frontend Devs · Part 11 — Prototype Pollution
Advanced track: how attacker __proto__ keys poison Object.prototype through unsafe merge and query parsing, the gadgets that turn pollution into XSS or auth bypass, and the defenses that stop both. With a live demo and exercises.
Part 11 — Advanced track in the Web Security for Frontend Devs series {Phần 11 — Nhánh nâng cao trong series Web Security for Frontend Devs}. Previous {Trước}: Part 10 — Input Validation, Open Redirects & a Frontend Threat Model · Next {Tiếp}: Part 12 — DOM Clobbering.
You finished the core 10 parts — the threats every frontend dev must know {Bạn đã xong 10 phần lõi — những mối đe dọa mọi frontend dev phải biết}. This advanced track goes deeper into bugs that are subtler, JavaScript-specific, and routinely missed in review {Nhánh nâng cao này đi sâu vào các lỗi tinh vi hơn, đặc thù JavaScript, thường bị bỏ sót khi review}. First up: prototype pollution — a vulnerability that does not inject a <script> tag, yet can end in XSS, auth bypass, or denial of service {Mở màn: prototype pollution — lỗ hổng không hề inject thẻ <script>, nhưng có thể dẫn tới XSS, vượt xác thực, hoặc từ chối dịch vụ}.
The one fact that makes this possible {Sự thật cốt lõi khiến nó xảy ra}
In JavaScript, almost every object inherits from a single shared object: Object.prototype {Trong JavaScript, gần như mọi object kế thừa từ một object dùng chung duy nhất: Object.prototype}. When you read obj.foo and obj has no own foo, the engine walks the prototype chain — and that chain ends at the same Object.prototype for {}, [], function arguments, parsed JSON, and config objects alike {Khi bạn đọc obj.foo mà obj không có foo riêng, engine đi dọc chuỗi prototype — và chuỗi đó kết thúc ở cùng một Object.prototype cho {}, [], JSON parse, object config}.
const a = {};
const b = { x: 1 };
a.__proto__ === Object.prototype; // true
b.__proto__ === Object.prototype; // true — same object
Prototype pollution is when attacker-controlled input writes a property onto that shared prototype {Prototype pollution là khi input do kẻ tấn công kiểm soát ghi một property lên prototype dùng chung đó}. From that moment, every plain object on the page appears to have that property {Từ thời điểm đó, mọi object thường trên trang trông như có property đó}:
// somewhere, attacker manages to run this effect:
({}).__proto__.isAdmin = true;
// now, everywhere else in the app:
const user = {};
user.isAdmin; // true ← nobody set it
The danger keys are __proto__, constructor, and prototype {Các key nguy hiểm là __proto__, constructor, prototype}. The attacker never assigns them directly — they smuggle them as string keys through code that builds object paths from untrusted data {Kẻ tấn công không gán trực tiếp — họ luồn chúng dưới dạng key chuỗi qua code dựng đường dẫn object từ dữ liệu không tin cậy}.
Where it enters — the vulnerable sinks {Nơi nó xâm nhập — các sink lỗ hổng}
Three patterns dominate real-world prototype pollution, all of them recursive or dynamic key assignment on untrusted data {Ba mẫu thống trị prototype pollution thực tế, đều là gán key đệ quy hoặc động trên dữ liệu không tin cậy}.
Sink 1 — recursive deep merge {Sink 1 — deep merge đệ quy}
// ❌ VULNERABLE — naive deepMerge walks attacker keys
function deepMerge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
deepMerge(target[key], source[key]); // recurses into __proto__
} else {
target[key] = source[key];
}
}
return target;
}
// attacker payload (e.g. parsed from a JSON request body or config)
const evil = JSON.parse('{"__proto__":{"polluted":"yes"}}');
deepMerge({}, evil);
({}).polluted; // "yes" ← global pollution
When key === '__proto__', target['__proto__'] is Object.prototype, and the recursion writes straight into it {Khi key === '__proto__', target['__proto__'] chính là Object.prototype, và đệ quy ghi thẳng vào đó}. The same flaw exists in hand-rolled setByPath('a.b.c', value) helpers {Lỗi tương tự ở các helper setByPath('a.b.c', value) tự viết}.
Sink 2 — setByPath / lodash-style set {Sink 2 — setByPath / set kiểu lodash}
// ❌ VULNERABLE — builds nested objects from a dotted path string
function setByPath(obj, path, value) {
const keys = path.split('.');
let node = obj;
for (let i = 0; i < keys.length - 1; i++) {
node[keys[i]] ??= {};
node = node[keys[i]];
}
node[keys.at(-1)] = value;
}
// path comes from a query string: ?__proto__.isAdmin=true
setByPath({}, '__proto__.isAdmin', true);
({}).isAdmin; // true
Sink 3 — query-string / form parsers {Sink 3 — parser query-string / form}
Libraries that expand a[b][c]=1 or a.b.c=1 into nested objects are classic vectors when they don’t filter __proto__ {Thư viện mở rộng a[b][c]=1 hoặc a.b.c=1 thành object lồng là vector kinh điển khi không lọc __proto__}:
?__proto__[isAdmin]=true
?constructor[prototype][isAdmin]=true
The constructor.prototype path matters: even environments that block the literal __proto__ key can be reached via constructor.prototype, which also resolves to Object.prototype {Đường constructor.prototype quan trọng: cả môi trường chặn key __proto__ thuần vẫn bị tới qua constructor.prototype, cũng trỏ về Object.prototype}.
From pollution to impact — the gadgets {Từ pollution tới hậu quả — các gadget}
Pollution alone sets an inherited property {Pollution đơn thuần chỉ đặt một property kế thừa}. The impact comes from a gadget: existing code that later reads a property it expects to be absent and trusts the (now polluted) default {Hậu quả đến từ gadget: code có sẵn về sau đọc một property mà nó kỳ vọng không tồn tại và tin vào giá trị mặc định (giờ đã bị nhiễm)}.
Auth / logic bypass {Vượt logic / xác thực} — code that gates on an optional flag {code kiểm tra một cờ tùy chọn}:
// ❌ pollution gadget — options.isAdmin was meant to default to undefined
function canEdit(options = {}) {
return options.isAdmin === true; // after pollution: ({}).isAdmin === true
}
DoS via inherited config {DoS qua config kế thừa} — polluting a property the framework reads on every object (e.g. a templating flag) can crash rendering or flip behavior app-wide {nhiễm một property framework đọc trên mọi object có thể làm sập render hoặc đảo hành vi toàn app}.
Escalation to XSS {Leo thang thành XSS} — if a sanitizer, template engine, or DOM helper reads an option like tagName, srcdoc, or an allow-list from a plain object, polluting that default can re-open an HTML/script sink you thought was closed {nếu sanitizer, template engine, hay DOM helper đọc option như tagName, srcdoc, hay allow-list từ object thường, nhiễm mặc định đó có thể mở lại sink HTML/script bạn tưởng đã đóng}. This is why prototype pollution is rated higher than “just a logic bug” — it is a gadget multiplier {Đây là lý do prototype pollution bị xếp cao hơn “chỉ là lỗi logic” — nó là bộ nhân gadget}.
Mental model {Mô hình tư duy}: pollution loads the gun (sets an inherited default); a gadget pulls the trigger (trusts that default). You must remove both {pollution lên đạn (đặt mặc định kế thừa); gadget bóp cò (tin mặc định đó). Bạn phải loại bỏ cả hai}.
Try it — live prototype-pollution playground {Thử ngay — sân chơi prototype pollution}
The demo below lets you send a JSON payload through a vulnerable vs a hardened deepMerge, watch Object.prototype get polluted (and reset), and fire a small auth-bypass gadget against the result {Demo dưới cho bạn gửi payload JSON qua deepMerge lỗ hổng vs đã vá, xem Object.prototype bị nhiễm (và reset), và bắn một gadget vượt xác thực nhỏ vào kết quả}.
Open the full demo {Mở demo đầy đủ}: /tools/prototype-pollution-demo/.
Defenses — break the sink, not just the symptom {Phòng thủ — chặn sink, không chỉ triệu chứng}
Defense 1 — reject dangerous keys at the boundary {Phòng thủ 1 — từ chối key nguy hiểm tại biên}
Any recursive writer that touches untrusted data must skip __proto__, constructor, and prototype {Mọi bộ ghi đệ quy chạm dữ liệu không tin cậy phải bỏ qua __proto__, constructor, prototype}:
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue; // ✅ never recurse into prototype keys
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
Prefer Object.keys / Object.entries over for...in — they return own enumerable keys only and avoid walking inherited ones {Ưu tiên Object.keys / Object.entries hơn for...in — chúng chỉ trả key own enumerable và tránh đi qua key kế thừa}.
Defense 2 — use prototype-less objects for untrusted maps {Phòng thủ 2 — object không prototype cho map không tin cậy}
When you build a dictionary from untrusted keys, drop the prototype entirely {Khi dựng dictionary từ key không tin cậy, bỏ hẳn prototype}:
const dict = Object.create(null); // no Object.prototype in the chain
dict['__proto__'] = 'x'; // ✅ just a normal own key now, no pollution
Or use a Map, which never confuses data keys with object internals {Hoặc dùng Map, không bao giờ nhầm key dữ liệu với nội bộ object}:
const m = new Map();
m.set('__proto__', 'x'); // ✅ stored as a plain key
Defense 3 — parse JSON with a reviver that strips proto keys {Phòng thủ 3 — parse JSON với reviver lọc key proto}
function safeParse(text) {
return JSON.parse(text, (key, value) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return undefined; // drop the dangerous key from the result
}
return value;
});
}
Note: JSON.parse itself does not pollute (it creates own properties), but the result often flows into a vulnerable merge — strip the keys early so they never reach a sink {Lưu ý: JSON.parse tự nó không gây pollution (tạo property own), nhưng kết quả thường chảy vào merge lỗ hổng — lọc key sớm để chúng không bao giờ tới sink}.
Defense 4 — freeze the prototype (defense in depth) {Phòng thủ 4 — freeze prototype (phòng thủ nhiều lớp)}
On high-risk apps you can harden the runtime so writes to the shared prototype fail {Trên app rủi ro cao, bạn có thể cứng hóa runtime để ghi vào prototype dùng chung bị fail}:
Object.freeze(Object.prototype);
Object.freeze(Object.getPrototypeOf({}));
// later: ({}).__proto__.x = 1 → silently ignored (or throws in strict mode)
This is a blunt instrument — some libraries legitimately extend prototypes — so test thoroughly {Đây là biện pháp thô — vài thư viện cố tình mở rộng prototype — nên test kỹ}. It is a backstop, not a substitute for fixing the sink (Defense 1) {Đây là lớp chặn cuối, không thay việc fix sink (Phòng thủ 1)}.
Defense 5 — close the gadgets {Phòng thủ 5 — đóng các gadget}
Even with pollution prevented, write code that does not depend on a property being absent {Kể cả đã chặn pollution, viết code không phụ thuộc vào việc một property vắng mặt}:
- Use
Object.hasOwn(obj, key)(orObject.prototype.hasOwnProperty.call) instead of truthy reads for security-relevant flags {DùngObject.hasOwn(obj, key)thay vì đọc truthy cho cờ liên quan bảo mật}. - Validate options with a schema (Zod) so unexpected inherited keys never become trusted defaults {Validate option bằng schema (Zod) để key kế thừa bất ngờ không bao giờ thành mặc định tin cậy}.
// ✅ gadget closed — own-property check, not inherited truthiness
function canEdit(options = {}) {
return Object.hasOwn(options, 'isAdmin') && options.isAdmin === true;
}
How CSP and the earlier parts relate {Liên hệ với CSP và các phần trước}
Prototype pollution is not stopped by CSP (Part 3) on its own — no new script is loaded; existing trusted code misbehaves {Prototype pollution không bị CSP (Phần 3) chặn một mình — không script mới nào tải; code tin cậy có sẵn hành xử sai}. But if the gadget chain ends in an HTML sink, the XSS defenses from Part 2 (sanitize, Trusted Types) and CSP still limit the final blast radius {Nhưng nếu chuỗi gadget kết ở sink HTML, phòng thủ XSS ở Phần 2 (sanitize, Trusted Types) và CSP vẫn giới hạn thiệt hại cuối}. As always: defense in depth {Như mọi khi: phòng thủ nhiều lớp}.
Prevention checklist {Checklist phòng tránh}
- Audit every recursive merge /
set-by-path / query parser that touches untrusted data {Rà mọi merge đệ quy / set-by-path / parser query chạm dữ liệu không tin cậy}. - Skip
__proto__,constructor,prototypekeys; iterate withObject.keys, notfor...in{Bỏ qua key__proto__/constructor/prototype; lặp bằngObject.keys}. - Use
Object.create(null)orMapfor untrusted dictionaries {DùngObject.create(null)hoặcMapcho dictionary không tin cậy}. - Validate parsed input with a schema; read security flags with
Object.hasOwn{Validate input parse bằng schema; đọc cờ bảo mật bằngObject.hasOwn}. - Keep dependencies patched — many CVEs are prototype-pollution fixes in merge/clone utilities {Cập nhật dependency — nhiều CVE là vá prototype pollution trong tiện ích merge/clone}.
- Consider
Object.freeze(Object.prototype)as a backstop on sensitive apps {Cân nhắcObject.freeze(Object.prototype)làm lớp chặn trên app nhạy cảm}.
Bài tập / Exercises
1. Explain why deepMerge({}, JSON.parse('{"__proto__":{"x":1}}')) pollutes, but JSON.parse('{"__proto__":{"x":1}}') on its own does not {Giải thích vì sao deepMerge gây nhiễm còn JSON.parse đơn thuần thì không}.
Solution {Lời giải}
JSON.parse creates an own property literally named __proto__ on the result object (via internal define, not assignment), so the shared prototype is untouched {JSON.parse tạo property own tên __proto__ trên object kết quả, prototype dùng chung không bị chạm}. The recursive deepMerge does target['__proto__'] = ... and recurses — and target['__proto__'] resolves to Object.prototype, so the write lands on the shared prototype {deepMerge làm target['__proto__'] = ... và đệ quy — mà target['__proto__'] trỏ về Object.prototype, nên ghi lên prototype dùng chung}.
2. Which of these are safe against pollution and why? (a) Object.create(null) as the dictionary, (b) iterating with for...in, (c) new Map(), (d) Object.assign({}, untrusted) {Cái nào an toàn trước pollution và vì sao?}
Solution {Lời giải}
(a) Safe — no prototype in the chain, so __proto__ is just a normal key {An toàn — không có prototype trong chuỗi}. (b) Unsafe pattern — for...in also walks inherited keys and is the typical vulnerable loop {Mẫu không an toàn}. (c) Safe — Map keys are isolated from object internals {An toàn}. (d) Safe for one shallow level — Object.assign copies own enumerable props and assigning __proto__ via it sets the target’s prototype, but a single shallow copy of {"__proto__":{...}} from JSON.parse does not recurse into the shared prototype; the danger is recursive merges {An toàn ở một mức nông — nguy hiểm là merge đệ quy}.
3. Close the gadget: rewrite function isFeatureOn(cfg = {}) { return cfg.beta; } so a polluted ({}).beta = true cannot silently enable the feature {Đóng gadget: viết lại sao cho ({}).beta = true bị nhiễm không âm thầm bật tính năng}.
Solution {Lời giải}
function isFeatureOn(cfg = {}) {
return Object.hasOwn(cfg, 'beta') && cfg.beta === true;
}Reading with an own-property check ignores anything inherited from a polluted prototype {Đọc kèm kiểm own-property bỏ qua mọi thứ kế thừa từ prototype bị nhiễm}.
Stretch {Nâng cao}: In the demo, craft a payload using constructor.prototype instead of __proto__ that still pollutes through the vulnerable merge, then confirm the hardened merge blocks both {Trong demo, tạo payload dùng constructor.prototype thay __proto__ vẫn nhiễm qua merge lỗ hổng, rồi xác nhận merge đã vá chặn cả hai}.
Key takeaways {Điểm chính}
- Prototype pollution = attacker-controlled
__proto__/constructor/prototypekeys writing onto the sharedObject.prototype{key__proto__/constructor/prototypeghi lênObject.prototypedùng chung}. - The sinks are recursive merge,
set-by-path, and query/form parsers that build objects from untrusted data {Sink là merge đệ quy, set-by-path, parser query/form}. - Impact needs a gadget — pollution loads, the gadget fires (auth bypass, DoS, escalated XSS) {Hậu quả cần gadget — pollution lên đạn, gadget bóp cò}.
- Defend at the sink (skip dangerous keys,
Object.keys,Object.create(null),Map) and at the gadget (Object.hasOwn, schema validation) {Phòng cả ở sink và ở gadget}. - CSP does not stop it; freezing the prototype is a backstop, not a fix {CSP không chặn; freeze prototype chỉ là lớp chặn}.
Next up {Tiếp theo}
Part 12 — DOM Clobbering: another scriptless attack where injected HTML names (not scripts) overwrite the globals your code trusts — and why a script-blocking CSP does nothing about it {Phần 12 — DOM Clobbering: một tấn công không cần script khác, nơi tên HTML được inject (không phải script) ghi đè global mà code bạn tin — và vì sao CSP chặn script không làm gì được}. Continue to Part 12 — DOM Clobbering.