Engine
Apple's Wi-Fi daemon still carries the load command that tells a disassembler which bytes inside its code segment are data:
$ otool -l /usr/libexec/airportd | grep -A3 LC_DATA_IN_CODE
cmd LC_DATA_IN_CODE
cmdsize 16
dataoff 1490096
datasize 0
The command is present. The table is empty. bluetoothd, sharingd, secinitd, and notifyd all ship the same way — command emitted, datasize 0. Every switch in those daemons compiled down to a jump table, and none of them is annotated any more. A tool that reads the hint resolves nothing, and the indirect branch at the heart of an XPC request dispatcher — the function deciding what a root daemon does with a message from an unprivileged process — becomes a dead end in the call graph.
A binary records a handful of roots — entry point, exports, unwind tables — because the loader needs nothing else. Function boundaries, control flow, types, calling conventions, which library a stub really reaches, which string reached a dlopen: none of that is in the file. The engine recovers it.
| Section | What it answers |
|---|---|
| The analysis record | what one analysis emits, and the two invariants every consumer relies on |
| Format parsing | what Mach-O, PE, and ELF each give up directly, and what identity each carries |
| IL lifting | how instructions become one architecture-independent form, and which conventions type the boundaries |
| Recovery passes | what is reconstructed because the file never recorded it — stack strings, call edges, jump tables, syscall numbers, call-site arguments |
| Curated knowledge | the shipped databases that put names on otherwise anonymous code |
| Cross-cutting views | capabilities, code signatures, hardening, behavioral aspects, secrets, and the authorization audit matrix |
| Outputs | the behavioral fingerprint, the Rust reconstruction, inspection and diff |
| Limits | where the engine is blind, by architecture and by design |
| Execution model | how one analysis is parallelized and served |
The analysis record
One typed record per binary — every fact the analysis produced, in one structure, type-checked in both directions at the boundary so a shape change is loud rather than silent. It carries its own schema version, distinct from the version of the build that produced it; a consumer ingesting a record newer than it knows warns rather than fails. It is the only thing that crosses the serialized wire, and the engine never reads its own output back as an input fact.
Two things travel beside it, in-process only:
| Companion input | Why it is not in the record |
|---|---|
| The raw bytes of the analyzed architecture slice | the taint detectors re-lift the whole binary from bytes rather than trust a cached IL, and byte-matching detectors need offsets relative to the same slice the record's sections are relative to — hand them the unsliced input and every match on a universal binary lands in the wrong sub-image |
| A typed carrier for analyzer-produced fields the findings and malware layers consume | as never-serialized fields on the record they were invisible to every fixture and every round-trip check: one was hard-coded to false, silently disabling a Rust-binary suppression and producing roughly 487 false positives on one Linux binary. The carrier has no serialization at all, so it cannot regrow a wire shape by accident |
Alongside it, a Rust source project that mirrors the program closely enough to compile.
The path from bytes to that record:
bytes
├─ detect → container format (Mach-O / PE / ELF / …) + an arch string
├─ parse → sections, segments, symbols, relocations, PLT stubs, unwind tables
├─ decode → mnemonic and typed operands per machine instruction
├─ lift → architecture-independent IL
├─ cfg → basic blocks and edges
├─ ssa → each value written once, every use pointing at one producer
├─ analysis → constants, value ranges, types, taint, points-to, guard dominance
├─ bridge → call graph and data cross-references, read back out of the IL
├─ recovery → ObjC / Swift / C++ / Go metadata, demangling
└─ the analysis record ◄── the output contract
An unknown architecture yields nothing, never the wrong decode. Detection returns an endian-qualified string (mips32-be, not mips32) or the literal unknown; a bare mips32 resolves to unsupported by design. Exactly one place classifies an architecture and exactly one place picks a lifter, both written as exhaustive matches so a new instruction set fails to compile until it is routed. The rule is scar tissue: a 32-bit PE once lifted as arm64 SVE/NEON garbage at 0.92 confidence, and x86-64 Mach-O once shipped an empty call graph because the only edge decoder in the tree was the arm64 one.
Unknown resolves to top, and top never proves safety. Every abstract domain over-approximates. A value absent from the range map is unbounded; arithmetic that could wrap the operand width returns unbounded; loop-carried values widen to unbounded. A consumer can therefore introduce no new false negatives by consulting a domain, only miss. The bias is toward silence everywhere.
Format parsing
Format is recognized from the leading bytes — 0xFEEDFACF for 64-bit Mach-O, MZ then a PE\0\0 header, \x7FELF — and each format parses into one structured object the rest of the pipeline queries. The parsers are in-house (crates/formats).
| Format | Structures parsed | Function extents from |
|---|---|---|
| Mach-O | chained fixups — the modern LC_DYLD_CHAINED_FIXUPS format Apple uses on arm64e — walked through bind ordinals into concrete imported symbol names | LC_FUNCTION_STARTS, a delta-encoded list that survives strip; extents come from the next start. Compact unwind is deliberately unparsed, since function starts already survive stripping and it would earn little |
| PE | imports, exports, the certificate directory, the application manifest resource, and the CodeView debug record naming the external symbol file | the .pdata runtime-function table, which gives explicit begin/end pairs — PE has the extent problem solved for free |
| ELF | dynamic sections, .rela.plt and .rela.dyn relocations, BTI-aware arm64 PLT trampolines, IBT-aware x86-64 .plt.sec, IRELATIVE entries for IFUNC resolvers, versioning tables, and the GNU build identifier | each .eh_frame unwind entry carries a function start and an exact code extent; the extent is used only to shrink the next-start heuristic window, never to grow it, because an entry spanning past the next start is suspect data |
Imports carry a kind tag — function or data — so a consumer can tell a function-pointer slot from a data-pointer entry like __stack_chk_guard or _environ. Same surface across all three formats, on different evidence:
| Format | Import-kind evidence |
|---|---|
| ELF | the relocation type, read directly — and the only format carrying a third kind, ifunc, because only ELF has an indirect-function relocation to classify |
| Mach-O | a curated libSystem data-symbol allowlist |
| PE | curated per-DLL C-runtime export allowlists |
Non-returning callees
Unwind tables answer a question nothing else can: which callees never return. A function whose window ends exactly at an unwind-table end, whose last instruction is a direct call falling through exactly to that end, calls something that does not return — the compiler ended the code there because there was no return path. That seed feeds a whole-program fixed point which propagates "does not return" through every local wrapper, sets the flag, and drops the bogus fall-through edge after each such call. On a fully stripped static ELF it is the only seed available, because name-based seeding dies with the symbol table. A binary built with unwind tables disabled has an empty seed set, the fixed point is blind, and dead fall-through arms survive into reconstruction.
Build identity
Each format spells its unique identifier differently, and the surface is made symmetric.
| Format | Unique identifier | Also carried |
|---|---|---|
| Mach-O | LC_UUID | minimum-OS, SDK, and source versions; the bundle identifier |
| ELF | the GNU build ID | DT_SONAME, the interpreter path |
| PE | the file hash, de facto | the PDB filename out of the CodeView record — but not the GUID, which is parsed and not yet surfaced |
Four hash families are computed on every analysis on the same byte slice — SHA-256, MD5, SHA-1, and an ssdeep fuzzy hash — with both a whole-file and a slice-only SHA-256 for universal binaries, so a binary joins cleanly into VirusTotal, MISP, and any other corpus keyed on a particular algorithm.
Containers
Container formats are unwrapped into the images they hold, and each image is analyzed in its own right.
- DMG disk images — walked through the HFS+ catalog.
- The dyld shared cache — every macOS system framework packed into one multi-gigabyte file plus subcaches, memory-mapped and reconstructed image by image into synthetic Mach-O buffers.
- Apple kernel collections — split into per-kext analyses, then merged.
- Universal binaries — sliced per architecture.
A wider recursive layer sits in front covering filesystems, archives, compression, firmware carriers, disk images, the Apple OTA chain, and UPX; the images it can name but not open are btrfs, LogFS, eCos and Wind River kernel images. Mechanism, budgets, and the packed-to-payload verdict handoff are in Unpacking.
IL lifting
Decoded instructions become IL — one representation independent of the CPU that produced it. Operations and expressions are a single set, and every pass refines that set in place rather than lowering between altitudes.
Flags split cleanly from data flow: ADDS lifts as an assignment plus a separate flag-set, so a pass tracking values never has to model condition codes. Operand width is stamped from the explicit operand, and W-forms sign-extend — a 32-bit operation modelled at 64 bits makes range analysis reason at 2^64 and silently manufactures false negatives.
An operation the lifter recognizes but has no primitive for becomes an explicit unmodelled-operation marker — a measured gap, and deliberately distinct from a decode failure, which means the byte stream at that address was never instructions in the first place. What is never done is lifting one operation as a different one:
| Operation | Where | How it lifts |
|---|---|---|
MOD | cBPF | remainder; it once lifted as unsigned divide — a real bug |
ROTL / ROTR | H8/300 | rotate; it once lifted as a bitwise or with zero — a real bug |
| 8-bit rotates | 8051, 6800, PIC | real rotates, carry bit included |
| Count-leading-zeros | arm, arm64 | a real unary operation |
BCD decimal-adjust (DA A, DAA) | 8051, 6800 | the accumulator re-defined as itself — the one identity over-approximation left |
The decimal-adjust case is deliberate: the corrected value depends on the carry and half-carry flags and the nibble values, and is not expressible without conditionals. Identity keeps the def-use chain intact for taint at the cost of a value-range gap on those two architectures.
Control-flow graph and SSA
SSA sits on top of the control-flow graph: each value is written exactly once and every later use points back at a single producer, so a pass asking where did this come from walks one edge instead of searching the function. Dominators via Lengauer-Tarjan; SSA construction via the Cytron pruned-phi algorithm.
CFG construction is shared for most of the tree, not all of it. Twenty-two architectures — the embedded families, M68k, Z80, s390x, LoongArch, TriCore, MSP430, 8051, 6800, PA-RISC and the rest — implement one small trait and get block splitting and edge recovery from a common builder. arm64, x86-64, arm and mips each carry a hand-written builder, so a new architecture in that class does not get a CFG for free.
Instruction coverage
The spec registry carries 104 targets, of which 72 are architectures — the other 32 describe executable formats, platform surfaces, and cross-cutting concerns rather than an instruction set. The 72 split into three tiers whose union is asserted equal to the registry, so a target that cannot be lifted is never made to look as though it can and a new architecture cannot land unaccounted-for. The per-target breakdown is in Architectures.
| Tier | Targets | Decodes | Lifts to IL | Note |
|---|---|---|---|---|
| Native architectures | 39 | yes | yes, with deep probes | |
| Bytecode VMs | 9 | yes | yes, through a non-native seam | wasm, Dalvik, JVM, CIL fully; EBC, Lua, Python, BPF and cBPF partial |
| Decode-only targets | 24 | yes | no | this is the coverage gap, named rather than hidden: alpha, mcore, rx, rl78, tms320, xcore and the rest get no taint, no CWE detection, no value range, no call graph |
The arm64 and x86-64 decoders in detail:
- arm64 — the full Apple Silicon instruction set.
- x86-64 — 25 measured coverage dimensions across the System V AMD64 ABI on macOS and Linux and the Microsoft x64 ABI on Windows: the base integer, branch, bit and system sets, plus x87, MMX, SSE through SSE4.2, AVX, AVX2 and AVX-512 including the FP16 and opmask subsets, AMX, AES-NI, SHA-NI, GFNI, SM3/SM4, Key Locker, XOP, BMI1/BMI2, ADX, TSX, CET,
LOCK-prefixed atomics, REP-prefixed string operations at byte through qword widths, ring transitions, the Windows thread environment block, and thread-local storage. Position-independent jump-table dispatchers resolve in three shapes — the cross-formatjmp [rip+disp], the two MSVC forms including the two-level byte-index table, and theLC_DATA_IN_CODE-hinted form.
Dozens more instruction sets sit under the same dispatch.
Calling conventions
Function boundaries carry typed parameters and typed returns from the first pass. The convention is selected from the architecture-and-format pair. Forty-eight ship; the ones a reader meets most:
| Convention | Where | Distinguishing feature |
|---|---|---|
| AAPCS64-Apple | macOS, iOS arm64 | X18 platform-reserved; ObjC variadic calls bypass registers |
| AAPCS64-SysV | Linux/BSD arm64 | X18 caller-saved; no ObjC promotion path |
| SysVAmd64 | macOS, Linux x86-64 | Standard System V |
| MS_X64 | Windows x86-64 | Microsoft x64 ABI |
| GoRegabi | Go binaries, any architecture | Go's register-based sequence |
| Swift and Swift-method | any Apple architecture | separate error and context registers; the method form reserves the receiver slot |
| MSVC x86 | 32-bit Windows | four distinct forms — stdcall, cdecl, fastcall, thiscall — that differ on who cleans the stack |
The remaining forty are per-architecture: MIPS o32 and n64, Darwin PowerPC and SysV PowerPC 32/64, AAPCS with and without the VFP float variant, the BPF helper and seccomp-cBPF sequences, and one each for roughly thirty embedded targets.
Recovery passes
Stack strings, call edges, jump tables, syscall numbers and call-site arguments are all reconstructed rather than read. The loader never needed any of them, so no format records them.
Stack strings
A string that never appears in the file is recovered from the stores that spell it out. Obfuscated loaders keep their strings out of the string section by writing them onto the stack a chunk at a time, so strings returns nothing; the pass groups consecutive constant stores to adjacent stack offsets at any width up to 8 bytes, splits each immediate into its in-memory bytes using the target's byte order, resolves overlaps last-write-wins, and reports printable runs of 4 characters or more:
mov qword ptr [rsp+0], 0x6c6c642e6c64746e
mov word ptr [rsp+8], 0x3233
mov byte ptr [rsp+10], 0x21
→ "ntdl.dll32!"
Three stores of three different widths compose into one eleven-character string rather than three fragments. Byte order is load-bearing and directional: the same 32-bit immediate 0x6c64746e yields ntdl under a little-endian convention and ldtn under a big-endian one, so getting it wrong reverses every string recovered on PowerPC, SPARC, s390x, and m68k.
The measurement that forced the design: an earlier pass handled only the byte-at-a-time idiom — the arm64 and -O0 shape — and recovered exactly one stack string across 679 corpus samples, 128 malware families, and 100,639 functions, because byte-at-a-time is not the idiom the x86-64 and Mach-O corpus uses. Widening to any-width turned that into runs like PasivRobber assembling 44 consumed strings inside a single function (RemoteUninstall, cp -r "%s" "%s", use_ansi_encoding) and a batch-command builder assembling nine.
The 8-byte ceiling is a real blind spot: a 16-byte vector store cannot carry its value in the IL's 64-bit immediate, so it is skipped rather than truncated into a wrong four-character fragment.
It is genuinely dual-use. Benign jq builds hello and goodbye this way; benign curl builds AUTHENTICATE and ssh-ed25519. The doubt is carried downstream by a flag recording whether the slot's address was taken, plus a measured count gate — never by refusing to recover the fact.
Dataflow passes
Passes over the SSA form fall into three groups — value tracking, runtime-noise removal, and source structure:
| Group | Pass | What it does |
|---|---|---|
| Value tracking | Constant propagation | moves literals forward through arithmetic |
| Value tracking | Copy propagation | collapses register-to-register chains |
| Value tracking | Common-subexpression elimination | removes redundant computation |
| Value tracking | Type propagation | infers shapes from compare instructions, mask widths, signed-versus-unsigned operations, and pointer arithmetic |
| Value tracking | Struct recovery | groups field accesses through a common base pointer into named layouts, unified across call boundaries so a struct discovered in one function keeps its identity in every caller and callee |
| Value tracking | Non-zero bit-mask propagation | tracks which bits of a value can be set at all, narrowing a 64-bit type down to 8, 16, or 32 bits on AND-mask evidence |
| Value tracking | Interval domain | runs beside all of it, narrowing each value to a low-high pair from constants, arithmetic, and branch guards, tracking intervals through stack slots, and feeding the bounds-checking detectors |
| Runtime noise | ARC lowering | recognizes Apple's automatic-reference-counting calls — objc_retain, objc_release, objc_storeStrong — and lowers them to semantic operations; those calls account for 30 to 50 percent of call-graph edges in typical Objective-C |
| Runtime noise | Dead code elimination | removes unreachable and unused work |
| Runtime noise | Peephole rewriting | 35 rules under fixpoint iteration capped at 20 rounds — 12 arm64 idiom rewrites (csel, ccmp, ubfx, extr) and 23 architecture-independent ones: division-by-constant recovery, boolean identities and annihilators, shift and arithmetic identities, pointer-arithmetic and redundant-load collapsing. The set runs twice, before and after constant, copy and subexpression propagation — exactly one extra pass, not a global restart loop — because folding constants and canonicalizing operands exposes rewrites the first pass could not match |
| Source structure | Stack-slot recovery | rebuilds the local variable layout |
| Source structure | Constant-sequence recovery | reassembles literals — printable strings, byte arrays, GUIDs, short structs — that the compiler split across several byte stores |
| Source structure | Variable merging | unifies values sharing a storage origin using interval-based merging rather than union-find, so cross-origin merges cannot leak into each other |
The interval domain's known cost: a bounded loop induction variable reads as unbounded — precision traded for soundness deliberately — and Asr, Sdiv, and Srem are modelled only for provably non-negative operands.
Naming. Up to three sources of human-meaningful names — argument-taint analysis, Objective-C ivar layouts, and DWARF when present — are applied at print time without touching the IL, so the raw stage dumps stay raw.
Guards. A structural pass answers "is this index checked?" and can miss a real bug but never manufactures a false one. It runs without value range or path sensitivity, and is conservative toward suppression: an index masked with & C or % C is bounded without any compare, and treating it as unguarded would false-positive on every hash table and ring buffer in existence. A compare in a dominating block counts as a guard even if the closure that found it was over-broad.
Memory. Points-to facts key on allocation-site address, not on the SSA value, so a use-after-free survives an SSA reload and an escape through a global. free(p) is a strong update to freed only when p resolves to exactly one object; empty, multiple, or non-unique targets weaken to maybe-freed, which never fires. At a branch join, freed and alive meet at maybe-freed.
One class of fact crosses a call: a callee proven by its bottom-up summary to free a parameter on every path applies that free to the caller's argument object, which is what catches a use-after-free through a one-level wrapper. A callee that only dereferences a parameter needs no transfer — the state already at the call site answers that. Nothing else crosses — an indirect target, an unlifted callee, a recursion-cap hit, or no summary for that callee leaves the object as it was. Only total leaks are reported: an error-path leak where some paths free and some do not is maybe-freed and stays silent.
Interprocedural summaries
Intraprocedural passes stop at a call; a family of bottom-up function summaries crosses it. The call graph is condensed into strongly-connected components with Tarjan, the condensation is walked bottom-up, and each function gets a summary of what it allocates, dereferences, frees, and flows to which sink — with a fixpoint only on recursion, so no callee body is re-analyzed at each call site. Those summaries feed points-to and use-after-free reasoning, interprocedural taint through helper functions, and type and range propagation across calls.
Two summary consumers ship and fire: the cross-call free transfer behind use-after-free through a wrapper, and cross-function taint from a source through a helper to a sink, which makes a local wrapper transparent to the whole sink catalog.
A third does not. An argument-access oracle would reflect a callee's dereference and free pattern back into the caller's taint state and fire three callee-aware rules on shapes an intraprocedural lattice cannot see. The rules and the oracle exist; nothing in the shipping tree calls either them or the interprocedural argument-access propagation, so those three rules cannot fire on any input, with or without the gate meant to control them. The yield measured when the wiring was present is the size of that gap, not current behavior:
| Fixture | null-deref.callee-dereferences | use-after-free.callee-uses | callee-dereferencing taint flow |
|---|---|---|---|
| curl | 4 | 8 | 5 |
| jq | 16 | 3 | 4 |
| airportd | 0 | 0 | 1 |
| ripgrep | 0 | 0 | 0 |
The taint substrate itself carries no notion of vulnerability — it answers reachability only, and saturates: once a value is marked as coming from any source, no later union can lose that, so every sink keeps firing and only source attribution degrades. Which flows are a CWE is a separate layer (Findings).
Bounded emulation
A single-path, integer-only interpreter over the IL runs wherever propagation cannot pin a value — when it depends on a memory load, a non-trivial loop, or arithmetic the compiler folded into a non-obvious form.
Budgets are tiered by driver and span four orders of magnitude:
| Budget | Value | On exhaustion |
|---|---|---|
| Per probe, default | 4,096 lifted IL operations — one step is one IL operation, not one machine instruction | the run's result is discarded, never truncated into a partial answer |
| Per seeded extraction run | 262,144 steps, because a real compression-class packer stub was measured to exhaust anything smaller while still writing output | same — a partial decrypt is dropped, not surfaced |
| Per entry-point unpack run | 64,000,000 steps, plus an 8-second wall-clock deadline and a 4 MiB copy-on-write overlay cap | one such run is 16 times the whole per-binary ledger, so it is a deliberate, separately-driven exception rather than something a scan falls into |
| Per binary | 4,000,000 steps across every probe | remaining sites return empty rather than running more emulator work |
The overlay cap is a memory bound, not a step bound: an overlay byte costs a measured 21.5 bytes of resident memory regardless of write pattern, so 4 MiB written is roughly 86 MB of peak resident memory for one run.
Every run ends in one of four states:
| End state | Meaning |
|---|---|
| resolved | the value folded to a concrete number |
| budget-exhausted | either budget ran out |
| unknown-load | an unmodelled address was read |
| diverged | a syscall, floating point, a condition whose flag comparison is not live, a branch or call target that does not fold, or a store whose address or value does not fold to a concrete number |
Divergence is the honest edge of the model. Copy-on-write memory lets an in-place decryptor read back the plaintext it wrote, bounded by an overlay cap. An uninitialized read follows an explicit policy and never returns a silent zero: the default faults, and the alternative returns zero but flags the run so the output gate can reject a plaintext derived from uninitialized memory.
Nothing auto-runs unbounded during a scan. Five consumers probe the interpreter on constant-propagation misses:
| Consumer | Probes for |
|---|---|
| Jump-table lifter | the table base |
| Syscall pass | svc immediates |
| Dynamic-loader pass | string arguments |
| Behavioral signatures | call-site arguments and format-string prototypes |
| Indirect-call fallback | targets the call-graph builder could not pin |
Seeded extraction runs that recover decrypted strings and malware configs, and the hash-recurrence oracle that reverses API-hash resolvers, sit on the same core and are described in Emulator — including the sweep of five real UPX-packed samples that returned 0 of 5, every run diverging at operation 31 on a branch whose truth depends on a stack-relative load. Seeding now binds the stack-pointer and frame-pointer registers to a synthetic base and continues past an unknown read instead of aborting, but still never binds the stack contents, since no kernel-supplied argument frame exists at that address; neither sweep has been re-measured since.
Call resolution
Every bl, blr, and call maps to a real target, with runtime indirection followed through.
| Platform | Indirection | How it resolves |
|---|---|---|
| Apple | objc_msgSend stub trampolines; arm64e authentication stubs | stubs are decoded so selectors appear directly as objc_msgSend$initWithMetricName:options:; authentication stubs resolve through GOT entries via chained-fixup bind ordinals |
| Windows | position-independent call qword ptr [rip+disp32] | resolves through the import address table to DLL function names, with no lifter changes |
| Linux | PLT/GOT trampolines, including the BTI-aware arm64 and IBT-aware x86-64 variants | jump-slot relocations bind each stub to its import name; the versioning table resolves the library ordinal |
On ELF, C++ exception landing pads recovered from .gcc_except_table are seeded as discovered functions and rooted, so a catch handler the unwinder branches to reads as reachable rather than dead code — the same behaviour Ghidra's GccExceptionAnalyzer locks in.
On Apple binaries roughly 98 percent of call sites — bl and blr instructions — end up traced to a named target rather than left dangling. Around that:
- Tail calls are distinguished from local branches.
- Compiler-inserted ARC calls are filtered from the graph by default.
- Every call and unconditional branch in the disassembly view carries an inline comment naming its resolved target.
One symbol table underlies all of it, merging the static symbol table, imports, Objective-C stub trampolines, authentication-stub targets, recognized library functions, and the synthetic addresses assigned to resolved syscalls into a single space the rest of the pipeline queries.
The hard limit is architectural: edges decode for arm64 and for x86/x86-64, and nowhere else. Everything outside those families still gets a symbol table, a function list, and lifted IL, but an edge-free call graph with a surfaced "no edge decoder" error — an embedded MIPS or PowerPC firmware image is honestly empty on edges rather than quietly wrong. Indirect targets that devirtualization cannot resolve stay edge-less on every architecture.
Jump tables
Jump tables are resolved from the instruction idiom itself rather than from the LC_DATA_IN_CODE hint, because Apple ships its daemons with that table emptied and a hint-reading resolver therefore resolves nothing on precisely the binaries most worth auditing. Compilers turn a switch over dense integer keys into a base address plus an array of offsets dispatched by an indirect branch, and until the table resolves, the call graph stops there, cross-references stop, and reachability walks stop.
The fallback detects the canonical idiom directly: cmp, then adrp+add, then ldrsw with a shift, then adr+add+br. Multi-tier range checks up to three nested cmp/b.gt pairs — the shape macOS daemon XPC dispatchers use — collapse into a single switch. The dispatcher's predecessor compare chain carries the bounds; the load-dispatch sequence carries the entry size; on arm64 the emulator supplies the table base when constant propagation cannot. x86-64 has no emulator by design, so every x86-64 shape is recovered by pattern-matching the lifted IL plus direct reads of the image bytes. Resolved handler addresses become first-class call-graph edges, so a cross-reference on a handler address finds its dispatcher, reachability walks pass through, and reconstruction emits the dispatcher as a match with one arm per handler.
Cross-references
Every reference between code and data is indexed forward and reverse. ADRP+ADD and ADRP+LDR pairs on arm64 resolve to target addresses and land in two indexes: which data each function references, and which functions reference each address. String constants resolve through those indexes and inline as comments, so a load of 0x1000d2480 reads as ; "com.apple.wifi.set_channel" beside the instruction.
The same index answers query-by-needle: an imported symbol, a virtual address, a string literal, or an Objective-C class name returns every referencing site, each tagged by kind — call, address load, class reference, string reference, jump table.
System calls
Syscalls are resolved whether they go through the standard library or straight to the instruction, with a separate path per OS because the ABIs diverge. Most go through the standard library; going straight to the instruction is a common way to bypass library-level instrumentation.
| OS | Mechanism |
|---|---|
| Apple | svc #0x80, number in x16 |
| Linux x86-64 | syscall, number in rax |
| Linux arm64 | svc #0, number in x8 |
| Linux ARM32 | svc 0, number in r7 |
| Linux MIPS o32 | syscall, number in $v0 |
| Linux PowerPC | sc |
| Windows | usually Win32 imports; a raw syscall is itself the tell |
One number table is curated per OS and per architecture, nine of them for Linux alone, straight from each kernel's own. The spread between them is the point — a number valid on one is a different call on another, which is why there is no single Linux table:
| Table | Entries | Note |
|---|---|---|
| Apple | 457 BSD entries plus 62 Mach traps | from XNU's syscalls.master |
| Linux arm64 | 328 | started from the trimmed asm-generic list |
| Linux MIPS o32 | 445 | carries decades of legacy numbering |
| Linux x86-64 | 385 | |
| Linux x86 (32-bit) | 423 | |
| Linux ARM (32-bit) | 424 | |
| Linux PowerPC | 425 | |
| Linux SPARC | 434 | |
| Linux s390x | 381 | |
| Linux MIPS n64 | 374 | |
| FreeBSD | 499 | the BSDs diverge harder than the Linux architectures do |
| NetBSD | 377 | |
| DragonFly | 316 | |
| OpenBSD | 231 | a direct readout of that project's minimalism |
| Solaris/illumos | 246 |
Windows renumbers its service table on every release, where Linux numbers have been stable for decades and the BSDs move only on major versions, so a Windows syscall number means nothing without a version to read it against:
| Windows table | Entries | Covers |
|---|---|---|
| Primary Nt* | 142 malware-relevant entries | their Windows 11 23H2 and Windows 10 22H2 numbers |
| Per-build | 57 entries | a separate number for each of Windows 10 1809, 10 22H2, 11 21H2, 11 22H2, 11 23H2, and 11 24H2, so a hardcoded index can be read back as which build the sample was written for |
win32k.sys graphics | 21 entries | the graphics service table |
| Windows 7 RTM fallback | 401 NT entries and over 800 graphics entries per bitness | the embedded and industrial systems frozen there, and the 2009–2015 samples that hardcoded those indices |
A number outside every table is reported as an unresolved direct syscall, not guessed at, and that non-answer is itself a strong indicator of syscall-based evasion.
Resolved syscalls are placed in a synthetic address range so they appear as first-class call-graph edges and cross-reference targets alongside imports. A separate counter tracks direct-syscall sites where the number could not be pinned — a spike in that counter is itself the obfuscation signal.
When the binary under analysis is libsystem_kernel.dylib, the Mach-trap number table is statically carved out of the SSA IL and keyed to that library's own build identifier, so trap numbers join to names without running a kernel; the carve also diffs itself against the curated table and reports which entries are new and which disappeared. The artifact is written only when explicitly asked for, and the shared-cache identifier the record has a slot for is never filled in — only the library's own identifier is real.
Dynamic loading
Each dynamic-loading site is an indirect call whose target is a string, and until the string is recovered the call graph at that site is empty: dlopen("/usr/lib/libobjc.A.dylib"), dlsym(h, "objc_msgSend"), NSClassFromString(@"NSXPCConnection"), objc_getClass, sel_registerName. Sites split three ways:
| Site state | Meaning |
|---|---|
| Resolved | pinned to a literal; the site is annotated with the exact path, symbol, or class |
| Computed | built from a computation traceable but not constant-foldable — a concatenation, a sprintf with a constant format and a variable |
| Unresolved | neither |
A high computed count is almost always string obfuscation, and the count is a first-class field on the fingerprint.
Behavioral signatures
A behavioral signature is the resolved arguments at a specific call site — one step past "this binary calls open". Opens a path under /Users/*/Library/Keychains/login.keychain-db. Execs /usr/bin/osascript. Connects to a Unix socket under /var/run/.
Sixty call primitives carry per-argument schemas:
| Family | Primitives |
|---|---|
| Filesystem | open, openat, stat, access, readlink, getattrlist, chmod, mkdir and their at variants |
| Process spawn and identity | execve, posix_spawn, the execl and setuid families |
| Dynamic loading | the dlopen/dlsym family |
| Kernel-state queries | sysctlbyname |
| BSD networking | socket, connect, bind, listen, accept, getaddrinfo, gethostbyname, res_query |
| CFNetwork | the URL/HTTP/Host/Stream surface |
| Mach-XPC | the XPC surface, including the initWithMachServiceName: message stubs |
Two transforms make a signature stable across hosts:
- Path normalization — rewrites
/Users/<name>/to/Users/*/and/var/folders/<hash>/T/<f>to/var/folders/*/T/*, while leaving Apple system paths verbatim. - Flag decoding — turns numeric arguments into named bit sets for open flags, access modes, and socket domain and type.
Resolution decides what is emitted:
| Required arguments resolved | Emitted as |
|---|---|
| All | a full-resolution signature |
| At least one | a partial signature |
| None | nothing at all |
That last rule is what keeps the surface searchable: asking a corpus for every binary that opens a path under /Users/*/Library/Keychains/ returns answers, not noise.
Curated knowledge
Names, layouts, and reference distributions the binary does not carry come from shipped databases, from the metadata a runtime requires of itself, and from debug information when it can be proven to match.
Apple framework layouts
A curated database of 3,162 classes and 18,633 fields — Foundation, AppKit, CoreData, CoreFoundation, Security, QuartzCore, CoreLocation, CoreText — turns Apple framework field accesses back into names. Most of what an Apple binary does is load fields out of those types, and without the layouts every one of them reads as an anonymous offset. NSXPCConnection, for one:
offset 8 → _connection (an OS_xpc_remote_connection)
offset 16 → _repliesExpected
offset 24 → _userQueue (a dispatch queue)
offset 32 → _state
Known struct types (CGRect, CGPoint, NSRange) resolve offsets to named members; known enums resolve integer constants to named variants. Loaded on first access, so a binary that never touches Apple frameworks pays nothing.
Library recognition
Four independent corpora identify linked-in library code and name it, consulted in descending confidence. A real binary is mostly not the application's own code — it is OpenSSL, libcurl, sqlite, and several hundred other linked-in projects — and without recognizing them, analysis spends its time reading library internals and reconstruction emits them instead of imports.
| Corpus | Scale | Keyed on |
|---|---|---|
| Hand-built signatures | 67 | the strings and algorithmic constants a function references plus its size range — zlib, sqlite, OpenSSL, BoringSSL, libcurl, lz4, zstd, libpng, mbedTLS, xxHash. The constants are drawn from FIPS, RFC, and ISO specifications: permanent mathematical facts, not version-specific bytes |
| Prologue hashes | 87,828 hashes from 219 open-source libraries | function prologues, shipped as a 6.5 MB binary index |
| Apple kernel fingerprints | 3,797 distinct kernel functions across 11 Apple Silicon variants, including the virtualization kernel | byte-identical signatures mined from the arm64e kernels on disk. Each signature carries the macOS version it was mined from, so a refresh after an OS upgrade is an append, not a replacement. They put real names like Switch_context and cluster_pagein on calls that were otherwise anonymous |
| FLIRT pattern corpora | a vendored Mandiant FLARE pack of 1,236,310 parsed entries (Apache-2.0), plus a generated corpus of 1,015 blobs covering 99 statically-linked libraries across all three formats — 667 ELF blobs spanning x86-64, arm64, arm, riscv64, ppc64, s390x, mips64 and both musl and glibc variants, 295 PE, and 53 Mach-O | FLIRT patterns. The FLARE pack covers MSVC runtime libraries for PE x86 and x86-64 and is lazy-loaded so it never fires on a non-PE input; the generated corpus covers libcrypto, libsodium, libonig, libpcre2, libnghttp2, libzstd, libopus, libwebp, libevent, libcares and the rest, each blob decompressing on first use and parsing once per process |
Precedence on hash collision is explicit and three-layered: an operator-supplied path beats a user cache, which beats the bundled corpus. The user cache is extensible from three miners — one refreshing the kernel fingerprints after a macOS upgrade, one extracting every named function from any dyld cache (a vendor SDK, an iOS firmware cache, a different macOS version), and one doing the same for a standalone dylib, daemon, or app.
The match set rolls up into build provenance — a consensus compiler (Apple clang, MinGW, MSVC, GCC), a consensus optimization level, and an agreement ratio — plus a statically-linked-library inventory with per-library name, version, and matched-function count. That inventory is what the CVE layer matches against (CVEs & SBOM).
Runtime metadata
Every binary publishes some metadata that its own runtime needs, and that metadata survives stripping because the program stops working without it.
| Language | Read from | What comes out |
|---|---|---|
| Objective-C | __objc_classlist | Classes, instance and class methods, ivars, protocol conformances. Type encodings (v24@0:8@16) decoded into readable signatures. Both the arm64e relative method-list layout and the legacy absolute-pointer layout. Shared-cache images additionally read Apple's libobjc optimization tables for selectors and class names the per-image path cannot reach |
| Swift | __swift5_types, __swift5_fieldmd, __swift5_proto | Type descriptors through relative 32-bit pointer resolution. Field names, types, mutability, indirect enum cases. Protocol descriptors and conformance records with retroactive and synthesized flags. Pure-Rust demangling. Module names inferred from prefixes |
| C++ | vtables in __const, RTTI in __data_const | Itanium ABI vtable parsing. Typeinfo classified leaf, single-inheritance, or multiple-inheritance. Full class hierarchy. Pure-virtual slot detection. Vtable function pointers merge into the global symbol table |
| Go | build info, pclntab, moduledata, type descriptors, interface tables | Go version, module path, build settings. Function names, boundaries, and source-line mappings even when stripped, across ELF, Mach-O, and PE. moduledata anchors the walk when section headers lie. Function-chain analysis attributes each function to its defining module. Toolchain provenance splits stdlib evidence into compiler-mandated versus author-elected imports, so a go build hello-world stops looking like it chose to link networking. Interface-table parsing resolves dispatch sites to concrete type-method pairs |
Each binary is classified Objective-C, Swift, C++, Rust, Go, Mixed, or Unknown from which metadata is present and which symbol patterns appear, and the classification is a first-class field on both the analysis and the fingerprint, so a similarity search can scope to one runtime.
DWARF
Debug information is used only when it can be proven to belong to the binary, and refused silently when it cannot — a mismatched dSYM or a CRC-mismatched sidecar means the analysis proceeds without it rather than with the wrong names.
- Mach-O — embedded
__DWARFsections, or an external.dSYMwhose UUID matches. - ELF — inline debug sections, or a separate debug file found two independent ways: a
.gnu_debuglinkchain walking the binary's directory, its.debugsubdirectory, and/usr/lib/debug/, with CRC32 validated against the link record; and a build-id-keyed lookup under/usr/lib/debug/.build-id/, which fires whether or not a.gnu_debuglinksection exists — the same rule GDB follows — because there the digest is its own integrity check.
What comes back: function signatures with named parameters and typed returns, struct field names and sizes, enum variant names, source-file paths from the line table. DWARF locals are one of three rename sources; precedence runs argument-taint, then ivar layout, then DWARF, then mechanical, so DWARF supplements rather than overwrites.
Function rarity
Each function is scored by Mahalanobis distance against a reference distribution of centroids, computed per binary type (daemon, app, extension, kernel) so scores are comparable within a class. Once library code is filtered out, what remains is the application's own — and most of that is unsurprising scaffolding. The reference distribution is a 65 KB blob compiled into the executable and parsed on first use: no setup, no cluster fit, no model serving. The output is an ordering, most unusual first.
This is the engine's only built-in machine learning. Heavier classifiers live on the consumer side, fed by the fingerprint and by a per-function feature vector of 41 dimensions:
- 34 numeric, log-transformed — basic-block count, IL operation count, branch count, call count, maximum loop depth, fan-in, fan-out, and per-opcode-class counts.
- 7 boolean — leaf, indirect call, indirect branch, recursion, noreturn call, exception handling, crypto constant hit.
Cross-cutting views
Each of these views is computed from the one analysis record, read from a different angle.
Capabilities
What a binary can do to the system, mapped into platform-specific domains by combining entitlement evidence, framework evidence, and resolved syscall evidence.
| Platform | Domains | Evidence |
|---|---|---|
| Apple | Location, Keychain, Network, Storage, Hardware, IPC, Process, Analytics, Security, System | Entitlements from the code-signature plist (specific keys plus prefix patterns), linked frameworks, syscalls and libSystem wrappers through the aspect taxonomy |
| Linux | Network, Process, Filesystem, Memory, IPC, Crypto, DynamicCode, Time, SystemInfo, Threading | A curated table of 297 libc symbols, nine per-architecture syscall tables, TLS-base and fs-segment intrinsic detection |
| Windows | ProcessControl, Network, Crypto, HostInteraction, AntiAnalysis | Imported DLL classification (kernel32, ws2_32, bcrypt, crypt32, ole32), TLS-callback presence, control-flow-guard and exception-continuation directory entries |
An entitlement with no mapping is reported as unmapped, never dropped. Every hit carries its raw evidence — the entitlement string, the framework name, or the syscall — and severity is left to whoever reads it.
Code signatures
One symmetric identifier surface across the three formats, so a consumer can join on it:
| Format | Identity digest | Other recovered fields |
|---|---|---|
| Mach-O | CDHash, a digest of the signed structure, from the CodeDirectory blob | team identifier, platform identifier, and signing flags decoded into names — hardened runtime, library validation, restrict. Each binary classifies as Apple-signed, developer-signed, ad-hoc, or unsigned |
| PE | a SHA-256 over the full Authenticode PKCS#7 signature blob, the PE counterpart to the CDHash | the first signer's common name, the digest algorithm from the SignerInfo OID, and the signing time from the PKCS#9 attribute. A malformed-but-present signature still surfaces whatever fields could be recovered; an absent certificate directory returns unsigned |
| ELF | the GNU build ID — typically 20 bytes for SHA-1, 16 for MD5 | written into a note section and its corresponding note segment |
The PE application manifest — the Win32 equivalent of an entitlements plist — surfaces the requested execution level (asInvoker, highestAvailable, requireAdministrator), the UI-access flag, and the DPI declaration.
Build hardening
Hardening is emitted on every analysis rather than behind a separate command, so two builds of one source with a single mitigation flipped differ in the fingerprint and in the diff automatically. Twenty-two Mach-O finding kinds, ten PE, eleven ELF. The per-flag meaning of each is in Binary anatomy.
Six of the Mach-O kinds read entitlements rather than build flags, because an entitlement can hand back a mitigation the compiler switched on:
get-task-allowdisable-library-validationallow-unsigned-executable-memorydisable-executable-page-protectionallow-dyld-environment-variablesallow-jit
The Linux classifier reads architecture-specific control-flow-integrity bits out of the PT_GNU_PROPERTY segment — and when a toolchain emits that property record only as a section, with no corresponding program header, it falls back to a section walk. That is the exact shape checksec and pwntools miss and report as unprotected.
And there is no global hardening score. Findings carry per-finding severity inheriting industry-tool conventions, but aggregating across formats would be dishonest and policy belongs to the consumer. A hardening hash and an on/off summary feed the fingerprint; the diff carries regression and improvement sections so a mitigation flip across versions surfaces as a fact.
Surface consistency
The gap between what a binary declares — entitlements, manifests, signatures — and what it actually does — resolve imports, call syscalls, follow dynamic loads, expose XPC services — is where the finding lives:
| Divergence | Meaning |
|---|---|
| Declared, unused | A capability is declared but never exercised |
| Used, undeclared | A capability is exercised without being declared |
| Via dynamic load | A capability is reached only through dlopen or dlsym, invisible to anything that reads static linkage |
| Private entitlement, non-Apple | An Apple-private entitlement is held by a binary that is not Apple-signed |
| Ad-hoc privileged | An ad-hoc-signed binary holds privileged entitlements |
| Bundle-ID impersonation | The bundle identifier is constructed to impersonate a trusted identity |
Each carries MITRE ATT&CK technique IDs to pivot on, and each is a record, not a verdict. The last two consult a curated Apple team-ID allowlist holding three classes of entry: legacy display strings (Apple Inc., Software Signing — the identity forms codesign -dv printed before team IDs existed), public ten-character team IDs observed on shipped Apple binaries with each row citing the binary it was observed on, and the short prefix sentinels Apple uses internally.
Behavioral aspects
Twelve axes partition what a binary does: file_io, network, process_control, ipc, memory, crypto, time, signals, threading, dynamic_code, system_info, hardware. Three evidence streams feed each — direct syscalls, libc or libSystem wrapper calls, and framework or Win32 API calls off the call graph — so an aspect's evidence count reflects the whole surface rather than raw syscalls alone.
The operation-to-aspect mapping is a curated table per platform. On Apple:
| Aspect | Operations mapped in |
|---|---|
file_io | open, read, write, stat, unlink |
network | socket, connect, bind, sendto, plus NSURLSession and CFNetwork |
process_control | posix_spawn, execve, fork, kill |
ipc | mach_msg, XPC bootstrap, Unix-domain sockets |
memory | mmap, mprotect, madvise, and the allocators |
Linux and Windows map into the same twelve.
The summary carries a taxonomy version — a cache-invalidation key for consumers when a new axis ships — a dominant aspect, and a hardening ratio: the share of behavioral evidence that came from direct svc sites rather than wrappers. A high ratio marks a binary talking to the kernel directly.
Network endpoints
Every URL, IPv4 and IPv6 address, and hostname is extracted from strings with false-positive rejection, and each networking call is attributed to the application method that makes it — so the answer is not just which networking APIs are used but which methods use them.
- Endpoints — extracted from strings; those containing format specifiers (
%@,%s,%d) are flagged as runtime-constructed. - API matching — against 23 known Objective-C and Swift networking classes and 10 selector patterns, with the call graph cross-referencing each API call to its calling method.
- Declaration correlation — network entitlements and linked frameworks are correlated against the calls.
- XPC — service identifiers are extracted from strings and protocol traits reconstructed from the discovered interfaces.
Strings
Printable runs of four or more characters come out across the whole file, plus structured CFString constants with chained-fixup pointer resolution, categorized into telemetry endpoints, URLs, file paths, bundle identifiers, and IOKit constants — so what does this collect and what does this access are answerable without walking the call graph. On airportd the telemetry surface comes out as 120 strings against 23 submission methods.
Entropy and embedded formats
Per-section Shannon entropy is reported across all three formats. A heuristic fires when imports are sparse and code entropy is high — the canonical compressed-or-encrypted shape — and overlay detection reports bytes past the end of the image. When the packer is one that can be statically unpacked, the story does not end at high entropy: the payload is recovered and judged on its own (Unpacking).
The overlay and high-entropy sections are also scanned through the full format-signature table with two confidence tiers, magic-only and header-validated, covering compression, containers, filesystems, firmware carriers, and crypto markers (AES tables, hash-init constants, PEM armour, LUKS and DPAPI headers). Each match carries section, offset, and tier.
Secret material
Twenty-three kinds of credential and key material are scanned for: PEM private keys, public keys and certificates, OpenSSH and PuTTY private keys, X.509 DER, PKCS#8, PKCS#12, PKCS#7 SignedData, raw-DER RSA and EC private keys, PGP private keys, OpenPGP RSA session keys, JWTs, AWS access keys, GitHub personal access tokens, Slack tokens, GCP service-account markers, crypt(3) password hashes, LDAP SHA hashes, expanded AES and SM4 key schedules in data sections, and leaked vendor firmware signing keys.
Scan sites are format-dependent, and the ELF and PE breadth is what makes stripped firmware with keys in read-only data come back non-empty instead of reporting nothing:
| Format | Scanned |
|---|---|
| Mach-O | four sites: the overlay, high-entropy sections, the C-string section, and the Objective-C method-name section |
| ELF | the overlay plus every non-empty section except loader metadata — .plt, .dynsym, .dynstr, the hash, version and relocation tables |
| PE | the overlay plus every non-empty section except .idata, .edata, .reloc, .pdata, .tls and .debug* |
- Parsing — X.509 DER is walked with an iterative ASN.1 reader capped at depth 8 and 1 KB, never recursive.
- Confidence — strict-alphabet validators on API tokens emit header-validated or nothing at all; the false-positive rate of a bare
AKIAmatch in random string data is too high to admit the lower tier. - Redaction — every finding is redacted by default: middle-elision for tokens, header-only for PEM, a sixteen-byte hex prefix for DER. The offset and the location are the load-bearing facts, and the matched text travels through CI logs, issue trackers, and downstream indexes only redacted.
- Volume — a hard cap of 256 per binary prevents a pathological input from running away.
Findings carry ATT&CK tags (T1552.001, T1552.004, T1552.005).
IOKit attack surface
For Apple kernel collections, IOUserClient subclasses are extracted from the C++ RTTI hierarchy — those are the kernel objects a user-space process can open and call. Entitlement string references inside driver code identify what gates each one. The result is a map both ways: which drivers an entitlement gates, and which entitlements a driver requires. That is the first thing a kernel security review walks.
The authorization audit matrix
A privileged operation reachable from an untrusted entry point with no authorization check on every path is the canonical authorization-gap bug, and the matrix looks for exactly that shape. It walks every entry point — XPC especially — forward through the call graph including jump-table edges, and records two things per reachable function:
- Which authorization verifiers it calls —
__verifyEntitlement:,__verifyRequiredEntitlement:,didUserConfirmRequestType:auditToken:, Authorization Services entry points, and on ELF and PE targets the password, TLS, Kerberos, GSSAPI, PAM, and SSH primitives too. - Which privileged operations it can reach — keychain writes, NVRAM writes,
SFAuthorizationsetters, Wi-Fi channel and association changes.
Every detector that asks "reachable from X" asks the same deterministic, bitset-backed index rather than walking the graph itself, so the answer cannot differ between the audit matrix and the finding that quotes it; the policy about which nodes are roots and where a walk stops stays with each consumer.
A row is flagged when a privileged operation is reachable and no verifier dominates every path to it. The dominance check is a stated under-approximation of dynamic coverage in both directions: a false negative when a verifier is conditionally skipped inside an intermediate function, a false positive when a privileged call is guarded by a runtime predicate static analysis cannot see. Verifier coverage is never claimed as a safety guarantee. Both tables are curated, auditable in version control, and extensible per macOS release. There is no severity scoring.
Security read-outs
Findings, indicators, malware verdict, and CVE matches are four parallel views of the same analysis, each with its own taxonomy and confidence model:
- Findings — CWE classes and stable rules. What is wrong with the code?
- Indicators — capa-aligned behavior categories mapped to MITRE ATT&CK, alongside the TOML-authored Capabilities catalog. What does this binary do?
- Malware — a four-tier classifier composing many signal sources into one scored ledger. Is this binary malicious?
- CVEs & SBOM — two matching planes for firmware and supply-chain audits. Does it contain a known-vulnerable version of a known component?
Security is the orientation map across the four.
Outputs
The behavioral fingerprint
A compact, comparable representation of behavior — so two binaries compare at a glance, a fleet searches by similarity, and drift across versions is detectable without re-reading the code.
Twenty-one structural hashes cover different facets, each content-addressed so identical content produces identical output regardless of analysis host or ordering. They split into two groups that must not be confused:
| Group | Members | Property |
|---|---|---|
| In-corpus clustering keys (11) | the code section only (so a re-signed binary with identical code matches its previous build), the sorted call-graph symbol set, the nlist function names, frameworks, Objective-C class names, the C++ hierarchy, entitlements, resolved syscalls, the twelve-axis aspect profile, the indicator-rule hit set, the on/off hardening posture | private SHA-256 digests |
| Cross-corpus identifiers (10) | imphash, telfhash, symhash, import_md5, dylib_hash, export_hash, import_hash, entitlement_hash, TLSH, the Rich-header hash | interoperability strings. Eight are byte-exact with the yara-x function of the same name; TLSH is a raw Trend locality-sensitive hash and the Rich-header hash follows VirusTotal's definition, neither of which yara-x exposes |
The two groups overlap in subject and not in value — entitlements are hashed twice, once as a private SHA-256 over the keys and once as the MD5 an external corpus can join on. The call-graph symbol hash is deliberately not imphash, and TLSH is deliberately not folded into similarity scoring — TLSH is what you paste into VirusTotal or a public threat report, not what ranks a corpus.
Beside the hashes, two ingestible bodies of content:
- A numeric vector of exactly 100 dimensions — class count, method count, entitlement and framework counts, syscall counts per aspect, indicator counts per category, ATT&CK technique counts per tactic, embedded-format counts, hardening signal counts, telemetry density.
- 40 named sets of categorical content — private frameworks, entitlements, XPC services, protocol conformances, Objective-C, Swift and C++ type names, syscall wrappers, dynamic-load targets, ATT&CK techniques, indicator rules matched, embedded formats, secret kinds, hardening features on and off, statically-linked libraries.
Similarity is a weighted average of per-set Jaccard scores, and the numeric vector is not part of it. Cosine over the vector is a retrieval step only: it pulls 500 candidates out of the index, which are then reranked and scored on Jaccard alone. Cosine and Jaccard orderings genuinely diverge — the pool is 500 rather than 100 precisely so a true Jaccard match is not lost to a cosine ordering that ranked it low.
Weights are calibrated against measured signal, not intuition, on the principle that behavioral coincidence is stronger evidence than structural coincidence:
| Set | Weight |
|---|---|
| Indicator-rule hits, entitlements | 6.0 |
| ATT&CK coverage, consistency flags | 5.0 |
| Objective-C and Swift type names | 0.5 |
Two weight tables exist and are not identical: corpus search adds six behavioral-signature sets at the top weight and omits the raw syscall sets the aspect partition supersedes, so a compare score and a search score for the same pair need not agree. On the compare table, nc returns iperf3 at 60.6%, traceroute at 57.8%, and rarpd at 56.3% — three independent networking tools clustering on behavior alone, none of which shares code with the others.
Every numeric and categorical field is directly ingestible by a downstream classifier or similarity index. Rendered for a human, the same data is a one-page bar chart.
Reconstruction to Rust
The control-flow graph is structured into a region tree — if/else, while, switch with one arm per resolved jump-table handler, break and continue in place of goto — by a "no more gotos" walk that turns the graph into a tree an emitter can render linearly.
Project assembly is one command producing a buildable Cargo project: one file per Objective-C, Swift, or C++ class, grouped C functions, framework stubs typed against the Apple SDK so the project compiles without an SDK dependency, a manifest whose dependencies are inferred from recognized statically-linked libraries, and a library root. A single class or method can be reconstructed instead of the whole binary.
The output preserves real source structure rather than flattening it to functions and gotos:
| Binary construct | Reconstructed as |
|---|---|
| Objective-C class-reference load | class!(NSData) |
| BOOL property pair | code that compiles |
| Multi-way dispatcher, including the XPC request dispatchers in system daemons | match with one arm per resolved handler |
| XPC protocol interface | a trait definition — a typed API contract pulled straight out of the binary |
Types are layered, drawn from four sources:
| Type source | Supplies |
|---|---|
| Objective-C type encoding in the class list | signatures at the function boundary |
| Swift type section | Swift signatures |
| DWARF | names, when present |
| Argument-taint analysis | parameter names, when nothing else does |
Parameter direction is not among them. A pointer parameter emits as a raw pointer — *const T or *mut T — from the type lattice, and nothing promotes it to a shared or mutable Rust reference on access evidence. The analysis is written — a walk of every load and store against the parameter slot, propagated bottom-up over the call-graph condensation, with an Objective-C carve-out so self is never promoted to a mutable reference by access inference alone — but neither level has a production caller, and the emitter has no slot for its output. The 520 parameter signatures it would change across four fixtures are the size of the missing improvement, not a description of what ships:
| Fixture | Signatures it would change |
|---|---|
| ripgrep | 396 |
| airportd | 103 |
| jq | 20 |
| curl | 1 |
Every emitted method carries a confidence score from 0.0 to 1.0 and a count of residual unrecovered operations. A manifest records per-method confidence, that count, resolved-versus-unknown class-reference counts, and a free-text reason when the project did not compile end to end — most often "verify step not run", because the pipeline does not measure compilation itself. Irreducible control flow is not in the manifest — it is a flag on the emitter's output, visible in the per-method stage dump, and costs a fixed 0.10 of the confidence score. On airportd — 37 classes, 1,586 methods — the full-binary path reaches a 99.7% method compile rate. That figure is for the whole binary; a single-class reconstruction is a different measurement and is not it.
A verify step runs cargo check against the output, computes class and method coverage relative to the original binary, and counts residual unknown markers, so reconstruction quality is a number that moves over time rather than an impression.
Method and class inspection
Any single class or method can be interrogated without re-running the analysis.
- Class view — ivars with decoded types, methods with their call targets, and the framework APIs the class touches.
- Method view — the typed signature, what it calls, what calls it, and annotated disassembly whose inline call names match the call list.
Fuzzy matching handles partial names; dot notation scopes a method search to one class; a single query searches Objective-C, Swift, and C++ types at once.
Any internal stage of a matched method can be dumped: annotated assembly, pre-SSA IL, the edge list, the full SSA form, and the structured region tree with real condition expressions in its if, while, and switch headers. Stages combine and serialize as JSON. This is what makes a low-confidence reconstruction diagnosable — when the manifest shows a method below threshold, the stage dumps show which stage lost the information.
Diff and compare
Two cross-binary outputs answer version drift and family attribution respectively.
Diff answers what a platform update added in telemetry, what changed in encryption behavior, and what new XPC endpoints appeared. It pairs functions across two versions in three phases:
| Phase | Population | Paired on |
|---|---|---|
| 1 | named functions | symbol name |
| 2 | unnamed functions | instruction hash |
| 3 | the remainder | structural Jaccard over their call-target sets |
Each pair classifies as size-only, body-changed, calls-changed, or fully-changed; new, removed, and changed methods come out as lists; the hardening section reports mitigation regressions and improvements.
Compare runs the same weighted-Jaccard similarity between two binaries directly, for attributing families and tracking drift.
Corpus-level aggregate reports walk a directory of binaries and emit population counts and pairwise similarity statistics. They double as engineering gates: a regression in aspect signal, literal recovery, or override coverage fails the build before merge.
Limits
The engine produces facts about a binary. It does not present them, rank them, alert on them, or decide what they mean. Anything dependent on judgment lives in a surface; anything dependent on measurement lives here.
- Call-graph edges exist only on arm64 and x86/x86-64. Every other architecture gets symbols, functions, and IL, with an explicitly empty edge set and a surfaced error. Reachability-based analysis — the audit matrix included — has nothing to walk on an embedded MIPS or PowerPC image.
- A fact that was never produced is invisible. The dominant silent-failure class is not a wrong answer but a default one: ELF imports are recorded with no resolved address, because ELF imports by name, so any detector anchoring a finding to an import address is blind on ELF and reports nothing at all — no error, no log. Similarly, a synthetic call edge (a jump-table rewrite, a peephole-resolved syscall) carries a reserved zero site address, and a detector that skips zero drops every one of them.
- Points-to reasoning crosses a call only for a proven must-free. A callee that frees a parameter on every path transfers that fact to its caller's argument; nothing else does. An indirect target, an unlifted callee, or a recursion-cap hit transfers nothing, and a leak on one error path among several never fires.
- Parameter direction is not emitted. Reconstruction types pointer parameters as raw pointers; the access-pattern analysis that would promote them to references has no caller.
- Bounded loops read as unbounded. The interval domain widens loop-carried values to top rather than risk claiming a bound it cannot prove.
- The emulator diverges at the first call, syscall, float, or branch it cannot decide. Single path, integer only, no symbolic execution. Values behind any of those are not recovered, and the failure is reported as divergence rather than as a guess — which is why the UPX sweep stopped 31 operations in. Seeding binds the stack-pointer and frame-pointer registers but never the stack contents.
- Three callee-aware finding rules cannot fire. The rules and their supporting oracle exist; the call sites that would invoke them do not.
- Dominance-based gate checking under-approximates in both directions — a conditionally skipped verifier is missed, a runtime-predicate guard is flagged anyway, and verifier coverage is never a safety guarantee.
Execution model
Analysis inside one binary parallelizes over Rayon — per-function passes, and ELF and PE section walks proven byte-equivalent to the serial path — and a pool of pre-warmed engine subprocesses keeps corpora and signature indexes loaded, so a request pays no cold start. Platform is the surface that indexes these facts, searches them, ranks them, and adds the policy layer that turns them into something to act on.