Bỏ qua để đến nội dung
Knowledge Hub

Hash mật khẩu: vì sao sha256 có salt vẫn vỡ khi database bị lộ? Trung cấp

Thời gian đọc ước tính: 10 phút

Bản dump bảng users của một shop bị lộ. Hệ thống cũ lưu sha256(salt + password), salt 8 byte riêng cho mỗi user. Đội dev tin rằng có salt là đủ.

Trên một core CPU máy dev (Apple M5, Node 24.17), demo đo được khoảng 2,3 triệu lần sha256(salt+pw) mỗi giây; GPU còn nhanh hơn nhiều. Mật khẩu phổ biến như Mua-sam-2024 rơi rất nhanh.

Phải lưu mật khẩu thế nào để bản dump gần như vô dụng, và chuyển hàng triệu hash cũ sang mà không bắt mọi người đổi mật khẩu?

Tham số lấy từ OWASP Password Storage Cheat Sheet, truy cập ngày 2026-10-24:

Tình huống Chọn Điều kiện
Hệ thống mới Argon2id m=19456 (19 MiB), t=2, p=1 Hoặc cấu hình tương đương: 46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5, đều p=1
Không có Argon2id scrypt N=2^17, r=8, p=1 (128 MiB) Tương đương: N=2^16/p=2, 2^15/p=3, 2^14/p=5, 2^13/p=10
Bắt buộc FIPS-140 PBKDF2-HMAC-SHA256 600.000 vòng HMAC-SHA512: 220.000; HMAC-SHA1: 1.400.000, chỉ legacy
Legacy đã dùng bcrypt bcrypt work factor ≥ 10 Mật khẩu tối đa 72 byte
DB còn SHA-256/MD5 Wrap ngay argon2id(sha256_old), rehash khi đăng nhập Ghi rõ scheme từng dòng

SHA-256 được thiết kế để nhanh. Kẻ có bản dump đoán offline: không rate limit, không lockout. Tốc độ băm chính là tốc độ đoán.

Thành phần Chống được Không chống được Lưu ở đâu
Salt (ngẫu nhiên, mỗi user một salt) Rainbow table, nhận ra hai user trùng mật khẩu Brute-force từng user Cùng hash, công khai
Pepper (secret chung) Bản dump DB đơn lẻ bị crack Lộ cả DB lẫn secret Ngoài DB: KMS, secret manager
Work factor (t, vòng lặp) Tốc độ đoán trên CPU GPU song song hàng nghìn lõi Trong PHC string
Memory-hard (m, N·r) Lợi thế GPU/ASIC: mỗi lần thử cần nhiều RAM Mật khẩu quá yếu Trong PHC string

Rainbow table là bảng hash → mật khẩu tính sẵn; salt riêng mỗi user làm bảng đó vô dụng. Salt không làm từng lần đoán chậm hơn; đó là việc của work factor và bộ nhớ.

Argon2 và scrypt là hàm memory-hard: mỗi lần băm phải giữ một mảng lớn trong RAM. GPU có nhiều lõi nhưng ít RAM mỗi lõi, nên số lần thử song song bị giới hạn bởi bộ nhớ.

Pepper là tuỳ chọn; crypto.argon2 nhận nó qua tham số secret. Đổi pepper cần mật khẩu gốc, nên lưu kèm id phiên bản (PHC có tham số keyid).

$argon2id$v=19$m=19456,t=2,p=1$vCDiwTb/NCC+X03gkja2jg$RCbALSwq0iAYJoiEggLTiN/AJO8PdP7/0cj3oNRdsGo
thuật toán version tham số salt (B64, 16 byte) hash (B64, 32 byte)

B64 là base64 chuẩn bỏ dấu =. Vì tham số nằm trong chuỗi, hash cũ và mới cùng tồn tại trong một cột; code đọc tham số của từng dòng để verify, rồi biết dòng nào cần nâng cấp.

Đăng ký và đăng nhập của user Lan, đi theo demo rehash-demo.ts. Hash của Lan được tạo khi chính sách còn là m=4096, t=3:

Đăng nhập đọc tham số từ PHC, so bằng timingSafeEqual, rehash khi tham số cũ
Đăng nhập đọc tham số từ PHC, so bằng timingSafeEqual, rehash khi tham số cũBước 1/8: Đăng ký: Lan gửi mật khẩu qua TLS. API không lưu và không log giá trị này.Clientemail + mật khẩuAuth APIparse PHCrandomBytessalt 16 byteargon2idm, t, puserspassword_hashtimingSafeEqual32 bytePOST /signup
Client → Auth APIPOST /signup
Bước 1/8

Đăng ký: Lan gửi mật khẩu qua TLS. API không lưu và không log giá trị này.

Bước Trong DB Ở API
1–4 PHC m=19456,t=2,p=1 Salt mới, hash 32 byte
5–7 PHC cũ m=4096,t=3 Hash lại với tham số và salt cũ, so → true
8 PHC mới m=19456,t=2 Chỉ rehash sau khi verify đúng

Vì sao so bằng timingSafeEqual? === dừng ở byte khác đầu tiên, nên thời gian phản hồi rò thông tin. Node yêu cầu hai buffer cùng độ dài, khác thì ném ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH.

Quyết định lúc đăng nhập phụ thuộc scheme của từng dòng:

Đang tải diagram…

User không đăng nhập lại thì sao? Rehash chỉ chạy khi có mật khẩu gốc. OWASP đưa hai cách: wrap hash cũ làm input cho thuật toán mới (không cần mật khẩu), hoặc xoá hash của user bỏ đi lâu và buộc reset mật khẩu.

Wrap argon2id(sha256_old) chạy ngay bằng migration offline. OWASP lưu ý hash xếp lớp có thể dễ crack hơn, nên thay bằng argon2id(pw) ở lần đăng nhập kế tiếp.

API của Node 24:

  • crypto.argon2 có từ v24.7.0, Stability 1.2 (release candidate). memory tính bằng KiB (19 MiB là 19456); nonce (salt) nên random, ≥ 16 byte.
  • crypto.scrypt: maxmem mặc định 32 MiB, lỗi khi khoảng 128 × N × r > maxmem. Tham số OWASP cần 128 MiB: tăng maxmem, đừng hạ N.
  • Dùng bản async (argon2, scrypt, pbkdf2): chạy trên libuv threadpool, mặc định 4 thread. Bản *Sync chặn event loop.

Chọn tham số: bảng trên là mức tối thiểu. OWASP khuyên hash dưới một giây, thử trên chính server production: đo ms/hash, nhân với tải đăng nhập đỉnh, rồi mới tăng m hoặc t.

DoS ở endpoint đăng nhập: hash chậm là chi phí cho cả server. Demo: 16 lần argon2id đồng thời mất 66 ms vì xếp hàng qua 4 thread. Ước tính từ số đo, một process xử lý khoảng 320 hash/giây và cần 19 MiB cho mỗi hash đang chạy.

Cách chặn: rate limit theo tài khoản và IP, giới hạn số hash đồng thời, đặt độ dài tối đa cho mật khẩu (OWASP: mật khẩu rất dài có thể gây DoS).

bcrypt và 72 byte: bcrypt chỉ dùng 72 byte đầu; chữ tiếng Việt có dấu tốn 2–3 byte UTF-8. OWASP khuyên chặn ở 72 byte. Demo không đo bcrypt (không có package).

Demo trong examples/src/password-hashing/. Số đo đổi theo lần chạy:

Terminal window
npx --prefix examples ts-node examples/src/password-hashing/bench.ts
npx --prefix examples ts-node examples/src/password-hashing/rehash-demo.ts
node v24.17.0, CPU Apple M5, 1 thread
cách băm ms/hash thử/giây/core RAM thêm (MiB)
sha256(pw) 0.00045 2,231,823 ~0
sha256(salt+pw) 0.00043 2,338,263 ~0
pbkdf2-sha256 600k 45.1 22 0
scrypt N=2^17,r=8,p=1 156.0 6 128
argon2id m=19456,t=2,p=1 12.4 80 19
argon2id m=47104,t=1,p=1 15.6 64 46
argon2 async x16 : 66 ms tổng

Đọc kết quả:

  • “RAM thêm” = maxRSS process con trừ RSS trước khi băm; khớp m và 128 × N × r. PBKDF2 gần như không cần RAM, nên GPU song song tốt.
  • Số SHA-256 gồm cả overhead của createHash trong JS; GPU còn nhanh hơn nhiều.
1. Vector mẫu của đặc tả PHC (hunter2, secret = pepper):
khớp spec: true
bỏ pepper khớp spec: false
3. Đăng nhập với hash tham số cũ (m=4096,t=3): verify rồi rehash
sai mật khẩu: false (không rehash)
rehash: argon2id 4096/3 -> $argon2id$v=19$m=19456,t=2,p=1$f...
4. Hash SHA-256 cũ của user không đăng nhập: wrap ngay, không cần mật khẩu
scheme sau đăng nhập: argon2id
5. timingSafeEqual
khác độ dài : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

Bước 1: crypto.argon2 tái tạo đúng vector mẫu của đặc tả PHC, kể cả pepper qua secret. phc.ts chỉ minh hoạ; production dùng thư viện đã được review, không tự viết crypto.

Ưu điểm

  • Mỗi lần đoán offline tốn hàng chục ms và nhiều MiB RAM.

  • PHC string cho phép nâng tham số dần.

  • Pepper thêm một lớp khi chỉ DB bị lộ.

  • < slot="cons">
  • Đăng nhập tốn CPU, RAM; endpoint thành mục tiêu DoS.

  • Pepper khó xoay: cần mật khẩu gốc.

  • Không cứu mật khẩu quá phổ biến; vẫn cần MFA.

Nhược điểm

  1. Dùng argon2id với tham số OWASP hiện tại; ghi ngày tra cứu trong code.
  2. Đo ms/hash trên máy production; chọn tham số cao nhất mà tải đăng nhập đỉnh chịu được, dưới một giây.
  3. Lưu PHC string; có hàm needsRehash chạy sau mỗi lần verify đúng.
  4. Gọi bản async; giới hạn số hash đồng thời và rate limit theo tài khoản, IP.
  5. Đặt độ dài tối đa cho mật khẩu; bcrypt thì chặn ở 72 byte.
  6. Hash cũ: wrap ngay bằng migration, đếm số dòng còn scheme cũ, có hạn xoá.
  7. Pepper (nếu dùng) để trong secret manager; không log mật khẩu.

Lỗi hay gặp nhất khi tự viết bước verify:

So hash bằng chuỗi: rò thời gian, và bỏ qua tham số đã lưu

Hay gặp

const ok = sha256(salt + pw).toString('hex') === row.hash;

Nên viết

const { params, salt, hash } = parsePhc(row.password_hash);
const ok = timingSafeEqual(await argon2id(pw, salt, params), hash);

Vì sao

Bản sai vừa dùng hash nhanh vừa so bằng ===. Bản đúng đọc tham số từ PHC, so constant-time.

Với hệ thống còn bcrypt, bẫy nằm ở cách lách giới hạn 72 byte:

Cách hiểu sai Hệ quả Cần làm
Có salt thì SHA-256 an toàn 2,3 triệu lần thử/giây/core Hash chậm, memory-hard
Login chậm thì hạ tham số Dump dễ crack hơn Rate limit, giới hạn đồng thời
Rehash cả bảng bằng job đêm Không có mật khẩu gốc để rehash Wrap ngay, rehash lúc đăng nhập
  1. Salt chống được rainbow table, vì sao không làm brute-force một user chậm hơn?
  2. Vì sao 16 lần argon2id đồng thời mất 66 ms chứ không phải 12 ms?
  3. Vì sao needsRehash chỉ chạy sau khi verify đúng?
  4. Wrap argon2id(sha256_old) có cần mật khẩu gốc không, và vì sao vẫn phải thay bằng argon2id(pw)?