Là gì
Prototype pollution là lỗi riêng của JavaScript: kẻ tấn công đặt một thuộc tính lên Object.prototype — prototype dùng chung của MỌI object — nên một trường mà họ chèn xuất hiện trên mọi object trong ứng dụng. Đường vào thường là một hàm merge/clone/set-path đệ quy nhận khoá __proto__ từ dữ liệu người dùng.
Vì sao bạn quan tâm
Điều phải nắm: prototype pollution không tự nó là RCE hay XSS — nó là một bộ khuếch đại biến một lỗi merge tưởng vô hại thành gần như mọi lỗ hổng khác, tuỳ ứng dụng đọc thuộc tính bị nhiễm ở đâu.
Cơ chế khuếch đại: sau khi Object.prototype.x = "evil", MỌI object không có x của riêng nó sẽ trả "evil" khi đọc obj.x. Nên tuỳ vào code đọc thuộc tính gì mà hậu quả là:
- RCE trong Node.js — nếu một thư viện đọc một option (như
shell,NODE_OPTIONS) từ một object bị nhiễm rồi truyền vàochild_process. - XSS — nếu một template/framework đọc một thuộc tính bị nhiễm rồi đưa vào HTML.
- Bỏ qua phân quyền — nếu code kiểm
if (user.isAdmin)vàisAdminbị nhiễm thànhtruetrên mọi object không đặt nó tường minh. - DoS — làm hỏng một thuộc tính mà runtime dựa vào.
Ba điều làm nó khó:
- Nó nằm trong dependency, không trong code bạn. Nhiều hàm merge/clone của thư viện (lodash cũ, jQuery cũ, các hàm deep-merge tự viết) là đường vào — bạn không viết chúng.
- Nó tác dụng từ xa. Object bị đầu độc ở một chỗ, hậu quả nổ ở một chỗ khác đọc thuộc tính đó, nên khó truy nguyên.
- Gadget phụ thuộc classpath, giống deserialization: cùng một prototype pollution vô hại trên một app, là RCE trên app khác vì có một thư viện đọc đúng thuộc tính bị nhiễm.
Cơ chế hoạt động
Cơ chế dựa vào chuỗi prototype của JavaScript: đọc obj.x mà obj không có x thì JS đi lên obj.__proto__ (tức Object.prototype). Ghi vào đó là ghi vào prototype dùng chung của mọi object.
flowchart TD P["JSON người dùng:<br/>{ \"__proto__\": { \"isAdmin\": true } }"] --> M{Hàm merge đệ quy} M -->|"Đi vào khoá __proto__<br/>không lọc"| W["Object.prototype.isAdmin = true"] W --> A["MỌI object không có isAdmin<br/>giờ trả true khi đọc .isAdmin"] A --> B1["if (user.isAdmin) → bypass phân quyền"] A --> B2["opts.shell → RCE nếu vào child_process"] A --> B3["thuộc tính bị nhiễm → XSS/DoS"] M -->|"Chặn __proto__, constructor,<br/>prototype"| G["Chỉ merge khoá của riêng object → an toàn"]Điểm cốt lõi: object bị đầu độc ở tầng merge, nhưng hậu quả nổ ở nơi khác đọc thuộc tính. Đây là "tác dụng từ xa" khiến nó khó truy nguyên — và là lý do prototype pollution là một khuếch đại, không phải một lỗ hổng đích một mình.
Bảng đường vào và khoá độc:
| Đường vào | Khoá độc | Ghi chú |
|---|---|---|
Deep merge (_.merge, tự viết) | __proto__, constructor.prototype | Đường phổ biến nhất |
Set-by-path (_.set(obj, path, v)) | __proto__.x trong path | Path do người dùng đặt |
JSON.parse rồi merge | {"__proto__": {...}} | JSON.parse KHÔNG tự nhiễm, hàm merge sau đó mới nhiễm |
| Query string parser | ?__proto__[isAdmin]=true | Một số parser cũ tạo object lồng |
Điểm tinh vi: JSON.parse('{"__proto__":{"x":1}}') tự nó KHÔNG nhiễm — nó tạo một khoá thường tên __proto__. Nhiễm xảy ra khi một hàm merge/set-path sau đó đi vào khoá đó và gán. Nên bản vá nằm ở hàm merge, không ở JSON.parse.
Mô tả sơ đồ: Sơ đồ nhánh cho một JSON người dùng chứa khoá __proto__ với isAdmin true. Một hàm merge đệ quy đi vào khoá __proto__ mà không lọc, nên nó gán Object.prototype.isAdmin bằng true. Từ đó mọi object không có isAdmin của riêng nó đều trả true khi đọc thuộc tính isAdmin, dẫn tới ba hậu quả: một câu if user.isAdmin bỏ qua phân quyền, một opts.shell đi vào child_process thành RCE, và một thuộc tính bị nhiễm gây XSS hoặc DoS. Nhánh an toàn là hàm merge chặn các khoá __proto__, constructor và prototype nên chỉ merge khoá của riêng object.
Ví dụ cụ thể
Một endpoint cập nhật cài đặt người dùng bằng một hàm deep-merge tự viết.
# Payload đầu độc. __proto__ đi vào hàm merge và ghi vào Object.prototype.POST /api/settings HTTP/1.1Content-Type: application/json {"theme":"dark","__proto__":{"isAdmin":true}}// Hàm merge đi vào khoá "__proto__" và gán → Object.prototype.isAdmin = true.// Từ giờ, MỌI object không đặt isAdmin của riêng nó trả về true:({}).isAdmin // → true (!!)someUser.isAdmin // → true, dù someUser không có trường đóObject bị đầu độc ở tầng merge, nhưng hậu quả nổ ở MỌI chỗ đọc .isAdmin — kể cả một phép kiểm phân quyền ở một request hoàn toàn khác của một người dùng khác. Đây là "tác dụng từ xa".
// Biến thể RCE (Node.js): nếu một thư viện đọc option từ object bị nhiễm.// Payload: {"__proto__":{"shell":"/bin/sh","argv0":"..."}}// rồi ở đâu đó: child_process.spawn("ls", [], opts) ← opts.shell giờ là "/bin/sh"// → biến một spawn vô hại thành thực thi shell (giao với topic command-injection).// Điểm tinh vi: JSON.parse KHÔNG tự nhiễm.const o = JSON.parse('{"__proto__":{"x":1}}'); // o có khoá THƯỜNG tên "__proto__"({}).x // → undefined (chưa nhiễm!)// Nhiễm chỉ xảy ra khi một hàm MERGE sau đó đi vào khoá đó và gán. Bản vá ở merge.# Sau khi vá: hàm merge chặn __proto__/constructor/prototype, và dùng Map/null-proto# object cho dữ liệu do người dùng kiểm soát.// 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 });});Chuyện đã xảy ra
Lodash CVE-2019-10744 (_.defaultsDeep) và họ CVE prototype pollution trong lodash. Một trong những thư viện được dùng nhiều nhất trên npm có nhiều hàm merge/set bị prototype pollution, nên hàng trăm nghìn dự án thừa hưởng lỗ hổng mà không viết một dòng code liên quan. Đáng nhớ vì nó minh hoạ luận điểm khối 2: đường vào nằm trong DEPENDENCY, không trong code bạn.
Prototype pollution → RCE trong các công cụ Node (nhiều CVE). Nghiên cứu (đặc biệt của nhóm Michał Bentkowski và Posix) cho thấy chuỗi từ prototype pollution tới RCE qua các gadget như child_process options, template engine, và require. Đây là nguồn của khái niệm "gadget phụ thuộc classpath" ở khối 2 — giống hệt deserialization.
Prototype pollution phía client → XSS (nhiều báo cáo). Ở trình duyệt, một prototype pollution qua query string (?__proto__[...]) cộng một gadget trong một thư viện client (một sink đọc thuộc tính bị nhiễm rồi ghi vào DOM) là XSS. Minh hoạ tính khuếch đại: cùng một pollution, hậu quả khác nhau tuỳ gadget có mặt.
Cách phòng chống
Chặn `__proto__`, `constructor`, `prototype` trong mọi merge/set-by-path
bắt buộcĐường vào là một hàm đi vào khoá do người dùng đặt và gán. Bản vá là không bao giờ gán vào ba khoá độc — __proto__, constructor, prototype.
- Trong hàm merge/set-by-path tự viết: kiểm mỗi khoá và bỏ qua ba khoá đó. Nhưng tốt hơn: đừng tự viết — dùng
structuredClonecho clone, và một thư viện merge đã vá. - Dùng thư viện đã vá. lodash sau CVE-2019-10744 đã vá, nhưng chỉ nếu bạn ở phiên bản mới — nên đây cũng là bài toán dependency (topic dependencies-sbom).
dotnet/C# không có lỗ này, nhưng Node/TypeScript của SecLab (web, agentic) thì có. Object.freeze(Object.prototype)ở đầu ứng dụng: nó làm mọi lần gán vào prototype thất bại (ném ở strict mode). Một biện pháp một dòng, mạnh, và hiếm khi phá gì — trừ code cố tình sửa prototype (thường là code xấu).
Điểm quan trọng: JSON.parse KHÔNG phải chỗ vá (khối 3) — nó không nhiễm. Vá ở hàm MERGE, và grep các hàm đó (khối 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);Dùng cấu trúc không có prototype cho dữ liệu người dùng
bắt buộcNếu một object không nằm trên chuỗi prototype dùng chung thì đầu độc prototype không ảnh hưởng nó, và một khoá __proto__ chỉ là một khoá thường.
Mapthay object cho dữ liệu do người dùng kiểm soát dạng key-value:Mapkhông có chuỗi prototype cho khoá, nênmap.get("__proto__")chỉ là một khoá, không ghi vàoObject.prototype.Object.create(null)khi cần một object thuần: nó không có prototype, nên đọcobj.isAdminkhông leo lênObject.prototype— kể cả khi prototype đã bị đầu độc, object này miễn nhiễm.- Schema validation với allowlist trường (Zod, ...) ở biên: nếu DTO chỉ khai
themevàlocale, thì__proto__trong body không có chỗ đi vào — cùng nguyên tắc mass assignment ở topic api-security (DTO đầu vào chỉ chứa trường được phép).
Điểm mấu chốt: schema validation là bản vá mạnh nhất vì nó đóng cả prototype pollution LẪN mass assignment LẪN injection ở một chỗ — dữ liệu người dùng được ép về đúng hình dạng đã khai trước khi chạm tới bất kỳ hàm merge nào.
Giảm gadget: quyền tối thiểu và classpath nhỏ
Prototype pollution là khuếch đại; hậu quả phụ thuộc gadget có mặt. Lớp này giảm gadget và giới hạn hậu quả — giống deserialization lớp 2 vì lý do giống nhau (RCE phụ thuộc classpath).
- Giảm dependency: mỗi thư viện là một tập gadget tiềm năng. Bỏ dependency không dùng bỏ đi các sink đọc thuộc tính bị nhiễm (topic dependencies-sbom).
- Sandbox process xử lý dữ liệu không tin cậy: user không phải root,
readOnlyRootFilesystem, egress đóng — nếu prototype pollution tới được RCE, nó chạy trong một sandbox rỗng (topic command-injection lớp 2). - Không đọc option nhạy cảm từ object có thể bị nhiễm: nếu code truyền một
optsobject vàochild_process,require, hay một template engine, đảm bảooptslàObject.create(null)hoặc được dựng tường minh, không phải kết quả của một merge với dữ liệu người dùng.
Đây là lớp 2 vì nó không đóng đường vào (lớp 1 làm việc đó) — nó làm một pollution thành công ít hậu quả hơn.
Phát hiện: khoá `__proto__` trong input là tín hiệu độ nhiễu thấp
Client hợp lệ gần như không bao giờ gửi một trường tên __proto__, constructor, hay prototype, nên sự xuất hiện của chúng trong body/query là một lần thử.
- Log request có khoá
__proto__/constructor/prototypetrong JSON body hoặc query string. Alert lần đầu — nó gần như luôn là một payload prototype pollution. Object.freeze(Object.prototype)cũng là một bộ phát hiện: sau khi freeze, một lần gán vào prototype ném lỗi ở strict mode, nên nó biến một pollution im lặng thành một exception thấy được trong log.- Test hồi quy: giữ một bộ payload prototype pollution đã biết và chạy qua mọi endpoint nhận JSON trong CI, khẳng định
({}).pollutedvẫn undefined sau đó.
Đây là lớp 3 vì lớp 1 đã chặn; nhưng khoá độc trong input là tín hiệu sạch, nên log nó là rẻ và nó cho biết ai đang thử.
Kiểm chứng đã vá
1. Test đơn vị: payload đầu độc KHÔNG được nhiễm prototype. Đây là phép kiểm cốt lõi, và khẳng định phải đúng: sau khi gọi hàm merge với {"__proto__":{"polluted":1}}, khẳđịnh ({}).polluted vẫn undefined. Xem tab typescript / test.
2. Grep các hàm merge/set-by-path và JSON đi vào chúng:
# Deep merge tự viết và các API dễ nhiễm.grep -rnE '_\.(merge|mergeWith|defaultsDeep|set|setWith)\(|Object\.assign\(.*JSON\.parse' \ --include='*.ts' --include='*.tsx' --include='*.js' src/ \ && echo "CẢNH BÁO: merge/set có thể nhận input người dùng — kiểm phiên bản và nguồn dữ liệu"# Deep-merge tự viết (đệ quy gán theo khoá) là đường vào phổ biến nhất.grep -rnE 'for .* in .*\)[^{]*{[^}]*\[key\] *=' --include='*.ts' src/ \ && echo "CẢNH BÁO: có thể là deep-merge tự viết — kiểm nó chặn __proto__"3. Test end-to-end qua endpoint nhận JSON — payload trong body thật:
B=https://staging.example.comcurl -s -X POST "$B/api/settings" -H 'Content-Type: application/json' \ -d '{"theme":"dark","__proto__":{"isAdmin":true}}' > /dev/null# Rồi kiểm một endpoint kiểm quyền — một người dùng thường KHÔNG được thành admin.curl -s "$B/api/admin/users" -b "session=$ORDINARY_USER" -o /dev/null -w '%{http_code}\n'# Phải là 403. Nếu 200 → prototype pollution đã bỏ qua phân quyền.4. Kiểm Object.freeze(Object.prototype) được gọi lúc khởi động:
grep -rn "Object.freeze(Object.prototype)" --include='*.ts' src/ \ || echo "CẢNH BÁO: không thấy Object.freeze(Object.prototype) — biện pháp một dòng bị thiếu"5. Kiểm dependency không có prototype pollution đã biết (giao với topic dependencies-sbom):
npm audit --audit-level=high 2>&1 | grep -i 'prototype pollution' \ && { echo "có dependency với prototype pollution đã biết"; 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); });});Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Sanitize __proto__ khỏi JSON string trước JSON.parse | JSON.parse không nhiễm; hàm MERGE sau đó mới nhiễm. Và constructor.prototype là đường khác |
Chặn chỉ __proto__ | Còn constructor và prototype. Chặn cả ba, hoặc dùng cấu trúc không có prototype |
| Coi nó là lỗ hổng thấp "chỉ làm bẩn object" | Nó là KHUẾCH ĐẠI: thành RCE, XSS, hay bypass phân quyền tuỳ gadget đọc thuộc tính bị nhiễm |
| Tự viết deep-merge "cẩn thận" | Đây là đường vào phổ biến nhất. Dùng structuredClone/thư viện đã vá, hoặc schema validation |
| Dựa vào phiên bản lodash "mới" | Chỉ đúng nếu bạn thật sự ở phiên bản đã vá. Đây cũng là bài toán dependency (topic dependencies-sbom) |
Kiểm if (user.isAdmin) trên object thường | Nếu prototype bị nhiễm, mọi object trả isAdmin=true. Dùng Object.create(null) hoặc kiểm hasOwnProperty |
| Đọc opts nhạy cảm từ object đã merge | opts.shell bị nhiễm → RCE qua child_process. Dựng opts tường minh |
Sai lầm về phân loại, và nó là sai lầm chính: coi prototype pollution là "làm bẩn object, rủi ro thấp". Nó là một KHUẾCH ĐẠI — như deserialization, hậu quả phụ thuộc gadget có trong classpath, và ở một app có đúng gadget thì nó là RCE. Đánh giá nó theo gadget xấu nhất có thể, không theo bản thân việc làm bẩn.
Sai lầm về vị trí: tìm bản vá ở JSON.parse. JSON.parse không nhiễm (khối 3). Đường vào là hàm MERGE/SET-BY-PATH — thường trong một dependency — nên cách tìm là grep các hàm đó và kiểm chúng chặn ba khoá độc, không phải lọc JSON string.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…