Có một sự thật khó chịu về URL mà lập trình viên học đi học lại: chuỗi ký tự trông giống một đường dẫn không có nghĩa nó là một đường dẫn. //evil.com mở đầu bằng dấu gạch chéo y như /trang-chu, nhưng với trình duyệt hai thứ đó khác nhau một trời một vực — cái đầu là một tên miền mới, cái sau là một trang trong nhà bạn. Đây là chỗ mà phép kiểm "chỉ cho URL bắt đầu bằng /" sụp đổ, và nó là cả một họ lỗ hổng mang tên chuyển hướng mở. Bài này tôi đo bằng trình duyệt thật, rồi nối nó với một cái bẫy anh em — rò rỉ qua header Referer — và đo xem trình duyệt hiện đại đã vá được bao nhiêu.

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

Open redirect: khi "bắt đầu bằng /" là không đủ

Ứng dụng có route /di?next=... chuyển hướng tới giá trị next. Câu hỏi: kẻ tấn công có đưa nạn nhân ra host khác không? Tôi thử với Chromium thật và xem URL cuối cùng trong thanh địa chỉ:

next URL cuối trong trình duyệt
/dich http://app:5000/dich — nội bộ
//evil:5001/gia-mao http://evil:5001/gia-mao — ra ngoài!

Cách chặn ngây thơ thường gặp là "chỉ cho phép URL bắt đầu bằng /". Nhưng //evil:5001/gia-mao bắt đầu bằng / — và trình duyệt hiểu nó là một URL theo giao thức tương đối, nơi // mở đầu một tên miền mới. Phép kiểm ngây thơ cho nó qua, và trình duyệt thật điều hướng ra host của kẻ tấn công. Các biến thể khác cũng vượt được: /\evil.com (dấu gạch chéo ngược mà nhiều trình duyệt chuẩn hoá thành //), https://evil.com trực tiếp, javascript:.... Tôi thử ba cách xử lý next trên năm payload:

Payload v0 không kiểm v1 kiểm "bắt đầu /" v2 danh sách trắng
/ho-so nội bộ nội bộ nội bộ
https://evil.com ra ngoài nội bộ nội bộ
//evil.com ra ngoài ra ngoài nội bộ
/\evil.com ra ngoài ra ngoài nội bộ
javascript:alert(1) ra ngoài nội bộ nội bộ

v1 chặn được URL tuyệt đối và javascript:, nhưng thủng với // và /\ — đúng cái bẫy phổ biến nhất. Chỉ v2 — chỉ chấp nhận các đường dẫn nội bộ nằm trong một danh sách định trước — chặn sạch. Lại là bài học danh sách trắng thắng danh sách chặn, lần này áp cho URL đích. Và tôi muốn nói rõ một điều: v1 không phải viết cẩu thả — "phải bắt đầu bằng /" là một trực giác hợp lý, nó chặn được ba trong năm payload. Nó thua đúng ở chỗ trực giác không lường: // cũng bắt đầu bằng / nhưng mang nghĩa hoàn toàn khác với trình duyệt. Đây là lý do kiểm URL bằng khớp chuỗi luôn mong manh — cú pháp URL có quá nhiều lối vào mà một phép kiểm tiền tố không thể bao hết.

Cái bẫy Referer: một nửa đã được vá

Open redirect thường đi kèm một rò rỉ tinh tế hơn. Khi trình duyệt tải một tài nguyên từ trang khác, nó gửi header Referer cho biết trang hiện tại. Nếu trang hiện tại có bí mật trong URL — như app.com/reset?token=... — bí mật đó có thể rò sang trang ngoài. Tôi đo header Referer mà một trang ngoài thật sự nhận được, qua bốn cấu hình:

Chính sách của trang chứa token Referer mà trang ngoài nhận
Mặc định (trình duyệt hiện đại) http://app:5000/ — chỉ origin, không token
Same-origin (tài nguyên cùng site) .../reset-same?token=BI-MAT-... — rò đầy đủ
Referrer-Policy: unsafe-url .../reset-unsafe?token=BI-MAT-... — rò đầy đủ
Referrer-Policy: no-referrer (không có) — không rò gì

Dòng đầu là một bất ngờ dễ chịu: với chính sách mặc định (strict-origin-when-cross-origin, mặc định từ khoảng 2020), khi gửi Referer cross-origin, trình duyệt tự cắt bỏ đường dẫn và query, chỉ gửi origin trần http://app:5000/ — token trong query không rò sang trang ngoài. Đây là một chiến thắng của phòng thủ mặc định: hàng triệu trang được bảo vệ khỏi rò token qua Referer mà không đổi một dòng mã. Nhưng "một nửa" nghĩa là token vẫn rò trong hai trường hợp thật. Nếu tài nguyên tải từ cùng site (same-origin), Referer gửi đầy đủ — token đi kèm. Và nếu ai đó đặt Referrer-Policy: unsafe-url (đôi khi để phân tích lưu lượng), token rò cross-origin như thời xưa. Nên bài học gốc vẫn nguyên giá trị: đừng bao giờ đặt bí mật trong URL — trình duyệt hiện đại giảm rủi ro chứ không xoá nó, và bạn không kiểm soát được trình duyệt của mọi người dùng, cũng không nên phụ thuộc vào một mặc định có thể bị ghi đè.

Đáng chú ý là hai lỗ này ghép thành một combo. Nếu ứng dụng có open redirect và đặt token trong URL, kẻ tấn công dùng redirect để đưa nạn nhân (đang mang token) sang trang của nó. Dù Referer cross-origin hiện chỉ rò origin, chính cú điều hướng đã là vấn đề — nạn nhân giờ ở trang giả mạo, và mọi kỹ thuật phishing tiếp theo bắt đầu từ đó.

Cả hai lỗ có chung một gốc với mọi bài trong sê-ri: tin một giá trị do người dùng hoặc ngữ cảnh cung cấp mà không kiểm. Open redirect tin tham số next là một đích an toàn — cách chặn là danh sách trắng đích, không kiểm bằng tiền tố chuỗi (dễ vượt như đã đo). Cái bẫy Referer tin rằng URL là nơi kín đáo để mang trạng thái — cách chặn là để bí mật đi trong thân request hoặc cookie, không trong URL, vì URL bị ghi vào log, lịch sử, và header Referer.

Ra thực tế thì nhớ bốn điều. Với tham số redirect, dùng danh sách trắng đích — hoặc dùng một mã tra cứu (next=trang-chu) thay vì URL trực tiếp; đừng kiểm bằng "bắt đầu bằng /". Không đặt bí mật trong URL: token đặt lại mật khẩu nên đi trong đường dẫn dùng-một-lần rồi hết hạn nhanh, và tốt nhất không xuất hiện ở các trang nhúng tài nguyên ngoài. Đặt Referrer-Policy chặt (strict-origin-when-cross-origin, hoặc no-referrer cho trang nhạy cảm) một cách tường minh, đừng dựa vào mặc định. Và cảnh báo người dùng khi rời site, hoặc chèn một trang trung gian "bạn đang rời khỏi..." cho các redirect ra ngoài không tránh được.

Mẫu số chung

Cái lõi của lỗ này là một sai lầm cụ thể về URL: xử lý một URL bằng cách khớp chuỗi thay vì phân tích (parse) nó thành cấu trúc rồi đọc đúng thành phần — và nó lặp ở mọi ngôn ngữ.

Trong thế giới Java, đây là khác biệt giữa next.startsWith("/") (mong manh) và dùng URI.create(next) rồi kiểm getHost() == null && !next.startsWith("//"), hay tốt hơn là so đích với một danh sách trắng. Spring có sẵn cái bẫy này trong RedirectView và các luồng "redirect sau đăng nhập", nên các dự án cẩn thận luôn chỉ lấy phần đường dẫn và bắt buộc bắt đầu bằng đúng một dấu gạch chéo — vì //evil.com/x là URL theo giao thức tương đối, trình duyệt đi thẳng ra ngoài.

Trong Go, net/url.Parse cho bạn cấu trúc thật: kiểm u.Host == "" && u.Scheme == "" mới là an toàn, còn strings.HasPrefix(next, "/") thì dính đúng payload //evil.com. Cùng bài học "parse, đừng match" mà html/template và net/url của Go được thiết kế để đẩy bạn về.

Còn ở cơ sở dữ liệu, cùng nguyên tắc "phân tích cấu trúc thay vì khớp chuỗi" chính là lý do prepared statement thắng việc lọc chuỗi SQL, và là lý do bạn không nên tự ghép rồi kiểm một định danh bảng/cột bằng regex mà nên so với một danh sách cột hợp lệ đã biết. Chuỗi do bên ngoài đưa vào, dù để làm URL đích hay tên bảng, đều phải được diễn giải đúng loại rồi đối chiếu với cái được phép, không phải sàng qua vài phép so tiền tố.

Mẫu số chung: một chuỗi trông-giống-thứ-an-toàn không phải là thứ an toàn — chỉ khi bạn phân tích nó thành cấu trúc thật (host, scheme, path) và đối chiếu với một danh sách cái được phép, bạn mới thật sự biết nó dẫn đi đâu. Vì thế câu hỏi đúng khi nhận một URL (hay bất kỳ chuỗi có cú pháp nào) từ bên ngoài không phải "nó có khớp mẫu an toàn của tôi không" mà là "khi trình phân tích thật đọc nó, host và scheme ra là gì" — và nếu bạn chưa parse, bạn chưa biết.

Muốn tự soi trong ba mươi giây, tìm trong ứng dụng của bạn tham số điều khiển chuyển hướng — thường tên là next, redirect, return_to, url, continue — rồi thử đặt giá trị //example.com (trên môi trường thử). Nếu trình duyệt điều hướng tới example.com, bạn có một open redirect mà cách kiểm "bắt đầu bằng /" không bắt được. Và trong lúc đó, nhìn thanh địa chỉ các trang nhạy cảm — trang đặt lại mật khẩu, xác nhận email: có token hay bí mật nào nằm trong URL không? Nếu có, nó đang đi vào lịch sử trình duyệt và log máy chủ, dù Referer nay đã kín hơn xưa.