Một cái két sắt tốt tới đâu cũng vô dụng nếu bạn dán mật mã lên cánh cửa. Ngược lại, một mật mã dài và ngẫu nhiên chẳng cứu được gì nếu ổ khoá bung ra chỉ với một cú đá. An toàn thật là tích của hai thứ: khoá tốt và bí mật tốt — và nếu một trong hai bằng không, tích bằng không. Bài này tôi đo đúng phép nhân đó trên mật khẩu, bằng một cuộc tấn công thật, và kết quả làm rõ một điều cả người dùng lẫn lập trình viên hay hiểu ngược: mật khẩu mạnh và thuật toán băm chậm bảo vệ hai thứ khác nhau, và bạn cần cả hai.
Bài trước đo băm mật khẩu — làm mỗi lần băm chậm để kẻ tấn công đoán chậm. Lần này tôi lật sang phía kẻ tấn công: dựng một cuộc tấn công thật vào một cơ sở dữ liệu mật khẩu đã băm, đo bao nhiêu bẻ được, mất bao lâu, trên thuật toán nhanh so với chậm. Toàn bộ chạy trong container tự dựng, tự dọn; tôi chỉ tấn công dữ liệu do chính mình tạo. (Các con số là thời gian và tốc độ dò đo trong container, không phải perf counter phần cứng.)
Cách đo
Tôi tạo cơ sở dữ liệu của mười người dùng. Sáu người đặt mật khẩu yếu — nằm trong danh sách mật khẩu phổ biến (123456, password, qwerty...). Bốn người đặt mật khẩu mạnh — chuỗi ngẫu nhiên 18 ký tự. Băm cả mười theo hai cách: SHA256 (nhanh, sai cách) và bcrypt (chậm, đúng cách). Rồi chạy một cuộc tấn công từ điển: thử lần lượt hơn một trăm mật khẩu phổ biến với từng bản băm. Ba lần chạy, kết quả giống hệt:
| SHA256 (nhanh) | bcrypt cost 11 (chậm) | |
|---|---|---|
| Mật khẩu yếu bẻ được | 6/10 | 6/10 |
| Mật khẩu mạnh bẻ được | 0/10 | 0/10 |
| Thời gian chạy từ điển | ~0,00 giây | ~51 giây |
| Tốc độ dò | ~3.900.000 lần/giây | ~12 lần/giây |
| Dò vét cạn mật khẩu 8 ký tự | ~0,011 năm (~4 ngày) | ~3.800 năm |
Ba điều bảng này nói thẳng vào mặt
Thuật toán chậm không cứu được mật khẩu yếu. Đây là điểm dễ hiểu sai nhất, nên tôi để nó lên đầu. Cả sáu mật khẩu yếu bị bẻ kể cả trên bcrypt — vì tấn công từ điển chỉ thử đúng những mật khẩu phổ biến, và 123456 nằm chình ình trong danh sách đó. Bcrypt kéo cuộc tấn công từ 0 giây lên 51 giây, nhưng 51 giây thì vẫn là "bẻ được ngay". Nếu mật khẩu của bạn nằm trong bất kỳ danh sách rò rỉ nào, thuật toán băm mạnh tới đâu cũng vô nghĩa — kẻ tấn công đâu cần dò vét cạn, nó chỉ thử đúng mật khẩu của bạn.
Nhưng thuật toán chậm bảo vệ mật khẩu mạnh — và bảo vệ khổng lồ. Bốn mật khẩu ngẫu nhiên không bẻ được bằng từ điển, nên kẻ tấn công buộc phải dò vét cạn, và ở đó thuật toán quyết định tất cả: với SHA256, một mật khẩu 8 ký tự chữ-và-số dò hết trong ~4 ngày; với bcrypt, cùng mật khẩu ấy cần ~3.800 năm. Chênh lệch ~325.000 lần đến thẳng từ chênh lệch tốc độ dò (3,9 triệu so với 12 lần mỗi giây). Bcrypt biến một mật khẩu mạnh từ "bẻ được trong một tuần" thành "không đời nào bẻ nổi".
Và đây là mấu chốt: hai lớp nhân nhau, không cộng. Sức mạnh mật khẩu quyết định kích thước không gian cần dò; tốc độ băm quyết định bao lâu mỗi lần thử. Thời gian bẻ là tích của hai. Một mật khẩu mạnh trên SHA256 vẫn chỉ tốn 4 ngày (thuật toán yếu kéo xuống); một mật khẩu yếu trên bcrypt vẫn bẻ trong 51 giây (mật khẩu yếu kéo xuống). Chỉ mật khẩu mạnh cộng thuật toán chậm mới cho ra 3.800 năm. Thiếu một lớp là toàn bộ sụp về mức của lớp yếu hơn — đúng như cái két có mật mã dán trên cửa.
Vì sao tôi tấn công từng người một, không cả bảng cùng lúc
Một chi tiết trong cách đo phản ánh một tính chất bảo mật thật. Bộ đo của tôi thử từ điển cho từng bản băm riêng — nó không thể tính một lần rồi so với cả bảng. Lý do là salt: mỗi mật khẩu được băm với một chuỗi ngẫu nhiên khác nhau (bài trước), nên 123456 của người dùng A và 123456 của người dùng B ra hai bản băm khác hẳn. Kẻ tấn công không thể băm 123456 một lần rồi tra cả mười người; nó phải băm lại 123456 với salt của từng người.
Đây chính là lý do salt nhân chi phí tấn công lên theo số người dùng: bẻ một bảng nghìn người là làm lại toàn bộ công việc nghìn lần. Không có salt, kẻ tấn công băm mỗi mật khẩu từ điển đúng một lần rồi tra cả bảng — nhanh gấp số-người-dùng lần. Trong phép đo, 51 giây của bcrypt là cho mười người; không có salt nó sẽ gần như không đổi dù bảng lớn tới đâu.
Sâu xa hơn, hai kiểu tấn công có kinh tế học khác nhau, và mỗi lớp phòng thủ nhắm vào một kiểu. Tấn công từ điển thử một danh sách hữu hạn các mật khẩu có khả năng — nó chẳng quan tâm không gian mật khẩu lớn cỡ nào, chỉ quan tâm mật khẩu của bạn có trong danh sách không; phòng thủ duy nhất là đừng dùng mật khẩu đoán được, còn thuật toán chậm chỉ làm nó tốn thời gian hơn một chút. Tấn công vét cạn thử mọi tổ hợp, luôn thành công nếu đủ thời gian, nên phòng thủ là kéo thời gian đó dài vô lý — và ở đây cả hai lớp cùng có tác dụng: mật khẩu dài hơn tăng số tổ hợp theo hàm mũ, thuật toán chậm tăng thời gian mỗi lần thử. Điểm cần nhớ: kẻ tấn công luôn thử từ điển trước vì nó rẻ, chỉ khi từ điển thất bại mới vét cạn — nên mật khẩu yếu chưa bao giờ chạm tới lớp phòng thủ thuật toán, nó thua ngay ở bước đầu.
Ra thực tế thì chia hai phía. Với người dùng: mật khẩu ngẫu nhiên, dài là lớp phòng thủ bạn kiểm soát — một chuỗi 16 ký tự ngẫu nhiên không nằm trong từ điển nào, nó buộc kẻ tấn công phải vét cạn, nơi thuật toán chậm phát huy; hãy dùng trình quản lý mật khẩu, đừng tự nghĩ mật khẩu "khó đoán" vì con người sinh mật khẩu tệ hơn mình tưởng. Với lập trình viên: dùng thuật toán chậm là điều kiện cần chứ không đủ — bcrypt/argon2 chỉ bảo vệ những người đã có mật khẩu mạnh, còn với người đặt 123456 bạn cần thêm lớp khác: kiểm mật khẩu so với danh sách rò rỉ khi họ đăng ký (Have I Been Pwned có API cho việc này), và giới hạn số lần đăng nhập sai để chặn dò trực tuyến. Và cấm mật khẩu phổ biến ngay lúc đặt — chặn 123456 khi đăng ký rẻ hơn nhiều so với chịu hậu quả khi cơ sở dữ liệu rò.
Mẫu số chung
Cái ý "an toàn là tích của nhiều lớp, thiếu một lớp là sụp về lớp yếu nhất" vượt xa chuyện mật khẩu — nó là hình dạng chung của phòng thủ theo chiều sâu.
Trong thế giới Java, đây đúng là lý do Spring Security mặc định dùng BCryptPasswordEncoder (hay DelegatingPasswordEncoder để nâng cấp dần), và khuyến khích ghép thêm khoá tài khoản sau vài lần sai, và kiểm mật khẩu yếu lúc đăng ký. Chọn encoder mạnh mà quên hai lớp kia là đúng cái bẫy "két tốt, mật mã dán trên cửa" — thư viện cho bạn một lớp, ba lớp còn lại là việc của bạn.
Trong Go, golang.org/x/crypto/bcrypt và argon2 cho bạn đúng thuật toán chậm ấy với một tham số cost/thời gian điều chỉnh được — nhưng cùng bài học: bcrypt.GenerateFromPassword mạnh tới đâu cũng không thay được việc từ chối mật khẩu nằm trong danh sách rò rỉ. Thuật toán là một thừa số của tích, không phải cả tích.
Còn ở cơ sở dữ liệu, cùng nguyên tắc nhân-lớp hiện ra ở chỗ khác: mã hoá dữ liệu khi nghỉ (encryption at rest) bảo vệ bạn khi ai đó lấy được file đĩa, nhưng vô dụng nếu quyền truy cập (GRANT, row-level security) lỏng lẻo — kẻ vào được bằng tài khoản hợp lệ đọc dữ liệu đã giải mã sẵn. Hai lớp bảo vệ hai mối đe doạ khác nhau, và mạnh một lớp không bù được yếu lớp kia.
Mẫu số chung: thời gian (hay công sức) để phá một hệ là tích của các lớp phòng thủ độc lập, nên một lớp bằng không thì cả tích bằng không — và kẻ tấn công luôn tấn công vào thừa số nhỏ nhất trước. Vì thế câu hỏi đúng khi đánh giá an toàn không phải "lớp mạnh nhất của tôi mạnh cỡ nào" mà là "lớp yếu nhất của tôi yếu cỡ nào" — vì đó mới là con số quyết định.
Muốn tự kiểm trong ba mươi giây, nghĩ tới mật khẩu bạn dùng cho một tài khoản quan trọng. Nó có nằm trong đầu bạn như một từ, một cụm, một ngày tháng, một cái tên không? Nếu có, nó gần như chắc chắn nằm trong một từ điển tấn công nào đó — và không thuật toán băm nào ở phía máy chủ cứu được nó. Cách kiểm thật: vào trang Have I Been Pwned, gõ mật khẩu đó (trang dùng k-anonymity, không gửi nguyên mật khẩu đi). Nếu nó xuất hiện trong dữ liệu rò rỉ, đổi ngay — không phải vì trang đó biết bạn là ai, mà vì kẻ tấn công cũng cầm đúng danh sách đó.