Use of a One-Way Hash without a Salt
Description
Use of a One-Way Hash without a Salt occurs when software generates a hash of a password or other security-sensitive data using a cryptographic hash function but does not include a unique salt value. Without salting, identical passwords produce identical hashes, enabling rainbow table attacks where attackers use pre-computed tables to quickly reverse common password hashes. This also allows attackers to identify users with the same password by comparing hash values.
Risk
Unsalted password hashes are extremely vulnerable to rainbow table attacks. Attackers have pre-computed tables for billions of common passwords against algorithms like MD5 and SHA-1. A single database breach exposes all users with common passwords instantly. Without salts, attackers can crack millions of passwords in parallel by comparing against known hashes. The LinkedIn breach (2012) exposed 6.5 million unsalted SHA-1 password hashes that were cracked within days.
Solution
Always use a unique, randomly generated salt for each password hash. Use modern password hashing functions that handle salting automatically (bcrypt, scrypt, Argon2). The salt should be at least 16 bytes of cryptographically random data. Store the salt alongside the hash—salts are not secrets. Never use simple hash functions like MD5 or SHA-1 for passwords. Use key derivation functions with configurable work factors to slow down brute-force attacks.
Common Consequences
| Impact | Details |
|---|---|
| Confidentiality | Scope: Password Exposure Rainbow table attacks can crack unsalted hashes for common passwords in seconds. |
| Authentication | Scope: Account Compromise Cracked passwords lead to unauthorized account access. |
| Privacy | Scope: Password Pattern Detection Attackers can identify users with identical passwords across systems. |
Example Code + Solution Code
Vulnerable Code
# VULNERABLE: Password hashing without salt
import hashlib
def hash_password_vulnerable(password):
# Simple hash without salt - rainbow table vulnerable!
return hashlib.sha256(password.encode()).hexdigest()
def verify_password_vulnerable(password, stored_hash):
return hash_password_vulnerable(password) == stored_hash
# VULNERABLE: Using MD5 (fast, no salt)
def hash_password_md5(password):
return hashlib.md5(password.encode()).hexdigest()
# VULNERABLE: Weak attempt at salting - reusing salt
GLOBAL_SALT = "myappsalt"
def hash_with_static_salt(password):
# Same salt for all users - still rainbow tableable!
return hashlib.sha256((GLOBAL_SALT + password).encode()).hexdigest()
// VULNERABLE: Java password hashing without salt
import java.security.MessageDigest;
import java.util.Base64;
public class VulnerablePasswordHash {
public String hashPassword(String password) throws Exception {
// No salt - vulnerable to rainbow tables!
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);
}
// VULNERABLE: MD5 - fast and unsalted
public String hashPasswordMD5(String password) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);
}
// VULNERABLE: Static salt
private static final String STATIC_SALT = "application_salt";
public String hashWithStaticSalt(String password) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(STATIC_SALT.getBytes("UTF-8"));
byte[] hash = md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);
}
}
// VULNERABLE: Node.js password hashing without salt
const crypto = require('crypto');
function hashPasswordVulnerable(password) {
// No salt!
return crypto.createHash('sha256').update(password).digest('hex');
}
// VULNERABLE: MD5 without salt
function hashPasswordMD5(password) {
return crypto.createHash('md5').update(password).digest('hex');
}
// VULNERABLE: Same salt for everyone
const GLOBAL_SALT = 'fixed_salt_for_app';
function hashWithGlobalSalt(password) {
return crypto.createHash('sha256')
.update(GLOBAL_SALT + password)
.digest('hex');
}
// VULNERABLE: PHP password hashing without salt
<?php
function hashPasswordVulnerable($password) {
// No salt - rainbow table attack possible!
return hash('sha256', $password);
}
// VULNERABLE: MD5 - extremely weak
function hashPasswordMD5($password) {
return md5($password); // Never use this!
}
// VULNERABLE: Static salt
define('APP_SALT', 'static_application_salt');
function hashWithStaticSalt($password) {
return hash('sha256', APP_SALT . $password);
}
?>
Fixed Code
# SAFE: Using bcrypt (recommended)
import bcrypt
def hash_password_bcrypt(password):
# bcrypt automatically generates and stores salt
# Work factor (rounds) can be adjusted for security
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def verify_password_bcrypt(password, stored_hash):
return bcrypt.checkpw(password.encode(), stored_hash.encode())
# SAFE: Using Argon2 (winner of Password Hashing Competition)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # Memory usage in KB
parallelism=4 # Number of parallel threads
)
def hash_password_argon2(password):
# Argon2 automatically handles salting
return ph.hash(password)
def verify_password_argon2(password, stored_hash):
try:
ph.verify(stored_hash, password)
return True
except VerifyMismatchError:
return False
# SAFE: Using PBKDF2 with proper salt
import hashlib
import os
def hash_password_pbkdf2(password):
# Generate random salt
salt = os.urandom(32)
# Use high iteration count
iterations = 600000 # OWASP recommendation for SHA-256
# Derive key
key = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt,
iterations
)
# Store salt and hash together
return salt.hex() + ':' + key.hex()
def verify_password_pbkdf2(password, stored_hash):
salt_hex, key_hex = stored_hash.split(':')
salt = bytes.fromhex(salt_hex)
# Derive key with same parameters
key = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt,
600000
)
return key.hex() == key_hex
# SAFE: scrypt for memory-hard hashing
import hashlib
def hash_password_scrypt(password):
salt = os.urandom(32)
key = hashlib.scrypt(
password.encode(),
salt=salt,
n=2**14, # CPU/memory cost
r=8, # Block size
p=1 # Parallelization
)
return salt.hex() + ':' + key.hex()
// SAFE: Java password hashing with bcrypt
import org.mindrot.jbcrypt.BCrypt;
public class SecurePasswordHash {
// Work factor (log rounds) - 12 is good default
private static final int WORK_FACTOR = 12;
public String hashPassword(String password) {
// BCrypt generates salt automatically
return BCrypt.hashpw(password, BCrypt.gensalt(WORK_FACTOR));
}
public boolean verifyPassword(String password, String storedHash) {
return BCrypt.checkpw(password, storedHash);
}
}
// SAFE: Using Argon2 in Java
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
public class Argon2PasswordHash {
private final Argon2 argon2 = Argon2Factory.create(
Argon2Factory.Argon2Types.ARGON2id
);
public String hashPassword(String password) {
// Parameters: iterations, memory, parallelism
return argon2.hash(3, 65536, 4, password.toCharArray());
}
public boolean verifyPassword(String password, String storedHash) {
return argon2.verify(storedHash, password.toCharArray());
}
}
// SAFE: Using PBKDF2 with proper salt
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class PBKDF2PasswordHash {
private static final int ITERATIONS = 600000;
private static final int KEY_LENGTH = 256;
private static final int SALT_LENGTH = 32;
public String hashPassword(String password) throws Exception {
// Generate random salt
SecureRandom random = new SecureRandom();
byte[] salt = new byte[SALT_LENGTH];
random.nextBytes(salt);
// Derive key
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(),
salt,
ITERATIONS,
KEY_LENGTH
);
SecretKeyFactory factory =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = factory.generateSecret(spec).getEncoded();
// Combine salt and hash
String saltBase64 = Base64.getEncoder().encodeToString(salt);
String hashBase64 = Base64.getEncoder().encodeToString(hash);
return saltBase64 + ":" + hashBase64;
}
public boolean verifyPassword(String password, String storedHash) throws Exception {
String[] parts = storedHash.split(":");
byte[] salt = Base64.getDecoder().decode(parts[0]);
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(),
salt,
ITERATIONS,
KEY_LENGTH
);
SecretKeyFactory factory =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = factory.generateSecret(spec).getEncoded();
String hashBase64 = Base64.getEncoder().encodeToString(hash);
return hashBase64.equals(parts[1]);
}
}
// SAFE: Node.js with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
async function hashPassword(password) {
// bcrypt generates salt automatically
return await bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, storedHash) {
return await bcrypt.compare(password, storedHash);
}
// SAFE: Using Argon2
const argon2 = require('argon2');
async function hashPasswordArgon2(password) {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
}
async function verifyPasswordArgon2(password, storedHash) {
return await argon2.verify(storedHash, password);
}
// SAFE: Using scrypt (built into Node.js)
const crypto = require('crypto');
const { promisify } = require('util');
const scryptAsync = promisify(crypto.scrypt);
async function hashPasswordScrypt(password) {
const salt = crypto.randomBytes(32);
const derivedKey = await scryptAsync(password, salt, 64, {
N: 16384, // CPU/memory cost
r: 8, // Block size
p: 1 // Parallelization
});
return `${salt.toString('hex')}:${derivedKey.toString('hex')}`;
}
async function verifyPasswordScrypt(password, storedHash) {
const [saltHex, keyHex] = storedHash.split(':');
const salt = Buffer.from(saltHex, 'hex');
const storedKey = Buffer.from(keyHex, 'hex');
const derivedKey = await scryptAsync(password, salt, 64, {
N: 16384,
r: 8,
p: 1
});
return crypto.timingSafeEqual(derivedKey, storedKey);
}
// SAFE: PHP password hashing (use built-in functions!)
<?php
function hashPasswordSecure($password) {
// password_hash automatically generates salt and uses bcrypt
return password_hash($password, PASSWORD_DEFAULT, [
'cost' => 12 // Work factor
]);
}
function verifyPasswordSecure($password, $storedHash) {
return password_verify($password, $storedHash);
}
// Check if password needs rehashing (e.g., work factor increased)
function needsRehash($storedHash) {
return password_needs_rehash($storedHash, PASSWORD_DEFAULT, [
'cost' => 12
]);
}
// SAFE: Using Argon2 (PHP 7.2+)
function hashPasswordArgon2($password) {
return password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 3
]);
}
// Full example with rehashing
function authenticateUser($password, $storedHash) {
if (!password_verify($password, $storedHash)) {
return false;
}
// Rehash if algorithm/cost changed
if (password_needs_rehash($storedHash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
// Update hash in database
updateUserPasswordHash($newHash);
}
return true;
}
?>
Exploited in the Wild
LinkedIn Breach (2012)
6.5 million unsalted SHA-1 password hashes were leaked. Within hours, security researchers cracked over 60% of them using rainbow tables.
Adobe Breach (2013)
153 million user accounts were exposed with passwords encrypted using 3DES (not even hashed). The lack of salting made pattern analysis trivial—identical passwords had identical ciphertexts.
Dropbox Breach (2012/2016)
68 million credentials were exposed. While Dropbox used bcrypt for newer accounts, older accounts had unsalted SHA-1 hashes.
Tools to test/exploit
-
Hashcat — password cracking with rainbow tables.
-
John the Ripper — password cracker.
-
CrackStation — online rainbow table lookup.
-
Have I Been Pwned — check for password exposure.
CVE Examples
-
CVE-2019-10790 — Unsalted password storage.
-
CVE-2021-22893 — Weak password hashing.
-
CVE-2020-5735 — MD5 password hashing vulnerability.
References
-
MITRE. "CWE-759: Use of a One-Way Hash without a Salt." https://cwe.mitre.org/data/definitions/759.html
-
OWASP. "Password Storage Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html