← All posts

Cơ chế khóa sau năm lần sai mật khẩu của chúng tôi chưa bao giờ khóa được ai

Authagonal·August 5, 2026

Khóa tài khoản là điều tối thiểu bắt buộc. Năm lần sai mật khẩu là tài khoản bị khóa mười phút, biến việc dò mật khẩu trực tuyến từ một mối đe dọa thực sự thành một ngõ cụt: kẻ tấn công chỉ có năm lần thử cho mỗi tài khoản trong mỗi mười phút, tức là gần như chẳng có gì. Chúng tôi đã có cơ chế đó. Ngưỡng là năm, khung thời gian là mười phút, đoạn mã kiểm tra nó là đúng, và nó chạy hoàn hảo mỗi lần chúng tôi thử, bởi vì mỗi lần thử chúng tôi gõ sai mật khẩu năm lần liên tiếp, lần này nối lần kia, như một con người.

Kẻ tấn công không phải là một con người gõ lần này nối lần kia. Kẻ tấn công gửi năm trăm lần cùng một lúc. Và khi chúng tôi làm đúng như vậy, cái bộ đếm lẽ ra phải dừng ở năm gần như không nhúc nhích, và tài khoản chẳng bao giờ bị khóa. Bạn có thể ném một số lượng phỏng đoán không giới hạn vào một tài khoản duy nhất, còn cơ chế bảo vệ duy nhất được thiết kế để chặn đúng điều đó thì ngồi đó chẳng làm gì, không phải vì logic sai, mà vì nó giả định rằng nó sẽ chỉ được hỏi một câu tại một thời điểm.

Một bộ đếm đếm sót

Đoạn mã xử lý đăng nhập thất bại làm điều hiển nhiên. Khi mật khẩu sai, nó nạp người dùng, cộng một vào số lần thử thất bại của họ, kiểm tra xem giá trị mới đã chạm năm hay chưa, rồi lưu người dùng trở lại. Đọc, sửa, ghi. Viết ra theo cách một con người nghĩ về nó, nó đúng.

Viết ra theo cách hai yêu cầu đến cùng một khoảnh khắc, nó không đúng. Cả hai đều đọc số đếm, và cả hai đều đọc cùng một giá trị, giả sử là ba. Cả hai đều cộng một và được bốn. Cả hai đều ghi bốn trở lại. Đã xảy ra hai lần thử thất bại, mà số đếm chỉ đi từ ba lên bốn. Một trong hai lần tăng đơn giản là biến mất, bị lần kia ghi đè, bởi vì cả hai cùng làm việc từ một điểm khởi đầu đã cũ, và lần ghi thứ hai đè lên lần ghi thứ nhất như thể lần thứ nhất chưa từng xảy ra.

Bây giờ hãy bắn năm trăm mật khẩu sai cùng một lúc. Chúng chồng lên nhau đúng theo kiểu này, mỗi lần đọc một số đếm thấp và ghi lại một số đếm chỉ thấp hơn một chút, các lần tăng bốc hơi hàng trăm cái một. Bộ đếm trôi lên một chút rồi dừng lại ở nơi cách xa năm. Ngưỡng khóa không bao giờ bị vượt qua, không phải vì phần kiểm tra sai, nó được kiểm tra một cách trung thực mỗi một lần, mà vì con số nó kiểm tra là một lời nói dối được lắp ghép bởi một đám đông yêu cầu đang ghi đè lẫn nhau. Cơ chế chống dò mật khẩu bị đánh bại không phải bởi một lỗi logic mà bởi tính đồng thời, vốn là loại lỗi vượt qua mọi bài kiểm thử tuần tự mà bạn từng viết và thất bại ngay khoảnh khắc nó gặp một kẻ tấn công thật sự, bên duy nhất có động cơ gửi yêu cầu song song.

Chỗ con lỗi này cư ngụ là điều quan trọng. Nó không bị chôn vùi trong một tầng lưu trữ nào đó. Nó nằm ngay đó, trong bộ xử lý đăng nhập, phơi bày rõ ràng, một ++ và một lệnh lưu, đoạn mã tự nhiên nhất trên đời. Chẳng có gì khi đọc nó gợi ý về nguy hiểm. Bạn phải đang nghĩ đến hai bản của nó chạy cùng một lúc thì con lỗi mới hiện ra, mà đọc mã từng dòng một chính xác là trạng thái tâm trí mà bạn không nghĩ như vậy.

Làm cho một bộ đếm trở nên nguyên tử trên một kho không biết đếm

Bản sửa lỗi phải làm cho toàn bộ chuỗi đọc-sửa-ghi trở nên không thể chia cắt: không lần thử nào khác được phép chen vào giữa lần đọc và lần ghi của chúng ta. Điều thú vị là chúng tôi đang ở trên một kho khóa-giá trị không có phép toán tăng. Bạn không thể bảo nó "cộng một vào trường này", vốn là nguyên hàm mà một cơ sở dữ liệu có bộ đếm nguyên tử sẽ trao cho bạn. Bạn chỉ có thể đọc một hàng và ghi một hàng.

Thứ mà kho đó thực sự cho bạn là một lệnh ghi có điều kiện. Mỗi hàng mang một nhãn phiên bản thay đổi sau mỗi lần ghi, và bạn có thể nói: hãy ghi lại hàng này, nhưng chỉ khi phiên bản của nó vẫn còn là cái tôi đã đọc. Nếu ai đó khác đã ghi trong lúc đó, phiên bản đã dịch chuyển, và lệnh ghi của bạn bị từ chối thay vì âm thầm đè bẹp lệnh ghi của họ.

Điều đó biến cuộc đua mất phần tăng thành một va chạm được bắt lại. Đọc hàng và phiên bản của nó. Cộng một. Thử ghi lại với điều kiện là phiên bản đó. Nếu thành công, phần tăng của bạn chắc chắn đã đáp xuống trên giá trị bạn đã đọc, không mất mát gì. Nếu bị từ chối, nghĩa là một lần thử khác đã đến trước bạn, nên bạn đọc lại số đếm giờ đã cao hơn và thử lại. Lặp lại khi bị từ chối, một nhúm lần có giới hạn, và mỗi phần tăng bị buộc phải xếp nối tiếp sau phần tăng cuối thay vì ghi đè lên nó. Số đếm trở lại đúng sự thật, và cuộc tấn công năm trăm cùng lúc giờ dẫn bộ đếm thẳng lên năm và bật khóa, đó là toàn bộ mục đích.

Chúng tôi mang đúng hình dạng đó sang backend lưu trữ còn lại của mình, backend này cũng không có phép tăng nhưng cũng cung cấp một lệnh ghi có điều kiện, lần này ràng buộc vào chính giá trị của bộ đếm thay vì vào một nhãn phiên bản: chỉ ghi số đếm mới nếu số đếm đã lưu vẫn còn là cái tôi đã đọc. Cùng một bảo đảm, được diễn đạt bằng từ vựng của kho đó. Bài học vượt ra ngoài bất kỳ cơ sở dữ liệu cụ thể nào: khi bạn thiếu một phép tăng nguyên tử, một lệnh ghi có điều kiện cộng với một vòng lặp thử lại sẽ dựng lại được một phép tăng như thế, bởi vì điều kiện chính là thứ khước từ lần ghi đè cũ kỹ vốn đang làm mất số đếm của bạn.

Có một sự bất đối xứng có chủ ý đáng nhắc tới. Chúng tôi làm cho đường đi thất bại trở nên nguyên tử và để nguyên đường đi thành công, vốn đặt lại bộ đếm về không khi đăng nhập đúng, như một lệnh ghi đơn giản theo kiểu người ghi cuối cùng thắng. Tính đồng thời không đe dọa một lần đặt lại: nhiều lần đăng nhập thành công đua nhau đều muốn ghi số không, và ai thắng cũng chẳng quan trọng. Chỉ phần tăng mới mang tính đối kháng, bởi vì chỉ phần tăng mới là thứ mà kẻ tấn công đang cố ghìm xuống. Bạn tiêu tốn chi phí của các lệnh ghi có điều kiện và các lần thử lại ở nơi kẻ địch đang đẩy, chứ không ở nơi hắn không đẩy.

Hãy giả định có kẻ đang đếm

Giá trị thật sự của con lỗi này là lối tư duy mà nó ép chúng tôi phải có, bởi vì ngay trong ngày sửa nó, chúng tôi đi tìm mọi thứ khác vốn âm thầm giả định mỗi-lần-một-yêu-cầu hoặc giả định chẳng ai đang đo đếm, và tìm thấy một chùm nhỏ những anh em họ hàng.

Các phản hồi đăng nhập của chúng tôi đang mách cho kẻ tấn công biết những tài khoản nào tồn tại. Một tài khoản không tồn tại, một tài khoản bị vô hiệu hóa, một tài khoản bị khóa và một mật khẩu sai, mỗi trường hợp tạo ra một câu trả lời phân biệt được, và đường đi không-có-người-dùng thậm chí còn trả về nhanh hơn vì nó bỏ qua hoàn toàn việc băm mật khẩu, nên kẻ tấn công có thể biết ai có tài khoản bằng cách quan sát hoặc trạng thái hoặc đồng hồ. Chúng tôi đã gộp các câu trả lời lại: một người dùng không rõ và một mật khẩu sai giờ trả về câu trả lời giống hệt nhau, và một người dùng không rõ vẫn được kiểm tra đối chiếu với một giá trị băm giả cố định để thời gian là như nhau, bất kể tài khoản có tồn tại hay không. Tài khoản tồn tại hoặc không, và bạn không thể biết được từ bên ngoài.

Việc đăng ký có cùng chỗ rò rỉ đó ở dạng khó hơn, bởi vì bạn không thể cứ luôn luôn báo thành công khi có người thử đăng ký một email đã bị người khác dùng, vì làm vậy dường như trao tài khoản cho họ. Vậy nên một lần đăng ký cho một địa chỉ đã được đăng ký giờ trả về đúng cùng một thành công trung tính như một lần đăng ký mới trả về, tiêu tốn cùng khoảng thời gian băm mật khẩu, không tạo ra gì cả, và thay vào đó gửi cho chủ sở hữu thật một email nói rằng có ai đó đã thử đăng ký địa chỉ của họ. Kẻ tấn công chẳng biết được gì. Chủ sở hữu thật thì được lặng lẽ báo cho biết.

Và biểu mẫu đặt lại mật khẩu là một khẩu đại bác email: không có giới hạn theo từng địa chỉ, nên bạn có thể bắt chúng tôi bắn email đặt lại tới bất kỳ địa chỉ nào lặp đi lặp lại, một cuộc tấn công nhắm vào một bên thứ ba và vào chính uy tín gửi thư của chúng tôi. Giờ nó giới hạn tần suất theo từng địa chỉ đích và, khi vượt trần, nó bỏ qua việc gửi trong khi vẫn trả về cùng một phản hồi thành công như mọi khi, để cho chính việc điều tiết không trở thành một cách mới để dò xem những địa chỉ nào tồn tại.

Mỗi trường hợp trong số đó là bài học của con lỗi khóa tài khoản mặc một bộ đồ khác. Hãy thiết kế như thể một kẻ tấn công đang gửi yêu cầu song song, bấm giờ các phản hồi của bạn, và đếm. Bởi vì điều duy nhất bạn có thể chắc chắn là kẻ đang tấn công phần đăng nhập của bạn không làm điều đó theo kiểu mỗi lần một yêu cầu lịch sự.

Vì sao một bộ đếm an ninh phải được kiểm thử một cách đồng thời

Một lỗi đồng thời trong một cơ chế kiểm soát an ninh còn tệ hơn một lỗi đồng thời ở bất kỳ nơi nào khác, vì một lý do cụ thể: bên có động cơ mạnh nhất để kích hoạt nó chính là kẻ tấn công, và những điều kiện kích hoạt nó, nhiều yêu cầu cùng lúc, chính xác là những điều kiện mà một cuộc tấn công tạo ra còn một bài kiểm thử thì hầu như không bao giờ. Vậy nên một cơ chế khóa, một giới hạn tần suất, một hạn mức, bất kỳ cơ chế kiểm soát nào dựng trên một bộ đếm, đều không thể được kiểm chứng bằng cách tăng nó trong một vòng lặp rồi nhìn nó bật. Nó phải bị đánh một cách đồng thời, bởi vì tính đồng thời là môi trường bản địa của kẻ tấn công, và phiên bản tuần tự của bài kiểm thử chỉ chứng minh rằng cơ chế của bạn hoạt động trước một kẻ địch đã đồng ý cư xử lịch sự.

Và khi bạn gia cố bộ đếm, công cụ trên một kho không có phép tăng nguyên tử chính là lệnh ghi có điều kiện: đọc phiên bản, chỉ ghi nếu nó chưa dịch chuyển, thử lại nếu nó đã dịch chuyển. Nó dựng lại phép tăng nguyên tử mà cơ sở dữ liệu đã không trao cho bạn, từ chính cái bảo đảm duy nhất mà nó đã trao.

Nếu bạn muốn chạy trên một nhà cung cấp danh tính vốn đã giả định rằng kẻ tấn công đang đếm song song, Authagonal đã dựng những cơ chế kiểm soát này để bị đánh tất cả cùng một lúc, bởi vì dù sao đi nữa đó cũng là cách duy nhất mà chúng rồi sẽ bị đánh trong thực tế.