Loạt bài phòng thủ: ta tìm hiểu cách lưu mật khẩu an toàn, không hướng dẫn tấn công.

Khi cần lưu mật khẩu người dùng, phản xạ của nhiều lập trình viên là "băm nó bằng SHA-256" — vì SHA-256 là hàm băm mật mã mạnh, một chiều, không đảo ngược được. Nghe hợp lý. Nhưng nó sai, và cái sai nằm ở một thuộc tính mà bình thường ta coi là ưu điểm: SHA-256 rất nhanh. Nhanh là tốt khi băm file hay kiểm tra toàn vẹn dữ liệu. Nhưng với mật khẩu, nhanh là thảm hoạ: nếu cơ sở dữ liệu bị lộ, kẻ tấn công có thể thử hàng trăm triệu mật khẩu mỗi giây để dò ngược.

Giải pháp đúng là dùng một hàm băm thiết kế riêng cho mật khẩu — bcrypt, scrypt, hay argon2 — mà điểm cốt lõi là chậm có kiểm soát. Nghe nghịch lý: ta cố ý làm nó chậm. Bài này (phần 2 loạt Bảo mật web) đo thật sự khác biệt tốc độ, cho thấy "chậm" là tính năng chứ không phải lỗi, và giải thích vai trò của salt.

Cơ chế: nhanh là điểm yếu, chậm là tính năng

Ảnh chụp đoạn mã nền tối minh hoạ băm mật khẩu vì sao bcrypt chậm là cố ý, khối SHA-256 cho mật khẩu nhanh bằng kẻ tấn công thử tỉ lần mỗi giây h bằng sha256.Sum256 password khoảng 38 ns siêu nhanh SHA-256 thiết kế để băm nhanh file dữ liệu nhưng với mật khẩu nếu DB bị lộ kẻ tấn công thử hàng trăm triệu mật khẩu mỗi giây dò ra mật khẩu yếu dễ dàng, khối bcrypt chậm có kiểm soát qua cost factor hash bằng bcrypt.GenerateFromPassword password 12 cost 12 cost là số vòng lặp theo hàm mũ 2 mũ cost cost cộng 1 chậm gấp đôi chọn cost sao cho khoảng 250ms mỗi hash đủ nhanh cho 1 lần đăng nhập đủ chậm để brute-force bất khả thi, khối salt tự sinh ngẫu nhiên nhúng trong hash bcrypt tự thêm salt ngẫu nhiên cùng 1 mật khẩu 2 hash khác nhau chống rainbow table bcrypt.CompareHashAndPassword hash password salt nằm trong hash verify vẫn đúng

Hình 1: SHA-256 băm mật khẩu trong ~38ns — quá nhanh, kẻ tấn công thử hàng trăm triệu/giây nếu DB lộ. bcrypt chậm có kiểm soát qua cost (số vòng lặp theo hàm mũ 2^cost, mỗi cost +1 chậm gấp đôi) — chọn cost cho ~250ms. Salt tự sinh ngẫu nhiên nhúng trong hash: cùng mật khẩu cho hash khác nhau, chống rainbow table.

Đo thật trong go-lab

Mình đo trong go-lab (golang 1.23, x/crypto/bcrypt): tốc độ SHA-256, bcrypt theo từng cost factor, và tính chất của salt.

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 x/crypto bcrypt, khối một SHA-256 nhanh đến mức nguy hiểm cho mật khẩu thời gian mỗi hash 38 ns khoảng 26 triệu hash mỗi giây trên 1 core lộ DB bằng kẻ tấn công thử hàng trăm triệu mật khẩu mỗi giây, khối hai bcrypt theo cost factor mỗi cost cộng 1 chậm gấp đôi bảng cost 8 thời gian 12.9 ms hash mỗi giây khoảng 78 cost 10 51.2 ms khoảng 20 cost 12 204.8 ms khoảng 5 cost 14 819.8 ms khoảng 1 tăng hàm mũ 2 mũ cost cost 10 tới 12 chậm gấp 4 chọn cost cho khoảng 250ms là điểm cân bằng, khối ba salt tự động cùng mật khẩu hash khác nhau hash lần 1 và hash lần 2 khác nhau hai hash giống nhau false chống rainbow table verify cả 2 với mật khẩu đúng true true salt nhúng trong hash, SHA-256 38ns nhanh gấp khoảng 1,3 triệu lần bcrypt cost 10 51ms và đó chính xác là lý do bcrypt an toàn hơn cho mật khẩu chậm có chủ đích

Hình 2: Kết quả thật — SHA-256: 38 ns/hash (~26 triệu/giây); bcrypt: cost 8=12.9ms, cost 10=51.2ms, cost 12=204.8ms, cost 14=819.8ms (mỗi cost +1 chậm gấp đôi); salt: cùng mật khẩu cho hai hash khác nhau, cả hai verify đúng.

Ba con số đáng khắc ghi:

  • SHA-256: 38 nano-giây — nhanh đến mức nguy hiểm. Một core CPU băm được ~26 triệu mật khẩu mỗi giây, và với GPU/cluster thì hàng tỉ. Nghĩa là nếu một DB lưu mật khẩu dạng SHA-256 bị lộ, kẻ tấn công dò ngược mọi mật khẩu yếu/trung bình trong thời gian ngắn. Sự nhanh — ưu điểm của SHA-256 cho mục đích khác — chính là tử huyệt của nó cho mật khẩu.
  • bcrypt: chậm theo hàm mũ, có kiểm soát. cost 8 là 12.9ms, cost 10 là 51.2ms, cost 12 là 204.8ms, cost 14 là 819.8ms. Mỗi lần tăng cost thêm 1, thời gian gấp đôi (hàm mũ 2^cost) — bạn thấy rõ cost 10→12 (hai bước) chậm gấp ~4. Điều này cho bạn một "núm vặn": chọn cost sao cho ~250ms/hash. Người dùng thật chỉ chịu 250ms khi đăng nhập (không nhận ra), nhưng kẻ tấn công chỉ thử được ~4 mật khẩu/giây thay vì hàng triệu — brute-force trở nên bất khả thi về kinh tế. SHA-256 nhanh gấp ~1,3 triệu lần bcrypt cost 10, và đó chính xác là lý do bcrypt an toàn.
  • Salt: cùng mật khẩu, hash khác nhau. Băm cùng một mật khẩu hai lần cho hai hash khác hẳn — vì bcrypt tự sinh một salt ngẫu nhiên và nhúng vào hash. Điều này chống rainbow table (bảng hash tính sẵn cho các mật khẩu phổ biến): vì mỗi hash có salt riêng, bảng tính sẵn vô dụng. Và verify vẫn hoạt động với cả hai hash — vì salt nằm trong chuỗi hash, CompareHashAndPassword đọc salt ra để băm lại và so.

Đánh đổi cần cân nhắc

Cost cao an toàn hơn nhưng tốn CPU và latency đăng nhập — chọn ~250ms. Cost là đánh đổi trực tiếp giữa an toàn và tài nguyên. Cost quá thấp (như 8) thì nhanh nhưng dễ brute-force hơn; cost quá cao (như 14, ~820ms) thì an toàn nhưng mỗi đăng nhập tốn gần một giây CPU — dưới tải cao, server băm mật khẩu có thể trở thành nút thắt (và thành vector DoS: kẻ tấn công spam đăng nhập để ngốn CPU). Khuyến nghị thực dụng: chọn cost sao cho ~250ms trên phần cứng production của bạn, và tăng dần theo năm khi CPU mạnh lên. Đo thật trên máy thật, đừng copy con số.

bcrypt giới hạn 72 byte — biết để không bị cắt âm thầm. Một chi tiết ít người biết: bcrypt chỉ dùng 72 byte đầu của mật khẩu, phần sau bị bỏ qua. Với mật khẩu thường thì không sao, nhưng nếu bạn cho passphrase rất dài hoặc (sai lầm) băm trước bằng thứ gì đó rồi đưa vào bcrypt, có thể bị cắt mà không báo. Argon2id không có giới hạn này. Nếu cần hỗ trợ mật khẩu dài, cân nhắc argon2id, hoặc hiểu rõ giới hạn 72 byte.

Argon2id là khuyến nghị mới nhất — và đừng bao giờ tự nghĩ thuật toán. Bcrypt vẫn an toàn và phổ biến, nhưng khuyến nghị hiện đại (OWASP) ưu tiên argon2id — nó chống tấn công GPU/ASIC tốt hơn vì tốn nhiều bộ nhớ (memory-hard), không chỉ tốn CPU; bạn chỉnh được cả thời gian lẫn RAM. Dù chọn cái nào, nguyên tắc tuyệt đối: dùng thư viện đã được kiểm chứng, đừng bao giờ tự nghĩ ra thuật toán băm mật khẩu (tự xoắn SHA-256 nhiều vòng là một sai lầm kinh điển — thiếu các tính chất quan trọng). Và tất nhiên: không bao giờ lưu mật khẩu dạng plaintext hay SHA-256 trần.

Ba ý mang về

  1. SHA-256 nhanh = SAI cho mật khẩu: đo thật, SHA-256 băm một mật khẩu trong 38ns (~26 triệu/giây một core) — nếu DB lộ, kẻ tấn công dò ngược hàng trăm triệu mật khẩu/giây; sự nhanh là ưu điểm cho băm file nhưng là tử huyệt cho mật khẩu.
  2. bcrypt chậm là tính năng, điều chỉnh bằng cost: đo thật, cost 8=12.9ms, 10=51.2ms, 12=204.8ms, 14=819.8ms — mỗi cost +1 chậm gấp đôi (hàm mũ); SHA-256 nhanh gấp ~1,3 triệu lần bcrypt cost 10, và đó là lý do bcrypt an toàn; chọn cost cho ~250ms/hash.
  3. Salt chống rainbow table, và dùng thư viện chuẩn: bcrypt tự sinh salt ngẫu nhiên nhúng trong hash nên cùng mật khẩu cho hash khác nhau (verify vẫn đúng); chọn cost cân bằng an toàn/latency (cẩn thận DoS); ưu tiên argon2id (memory-hard); đừng tự nghĩ thuật toán; không bao giờ lưu plaintext/SHA-256 trần.

Nguồn

Phần sau ta bàn về một lỗ hổng tinh vi: timing attack — khi so sánh chuỗi bí mật (token, chữ ký) bằng == thông thường, thời gian so sánh rò rỉ thông tin về chuỗi đúng; đo thật và chống bằng so sánh constant-time.