SecLab
Client-sideĐủ dùng

DOM-based vulnerabilities

A05CWE-79
01

Là gì

Lỗ hổng DOM-based là khi JavaScript của chính trang lấy dữ liệu từ một nguồn kẻ tấn công kiểm soát (URL, location.hash, postMessage, localStorage) và đưa nó vào một đích nguy hiểm (innerHTML, eval, location) — tất cả xảy ra trong trình duyệt, không đi qua server.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Điều làm DOM-based khác mọi lỗ hổng khác trong catalogue: server không bao giờ thấy payload, nên mọi phòng thủ phía server đều mù với nó.

  • WAF không thấy. location.hash (phần sau dấu #) không được gửi tới server — trình duyệt giữ nó lại. Một payload trong fragment đi qua mọi WAF, mọi log, mọi kiểm tra server-side.
  • Test server-side không bắt được. Không có request nào chứa payload để một integration test nhìn thấy. Nó cần một trình duyệt thật để tái hiện.
  • Framework không tự lo hết. React escape thân HTML, nhưng dangerouslySetInnerHTML, location = userUrl, và eval vẫn là đích nguy hiểm mà framework không chặn.

Đây là một dạng của XSS (xem topic xss), nhưng đủ khác để tách riêng: XSS phản chiếu/lưu trữ đi qua server và có thể vá ở server; DOM-based thì chỉ vá được ở client, và cách tìm nó là truy nguồn → đích trong JavaScript, không phải quét request.

03

Cơ chế hoạt động

Cơ chế là một luồng nguồn → đích hoàn toàn trong trình duyệt. Biết danh sách nguồn và danh sách đích là cách tìm nó có hệ thống.

Nguồn sơ đồ
flowchart LR    subgraph SRC["Nguồn (kẻ tấn công kiểm soát)"]        S1["location.hash / .search"]        S2["postMessage"]        S3["localStorage / document.referrer"]    end    subgraph SINK["Đích (thực thi)"]        K1["innerHTML / outerHTML"]        K2["eval / Function / setTimeout(str)"]        K3["location / location.href"]    end    S1 --> K1 & K2 & K3    S2 --> K1    S3 --> K1    K1 --> X["XSS — script chạy trong origin"]    K2 --> X    K3 --> O["Open redirect / javascript: URL"]

Điểm cốt lõi: server không nằm trong sơ đồ. Toàn bộ luồng từ nguồn tới đích xảy ra trong JavaScript trên máy nạn nhân, nên nó là một lỗi của code client, và chỉ tìm được bằng cách đọc code client hoặc dùng một trình duyệt thật.

Bảng nguồn và đích — đây là bản đồ để đi tìm:

Nguồn (không tin)Đích (nguy hiểm)Hậu quả
location.hash, .searchinnerHTMLXSS
postMessage (không kiểm origin)innerHTML, evalXSS từ một khung khác
location.hashlocation = ...open redirect, javascript: URL
document.referrerinnerHTMLXSS qua trang giới thiệu
bất kỳ nguồneval, Function, setTimeout("str")thực thi code trực tiếp

postMessage đáng nói riêng: một handler message không kiểm event.origin nhận dữ liệu từ BẤT KỲ trang nào mở được một khung tới trang bạn — nên một trang kẻ tấn công nhúng trang bạn trong iframe rồi gửi postMessage payload. Đây là DOM XSS phổ biến mà ít ai đi tìm.

Mô tả sơ đồ: Sơ đồ nguồn-đích cho DOM XSS, không có server trong sơ đồ. Bên trái là các nguồn kẻ tấn công kiểm soát: location.hash và location.search, postMessage, localStorage và document.referrer. Bên phải là các đích nguy hiểm: innerHTML và outerHTML, eval và Function và setTimeout với chuỗi, location và location.href. Dữ liệu từ nguồn chảy tới đích: tới innerHTML hoặc eval thành XSS chạy trong origin, tới location thành open redirect hoặc URL javascript. Toàn bộ luồng nằm trong trình duyệt nạn nhân nên server không bao giờ thấy payload.

04

Ví dụ cụ thể

Một widget "chia sẻ" đọc tên trang từ fragment để hiện tiêu đề — và fragment không bao giờ tới server.

JavaScript
// ❌ Nguồn: location.hash. Đích: innerHTML. Server KHÔNG thấy gì.// URL: https://app.example/share#<img src=x onerror=fetch('/api/keys')>const title = decodeURIComponent(location.hash.slice(1));document.getElementById("preview").innerHTML = "Chia sẻ: " + title;

Payload nằm sau dấu #, nên nó chỉ tồn tại trong trình duyệt. WAF, log server, integration test server-side đều không thấy nó — chỉ một trình duyệt thật (hoặc đọc code) mới phát hiện.

JavaScript
// ❌ postMessage không kiểm origin. BẤT KỲ trang nào nhúng trang này đều gửi được.window.addEventListener("message", (e) => {  // Thiếu kiểm e.origin → nhận dữ liệu từ mọi khung.  document.getElementById("chat").innerHTML += e.data;});
JavaScript
// ❌ Nguồn → location. Open redirect và javascript: URL.// URL: https://app.example/go#javascript:fetch('/api/keys')location = location.hash.slice(1);   // chạy javascript: hoặc chuyển tới site kẻ tấn công
JavaScript
// Sau khi vá: textContent thay innerHTML, kiểm origin, kiểm scheme.preview.textContent = "Chia sẻ: " + title;             // không parse HTMLif (e.origin !== "https://app.example") return;         // chỉ nhận từ origin của mình
TypeScriptBa luồng nguồn-đích, và server không thấy một payload nào trong cả ba.
// ❌ Nguồn location.hash → đích innerHTML. Payload sau dấu # không tới server.function showShareTitle() {  // URL: https://app.example/share#<img src=x onerror=fetch('/api/keys')>  const title = decodeURIComponent(location.hash.slice(1));  // innerHTML PARSE HTML, nên <img onerror> chạy. WAF/log/test server-side đều mù.  document.getElementById("preview")!.innerHTML = "Sharing: " + title;} // ❌ postMessage không kiểm origin → nhận từ BẤT KỲ trang nào nhúng trang này.window.addEventListener("message", (e) => {  // Thiếu kiểm e.origin. Một trang kẻ tấn công mở iframe tới trang này rồi  // postMessage một payload, và nó chạy trong origin của ta.  document.getElementById("chat")!.innerHTML += e.data;}); // ❌ Nguồn → đích location. javascript: URL và open redirect.function goToTarget() {  // URL: https://app.example/go#javascript:fetch('/api/keys')  // Không kiểm scheme → javascript: chạy; hoặc //evil.com → open redirect.  location.href = decodeURIComponent(location.hash.slice(1));}
05

Chuyện đã xảy ra

DOM XSS trong các thư viện quảng cáo và phân tích (nhiều báo cáo). Nhiều script bên thứ ba đọc location rồi ghi vào innerHTML để "cá nhân hoá", và chúng chạy trên hàng triệu trang. Đáng nhớ vì lỗ hổng nằm trong code bạn KHÔNG viết nhưng nhúng vào — và nó vô hình với mọi kiểm tra server-side của bạn.

Họ lỗ hổng postMessage (Facebook, nhiều SDK, 2013–nay). Các SDK nhúng (login button, chat widget) dùng postMessage để giao tiếp giữa iframe và trang cha, và handler thiếu kiểm origin nhận dữ liệu từ trang kẻ tấn công. Đây là nguồn của khái niệm postMessage ở khối 3, và nó vẫn xuất hiện đều đặn.

DOM Invader (PortSwigger) và nghiên cứu về DOM Clobbering. Công cụ và nghiên cứu cho thấy quy mô thật của DOM XSS: nó phổ biến hơn reflected XSS trên các ứng dụng JavaScript nặng, chính vì không ai đi tìm nó — server-side scanner không thấy, và nó cần phân tích luồng nguồn-đích trong code client.

06

Cách phòng chống

Lớp 1

Dùng đích AN TOÀN — textContent, không innerHTML; không eval

bắt buộc

Bản vá là chọn một đích không thực thi. Bảng ở khối 3 liệt kê các đích nguy hiểm; mỗi cái có một thay thế an toàn:

  • textContent thay innerHTML. Trình duyệt không parse HTML từ textContent, nên không có ngữ cảnh nào để thoát ra. Nếu cần HTML thật từ dữ liệu người dùng thì qua DOMPurify (sanitizer allowlist — xem topic xss lớp 1), không tự viết.
  • Không bao giờ eval, Function(str), setTimeout("str"), setInterval("str"). Không có phiên bản an toàn của việc đưa chuỗi người dùng vào các hàm này.
  • Với location: kiểm scheme trước khi gán (xem topic xss — javascript: không chứa ký tự nào HTML-escaping quan tâm). Chỉ cho http/https, và với redirect nội bộ chỉ cho đường dẫn bắt đầu bằng / mà không phải // (protocol-relative → open redirect).

Cách tìm: grep các đích nguy hiểm trong code client (xem khối 7). Danh sách đích hữu hạn, nên đây là một trong ít lỗ hổng client mà grep thật sự hiệu quả.

TypeScript · Layer 1textContent thay innerHTML, kiểm origin so bằng, và kiểm scheme trước khi gán location.
// Nguồn location.hash → đích AN TOÀN textContent. Trình duyệt không parse HTML// từ textContent, nên không có ngữ cảnh nào để <img onerror> thoát ra.function showShareTitle() {  const title = decodeURIComponent(location.hash.slice(1));  // Nếu cần HTML thật từ dữ liệu người dùng thì qua DOMPurify (topic xss), không tự viết.  document.getElementById("preview")!.textContent = "Sharing: " + title;} const ALLOWED_ORIGIN = "https://app.example"; window.addEventListener("message", (e) => {  // Kiểm origin TRƯỚC khi chạm e.data, và so BẰNG chuỗi chính xác — không endsWith  // (khớp lỏng như topic cors: "evil-app.example" đi qua endsWith(".app.example")).  if (e.origin !== ALLOWED_ORIGIN) return;   // Và vẫn coi e.data là dữ liệu: textContent, không innerHTML.  const node = document.createElement("div");  node.textContent = String(e.data);  document.getElementById("chat")!.appendChild(node);}); function goToTarget() {  const raw = decodeURIComponent(location.hash.slice(1));   // Kiểm scheme: javascript: không chứa ký tự nào HTML-escaping quan tâm, nên chỉ  // một allowlist scheme mới chặn được nó. Và chặn // (protocol-relative → open redirect).  if (raw.startsWith("/") && !raw.startsWith("//")) {    location.href = raw;              // chỉ đường dẫn nội bộ  } else {    try {      const url = new URL(raw);      if (url.protocol === "https:" && url.origin === ALLOWED_ORIGIN) location.href = raw;    } catch { /* không parse được → không chuyển */ }  }}
Lớp 1b

Kiểm `event.origin` trong mọi handler postMessage

bắt buộc

Một handler message không kiểm origin nhận dữ liệu từ bất kỳ trang nào — kể cả một trang kẻ tấn công đã nhúng trang bạn trong iframe.

JavaScript
window.addEventListener("message", (e) => {  // Kiểm origin TRƯỚC khi chạm tới e.data. So BẰNG với allowlist, không  // endsWith/includes (cùng lỗi khớp lỏng như topic cors).  if (e.origin !== "https://app.example") return;  // ... và vẫn coi e.data là dữ liệu, không đưa vào innerHTML thô.});

Hai chi tiết hay sai:

  • So origin bằng chuỗi chính xác, không endsWith(".example.com") — cùng lỗi khớp lỏng như topic cors, và evil-example.com đi qua.
  • Bên GỬI cũng chỉ định targetOrigin cụ thể: postMessage(data, "https://app.example"), không postMessage(data, "*")* gửi dữ liệu tới bất kỳ trang nào đang chiếm khung đó.
Lớp 2

CSP với Trusted Types đóng cả họ ở tầng API

Đây là biện pháp duy nhất tác động vào cả họ DOM XSS ở một chỗ, thay vì sửa từng đích một.

Content-Security-Policy: require-trusted-types-for 'script' làm cho các đích nguy hiểm (innerHTML, eval, Function) từ chối một chuỗi thô — chúng chỉ nhận một TrustedHTML/TrustedScript do một policy đã đăng ký tạo ra. Nghĩa là một element.innerHTML = userString ném lỗi ở runtime, biến một lỗ hổng im lặng thành một lỗi thấy được.

Xem topic csp lớp 1b — đây là một trong bốn chỉ thị hay bị bỏ. Nó là lớp 2 chứ không phải lớp 1 vì nó cần trình duyệt hỗ trợ và cần chuyển code sang dùng policy, nhưng nó là biện pháp duy nhất bắt được một đích mới do người khác thêm vào sau.

Cộng CSP script-src dạng nonce (topic csp) để giới hạn thiệt hại nếu một đích lọt qua.

Lớp 3

Phát hiện: quét luồng nguồn-đích, không quét request

DOM XSS không để lại dấu vết ở server, nên phát hiện phải ở tầng client và ở tầng CI.

  • Phân tích tĩnh luồng nguồn-đích trong CI: các công cụ như CodeQL, hay ESLint với plugin bảo mật, truy được location.hash → innerHTML. Đây là cách duy nhất tìm nó một cách hệ thống, vì grep chỉ thấy đích chứ không thấy dữ liệu tới đích từ một nguồn không tin.
  • DOM Invader / dynamic testing trong trình duyệt trên staging: nó bơm một marker vào mọi nguồn và xem marker có tới một đích không.
  • CSP report-uri với Trusted Types bật: một vi phạm Trusted Types là một đích đang nhận chuỗi thô — tức là một DOM XSS tiềm năng, báo cáo trong lúc nó xảy ra.

Đây là lớp 3 vì nó không chặn, nhưng với một họ lỗ hổng mà mọi phát hiện server-side đều mù, phân tích client-side là cách duy nhất biết nó tồn tại.

07

Kiểm chứng đã vá

1. Grep các ĐÍCH nguy hiểm trong code client — phép kiểm rẻ nhất, và danh sách đích hữu hạn:

Shell
grep -rnE '\.innerHTML|\.outerHTML|\beval\(|new Function\(|setTimeout\([^,]*[a-z]' \  --include='*.ts' --include='*.tsx' --include='*.js' --include='*.vue' src/ \  | grep -v 'textContent\|DOMPurify\|sanitize' \  && { echo "đích DOM nguy hiểm — xem lại nguồn của nó"; exit 1; }exit 0

2. Grep handler postMessage không kiểm origin:

Shell
grep -rnE 'addEventListener\(\s*["\x27]message["\x27]' -A6 \  --include='*.ts' --include='*.tsx' --include='*.js' src/ \  | grep -L 'e\.origin\|event\.origin' \  && echo "handler message có thể thiếu kiểm origin"# Và grep postMessage(data, "*") — gửi tới mọi origin.grep -rnE 'postMessage\([^,]+,\s*["\x27]\*["\x27]' --include='*.ts' --include='*.tsx' src/ \  && { echo "postMessage targetOrigin=* — gửi tới mọi trang"; exit 1; }exit 0

3. Phân tích tĩnh luồng nguồn-đích — grep chỉ thấy đích; phân tích luồng thấy DỮ LIỆU tới đích từ một nguồn không tin. Chạy CodeQL query js/dom-xss trong CI. Đây là phép kiểm duy nhất tìm được nó một cách hệ thống.

4. Test trong TRÌNH DUYỆT THẬT — phép kiểm duy nhất tái hiện được, vì payload trong fragment không tới server:

JavaScript
// Playwright. Payload trong hash → khẳng định nó KHÔNG chạy.test("hash không thành XSS", async ({ page }) => {  await page.goto(`${BASE}/share#<img src=x onerror=window.__pwned=1>`);  expect(await page.evaluate(() => window.__pwned)).toBeUndefined();  // Và chuỗi hiện ra dưới dạng CHỮ, đó mới đúng.  expect(await page.textContent("#preview")).toContain("<img");});

5. Kiểm Trusted Types bật (lớp 2):

Shell
curl -sI https://app.example/ | grep -i content-security-policy \  | grep -q "require-trusted-types-for" || echo "CẢNH BÁO: thiếu Trusted Types"
TypeScriptTest trong trình duyệt thật — payload fragment không tới server nên chỉ browser tái hiện được.
import { test, expect } from "@playwright/test"; const BASE = "http://localhost:3100"; // DOM XSS chỉ tái hiện được trong TRÌNH DUYỆT THẬT: payload nằm sau dấu #, nên nó// không bao giờ tới server, và không integration test server-side nào thấy nó.// Đây là điểm khác biệt cốt lõi so với reflected/stored XSS. test.describe("DOM XSS", () => {  test("hash source vào textContent không chạy", async ({ page }) => {    // Nếu payload chạy, nó set window.__pwned. Khẳng định nó KHÔNG chạy.    await page.goto(BASE + "/share#" + encodeURIComponent("<img src=x onerror=window.__pwned=1>"));     expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();    // Và chuỗi hiện ra dưới dạng CHỮ — đó mới là hành vi đúng của textContent.    expect(await page.textContent("#preview")).toContain("<img src=x");  });   test("postMessage từ origin lạ bị bỏ qua", async ({ page, context }) => {    await page.goto(BASE + "/chat");     // Mở một trang ở origin KHÁC và gửi postMessage tới trang chat.    const evil = await context.newPage();    await evil.setContent(      '<iframe src="' + BASE + '/chat" id="t"></iframe>' +      '<scr' + 'ipt>' +      '  document.getElementById("t").onload = () => {' +      '    document.getElementById("t").contentWindow.postMessage(' +      '      "<img src=x onerror=window.__pwned=1>", "*");' +      '  };' +      '</scr' + 'ipt>');    await page.waitForTimeout(500);     // Handler kiểm origin nên payload từ origin lạ bị bỏ qua.    expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();  });   test("javascript: trong hash không chuyển hướng", async ({ page }) => {    await page.goto(BASE + "/go#" + encodeURIComponent("javascript:window.__pwned=1"));    await page.waitForTimeout(200);     expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();    // Và không rời khỏi origin của mình.    expect(new URL(page.url()).origin).toBe(new URL(BASE).origin);  });   test("//evil.com trong hash không open redirect", async ({ page }) => {    await page.goto(BASE + "/go#" + encodeURIComponent("//evil.example/"));    await page.waitForTimeout(200);     expect(new URL(page.url()).hostname).not.toBe("evil.example");  });});
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Sanitize ở serverPayload trong fragment KHÔNG tới server. DOM XSS chỉ vá được ở client
Escape HTML rồi vẫn dùng innerHTMLĐúng đôi khi, nhưng textContent đơn giản và không sai được. Và javascript: trong đích location không có ký tự nào để escape
Tin React tự loReact escape thân HTML. dangerouslySetInnerHTML, location=, eval vẫn là đích nguy hiểm React không chặn
postMessage kiểm origin.endsWith(...)Khớp lỏng như topic cors. evil-example.com đi qua. So bằng chuỗi chính xác
postMessage(data, "*")Gửi dữ liệu tới bất kỳ trang nào chiếm khung. Chỉ định targetOrigin cụ thể
WAF chặn payload XSSFragment không tới WAF. Không có gì cho WAF thấy
Chỉ test reflected/stored XSSServer-side test không có request nào chứa payload fragment. Cần trình duyệt thật

Sai lầm về vị trí, và nó là sai lầm chính: đi tìm và vá ở server. DOM XSS là một lỗi hoàn toàn client-side — nguồn, đích, và thực thi đều trong trình duyệt. Cách tìm là truy nguồn → đích trong JavaScript, không phải quét request.

Sai lầm về công cụ: dùng scanner server-side. Chúng gửi request và xem response — nhưng payload DOM XSS nằm trong fragment không bao giờ tới server, nên scanner thấy một trang sạch. Cần phân tích luồng client-side (CodeQL) hoặc test trình duyệt thật (DOM Invader, Playwright).

09

Nguồn tham khảo

Bậc 1Window: postMessage() — security concerns · MDN · Web API reference · 2025
Bậc 1Trusted Types · W3C · Working Draft · 2025
Bậc 2DOM-based vulnerabilities · PortSwigger · Web Security Academy
Bậc 2DOM based XSS Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 2DOM Clobbering Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 3Introducing DOM Invader · PortSwigger
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…