Hashing kata sandi yang tidak aman (CWE-916): mengapa SHA-256 tidak cukup dan cara menyimpan kata sandi
Menyimpan kata sandi sebagai digest SHA-256, SHA-1, atau MD5 tanpa salt berarti tabel yang bocor bisa diuji dengan miliaran tebakan per detik di GPU, dan semua pengguna dengan kata sandi yang sama memakai satu hash yang sama. Bagaimana salt, fungsi yang boros memori seperti Argon2id, rehash saat login, dan verifikasi tiruan melindungi database yang dicuri, dengan contoh Python memakai argon2-cffi.
Penjelasan Sederhana (ELI5)
Tempat penitipan mantel menyimpan salinan setiap tiket untuk memeriksa mantel mana milik siapa. Jika salinannya hanya fotokopi biasa, pencuri yang mengambil mapnya bisa membawa semua mantel. Hash yang cepat seperti fotokopi yang sedikit buram: pencuri dengan pemindai cepat bisa menjernihkan jutaan per detik. Hash yang lambat dan boros memori seperti salinan yang dikunci dalam brankas kecil yang butuh satu detik penuh dan meja kerja besar untuk dibuka, satu brankas per tiket dengan kunci yang berbeda-beda. Pemilik membuka satu brankas di pintu tanpa merasakan jeda; pencuri harus membuka jutaan.
Konsep Kunci & Istilah
- Hash cepat
- SHA-256, SHA-1, dan MD5 dirancang untuk cepat. Satu GPU modern menghitung miliaran hash SHA-256 per detik, sehingga kata sandi yang pendek atau umum jebol dalam hitungan jam oleh brute force dan serangan kamus.
- Salt
- Nilai acak yang disimpan bersama setiap hash agar kata sandi yang sama menghasilkan hash berbeda. Tanpa salt, satu tabel yang sudah dihitung sebelumnya bisa membobol semua akun sekaligus, dan hash yang sama mengungkap pengguna yang memakai kata sandi sama.
- Fungsi boros memori (Argon2id)
- Argon2id membuat setiap tebakan memakan waktu dan memori, sehingga sebagian besar keunggulan GPU hilang. Batas minimum OWASP adalah memori 19 MiB, dua iterasi, dan satu jalur (
memory_cost=19456,time_cost=2,parallelism=1); scrypt dan bcrypt adalah alternatif yang dapat diterima. - String PHC dan rehash
PasswordHasher.hash()mengembalikan string seperti$argon2id$v=19$m=19456,t=2,p=1$...yang berisi algoritma, parameter, salt, dan hash.check_needs_rehash()memberi tahu ketika parameter yang tersimpan lebih lemah dari yang sekarang, sehingga hash bisa ditingkatkan pada login berhasil berikutnya.- Waktu respons dan enumerasi akun
- Jika email yang tidak dikenal langsung dijawab sementara email asli butuh waktu satu verifikasi hash, waktu respons membocorkan akun mana yang ada. Memverifikasi terhadap hash tiruan membuat kedua jalur memakan waktu yang sama.
Alur Serangan Langkah demi Langkah
Tabel pengguna bocor
Injeksi SQL, cadangan yang terbuka, atau laptop yang dicuri memberi penyerang kolom password_hash.
Penyerang menjalankan cracker GPU
SHA-256 tanpa salt memungkinkannya meng-hash daftar kata sekali lalu membandingkannya dengan semua baris. Akun uji dengan kata sandi Summer2024! menunjukkan betapa cepat pola umum jebol.
Kata sandi yang sama jebol bersamaan
Setiap pengguna yang memilih kata sandi yang sama punya digest yang sama, jadi satu hash yang jebol mengekspos mereka semua.
Kata sandi dipakai ulang di tempat lain
Kata sandi yang jebol dicoba di akun email, bank, dan cloud. Pada 2012 LinkedIn kehilangan sekitar 6,5 juta hash kata sandi SHA-1 tanpa salt, dan pada 2016 sekumpulan sekitar 117 juta kredensial dari insiden yang sama dijual.
Kode Sumber: Rentan vs Aman
# users.py: satu digest cepat tanpa salt per kata sandi
import hashlib
from .db import db
# kata sandi sama, hash sama, miliaran tebakan per detik di GPU
def create_user(email: str, password: str) -> None:
digest = hashlib.sha256(password.encode()).hexdigest()
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))
# email tak dikenal langsung dijawab, jadi waktu membocorkan akun yang ada
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
# users.py: Argon2id dengan salt per kata sandi dan peningkatan saat login
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# minimum OWASP untuk Argon2id: 19 MiB, 2 iterasi, 1 jalur
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# string yang disimpan memuat algoritma, parameter, salt, dan hash
def create_user(email: str, password: str) -> None:
if not 12 <= len(password) <= 128:
raise ValueError("password must be 12 to 128 characters")
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))
# pengguna tak dikenal tetap memakan satu verifikasi; hash lemah ditingkatkan
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
stored = row[1] if row else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if row is None:
return False
if hasher.check_needs_rehash(stored):
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
return True
Daftar Periksa Penguatan Sistem Rekayasa
- Hash kata sandi dengan Argon2id (memori minimal 19 MiB, 2 iterasi, 1 jalur), scrypt, atau bcrypt; tambahkan aturan Semgrep yang menandai panggilan
hashlibpada variabel kata sandi dan jalankan Bandit B324 untuk MD5 dan SHA-1. - Simpan string PHC lengkap yang dikembalikan library, dan jangan pernah memisahkan atau memakai ulang salt-nya.
- Panggil
check_needs_rehash()setelah setiap login berhasil dan ganti hash yang dibuat dengan parameter lebih lemah. - Verifikasi terhadap hash tiruan ketika akun tidak ada, agar waktu respons tidak membocorkan email yang terdaftar.
- Wajibkan minimal 12 karakter, izinkan frasa sandi panjang hingga setidaknya 64, dan tolak kata sandi yang ada di daftar kebocoran.