Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.
Giả sử bạn đã làm đúng mọi thứ ở phần 2: băm mật khẩu bằng bcrypt, salt đầy đủ. Nhưng vẫn còn một cửa: đăng nhập trực tiếp. Nếu endpoint đăng nhập cho phép thử bao nhiêu lần cũng được, kẻ tấn công không cần phá hash — họ chỉ cần thử hết các mật khẩu phổ biến cho tới khi trúng (credential stuffing, dictionary attack, brute-force). Mật khẩu yếu hay dùng lại từ vụ rò rỉ khác sẽ bị dò ra chỉ còn là vấn đề thời gian.
Tuyến phòng thủ là giới hạn tốc độ (rate limiting): đếm số lần thử sai và chặn khi vượt ngưỡng. Nghe đơn giản, nhưng tác động của nó lên "thời gian để brute-force" là theo cấp số — biến một cuộc tấn công khả thi trong vài giờ thành bất khả thi trong hàng trăm nghìn năm. Bài này (phần 8 loạt Bảo mật web) đo thật tác động đó, và các đánh đổi khi triển khai (vì rate limiting làm sai cách lại mở ra lỗ hổng khác: DoS).
Cơ chế: đếm thử sai, chặn khi vượt ngưỡng

Hình 1: Không giới hạn — if checkPassword(input) cho thử bao nhiêu lần cũng được, kẻ tấn công thử hàng triệu/giây. Rate limit — đếm số lần thử sai theo tài khoản/IP trong một cửa sổ thời gian (5 lần sai/phút), vượt ngưỡng thì chặn tới khi hết cửa sổ; kết hợp bcrypt (phần 2) để mỗi lần thử còn chậm hẳn.
Tái hiện thật trong go-lab
Mình đo trong go-lab (golang 1.23): mô phỏng 100.000 lần thử, so trường hợp không giới hạn với có rate limiter (5 lần sai/phút/tài khoản).

Hình 2: Kết quả thật — ① không giới hạn: 100.000 mật khẩu trong 5ms = 18.471.768 lần/giây; ② có rate limit (5/phút): trong 100.000 request chỉ cho qua 5, chặn 99.995; ③ thời gian brute-force không gian ~10¹²: không giới hạn ~15 giờ, có rate limit ~380.000 năm (bất khả thi).
Con số kể câu chuyện rõ:
- Không giới hạn: tốc độ dò khủng khiếp. Thử 100.000 mật khẩu trong 5ms — tức ~18 triệu lần/giây. (Mình đo trong RAM nên con số cao bất thường; thực tế qua mạng + bcrypt sẽ chậm hơn nhiều, nhưng điểm là: không có gì chặn kẻ tấn công thử liên tục.) Với không gian mật khẩu ~10¹² (các mật khẩu phổ biến + biến thể), dò hết chỉ mất ~15 giờ. Mật khẩu yếu rơi còn nhanh hơn.
- Rate limit chặn gần như tất cả. Với giới hạn 5 lần sai/phút/tài khoản, trong 100.000 request chỉ 5 lọt qua, 99.995 bị chặn. Kẻ tấn công bị bóp xuống còn vài lần thử mỗi phút.
- Thời gian brute-force vọt lên bất khả thi. Chính con số này là lý do rate limiting hiệu quả: với 5 lần/phút, dò hết không gian 10¹² mất ~380.000 năm. Từ "khả thi trong một đêm" thành "bất khả thi trong nhiều đời người". Đây là sức mạnh của việc giới hạn tốc độ — nó không cần chặn tuyệt đối, chỉ cần làm chậm đủ để tấn công vô nghĩa.
Đánh đổi cần cân nhắc
Đếm theo cửa sổ, không chặn cứng — vì nhiều người dùng chung một IP. Cách triển khai rate limiting quan trọng. Đừng chặn vĩnh viễn một IP sau vài lần sai: văn phòng, trường học, mạng di động dùng chung một IP qua NAT — chặn cứng sẽ nuốt luôn người dùng thật đứng sau cùng IP đó. Dùng đếm trong cửa sổ trượt (như demo: reset sau mỗi phút) để chỉ làm chậm tạm thời. Cân nhắc giới hạn theo tài khoản (chống dò một tài khoản cụ thể) và theo IP (chống rải nhiều tài khoản) — mỗi loại chặn một kiểu tấn công khác.
Account lockout có thể bị lạm dụng để DoS — cẩn thận. Có một cạm bẫy ngược: nếu bạn khoá tài khoản sau N lần sai, kẻ tấn công có thể cố tình nhập sai mật khẩu của người khác để khoá tài khoản họ — biến một cơ chế bảo mật thành công cụ tấn công (denial of service). Vì vậy "account lockout cứng" thường không phải lựa chọn tốt cho hệ thống công khai. Thay vào đó: làm chậm dần (exponential backoff), yêu cầu CAPTCHA sau vài lần sai (chặn bot mà không khoá người thật), hoặc kết hợp giới hạn theo IP. Khoá cứng chỉ hợp lý khi có kênh khôi phục dễ dàng.
Đừng tiết lộ username tồn tại, và CAPTCHA/MFA mới là lớp mạnh. Nhớ bài 3 (timing): thông báo lỗi và thời gian phản hồi không được tiết lộ username nào tồn tại — "sai mật khẩu" cho tài khoản thật phải giống hệt "không tồn tại tài khoản" (cả nội dung lẫn thời gian, luôn chạy bcrypt kể cả khi user không tồn tại). Và rate limiting chỉ là một lớp; các lớp mạnh hơn là CAPTCHA (phân biệt người-máy) và nhất là MFA/2FA (xác thực hai yếu tố) — kể cả khi kẻ tấn công đoán đúng mật khẩu, không có yếu tố thứ hai thì không vào được. Cộng thêm: log cảnh báo khi một tài khoản có nhiều lần thất bại để đội vận hành biết đang bị tấn công.
Ba ý mang về
- Không giới hạn số lần thử = mời brute-force: đo thật, 100.000 mật khẩu thử trong 5ms (~18 triệu lần/giây trong RAM) — không gian ~10¹² dò hết trong ~15 giờ; mật khẩu yếu/dùng lại rơi nhanh dù hash đã an toàn.
- Rate limiting biến khả thi thành bất khả thi: đo thật, giới hạn 5 lần sai/phút chặn 99.995/100.000 request và đẩy thời gian brute-force từ 15 giờ lên ~380.000 năm; không cần chặn tuyệt đối, chỉ cần làm chậm đủ.
- Triển khai cẩn thận, nhiều lớp: đếm theo cửa sổ (không chặn cứng vì IP chung), tránh account lockout cứng (bị lạm dụng DoS — dùng backoff/CAPTCHA theo IP), không tiết lộ username tồn tại (nội dung + timing), và MFA/CAPTCHA là lớp mạnh hơn + log cảnh báo khi nhiều lần thất bại.
Nguồn
- OWASP — Blocking Brute Force Attacks: https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks
- OWASP — Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP — Credential Stuffing Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html
Phần sau ta bàn về TLS/HTTPS: vì sao xác thực chứng chỉ là cốt lõi của bảo mật kết nối, và cái bẫy InsecureSkipVerify biến HTTPS thành vô dụng trước tấn công người-đứng-giữa.