Fuzzy Hashing: Finding Malware Variants with ssdeep, TLSH, and imphash
Exact hashes break the moment an attacker flips one byte. Fuzzy hashing survives mutation: how ssdeep, TLSH, and imphash work, and when to reach for each.
The one-byte problem
In MD5 vs SHA-256 we covered which cryptographic hash to use for identifying files. Here is the uncomfortable follow-up: for hunting malware, both fail the same way.
Cryptographic hashes are designed so that changing a single bit produces a completely different digest. That is exactly what you want for integrity, and exactly what an attacker wants for evasion. Flip one byte in a padding section, rebuild with a new timestamp, repack the binary: the SHA-256 changes entirely, your blocklist goes blind, and the "new" sample is 99.9% identical to the one you already analyzed. This is why hash-based detection sits at the very bottom of the pyramid of pain, and why malware families ship thousands of unique hashes per campaign.
Fuzzy hashing (similarity hashing) attacks the problem from the other direction: instead of answering "is this the exact same file?", it answers "how similar is this file to one I know?" Three tools dominate in practice, and they work in three different ways.
ssdeep: context-triggered piecewise hashing
ssdeep is the veteran, built on context-triggered piecewise hashing (CTPH). Instead of hashing the whole file in one pass, it uses a rolling hash over a sliding window to find "trigger points" in the content, splits the file into pieces at those points, hashes each piece, and concatenates the results into a compact signature.
The trick is that trigger points depend on local content, not absolute offsets. Insert 200 bytes in the middle of a file and the pieces before and after the insertion still hash the same, so most of the signature survives. Comparing two signatures yields a match score from 0 to 100.
$ ssdeep -b sample_v1.exe sample_v2.exe
ssdeep,1.1--blocksize:hash:hash,filename
3072:kAl2sPMdyXvS9Qp0oNb5ZFmJ+wG7QIrq:kAlxKyXvSep0oNbtmJG7z,"sample_v1.exe"
3072:kAl2sPMdyXvS9Qp0oNb5ZFmJ+wG7QIrK:kAlxKyXvSep0oNbtmJG7K,"sample_v2.exe"
$ ssdeep -d sample_v1.exe sample_v2.exe
sample_v2.exe matches sample_v1.exe (96)
For hunting, the useful mode is matching a directory of new samples against a saved signature list:
$ ssdeep -b known_family/*.exe > family.ssdeep
$ ssdeep -bm family.ssdeep incoming/*.exe
incoming/dropper_2024.exe matches family.ssdeep:sample_v1.exe (83)
A score of 96 means near-identical; anything above roughly 60 deserves a look. Two caveats: a score of 0 does not prove the files are unrelated (it only means CTPH found no common pieces), and ssdeep can only compare signatures whose block sizes are within one step of each other, so files of very different sizes often cannot be compared at all.
TLSH: locality-sensitive hashing
TLSH (Trend Micro Locality Sensitive Hash) takes a statistical approach. It slides a 5-byte window across the file, tallies features into buckets, and encodes the resulting distribution (plus length and quartile information) into a fixed 72-character digest. Because it is locality-sensitive, similar inputs land on nearby digests by construction.
The output is a distance, not a similarity: 0 means effectively identical, and larger numbers mean more different. There is no fixed ceiling.
$ tlsh -f sample_v1.exe
T1A4F19C176390D2B2E85B0670AA65D7315F31BC423827D19F53A80E5C1F32ED81A3B196 sample_v1.exe
$ tlsh -c sample_v1.exe -f sample_v2.exe
28 sample_v1.exe sample_v2.exe
Rules of thumb for the distance: under 30 is a strong match, 30 to 100 is worth investigating, and beyond 150 treat the files as unrelated. Tune the threshold to your tolerance: research on large corpora found a cutoff near 30 keeps false positives low, while 100 catches more variants at the cost of noise.
TLSH has two practical advantages over ssdeep. It is more resistant to deliberate evasion (an attacker can craft insertions that shred ssdeep signatures much more easily than they can shift a whole byte distribution), and distances support efficient nearest-neighbor indexing, which is why TLSH scales to clustering millions of samples where pairwise ssdeep comparison becomes quadratic pain. It is also the similarity hash VirusTotal exposes on every file page.
imphash: hashing what a PE imports
imphash is the odd one out: it is not a byte-similarity hash at all. For Windows PE files, it takes the import table (which DLLs and functions the binary links against, in order), normalizes the names, and computes an MD5 over the result.
$ python3 -c "import pefile; print(pefile.PE('sample_v1.exe').get_imphash())"
f34d5f2d4577ed6d9ceec516c1f5a744
The insight: recompiling, restamping, or appending data changes every byte-level hash, but if the author did not change what the program does, the import table (a fingerprint of the build environment and source) often stays identical. Two samples sharing an imphash were very likely built from the same codebase or toolkit, which makes imphash a fast pivot for campaign tracking.
This is why imphash keeps earning its place in threat reports: when a vendor writes "all samples in this campaign share imphash f34d...", they are telling you the actor reused one build setup across the whole operation, and you can pivot on that value in sandboxes and sample repositories to surface related binaries that no byte-level hash would connect.
Its blind spots are equally sharp. Packed samples are a trap: the imphash you compute is the packer's import table, so thousands of unrelated families packed with UPX share an imphash. Generic loader stubs that resolve imports at runtime yield near-useless common values. And unlike ssdeep or TLSH, imphash is binary: it matches or it does not, with no similarity gradient. Treat a shared imphash as a lead to verify, never as a verdict.
Choosing the right tool
| ssdeep | TLSH | imphash | |
|---|---|---|---|
| Method | CTPH (piecewise, rolling hash) | Locality-sensitive statistics | MD5 of PE import table |
| Applies to | Any file | Any file over ~50 bytes | Windows PE only |
| Output | Score 0 to 100 (higher = closer) | Distance (lower = closer) | Exact match or nothing |
| Scales to millions | Poorly (pairwise) | Yes (indexable) | Yes (exact lookup) |
| Evasion resistance | Low | Moderate | Low (packing, runtime resolution) |
In practice they are complements, not competitors:
- Triage a new sample: compute all three alongside SHA-256. They are cheap.
- "Have we seen something like this?" ssdeep against your local incident corpus; TLSH against large repositories.
- Cluster a family or track a campaign: TLSH distances for byte-level lineage, imphash for shared tooling.
- Blocklist or IOC feed: none of them. Fuzzy hashes are hunting and clustering tools; their false-positive behavior makes them poor blocking indicators.
Know the shared limits too. All three compare representations, not behavior: a packed or encrypted payload defeats byte-similarity entirely (unpack first, then hash), and none of these are cryptographic, so near-matches can occur between unrelated files that happen to share structure, especially small files with common headers. Every fuzzy match is a hypothesis to confirm with actual analysis.
When you look up a hash on mlab.sh, enrichment covers these angles for known samples, and the free hash generator handles the exact-hash side of the workflow. For a deeper grounding in how hashing fits into detection, the malware analysis modules on academy.mlab.sh build up from these fundamentals.
Exact hashes tell you what a file is. Fuzzy hashes tell you what it resembles, and in malware work, resemblance is where the investigation starts.