Lưu mật khẩu đúng cách: vì sao SHA-256 không đủ
Đo tốc độ đoán mật khẩu của SHA-256, PBKDF2, bcrypt, scrypt và Argon2id khi lộ backup 200.000 tài khoản, rồi chọn cách lưu cho BanHang.
Một file full backup của database BanHang bị lộ, trong đó có 200.000 tài khoản khách. Kẻ tấn công thử mật khẩu trên máy riêng, bao lâu cũng được, và giới hạn tần suất ở API không còn tác dụng. Bài này đo mỗi cách lưu cho đoán bao nhiêu lần mỗi giây, rồi đưa ra thiết kế cho BanHang.
Đọc nhanh
- SHA-256 có salt cho khoảng 350.000 lần đoán mỗi giây trên một core (Python), khoảng 19 tỷ trên một RTX 4090 (hashcat). Salt chặn bảng tính sẵn, không làm mỗi lần đoán chậm đi.
- Argon2id với tham số OWASP (19 MiB,
t=2,p=1) tốn 77 ms mỗi lần trên laptop đo bài, chậm hơn SHA-256 khoảng 27.000 lần, và cần 19 MiB bộ nhớ cho mỗi lần đoán. - Lưu nguyên chuỗi
$argon2id$...trongvarchar(255)ở bảng riêng, băm lại khi đăng nhập, bọc ngay hash SHA-256 cũ. - Băm chậm không cứu mật khẩu yếu. Vẫn cần chặn mật khẩu phổ biến, giới hạn tần suất và MFA.
1. Mô hình tấn công: bảng đã nằm trên máy người khác
File full backup lúc 22:00 ngày 2026-10-01 bị chép ra từ một máy thử nghiệm. Kẻ tấn công restore được bảng đăng nhập 200.000 dòng ở mục 4, không cần đi qua API. Họ thử từ điển: 10 triệu mật khẩu lộ từ các vụ trước, xếp theo độ phổ biến (minh họa).
thời gian = số mật khẩu thử × số lần băm cho mỗi mật khẩu thử × thời gian một lần băm
Cách lưu quyết định hai thừa số sau.
2. Ba cách lưu sai, và phần salt sửa được
Lưu nguyên văn. Restore xong là đọc được.
Mã hóa hai chiều. Ứng dụng phải giữ khóa. Với ENCRYPTBYPASSPHRASE trong stored procedure, passphrase nằm trong định nghĩa thủ tục, tức là trong backup. Khóa ở cấu hình API thì lộ thêm một file là giải mã cả 200.000 mật khẩu. Kiểm tra mật khẩu không cần lấy lại mật khẩu gốc, nên không cần khả năng đảo ngược.
SHA-256 không salt. Cùng mật khẩu cho cùng hash trên mọi hệ thống: hashlib.sha256(b"123456").hexdigest() luôn là 8d969eef…6c92. Hash của mật khẩu phổ biến đã được tính sẵn (rainbow table). Một lần băm cho mỗi mật khẩu thử đủ để so với cả 200.000 dòng: 10 triệu lần băm, khoảng 29 giây bằng Python trên một core. GROUP BY theo hash còn lộ nhóm khách dùng chung mật khẩu.
Salt là 16 byte ngẫu nhiên riêng cho từng tài khoản, băm cùng mật khẩu và lưu cạnh hash, không cần giữ bí mật. Hai khách cùng đặt 123456 có hai hash khác nhau, bảng tính sẵn vô dụng, và kẻ tấn công phải băm riêng cho từng tài khoản:
10.000.000 mật khẩu × 200.000 tài khoản = 2 × 10^12 lần băm
Salt không đổi thừa số cuối. SHA-256 được thiết kế để nhanh, và với mật khẩu, nhanh là điểm yếu. Cần một hàm chậm có chủ đích, có tham số chỉnh độ chậm (work factor), tốt nhất tốn cả bộ nhớ.
3. Đo trên một core: mỗi cách cho đoán bao nhiêu lần mỗi giây
Tham số theo OWASP Password Storage Cheat Sheet, đọc ngày 2026-10-01. Thứ tự OWASP: Argon2id (hai cấu hình trong bảng là tương đương), scrypt, bcrypt cost từ 10 cho hệ thống cũ, PBKDF2 khi phải theo FIPS-140.
Cài python -m pip install argon2-cffi==25.1.0 bcrypt==5.0.0. Gói đầu kéo theo argon2-cffi-bindings 26.1.0 (thư viện C tham chiếu). do_toc_do.py chạy mỗi cách 5 lượt và lấy trung vị:
import hashlib
import statistics
import time
import bcrypt
from argon2.low_level import Type, hash_secret_raw
MAT_KHAU = b"Banhang@2026"
SALT = bytes(range(16)) # cố định để lần đo lặp lại được; khi lưu thật, salt sinh ngẫu nhiên
def do(ten, ham, so_lan):
mau = []
for _ in range(5):
t0 = time.perf_counter()
for _ in range(so_lan):
ham()
mau.append((time.perf_counter() - t0) / so_lan)
s = statistics.median(mau)
print(f"{ten:<28} {s * 1000:10.4f} ms {1 / s:12.1f} lần/s")
do("SHA-256 + salt", lambda: hashlib.sha256(SALT + MAT_KHAU).digest(), 200_000)
do("PBKDF2-SHA256 600000", lambda: hashlib.pbkdf2_hmac("sha256", MAT_KHAU, SALT, 600_000), 3)
do("bcrypt cost 10", lambda: bcrypt.hashpw(MAT_KHAU, bcrypt.gensalt(10)), 10)
do("bcrypt cost 12", lambda: bcrypt.hashpw(MAT_KHAU, bcrypt.gensalt(12)), 3)
do("scrypt N=2^17 r=8 p=1", lambda: hashlib.scrypt(
MAT_KHAU, salt=SALT, n=2**17, r=8, p=1, maxmem=256 * 1024**2), 3)
for m, t in [(19456, 2), (47104, 1)]:
do(f"Argon2id m={m} t={t} p=1", lambda: hash_secret_raw(
MAT_KHAU, SALT, time_cost=t, memory_cost=m, parallelism=1,
hash_len=32, type=Type.ID), 10)
hashlib.scrypt cần maxmem: 128 MiB vượt giới hạn mặc định của OpenSSL, thiếu nó lệnh báo memory limit exceeded.
Máy đo: laptop Intel Core Ultra 5 125U, Windows 11, Python 3.14.3, OpenSSL 3.0.18, process ghim vào một core logic, máy vẫn chạy việc khác. Mỗi dòng là trung vị của 3 lần chạy, lệch nhau tới 44% ở dòng SHA-256, dưới 30% ở các dòng khác. Chỉ nên đọc thứ tự và bậc độ lớn.
| Cách lưu | Tham số | Một lần băm | Lần đoán/giây, 1 core | 10 triệu × 200.000 tài khoản, 1 core (ước lượng) |
|---|---|---|---|---|
| SHA-256 + salt | 1 lần | 2,9 µs | khoảng 350.000 | 66 ngày |
| PBKDF2-HMAC-SHA256 | 600.000 vòng | 864 ms | 1,2 | 54.800 năm |
| bcrypt | cost 10 | 139 ms | 7,2 | 8.800 năm |
| bcrypt | cost 12 | 590 ms | 1,7 | 37.400 năm |
| scrypt | N=2^17, r=8, p=1 (128 MiB) |
1.150 ms | 0,87 | 72.900 năm |
| Argon2id | m=19456 (19 MiB), t=2, p=1 |
77 ms | 13 | 4.900 năm |
| Argon2id | m=47104 (46 MiB), t=1, p=1 |
109 ms | 9,2 | 6.900 năm |
Cột cuối là 2 × 10^12 × thời gian một lần băm. Với Argon2id 19 MiB: 2 × 10^12 × 0,077 s = 1,54 × 10^11 s, khoảng 4.900 năm. Dòng SHA-256 còn chi phí trình thông dịch Python, nên 350.000 là cận dưới. PBKDF2 (864 ms) đã sát và scrypt (1.150 ms) đã vượt mốc "dưới 1 giây" OWASP đặt cho một lần băm, nên tham số phải đo trên đúng máy sẽ chạy.
Benchmark hashcat 6.2.6 trên một RTX 4090 cho sha256($salt.$pass), đúng dạng trong script, 19.450,8 MH/s: 2 × 10^12 lần băm hết khoảng 103 giây. PBKDF2-HMAC-SHA256 đạt 8.865,7 kH/s ở 999 vòng, quy đổi tuyến tính ra khoảng 14.800 lần mỗi giây ở 600.000 vòng (ước lượng). Bài không có số GPU có nguồn cho Argon2id và scrypt ở tham số trên. Khác biệt nằm ở thiết kế: mỗi lần đoán phải giữ và đọc ghi một vùng nhớ riêng, nên số lần đoán song song bị chặn bởi bộ nhớ chứ không chỉ bởi số lõi.
Cột "năm" dễ gây yên tâm sai, vì mật khẩu yếu nằm đầu từ điển. Thử 1.000 mật khẩu phổ biến nhất trên cả bảng là 2 × 10^8 lần băm: khoảng 0,01 giây với SHA-256 và 3,8 giờ với PBKDF2 trên RTX 4090, khoảng 178 ngày với Argon2id trên một core như máy đo, hoặc 1,8 ngày trên 100 core (ước lượng). Băm chậm biến mili giây thành ngày, nhưng không biến 123456 thành mật khẩu tốt.
4. Thiết kế cho BanHang
dbo.KhachHang là bảng temporal: mỗi UPDATE chép phiên bản cũ của dòng sang dbo.KhachHang_LichSu. Hash đặt ở đó thì mỗi lần đổi mật khẩu hay băm lại để thêm một hash cũ trong lịch sử. Hash nằm ở bảng riêng, không system-versioned, chỉ thủ tục đăng nhập được đọc:
CREATE TABLE dbo.KhachHang_DangNhap (
KhachHangId int NOT NULL
CONSTRAINT PK_KhachHang_DangNhap PRIMARY KEY CLUSTERED
CONSTRAINT FK_KhachHang_DangNhap_KhachHang REFERENCES dbo.KhachHang (KhachHangId),
MatKhauHash varchar(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
CapNhatLuc datetime2(0) NOT NULL
);
Chuỗi hash chỉ có ký tự ASCII, nên dùng varchar theo quy ước ở Kiểu dữ liệu, collation và khóa chính. Với tham số OWASP, chuỗi dài 97 byte; 255 chừa chỗ cho tham số lớn hơn và tiền tố sha256+. BIN2 so từng byte: trên Vietnamese_100_CI_AS, 'aBc' = 'abc' là đúng, nên một phép so hash lỡ viết trong SQL sẽ sai. Lệnh đã chạy trên SQL Server 2019 CU27 (LocalDB).
Chuỗi $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash> chứa thuật toán, phiên bản, tham số, salt và hash. Hàm kiểm tra đọc tham số từ chính chuỗi, nên đổi tham số không cần đổi schema. Ứng dụng đọc chuỗi theo KhachHangId rồi kiểm tra, không tìm theo hash.
dang_nhap.py dùng dict bang thay cho bảng. Dòng 1 là SHA-256 cũ của 123456, đã bọc trong Argon2id. Dòng 2 băm bằng tham số năm trước.
import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
# Cấu hình OWASP (đọc ngày 2026-10-01): Argon2id, 19 MiB, t=2, p=1
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def sha256_hex(mat_khau: str) -> str:
return hashlib.sha256(mat_khau.encode()).hexdigest()
def boc_hash_cu(sha256_cu: str) -> str:
"""Chạy một lần cho mọi dòng cũ, không cần mật khẩu: bọc SHA-256 trong Argon2id."""
return "sha256+" + ph.hash(sha256_cu)
def dang_nhap(bang: dict[int, str], khach_hang_id: int, mat_khau: str) -> bool:
luu = bang[khach_hang_id]
try:
if luu.startswith("sha256+"):
ph.verify(luu.removeprefix("sha256+"), sha256_hex(mat_khau))
bang[khach_hang_id] = ph.hash(mat_khau) # bỏ lớp SHA-256 cũ
else:
ph.verify(luu, mat_khau)
if ph.check_needs_rehash(luu):
bang[khach_hang_id] = ph.hash(mat_khau) # tham số đã đổi
except (VerifyMismatchError, InvalidHashError):
return False
return True
cu = PasswordHasher(time_cost=1, memory_cost=7168, parallelism=1) # tham số năm trước
bang = {
1: boc_hash_cu("8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"),
2: cu.hash("Banhang@2026"),
}
dau = lambda k: bang[k].rsplit("$", 2)[0] # bỏ salt và hash, chỉ in phần đầu
print("trước:", dau(1), "|", dau(2))
print(dang_nhap(bang, 1, "1234567"), dau(1))
print(dang_nhap(bang, 1, "123456"), dau(1))
print(dang_nhap(bang, 2, "Banhang@2026"), dau(2), len(bang[2]))
trước: sha256+$argon2id$v=19$m=19456,t=2,p=1 | $argon2id$v=19$m=7168,t=1,p=1
False sha256+$argon2id$v=19$m=19456,t=2,p=1
True $argon2id$v=19$m=19456,t=2,p=1
True $argon2id$v=19$m=19456,t=2,p=1 97
ph.verifygọiargon2_verifycủa thư viện C, so bằngargon2_compare: OR các byte đã XOR, chạy hết độ dài dù khác ở đâu.check_needs_rehashso tham số trong chuỗi vớiph. Chỉ lúc đăng nhập đúng mới có mật khẩu gốc để băm lại.boc_hash_culà cách nâng cấp thứ hai OWASP mô tả: hash cũ làm đầu vào cho thuật toán mới, chạy một lần cho mọi dòng. Chỉ chờ đăng nhập thì khách không quay lại giữ SHA-256 trần mãi. Backup cũ vẫn chứa SHA-256 trần; nếu chúng đã lộ, chỉ còn cách buộc đổi mật khẩu.
Chỉnh tham số. CPU cho đăng nhập ≈ L × T, với L là số lần đăng nhập mỗi giây và T là thời gian một lần băm. Minh họa: 20 lần mỗi giây lúc cao điểm × 0,077 s ≈ 1,5 core. Bộ nhớ là số lần băm đồng thời × 19 MiB; giới hạn ở 16 lần thì tối đa 304 MiB. Chạy script ở mục 3 trên máy API, tăng time_cost hoặc memory_cost khi còn dưới mục tiêu, và để check_needs_rehash nâng dần dòng cũ.
Với ASP.NET Core Identity trong .NET 8, PasswordHasher<TUser> mặc định PBKDF2-HMAC-SHA512 100.000 vòng, dưới mức 220.000 của OWASP; tăng PasswordHasherOptions.IterationCount thì UserManager tự băm lại khi đăng nhập.
Pepper, tùy chọn. Pepper là khóa bí mật chung cho mọi tài khoản, cất trong secrets vault hoặc HSM, ngoài database. OWASP mô tả cách làm: băm như thường rồi HMAC kết quả bằng pepper, nên backup một mình không đủ để thử mật khẩu nào. Cái giá là quản lý khóa: mọi máy API đọc được pepper, nên nó chỉ bảo vệ khi riêng database hay backup lộ. Mất pepper là không ai đăng nhập được, đổi pepper buộc mọi khách đổi mật khẩu.
Giới hạn tần suất và MFA. Giới hạn tần suất chống đoán online qua API, như credential stuffing (thử cặp tài khoản, mật khẩu lộ từ trang khác), và giữ cho 77 ms CPU mỗi lần thử không làm quá tải API. MFA làm mật khẩu bẻ được không đủ để vào BanHang, nhưng không bảo vệ khách dùng lại mật khẩu đó ở trang khác. Khi khách đặt mật khẩu, so với danh sách mật khẩu đã lộ và từ chối nếu trùng.
5. Những lỗi khi tự làm
Tự chế thuật toán. Biến thể hay gặp: sha256(salt + mật khẩu) lặp 1.000 lần, salt bằng KhachHangId (đoán được trước nên vẫn tính sẵn được), salt từ module random (không dành cho bảo mật), định dạng không ghi tham số. argon2-cffi lấy salt từ os.urandom(16) và ghi tham số vào chuỗi.
So hash bằng ==. Trong CPython, == trên bytes hay str dừng ở chỗ khác nhau đầu tiên, nên thời gian trả lời lộ độ dài phần đầu trùng nhau. Với hash không salt, kẻ tấn công chọn mật khẩu có hash mở đầu như ý, đo thời gian và dò dần hash đã lưu. Qua mạng việc này cần rất nhiều mẫu, salt làm nó khó hơn nữa, nhưng không đáng đánh cược. hmac.compare_digest không dừng sớm theo nội dung, chỉ có thể lộ kiểu và độ dài.
Giới hạn 72 byte của bcrypt. Chữ có dấu tốn 2 đến 3 byte UTF-8, nên mật khẩu tiếng Việt chạm giới hạn sớm hơn số ký tự:
import bcrypt
mk = "mật khẩu " * 8
print(len(mk), len(mk.encode()))
try:
bcrypt.hashpw(mk.encode(), bcrypt.gensalt())
except ValueError as e:
print(e)
72 104
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Từ bcrypt 5.0.0, hashpw và checkpw ném ValueError (đã chạy thử). Trước 5.0.0, thư viện cắt ngầm: hai mật khẩu chung 72 byte đầu đều đăng nhập được. Argon2 nhận mật khẩu tới 2^32 − 1 byte theo RFC 9106.
Ghi mật khẩu vào log. Mật khẩu lọt vào log qua body request (như HttpLoggingFields.RequestBody trong ASP.NET Core), exception kèm model đăng nhập, query string, trace APM. Lọc trường mật khẩu trước khi ghi, rồi đăng nhập bằng một mật khẩu thử và tìm nó trong log.
Đọc tiếp
- Kiểu dữ liệu, collation và khóa chính
- Kiến trúc lưu trữ SQL Server: backup mang theo những gì.
- Một request HTTPS mất bao lâu
Nguồn
- OWASP, Password Storage Cheat Sheet, đọc ngày 2026-10-01.
- RFC 9106: Argon2.
- phc-winner-argon2,
src/argon2.c:argon2_compare. - argon2-cffi 25.1.0:
PasswordHasher. - bcrypt trên PyPI: changelog 5.0.0.
- Python,
hmac.compare_digest. - Chick3nman, Hashcat v6.2.6 benchmark on the Nvidia RTX 4090.
- ASP.NET Core
release/8.0, IdentityPasswordHasher,UserManager.