Hãy tưởng tượng cửa nhà bạn có ổ khoá tốt tới đâu cũng không quan trọng, nếu bạn cho phép một người lạ đứng ngoài thử chìa vô hạn lần, cả ngày lẫn đêm, không ai ngăn. Sớm muộn gì họ cũng mở được — không phải vì khoá dở, mà vì bạn quên đặt ra một giới hạn cho số lần thử. Route đăng nhập của một ứng dụng web y hệt cái cửa đó. Bài trước tôi đo tấn công ngoại tuyến — kẻ tấn công đã cầm sẵn cơ sở dữ liệu băm và dò trên máy của nó. Lần này ngược lại: tấn công trực tuyến, kẻ tấn công gõ cửa route đăng nhập thật của bạn, thử mật khẩu này tới mật khẩu khác. Và phòng thủ ở đây không phải băm cho mạnh — mà là giới hạn tần suất, tức là ngăn nó thử quá nhiều lần.
Tôi dựng bốn chiến lược, đo từng cái chặn được bao nhiêu và hỏng ở đâu. Toàn bộ chạy trên ứng dụng tôi tự dựng trong container, tự dọn; tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai. (Các con số là số lần thử đo được trong container, không phải perf counter phần cứng.)
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 bằng mã 429 không tính. Giới hạn đặt là 5 lần mỗi phút. Ba lần chạy cho kết quả 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 |
Có một chi tiết trong cách đếm mà tôi muốn nói rõ, vì nó là chỗ dễ hiểu lầm nhất. 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ự chạm tới bước so mật khẩu". Trong cả bốn chiến lược, vòng lặp của nó vẫn bắn đủ khoảng 2.600 request trong 70 giây — nó không hề dừng lại. Khác biệt nằm ở chỗ mỗi chiến lược từ chối phần lớn số đó bằng 429 trước khi chạm tới logic đăng nhập. Tôi chỉ đếm những lần lọt qua.
Điều này phản ánh đúng bản chất của giới hạn tần suất: nó 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 mà 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 vẫn nằm đó, trông đáng sợ y như nhau.
Bốn con số, bốn bài học
Nhìn dòng đầu trước đã, vì nó là lời cảnh báo: không giới hạn là thảm hoạ. Trong 70 giây, kẻ tấn công thử được 2.600 mật khẩu — và đây mới chỉ là một luồng, chạy chậm, có sleep chèn giữa các lần. Một cuộc tấn công thật tung nhiều luồng song song, thử hàng nghìn lần mỗi giây. Không có giới hạn tần suất, route đăng nhập của bạn biến thành một máy dò mật khẩu miễn phí, và mọi công sức bạn đổ vào việc băm mật khẩu cho mạnh ở phía sau trở nên vô nghĩa — kẻ tấn công cứ thử thoải mái tới khi trúng.
Rồi nhìn bước nhảy từ 2.600 xuống 10. Giới hạn tần suất cắt tấn công xuống ba bậc độ lớn — giảm 260 lần. Tôi thấy đây là một trong những dòng phòng thủ đáng đồng tiền bát gạo nhất cả sê-ri: chỉ vài dòng mã đếm request, mà không gian tấn công co từ "dò trọn một từ điển trong một phút" xuống "vài lần thử mỗi phút". Hiếm khi công sức bỏ ra ít mà đổi lại nhiều đến thế.
Nhưng hai con số 10 giống nhau lại giấu một khác biệt quan trọng. Cửa sổ cố định có lỗ 'burst biên giới'. Nó đế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. Kẽ hở: 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 đúng một khoảnh khắc. Cửa sổ trượt không dính lỗ này, vì nó đếm request trong 60 giây gần nhất tính từ bây giờ, không có cái "biên giới" nào để mà lợi dụng. Trong phép đo dài 70 giây cả hai đều cho ra 10, nhưng phân bố của cửa sổ cố định nguy hiểm hơn hẳn.
Và dòng cuối là chỗ thú vị nhất. Khoá theo tài khoản chặn chặt nhất — rồi 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 IP hay đổi gì. Chặt nhất về chống dò. Nhưng mặt trái thì nguy hiểm không ké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, thế là an bị khoá — một cuộc từ chối dịch vụ (DoS) nhắm vào từng người dùng cụ thể. Đây là đánh đổi kinh điển của bảo mật: 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 ra ngoài.
Vì sao mỗi chiến lược lại mù một chỗ
Bốn chiến lược khác nhau ở đếm theo cái gì, và chính lựa chọn đó quyết định điểm mù:
- 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 xoay vòng — là vượt qua, vì mỗi IP có hạn ngạch riêng. Chúng chặn dò từ một nguồn, không chặn được 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 đúng vì nó bám theo tài khoản, kẻ tấn công điều khiển được việc khoá và biến nó thành 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 hầu như luôn kết hợp: giới hạn theo IP (cửa sổ trượt) để chặn dò tập trung, cộng thêm 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 tặng không cho kẻ tấn công một nút DoS.
Rút ra cho công việc hằng ngày thì gọn thế này. Bật giới hạn tần suất trên route đăng nhập là bắt buộc, không phải tuỳ chọn — con số 2.600 so với 10 đã nói hết mức độ. Nếu thư viện của bạn có cả hai, ưu tiên cửa sổ trượt hơn cửa sổ cố định: nó bịt lỗ burst biên giới với chi phí gần như y hệt. Đừng khoá cứng theo tài khoản mà hãy 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...); cách này làm chậm kẻ tấn công gần bằng khoá cứng, nhưng người thật lỡ gõ nhầm chỉ phải chờ vài giây, và không ai bị khoá vĩnh viễn. Và quan trọng nhất: 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.
Mẫu số chung
Chuyện "đếm theo trục nào thì mù ở trục còn lại" không phải đặc sản của một framework web nào, nó lặp lại ở mọi tầng.
Trong thế giới Java, đây đúng là bài toán mà Bucket4j hay Resilience4j giải, và Spring Security cũng thường ghép một AuthenticationFailureHandler đếm số lần sai vào bộ đếm theo IP hoặc theo username. Điểm đáng nhớ giống hệt: chọn khoá đếm là IP thì botnet lách được, chọn khoá đếm là username thì mở đường cho ai đó khoá tài khoản người khác — nên production thường giữ cả hai bucket song song.
Trong Go, golang.org/x/time/rate cho bạn một token bucket mịn (thực chất là họ hàng của cửa sổ trượt: token hồi lại liên tục theo thời gian, không có biên giới phút để giật cục), nhưng nó chỉ cho bạn một limiter — còn đặt limiter đó theo khoá nào (IP, tài khoản, hay cả hai qua một map[string]*rate.Limiter) vẫn là quyết định của bạn, và đó mới là chỗ quyết định điểm mù.
Còn ở cơ sở dữ liệu, cùng một câu chuyện đội lốt: giới hạn kết nối theo pg_hba.conf / theo user, hay max_connections toàn cục, hay quota theo vai trò — mỗi cái chặn lạm dụng ở một trục và bỏ ngỏ trục kia. Giới hạn toàn cục chặn được tổng tải nhưng để một tenant ngốn hết; giới hạn theo tenant thì công bằng hơn nhưng lại là cái nút mà một tenant có thể tự khoá mình.
Mẫu số chung: giới hạn tần suất luôn phải chọn một khoá để đếm, và bất cứ trục nào không nằm trong khoá đó đều là điểm mù — kẻ tấn công chỉ cần di chuyển dọc theo cái trục bạn không đếm. Vì thế câu hỏi đúng khi thiết kế không phải "tôi đã giới hạn tần suất chưa" mà là "tôi đang đếm theo khoá nào, và kẻ tấn công lách được bằng cách đổi thứ gì" — rồi thêm một trục nữa để bịt đúng chỗ đó. Không có một khoá vạn năng; chỉ có việc xếp chồng vừa đủ trục để mọi hướng lách đều đụng ít nhất một lớp.
Muốn tự kiểm ngay trong ba mươi giây, mở route đăng nhập của ứng dụng bạn (trên môi trường thử) và bắn 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à đều 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 ngồi chờ kẻ tấn công. Còn nếu tới lần thứ 5 hay 6 nó bắt đầu trả 429, bạn đã có lớp phòng thủ; việc còn lại chỉ là xác định nó đếm theo IP, theo tài khoản, hay cả hai — và trục nào chưa đếm thì đó là chỗ cần bịt tiếp.