Sign in

Product

/usr/libexec/airportd is a 3,220,640-byte universal Mach-O carrying an x86_64 slice and an arm64e slice. One pass over it on an Apple Silicon laptop takes 9.93 seconds and executes none of it:

$ /usr/bin/time -p openbinary /usr/libexec/airportd
real 9.93

That pass reads one slice — the host architecture, arm64, unless another is named; the x86_64 slice is untouched. Out of it: 37 Objective-C classes and 1,593 methods, a call graph of 2,244 functions over 31,602 edges, and a telemetry surface of 118 strings, 18 of them Objective-C methods and 14 of those carrying a submit selector. Two further passes over the same file add 120 CWE-tagged findings, 52 of them os_log disclosure hits, and — in 83 seconds — a 59-file, 85,519-line Rust project mirroring all 1,593 methods with zero unrecovered operations. The file itself records almost none of that. It records an entry point, an export list, and unwind tables, because that is all the loader needs. Everything else is recovered.

SectionAnswers
Analysis outputsWhat static analysis produces, and which brief details each surface
WorkflowsVetting a binary, diffing releases, auditing firmware, triaging a sample, querying a corpus
Correctness safeguardsWhy the output abstains instead of guessing when format, architecture or trust is uncertain
Known failures and limitsWhere it is measurably wrong, with the measurement
Deployment modelsCloud, on-prem, air-gapped
Comparison with other toolsWhat workbenches, VirusTotal, SCA scanners and binwalk do not cover

Analysis outputs

Twelve surfaces come out of static analysis, executing none of the file: disassembly, reconstructed source, extracted containers, a malware ledger, CWE findings, capabilities, source findings, emulated values, an SBOM, recovered function names, similarity, and exportable formats. They are not one command: the default pass produces classes, call graph, capabilities, strings, indicators and hardening posture, while CWE findings, source reconstruction, container extraction and SARIF export are each a separate pass over the same file.

Disassembly and lifting (Architectures). Machine code decodes on 72 instruction sets, and what can be asked of one depends on how far it is carried:

ClassInstruction setsWhat runs on them
Lifted to an intermediate language39taint analysis, CWE detection, interval reasoning, call-graph construction, argument recovery
Decode and disassemble only24disassembly; promoting one means writing a lifter
Bytecode virtual machines, also reaching IL9WASM, Dalvik, JVM, CIL, UEFI EBC, eBPF, cBPF, Python and Lua bytecode
Total72

14 of 104 coverage targets have been exercised against real binaries rather than a specification. The largest of those runs is a Zyxel VDSL2 gateway image: 537 big-endian MIPS32 executables, 10,005,476 instructions, zero unknown opcodes.

Source reconstruction (Engine). Reconstruction emits a Rust project mirroring the program — one file per class, a function per method, a per-method confidence score, and a count of residual unrecovered operations. Nothing is skipped and no instruction is left unlifted — on airportd, stubbed: 0 and unknown_ops: 0. The project does not compile, and the manifest carries that as a field rather than smoothing it. Parameters are emitted as u64 or an opaque pointer; the per-parameter access-pattern pass that would promote a pointer to a reference is consumed by the null-dereference detector, not by the emitter, so no reconstructed signature carries a reference.

Container extraction (Unpacking). 170 format identities sit behind one closed enum, so adding a format fails to compile until the extractor question is answered — filesystems, archives, compression, firmware carriers, disk images, Docker/OCI, the Apple OTA chain. Seven containers are recognized and reported rather than carved because no extractor has been written for them, not because the container defeats extraction: btrfs, logfs, Seama, Qualcomm MBN, eCos, Wind River kernel images, and Inno Setup installers. Two more — raw zlib and compress(1) — have extractors that a scan hit never routes to, because a two-byte magic false-fires too heavily to trust; only carrier-aware code reaches them, by name. UPX gets a real static decoder rather than emulation of its stub, and the payload is what gets judged: across 12 UPX-packed corpus samples, 8 unpacked children carried evidence that changed the parent's verdict, including one parent that scored Benign on its stub and LikelyMalicious on its 41,516-byte payload.

Extraction runs against fixed default budgets, and each one shapes what a large image yields:

BudgetDefaultWhat it costs when hit
Bytes per extracted file4 GiBthat file is declined, recorded as over-budget
Bytes per invocation16 GiBextraction stops; already-written members are kept
Files per invocation1,000,000the walk truncates mid-image
Nesting depthtop level + 3deeper matches are recorded, not descended into
Decompression ratio1024× input, past 16 MiB emittedthe stream is cut as a suspected bomb
Rescan head per extracted file16 MiBembedded formats past the head are missed — except in a filesystem, disk image, archive or compression carrier, which is rescanned whole

Malware verdict (Malware). The verdict is the sum of a flat ledger, and the ledger is the only stored artifact — every row names a rule, its point contribution, and the evidence that earned it, down to a call site and an argument value. Twenty-three registered sources feed it; the largest is a set of 46 code-shape scanners, one per malicious primitive, each behind its own panic boundary so a crafted input costs that scanner's rows rather than silently producing an empty assessment. Measured against a 1,729-sample corpus — 1,336 Mach-O, 376 PE, 1 ELF, 16 scripts, 684 of them AmosStealer-labelled, so a macOS-stealer corpus with a Windows tail. That corpus is not in CI — the recall gate over it is skipped unless the samples are provisioned on the box.

CWE findings and reachability (Findings). Every CWE finding carries two independent reachability answers, and on a real daemon only one of them discriminates. configd returns 326 findings:

configd findingsReachable from the entry pointReachable before authentication
Yes1510
No0269
Cannot be decided31147

The zero is the load-bearing number. Unreachable is asserted only when the entry-rooted closure covers at least 10% of the call graph's caller nodes; below that the closure is too small to prove absence, and every caller outside it falls to cannot be decided rather than being stamped dead. Apple daemons fail that gate — their work hangs off dispatch queues and XPC handlers the static edges never reach — so on configd and airportd alike the unreachable bucket is empty and the entry-point axis sorts almost nothing.

The pre-auth axis sorts them instead. It walks forward from listener roots — the callers of recv, recvfrom, recvmsg, accept, accept4 and nw_listener_create — and stops at any function that calls an authentication primitive, so a finding is reachable before authentication only when an unauthenticated caller can reach it. That partitions 279 of configd's 326 findings, and it needs a resolved listener to run at all: airportd has none, so all 120 of its findings come back undecided on that axis.

Seventy-two CWE ids appear across the binary detectors, 71 with a detector that can emit them, twenty of those in MITRE's Top-25. Sixteen are reachable through an SSA taint lattice, so their tight catches require a proven flow from an external-input source into an argument-typed sink rather than co-occurrence anywhere in the call graph.

Capabilities and indicators (Indicators, Capabilities). Behavior is stated as facts and never as a score: each hit carries a rule id, a category, an evidence type, and its ATT&CK technique ids — no severity, no weight. 190 curated baseline rules cover an eleven-category enum of which the baseline populates nine — only Impact and Exploitation are reached exclusively through the catalog — plus 923 catalog entries across 76 files transpiled from and extending Mandiant's capa vocabulary. The baseline rules reference 68 distinct techniques and the catalog 190; the frozen 430-technique macOS table they resolve against is a lookup taxonomy, not a coverage claim. Every rule in the pinned capa snapshot is classified in-tree:

DispositionRules
Reproduced as native detectors828
Routed to fingerprinting, because they identify what a binary is157
Kept as structural Rust11
Unported — 7 of them capa's own housekeeping pseudo-rules, the other 28 each naming a mechanical blocker35
Total in the pinned snapshot1,031

Source analysis (Source SAST). Ten language walkers dispatch by file extension in production — C, Python, JavaScript, PHP, Go, Java, Swift, Ruby, C# and Kotlin; nine more are reachable only from the evaluation harness. A twentieth, the Rust walker, has no caller anywhere in the workspace, so .rs files are analyzed on no path at all. A source file over 8 MiB is skipped outright: at that size it is a generated bundle or a machine-emitted table, and a full parse tree buys no signal. They share a rule catalog with the binary engine, though the sharing is narrower than the catalog is: of 1,190 rows carrying a detector assignment, 1,084 name exactly one engine and 106 name more than one. Those 106 are the rows where tightening a topic for compiled code tightens the source walkers in the same commit.

Emulated decoding (Emulator). A bounded, sandboxed IL interpreter runs a sample's own decoder to recover the configuration and strings the binary hid from itself, rather than only flagging that a decoder exists. Nothing real is executed — library and crypto calls are modelled and return computed results — and every run ends in a resolved value, budget exhaustion, an unresolvable load, or divergence.

SBOM and CVE attribution (CVEs & SBOM). Attribution runs on two independent planes. A curated stack of 21 rules carries 22 distinct CVE ids, in three tables split by what each reads:

Curated rule tableRulesWhat it matches
Component version banner7a BusyBox, OpenSSL, Dropbear, dnsmasq, OpenSSH or Linux kernel version string inside an extracted file
Vendor firmware version8a version string in the container header, such as AAZF.4
Path6weblogin.cgi, /zysh, /samba, /apache, /cups or /pluto in the extracted tree — the path is itself the evidence

A Zyxel NAS326 image announces V5.21(AAZF.4) in its container header, which lands on CVE-2020-9054, a pre-authentication command injection patched in AAZF.7 — established without opening a single binary in the image. Separately, an operator-fetched advisory database across 13 OSV ecosystems plus EPSS and KEV matches package databases, lockfiles, and identified native libraries by version range.

Function identification (Fingerprinting). Functions that no longer have names get them back from five mechanisms working against four losses — relocation and PIE rewrites of operands, whole-symbol-table stripping, a different toolchain, a different instruction set — each giving up something to survive one of them. What ships is inspectable without an analysis run:

$ openbinary sigs status

  FLARE PE (Apache-2.0 Mandiant)        ~14.4 MiB · 1.23M entries
  openbinary-trained FLIRT (.pat.gz)    ~53 MiB · 1015 libs · 1.09M entries
  apple-symbols (Mach-O)                ~1.3 MiB · 43,812 entries
  linux-symbols (ELF)                   ~1.6 MiB · 47,554 entries

Behind those sit the ELF quad-hash corpora: 1,835,557 relocation-aware signatures over 2,118 library names and six instruction sets, mined from six corpora including an OpenWrt router root filesystem carrying 32-bit MIPS in both endiannesses. 93.8% of those signatures carry a version string, but only 69.6% carry one comparable against a CVE range: a version mined from a soname is often a bare ABI-major integer like 3 from libcrypto.so.3, with no minor or patch segment to order against "before 3.0.14".

Similarity and corpus search (Platform). A 106-dimension behavioral vector with approximate-nearest-neighbor retrieval, re-ranked by weighted Jaccard over 40 content sets, places one binary among its neighbours, and a pairwise diff over 39 named sets places two side by side: entitlements, indicators, ATT&CK techniques, XPC services, static libraries, signature-derived path and exec-target sets. Two ELF executables of 239,016 and 334,312 bytes whose sha256, md5, sha1, build-id and TLSH all differ, one named and one stripped to sub_210e0, still produce four identical derived hashes — aspecthash, syscallhash, indicator_hash and hardening_hash (Fingerprinting). A query DSL over 71 identifiers, each with a kind fixing which operators are legal on it, puts those sets in reach of a filter expression: every binary bundling libcurl that Apple did not sign is one clause against the component index and one against the signing facet.

Output formats (Outputs). Every surface leaves as a file or an endpoint: 93 route paths under /api/v1, 48 MCP tools, JSONL for batch work, and SARIF 2.1.0 carrying binary findings, source findings, redacted secrets, CVE matches, capabilities and threat-ledger rows in one document, with ATT&CK as a first-class SARIF taxonomy.

Workflows

Vet a third-party binary before it reaches a fleet. A capability map, network endpoints, code-signature state and a scored verdict come back with nothing executed. Entitlements are checked in both directions: a sandboxed app importing AVCaptureDevice or CNContactStore with no matching entitlement is a finding, and so — at lower severity — is an entitlement claimed with no corresponding API use anywhere in the code.

Diff two releases. Package-versus-package returns the added, removed and changed files between two images, optionally enriched per path with a verdict, the highest CVE severity attributed to that file, whether any of its CVEs is in CISA KEV, and an SBOM component count. Binary-versus-binary returns shared and unique sets across 39 categories — entitlements, indicators, ATT&CK techniques, XPC services, static libraries, dlopen and exec targets — plus a function-symbol diff with Rust and Itanium C++ demangling.

Audit a firmware image. Unpacking, analyzing every extracted member, and exporting one merged SARIF run produces a document in which each member is an artifact keyed by its path inside the image. Each member's findings are pushed into the document and dropped immediately, so peak memory is one member plus the 25,000-result keep-top-N heap, not every member's findings held at once. Past 1,024 members the artifact axis truncates, and that omission is recorded separately from result truncation so the two cannot be confused.

Triage a sample without a sandbox. The label, the rationale listing what drove it, and the rule-set version are reproducible with no LLM in the loop. What arrives is the ledger, the ATT&CK rollup, and nearest neighbours by behavioral similarity against the corpus — all static.

Ask the corpus a behavioral question. A filter expression answers questions asked in behavioral terms: every binary that opens a path under a user keychain directory, every unsigned binary holding a private Apple entitlement. Path arguments are normalized before indexing — /Users/alice/... becomes /Users/*/... — so the same query matches across machines and users.

Correctness safeguards

Four rules keep the output from asserting more than the bytes support.

Claims versus measurements. Every field on a binary is either a claim its author typed — section names, sizes, imports, version info, build timestamp — or a measurement that cannot be typed (Binary anatomy): entropy, derived hardening probes, recovered capabilities, internal consistency, similarity. Findings live in the gap between the two.

Format and architecture routing. Getting format and architecture wrong is silent, which is why routing returns nothing rather than guessing. PE dispatch once keyed on bitness, so every 64-bit PE went through the x86_64 lifter — including ARM64 PEs, which produced a full, confident, entirely fictional call graph. Routing now reads the COFF machine field and an unsupported machine yields an honest empty call graph.

Trust as abstention. Trust is expressed as abstention rather than as negative weight, because an abstention expressed as a weight can be outvoted by enough other weights and an abstention expressed as absence cannot. On a platform-signed binary the detectors return nothing and two entitlement-consistency kinds are dropped outright — a capability exercised without a matching entitlement, since every Apple daemon legitimately does that, and an entitlement declared but unused.

Declined containers. Recognizing a container and declining to open it is a different outcome from never seeing it, so every decline is carried through to the readout with its reason. Seven reasons cover a container identified and not descended into — low-confidence magic, no extractor, depth exceeded, over budget, cycle, already extracted, failed speculative probe — and a separate set covers an entry skipped once extraction had begun, among them encrypted and unsupported compression. Low confidence dominates: one Zyxel run recorded 813 identifications it did not descend into, 726 of them declined as low-confidence magic and 3 for having no extractor at all.

Known failures and limits

Two of six benign fixtures do not classify Benign. curl built for Windows reaches LikelyMalicious at 36 points on two TLS callbacks, a SetFileTime import, and two Communication indicators:

$ openbinary malware-score tests/binaries/curl/windows-x86_64.exe

  tier=LikelyMalicious score=36
   +8  malware.tls_callbacks       2 non-null TLS callback(s) execute before main
   +8  malware.file_timestamp_write   file-time write import "SetFileTime"
  +20  malware.indicators_communication   2 unique Communication hits:
       cgi.input_with_unsafe_strings, communication.protocol_decoder_with_unbounded_memcpy

ripgrep for Windows reaches Malicious at exactly 70. Seven of its functions match the decryptor-loop shape — Rust codegen, not a decryptor — and the three densest keep 18 points each while the other four are held at zero; systeminfo with CreateProcessW adds 8, and one TLS callback adds 8. Every contributing row is a weak band that sets no floor, so this is a pure sum crossing a boundary. The gate that runs in CI is narrower than the fixture set — it asserts the 46 scanners emit zero rows on Linux curl and jq — and neither Windows binary is covered by it.

The emulator does not unpack UPX. A sweep of five real UPX-packed samples through the entry-point unpack driver returned 0 of 5. The run diverges at operation 31, at a branch depending on a value loaded from a stack-relative address: seeding binds the stack-pointer register but never the stack contents, so the load is honestly unknown and an unknown value can never decide a branch. That is nowhere near UPX's decompression loop. The static UPX decoder is what handles those samples; the emulator's contribution is string and config recovery, and its one production family config extractor — AmosStealer — is a static byte replay, not emulator output.

Reconstructed projects do not compile. cargo check over the airportd project produces 4,605 errors spread across all 37 class files, 1,015 of the 1,593 functions carrying at least one. The failures are structural, not marginal — frame and link registers referenced but never bound, a stack pointer emitted as a constant, the same local bound twice in one scope. 100% class and method coverage and compiles: false sit side by side in the manifest: the output is a mirror of the recovered program, not a buildable port of it.

Apple's userland is the largest signature hole. The Mach-O body-hash corpus is XNU kernels only: 3,797 distinct function names across 11 SoC builds from a single macOS release. No Apple system dylib and no Apple library version is in any bundled corpus. The 43,812 records it ships collapse to 34,219 distinct body hashes at load — the same kernel function recurring across SoC builds — with 86 of the survivors dropping a competing name.

Timing is per-file work, not a constant. Measured on an Apple Silicon laptop through the CLI, cold, in the default mode:

FileBytesCold wall time
/bin/ls154,6240.54 s
jq807,9843.47 s
airportd3,220,6409.93 s

The default runs every pass; an ingest mode drops 16 of them — per-function feature vectors, C++ hierarchy, DWARF, Objective-C, Swift and Go metadata, the IOKit, dynamic-load and syscall surfaces, packing, data-in-code, fileset entries, carved Mach traps, string extraction and build provenance — for 30–50% less wall time, keeping only what a corpus record needs. Firmware images are a walk over every extracted member and scale with the member count.

Static only. Fuzzers reach paths static analysis does not. iOS binaries analyze through the macOS path; there is no IPA handler. Decompilation is mechanical and the manifest reports its gaps rather than smoothing them. The output is facts — policy belongs to whoever consumes them.

Deployment models

Three shapes, differing in where the data and the corpora live.

ModelShape
Cloud / SaaSWeb UI plus REST API against a shared corpus.
On-premLocal database and UI. Data stays on the machine.
Air-gappedTwo binaries — the CLI and the daemon. No network, no cloud. Signature corpora, the capability catalog, the Apple type database and the rule set are compiled in, so there is no runtime data directory to sync.

Comparison with other tools

Each neighbouring tool class stops somewhere specific: workbenches at one session, VirusTotal at execution, SCA at the lockfile, binwalk at the carve.

ToolHow it worksWhat is different here
Ghidra, IDA, Binary NinjaInteractive workbench: one analyst, one binary, one sessionA corpus to query, a filter expression over 71 fields, a batch pass over 6,789 extracted firmware files
VirusTotalExecutes the sample; cloud-onlyEverything here is static and runs air-gapped
Black Duck, Snyk and the SCA familyRead manifests and lockfilesCompiled binaries, which is where a vendored, statically linked, stripped OpenSSL actually lives
binwalkThe firmware-carving baseline: identifies 111 formats, extracts 72Every extracted member goes through the same analysis the top-level file did

Against binwalk's table specifically: zero of the 72 formats binwalk extracts are formats identified but not extracted here; 23 are extracted here that binwalk has no entry for at all; and two — deb and binhdr — have a binwalk magic row with no counterpart identity here. Carving is where binwalk stops and where the rest of this pipeline starts.


Engine → · Platform → · Partner →