← All posts

Chúng tôi bắt buộc MFA. Một query parameter đã tắt nó.

Authagonal·July 14, 2026
securitymfaoidcwar-story

Chúng tôi đã triển khai xác thực đa yếu tố. Sau mật khẩu, trang login yêu cầu yếu tố thứ hai, đúng như bạn mong đợi. Chúng tôi test nó, nó chạy, chúng tôi đi tiếp. Rồi, trong một lượt rà soát trước khi ra mắt trên chính auth server của mình, chúng tôi phát hiện có thể bỏ qua toàn bộ chuyện đó chỉ bằng cách sửa một URL.

Đại khái nó thế này. Trong một luồng OIDC, ứng dụng của bạn đưa người dùng đến /connect/authorize. Nếu họ chưa đăng nhập, server đẩy họ sang trang login với đích đến ban đầu được nhét vào một tham số: /login?returnUrl=/connect/authorize?.... Bạn nhập mật khẩu, bạn vượt qua MFA, và luồng login đưa bạn quay lại returnUrl đó, lúc này /connect/authorize cấp một authorization code và các token theo sau.

Lỗi nằm ở thứ tự thực hiện. Bước mật khẩu đăng nhập bạn vào. Tức là, nó đặt một cookie đã xác thực. MFA là trang tiếp theo trong chuỗi. Còn /connect/authorize, cái endpoint thực sự phát ra token, chỉ hỏi đúng một câu trước khi làm việc đó: request này đã được xác thực chưa? Nó không bao giờ hỏi liệu session đã vượt qua yếu tố thứ hai hay chưa. “Đã xác thực” và “đã xác thực với MFA” là cùng một cookie.

Vậy nên cách vượt qua chẳng có gì tinh vi. Bạn gửi mật khẩu. Bạn nhận cookie đã xác thực. Rồi, thay vì đi theo luồng đến màn hình MFA, bạn tự đi thẳng đến returnUrl, /connect/authorize?..., cái địa chỉ mà server đã đưa cho bạn ngay từ đầu. Authorize thấy một principal đã xác thực, chạy đúng một bước kiểm tra của nó, và cấp code. Yếu tố thứ hai không bao giờ diễn ra. Màn hình MFA chỉ là một chặng trên hành lang, và chẳng có gì ngăn bạn đi vòng qua nó.

Lý do lỗi này sống sót qua khâu test là vì happy path kín kẽ tuyệt đối. Bấm qua giao diện như một người bình thường thì lần nào bạn cũng bị yêu cầu yếu tố thứ hai. Cái cổng là có thật. Chỉ là nó đặt sai chỗ. Ranh giới bảo mật trong một OIDC server không phải là màn hình login. Nó là /connect/authorize, điểm mà một session biến thành token. Bất kỳ yêu cầu nào chỉ tồn tại ở những trang dẫn đến ranh giới đó đều chỉ mang tính khuyến nghị, bởi vì bất kỳ ai tạo được request nền bên dưới thì đều có thể gửi nó trực tiếp.

Chính cách nhìn lại đó là bản sửa lỗi. MFA không phải một bước bạn thực hiện. Nó là một thuộc tính mà session hoặc có hoặc không. Vì vậy chúng tôi ngừng mô hình hóa nó như một trang và bắt đầu mô hình hóa nó như một claim. Khi bạn vượt qua yếu tố thứ hai, luồng login ghi một dấu hiệu mfa_authenticated vào auth cookie. /connect/authorize không còn chấp nhận “đã xác thực” nữa. Nó đòi hỏi “đã xác thực và mang theo dấu hiệu đó”. Một cookie chỉ có mật khẩu giờ trở nên vô dụng tại token endpoint: nó bị đẩy ngược lại để hoàn tất MFA bất kể bạn trỏ nó đến URL nào, bởi vì thứ nó thiếu là một claim, không phải một lượt ghé trang.

Chuyện liên kết định danh (federation) cũng được giải quyết gọn gàng từ đây. Khi một người dùng đăng nhập qua một identity provider bên ngoài đã tự chạy MFA của họ, provider đó bảo chứng cho yếu tố thứ hai, nên lượt đăng nhập liên kết cũng đặt đúng dấu hiệu đó. Người dùng SSO không bị yêu cầu hai lần cho thứ mà IdP của họ đã bắt buộc rồi. Dấu hiệu đó là nguồn sự thật duy nhất, và mọi con đường có thể thỏa mãn MFA một cách hợp lệ đều ghi nó.

Bài học có thể mang đi nơi khác chẳng liên quan riêng gì đến MFA. Nó là thế này: hãy thực thi kiểm soát tại ranh giới cấp phát thứ bạn đang bảo vệ, chứ không phải tại bước giao diện được thiết kế để dẫn đến đó. Chúng tôi có bước kiểm tra. Chúng tôi đã viết logic MFA, đã test nó, đã đưa nó lên. Chỉ là nó được gắn vào phần hệ thống mà kẻ tấn công không buộc phải dùng. Màn hình consent, chấp nhận điều khoản, step-up cho các scope nhạy cảm: cùng một kiểu thất bại. Nếu endpoint cấp phát credential không xác minh điều kiện, thì màn hình yêu cầu điều kiện đó chỉ là một lời gợi ý.

Chúng tôi bắt được lỗi này trước khi bất kỳ khách hàng nào dính phải, trong cùng đợt kiểm định đã lôi ra thêm vài lỗi anh em của nó. Đó là lý lẽ cho việc rà soát chính hệ thống auth của mình như thể bạn đang tấn công nó, và cụ thể là cho việc hỏi, tại mọi endpoint phát ra thứ gì đó có giá trị, không phải “người dùng có đi qua luồng không” mà là “request này thực sự chứng minh điều gì”. Request của chúng tôi chứng minh một mật khẩu. Còn chúng tôi lại coi nó là bằng chứng của hai yếu tố.

Thực thi MFA tại endpoint đúc ra token, chứ không phải tại trang yêu cầu nó, chính là toàn bộ khác biệt giữa MFA thật và một lời gợi ý. Authagonal thực thi nó ngay tại nơi credential được cấp phát, nên một return URL sửa bằng tay chẳng đi đến đâu cả.