Crack a hashed phone number in seconds
Break it on purposeNeeds Python
Problem
Hashing an identifier is often treated as anonymizing it, but a hash of a value from a small domain is reversible by enumeration: hash every possible input once, keep a table from hash to input, and every hashed value in the data is a lookup. A phone number, a national ID or a date of birth has a domain small enough to enumerate on a laptop.
Turn "hashing is not anonymization" into a measured number. Hash all 10^7 seven-digit numbers with SHA-256 into a table from hash to number, then recover a hashed target by lookup. Repeat with a per-record random salt stored beside the hash, and time recovering that one record.
The question. How long does it take to build the table, and what does a per-record salt change?
Predict
The unsalted table is built in tens of seconds on a laptop (10^7 hashes), and every number is recovered by lookup. Extrapolate to 10^10 digits: about 1,000 times more work, still hours on one machine and seconds on a GPU cluster. With a per-record salt the single table no longer works (the join is gone too).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
crack-a-hashed-phone-number-in.py # /// script # requires-python = ">=3.10" # dependencies = [] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Crack a hashed phone number in seconds. Demonstrates how fast an unsalted hash lookup table can be built and used to crack a hashed phone number, and contrasts this with per-record salts.""" import argparse import hashlib import time import random import string import json def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument("--digits", type=int, default=7, help="Number of digits in the phone numbers.") parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility.") parser.add_argument("--json", type=str, help="Also write the results to this JSON file.") args = parser.parse_args() random.seed(args.seed) domain_size = 10 ** args.digits results = {"summary": {}, "tables": {"cracks": []}} # --- Part 1: Unsalted Hash Cracking --- print("--- Part 1: Unsalted Hashing ---") t0 = time.time() # Build dictionary from hash to input table = {hashlib.sha256(f"{n:0{args.digits}d}".encode()).hexdigest(): f"{n:0{args.digits}d}" for n in range(domain_size)} build_time = time.time() - t0 print(f"Table of {domain_size} hashes built in {build_time:.2f} seconds.") # Pick a random target to crack target_number = f"{random.randint(0, domain_size - 1):0{args.digits}d}" target_hash = hashlib.sha256(target_number.encode()).hexdigest() t1 = time.time() cracked_number = table[target_hash] crack_time = time.time() - t1 print(f"Cracked target hash {target_hash[:8]}... -> {cracked_number} in {crack_time:.5f} seconds.") results["summary"]["unsalted_build_time_s"] = build_time results["summary"]["unsalted_crack_time_s"] = crack_time results["tables"]["cracks"].append({ "type": "unsalted", "time_s": crack_time, "success": cracked_number == target_number }) # --- Part 2: Per-Record Salt --- print("\n--- Part 2: Per-Record Salt ---") num = f"{random.randint(0, domain_size - 1):0{args.digits}d}" salt = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) target_salted_hash = hashlib.sha256((salt + num).encode()).hexdigest() print(f"Target Record - Hash: {target_salted_hash[:8]}..., Salt: {salt}") t2 = time.time() salted_cracked = False salted_crack_time = 0 for n in range(domain_size): guess = f"{n:0{args.digits}d}" if hashlib.sha256((salt + guess).encode()).hexdigest() == target_salted_hash: salted_crack_time = time.time() - t2 print(f"Cracked salted hash -> {guess} in {salted_crack_time:.2f} seconds.") salted_cracked = True break results["summary"]["salted_crack_time_s"] = salted_crack_time results["tables"]["cracks"].append({ "type": "per_record_salt", "time_s": salted_crack_time, "success": salted_cracked }) # --- Output JSON --- if args.json: with open(args.json, "w") as f: json.dump(results, f, indent=2) if __name__ == "__main__": main()3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run crack-a-hashed-phone-number-in.py uv run crack-a-hashed-phone-number-in.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --digits7 | Number of digits in the phone numbers. |
| --seed42 | Random seed for reproducibility. |
Our run
Recovering one hashed number
| Hashing | Time to recover | Recovered |
|---|---|---|
| unsalted | 5.25 µs | yes |
| per_record_salt | 255 ms | yes |
--- Part 1: Unsalted Hashing ---
Table of 10000000 hashes built in 10.75 seconds.
Cracked target hash 504c208e... -> 1867825 in 0.00001 seconds.
--- Part 2: Per-Record Salt ---
Target Record - Hash: 00c0605d..., Salt: TpigTHKb
Cracked salted hash -> 0419610 in 0.25 seconds.What we found
Hashing all 10^7 seven-digit numbers took 10.7 s, within the chapter's "tens of seconds" (six digits took 962 ms). After that, recovering a hashed number was a dictionary lookup of 5.25 µs.
The per-record salt removed the table: no single precomputation covers every record. It did not protect the record, though. Enumerating the domain again with that record's salt found the number in 255 ms, because the salt is stored beside the hash and the domain is still only 10^7 values.
Read it
A salt stored beside the hash defeats precomputation and joins across tables, not enumeration: the cost moves from one table for everyone to one pass per record, and for a small domain one pass is seconds. A secret global salt, which the attacker does not have, stops the table from being built at all; at that point the salt is acting as a key, and the honest name for the scheme is keyed hashing (an HMAC) with the key management that implies.