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.
Vì sao bạn quan tâm
Đ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àevalvẫ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.
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.
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, .search | innerHTML | XSS |
postMessage (không kiểm origin) | innerHTML, eval | XSS từ một khung khác |
location.hash | location = ... | open redirect, javascript: URL |
document.referrer | innerHTML | XSS qua trang giới thiệu |
| bất kỳ nguồn | eval, 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.
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.
// ❌ 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.
// ❌ 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;});// ❌ 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// 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// ❌ 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));}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.
Cách phòng chống
Dùng đích AN TOÀN — textContent, không innerHTML; không eval
bắt buộcBả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:
textContentthayinnerHTML. 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ỉ chohttp/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ả.
// 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 */ } }}Kiểm `event.origin` trong mọi handler postMessage
bắt buộcMộ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.
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
originbằng chuỗi chính xác, khôngendsWith(".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ôngpostMessage(data, "*")—*gửi dữ liệu tới bất kỳ trang nào đang chiếm khung đó.
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.
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-urivớ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.
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:
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 02. Grep handler postMessage không kiểm origin:
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 03. 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:
// 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):
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"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"); });});Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Sanitize ở server | Payload 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ự lo | React 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 XSS | Fragment không tới WAF. Không có gì cho WAF thấy |
| Chỉ test reflected/stored XSS | Server-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).
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.
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…