Có một cách kiểm tra XSS rất phổ biến mà cũng rất dễ đánh lừa: mở HTML trả về, thấy <script> của mình nằm trong đó, rồi kết luận "trang này thủng". Nhưng payload nằm trong HTML với payload chạy được là hai chuyện khác nhau — như tìm thấy một que diêm trong túi áo với việc que diêm ấy đang cháy. Nên trong bài này tôi bỏ hẳn cách nhìn HTML. Tôi bật một trình duyệt Chromium thật, cho nó nạp trang, và ngồi chờ đúng một tín hiệu: hộp thoại alert() có bật lên không. Nếu có, mã của kẻ tấn công đã thực thi — không còn chỗ cho suy diễn.

Cross-site scripting (XSS) là khi kẻ tấn công chèn được JavaScript chạy trong trình duyệt của người dùng khác. Đó là ranh giới giữa "trông có vẻ nguy hiểm" và "đã bị chiếm", và cả bài này xoay quanh việc đo cho bằng được cái ranh giới đó.

Toàn bộ chạy trên một ứng dụng tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.

Hai loại XSS, khác nhau ở một chỗ chí mạng

  • 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 dòng hồ sơ — rồi bật ra với mọi người mở trang. Không cần dụ ai bấm gì.

Ứng dụng của tôi 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. Đúng một biến độc lập để so.

Đo bằng trình duyệt thật

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

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 rồi.

Có một chi tiết về cách đo XSS lưu trữ mà tôi muốn kể kỹ, vì nó là chỗ nhiều người mô phỏng ẩu. Kịch bản của tôi tách vai rạch ròi. Kẻ tấn công gửi đúng một request POST chứa <script> vào ô bình luận — xong việc, biến mất. 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 một GET sạch, không mang theo payload nào trong URL. Script vẫn chạy. Điều này chứng minh payload đã nằm sẵn trên máy chủ, chứ không đến từ hành động của nạn nhân — đúng cái làm nên chữ "lưu trữ".

Vì sao XSS lưu trữ đáng sợ hơn hẳn

Số đo nói rất rõ điều này. Với phản chiếu, kẻ tấn công phải gửi link độc cho từng nạn nhân rồi ngồi chờ họ bấm — một quy trình chậm, dễ hỏng, đòi hỏi nạn nhân hợp tác. 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 — không bấm gì, không nhập gì — và script chạy ngay tức khắc. 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, dù trên lý thuyết cả hai đều là "XSS".

Một chuyện nữa đáng gằn giọng: <img onerror> vượt qua mọi bộ lọc chỉ đi tìm chữ script. Payload thứ hai trong bảng không chứa từ "script" nào cả — 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 cố chặn XSS bằng cách rình chuỗi <script> sẽ để lọt nó trọn vẹn. Đây đúng là XSS song song với chuyện danh sách đen ở các bài injection trước: bạn cấm một hình dạng, kẻ tấn công đổi hình dạng, và bạn thua.

Còn autoescape thắng vì nó chơi một trò khác hẳn: nó vô hiệu hoá ký tự, chứ 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 thay vì phân tích nó thành thẻ. Nó chẳng cần biết <img onerror> hay <svg onload> có nguy hiểm không — nó chỉ cần biến mọi dấu ngoặc nhọn thành ký tự vô hại. Chính vì thế nó chặn được cả những payload nó chưa từng thấy bao giờ.

Chỗ autoescape không đủ — và tôi suýt kết luận sai

Đây là phần dễ tự tin nhầm nhất. Nhìn cái bảng trên, phản xạ tự nhiên là gật gù "bật autoescape là xong chuyện". Tôi đã suýt dừng ở đó. Nhưng rồi 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 y hệt:

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 đủ mà vẫn để lọt. Lý do cốt lõi làm tôi ngồi lặng một lúc: payload javascript:alert() không chứa lấy một ký tự HTML đặc biệt nào. Không <, không >, không dấu nháy cần thoát. Autoescape nhìn vào chẳ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: — và thế là xong.

Đây là đúng cái bài học từ SQL injection, chỉ đổi sân: 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". Ở href và src, 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à thẳng tay từ chối javascript:.

Một sắc thái tôi thấy phải nói cho 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 nó không thoát ra được.

Nhưng "tình cờ đủ" không phải là "an toàn theo thiết kế", và tôi không muốn ai đọc bài này rồi trông cậy vào sự may mắn đó. Trong ngữ cảnh JavaScript, một payload khác vẫn có thể lách qua, và bạn không nên dựa 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ả — và biết ranh giới đó nằm ở đâu mới là phần khó.

Nghĩa là gì trong công việc hằng ngày

  • Bật autoescape, và đừng bao giờ tắt nó cho một biến cụ thể chỉ 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: và data:.
  • Đừng lọc <script> rồi 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 nhiều người xem, lỗ càng nặng.

Cùng một bài học, đổi hệ sinh thái

Cái lõi của XSS — thoát theo ngữ cảnh — không phải chuyện riêng của Jinja2, và nhìn sang các nền tảng khác thấy rõ ai đã học được bài này, ai chưa.

Trong thế giới Java, các template engine hiện đại đã đưa "escape theo ngữ cảnh" vào tận thiết kế. Thymeleaf th:text HTML-escape mặc định; JSP cổ điển thì <c:out> escape còn EL trần ${...} lại không, và đó là nguồn XSS kinh điển của Java suốt bao năm. Đáng chú ý nhất là Google Closure Templates với chế độ autoescape theo ngữ cảnh: nó phân tích xem biến rơi vào HTML, vào href, hay vào chuỗi JS, rồi tự chọn kiểu thoát — chính là để bịt đúng cái lỗ href mà tôi vừa đo.

Trong Go, html/template của thư viện chuẩn làm đúng thứ tôi ước Jinja2 làm: nó cũng autoescape theo ngữ cảnh. Đặt một biến vào href="{{.}}" mà giá trị là javascript:..., Go sẽ tự lọc và vô hiệu hoá nó — khác hẳn text/template vốn không biết gì về HTML. Đây là ví dụ đẹp của "an toàn theo mặc định" đưa vào thẳng thư viện chuẩn.

Còn ở cơ sở dữ liệu, họ hàng gần nhất của bài học này là ranh giới giữa lưu trữ và hiển thị. CSDL lưu nguyên văn <script> mà không phàn nàn — và nó nên thế, vì nhiệm vụ của nó là giữ dữ liệu trung thực, không phải phán xét nội dung. Sai lầm chết người là nghĩ "đã lọc lúc ghi vào là xong": nếu bạn escape khi ghi thay vì khi đọc ra, bạn vừa làm hỏng dữ liệu vừa vẫn thủng ở chỗ hiển thị khác. Đúng cột dữ liệu đó có thể được đọc ra HTML, ra JSON, ra một file CSV — mỗi nơi cần một kiểu thoát riêng.

Mẫu số chung: dữ liệu không mang theo ngữ cảnh của nó; ngữ cảnh do nơi bạn đặt dữ liệu quyết định. Cùng một chuỗi vô hại trong HTML lại chết người trong href, vô hại trong CSDL lại chết người khi bung ra trang. Nên câu hỏi đúng không bao giờ là "chuỗi này có sạch không" mà là "tôi đang đặt nó vào đâu, và ngữ cảnh đó đòi kiểu thoát nào".

Nếu muốn tự thấy tận mắt, hãy đi 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ử nhét 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 vừa tìm thấy một lỗ XSS mà autoescape chẳng bao giờ chạm tới, đơn giản vì nó không được sinh ra cho ngữ cảnh URL.