Skip to content
About
Main content

Crack a hashed phone number in seconds

Break it on purposeNeeds Python

Problem

an identifier is often treated as anonymizing it, but a 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 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

With a per-record salt, how long does it take to recover one hashed 7-digit phone number?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    crack-a-hashed-phone-number-in.py87 linesDownload
    View 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()
  3. 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
Knobs
Knob · defaultWhat it changes
--digits7 Number of digits in the phone numbers.
--seed42 Random seed for reproducibility.

Our run

Recovering one hashed number

Recovering one hashed number
HashingTime to recoverRecovered
unsalted5.25 µsyes
per_record_salt255 msyes
Reference runApple M2 · 8 cores · 8 GB
Output · uv run crack-a-hashed-phone-number-in.py --digits 7
--- 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.
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20 · 2 runs

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 (an HMAC) with the key management that implies.