Chữ ký SAML hợp lệ. Nhưng đó chưa bao giờ là vấn đề.
SAML single sign-on có sẵn một vấn đề replay ngay trong cấu trúc của nó, và hầu hết các nhà cung cấp dịch vụ (service provider) lại "giải quyết" nó ở đúng nơi mà kẻ tấn công có thể chạm tới.
Bối cảnh như sau. Bạn là service provider. Một người dùng vào trang đăng nhập của bạn, bạn gửi một AuthnRequest tới identity provider của họ, và request đó mang theo một ID ngẫu nhiên. IdP xác thực người dùng rồi gửi ngược lại một SAML Response kèm InResponseTo="<that ID>". Bạn tra cứu ID đó, xác nhận đúng là mình đã phát hành nó, và tiêu thụ (consume) nó để nó không bao giờ dùng được lần thứ hai. Request được ràng với response, response chỉ dùng một lần, replay bị vô hiệu. Đúng sách giáo khoa.
Giờ đến câu hỏi làm sụp đổ tất cả: IdP thực sự đã ký cái gì?
Không phải Response. Mà là Assertion. Entra, Okta, và phần lớn các bên còn lại trong ngành đều ký phần tử <Assertion> rồi để nguyên lớp bao <Response> bên ngoài mà không ký. Điều đó là bình thường, và spec cho phép. Nhưng InResponseTo lại nằm trên <Response>. Vậy nên cái trường mà toàn bộ hàng phòng thủ replay của bạn dựa vào chính là trường duy nhất mà chữ ký không bao phủ. Bạn có thể sửa nó, xóa nó, làm gì tùy thích với nó, và chữ ký của assertion vẫn xác thực hoàn hảo, vì bạn chưa hề động vào assertion.
Vậy hãy xem chuyện gì xảy ra khi kẻ tấn công làm điều hiển nhiên.
SAML có hai kiểu. Response kiểu SP-initiated trả lời cho một request mà bạn đã gửi, nên chúng mang theo InResponseTo. Response kiểu IdP-initiated (không được yêu cầu) thì chẳng ai hỏi tới, nên chúng hoàn toàn không có InResponseTo. Code của bạn, một cách hợp lý, rẽ nhánh dựa trên điều này: nếu có InResponseTo, khớp nó với một request đã lưu và tiêu thụ nó; nếu không có, thì chẳng có request nào để khớp, nên nó bỏ qua bước kiểm tra đó. Chính cái bỏ qua ấy là toàn bộ lỗ hổng.
Cuộc tấn công gồm ba bước và không cần chút mật mã học nào:
- Bắt lấy một SAML Response thật, thành công. Một phiên đăng nhập thật, một chữ ký thật, một assertion thật.
- Xóa thuộc tính
InResponseTo. Chữ ký bao phủ assertion, không phải lớp bao, nên nó vẫn xác thực được. - Gửi nó ngược lại. Không có
InResponseTo, SP của bạn coi đó là IdP-initiated, đi vào nhánh không có kiểm tra replay, xác thực chữ ký (hợp lệ), rồi cho người dùng đăng nhập.
Rồi gửi lại lần nữa. Và lần nữa. Giờ assertion đã thành một chiếc chìa khóa vạn năng. Mọi kiểm tra mà bạn từng tự hào vẫn qua hết: chữ ký hợp lệ, chứng chỉ được ghim, issuer đúng, điều kiện còn hạn. Cái kiểm soát duy nhất lẽ ra chặn được replay lại gắn với một trường mà kẻ tấn công xóa đi chẳng tốn gì.
Đây là cái bẫy gói gọn trong một dòng: bạn ký assertion, nhưng lại chốt chặn dựa trên lớp bao.
Cách sửa không phải là kiểm tra nhiều hơn. Mà là kiểm tra đúng thứ cần kiểm tra. Đừng gắn cơ chế chống replay vào InResponseTo, thứ vốn không được ký và là tùy chọn, mà hãy gắn nó vào chính ID của assertion, thứ được ký và luôn hiện diện. Mọi assertion đều có một ID, nó nằm bên trong chữ ký, và can thiệp vào nó sẽ làm hỏng việc xác thực. Hãy giữ một cache dùng-một-lần cho các assertion ID, giới hạn theo NotOnOrAfter của từng assertion để cache không bao giờ phình ra vô hạn, và từ chối bất kỳ ID nào bạn đã thấy rồi. Giờ thì việc xóa InResponseTo chẳng mang lại gì cho kẻ tấn công, vì giá trị mà bạn thực sự kiểm tra không thể bị giả mạo và không thể bị replay.
Nhân tiện đang ở đó, hãy coi việc ràng buộc request là phòng thủ theo chiều sâu (defense in depth) chứ không phải là lớp kiểm soát chính. Vẫn khớp InResponseTo khi nó có mặt. Vẫn thẳng tay từ chối các response không được yêu cầu nếu bạn không bao giờ cung cấp đăng nhập kiểu IdP-initiated. Vẫn áp đặt Audience và khoảng thời gian Conditions. Nhưng bước kiểm tra chống replay mang tính chịu lực thì bắt buộc phải đặt trên những byte đã được ký, chấm hết.
Chúng tôi biết lỗi này vì đã tự tìm ra nó khi audit chính SAML của mình trước khi ra mắt, chứ không phải vì một khách hàng phát hiện ra sau đó. Và bài học này áp dụng rộng hơn nhiều so với riêng SAML. Một chữ ký chỉ trả lời đúng một câu hỏi: người nắm giữ khóa này có tạo ra những byte này hay không. Nó không cho bạn biết những byte đó có mới hay không, có được gửi cho bạn hay không, hay bạn đã từng chấp nhận chúng một lần rồi hay chưa. Đó là ba bước kiểm tra riêng biệt, và mỗi bước đều phải đọc một trường mà chữ ký thực sự bao phủ. Ngay khi một quyết định bảo mật phụ thuộc vào dữ liệu nằm ngoài chữ ký, nó thôi không còn là một quyết định bảo mật nữa mà trở thành một lời đề nghị lịch sự mà kẻ tấn công có toàn quyền từ chối.
Hãy ký cái trường mà bạn chốt chặn dựa vào, hoặc chốt chặn dựa vào cái trường đã được ký. Không có lựa chọn thứ ba nào sống sót qua một cuộc replay.
Nếu bạn không muốn tự tay viết phần xác thực SAML rồi làm sai ở tình huống replay, thì đó là một lý do chính đáng để giao nó cho người khác vận hành. Authagonal gắn cơ chế chống replay vào assertion ID đã được ký, nên việc lột bỏ InResponseTo chẳng đi đến đâu.