VulHunt vs openbinary
openbinary findings /usr/bin/tar returns 88 findings on the arm64e slice of the universal binary macOS ships — auto-detect takes the native slice, and the x86_64 one is a separate program as far as any of this is concerned. Eleven carry a taint witness — an ordered chain of virtual addresses from a seeded input to a dangerous primitive — and three of those eleven begin at a named external source rather than at a function's own parameter. One of the three, with the virtual addresses rewritten from decimal to hex and the caller's fifteen-entry forward-call list dropped:
{
"cwe_id": 119,
"detector": { "kind": "symbolic-exec" },
"confidence": "high",
"severity": "medium",
"rule_id": "taint.flow.src-arg.__memcpy_chk",
"summary": "Tainted value reaches the source buffer of a sized primitive — read amplification or oracle of attacker-controlled bytes.",
"location": {
"va": "0x10000863c",
"symbol": "__memcpy_chk",
"caller_va": "0x10000856c",
"caller_name": "set_reader_options",
"reachable_from_main": true,
"flow_path": [
{ "va": "0x100008594", "role": "source", "symbol": "getenv" },
{ "va": "0x10000863c", "role": "sink", "symbol": "__memcpy_chk" }
]
}
}
The code between those two addresses:
_set_reader_options:
100008590 add x0, x0, #0xfdc ; "TAR_READER_OPTIONS"
100008594 bl _getenv ; ← source
100008598 stur x0, [x29, #-0x18]
1000085a8 mov x8, #0x1d ; len("__ignore_wrong_module_name__,")
1000085b4 bl _strlen
1000085b8 add x8, x0, #1 ; strlen(env) + 1
1000085cc add x0, x8, x9 ; 0x1d + strlen(env) + 1
1000085d8 bl _malloc_type_malloc
1000085dc str x0, [sp, #0x18] ; the buffer
100008628 bl ___memcpy_chk ; the 0x1d-byte literal prefix
100008630 ldr x0, [sp, #0x18] ; ← destination
100008634 ldur x1, [x29, #-0x18] ; ← the getenv result
100008638 ldr x2, [sp, #0x20] ; strlen(env) + 1
10000863c bl ___memcpy_chk ; ← sink
The flow is real: the bytes of TAR_READER_OPTIONS reach a copy primitive, and the caller is reachable from main. The bug is not. The buffer was allocated at 0x1d + strlen(env) + 1 bytes and the copy length is strlen(env) + 1, so it cannot overrun. The severity is arithmetic on the callee name, not on that reasoning: a tainted source buffer is High by default and drops one tier to Medium because __memcpy_chk carries the fortified _chk suffix. Proving the flow is mechanical. Deciding whether a proven flow is a bug is not, and that is the axis both engines are actually competing on.
Binarly open-sourced the VulHunt Community Edition under GPLv3 in March 2026. It is the closest public analogue to the Findings surface. Exploitation-maturity scoring, SBOM/CBOM validation and fleet management stay in the paid Binarly Transparency Platform and are not in the open source.
Summary of differences
| Question | Answer |
|---|---|
| What is VulHunt built on? | Lua rules over the BIAS substrate, with Ghidra's decompiler embedded whole and lifting through sleigh |
| What can each engine read? | VulHunt: ELF, PE, UEFI, shellcode, five architecture modules, no Mach-O loader. openbinary: 104 spec-registry targets of which 72 are architectures — 48 lifted to IL, 24 decode-only — Mach-O included |
| Where does VulHunt lead? | Semantic C-grep over decompiled pseudo-C, Lua rule authoring, deep UEFI/SMM analysis, type libraries and inline annotated explanations |
| Where does openbinary lead? | Architecture and format breadth, constant-arithmetic proofs, SSA-taint with interprocedural summaries, SARIF witnesses, 48 MCP tools and the surrounding SAST/SCA/malware surface |
| What does a finding cost to read? | 88 findings on /usr/bin/tar, 564 on /usr/bin/ssh, 867 on curl for Windows, 2,629 on a statically linked curl for Linux; 11–20% of every one of those counts is a synthesized correlation of the others |
| Where does openbinary go silent? | Taint, use-after-free, loop-bounded overflow and double-fetch all sit behind one format/architecture matrix: PE is x86 and x86-64 only, and a static stripped ELF yields zero taint findings on any architecture |
| What does neither ship? | Automatic triage — ranking, clustering, confirming or dismissing findings so an auditor reads ten instead of the whole list |
VulHunt architecture
VulHunt is a Lua rule engine over BIAS — Binary Analysis and Inspection System, dual MIT/Apache — with Ghidra's decompiler embedded whole. The engine is the product; the community writes the rules, and no rule packs ship in the Community Edition tree — only the builder and CLI command that push a rule directory to a registry as an OCI artifact for the paid platform to pull.
| Layer | Implementation | Measured detail |
|---|---|---|
| Rule language | Lua scripts evaluated at one of three scopes: whole project, per function, per call site | 1,711 lines of in-tree Lua: 1,058 a vendored third-party functional-programming library, 613 the engine's own runtime prelude, 40 scope-condition helpers |
| Decompiler | Ghidra, embedded | 107 C++ translation units under bias-core/cxx/decompiler/ghidra |
| Lifting | Ghidra's sleigh by way of the fugue crate | Five architecture modules register with the core: x86 (covering x86-64), ARM, AArch64, eBPF, Xtensa |
| IR | SSA form over nine statement kinds, with phi nodes per block | Assign, Store, Branch, CBranch, Call, Return, Skip, Intrinsic, PointerHint — exposed into Lua as userdata so a rule can pattern-match on it directly |
| Firmware model | bias-core/src/efi | 228 KB across eleven files: services.rs at 85 KB tracking EFI boot and runtime services, smi.rs at 44 KB for SMI handlers, globals.rs at 33 KB, plus GUID tables, PEI phase modelling, interprocedural typed aliases |
| Signature compatibility | Separate crates | Read FwHunt, FLIRT and PatFind signatures |
| Vulnerability vocabulary | First-class types, not string tags | CPE, CVSS, CWE, MBC, PURL, an environment-reachability model |
| Agent surface | MCP server | Seven tools; the one that matters is query_project, described in its own schema as "Execute a Lua script against the currently open project and return the result as JSON." The agentic story is that the model writes the rule |
UEFI and SMM are the depth of the tree.
Input formats
There is no Mach-O loader. The set is ELF, PE, UEFI firmware volumes and TE images, raw shellcode, Binarly's own BA2 package, and a Binary Ninja database importer. Mach-O exists as a name in two places only: a format enum whose string parser can spell it, and two identical UnsupportedFormat("Mach-O") reject arms in the EFI module loader, sitting under the same treatment for ELF, which does have a loader of its own. Nothing routes a Mach-O anywhere.
Rule query API
The rule query API is a dataflow language, and a rule author can reason about value provenance at a call site and semantically grep pseudo-C in the same script, without recompiling anything.
- Value and provenance:
is_const_pre_call,pre_call_origin,pre_call_string,pre_call_bytes,is_unk_post_call. - Control flow:
dominates,precedes,is_reachable. - Metadata:
prototype,annotation. - Pseudo-C query engines, three separate ones running over the decompiled C: weggli (the default), ast-grep, semgrep.
Capability comparison
Foundation
What each engine is built on.
fugue; five registered arch modules: x86 (+x64), ARM, AArch64, eBPF, XtensaDetection technique
How a vulnerability is found in the bytes.
efi/ 228 KB: services.rs 85 KB, smi.rs 44 KB)efi.smm-callout, CWE-829 — handler-root reachability × gBS/gRT call classification over the efi-model platform recovery). CommBuffer nested-pointer taint is specced, not shippedO+N > F, mul-into-malloc wrap, chk-literal OOB → mathematical CriticalFindings & triage
What a finding carries and how it's ranked.
is_const_pre_call, origin, dominates, is_reachable) — arbitrary new detection logic, no recompiledetectors = ["binary", "source-c", …]); 106 of 1,190 do, across the binary engine + 10 source languages. Only novel gate *semantics* need Rustreachable_from_main + 7-tier root anchoring (entry point, main, run-loop daemons, __libc_start_main callees, initialisers/TLS, exports, EH landing pads), plus reachable_before_auth; measured — curl.exe x86-64 PE 166 of 867 mainline-reachable, /usr/bin/ssh 278 of 564forward_calls; no inline pseudo-C annotationreachable_from_main filter + a vuln-triage agent skill; no in-product auto-triage classifier yet — roadmapSurrounding capability
Everything beyond the per-binary finding.
Delivery & integration
How each ships and plugs into a workflow.
UnsupportedFormat("Mach-O"))bias-vulhunt-mcp, 7 tools — query_project runs arbitrary Lua)ob-mcp, 48 tools)ob-sarif — findings, secrets, capabilities, ATT&CK taxonomy)Cross-architecture rule portability
Both engines now write one rule that fires across architectures, by different routes. A VulHunt rule written against normalised P-code fires on every sleigh-supported ISA — VulHunt's headline claim, and now close to parity. openbinary gets there through SSA-taint passes driven from one catalog:
| Rule | Fires in |
|---|---|
taint.flow.path-arg.fopen | the arm64e /usr/bin/tar above |
taint.flow.path-arg.CreateFileA | curl's Windows x86-64 build |
taint.flow.src-arg.strcpy | an x86-64 ELF, on the witness getenv → strcpy in main |
Which of them can fire is decided by a format-and-architecture matrix, not by the rule:
| Format | Architecture names admitted |
|---|---|
| ELF | 39 |
| Mach-O | 13 |
| PE | 2 — x86_64 and x86 |
The same matrix gates use-after-free, loop-bounded read and write, sprintf overflow and kernel double-fetch, so an unlisted combination loses the whole IL-dataflow tier at once rather than one rule. One curl source built two ways measures the cliff:
| curl build | Findings | Taint findings |
|---|---|---|
| Windows x86-64 | 867 | 6 |
| Windows arm64 | 10 | 0 |
The taint tier is portable across everything the matrix lists and absent everywhere else, so architecture-bound is not only the arithmetic-proof tier.
Semantic matching over decompiled C
Weggli, ast-grep and semgrep over decompiled C have no counterpart. openbinary matches on IL and SSA and on raw bytes, and its pseudo-C is not exposed to rules at all. Semantic C-grep against readable decompiler output is architecture-independent for free.
Rule authoring: Lua versus a declarative catalog
Adding a sink is cheaper in openbinary; adding a new kind of gate is cheaper in VulHunt, and that is the sharpest real difference between the two. openbinary's catalog is 40 topic files holding 1,190 detector-tagged entries — a new shell-exec sink is one entry, no Rust. But novel gate semantics — conjunctive gates, variadic payload positions, struct-literal field values — still require a walker change. VulHunt's is_const_pre_call and pre_call_origin express those directly in a script.
The cheap-to-add path is also the smaller half of the sink surface: the engine's primary sink table is 291 entries compiled in, and the catalog adds 109 binary-tagged rows on top of it, deduplicated by name.
Finding explanations
VulHunt explains a finding in line; openbinary explains it by context. A VulHunt finding pins a root-cause note to an exact instruction address in the decompiled C, and type libraries plus FLIRT keep that readable on stripped binaries. openbinary resolves a finding to a call site, a calling function, and the ordered list of calls that function makes next — for set_reader_options above, fifteen names from getenv to lafe_errc; it does not explain itself in line.
UEFI and SMM coverage
VulHunt leads on firmware depth. openbinary ships one firmware vulnerability rule, efi.smm-callout (CWE-829), which walks SMI-handler roots against gBS/gRT call classification over its EFI platform recovery, plus a structural kernel double-fetch detector for the copy_from_user TOCTOU shape. VulHunt covers CommBuffer nested-pointer taint, PEI/DXE service tracking, and interprocedural typed aliases on top of that.
Architecture and format breadth
Breadth runs the other way: 104 spec-registry targets against VulHunt's five registered architecture modules. That difference is where firmware-blob diversity lives. Mach-O works. But the 104 is not 104 architectures, and only part of it carries analysis rather than disassembly:
| Tier | Count | What it supports |
|---|---|---|
| Formats, platforms and meta specs | 32 | ELF, Mach-O, PE, TE, COFF, DEX, calling conventions, hardening — not architectures at all |
| Architectures with an IL lift | 39 | Full chain: taint, CWE detectors, value-range |
| Bytecode and VM targets with a lift | 9 | Dalvik, JVM, WASM, CIL, EBC, Lua, Python, BPF, cBPF |
| Architectures that decode only | 24 | Recognised and disassembled, no IL — so no taint, no CWE findings, no value-range |
Rizin parity sits at 57 of 59 architectures and 63 of 65 formats shared with librz.
Proof tiers and taint
openbinary's top finding tier is proof, not inference, and it is close to empty on real binaries. When the frame size from a function prologue, the destination offset and the copy length of a memcpy all reach the call site as compile-time constants, O + N > F settles the question arithmetically and the finding is Critical with no dataflow hedge. It fires zero times on /usr/bin/tar, /usr/bin/ssh and every curl build measured here; its only firings in the corpus are 9 on the arm64 macOS ripgrep, in two unnamed functions on the Rust panic-helper path where the compiler statically builds error buffers — almost certainly not bugs. All 10 Critical findings on tar come instead from the cross-detector correlation composer, which emits one severity tier above the maximum of its inputs, capped at Critical.
Below that tier, SSA-taint carries typed sources against typed sinks — quantity, indexed write, path, shell argument, SSRF, dereference — with dominator-based sanitiser suppression, alias-aware seeding, and inter-procedural sink-wrapper summaries: the case where recv() bytes cross a function boundary and reach system() one frame down, which no intra-procedural walker sees.
Output formats and surrounding tools
Findings serialize to SARIF 2.1.0, so a flow_path becomes a SARIF codeFlow carrying one threadFlowLocation per step — the same shape CodeQL and Semgrep consumers already parse. The flow is short: of 103 witnesses across tar, ssh and curl for Windows, 102 have exactly two steps — source, then sink — and one has three. The MCP server exposes 48 tools against VulHunt's seven. The surround answers questions VulHunt does not ask:
- Source SAST across 20 language walkers, 10 of them wired into language dispatch — JavaScript, Python, PHP, Go, Java, Swift, C, Ruby, C# and Kotlin. The other 10, Rust included, are reachable only from an in-tree evaluation harness; nothing routes a
.tf,.sh,.scalaorDockerfileto a walker. - Lockfile SCA across 11 package ecosystems, from npm to opkg.
- A deterministic four-tier malware verdict with an ONNX behavioural-embedding layer.
- DNA similarity and variant clustering.
- Firmware unpacking down through ext4, UFS2, squashfs and UEFI capsules.
Reachability filtering
Volume, not detection, is the workload, and a forward call-graph walk is what makes it readable — but how much it removes depends entirely on how the binary was linked: a dynamically linked system tool has almost nothing off the path, a statically linked one has almost nothing on it.
| Binary | Findings | On the mainline path | Off it | No answer | Off-path share | Compound |
|---|---|---|---|---|---|---|
/usr/bin/tar, Mach-O arm64e | 88 | 80 | 0 | 8 | 0% | 14 |
/usr/bin/ssh, Mach-O arm64e | 564 | 278 | 237 | 49 | 42% | 104 |
| curl, Windows x86-64 | 867 | 166 | 694 | 7 | 80% | 170 |
| curl, Linux x86-64, static and stripped | 1,909 | 323 | 1,579 | 7 | 83% | not measured |
| curl, Linux arm64, static and stripped | 2,629 | 322 | 2,300 | 7 | 87% | 287 |
The filter hides seven findings in eight of the static curl builds and none at all of tar's. Off the path is not proof of dead code, only that no static edge chain from a root reaches the caller — an indirect dispatch still can, and the field says false rather than "unreachable" for that reason.
"No answer" is not only binary-wide rules. Of ssh's 49, six are genuine binary-wide rules — posture flags, a hardcoded-secret marker, two weak-crypto entries — and 43 are interprocedural taint findings that do have a call site: they carry the sink address and the calling function's name but no calling-function address, and the placement walk keys on that address alone. One of tar's eight is the same shape.
The compound column is synthesis, not independent detection: the cross-detector correlation composer groups findings by calling function and, where two or more distinct rules converge, emits an additional compound.* finding one severity tier higher, leaving the originals in the list. Between one finding in nine and one in five in every count above arrives that way.
Call-graph roots
Anchoring that walk takes seven tiers of roots, because a stripped daemon has no main:
- The format's own entry point.
mainitself.- The callers of
xpc_main/dispatch_main/NSApplicationMain/__CFRunLoopRun. - The direct callees of
__libc_start_main. - Static initialisers and TLS callbacks.
- Every defined export.
- C++ exception landing pads.
If the resulting closure covers less than 10% of the call graph's caller nodes it is declared untrustworthy and every unreached finding is left null instead of stamped false.
Pre-authentication reachability
openbinary computes pre-authentication reachability; VulHunt's open tree does not. A second Boolean asks whether a network listener reaches the call site without crossing a recognised authentication primitive on any path — the CVSS-9.8 unauthenticated-RCE shape. VulHunt declares a reachability type — undetermined, entrypoint, runtime-invocation, runtime-dependency — and never constructs anything but the default, undetermined: outside its own module the type appears only in the re-export beside it and one error variant. The scoring is a paid-platform feature.
Limits
Arithmetic proofs
The arithmetic-proof detector is arm64-only and explicitly v0, and when it cannot resolve all three constants it stays silent, so the failure mode is a miss. What defeats it:
- Frame-pointer-relative destinations (
sub xR, x29, #O) — not recognised. - Pre-index prologues that fuse save and subtract (
stp x29, x30, [sp, #-F]!) — not decoded. - Lengths above 16 bits — read only from a single
MOVZ, so aMOVZ+MOVKchain is invisible. - A destination pointer spilled to a stack slot and reloaded before the call, as at
-O0— defeats it entirely.
Catalog fan-out
The shared rule catalog is a shared schema, not a fan-out multiplier. Thirteen consumers are declared — the binary engine, eleven source languages and a firmware tier — and almost every entry reaches exactly one of them.
| Detector-tagged entries | Detectors named | Which, and why |
|---|---|---|
| 1,084 | exactly one | Most sinks are language-specific: os.system exists only in Python, shell_exec only in PHP, so the entry has nowhere else to go |
| 105 | exactly two | A name two consumers share — libc system, tagged for the C walker and the binary engine |
| 1 | three | POSIX open — the C walker, the Python walker and the binary engine |
| 1,190 | — | The whole sink and dangerous-call catalog, across 40 topic files. The capability catalog beside it, 77 more files, carries no detector tags at all |
Per consumer it is just as lopsided, and two of the thirteen are declared but empty:
| Consumer | Tagged entries |
|---|---|
| Python | 321 |
| PHP | 223 |
| JavaScript | 201 |
| C | 123 |
| Binary engine | 109 |
| Ruby | 107 |
| Java | 100 |
| Go | 74 |
| Swift | 22 |
| Kotlin | 9 |
| C# | 8 |
| Rust walker, firmware tier | 0 |
What the catalog buys is that every shell-execution sink for every consumer lives in one file with one shape, not that one line becomes eleven detectors.
Firmware topics without a detector
Two firmware topic files, cwe-20-smm-commbuffer.toml and cwe-367-nvram-double-fetch.toml, have no consuming detector. They are catalogued, not shipped, and they are blocked differently: the CommBuffer topic is metadata awaiting a taint source and a sanitiser gate, while the NVRAM double-fetch shape cannot be written down at all, because the catalog's lifecycle-predicate vocabulary has no way to express it.
Kernel double-fetch detection
The kernel double-fetch detector that did ship carries no proof at all: it is purely structural over the SSA IL, grouping copy-in calls by the structural identity of their user-address argument. What that costs:
- Low confidence on every emission — a benign header-then-body re-fetch of one base pointer has the same shape.
- A reload of the address between the two fetches — the SSA expressions differ, so no candidate is raised. It under-reports by construction.
- An inlined
copy_from_user— invisible, since recall depends on the lifter recovering the copy as a resolved call.
Interprocedural taint
Two independent interprocedural passes emit into the same taint.flow.interproc.* rule namespace with different sink vocabularies. The engine-side function summary is deliberately narrow — six neutral argument positions, no CWE vocabulary, so the analysis layer stays free of detector concepts — and everything else collapses there into one undifferentiated taint.flow.interproc.other. The second pass, in the detector layer, keeps its own parameter-to-sink summary over the full eighteen-kind catalog, which is why rows the narrow summary cannot express still fire.
| Argument position or sink kind | Neutral engine summary | Full detector summary | Fired on /usr/bin/ssh |
|---|---|---|---|
| Written-through | yes | yes | 1 |
| Read-through | yes | yes | 16 |
| Length | yes | yes | 10 |
| Bare dereference | yes | yes | 0 |
| Exec argument | yes | yes | 0 |
| Format string | yes | yes | 2 |
| Path | no slot | yes | 8 |
| Loader path | no slot | yes | 0 |
| Registry name and data | no slot | yes | 0 |
| SSRF | no slot | yes | 0 |
| SQL injection | no slot | yes | 0 |
| Process injection | no slot | yes | 0 |
| Unchecked return | no slot | yes | 0 |
| Allocation size | no slot | yes | 2 |
| Use-after-free | no slot | yes | 0 |
The tier switches itself off above 6,000 functions in the binary: the summary comes back empty and analysis falls back to intraprocedural — sound but silent — and large firmware images sit above that line. The findings that do fire prove which parameter index reaches the sink, not the ordered operation sequence, so they carry no witness path and no calling-function address: the cross-function flow is the one case that arrives both without an address chain and without a reachability answer.
Taint results mix converged and over-approximated states
The lattice fixpoint has a step cap, and hitting it does not discard the result — it lowers the finding's confidence by one tier. An emitted taint finding is therefore either a converged answer or an over-approximation, and that confidence value is the only thing in the output distinguishing them.
Static linking plus stripping removes the taint tier entirely
Both curl builds for Linux sit inside the supported format-and-architecture matrix, and neither produces a single taint finding:
| curl build | Findings | Taint findings | Witnesses |
|---|---|---|---|
| Linux x86-64, static and stripped | 1,909 | 0 | 0 |
| Linux arm64, static and stripped | 2,629 | 0 | 0 |
The sink tables are keyed on resolved callee names. A statically linked, stripped binary has neither dynamic imports nor a symbol table, so its calls into libc land on addresses with no names attached, no call site classifies as a sink, and nothing seeds. This is not an architecture gap — the x86-64 build is the most supported target there is. The binary shape most common in firmware is the shape that yields no witnesses, and it is also the shape that produces the most output to read: 2,629, not 867, is the high-water mark.
True flows that are not bugs
The /usr/bin/tar finding at the top is a true flow that is not a bug. Three of tar's eleven witnesses are the same shape: an environment variable copied into a buffer sized from its own length. Any engine that emits taint witnesses emits these. The honest position is that a witness answers which input reaches this primitive, and a human or a classifier still has to answer and does that matter.
Those three are also the only tar witnesses that begin at a named external input. Most witnesses start at function.parameter instead — the analysis seeds a function's own parameters as potentially attacker-influenced, without proving any caller supplies attacker data:
| Binary | Witnesses | From a named external source | From function.parameter |
|---|---|---|---|
/usr/bin/tar | 11 | 3 — getenv | 8 |
/usr/bin/ssh | 91 | 16 — getenv, fgets, read | 75 |
"Carries a taint witness" therefore means a chain exists from a seed, not a chain exists from a proven external input.
Automatic triage
Nobody ships automatic triage of CWE findings: take the 88 on tar or the 2,629 on the static Linux curl, rank them, cluster them, confirm or dismiss each, and explain the decision so an auditor reads ten rather than the whole list. VulHunt markets an agentic workflow — bring your own model, drive the engine over MCP, write Lua. openbinary has 48 MCP tools and the reachability filter; the agent skills in its tree triage a binary for malware, not a findings list for exploitability. Neither has an in-product classifier that turns 2,629 findings into a queue.
The output is not even severity-ordered. Findings sort alphabetically by rule name and then by address, so the list opens on whatever name sorts first — archive-extract.* on tar, anti-debug.* on ssh — and the Critical entries sit wherever their rule names land. Sorting by severity is one line of work; deciding what to read first is not, and the engine that produces the most findings only wins if it also says which to read first.
Read against the VulHunt Community Edition source at commit 256a558. vulhunt.re · VulHunt repo · VulHunt in Depth — Binarly · Help Net Security · Findings · Engine · Source SAST.