jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

Phần 11 — Nhánh nâng cao trong series Web Security for Frontend Devs. Trước: Tiếp:

Bạn đã xong 10 phần lõi — những mối đe dọa mọi frontend dev phải biết. 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. 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ụ.


Sự thật cốt lõi khiến nó xảy ra

Trong JavaScript, gần như mọi object kế thừa từ một object dùng chung duy nhất: Object.prototype. Khi bạn đọc obj.fooobj 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 là khi input do kẻ tấn công kiểm soát ghi một property lên prototype dùng chung đó. 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

Các key nguy hiểm là __proto__, constructor, prototype. 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.

attacker JSON {"__proto__":{"isAdmin":true}} deepMerge() walks __proto__ Object.prototype (shared) {} user .isAdmin → true {} cart .isAdmin → true {} config .isAdmin → true one polluted prototype is inherited by every plain object on the page
Prototype pollution: one __proto__ write through an unsafe merge is inherited by every plain object on the page

Nơi nó xâm nhập — các sink lỗ hổng

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 — 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

Khi key === '__proto__', target['__proto__'] chính là Object.prototype, và đệ quy ghi thẳng vào đó. Lỗi tương tự ở các helper setByPath('a.b.c', value) tự viết.

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 — parser query-string / form

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

Đườ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.


Từ pollution tới hậu quả — các gadget

Pollution đơn thuần chỉ đặt một property kế thừa. 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).

Vượt logic / xác thực — 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 qua config kế thừa — 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.

Leo thang thành XSS — 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. Đây là lý do prototype pollution bị xếp cao hơn “chỉ là lỗi logic” — nó là bộ nhân gadget.

Mô hình tư duy: 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.


Thử ngay — sân chơi prototype pollution

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ả.

Mở demo đầy đủ:


Phòng thủ — chặn sink, không chỉ triệu chứng

Phòng thủ 1 — từ chối key nguy hiểm tại biên

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;
}

Ư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.

Phòng thủ 2 — object không prototype cho map không tin cậy

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

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

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;
  });
}

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.

Phòng thủ 4 — freeze prototype (phòng thủ nhiều lớp)

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)

Đây là biện pháp thô — vài thư viện cố tình mở rộng prototype — nên test kỹ. Đây là lớp chặn cuối, không thay việc fix sink (Phòng thủ 1).

Phòng thủ 5 — đóng các gadget

Kể cả đã chặn pollution, viết code không phụ thuộc vào việc một property vắng mặt:

  • Dùng Object.hasOwn(obj, key) thay vì đọc truthy cho cờ liên quan bảo mật.
  • 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;
}

Liên hệ với CSP và các phần trước

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. 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. Như mọi khi: phòng thủ nhiều lớp.


Checklist phòng tránh

  1. Rà mọi merge đệ quy / set-by-path / parser query chạm dữ liệu không tin cậy.
  2. Bỏ qua key __proto__/constructor/prototype; lặp bằng Object.keys.
  3. Dùng Object.create(null) hoặc Map cho dictionary không tin cậy.
  4. Validate input parse bằng schema; đọc cờ bảo mật bằng Object.hasOwn.
  5. Cập nhật dependency — nhiều CVE là vá prototype pollution trong tiện ích merge/clone.
  6. Cân nhắc Object.freeze(Object.prototype) làm lớp chặn trên app nhạy cảm.

Bài tập / Exercises

1. Giải thích vì sao deepMerge gây nhiễm còn JSON.parse đơn thuần thì không.

Lời giải

JSON.parse tạo property own tên __proto__ trên object kết quả, prototype dùng chung không bị chạm. deepMerge làm target['__proto__'] = ... và đệ quy — mà target['__proto__'] trỏ về Object.prototype, nên ghi lên prototype dùng chung.

2. Cái nào an toàn trước pollution và vì sao?

Lời giải

An toàn — không có prototype trong chuỗi. Mẫu không an toàn. An toàn. An toàn ở một mức nông — nguy hiểm là merge đệ quy.

3. Đóng gadget: viết lại sao cho ({}).beta = true bị nhiễm không âm thầm bật tính năng.

Lời giải
function isFeatureOn(cfg = {}) {
  return Object.hasOwn(cfg, 'beta') && cfg.beta === true;
}

Đọc kèm kiểm own-property bỏ qua mọi thứ kế thừa từ prototype bị nhiễm.

Nâng cao: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.


Điểm chính

  • key __proto__/constructor/prototype ghi lên Object.prototype dùng chung.
  • Sink là merge đệ quy, set-by-path, parser query/form.
  • Hậu quả cần gadget — pollution lên đạn, gadget bóp cò.
  • Phòng cả ở sink và ở gadget.
  • CSP không chặn; freeze prototype chỉ là lớp chặn.

Tiếp theo

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.