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

ImpactDetails
ConfidentialityScope: Password Exposure

Rainbow table attacks can crack unsalted hashes for common passwords in seconds.
AuthenticationScope: Account Compromise

Cracked passwords lead to unauthorized account access.
PrivacyScope: 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


CVE Examples


References

  1. MITRE. "CWE-759: Use of a One-Way Hash without a Salt." https://cwe.mitre.org/data/definitions/759.html

  2. OWASP. "Password Storage Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html