Chuyển đến nội dung chính
  1. Nghiên cứu & Ghi chép kỹ thuật/

Lỗ hổng Cross-site scripting - Stored

·2648 từ·13 phút
Nguyễn Hoàng Thanh Phong
Tác giả
Nguyễn Hoàng Thanh Phong
Sinh viên năm cuối ngành An toàn thông tin tại Đại học FPT. Chuyên nghiên cứu khai thác lỗ hổng bảo mật Web, kiến trúc bảo mật AWS và các công cụ tự động hóa
Mục lục
Web Security IAW301 - Bài viết này là một phần của loạt bài.
Phần 15: Bài viết này

Hỏi & Đáp
#

1. Điều gì định nghĩa Stored Cross-Site Scripting (XSS), và nó khác biệt như thế nào so với Reflected XSS và DOM-Based XSS?
#

Stored Cross-Site Scripting, còn được gọi là persistent XSS (XSS lưu trữ tĩnh) hoặc second-order XSS (XSS bậc hai), xảy ra khi ứng dụng nhận input không đáng tin cậy, lưu trữ nó ở phía server, và sau đó đưa dữ liệu đã lưu trữ này vào HTTP response hoặc luồng render phía client mà không xử lý đầu ra an toàn. Các vị trí lưu trữ điển hình bao gồm bình luận blog, hồ sơ người dùng, tin nhắn chat, đánh giá sản phẩm, ghi chú ticket, hoặc bất kỳ nội dung nào dựa trên database sẽ được hiển thị cho người dùng khác sau này.

Đặc điểm chính của Stored XSS là tính bền vững (persistence). Kẻ tấn công không cần lừa từng nạn nhân mở một URL độc hại được chế tạo sẵn. Thay vào đó, kẻ tấn công chỉ cần gửi payload độc hại một lần, ứng dụng sẽ lưu trữ nó, và payload sẽ thực thi bất cứ khi nào một người dùng khác xem trang hoặc thành phần bị ảnh hưởng.

Sự khác biệt giữa 3 loại hình XSS chính có thể được tóm tắt như sau:

Loại XSSNguồn gốc của input độc hạiThời điểm trả về hoặc thực thiVí dụ điển hình
Reflected XSSHTTP request hiện tạiTrả về ngay lập tức trong cùng một responseTham số tìm kiếm bị reflect vào trang kết quả mà không được encode.
Stored XSSDữ liệu được lưu phía server, ví dụ như bản ghi trong databaseVề sau, khi người dùng xem nội dung bị ảnh hưởngMột bình luận blog độc hại được lưu lại và thực thi với mọi khách truy cập mở bài viết đó.
DOM-Based XSSJavaScript phía client xử lý dữ liệu do kẻ tấn công kiểm soát một cách không an toànTrong DOM của trình duyệt, thường thông qua một “sink” nguy hiểmJavaScript đọc dữ liệu và ghi vào innerHTML, dẫn đến việc thực thi HTML hoặc JavaScript.

Trong bài lab này, lỗ hổng cụ thể hơn là Stored DOM XSS. Bình luận độc hại được ứng dụng lưu trữ, trả về sau đó thông qua endpoint tải bình luận, và tiếp tục bị JavaScript phía client xử lý không an toàn trước khi chèn vào DOM.

2. Giải thích cách các script độc hại được inject, lưu trữ và sau đó thực thi trong các cuộc tấn công Stored XSS. Các hậu quả tiềm ẩn là gì?
#

Một cuộc tấn công Stored XSS thường diễn ra theo trình tự sau:

  1. Injection (Tiêm nhiễm): Kẻ tấn công xác định một trường input mà giá trị của nó được ứng dụng lưu lại. Trong bài lab này, mục tiêu là trường bình luận blog.
  2. Storage (Lưu trữ): Kẻ tấn công submit một payload chứa HTML hoặc JavaScript. Backend chấp nhận bình luận và lưu nó như nội dung bình thường của người dùng.
  3. Retrieval (Truy xuất): Một người dùng khác, hoặc chính kẻ tấn công, sau đó mở trang bị ảnh hưởng. Ứng dụng truy xuất bình luận đã lưu và trả về cho trình duyệt.
  4. Unsafe rendering (Render không an toàn): Ứng dụng hoặc JavaScript phía client của nó chèn giá trị đã lưu vào trang mà không có cơ chế mã hóa đầu ra an toàn theo ngữ cảnh (context-aware output encoding).
  5. Execution (Thực thi): Trình duyệt diễn giải (interpret) một phần giá trị đã lưu dưới dạng HTML hoặc JavaScript đang hoạt động (active). Payload chạy trong ngữ cảnh bảo mật của trang web có lỗ hổng.

Hậu quả phụ thuộc vào ứng dụng và đặc quyền của nạn nhân. Một lỗ hổng Stored XSS khai thác thành công có thể cho phép kẻ tấn công chiếm đoạt phiên (hijack user sessions), đánh cắp thông tin nhạy cảm, thực hiện các hành động dưới quyền nạn nhân, đánh cắp thông tin đăng nhập (credentials), chèn nội dung phishing, thay đổi giao diện (deface), hoặc tấn công tài khoản quản trị. Stored XSS đặc biệt nguy hiểm vì nó tự thân nằm gọn bên trong ứng dụng: một khi payload được lưu trữ, nạn nhân có thể kích hoạt nó chỉ bằng cách duyệt đến một trang web hợp lệ.


Khai thác Stored DOM XSS
#

Mục tiêu Lab
#

Mục tiêu của bài lab PortSwigger Web Security Academy này là khai thác lỗ hổng Stored DOM XSS trong chức năng bình luận của blog và gọi hàm alert(). Lỗ hổng tồn tại vì ứng dụng lưu trữ bình luận của người dùng và sau đó render chúng thông qua đoạn mã JavaScript phía client có lỗ hổng.

Bước 1: Mở trang chủ của lab
#

Đầu tiên, tôi mở instance của bài lab. Trang chủ hiển thị một ứng dụng dạng blog với một số bài viết. Ở giai đoạn này, trạng thái lab vẫn là Not solved.

Bước 2: Mở một bài viết và tìm form bình luận
#

Tiếp theo, tôi mở một trong các bài viết. Trang này chứa một khu vực bình luận nơi người dùng có thể submit comment, tên, email và website. Form này là bề mặt tấn công (attack surface) chính vì các bình luận được lưu trữ và sau đó hiển thị ngay dưới cùng bài viết.

Từ góc độ kiểm thử XSS, form bình luận này là một entry point (điểm vào) quan trọng. Nếu ứng dụng lưu trữ giá trị được submit và sau đó hiển thị nó mà không encode an toàn, bình luận có thể biến thành một payload Stored XSS.

Bước 3: Kiểm tra mã nguồn HTML và xác định script tải bình luận
#

Tôi kiểm tra source code của trang và nhận thấy các bình luận không được render hoàn toàn dưới dạng HTML tĩnh phía server. Thay vào đó, trang tải một file JavaScript có tên loadCommentsWithVulnerableEscapeHtml.js và sau đó gọi hàm loadComments().

Phần source code liên quan của trang là:

1
2
3
4
<span id="user-comments">
    <script src="/resources/js/loadCommentsWithVulnerableEscapeHtml.js"></script>
    <script>loadComments('/post/comment')</script>
</span>

Điều này cho thấy các bình luận được truy xuất động (dynamically) từ endpoint /post/comment và được JavaScript render vào trang. Do đó, lỗ hổng rất có thể nằm ở logic render bình luận phía client thay vì chỉ nằm trong phản hồi HTML ban đầu.

Bước 4: Phân tích đoạn JavaScript có lỗ hổng
#

Tôi mở file JavaScript và tìm thấy hàm escapeHTML(). Hàm này cố gắng encode các dấu ngoặc nhọn (angle brackets) trước khi chèn dữ liệu bình luận vào DOM.

Logic bị lỗi là:

1
2
3
function escapeHTML(html) {
    return html.replace('<', '&lt;').replace('>', '&gt;');
}

Đây có vẻ như là một biện pháp phòng thủ output-encoding, nhưng nó chưa hoàn thiện. Trong JavaScript, khi hàm replace() được gọi với đối số là một chuỗi (string), nó chỉ thay thế lần xuất hiện đầu tiên khớp với chuỗi đó. Hậu quả là, dấu < đầu tiên và dấu > đầu tiên bị mã hóa, nhưng bất kỳ dấu ngoặc nhọn nào phía sau vẫn giữ nguyên.

Ví dụ, nếu bình luận được submit là:

1
<><img src=1 onerror=alert(1)>

Hàm chỉ vô hiệu hóa cặp dấu ngoặc nhọn đầu tiên:

1
&lt;&gt;<img src=1 onerror=alert(1)>

Cặp <> đầu tiên trở thành văn bản vô hại, nhưng thẻ <img> phía sau vẫn là một HTML hợp lệ. Khi trình duyệt phân tích (parse) bình luận được render, phần tử img được tạo ra và event handler onerror sẽ thực thi do src=1 không hợp lệ.

Điều này tạo ra một luồng Stored DOM XSS:

1
Form bình luận -> lưu trữ bình luận -> phản hồi /post/comment -> render bằng JavaScript -> chèn DOM không an toàn -> thực thi JavaScript

Bước 5: Kiểm thử một payload script cơ bản
#

Đầu tiên, tôi test một payload script đơn giản trong trường bình luận:

1
<script>alert('comment')</script>

Payload <><script>alert('comment')</script> được gửi đi để kiểm tra xem trường bình luận có thể lưu trữ và render các input giống HTML hay không. Khi bình luận được hiển thị, output nhìn thấy có vẻ không hoàn chỉnh vì thẻ đóng </script> không xuất hiện dưới dạng plain text. Điều này không có nghĩa là server đã xóa nó. Thay vào đó, trình duyệt đã phân tích <script>alert('comment')</script> thành một phần tử script thực sự sau khi các dấu <> đầu tiên đã bị “tiêu thụ” (consumed) bởi hàm escaping bị lỗi. Tuy nhiên, payload này vẫn không giải quyết được lab vì các phần tử script được chèn thông qua quá trình render DOM sẽ bị coi là trơ (inert) và không được thực thi. Do đó, bài test này xác nhận input đã được lưu và render sau đó, nhưng nó cũng cho thấy cần phải có một phần tử HTML khác đi kèm một event handler có khả năng thực thi.

Bước 6: Kiểm thử xem thẻ HTML thứ hai có thể hoạt động không
#

Sau khi xác nhận payload <script> thông thường không thực thi, tôi test xem liệu cơ chế escaping yếu kém có thể bị bypass bằng cách thêm một cặp dấu ngoặc nhọn vô hại đầu tiên trước một phần tử HTML khác hay không.

Payload kiểm thử là:

1
2
3
<><svg width="400" height="180" onload="alert('comment')">
    <rect x="50" y="20" rx="20" ry="20" width="150" height="150" style="fill:red;stroke:black;stroke-width:5;opacity:0.5"></rect>
</svg>

Kết quả khớp với hành vi của JavaScript có lỗ hổng. Dấu <> đầu tiên bị coi là văn bản vô hại vì hàm escapeHTML() chỉ encode dấu <> đầu tiên. Tuy nhiên, thẻ <svg> theo sau vẫn được chèn vào DOM như một phần tử HTML/SVG thực sự.

Trong Developer Tools, bình luận được render đại khái như sau:

1
2
3
4
5
6
<p>
    "&lt;&gt;"
    <svg width="400" height="180" onload="alert('comment')">
        <rect ...></rect>
    </svg>
</p>

Điều này xác nhận payload không còn chỉ hiển thị dưới dạng văn bản. Trình duyệt đã tạo ra một phần tử SVG thực sự và event handler onload có thể thực thi JavaScript.

Bước 7: Xây dựng payload Stored DOM XSS cuối cùng
#

Dựa trên logic replace() bị lỗi, kỹ thuật bypass là đặt một cặp dấu ngoặc nhọn vô hại đầu tiên lên trước phần tử HTML thực thi thực sự.

Cấu trúc của payload là:

1
2
3
<>            -> bị vô hiệu hóa bởi hàm escaping yếu kém
<svg ...>     -> vẫn hoạt động như một markup thực sự
onload=...    -> thực thi JavaScript khi phần tử SVG load

Trong bài lab này, tôi sử dụng payload dựa trên event-handler thay vì dựa vào thẻ <script>, bởi vì các thẻ script được chèn qua quá trình render DOM không thực thi một cách đáng tin cậy trong ngữ cảnh này.

Một định dạng payload hoạt động được là:

1
<><svg onload=alert('comment')>

hoặc, theo cùng nguyên lý với phần tử hình ảnh (image):

1
<><img src=1 onerror=alert(1)>

Hộp thoại cảnh báo (alert box) xuất hiện, chứng tỏ việc thực thi JavaScript đã đạt được thông qua bình luận được lưu trữ. Tuy nhiên, tại thời điểm này banner của lab vẫn hiển thị Not solved, do đó ảnh chụp màn hình này nên được coi là bằng chứng về việc thực thi mã thành công, chứ chưa phải là trạng thái hoàn thành lab cuối cùng.

Bước 8: Kích hoạt payload đã lưu trữ
#

Sau khi đăng bình luận được chế tạo, tôi quay lại bài viết và đợi các bình luận load xong. Payload đã lưu trữ được ứng dụng truy xuất và chèn vào DOM bởi mã JavaScript có lỗ hổng. Sau đó, trình duyệt biên dịch thẻ <img> còn lại dưới dạng HTML và thực thi event handler.

Trong Developer Tools, DOM được render xác nhận rằng bình luận chứa một phần tử img thực sự với một event handler của JavaScript. Điều này chứng minh payload không còn chỉ là văn bản; nó đã trở thành một đoạn mã thực thi (executable markup) bên trong trang web.

Bước 9: Hoàn thành Lab
#

Ngay khi payload thực thi thành công, trạng thái lab chuyển thành Solved. Điều này xác nhận lỗ hổng Stored DOM XSS đã được khai thác thành công.


Phân tích nguyên nhân cốt lõi (Root cause analysis)
#

Nguyên nhân cốt lõi là hàm escaping HTML không hoàn thiện trong JavaScript phía client. Ứng dụng cố gắng làm sạch (sanitize) dữ liệu bình luận do người dùng kiểm soát bằng:

1
html.replace('<', '&lt;').replace('>', '&gt;')

Tuy nhiên, cách này chỉ thay thế lần xuất hiện đầu tiên của mỗi ký tự. Vì ứng dụng sau đó chèn giá trị đã xử lý vào DOM, kẻ tấn công có thể bypass bộ lọc bằng cách thêm một cặp dấu ngoặc nhọn phụ trước payload thực sự.

Lỗ hổng có thể được tóm tắt như sau:

Thuộc tínhMô tả
Loại lỗ hổngStored DOM Cross-Site Scripting
Điểm vào (Entry point)Form bình luận blog
Vị trí lưu trữNơi lưu trữ bình luận của ứng dụng
Nguồn (Source)Bình luận được lưu trữ trả về từ /post/comment
Đích (Sink)Việc chèn DOM sử dụng render phía client có lỗ hổng, chẳng hạn như innerHTML
Phòng thủ yếuCơ chế escaping ngoặc nhọn không hoàn chỉnh sử dụng replace() dạng chuỗi
Payload hoạt động<><img src=1 onerror=alert(1)>
Tác động trong labThực thi JavaScript thông qua hàm alert()

Khuyến nghị khắc phục (Remediation)
#

Để khắc phục lỗ hổng này, ứng dụng không nên dựa vào việc thay thế chuỗi (string replacement) không hoàn chỉnh như một biện pháp phòng thủ XSS. Các phương án khắc phục an toàn hơn bao gồm:

  1. Sử dụng các DOM API an toàn: Chèn bình luận của người dùng bằng textContent thay vì innerHTML khi không cần render HTML.
  2. Áp dụng output encoding theo ngữ cảnh (Context-aware output encoding): Encode tất cả các ký tự nguy hiểm theo đúng ngữ cảnh đầu ra (ví dụ: nội dung HTML, thuộc tính HTML, chuỗi JavaScript, URL hoặc CSS).
  3. Tránh dùng custom sanitizers: Không viết các hàm escaping tự chế như replace('<', '&lt;'). Chúng rất dễ bị bypass.
  4. Sử dụng thư viện HTML sanitizer được bảo trì tốt nếu bắt buộc phải dùng HTML: Nếu ứng dụng phải cho phép HTML an toàn, hãy sử dụng một công cụ làm sạch (sanitizer) đáng tin cậy và chỉ cho phép (allowlist) các thẻ và thuộc tính an toàn.
  5. Validate đầu vào khi tiếp nhận: Giới hạn các trường như tên, email và website theo định dạng mong đợi.
  6. Triển khai Content Security Policy (CSP) như một lớp phòng thủ chiều sâu: CSP có thể giảm thiểu tác động của XSS, nhưng nó không nên thay thế việc output encoding chuẩn xác và xử lý DOM an toàn.

Kết luận
#

Bài lab này minh chứng một lỗ hổng Stored DOM XSS gây ra bởi việc render các bình luận được lưu trữ ở phía client một cách không an toàn. Ứng dụng cố gắng ngăn chặn XSS bằng cách thay thế các dấu ngoặc nhọn, nhưng cơ chế phòng thủ chưa hoàn chỉnh vì nó chỉ thay thế lần xuất hiện đầu tiên của <>. Bằng cách gửi thêm một cặp <> vô hại phía trước payload thực sự, kẻ tấn công đã bypass được bộ lọc yếu kém này và chèn thành công một phần tử HTML thực thi.

Cấu trúc payload hoạt động cuối cùng là:

1
<><img src=1 onerror=alert(1)>

Payload này hoạt động vì cặp dấu ngoặc nhọn đầu tiên đã bị encode, trong khi thẻ <img> phía sau vẫn giữ trạng thái active và kích hoạt thực thi JavaScript trên trình duyệt của nạn nhân.

Web Security IAW301 - Bài viết này là một phần của loạt bài.
Phần 15: Bài viết này