Weak hashing for sensitive data

Weak hashing for sensitive data

Description

MD5 and SHA-1 are unsuitable where collision resistance is required. Password storage needs a dedicated password-hashing function with a salt and an adjustable work factor, rather than these fast hashes. An unkeyed hash also cannot authenticate a download link or token: attackers can change its contents and recompute the hash without finding a collision.

Potential impact

  • Authentication bypass or forgery: Collisions can defeat integrity checks that rely on a weak hash, depending on the verification scheme.
  • Password disclosure: Fast, unsalted hashes make large-scale guessing and precomputed lookup attacks easier.
  • Weakened integrity: Modified data may be accepted as authentic.
  • Forged links or tokens: An attacker can recompute an unkeyed hash after altering the data.

Remediation

  • Password storage: Prefer Argon2id, or use an appropriate bcrypt, scrypt, or PBKDF2-HMAC-SHA256 configuration. Use random salts and sufficient computational or memory cost, and compare derived values with a constant-time function such as hmac.compare_digest. For PBKDF2-HMAC-SHA256, use at least 600,000 iterations and a random salt of at least 16 bytes; tune the cost for the service's capacity.
  • Integrity and authentication: Use a keyed MAC such as HMAC-SHA256 when authenticity is required. Retire MD5 and SHA-1 from security-sensitive uses; use SHA-256/512 or SHA-3 where an unkeyed digest is appropriate.
  • Migration: Rehash passwords at login and store the algorithm and parameters with each hash so they can be upgraded.

Examples

Before

python
import hashlib

# Weak MD5/SHA-1 use for passwords and tokens

def store_password_md5(username: str, password: str) -> str:
    # BAD: fast MD5 password hashing without a salt
    digest = hashlib.md5(password.encode("utf-8")).hexdigest()
    return f"{username}:{digest}"

def sign_download_link_sha1(user_id: str, expires: int) -> str:
    # BAD: an unkeyed SHA-1 digest does not authenticate the data
    data = f"{user_id}:{expires}".encode()
    sig = hashlib.sha1(data).hexdigest()
    return f"{user_id}:{expires}:{sig}"

After

python
import os
import hmac
import base64
import hashlib
import secrets

# Password storage with PBKDF2-HMAC-SHA256

def hash_password(password: str) -> str:
    salt = os.urandom(16)
    iterations = 600_000
    dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
    return f"pbkdf2_sha256${iterations}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"

def verify_password(password: str, stored: str) -> bool:
    scheme, iters_s, b64_salt, b64_dk = stored.split("$")
    if scheme != "pbkdf2_sha256":
        return False
    salt = base64.b64decode(b64_salt)
    iterations = int(iters_s)
    true_dk = base64.b64decode(b64_dk)
    test_dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
    return hmac.compare_digest(true_dk, test_dk)

# Integrity/authentication with HMAC-SHA256 (requires a secret key)

def sign_download_link(user_id: str, expires: int, secret_key: bytes) -> str:
    # Generate secret_key with secrets.token_bytes(32), for example, and store it securely
    msg = f"{user_id}:{expires}".encode()
    mac = hmac.new(secret_key, msg, hashlib.sha256).hexdigest()
    return f"{user_id}:{expires}:{mac}"

Explanation:

  • Before: Fast, unsalted MD5 makes password guessing easier. The unkeyed SHA-1 link hash can be recalculated after changing the link's contents.
  • After: PBKDF2-HMAC-SHA256 uses 600,000 iterations and a random salt to increase guessing cost; hmac.compare_digest reduces timing leakage in comparison. HMAC-SHA256 authenticates link data using a secret key that must be protected.

References