Vấn đề: Những “quả bom logic” ẩn mình trong Object của JavaScript
Hầu hết các lập trình viên backend đều nhớ lần đầu tiên một lỗi bất ngờ làm sập hệ thống production. Thông thường, vector tấn công không phải là brute-force SSH hay một lỗi SQL injection lộ liễu, mà lại là một khiếm khuyết kiến trúc tinh vi nằm ngay trước mắt. Trong các hệ thống backend Node.js, hiếm có lỗ hổng nào âm thầm—nhưng lại có sức tàn phá khủng khiếp—như Prototype Pollution.
Hãy hình dung một endpoint cập nhật tài khoản thông thường trong API viết bằng Express hoặc Fastify. Client gửi một payload JSON khoảng 2 KB để cập nhật địa chỉ thanh toán, số điện thoại và tùy chọn giao diện—tương tự kịch bản thường gặp của các lỗ hổng Mass Assignment. Phía server, một helper function sẽ merge đệ quy dữ liệu đầu vào này vào bản ghi session hiện có. Toàn bộ unit test đều pass với độ bao phủ (coverage) 100%. Quá trình code review không phát hiện điểm bất thường nào. Dữ liệu được ghi thành công vào database.
Thế nhưng, kẻ tấn công lại gửi kèm một thuộc tính trông rất vô hại: __proto__. Vì các object trong JavaScript chia sẻ cùng một chuỗi kế thừa (inheritance chain), thuộc tính này không chỉ nằm gói gọn trong bản ghi của người dùng đó. Thay vào đó, nó làm biến đổi (mutate) root prototype dùng chung của mọi object trong tiến trình Node.js của bạn.
Đột nhiên, các object rỗng lại vô tình thừa hưởng quyền quản trị viên (admin). Trình validate đầu vào bỏ sót các trường dữ liệu. Các microservice ở downstream bị crash. Những sự cố chấn động—chẳng hạn như CVE-2018-3721 của Lodash (ảnh hưởng đến hơn 4 triệu repository phụ thuộc) và CVE-2019-7609 của Kibana (cho phép thực thi mã từ xa tùy ý – RCE)—đã chứng minh rằng lỗ hổng kiểu này hoàn toàn có thể đánh sập các hệ thống doanh nghiệp lớn.
Các khái niệm cốt lõi: Prototype và cơ chế Property Lookup
Cách cơ chế kế thừa Prototype trong JavaScript hoạt động
JavaScript dựa vào chuỗi prototype (prototype chaining) thay vì mô hình kế thừa cổ điển (classical inheritance). Hầu hết mọi object đều liên kết với một prototype object cấp trên để kế thừa các phương thức và thuộc tính tiện ích. Khi code của bạn truy xuất user.isAdmin, V8 engine trước tiên sẽ kiểm tra chính instance đó. Nếu key này không tồn tại, engine sẽ duyệt ngược lên chuỗi kế thừa tới Object.prototype. Quá trình tra cứu chỉ dừng lại khi tìm thấy kết quả khớp hoặc chạm mức null.
Dưới đây là ví dụ minh họa cơ chế phân giải thuộc tính cơ bản:
const user = { name: "Alex" };
console.log(user.isAdmin); // Output: undefined
console.log(user.toString); // Output: [Function: toString] (kế thừa từ Object.prototype)
Node.js giải quyết user.toString trực tiếp từ Object.prototype. Tham chiếu dùng chung này giúp hàng triệu object đồng thời vẫn giữ được dung lượng bộ nhớ RAM tối thiểu. Tuy nhiên, nó cũng vô tình biến mọi object thành một bề mặt tấn công chung.
Điều gì kích hoạt Prototype Pollution?
Lỗ hổng này phát sinh khi ứng dụng merge, clone hoặc extend các object một cách đệ quy từ dữ liệu đầu vào chưa được làm sạch (unsanitized) của người dùng. Nếu kẻ tấn công chèn các key như __proto__ hoặc constructor.prototype, các hàm đệ quy ngây thơ sẽ xử lý chúng như các đường dẫn lồng nhau thông thường. Quá trình merge sẽ vượt ra khỏi object đích cục bộ và ghi thẳng vào root prototype.
Một khi đã bị ghi đè, key bị chèn đó sẽ lây nhiễm sang mọi object trong toàn bộ runtime:
// Chèn mã độc vào root prototype
Object.prototype.isAdmin = true;
const regularCustomer = {};
console.log(regularCustomer.isAdmin); // Output: true
Chỉ một request độc hại duy nhất cũng có thể phá vỡ các lớp kiểm tra xác thực tiêu chuẩn như if (req.user.isAdmin). Bất kỳ người dùng chưa xác thực nào cũng ngay lập tức chiếm được quyền quản trị viên.
Thực hành: Tái hiện và vô hiệu hóa mã khai thác
Bước 1: Xây dựng hàm merge object dính lỗ hổng
Nhiều codebase nội bộ hiện vẫn sử dụng các hàm merge đệ quy tự viết từ trước khi lỗ hổng này được ghi nhận rộng rãi. Hãy lưu đoạn code sau dưới tên vulnerable-server.js để kiểm tra kịch bản lỗi:
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) {
target[key] = {};
}
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
module.exports = { unsafeMerge };
Cách triển khai này sẽ duyệt sâu hơn mỗi khi gặp một object lồng nhau. Nhưng hãy xem điều gì xảy ra khi payload đầu vào nhắm mục tiêu vào chuỗi prototype.
Bước 2: Mô phỏng payload tấn công
Khi V8 parse một chuỗi JSON có chứa "__proto__" bằng JSON.parse(), nó sẽ tạo ra một property key thông thường, có thể lặp qua (enumerable), thay vì gán trực tiếp prototype. Khi được truyền vào unsafeMerge, biểu thức target["__proto__"] sẽ trỏ thẳng tới Object.prototype:
const { unsafeMerge } = require("./vulnerable-server");
// Payload JSON thô từ body của một HTTP request
const maliciousPayload = JSON.parse('{"__proto__": {"role": "system_admin"}}');
const userProfile = {};
unsafeMerge(userProfile, maliciousPayload);
// Kiểm tra một object hoàn toàn không liên quan được khởi tạo sau đó
const freshAccount = {};
console.log("freshAccount.role:", freshAccount.role);
// Output: freshAccount.role: system_admin
Hàm merge không chỉ làm biến đổi userProfile mà nó đã làm tổn hại đến toàn bộ vùng nhớ global trong tiến trình ứng dụng của bạn.
Bước 3: Khắc phục lỗ hổng trong mã nguồn ứng dụng
Để vô hiệu hóa triệt để Prototype Pollution, cần áp dụng mô hình phòng thủ nhiều lớp—từ việc thiết lập WAF (ModSecurity và OWASP CRS) ở tầng gateway cho đến xử lý an toàn trong mã nguồn. Dưới đây là ba kỹ thuật hiệu quả bạn có thể triển khai ngay trong codebase của mình.
Giải pháp 1: Chặn các property key nguy hiểm
Chủ động loại bỏ các key nhạy cảm có khả năng duyệt prototype trước khi thực hiện gán đệ quy:
function safeMerge(target, source) {
const bannedKeys = new Set(["__proto__", "constructor", "prototype"]);
for (const key of Object.keys(source)) {
if (bannedKeys.has(key)) {
continue; // Bỏ qua các key nguy hiểm
}
if (typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== "object") {
target[key] = {};
}
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Giải pháp 2: Sử dụng dictionary không có prototype
Khi tổng hợp các cặp key-value động hoặc xây dựng cache tra cứu, hãy khởi tạo object bằng Object.create(null). Các object này hoàn toàn không kế thừa từ Object.prototype, qua đó chặn đứng hoàn toàn đường dẫn duyệt prototype:
// Hash map sạch không kế thừa các thuộc tính prototype
const safeStorage = Object.create(null);
safeStorage["__proto__"] = "custom_value";
console.log(safeStorage.toString); // Output: undefined (không tồn tại prototype)
console.log(Object.prototype.custom_value); // Output: undefined (root prototype không bị ảnh hưởng)
Đối với việc tra cứu chuỗi tùy ý, cấu trúc dữ liệu Map tích hợp sẵn trong JavaScript là giải pháp thay thế thậm chí còn sạch hơn. Các key trong Map luôn được cách ly nghiêm ngặt như dữ liệu thuần túy mà không kích hoạt cơ chế property lookup.
Giải pháp 3: Đóng băng (Freeze) Global Prototype khi khởi động
Khóa cứng root prototype bằng Object.freeze() trước khi khởi tạo bất kỳ network listener nào:
// Chạy ở đầu file entry chính (index.js / server.js)
Object.freeze(Object.prototype);
const testObject = {};
try {
testObject.__proto__.polluted = true;
} catch (err) {
console.error("Ghi dữ liệu bị chặn: prototype ở trạng thái bất biến (immutable)");
}
console.log({}.polluted); // Output: undefined
Lưu ý rằng Object.freeze() có thể làm lỗi các package npm đời cũ có hành vi mở rộng prototype mặc định trong quá trình khởi tạo. Hãy luôn kiểm tra kỹ lưỡng thay đổi này với test suite trước khi deploy lên production.
Bước 4: Tăng cường bảo mật (Hardening) cho Node.js Runtime
Bắt đầu từ phiên bản 12.19.0 và 14.12.0, Node.js đã bổ sung các CLI flag gốc nhằm giảm thiểu nguy cơ khai thác prototype mà không cần viết lại mã nguồn. Bạn có thể hạn chế quyền truy cập prototype ngay trong lệnh khởi chạy tiến trình:
# Quăng lỗi nghiêm trọng (fatal exception) bất cứ khi nào code truy cập Object.prototype.__proto__
node --disable-proto=throw server.js
# Hoặc âm thầm xóa Object.prototype.__proto__ trong quá trình khởi động tiến trình
node --disable-proto=delete server.js
Hãy thêm các flag này vào ENTRYPOINT trong Dockerfile hoặc script npm start ở production để được bảo vệ ngay lập tức ở cấp độ runtime.
Tổng kết: Những bài học then chốt về bảo mật Node.js
Prototype Pollution khai thác một cơ chế cốt lõi của JavaScript runtime. Khi bạn parse dữ liệu JSON không đáng tin cậy, tính cô lập của object sẽ bị phá vỡ trừ khi bạn chủ động thiết lập các ranh giới cấu trúc chặt chẽ.
Hãy bảo vệ các dịch vụ production của bạn bằng 4 thói quen sau:
- Loại bỏ các key
__proto__,constructorvàprototypetrong bất kỳ hàm merge đệ quy hoặc deep-clone tự viết nào. - Validate các payload JSON gửi đến theo schema nghiêm ngặt bằng Zod hoặc Ajv với tùy chọn
additionalProperties: false. - Chuyển sang dùng
MaphoặcObject.create(null)cho các key-value map động do người dùng kiểm soát. - Khởi chạy tiến trình Node.js với flag
--disable-proto=throwtrong các môi trường container hóa.

