Bài dò mật khẩu trước đo tấn công ngoại tuyến — kẻ tấn công đã có cơ sở dữ liệu băm và dò trên máy của nó. Bài này đo tấn công trực tuyến: kẻ tấn công gõ cửa route đăng nhập của bạn, thử mật khẩu này tới mật khẩu khác. Phòng thủ ở đây không phải băm — mà là giới hạn tần suất: chặn kẻ tấn công thử quá nhiều lần. Tôi dựng bốn chiến lược và đo từng cái chặn được bao nhiêu, hỏng ở đâu.
Toàn bộ chạy trên ứng dụng tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.
Số đo
Kẻ tấn công dò liên tục trong 70 giây. Tôi đếm số lần nó thực sự được thử mật khẩu (những lần bị chặn 429 không tính). Giới hạn đặt là 5 lần mỗi phút. Ba lần chạy, giống hệt:
| Chiến lược | Lần đoán được trong 70 giây |
|---|---|
| Không giới hạn | ~2.600 |
| Cửa sổ cố định | 10 |
| Cửa sổ trượt | 10 |
| Khoá theo tài khoản | 5 |
Một chi tiết trong cách đếm
Con số quan trọng không phải "kẻ tấn công gửi bao nhiêu request" mà "bao nhiêu request thực sự được thử mật khẩu". Trong cả bốn chiến lược, kẻ tấn công vẫn gửi khoảng 2.600 request trong 70 giây — vòng lặp của nó không dừng lại. Khác biệt là mỗi chiến lược từ chối phần lớn số đó bằng mã 429 trước khi chạm tới bước so mật khẩu. Tôi chỉ đếm những lần lọt qua.
Điều này phản ánh đúng thực tế bảo mật: giới hạn tần suất không làm kẻ tấn công ngừng gửi, nó làm phần lớn request của chúng trở nên vô hại. Một route bị dội 2.600 lần nhưng chỉ cho 10 lần chạm tới logic đăng nhập là một route được bảo vệ — dù nhìn vào log truy cập thô, cả 2.600 dòng đều ở đó.
Điều đáng nhớ
Không giới hạn là thảm hoạ, và con số cho thấy tại sao. Trong 70 giây, kẻ tấn công thử được 2.600 mật khẩu — và đó chỉ là một luồng, chạy chậm với sleep giữa các lần. Một cuộc tấn công thật với nhiều luồng thử hàng nghìn mỗi giây. Không giới hạn tần suất, route đăng nhập của bạn là một máy dò mật khẩu miễn phí; mọi công sức băm mật khẩu ở phía sau trở nên vô nghĩa nếu kẻ tấn công thử được không giới hạn.
Giới hạn tần suất cắt tấn công xuống ba bậc độ lớn. Từ 2.600 xuống 10 — giảm 260 lần. Đây là một trong những dòng phòng thủ hiệu quả nhất so với công sức bỏ ra trong cả sê-ri: vài dòng mã đếm request, và không gian tấn công co lại từ "dò được cả từ điển trong một phút" xuống "vài lần thử mỗi phút".
Cửa sổ cố định có lỗ 'burst biên giới'. Cửa sổ cố định đếm request theo từng khối thời gian rời rạc — ví dụ mỗi phút reset bộ đếm về 0. Lỗ hổng: kẻ tấn công bắn 5 lần vào cuối một phút, rồi 5 lần nữa ngay đầu phút sau. Tôi đo được 10 lần trong khoảng 3 giây — gấp đôi giới hạn danh nghĩa "5 mỗi phút", dồn vào một khoảnh khắc. Cửa sổ trượt không có lỗ này: nó đếm request trong 60 giây gần nhất tính từ bây giờ, nên không có "biên giới" để lợi dụng. Trong phép đo dài 70 giây cả hai đều cho 10, nhưng phân bố của cửa sổ cố định nguy hiểm hơn.
Khoá theo tài khoản chặn chặt nhất — và mở ra một cuộc tấn công khác. Nó hạ xuống 5 lần rồi khoá cứng tài khoản, không cho thử thêm bất kể kẻ tấn công đổi gì. Chặt nhất về chống dò. Nhưng nó có một mặt trái nguy hiểm: kẻ tấn công có thể cố ý khoá tài khoản của người khác. Chỉ cần thử sai 5 lần vào tài khoản an, và an bị khoá — một cuộc tấn công từ chối dịch vụ (DoS) nhắm vào từng người dùng. Đây là đánh đổi kinh điển: bảo vệ càng cứng theo tài khoản, càng dễ bị lợi dụng để khoá người vô tội.
Vì sao
Bốn chiến lược khác nhau ở đếm theo cái gì, và mỗi lựa chọn có điểm mù riêng:
- Cửa sổ cố định / trượt đếm theo IP. Rẻ, không đụng tới tài khoản, nhưng kẻ tấn công đổi IP (botnet, proxy) là vượt qua — mỗi IP có hạn ngạch riêng. Chúng chặn dò từ một nguồn, không chặn dò phân tán.
- Khoá theo tài khoản đếm theo tài khoản. Không quan tâm IP, nên chống được dò phân tán vào một tài khoản. Nhưng chính vì nó theo tài khoản, kẻ tấn công điều khiển được việc khoá — dùng nó làm vũ khí DoS.
Không có chiến lược nào đúng tuyệt đối; mỗi cái mạnh ở trục nó đếm và mù ở trục kia. Đó là lý do hệ thống thật kết hợp: giới hạn theo IP (cửa sổ trượt) để chặn dò tập trung, cộng backoff theo tài khoản (chậm dần thay vì khoá cứng) để chống dò phân tán mà không cho DoS dễ dàng.
Nghĩa là gì trong thực tế
- Bật giới hạn tần suất trên route đăng nhập — đây là bắt buộc, không phải tuỳ chọn. Con số 2.600 so với 10 nói rõ mức độ.
- Ưu tiên cửa sổ trượt hơn cửa sổ cố định nếu thư viện của bạn có cả hai — nó không có lỗ burst biên giới, với chi phí gần như bằng nhau.
- Đừng khoá cứng theo tài khoản; dùng backoff tăng dần. Sau mỗi lần sai, bắt chờ lâu hơn (1 giây, 2, 4, 8...) thay vì khoá hẳn. Nó làm chậm kẻ tấn công gần như khoá cứng, nhưng người dùng thật gõ nhầm mật khẩu chỉ phải chờ vài giây, và kẻ tấn công không khoá vĩnh viễn được ai.
- Kết hợp nhiều trục. Giới hạn theo IP và theo tài khoản; thêm CAPTCHA sau vài lần sai. Mỗi lớp bịt điểm mù của lớp kia.
Chỗ tôi không kết luận được
Tôi đo trạng thái giới hạn lưu trong bộ nhớ của một tiến trình. Hệ thống thật chạy nhiều máy chủ sau một bộ cân bằng tải, và lúc đó bộ đếm phải nằm ở nơi dùng chung (Redis) — nếu mỗi máy đếm riêng, kẻ tấn công chỉ cần rải request qua các máy để nhân giới hạn lên theo số máy. Đó là một lỗ triển khai mà phép đo một-tiến-trình của tôi không chạm tới.
Và tôi không đo CAPTCHA hay xác thực hai bước — hai lớp mạnh nhất chống dò trực tuyến, nhưng chúng đổi lấy trải nghiệm người dùng, và việc đo "phiền tới mức nào" cần người thật chứ không phải container.
Thử ba mươi giây
Mở route đăng nhập của ứng dụng bạn (trên môi trường thử). Gửi 20 yêu cầu đăng nhập sai liên tiếp bằng một vòng lặp curl.
Nếu cả 20 đều được xử lý và trả về "sai mật khẩu", bạn không có giới hạn tần suất — và route đó là một máy dò mật khẩu đang chờ kẻ tấn công. Nếu tới lần thứ 5 hay 6 nó bắt đầu trả 429, bạn có lớp phòng thủ; giờ kiểm xem nó đếm theo IP, theo tài khoản, hay cả hai.