Bài trước kết bằng một lời cảnh báo: hash nhanh tốt cho toàn vẹn nhưng tệ cho mật khẩu. Đây là một trong những lỗi bảo mật phổ biến nhất mà lập trình viên mắc phải — lưu mật khẩu bằng SHA-256(password). Nghe có vẻ an toàn (một chiều mà!), nhưng khi CSDL bị lộ, chính tốc độ của SHA phản chủ. Bài này đo thật vì sao, và cách đúng: salt + hàm băm cố ý chậm. Chạy thật bằng python3 và openssl.

Vì sao SHA nhanh là thảm họa cho mật khẩu

SHA-256 được thiết kế để băm nhanh nhất có thể — tốt khi bạn cần băm 10 MB file tức thì. Nhưng với mật khẩu, kẻ tấn công cũng hưởng chính tốc độ đó. Kịch bản: CSDL của bạn bị lộ, kẻ tấn công có toàn bộ hash. Chúng không cần "giải ngược" (bất khả thi) — chúng chỉ đoán: băm thử 123456, password, matkhau... rồi so với hash trong CSDL. Với SHA nhanh, chúng thử hàng tỷ mật khẩu mỗi giây trên GPU, dò hết mọi mật khẩu yếu/phổ biến trong vài giây.

Hash nhanh là kẻ thù khi lưu mật khẩu. Giải pháp gồm ba phần:

1. Salt        chuỗi ngẫu nhiên riêng mỗi user, lưu kèm hash
               -> hai người cùng mật khẩu ra hash KHÁC nhau
2. Cố ý chậm   nhiều vòng lặp -> mỗi lần đoán tốn thời gian/CPU
3. Tốn bộ nhớ  (scrypt/argon2) -> chống GPU/ASIC song song hàng loạt
# python: hàm băm mật khẩu chuyên dụng
python3 -c 'import hashlib,os; print(hashlib.scrypt(b"pw", salt=os.urandom(16), n=16384, r=8, p=1).hex())'
openssl passwd -6 -stdin   # sha512-crypt (dạng $6$), có salt + nhiều vòng

Ảnh chụp đoạn mã nền tối giải thích vì sao không lưu mật khẩu bằng SHA trần, SHA nhanh là thảm họa cho mật khẩu SHA-256 thiết kế để băm nhanh nên kẻ tấn công cũng băm nhanh lộ CSDL hash chúng đoán hàng tỷ mật khẩu mỗi giây trên GPU dò hết mật khẩu yếu phổ biến trong vài giây hash nhanh là kẻ thù khi lưu mật khẩu, ba thứ một hàm lưu mật khẩu phải có 1 salt chuỗi ngẫu nhiên riêng mỗi user lưu kèm hash hai người cùng mật khẩu ra hash khác nhau rainbow table vô dụng, 2 cố ý chậm nhiều vòng lặp mỗi lần đoán tốn CPU thời gian, 3 tốn bộ nhớ scrypt argon2 chống GPU ASIC song song, dùng hàm chuyên dụng đừng tự chế argon2 khuyến nghị 2024 tốn bộ nhớ tốt nhất bcrypt kinh điển có cost factor scrypt tốn bộ nhớ PBKDF2 khi buộc chuẩn FIPS đặt vòng lặp cao python hashlib scrypt pbkdf2_hmac openssl passwd -6

Hình 1: SHA nhanh khiến kẻ bẻ khóa đoán hàng tỷ mật khẩu/giây. Hàm lưu mật khẩu đúng cần salt (chống rainbow table), cố ý chậm, và tốn bộ nhớ — dùng argon2/bcrypt/scrypt/PBKDF2, đừng tự chế.

Đo thật: nhanh vs cố ý chậm

Đo thời gian băm một mật khẩu bằng SHA-256 trần so với PBKDF2 và scrypt:

Ảnh chụp bảng kết quả đo thật nền tối chạy python3 và openssl, phần một thời gian băm một mật khẩu nhanh vs cố ý chậm SHA-256 trần 0.000441 ms khoảng 2.3 triệu hash mỗi giây PBKDF2 600k 102 ms khoảng 10 hash mỗi giây scrypt n bằng 16384 22.7 ms khoảng 44 hash mỗi giây, phần hai tốc độ đoán của kẻ bẻ khóa cùng phần cứng SHA-256 khoảng 2.300.000 lần đoán mỗi giây PBKDF2 khoảng 10 lần đoán mỗi giây chậm hơn khoảng 231.000 lần với đăng nhập thật 102 ms mỗi lần là không ai để ý, phần ba không salt hai user cùng mật khẩu hash giống hệt user A matkhau123 ra fc8d5c17ee6bd893 chấm chấm bc62de28 user B matkhau123 ra fc8d5c17ee6bd893 chấm chấm bc62de28 giống hệt rainbow table tra một phát ra cả hai, phần bốn có salt riêng cùng mật khẩu hash khác nhau user A salt 3e3140d1 ra fea0c31b2b79 user B salt 35428e6c ra 1c1c9d568c98 rainbow table vô dụng

Hình 2: SHA-256 trần 0,000441 ms/hash (~2,3 triệu/giây); PBKDF2 600k vòng 102 ms (~10/giây); scrypt 22,7 ms. Kẻ bẻ khóa với SHA đoán ~2,3 triệu/giây, với PBKDF2 chỉ ~10/giây — chậm hơn ~231.000 lần. Không salt: hai user cùng mật khẩu ra hash giống hệt; có salt: khác nhau.

Kết quả nói lên tất cả:

  • SHA-256 trần: 0,000441 ms/hash = 2,3 triệu hash/giây chỉ với một luồng python (GPU thì hàng tỷ). Đây chính là tốc độ kẻ tấn công dùng để dò.
  • PBKDF2 600.000 vòng (khuyến nghị OWASP): 102 ms/hash = ~10 hash/giây. Chậm hơn ~231.000 lần. Kẻ tấn công giờ chỉ đoán được ~10 mật khẩu/giây thay vì 2,3 triệu — dò một mật khẩu mạnh trở nên bất khả thi về thời gian. scrypt còn thêm rào bộ nhớ.
  • Với người đăng nhập thật, 102 ms/lần là KHÔNG ai để ý — bạn chỉ băm một lần khi họ đăng nhập. Nhưng kẻ tấn công phải trả đúng cái giá đó cho mỗi lần đoán trong hàng tỷ lần. Đây là sự bất đối xứng cốt lõi.
  • Salt: không salt, "matkhau123" của user A và B đều ra fc8d5c17...bc62de28 — giống hệt, nên kẻ tấn công dùng rainbow table (bảng hash tính sẵn) tra một phát ra cả hai. Có salt riêng mỗi user, cùng mật khẩu ra hash khác nhau (fea0c31b... vs 1c1c9d56...) — rainbow table vô dụng, phải dò lại từ đầu cho từng user.

Đánh đổi và lưu ý

Chọn hàm theo thứ tự ưu tiên: argon2 > scrypt/bcrypt > PBKDF2. argon2id (thắng cuộc thi Password Hashing Competition) là khuyến nghị hiện đại vì tốn cả CPU lẫn bộ nhớ. bcrypt kinh điển, vẫn tốt. PBKDF2 chỉ nên dùng khi buộc theo chuẩn FIPS — và khi đó đặt số vòng thật cao (OWASP: 600k+ cho PBKDF2-HMAC-SHA256).

Chỉnh tham số theo phần cứng của bạn. "Cố ý chậm" nghĩa là chọn cost sao cho băm mất ~100–250 ms trên server của bạn — đủ chậm để cản kẻ tấn công, đủ nhanh để không nghẽn đăng nhập. Phần cứng mạnh lên thì tăng cost theo thời gian (bcrypt cost factor, PBKDF2 iterations, argon2 memory).

Đừng tự chế, và đừng "băm nhiều lần SHA". SHA256(SHA256(...)) nghe có vẻ chậm hơn nhưng vẫn nhanh khủng khiếp so với argon2, và dễ dính lỗi tinh vi. Luôn dùng thư viện chuẩn của ngôn ngữ (Spring Security BCryptPasswordEncoder, passlib của Python, argon2 package...). Và salt phải ngẫu nhiên mật mã (bài random) + lưu kèm hash (các định dạng như $argon2id$..., $2b$..., $6$... đã gói sẵn salt + tham số trong chuỗi).

Ba ý mang về

  1. SHA nhanh là kẻ thù khi lưu mật khẩu: đo thật SHA-256 làm 2,3 triệu hash/giây — kẻ bẻ khóa dò hết mật khẩu yếu trong giây; PBKDF2 cố ý chậm chỉ ~10/giây, chậm hơn ~231.000 lần mà người đăng nhập không hề thấy (102 ms/lần).
  2. Salt riêng mỗi user là bắt buộc: không salt thì hai người cùng mật khẩu ra hash giống hệt (rainbow table tra một phát); có salt thì khác nhau, buộc kẻ tấn công dò lại từng user.
  3. Dùng hàm chuyên dụng, đừng tự chế: argon2id (ưu tiên) / bcrypt / scrypt / PBKDF2 (chỉnh cost ~100–250 ms, tăng theo thời gian); tránh SHA trần và "băm SHA nhiều lần" — dùng thư viện chuẩn đã gói salt + tham số.

Nguồn

Phần sau ta sang cách xác thực toàn vẹn kèm khóa bí mật: HMAC — khác gì hash trần, vì sao không thể tự chế bằng cách nối khóa vào dữ liệu rồi băm, đo thật bằng openssl.