0

Tôi đã suýt tìm ra XSS bypass trong DOMPurify (nhờ AI) như thế nào?

Sau khi phát hiện ra thì tôi đúng kiểu “Ngon rồi có CVE xịn rồi”. Một pha phối hợp quá tuyệt vời giữa tôi + claude😄. Nhưng đời không như là mơ. Trong lúc còn đang hí hửng đào sâu cái 0-day tưởng như đã nắm chắc, thì DOMPurify lại tung bản 3.4.1 — chỉ đúng 1 tiếng sau khi tôi tìm ra. 😦


TL;DR

  • Vấn đề: Ở bản 3.4.0, một bước transform/build làm lệch logic kiểm tra custom-element, khiến một vài tên nằm trong tập RESERVED_CUSTOM_ELEMENT_NAMES bị nhận diện nhầm là custom-element.
  • Hậu quả: Trong một số kịch bản attribute/element đáng lẽ phải bị loại lại được giữ lại → khả năng bypass filter nội dung.
  • Trạng thái: Lỗi đã được fix trong bản 3.4.1.

Phân tích chi tiết

Ở phiên bản 3.4.0, hàm _isBasicCustomElement là:

const _isBasicCustomElement = function (tagName: string): RegExpMatchArray {
  return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT);
};

Sang phiên bản 3.4.1, nó được đổi thành:

const RESERVED_CUSTOM_ELEMENT_NAMES = addToSet({}, [
  'annotation-xml',
  'color-profile',
  'font-face',
  'font-face-format',
  'font-face-name',
  'font-face-src',
  'font-face-uri',
  'missing-glyph',
]);

const _isBasicCustomElement = function (tagName: string): boolean {
  return (
    !RESERVED_CUSTOM_ELEMENT_NAMES[stringToLowerCase(tagName)] &&
    regExpTest(CUSTOM_ELEMENT, tagName)
  );
};

Nếu nhìn nhanh thì diff này trông nhỏ. Nhưng về mặt security, nó thay đổi hẳn ý nghĩa:

  • 3.4.0: cứ có dấu -, và không phải đúng chuỗi annotation-xml, thì coi là custom element.
  • 3.4.1: có dấu - thôi chưa đủ; nếu tên đó nằm trong danh sách reserved của spec thì tuyệt đối không được coi là custom element.

Đây mới là chỗ mấu chốt.


Step 1: tại sao logic của 3.4.0 bị sai?

Vấn đề của 3.4.0 là nó chỉ loại đúng một cái tên: annotation-xml.

Trong khi đó, HTML spec vẫn còn nhiều tên có dấu - nhưng không phải custom element hợp lệ, ví dụ:

  • font-face
  • font-face-src
  • font-face-uri
  • font-face-format
  • font-face-name
  • color-profile
  • missing-glyph

Nói cách khác, 3.4.0 đang lấy một rule quá thô:

  • Có dấu - => gần như coi là custom element.

Trong khi rule đúng phải là:

  • Có dấu -
  • Và không nằm trong danh sách reserved names của spec.

Ngoài ra còn một khe hở nữa: ở 3.4.0, check annotation-xml là so sánh chuỗi trực tiếp. Trong XHTML mode, tag name có thể giữ nguyên case, nên mấy biến thể kiểu Annotation-XML cũng không bị chặn đúng cách. 3.4.1 xử lý luôn phần này bằng stringToLowerCase(tagName) trước khi lookup.


Step 2: vì sao nhận diện sai lại ảnh hưởng tới sanitize?

Chỗ nguy hiểm nằm ở việc DOMPurify không chỉ “đánh dấu cho vui” một tag là custom element. Nó dùng kết quả đó để quyết định giữ hay xóa element.

Trong luồng _sanitizeElements, có đoạn:

if (
  FORBID_TAGS[tagName] ||
  (!(
    EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
    EXTRA_ELEMENT_HANDLING.tagCheck(tagName)
  ) &&
    !ALLOWED_TAGS[tagName])
) {
  if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
    if (
      CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
      regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
    ) {
      return false;
    }

    if (
      CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
      CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
    ) {
      return false;
    }
  }

  _forceRemove(currentNode);
  return true;
}

Đọc dễ hiểu hơn thì là:

  1. Nếu tag không nằm trong allow-list, bình thường nó sẽ bị xóa.
  2. Nhưng trước khi xóa, DOMPurify cho custom elements một “cửa thoát”.
  3. Nếu _isBasicCustomElement(tagName) trả về true, và config CUSTOM_ELEMENT_HANDLING.tagNameCheck của user cũng cho qua, thì element đó sẽ được giữ lại.

Thế nên bug không chỉ là “check sai một cái tên”. Bug là:

  • check sai
  • rồi dùng kết quả sai đó để cho element đi qua nhánh keep thay vì remove

Đó là lý do impact của nó không còn là chuyện lý thuyết nữa.


Step 3: không chỉ giữ element, nó còn ảnh hưởng cả attribute

Sau khi element được giữ lại, DOMPurify lại dùng chính _isBasicCustomElement(...) thêm một lần nữa trong _isValidAttribute:

if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) {
  if (
    (_isBasicCustomElement(lcTag) &&
      ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
        regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag)) ||
        (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
          CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag))) &&
      ((CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp &&
        regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName)) ||
        (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function &&
          CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName, lcTag)))) ||
    ...
  ) {
    // allow
  } else {
    return false;
  }
}

Đây là chỗ mọi thứ bắt đầu nguy hiểm thật sự.

Ý nghĩa của đoạn này là:

  • Nếu attribute đó không nằm trong allow-list mặc định,
  • thì DOMPurify vẫn có thể cho qua,
  • miễn là element hiện tại được xem là custom element hợp lệ,
  • attributeNameCheck trong CUSTOM_ELEMENT_HANDLING cũng cho qua.

Tức là bug ở _isBasicCustomElement không chỉ làm sai tầng element, mà còn kéo theo sai cả tầng attribute.

Đó là lý do release note của 3.4.1 ghi rất rõ ràng:

Fixed an issue with on-handler stripping for HTML-spec-reserved custom element names under permissive CUSTOM_ELEMENT_HANDLING

Từ khóa ở đây là:

  • on-handler stripping
  • reserved custom element names
  • permissive CUSTOM_ELEMENT_HANDLING

Tức là đúng cái case: lẽ ra onclick phải bị strip, nhưng vì tag bị nhận diện nhầm thành custom element nên nó có đường đi qua.


Step 4: khi nào bug này chỉ là policy bypass, và khi nào nó thành XSS?

Đây là phần quan trọng nhất.

Trường hợp 1: chỉ là bug logic / policy bypass

Nếu ứng dụng:

  • có dùng CUSTOM_ELEMENT_HANDLING,
  • nhưng attributeNameCheck không cho qua attribute nguy hiểm,
  • hoặc tag đi qua nhưng không có sink thực thi,

thì lúc đó bug chủ yếu là:

  • DOMPurify xử lý sai policy
  • một số tag/attr bị giữ lại không đúng ý muốn
  • nhưng chưa chắc đã tới XSS

Nói ngắn gọn: sanitize sai chưa tự động đồng nghĩa với exploit được.

Trường hợp 2: leo thành XSS

Bug này thành XSS khi hội đủ mấy điều kiện sau:

  1. Ứng dụng không dùng default config an toàn, mà bật CUSTOM_ELEMENT_HANDLING khá thoáng.
  2. tagNameCheck cho qua rộng, ví dụ regex kiểu /.+/ hoặc callback gần như return true.
  3. attributeNameCheck cũng cho qua rộng, nên các attribute nguy hiểm như onclick không bị strip.
  4. Attacker kiểm soát được HTML đầu vào và chèn được một reserved name như font-face, color-profile, ...
  5. Browser hoặc app có đường thực thi cho attribute đó. Ví dụ đơn giản nhất là onclick, tức dạng XSS cần tương tác người dùng.

Tức là chuỗi logic là:

  • font-face bị nhận nhầm là custom element
  • custom element path cho element đi qua
  • custom element path cho luôn attribute đi qua
  • onclick còn sống sau sanitize
  • user click vào node đó
  • JavaScript chạy

Đến đây thì nó không còn là policy bug nữa, mà là XSS.

Điểm rất đáng nói

Nếu app không bật CUSTOM_ELEMENT_HANDLING theo kiểu permissive, thì bug này thường không thành XSS. Đây cũng là lý do phải nói impact cho chuẩn:

  • Không phải mọi project dùng DOMPurify 3.4.0 đều dính XSS ngay.
  • Nhưng mọi project dùng 3.4.0 mở custom-element policy quá rộng thì có một bề mặt bypass rất đáng lo.

Một PoC tối giản để nhìn ra vấn đề

PoC này chỉ để minh họa đúng logic của diff 3.4.0 -> 3.4.1, không phải để tấn công thực tế.

<!doctype html>
<meta charset="utf-8">
<title>DOMPurify 3.4.0 vs 3.4.1 - reserved name test</title>
<script src="purify.min.js"></script>
<script>
  const dirty = '<font-face onclick="alert(1)">click me</font-face>';

  const cfg = {
    CUSTOM_ELEMENT_HANDLING: {
      tagNameCheck: /.+/,
      attributeNameCheck: /.+/,
      allowCustomizedBuiltInElements: true
    }
  };

  const clean = DOMPurify.sanitize(dirty, cfg);
  console.log(clean);
  document.body.innerHTML = clean;
</script>

Cách đọc PoC này:

  • Trên 3.4.0, font-face có thể bị coi nhầm là custom element.
  • Vì config quá thoáng, element đi qua.
  • onclick cũng có thể đi qua cùng nhánh custom-element handling.
  • Nếu browser render node đó và user click, bạn có execution.

Trên 3.4.1, case này phải bị chặn vì font-face đã bị đưa vào RESERVED_CUSTOM_ELEMENT_NAMES.

Lưu ý quan trọng:

  • Đây không phải default behavior của DOMPurify.
  • Đây là bug xuất hiện khi app đã mở custom-element policy quá rộng.
  • Với payload kiểu onclick, đây thường là XSS cần tương tác.

3.4.1 vá như thế nào?

Bản 3.4.1 làm ba việc rất đúng chỗ:

  1. Thêm hẳn một set RESERVED_CUSTOM_ELEMENT_NAMES thay vì hard-code riêng annotation-xml.
  2. Lowercase tagName trước khi lookup, để đóng luôn khe mixed-case trong XHTML.
  3. Đổi _isBasicCustomElement sang trả về boolean rõ ràng bằng regExpTest(...), thay vì trả kết quả stringMatch(...).

Bản thân diff này chưa phải là tất cả. Cái đáng giá hơn là maintainer còn thêm regression test trong test/test-suite.js, ví dụ:

  • CUSTOM_ELEMENT_HANDLING rejects all spec-reserved names
  • CUSTOM_ELEMENT_HANDLING reserved-name check is case-insensitive (HTML)
  • CUSTOM_ELEMENT_HANDLING reserved-name check is case-insensitive (XHTML)

Tức là sau bản vá, họ không chỉ sửa code, mà còn đóng luôn đường regression.


Kết luận

Nếu phải tóm lại bug này trong một câu, mình sẽ nói thế này:

3.4.0 đã mở nhầm cánh cửa dành cho custom elements với một nhóm tên mà HTML spec bảo là không được đi qua cánh cửa đó.

Và khi cánh cửa đó mở nhầm trong một cấu hình CUSTOM_ELEMENT_HANDLING quá permissive, hậu quả không dừng ở chuyện sanitize hơi sai. Nó có thể tiến thẳng tới việc giữ lại on* handler, tức là đi vào territory của XSS.

3.4.1 vá đúng vào gốc lỗi:

  • nhận diện đúng reserved names
  • xử lý case-insensitive
  • và thêm regression test để khóa lại hành vi đó

Đây là kiểu bug rất đáng học, vì nó cho thấy một điều quen mà nhiều người vẫn hay bỏ qua:

  • security bug không nhất thiết phải nằm ở chỗ regex URI hay strip <script>
  • đôi khi nó bắt đầu từ một bước classification tưởng rất nhỏ
  • và chỉ đến khi classification sai lọt vào nhánh allow-list đặc biệt, mọi thứ mới nổ ra thành XSS


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.