Sign in

Fingerprinting and library identification

Two ELF executables in the public corpus. 02c1abdd… is 239,016 bytes; 0513ba35… is 334,312 bytes. Their sha256, md5, sha1, GNU build-id and TLSH all differ. One still carries its function names (tool_create_output_file, create_dir_hierarchy); the other is stripped down to sub_210e0. Four derived hashes over them come out identical:

                 02c1abdd…  239,016 B    0513ba35…  334,312 B
build-id         2d1b3885e137ff4b…       0ece1ef25bd636ef…
tlsh             T18F343A17A5851CB…      T110646C13F9821C7…
import_md5       0ce141cf4e755e54…       3491189c6aa3f6fc…
symbolhash       181ca76ef0473133…       98202f531322badd…
──── identical ────────────────────────────────────────────
aspecthash       9a6be24b0edba286…       9a6be24b0edba286…
syscallhash      6f6ab0a72af9ab38…       6f6ab0a72af9ab38…
indicator_hash   5eda02c0cbce9a90…       5eda02c0cbce9a90…
hardening_hash   6b2a964b50b008a5…       6b2a964b50b008a5…

Both files are curl, built by different Linux distributions — same glibc 2.34, but minimum-kernel ABI tags of 3.2.0 and 4.3.0. Identification runs in three layers over what no whole-file hash can say:

Byte hashes and derived hashes

What a hash is computed over decides whether it survives a rebuild. The four that agree across the pair above are computed over derived sets rather than over bytes:

HashComputed overThis pairWhy
sha256 / md5 / sha1every bytedifferdifferent files
GNU build-idthe linker's build notedifferdifferent builds
tlsha rolling window over contentdifferdifferent builds
import_md5on ELF, the undefined dynamic symbol names alone — sorted, lowercased, comma-joined, undeduplicated. No library name or soname enters the digestdiffer0513ba35… links libz.so.1 and 02c1abdd… does not, so the undefined-symbol sets differ
symbolhashthe symbol table's namesdiffer02c1abdd… kept its symbol table
aspecthashall twelve behavioural aspect sets flattened to aspect:name lines — file I/O, network, process control, IPC, memory, crypto, time, signals, threading, dynamic code, system info, hardwareidenticala derived set, not bytes
syscallhashthe union of syscall and libc-wrapper namesidenticala derived set, not bytes
indicator_hashthe set of indicator rule ids that firedidenticala derived set, not bytes
hardening_hashthe canonicalised hardening feature bitsidenticala derived set, not bytes

Corpus similarity ranking

Three signals score a neighbourhood; only two of them share a ranked list, and only one separates the sibling curl from unrelated software. Scoring 02c1abdd… against the 3,899 other binaries in the similarity index:

rank  name          cosine    weighted-Jaccard   bigram dHash
1     curl          0.98677   0.7104             16
2     libdpv.so.3   0.98458   0.1474             23
3     bsdtar        0.97969   0.1380             13
4     ssh-agent     0.97103   0.0624             15
5     scp           0.98243   0.0527             20
SignalComputed overRoleBehaviour on this pair
Cosinea 106-dimension feature vector — 24 structural counts (Objective-C, C++, Swift, framework, path and entitlement tallies, the log of the file size, the text-section ratio), 12 aspect counts, 12 ATT&CK tactic counts, 10 capability counts, 10 indicator-category counts, 8 derived action booleans, 8 behavioural-signature counts, 7 syscall-surface counts, 7 consistency-flag counts, 5 dynamic-loader counts, 3 entropy values — L2-normalised and log-transformedfirst stage of the ranked list: top 500 by approximate nearest neighbour, visibility- and format-scopedseparates nothing — the sibling curl and an unrelated shared library sit 0.002 apart, and scp outranks ssh-agent
Weighted Jaccard40 behavioural set axes, each member discounted by how many binaries in the corpus carry it, so a framework or syscall every ELF uses contributes almost nothing. The discount engages only at 1,000 or more indexed binaries; below that the axes carry flat weightssecond stage of the same list — the rerank, and the answer. The published score is 0.4 × cosine + 0.6 × Jaccardopens a 4.8× gap between the true sibling and the next candidate
Bigram dHasha 64-bit difference hash over the 256×256 byte-bigram picture of the file, compared by Hamming distancea separate neighbour surface with its own hash registry — it never enters the ranked list aboveputs bsdtar (13) closer than the sibling curl (16)

The dHash bands describe the wrong relationship in both directions:

Hamming distanceDocumented relationshipObserved
5 or undertwo recompiles
15 or undersame family, different versionbsdtar at 13 — a different program, Jaccard 0.138
16 to 25between the documented bandsthe two curl builds at 16 — actual siblings, outside even the family band
above 25unrelated

On the byte picture alone bsdtar sits closer to 02c1abdd… than the sibling curl does.

Signature mechanisms

Five mechanisms match code, and each gives up something to survive one of the four losses that stand between a signature and a match: relocation and PIE rewrites of operands, whole-symbol-table stripping, recompilation by a different toolchain and version, and recompilation for a different instruction set. A signature that matches only identical bytes answers a question a sha256 already answered.

MechanismSurfaceWhat it matchesCarries a versionPrecision
Quad-hash (Ghidra FunctionID)ELFopcode prefixes + relocation-aware operandsexact on a dynamically linked query, advisory on a static one
FLARE FLIRTPE (MSVC ABI)byte pattern + mask + CRC16exact
Body hashMach-Ofirst 16 bytes of a SHA-256 over the function bodyexact
Trained FLIRT patternsELF / PE / Mach-Obyte pattern + mask + CRC16exact
Curated size + string / prologueanysize window with required strings, or a prologue hashfuzzy

When two of them name the same address, a fixed precedence decides:

RankName sourceEvidence it rests on
0a symbol the binary still carriesthe file itself — a matched name never overwrites one the binary already told you
1curator assertiona human
2curated signaturematched data references and size
3curated signaturematched prologue hash and size
4mined hasha byte-identical body, operands bound
5re-anchored name, or a mined hash whose operand half was waiveda semantic token set, or a body match that never bound its operands

Quad-hash is the version-bearing tier and the only one that is relocation-aware by construction. Per instruction it splits an opcode prefix from the scalar operand bytes. The loose hash replaces every operand with the sentinel 0xfeeddead, so PIE rewrites, jump-table layout drift and ASLR offsets do not perturb it. The tight hash keeps the literal operand bytes except where the instruction's byte range overlaps a relocation entry, where the sentinel goes back in — so a call site whose immediate the dynamic linker patches hashes the same across distributions. Alignment NOPs are dropped from the hash and from the instruction count; call instructions still hash but are excluded from the count. Bodies under 32 bytes are skipped as noise; the hash streams up to 8,192 instructions.

On a statically linked binary the tight hash cannot bind — the case the bill of materials exists for. A fully static image carries no relocation coverage, so its tight hash is computed over literal operands the signature masked out, and would veto every otherwise-good hit. The match falls back to the loose hash plus the instruction count alone, and a hit clearing only that pair is stamped at the weakest tier above — a guess that never displaces a name. That two-field key is coarse enough to need its own filter: a key appearing under six or more distinct library names is a generic opcode skeleton — a tiny getter, initialiser or wrapper many unrelated projects vendor — and is deleted at corpus-build time, removing 628 keys and 57,717 entries, 3.14% of the mined total, leaving the 1,777,840 served.

The curated tier is the only one that reads meaning rather than shape. 67 signatures across 12 libraries — OpenSSL (20), zlib, SQLite and libcurl (8 each), then zstd, mbedTLS, libpng, xxHash, LZ4, U-Boot, Dropbear, BusyBox. A signature is a size window, the strings the function must reference as data, and — in 20 of the 67 — a prologue hash:

zlib 1.3 · inflate
  size       1024 … 16384 bytes
  strings    "incorrect header check"       ← matched
  algorithmic constants
             0xEDB88320  CRC-32 polynomial  ← recorded, never matched

Sixteen of the 67 record algorithmic constants — CRC polynomials, SHA, MD5 and AES initial values, the zstd frame magic, xxHash primes — and not one is consulted: matching a constant needs the value recovered at IL level, and that leg was never wired. All 67 leave the separate required-constant field empty as well, so in practice a curated match is a size window plus strings, or a size window plus a prologue hash. That costs exactly the eight signatures whose only distinguishing evidence was the constant table. Carrying no string and no prologue, they can never fire:

A string-only signature clears an evidence bar, because where a string carries the match it carries all of it. A single anchor names a function only if it is at least 12 characters and survives a distinctiveness test that strips printf conversion specifiers before counting letters; anything shorter needs a second anchor. That bar exists because the earlier substring comparison — "%s: %s\n" is a substring of countless real messages — fabricated high-confidence BusyBox matches across roughly 12% of the corpus. The comparison is now exact string equality.

Pattern length is ungated, so "exact" is a statement about the comparison, not about the answer. Of the 1,096,000 entries in the bundled pattern corpus:

EntriesWhat it means
2,121pattern one byte long — no minimum-length gate declines to use one
52,377 (4.8%)pattern shorter than 8 bytes
300,960 (27.5%)CRC length zero — the matcher reads "matched" the instant the pattern bytes agree, so the CRC16 backstops nothing

libaom's aom__dummy_function sits in all three: the byte C3, an x86 ret, with its CRC16 computed over zero further bytes. It matches every ret-only stub in every x86_64 ELF, and the stripped curl above carries 43 of them at a 16-byte stride, in a binary that links no AV1 codec.

Version coverage

69.6% of the 1,835,557 bundled quad-hash signatures carry a multi-segment version that can be ordered against a CVE range, against 93.2% that carry a version string of any kind. The gap is that a version mined from a soname is often a bare ABI-major integer — 3, from libcrypto.so.3 — which cannot satisfy a range like "before 3.0.14" because it has no minor or patch segment to order.

ArchSignaturesAny versionRange-comparable
x86_64999,09196.1%64.1%
aarch64329,48390.1%70.8%
mips32230,83886.3%78.7%
arm151,70386.9%69.4%
x86107,54799.6%99.5%
mips6416,89587.8%63.5%

The 32-point gap on x86_64 is the one that matters, because x86_64 is where most vulnerability questions get asked. Where the version is only an ABI-major, the library is named and the CVE match falls back to the range's own major.

The published coverage figure is not this table. It aggregates three of the six mining corpora — the Linux base, Linux-distribution and BSD bundles — and reports 1,332,153 signatures over 3 architectures and 1,225 library names, while the matcher loads all six. It is 503,404 signatures (27%) short, and silent about exactly the three architectures carrying the embedded coverage: mips32, mips64 and arm.

Architecture and format coverage

Coverage is uneven by tier, and whole (format, arch) cells hold a single library:

TierReachVersionWhere it is thin
Quad-hashsix instruction sets, 2,118 distinct library names, six mining corpora
Trained FLIRT patternsnine architectures across three formatsELF mips holds exactly one library (libogg, pulled out of extracted firmware) against 61 for mips64; Mach-O x86_64 holds exactly one (libarm64decode, a cross-compiled bootstrap) against 52 for arm64
Mach-O body hashXNU kernels only — 3,797 distinct function names, 43,812 stored recordsthe 43,812 records are the same 3,797 names roughly 11 times over, once per SoC build

On Intel macOS and on 32-bit MIPS the pattern tier is a rounding error.

A third of the pattern corpus is a copy filed under the wrong cell, and 109 further blobs name nothing but .text. Matching stops at the first entry that fits, in corpus iteration order, so where blobs duplicate, sort order rather than evidence decides which library name a hit carries.

Defect in the 1,015 pattern blobsExtentConsequence
byte-identical to another blob330 blobs (32.5%)
the 38 musl-built libraries mined once on x86_64, then filed into six architecture cells — elf.arm, elf.arm64, elf.ppc64, elf.riscv64, elf.s390x, elf.x86_6438 libraries in 6 cellsfive of the six cells can match nothing: the arm copy of musl.libaom opens on 41574156415541544989F4, the x86-64 encoding of push r15 / r14 / r13 / r12
duplicate groups spanning different library names, all in PE cells11 groups — libcurl/liblz4, libbz2/libffi, libsndfile/libtasn1, libuv/libflatbuffers, libffi/libvorbisfile, libsamplerate/libwebpdemux, the three libpcre2 character widths with each other, libpng/libpng16the reported library is whichever member sorts first
named .text and nothing else109 blobs — 58 of the 123 pe.x86_64, 51 of the 108 pe.x86 — carrying 58 library labels and 41,189 of the 48,318 corpus-wide .text entriesevery entry is the same 8-byte MinGW import thunk (FF25, a wildcarded displacement, two nops, no CRC), and the only name filter drops numeric local-label artefacts (..@<digits>.), so they match live: thunks get named .text and attributed to whichever library owns the first fitting blob

Nearly half of each PE cell is import thunks, not function coverage.

Three of the six quad-hash mining corpora carry reach nothing else does — one is the largest, and two are the only ones reaching embedded targets:

CorpusContributesDistinctive property
BSD base and comp sets — FreeBSD, OpenBSD, NetBSD842,583 signatures over 505 librariesthe largest of the six, and the sole source of 32-bit x86 coverage — which is why x86 carries the highest version rate of any architecture
Debian bookworm mips64el root filesystem64-bit MIPSan embedded target
OpenWrt 25.12.5 router root filesystem32-bit MIPS in both endiannesses (malta/be and malta/le), plus musl-built arm and aarch64an embedded target, and code no public container registry publishes; musl bodies hash differently from the glibc bundles, so they collide with nothing already present, and musl's libc.so carries no version suffix at all — this bundle's version rate (86.2%) is the lowest of the six

Fifteen libraries exist in the pattern corpus on macho/arm64 and nowhere else — libcares, libevent, libgio-2.0, libglib-2.0, libgobject-2.0, libharfbuzz-icu, libmp3lame, libmpdec, libmpg123, libopenjp2, libtheora, libtheoradec, libunistring, libupb, libwebsockets. Pattern matching is gated on the exact (format, arch) pair, so none of them can name the same library statically linked into a Linux or Windows binary.

Apple's userland is the largest hole. The Mach-O body-hash corpus spans 11 kernel images (t8103, t6000, t6020, t8112, t8122, t6030, t6031, t6041, t8132, t8142, vmapple), all from a single OS build — macOS 26.1 (25B78). No Apple system dylib, and no Apple library version, is in any bundled corpus.

Corpus-mined naming

A second layer carries no pre-built signatures at all: it mines names from binaries that are already identified and re-applies them. Nothing in it runs by default — each tier is a no-op unless the deployment points it at a mined database, and the fuzzy and re-anchoring tiers additionally need the token sidecar built beside that database. Writing back is gated separately from reading: a run contributes to the corpus only under a per-job curator authorization, so an ordinary upload reads and adds nothing.

Every accept is gated:

GateValueApplies to
Distinct binaries a body must appear in2exact body clustering
Anchors a function must offer4semantic anchors
Anchor string length4 characters minimumsemantic anchors
Fuzzy accept, steady state0.5 IDF-weighted Jaccardfuzzy anchor tier, re-anchoring
Fuzzy accept, round-0 seed0.75 IDF-weighted Jaccardre-anchoring
Margin over the runner-up0.1 IDF-weighted Jaccardre-anchoring
Rounds before the cascade stops4re-anchoring
Callee fan-out above which edges are skipped256 distinct calleeslibrary-label propagation — measured, not wired

Exact body clustering hashes every recovered function body with relocations normalised away, so the same code collides across binaries. A name propagates only once its body has appeared in two or more distinct binaries — one sighting never seeds a name.

Semantic anchors key a function on the set of API names it calls, its distinctive immediate constants and its distinctive string literals rather than on opcodes. None of those depend on the instruction encoding, so one evidence set matches the same library compiled for MIPS, ARM, PowerPC, RISC-V or x86. A function offering fewer than four anchors is never matched at all.

The fuzzy tier sits behind the exact one to absorb its brittleness. The fast path keys on a hash of the whole joined token set, count included, so one extra inlined literal in a static build changes the hash and the reference .so becomes a total miss. That cannot be tuned out of a hash lookup, so a second index carries it: every token points at the records carrying it, a document-frequency table demotes tokens every binary has (memcpy, malloc) and promotes rare ones (a crypto magic constant, a distinctive error string), and candidates are reranked by IDF-weighted Jaccard. It only runs on near misses of the exact tier, and the best candidate must reach 0.5 on that kernel — a match carried entirely by ubiquitous tokens scores below it.

Re-anchoring is what makes anchors work on a stripped static binary, where every direct call resolves to a placeholder and the call channel collapses. Measured there, constants and strings alone recover about 11% of functions. But the moment a callee is named by its own surviving anchors, its address enters the symbol map, the caller re-extracts and now emits a token naming that callee — which can push the caller over the floor, naming it, which names its callers. The round-0 seed is stricter than steady state because a wrong seed poisons everything downstream. Within a round, naming works off a snapshot taken at the round's start, so the order functions are visited in cannot change the result.

Library-label propagation is measured but not connected. It spreads a library label rather than a name: direct-call targets are decode-derived and PC-relative, so the within-library call subgraph survives static linking and stripping intact — gated on 538 MIPS firmware ELFs, 1.0 million edges, zero panics. A neighbour would inherit the label only when its own anchor evidence independently narrows to exactly that one library; high-fan-out edges are skipped because that fan-out is the signature of two functions merged by faulty boundary inference rather than of a real hub. No ELF, PE or Mach-O analysis path calls it — the only callers are its own tests. The call graph reaches naming through re-anchoring instead.

A library label is only ever attached to a binary that structurally is a library — an ELF carrying DT_SONAME or a Mach-O carrying LC_ID_DYLIB. Executables have neither, so an executable can acquire a library label only by byte-matching code that a real shared object taught. The bill of materials cannot invent a dependency out of an executable that merely resembles one.

Curator names publish to a separate database on a separate rail from the auto-learned one, so a deliberate human assertion is never confused with a cross-binary inference.

Family attribution

The same machinery names a malware family instead of a library, by k-nearest-neighbour vote over a byte-free index of 1,130 labelled samples across 188 families. Each entry is a family label, a sha256, and the sample's DNA set axes — no executable bytes, and not reversible to the malware. The index is compiled into the engine executable, so an air-gapped deployment gets attribution with no runtime fetch and no data directory to populate. The failure mode moves to the build rather than disappearing: a checkout that has not generated the corpus compiles with no index embedded at all, and attribution is then skipped for every binary with a log line and no other symptom.

The vote is its own metric, not the corpus-similarity one: 32 flat-weighted set axes plus embedded formats at 2.0 and hardening posture at 3.0, k of 5, and no corpus discounting — a member every sample carries counts as much here as a rare one. Corpus similarity runs 40 axes, including eight behavioural-signature axes absent here, and carries neither embedded formats nor hardening.

The weights are opinions, and an axis participates only where at least one of the two samples is non-empty — an axis usually empty on both sides drops out of the average rather than scoring zero. Measured against the shipped 1,130 entries, the heaviest axes are the emptiest:

Set axisWeightEntries carrying it
Matched indicators6.026%
Entitlements6.015%
ATT&CK techniques5.090%
Process-control aspect5.087%
Consistency flags5.087%
Called APIs (the sample's own code)5.015%
Network aspect5.015%
Crypto aspect5.09%
Dynamic-code aspect5.021%
XPC services5.03%
Private frameworks5.00.4%
dlsym targets4.00.2%
Hardening posture3.0100%
Static libraries3.069%
Linked frameworks1.095%
Objective-C class names0.58%
Swift type names0.52%

A vote is therefore carried by the populated axes — hardening posture, linked frameworks, ATT&CK techniques, process control, consistency flags, static libraries — not by the entitlement and indicator axes the weights nominate as decisive.

If the single best neighbour scores below 0.55 the classifier returns no family rather than a low-confidence guess. When the sample under test is itself in the corpus, its own entry is held out — otherwise it elects itself at similarity 1.0 and the measurement reports memorisation.

The corpus is heavily skewed, and a family with one labelled sample gets one neighbour:

FamilyLabelled samples
AmosStealer678
Medusa21
AzureHound18
EvilEgg16
PEASS-NG16
all 188 families1,130

Known failures and gaps

The one-byte-signature failure is a class, not an incident. aom__dummy_function is the visible end of it. On one real static-musl curl, a single libaom signature (av1_get_third_pass_mi — 3 bytes, 31C0C3) matched 87 distinct functions; whole collision clusters fabricated libaom (342 functions), libldns (133) and libgnutls (114) as components of a binary that links none of them.

Two filters run before anything reaches the bill of materials: any single signature attributed to 8 or more distinct functions in one binary is dropped as degenerate, and a library then has to average at most 2.5 functions per distinct signature to count as evidence at all.

LibraryFunctions per distinct signatureVerdict
musl.libssl1.03real
libcrypto1.07real
libzstd1.73real
libcurl1.78real
gate2.5maximum average to count as evidence
the fabricated clusters3.00–3.85fabricated, measured after the degenerate signatures are removed

The suppression protects the component list, not the function table — which is exactly why the stripped curl's software bill of materials correctly names zero statically linked libraries while its function listing still shows all 43 aom__dummy_function rows.

Go is invisible to every mechanism above. go build compiles the runtime and the whole standard library into the same image as the author's main, so no library signature ever matches: measured on committed fixtures, 0 of 1,552 functions are named in a Go 1.18 Linux binary and 0 of 2,337 in a Go 1.25 macOS binary. Go identity comes from a different place entirely — the runtime's own function-name table, which survives stripping because the runtime needs it.

That table also supports a distinction none of the signature tiers can make: standard-library code splits into mandatory toolchain code, linked into essentially every Go image and therefore telling you nothing about this one, versus author-elected imports that are present only because someone wrote the import. Over 80 well-recovered Go images spanning 17 families, package prevalence is bimodal — 28 of 259 standard-library packages appear in 95–100% of images, then a nine-package shoulder, then a long tail — and the cut sits at 95%, in the sparse region.

Prevalence is not bimodal at function granularity (9,635 symbols, smooth decay), so a mandatory package over-suppresses its own elective functions:

SymbolPrevalenceOutcome
crypto/aes (package)63.8%below the cut — ransomware that chose ChaCha20 keeps its crypto on the threat surface
crypto/x509 (package)57.5%below the cut — stays on the threat surface
net/url (package)56.2%below the cut — stays on the threat surface
os.Remove (function)61.2%suppressed anyway, because its package is not
syscall.Socket (function)66.2%suppressed anyway, because its package is not

Rust crates are absent. No Rust crate appears in any bundled corpus, so a statically linked Rust binary's crate-level dependencies are not named by any tier. Its libc is.

MinGW-built PE gets a name and never a version. The FLARE corpus is MSVC-ABI only; the MinGW/GCC ABI is covered by the trained pattern tier, which carries no version at any architecture. The same applies to ppc64, riscv64 and s390x, which the pattern tier reaches and quad-hash does not.

The platform provenance is coarser than it looks. The Linux-distribution bundle is mined from nine container images — Alpine 3.20, Fedora 41, Arch, Ubuntu 24.04 and 22.04, Amazon Linux 2023, Rocky 9, Oracle 9, Debian trixie — but the stored records carry no per-distribution tag, so a hit says "a Linux distribution", never which one. Splitting it requires a change to the miner, not to the query.

The precision gate is a library, not a pipeline. Coverage alone hides the failure that matters — a confident wrong name corrupts both the bill of materials and the set of functions the decompiler skips, which is strictly worse than a blank. The evaluator for that exists: it scores predictions on a stripped binary per function against its unstripped twin's symbol table, computes precision, recall and F1, and accepts a change only when F1 strictly improves and coverage does not regress. It is unit-tested against a synthetic predictor, and a runnable end-to-end harness sits on top of it: mine a reference set of symbol-bearing binaries into one anchor sidecar, then per target capture ground truth, strip a copy, re-analyse it through the production path, and score per binary plus an aggregate — warning when a target also appears in the reference set, because that overlap inflates recall. What is missing is not the harness but the verdict: no build gate consumes it, and no F1 number from a real run is recorded anywhere.

Reference