Cross-site scripting (XSS) là khi kẻ tấn công chèn được mã JavaScript chạy trong trình duyệt của người dùng khác. Bài này không kiểm bằng cách nhìn xem payload có mặt trong HTML — đó là cách kiểm dễ gây nhầm. Tôi dùng một trình duyệt Chromium thật nạp trang và bắt sự kiện alert(): nếu hộp thoại bật lên, mã của kẻ tấn công đã thực thi. Đó là khác biệt giữa "trông có vẻ nguy hiểm" và "đã bị chiếm".

Toàn bộ chạy trên ứng dụng tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.

XSS phan chieu va luu tru: do bang trinh duyet that, va cho autoescape khong du

Hai loại XSS

  • Phản chiếu: payload nằm trong URL, được "phản chiếu" thẳng vào trang trả về. Kẻ tấn công phải dụ nạn nhân bấm một link đã cài sẵn.
  • Lưu trữ: payload được lưu vào cơ sở dữ liệu (một bình luận, một hồ sơ), rồi bật ra với mọi người xem trang. Không cần dụ ai bấm gì.

Ứng dụng có cả hai, mỗi loại hai phiên bản: v0 nối chuỗi input thẳng vào HTML, v1 đưa qua template Jinja2 có bật autoescape.

Số đo bằng trình duyệt thật

Ba lần chạy, kết quả giống hệt:

Kịch bản Payload v0 nối chuỗi v1 autoescape
Phản chiếu <script>alert()</script> script chạy an toàn
Phản chiếu <img src=x onerror=alert()> script chạy an toàn
Lưu trữ <script> trong bình luận script chạy khi chỉ xem trang an toàn

Cột v0 là cột đáng sợ. Mọi ô của nó, hộp thoại alert thật sự bật lên trong Chromium — nghĩa là nếu payload là mã trộm cookie thay vì alert, nó đã gửi phiên đăng nhập của nạn nhân đi.

Một chi tiết về cách đo lưu trữ đáng nói: kịch bản của tôi tách vai rõ ràng. Kẻ tấn công gửi một yêu cầu POST chứa <script> vào ô bình luận — chỉ một request, xong việc. Sau đó, một trình duyệt nạn nhân hoàn toàn riêng biệt mở trang bình luận bằng GET, không mang theo payload nào trong URL của nó. Script vẫn chạy. Điều này chứng minh payload đã nằm sẵn trên máy chủ, không phải đến từ hành động của nạn nhân — đúng bản chất của "lưu trữ".

Điều đáng nhớ

XSS lưu trữ nguy hiểm hơn hẳn, và số đo nói rõ vì sao. Với phản chiếu, kẻ tấn công phải gửi link độc cho từng nạn nhân và chờ họ bấm. Với lưu trữ, tôi chỉ gửi một bình luận chứa <script>, rồi con bot nạn nhân chỉ mở trang bình luận — không bấm gì, không nhập gì — và script chạy ngay. Một payload, mọi người xem trang đều dính. Đó là lý do một ô nhập bình luận không lọc là lỗ hổng nghiêm trọng hơn nhiều so với một ô tìm kiếm không lọc.

<img onerror> vượt qua mọi bộ lọc chỉ tìm chữ script. Payload thứ hai không chứa từ "script" nào — nó là một thẻ ảnh với nguồn hỏng, và trình duyệt chạy onerror khi ảnh không tải được. Bất kỳ bộ lọc nào chặn XSS bằng cách tìm chuỗi <script> sẽ để lọt nó hoàn toàn. Đây là XSS song song với danh sách đen ở các bài injection trước: cấm một hình dạng, kẻ tấn công đổi hình dạng.

Autoescape thắng vì nó vô hiệu hoá ký tự, không đoán payload. Jinja2 với autoescape đổi < thành &lt;, > thành &gt;, " thành &#34;. Trình duyệt hiển thị <script> như văn bản chứ không phân tích nó thành thẻ. Nó không cần biết <img onerror> hay <svg onload> nguy hiểm — nó chỉ cần biến mọi dấu ngoặc nhọn thành ký tự vô hại. Đó là lý do nó chặn được cả những payload nó chưa từng thấy.

Chỗ autoescape không đủ

Đây là phần dễ kết luận sai. Nhìn bảng trên, dễ tin "bật autoescape là xong". Tôi đo thêm một trang đặt biến người dùng vào thuộc tính href thay vì nội dung HTML:

<a href="{{ url }}">trang của bạn</a>

Payload: url = javascript:alert('XSS'). Kết quả, ba lần đều vậy:

Ngữ cảnh Payload Kết quả với autoescape bật
Nội dung HTML <script> an toàn (chặn)
Thuộc tính href javascript:alert() script chạy khi bấm link

Autoescape bật đầy đủ vẫn để lọt. Lý do cốt lõi: payload javascript:alert() không chứa một ký tự HTML đặc biệt nào. Không có <, không có >, không có dấu nháy cần thoát. Autoescape nhìn vào không thấy gì để làm, và chuỗi đi thẳng vào href. Khi nạn nhân bấm link, trình duyệt thực thi giao thức javascript:.

Đây là cùng một bài học từ bài SQL injection: một cách chặn đúng cho một ngữ cảnh không tự động đủ cho ngữ cảnh khác. Autoescape giải bài toán "chèn vào HTML"; nó không giải bài toán "chèn vào URL". Ở hrefsrc, cách chặn đúng là kiểm giao thức — chỉ cho phép http:, https:, hoặc đường dẫn tương đối, và từ chối javascript:.

Một sắc thái trung thực

Tôi cũng thử đặt biến vào trong một chuỗi JavaScript (var ten = "{{ ten }}"), và ở đó autoescape tình cờ đủ — payload dựa vào dấu nháy để thoát khỏi chuỗi, mà autoescape đã đổi cả " lẫn < thành thực thể, nên không thoát ra được.

Nhưng "tình cờ đủ" không phải "an toàn theo thiết kế". Trong ngữ cảnh JavaScript, một payload khác có thể lách qua, và không nên trông cậy vào autoescape HTML để bảo vệ mã JS. Nguyên tắc là thoát theo đúng ngữ cảnh nơi dữ liệu được đặt: HTML-escape cho HTML, URL-encode cho URL, JS-escape cho JavaScript. Autoescape của template lo được ngữ cảnh phổ biến nhất, không lo được tất cả.

Nghĩa là gì trong thực tế

  • Bật autoescape, và đừng bao giờ tắt nó cho một biến cụ thể vì "tôi tin dữ liệu này". Dữ liệu bạn tin hôm nay là dữ liệu người khác nhập vào ngày mai.
  • Kiểm giao thức cho mọi href/src nhận dữ liệu người dùng. Chỉ cho http:, https:, đường dẫn tương đối; chặn javascript:data:.
  • Đừng lọc <script> và tin là đã chặn XSS. <img onerror>, <svg onload>, và hàng chục biến thể khác không chứa chữ "script". Vô hiệu hoá ký tự (escape) luôn thắng liệt kê payload (blacklist).
  • Coi mọi ô nhập được hiển thị lại là XSS lưu trữ tiềm năng — bình luận, tên hiển thị, tiểu sử, tên tệp tải lên. Ô càng được nhiều người xem, lỗ càng nặng.

Chỗ tôi không kết luận được

Tôi đo với alert() làm bằng chứng thực thi. XSS thật dùng payload trộm cookie, ghi bàn phím, hoặc gửi yêu cầu thay mặt nạn nhân — nguy hiểm hơn nhiều nhưng cùng một cơ chế chèn. Content-Security-Policy là một lớp phòng thủ thứ hai quan trọng (chặn cả script inline lẫn nguồn ngoài) mà tôi không đo ở đây; nó xứng một bài riêng.

Và tôi chỉ thử ba ngữ cảnh: nội dung HTML, href, và chuỗi JS. Còn thuộc tính sự kiện (onclick), CSS, và DOM-based XSS (payload không bao giờ chạm máy chủ, chỉ chạy trong JavaScript phía client) — mỗi cái là một mặt trận riêng.

Thử ba mươi giây

Tìm trong template của bạn một chỗ đặt biến vào href hay src: <a href="{{ ... }}">. Autoescape của bạn đang bật, nhưng nó không cứu chỗ này.

Thử đặt giá trị javascript:alert(1) vào đúng biến đó trên môi trường thử. Nếu bấm link mà hộp thoại bật lên, bạn có một lỗ XSS mà autoescape không bao giờ chạm tới — vì nó không được sinh ra cho ngữ cảnh URL.