The Hashes Without Salt — Python Bug Hunt

Modelled on the LinkedIn password leak of June 2012. About 6.5 million LinkedIn password hashes were posted online.

  • Language: Python
  • Layer: Database
  • Difficulty: Medium
  • Concepts: Security, Hashing
  • Modelled on: LinkedIn · 2012
  • Visible tests: a stored password verifies and a wrong one does not; two members with the same password get different hashes
  • Reward: 50 XP for a complete fix

Briefing

Modelled on the LinkedIn password leak of June 2012. About 6.5 million LinkedIn password hashes were posted online. They were unsalted SHA-1: the same password always produced the same hash, so one precomputed guess cracked every account that shared it, and a fast hash made guessing cheap. LinkedIn confirmed the leak and moved to salted hashing; years later a far larger dump from the same breach surfaced.

passwords.py stores and checks member passwords. The storage policy in config.py has long said what it should be; the code never followed it.

Rewrite hash_password and verify_password to follow the policy.

Bug report

BUG-LI-2012 · Priority: Critical · Reported by: security

hash_password(password, rng) returns "pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>"

  • salt = rng(config.SALT_BYTES) — a fresh salt per call, from the injected rng
  • hash = hashlib.pbkdf2_hmac("sha256", password UTF-8, salt, config.ITERATIONS, config.KEY_BYTES)
  • hex is lowercase

verify_password(password, stored):

  • recomputes with the stored salt and iteration count, compares in constant time (hmac.compare_digest), returns True/False
  • anything not in the four-part format above (including a legacy bare SHA-1 hex digest) returns False — never raises

Observed: two members with the password "linkedin" have byte-identical stored hashes.

Logs

[audit] users.password_hash: 6.5M rows, 0 salts
[audit] top hash 7c4a8d09ca3762af61e59520943dc26494f8941b shared by many accounts

The code as shipped

src/accounts/passwords.py (editable)

import hashlib
import hmac

config = bug_require("./config.py")


def hash_password(password, rng):
    return hashlib.sha1(password.encode("utf-8")).hexdigest()


def verify_password(password, stored):
    candidate = hashlib.sha1(password.encode("utf-8")).hexdigest()
    return hmac.compare_digest(candidate, stored)

Read-only context: src/accounts/config.py.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More Python bug hunts.