Findings (CWE)
A scan of the statically linked, stripped curl in the test corpus (ELF x86-64) produces 1,909 findings. 323 of them sit in a function the forward walk from the binary's entry roots actually reaches. 1,579 sit in functions no entry-rooted path reaches — static initialisers, dead-but-linked library code, handlers registered but never called. Seven are binary-wide posture facts with no calling function at all. The list an auditor reads first is 323 long, not 1,909, and the distance between those two numbers is most of what a static CWE scanner has to earn.
Each finding is a record keyed on a MITRE CWE id, carrying a stable rule identifier, a severity tier, a confidence grade, a one-sentence summary, and — when the detector can resolve a caller — the address of the call instruction and the function containing it. The output is a list of facts. Whether any of them is a vulnerability in production depends on the deployment, the reachability from untrusted input, and the threat model, none of which the engine knows.
| Question | Short answer | Section |
|---|---|---|
| Which findings to read first? | the ones whose caller sits in the entry-root closure — 323 of that curl build's 1,909 | Reachability triage |
| What can a detector prove? | one of eight signal classes, from symbol presence up to total constant arithmetic | Detector signal classes |
| How much is covered? | 72 CWE ids, 71 with a live detector, 18 with a contrastive fixture that fires | The CWE catalog; Quality gates |
| Where does it run? | four format-and-architecture combinations carry per-call-site addresses; the taint lattice runs far wider, and most byte-aware argument resolution runs on arm64 only | Format and architecture coverage |
| What does one detector do, and where is it wrong? | mechanism, rules, and measured limits, one entry per CWE | Per-CWE reference |
| Is a finding a vulnerability? | no — it is a fact about the file, not a judgment about a deployment | Limits of a finding |
Worked example: a heap out-of-bounds write
A three-line C bug with no symbol naming it still resolves to a proof. This is a positive fixture from the labelled binary corpus — real C, compiled, checked in, and read by the detector that has to catch it:
char *b = malloc(8); /* 8-byte buffer */
int i = argc & 0x1f; /* i in [0, 31] */
b[i] = (char)argc; /* OOB write when i >= 8 */
Built with zig cc -target x86_64-linux-gnu -O0 -fno-sanitize=undefined, that becomes:
10014b6: movl $0x8, %edi
10014bb: callq 0x1001590 <malloc@plt> ← allocation site, size 8
10014c0: movq %rax, -0x18(%rbp) ← pointer spilled to a stack slot
10014c4: movl -0x8(%rbp), %eax
10014c7: andl $0x1f, %eax ← index constrained to [0, 31]
10014ca: movl %eax, -0x1c(%rbp) ← index spilled
10014cd: movl -0x8(%rbp), %eax
10014d0: movb %al, %dl ← the byte to store
10014d2: movq -0x18(%rbp), %rax ← pointer reloaded
10014d6: movslq -0x1c(%rbp), %rcx ← index reloaded
10014da: movb %dl, (%rax,%rcx) ← the out-of-bounds store
No symbol names the bug. There is no memcpy, no strcpy, no fortified _chk twin, nothing an import-table scan or a string scan can see. The finding is assembled from three recovered facts: the store's address resolves to the abstract object minted at 0x10014bb; that object's size is the literal 8; the index interval reaching the store is [0, 31]. Since 31 exceeds 7, the write can land 23 bytes past the allocation. Rule bounds.oob-write.object, CWE-787.
The spill matters more than the arithmetic. Between the malloc and the store, the pointer round-trips through -0x18(%rbp) and the index through -0x1c(%rbp). An analysis keyed on SSA value identity loses both at the reload, because a reloaded value is a new SSA name for the same thing. Keying the object on its allocation-site address instead of on an SSA name is what survives the -O0 spill — and the interval [0, 31] only survives it because the value-range domain carries ranges through stack slots. Both are properties of the substrate, not of the rule.
Reachability triage
Every finding whose caller is known carries reachable_from_main, a three-state field derived from a forward walk of the recovered call graph. It matters more at scan volume than the catalog does: the reference binaries below return between 25 and 2,629 findings each, and no auditor reads the tail of any of them.
| State | What it asserts | Cases |
|---|---|---|
true | a static edge chain runs from an entry root to this caller | — |
false | the closure is trustworthy and does not contain the caller | initialiser-only paths, unregistered callbacks, dead-but-linked code |
| absent | no answer, left empty rather than guessed | binary-wide posture rules, which have no calling function at all; every finding in a binary whose closure is itself untrustworthy |
The walk needs roots, and seven tiers contribute them. None is a fallback for another — the walk runs from their union, not from the first tier that resolves.
| Tier | Root resolved from | What it anchors |
|---|---|---|
| 0 | Mach-O LC_MAIN or LC_UNIXTHREAD, PE AddressOfEntryPoint, ELF e_entry | the format-supplied entry — present on every executable image and never a guess |
| 1 | main or _main in the symbol table | any binary that still names its entry point |
| 2 | the function that calls xpc_main, dispatch_main, NSApplicationMain, CFRunLoopRun, or __CFRunLoopRun | stripped Apple daemons that export no main |
| 3 | every direct callee of a __libc_start_main call site | PIE ELF startup shims, where main arrives as a register argument |
| 4 | static initialisers and TLS callbacks — ELF .init_array and DT_INIT, Mach-O __mod_init_func, PE TLS callbacks | loader-run code outside any main-rooted chain. Constructors only; destructors run at exit() and are excluded |
| 5 | every defined, non-re-exported entry in the export table | a .so, .dll, or .dylib export has external callers and often no internal one |
| 6 | ELF .gcc_except_table catch and cleanup handlers | C++ landing pads the unwinder enters during exception propagation, outside any static call chain |
A small closure is declared untrustworthy, not inflated with extra roots. There is no size-based fallback tier. When the union of those roots reaches under 10% of the call graph's caller nodes, every finding the closure does not contain gets an empty reachable_from_main rather than a false. Findings it does contain still read true: an edge chain exists regardless of how much of the program the closure covers.
That gate bites on exactly the daemon class the field is for. Of airportd's 102 findings, 43 have no calling function at all and 40 more have a known caller but sit below the closure floor — so on a stripped ObjC daemon the third state is the majority, not a residue.
Measured on the checked-in reference binaries:
| Binary | Findings | Reachable from an entry root | Provably not | No caller, or no answer |
|---|---|---|---|---|
ripgrep/macos-aarch64 | 25 | 1 | 0 | 24 |
airportd | 102 | 19 | 0 | 83 |
curl/macos-aarch64 | 153 | 121 | 26 | 6 |
jq/macos-aarch64 | 342 | 129 | 189 | 24 |
ripgrep/linux-aarch64 | 158 | 137 | 20 | 1 |
curl/linux-x86_64 | 1,909 | 323 | 1,579 | 7 |
curl/linux-aarch64 | 2,629 | 322 | 2,300 | 7 |
Limits. Resolved indirect dispatch is followed; each resolved edge is added alongside the original dispatcher edge. Unresolved dispatch is where the walk stops.
| Traversed | Not traversed |
|---|---|
| an Objective-C send the devirtualiser resolves to a concrete method implementation | a class chosen from a runtime-computed name |
| a C++ virtual call resolved to a vtable slot | a function pointer read from a table the lifter could not recover |
| an Objective-C, GCD, or XPC block invocation | a jump table whose entries did not decode |
a switch jump table the lifter recovered | every other unresolved indirect branch |
This is a triage hint; a suspected dynamic-dispatch path has to be checked by hand.
A second closure, reachable_before_auth, runs the same forward walk from network-listener roots — any function calling recv, recvfrom, recvmsg, accept, accept4, or nw_listener_create — and stops at the first function whose outgoing calls include a recognised authentication verifier. true is the pre-auth remote-code-execution shape. It is absent on every finding of all four Mach-O arm64 reference binaries, airportd included, because no listener root resolves in any of them. It earns its keep on network daemons and firmware.
Two demotion passes run before the composer, never after. The origin pass down-ranks a finding whose calling function resolves through the merged signature tables to library, runtime, or glue code rather than to the binary's own. The dead-library pass floors heuristic memory-safety findings inside an identified library function no entry root reaches — statically linked compiler-rt copy loops, libssp _chk helpers — the dominant per-binary false-positive source on statically linked ELF. Neither pass deletes a finding: both only lower severity, so a real bug in a bundled vulnerable library stays auditable instead of becoming a silent miss.
The companion enrichment forward_calls lists the resolved names of every call site reached from the finding's caller — what the suspect function does after the bad primitive fires.
The finding schema
A finding carries seven fields, and none of them is a remediation.
| Field | Meaning |
|---|---|
cwe_id | Anchor in the MITRE CWE catalog. |
rule_id | Stable detector-and-shape identifier (dangerous-call.strcpy, xpc.listener.no-code-signing-requirement, oob-write.memcpy-chk-proven). Survives across releases, so consumers can group, filter, baseline, and suppress on it. |
detector | The mechanism: StaticRule (curated symbol or string match), Heuristic (structural pattern over the call graph), SymbolicExec (the SSA-taint lattice), plus Ml and Manual — both reserved in the schema, neither constructed anywhere in the engine. |
severity | Per-instance hint at the runtime context around the source-level bug. |
confidence | How trustworthy the detection signal is — not how exploitable the finding is. |
summary | One sentence of user-facing prose: what fired, why it matters, and the audit pivot. Every rule ships one. |
location | Where the finding anchors, itself a record of nine fields. Absent for binary-wide posture findings. |
The location record is where triage happens:
| Location field | Meaning | Population |
|---|---|---|
va | address of the call instruction or the anchoring symbol | wherever the bridge populated a per-call-site address |
symbol, dylib | the dangerous primitive being called and the library that provides it | named-import findings |
caller_va, caller_name | the function in this binary that invokes the primitive; the name falls back to sub_<va> when the symbol table cannot supply one | wherever the call graph resolved a caller |
forward_calls | resolved names of the call sites reached from the caller | every finding with a caller |
reachable_from_main | the three-state entry-closure verdict | every finding with a caller and a usable call graph |
reachable_before_auth | the pre-auth-closure verdict | only when network-listener roots resolve — zero of the four reference binaries |
flow_path | the taint witness: an ordered Source → … → Sink chain of addresses, each tagged source, waypoint, or sink and carrying the resolved primitive name where one is known | only the taint detectors that retain a source anchor. 26 of curl's 153 findings, 25 of jq's 342, 1 of airportd's 102, 0 of ripgrep's 25 |
flow_path answers why a sink is reachable, where an address answers only where. It is absent on every static-rule and heuristic finding, and on the interprocedural summary detectors too: those prove that parameter i reaches a sink class without recording an ordered per-operation witness. It exports as a SARIF codeFlow.
There is no remediation field. A finding carries upstream facts only.
Severity is per-instance, not per-CWE. A __strcpy_chk call is the same CWE-120 as strcpy but lands at Info, because the fortified runtime aborts before the overflow corrupts anything. A system() call whose argument resolves to a constant string with no shell metacharacters is the same CWE-78 as system(user_input) but lands at Info too.
| Tier | What it asserts | Default for |
|---|---|---|
| Critical | unconditionally exploitable source pattern | raw strcpy, a real PEM private-key body, a proven stack-frame overrun |
| High | dangerous; only a missing condition makes it safe | raw libc buffer primitives |
| Medium | mainstream tools use this legitimately; the auditor decides | — |
| Low | needs corroboration | symbol-only detection of niche functions |
| Info | the compiler or runtime mitigates, or the match is a parser template rather than a bug | fortified _chk twins, constant-argument system() |
Confidence tracks the mechanism.
| Signal | Grade | What the grade claims |
|---|---|---|
| Direct symbol import | High | that the binary references that symbol — not that the call is reachable, that the argument is attacker-controlled, or that no unseen check bounds the length |
| Constant-arithmetic proof at a specific call site | High | a mathematical claim: the constants are in the binary and the arithmetic is total |
| Call-graph co-occurrence pattern | Low to Medium | a structural signal, not provable |
Compound findings. A composer runs after every other detector and cross-correlates by calling-function address. Two findings sharing a caller emit an additional compound.* finding one severity tier above the most severe constituent, capped at Critical, with confidence set to the maximum of the constituents. A function that calls recv() and then __memcpy_chk() with a length the chk variant proves overruns is the Heartbleed shape, and it surfaces as one named claim rather than two unrelated rows.
Two suppressions drop that escalation back to the plain maximum, each calibrated against a named binary:
| Suppression | Fires when | Why, and where it was calibrated |
|---|---|---|
| Large caller | the calling function has 45 or more outgoing calls | the same-function-pair detectors (double-free.same-function, use-after-free.same-block, null-deref.unchecked-alloc-use) fire by construction in a method that big. configd's -[AgentController processScopedProxyChanges:] makes 457 objc_release calls and was elevating a three-detector stack to Critical with no constituent strong enough to support the claim |
| All-Low stack | every constituent is Low confidence | convergence of dataflow-less heuristics is structural co-occurrence, not corroboration. FreeBSD ssh-keysign, a benign config parser, stacked a double-free heuristic, a non-literal format string, and an execv import into a High compound with no dataflow proof anywhere |
A compound's strength scales with the kind of co-occurrence, not the count: a two-detector stack combining an SSA-taint flow with a chk-family proof at the same site is two independent analyses converging, while a many-detector stack on an interactive shell's main loop (popen plus system plus getenv plus unsafe-open plus a format-string rule plus a double-free rule) is breadth, not depth — each detector fires correctly on its own pattern and the function legitimately does many sensitive things.
There is no cross-detector confidence promotion. A taint finding and a static-rule finding at the same caller compose into a compound but never re-grade each other. The one place a detector rewrites another's grade runs the opposite direction: dangerous-call.realpath is demoted to Info when every realpath site in that caller provably passes NULL as the destination — the safe allocating form.
Detector signal classes
Detectors fall into eight signal classes, and the class determines what a finding can prove.
| Class | Reads | Strongest available claim |
|---|---|---|
| Imports | the import symbol table | this binary references this symbol |
| Strings | __cstring, __objc_methname, CFString surfaces | this byte sequence is present in the file |
| Call-graph | the per-function outgoing-call set | A and B co-occur, or A precedes B with nothing in between |
| Byte-aware | raw instruction bytes alongside the call graph | this constant provably reached this call site |
| IL+SSA dataflow | the lifted IL, reduced to SSA | a typed source flows to an argument-typed sink with no dominating sanitiser |
| IL object models | points-to plus lifecycle over the SSA IL | this heap object's size, liveness, or null-ness at this use site |
| Binary-wide | the binary's hardening surface | a build-time protection is absent |
| Composer | the other detectors' findings | two independent detectors fired on one calling function |
Imports — direct symbol-table match against the binary's import set. Proof-grade for "this binary references this symbol" and nothing more.
Strings — regex or token scan over __cstring, __objc_methname, and CFString surfaces. Proof-grade for presence of an embedded JWT, a %n template, a hardcoded key; not for use, since the string may be inert metadata.
Call-graph — walks the outgoing-call set per function. Set-membership asks whether a function calls both A and B (the predictable-seed and chroot-without-chdir detectors). Site-ordered asks whether it calls A before B with no intervening sanitiser (TOCTOU, double-free, use-after-free, null-deref). Site-ordered rules need a non-zero per-call-site address on every edge; where the IL bridge leaves that at the sentinel zero the detector skips the whole caller silently rather than reason from an unordered set.
Byte-aware — walks raw instruction bytes alongside the call graph. A strict register tracker resolves the constant reaching a target register at a specific call site by walking back through MOVZ and register-copy chains, aborting on any intermediate write, so a successful resolve is a guarantee that the constant reached the call. A literal resolver computes the address loaded via ADRP + ADD or ADR. Both decoders read arm64 instruction bytes; six detector modules route through a portable seam instead, and the rest are arm64 in practice — see Format and architecture coverage. Two sub-classes:
- Proof-grade — when the destination size, the copy length and, for stack frames, the prologue's frame size all resolve as compile-time constants, the arithmetic is total and the finding is unconditional given the call site is reached. CWE-121, CWE-787, CWE-125, CWE-190, CWE-269, CWE-560, CWE-825, CWE-1240, plus the W^X
mmap/mprotect/vm_protectandptrace(PT_DENY_ATTACH)rules. - Shape-only — pattern detection with no arithmetic proof. CWE-369 walks division instructions looking for a zero-check on the divisor in the preceding window; CWE-467 fires when a size argument resolves to a literal
4or8. These report Low to Medium: the pattern is real, but the auditor still has to confirm that the divisor is not proven non-zero by an unrecognised guard, and that the literal4is notstrlen("true").
IL+SSA dataflow — forward taint over the lifted IL, reduced to SSA and propagated to a fixed point per function. Three source classes, each carrying its kind so the wrong taint cannot reach the wrong sink (an allocator return arriving at a memcpy size argument is not a CWE-1284 candidate): external input (read, recv, getenv, argv), allocator returns (malloc, calloc, realloc), and must-check-return symbols across libc, Security.framework, and IOKit. Sinks key off both the callee and the argument position:
| Sink kind | At argument of | Emits | Rule prefix |
|---|---|---|---|
| Quantity | size arg of memcpy / memmove / strncpy / read / write | CWE-1284 | taint.flow.quantity-arg |
| IndexedWrite | destination buffer of a sized primitive | CWE-787 | taint.flow.dest-arg |
| Read | source buffer of a memcpy-class primitive | CWE-119 | taint.flow.src-arg |
| AllocSize | size arg of malloc / calloc / realloc | CWE-789 | taint.flow.alloc-size |
| Deref | load or store through an allocator-return pointer | CWE-476 | taint.flow.deref |
| ShellArg | command string of system / popen | CWE-78 | taint.flow.shell |
| ProcArg | path or argv of the execve family | CWE-78 | taint.flow.exec |
| DlPath | path arg of dlopen | CWE-94 | taint.flow.dlpath |
| FormatString | format arg of the printf family | CWE-134 | taint.flow.fmt-arg |
| Path | path arg of open / fopen / unlink / rename / chmod and the POSIX-2008 *at family | CWE-22 | taint.flow.path-arg |
| RegistryName | name or sub-key of RegOpenKeyEx / RegSetValueEx / RegCreateKeyEx | CWE-99 | taint.flow.registry-name |
| RegistryData | data buffer of RegSetValueEx / RegSetKeyValue | CWE-15 | taint.flow.registry-data |
| Ssrf | URL or host arg of a Win32 HTTP / Internet primitive | CWE-918 | taint.flow.ssrf |
| SqlInjection | query-string arg of sqlite3_exec / mysql_query / PQexec | CWE-89 | taint.flow.sql-injection |
| ProcessInjection | payload, start-routine, or APC arg of WriteProcessMemory / CreateRemoteThread / NtMapViewOfSection | CWE-94 | taint.flow.process-injection |
| UseAfterFree | freed pointer reaching a use or a second free | CWE-416, CWE-415 on double-free | taint.flow.uaf |
| ClobberedReturn / IsolatedReturn | must-check return overwritten unread, or never compared | CWE-252 | taint.flow.unchecked-return |
Fortified _chk sinks are demoted one severity tier, because the runtime bounds check aborts before exploitable corruption. Three sanitiser shapes are recognised by dominator analysis, and a candidate is suppressed when every member of its source set is dominated by a matching sanitiser's safe edge: ConstantBound (the source is MIN'd with a literal before the sink), MeasuredBound (the source is compared against the same length argument the sink receives), RealpathNullCheck (a realpath return is null-checked before downstream use). Confidence is High when no shape touched any source, Medium when shapes matched but none dominated, Low when the source set saturates.
Three extensions widen what that lattice can see beyond a single function's SSA graph.
Alias-aware seeding closes the recall gap the worked example above illustrates. The seed-point SSA value dies within a few operations when a reload produces a different name; the alias pass identifies every SSA value holding the same logical pointer at the seed — through stack-slot spills, register-copy chains, and slot reads — and seeds them all, bounded to the current function and its dominator-reachable predecessors, with a 16-member cap so a saturating alias set cannot flood.
Interprocedural sink-wrapper transparency closes the same gap from the sink side. The intra-procedural lattice only fires when the call target is itself a named sink, so a local wrapper — void run_cmd(char *c){ system(c); } — looks like an ordinary direct call and the flow is invisible. A single bottom-up fixpoint over the call graph's strongly-connected-component condensation computes, per function, "parameter i provably reaches a sink of class k", directly or through callees; any caller passing tainted data into such a parameter fires. The summary is a positive reachability witness only — indirect and unsummarised callees contribute no edge — so it under-approximates: it can miss a flow, but it cannot manufacture a false positive the intra-procedural baseline would not also produce. Rules are taint.flow.interproc.<sink>, keyed to the same sink classes as the lattice.
Interprocedural callee-access propagation is a distinct extension: when a function passes a parameter into a callee that dereferences or frees it, the callee's recovered access pattern reflects back into the caller's taint state, firing null-deref.callee-dereferences (CWE-476), use-after-free.callee-uses (CWE-415 / CWE-416), and taint.flow.callee-derefs.<source> (CWE-476, carrying the source kind through). It walks bottom-up over the same condensation with a fixpoint only on recursion, and it is off by default — enabling it changes the recovered argument-access signatures every reconstruct-mode detector reads, so the default path stays byte-identical to the pre-propagation behaviour until the promotion is opted into.
IL object models — points-to plus lifecycle analysis over the SSA-form IL. Where the taint lattice tracks values flowing into named sinks, the object models track heap objects keyed on their allocation-site address through mint → alive → freed states, which is why a pointer that round-trips through a stack slot is still the same object at the use site. Four detectors ride the shared state: null-deref.unchecked-object (an object minted by a may-return-NULL allocator, dereferenced, never null-checked anywhere in the function), resource.leak.unreleased-object (an owning allocation never freed on any path and never escaping by call argument, return, or global store), bounds.oob-write.object / bounds.oob-read.object (an access whose offset provably exceeds the object's size), and the object-model half of the use-after-free family. The posture is bias-to-silence: an unknown points-to target, a may-freed state, or any escape asserts nothing. Low recall traded for near-zero false positives.
Two of these carry an extra architecture gate. null-deref.unchecked-object and resource.leak.unreleased-object run only on x86_64, arm64, and arm64e. An intraprocedural pass cannot tell a may-return-NULL allocator from an abort-on-null wrapper — jq's allocator wrapper does bl malloc; cbz x0, abort, so its result is never NULL, but the check lives inside a function the pass does not enter — and there is no interprocedural allocator summary to settle it. On the three measured architectures the benign rate is zero; elsewhere it is unmeasured, and the gate is the conservative answer to that. The bounds detectors carry no such gate: they are proof-carrying, so on an architecture where the lift cannot resolve an object they stay silent rather than flood, and that is what makes the precise overflow proof available on the MIPS, PowerPC, and ARM32 firmware corpus where out-of-bounds writes are the dominant bug class.
Binary-wide — one finding per missing protection: PIE, stack canaries, FORTIFY, RELRO, executable stack, RWX segments, hardened runtime, library validation, CS_RESTRICT, dangerous entitlements, shipped debug info, a leaked dSYM path, PE ASLR / NX / CFG / GS / CET / force-integrity, ELF branch-target and pointer-authentication hardening. Keyed off the binary's hardening surface, not any call site. A posture audit, not a per-bug claim.
Composer — described above; runs last.
The CWE catalog
Seventy-two CWE ids appear across the binary detectors, and the numbers around that figure do not reconcile on their own:
| Count | What it counts |
|---|---|
| 72 | CWE ids appearing across the binary detectors |
| 71 | of those, ids with a detector that can emit them |
| 61 | rows in the hand-curated registry that supplies a finding's CWE name |
| 60 | of those rows that correspond to one of the 72. The 61st is CWE-1325, registered for the process-injection chain and named inside the CWE-94 summaries, but never a finding's own cwe_id |
| 12 | emitted ids with no registry row — 125, 215, 319, 330, 332, 369, 401, 434, 601, 782, 829, 1395 |
CWE-285 is the one registered with no binary detector and the catalog's one pending entry: the shared rule catalog's CWE-285 rows are gated on a source-level "the argument is not a literal" check the binary import scan cannot enforce, so the binary path skips them by design.
CWE-829 is the expensive omission: the id the UEFI/SMM callout detector emits, the firmware finding with the highest single-finding impact in the catalog, renders with a null CWE name.
The sixteen CWEs the taint lattice can emit are all present in the name registry. Findings for the twelve missing ids render with the id, the MITRE link, and a null name. The registry's tests pin uniqueness, non-empty names, and MITRE-link format, plus a per-detector spot check; there is no exhaustive detector-to-registry parity test, which is exactly how twelve ids went missing.
| Family | CWEs |
|---|---|
| Memory safety | 119, 120, 121, 122, 125, 129, 401, 415, 416, 476, 787, 825 |
| Dataflow-proven | 22, 1284 |
| Cryptography and randomness | 295, 319, 326, 327, 330, 332, 337, 338, 347, 916, 1240 |
| Injection and code execution | 78, 89, 94, 99, 134, 502 |
| Web, server, and cloud | 346, 434, 601, 611, 918 |
| Authentication and authorization | 285, 287, 306, 862, 863 |
| Identity, privilege, permissions | 269, 273, 312, 345, 693, 732, 782, 798 |
| Filesystem, environment, races | 15, 59, 243, 276, 367, 377, 426, 560, 668 |
| Integer and allocation correctness | 190, 195, 197, 789 |
| Coding hygiene, posture, crashes | 209, 215, 242, 252, 369, 467, 479, 676 |
| Firmware and supply chain | 829, 1395 |
Twenty of these are in MITRE's Top-25: CWE-22, 78, 89, 94, 119, 125, 190, 269, 276, 287, 306, 416, 434, 476, 502, 787, 798, 862, 863, 918.
Sixteen of them can be reached through the SSA-taint lattice (15, 22, 78, 89, 94, 99, 119, 134, 252, 415, 416, 476, 787, 789, 918, 1284), which means their tight catches require a flow-proven source reaching an argument-typed sink rather than co-occurrence anywhere in the call graph.
Format and architecture coverage
Four format-and-architecture combinations carry a real per-call-site address on every call-graph edge:
| Format | Architectures | Where the symbols come from |
|---|---|---|
| Mach-O | arm64, arm64e | the nlist table plus Objective-C metadata |
| Mach-O | x86_64 | the nlist table plus Objective-C metadata |
| PE32+ | x86_64 | IAT entries and bare names |
| ELF | arm64, x86_64 | PLT stubs, .symtab, .dynsym |
That address is what lets a finding name the specific call instruction inside its calling function — the IDE-jumpable case.
Everywhere else the lift bridge leaves the field at a sentinel zero, and 40 detector modules — every rule that resolves an argument at a call site, and every site-ordered call-graph rule — skip that call target rather than reason from an unordered set. The silence is total and unreported: the detector runs, finds nothing it can attribute, and emits nothing. This is why the CWE-476 call-graph rule was effective on Mach-O arm64 alone for as long as it was the only bridge populating the field.
Static-linked ELF binaries lose the import-keyed detectors, not the findings. With no dynamic imports, import-match rules have nothing to bind to. On the statically linked, stripped curl/linux-aarch64 fixture not a single dangerous-call.* rule fires, despite strcpy in its source. What that binary does produce is 2,629 findings, every one of them from a name-independent IL detector:
| CWE | Findings | Dominant rules |
|---|---|---|
| 787 | 1,519 | bounds.oob-write.object 943, oob-write.loop-bounded.fixed-buffer 316 |
| 125 | 1,032 | oob-read.loop-bounded.fixed-buffer 708, bounds.oob-read.object 297 |
| 369 | 38 | divide-by-zero.unchecked-divisor |
| 789 | 33 | excessive-alloc.stack |
| 693 | 6 | ELF hardening posture |
| 798 | 1 | hardcoded-secret.pem.marker-only |
Static linkage alone is not the blinding condition: the dangerous-calls detector walks resolved call-graph symbols rather than gating on the import table, so a statically linked binary that keeps its symbol table is still covered. Blindness needs static and stripped together. String-based detectors work throughout; closing the remainder needs string-based heuristics over the linked-in libc, which do not exist.
Twenty-three detectors skip Rust binaries outright, across twenty-four modules. Each skip trades a real class of miss for a measured flood avoided:
| Detector | The Rust shape it collides with | Spurious findings on ripgrep before the skip |
|---|---|---|
| Taint lattice | the conservative-call default marks any unmodelled callee's result tainted, and Rust binaries are dominated by unmodelled callees — every Clone::clone, every Copy, every derive-emitted helper, every monomorphisation | 2,326 (0 after) |
sizeof()-on-a-pointer | Clone of a pointer-sized type compiles to mov w2, #8; bl _memcpy, exactly the shape the C sizeof(ptr) bug produces | 42 — 40 memcpy, 2 memmove |
| Divide-by-zero | checked-arithmetic helpers compile to the same division shape everywhere | — |
| Link-following, W^X, null-deref, loop-bounded read and write, array-index, signed-guard, truncation, memory-leak object model | std::fs opens files without O_NOFOLLOW throughout, so the link-following rule fired on every Rust binary's ordinary I/O | — |
The two bounds object models are the exception: bounds.oob-write.object and bounds.oob-read.object carry no Rust skip and fire 77 and 41 times respectively on ripgrep/linux-aarch64. They are proof-carrying — an offset provably past a sized object — rather than pattern-keyed, so the Rust idioms that flood the heuristics never reach them.
The cost of the skips is real and it compounds: a Rust binary that FFI-calls memcpy with a genuine pointer-size confusion, that reaches a C sink through unsafe, or that has a real unchecked open in its own code, is missed by all of them at once.
Apple-runtime surfaces are Mach-O-only by construction. The XPC client-identity (CWE-345), keychain at-rest protection (CWE-312), TCC entitlement mismatch (CWE-732), and DYLD-injection-tolerant (CWE-693) detectors read the Mach-O entitlement plist, Objective-C class-ref imports, and the code-signature blob. Other formats have none of those.
Byte-aware argument resolution is arm64 by default; six modules escape it. The strict register tracer and the ADRP + ADD literal resolver both decode arm64 instruction bytes, the literal resolver by construction. A portable seam reads the constant arguments the ELF and PE call-graph builders recover from the lifted IL instead, and six detector modules route through it.
| Rules | Where the constant comes from | Runs on |
|---|---|---|
fortified _chk proofs (CWE-787 / CWE-125), the setuid-zero rule (CWE-269), the umask rule (CWE-560), the byte-aware format-string pass | on the arm64 family, the original strict byte tracer unchanged; everywhere else, the lifter-recovered constant arguments the ELF and PE bridges both populate | every bridged format |
CWE-190 multiply-into-malloc, CWE-467, the anti-debug shapes, the CWE-369 divisor window | a separate x86_64 register tracer (System V on Mach-O and ELF, MS x64 on PE, forward decode) | arm64 and x86_64 |
every other call-site argument rule — CWE-59 link following, CWE-668 O_CLOEXEC, CWE-377 O_EXCL and predictable temporary paths, CWE-276 create modes, CWE-732 chmod modes, CWE-611 libxml2 options, CWE-916 PBKDF2 iteration counts, CWE-295 libcurl verify flags, CWE-330 and CWE-337 seeds, CWE-273 privilege-drop verification, CWE-789's heap arm, CWE-78's literal command re-grading, CWE-94's dlopen path split, CWE-15 and CWE-426 environment-variable names, hardcoded-credential comparisons, signal-handler resolution, jailbreak string scans, the dyld image scan, the CWE-209 os_log pass, the CWE-121 stack-frame overrun proof, CWE-326 key lengths, the CWE-825 and W^X argument resolution, PT_DENY_ATTACH | the arm64 strict tracer or the arm64 literal resolver, called directly with no fallback path | arm64 only |
That third row is most of the byte-aware catalog. On the dynamically linked ripgrep/linux-aarch64 fixture it produces fd-leak-on-exec.no-cloexec.open, dangerous-call.realpath, and search-path.exec-unclean.setuid-helper; on the x86-64 builds of the same programs, none of them fire.
The taint matrix is wide, but half of it needs a non-default build.
| Format | Architectures the lattice runs on | Reached by a default build |
|---|---|---|
| Mach-O | arm64, arm64e, x86_64, the PowerPC family, 32-bit ARM variants | yes |
| ELF | arm64, x86_64, x86, MIPS, PowerPC, RISC-V, ARM | yes |
| ELF | the embedded register-machine set — SuperH, SPARC, m68k, s390x, Xtensa, AVR, MSP430, Z80, H8/300, V850, TriCore, Hexagon, NDS32, LoongArch64 | no — one opt-in feature each |
| PE | x86_64, x86 | yes |
The analysis crate's default feature set is PE, Windows, ELF, Linux, BSD, Solaris, MIPS, ARM, eBPF, PowerPC, RISC-V, and CIL, so a default build never lifts the fourteen in that third row far enough to reach the matrix at all. Combinations outside the matrix return an empty taint vector and still get import, string, call-graph, byte-aware, and posture coverage.
The WebAssembly arm is unreachable; the Dalvik arm is pinned shut. The WASM arm admits the format without an architecture check — linear memory makes the loop-OOB, heap-overflow, and value-range passes semantically valid, and the lift resolves WASM function indices to import and export names so name-keyed sinks match. It sits behind a detection-crate feature nothing forwards: the analysis crate's own WASM feature enables the loader and the architecture tables but not the detection arm, and the engine crate has no WASM pass-through at all. No build configuration in the workspace reaches it.
Dalvik is shut twice over. The lift does resolve the register VM's constant-pool tokens back to smali method, field, and string names, which is what a name-keyed sink lookup needs — but dalvik is not one of the format-and-architecture pairs in the taint matrix, and a test pins it out explicitly, so enabling the feature would still return an empty taint vector. CIL, JVM, pyc, and luac are excluded by the same matrix and for the stated reason: their calls are token-keyed and their memory is object- and reference-based, so the out-of-bounds and pointer-taint sink classes are a category mismatch.
Per-CWE reference
Each entry states the detector's mechanism first, then the rules it emits, then — where one has been measured — its known limit.
CWE-15 — External Control of System or Configuration Setting
Byte-aware register trace at getenv and secure_getenv call sites, resolving the literal variable name and classifying it. App-custom debug or config variables — an all-uppercase identifier containing DEBUG, LOG, CONFIG, TRACE, or VERBOSE — fire at Low / Medium: the binary's behaviour is steerable through the environment, and the auditor confirms whether that surface is documented config or an undocumented back door. Standard system variables (PATH, HOME, TMPDIR, USER) are deliberately not flagged, because every Unix tool calls getenv("HOME") and emitting on them would flood the queue. Loader-hijack variables fire under CWE-426 instead.
The write side runs the same classifier over setenv / putenv / unsetenv; the loader-variable writes again land under CWE-426. The registry-data half of the Windows surface reaches this CWE through the taint lattice's RegistryData sink.
Rules — env-var-use.app-config, env-var-set.standard-override, env-var-set.app-config.
CWE-22 — Path Traversal
The lattice's Path sink class, plus a cross-format archive-extraction shape. When an external-input source (read, recv, fread, getenv, argv) is proven to flow into the path argument of open, openat, fopen, creat, unlink, unlinkat, rename, renameat, chmod, fchmodat, chown, lchown, fchownat, mkdir, mkdirat, rmdir, symlink, symlinkat, link, linkat, truncate, access, faccessat, stat, lstat, or fstatat without a dominating sanitiser, the detector emits on it. The POSIX-2008 *at family is in scope because the path component still flows from input — the directory descriptor argument does not change the traversal exposure. The recognised sanitiser is RealpathNullCheck, the canonical canonicalisation gate.
The second detector catches the Zip-Slip shape: a libarchive, libzip, libtar, minizip, or miniz consumer that reads entry pathnames without importing a realpath canonicaliser. The entry name comes out of the archive header, so it is attacker-controlled, and it flows straight to the extraction-side open and write.
Rules — taint.flow.path-arg.<callee>; archive-extract.<lib>.no-path-validation, with an unknown variant when the library cannot be identified.
CWE-59 — Link Following
Byte-aware flag-mask gate. Fires when the flags argument of open / openat / open_nocancel sets at least one write, create, truncate, or append bit while clearing both O_NOFOLLOW (0x0100) and O_SYMLINK (0x20_0000) — a privileged write through a path an attacker can swap for a symlink. Read-only opens are deliberately not flagged: link following only matters when the code writes. CWE-61 (symbolic) and CWE-62 (hard link) are variant children of the same gate.
Rules — link-follow.open-without-nofollow.{open,openat,open_nocancel}.
CWE-78 — OS Command Injection
Calls into system, popen, execlp, execvp, execv, and execle flag at import-match time. execve and posix_spawn are not in the import table — they reach this CWE only through the taint lattice. The byte-aware variant resolves the command string at the call site and re-grades: a literal with no shell metacharacters demotes to Info, a literal containing $, a backtick, |, <, >, &, or ; fires a shell-metacharacter rule at High confidence, a bare filename passed to an exec*p variant fires a PATH-search rule, and a non-literal argument keeps the Medium-confidence import finding so the unresolved case stands out rather than disappearing. That re-grading reads arm64 bytes directly. The flow-proven version is the lattice's ShellArg and ProcArg sinks.
Rules — command-exec.{system,popen,execlp,execvp,execv,execle}, plus the per-call-site .literal-safe, .literal-shell-meta, .path-search, and .literal-absolute variants.
CWE-89 — SQL Injection
Import-match audit prompt over the SQL-execution surface, six rules. sqlite3_exec, sqlite3_get_table, mysql_query, mysql_real_query, and libpq's PQexec run a full SQL string rather than a parameterised statement, and fire at Medium / Medium. The detector cannot see whether the string is concatenated from attacker input, so the finding is a surface map: the binary executes dynamic SQL, audit how the string is built.
PQexecParams is the sixth rule rather than an exclusion. It binds parameters separately from the SQL text, so injection is structurally prevented unless the query template itself is attacker-built — it fires at Info severity, Medium confidence, purely as attack-surface inventory. The other safe alternatives, sqlite3_prepare_v2 and mysql_stmt_prepare, carry no rule at all. MITRE Top-25 #3.
Rules — sql-exec.<callee>.
CWE-94 — Code Injection via Dynamic Loading
dlopen, dlsym, and NSClassFromString against attacker-influenced paths or names. The byte-aware variant resolves the path argument and splits the outcome: a literal under /usr/lib/ or /System/Library/ is a benign system load (dynamic-code.dlopen.system-path), a literal absolute path elsewhere, a relative path, an @rpath-relative path, or a path under a writable directory each get their own rule and escalate. A non-literal dlopen argument is the canonical load-from-an-attacker-influenced-string shape and emits at High severity. dynamic-code.macho-from-memory covers in-memory image loading.
Two remote-eval companions extend the class past dynamic loading. The Lua rule fires when luaL_loadstring and lua_pcall co-occur with a network-read source — the Redis Lua sandbox-escape shape (CVE-2022-0543). The Python rule fires on PyRun_String, PyRun_SimpleString, PyEval_EvalCode, Py_CompileString, or PyMarshal_ReadObjectFromString reachable from a network read — the data-science-platform pre-auth RCE shape — while excluding the load-from-disk variants PyRun_File and Py_CompileFile. Both demote to a local-only rule when no network source reaches the eval.
The Win32, Linux, and Mach process-injection sinks also emit CWE-94, naming CWE-1325 in the summary.
Rules — dynamic-code.dlopen*, dynamic-code.dlsym, dynamic-code.macho-from-memory, code-injection.{lua,python}-eval.{network-reachable,local-only}.
CWE-99 — Resource Injection
The lattice's RegistryName sink class: external input proven to reach the name or sub-key argument of RegOpenKeyEx, RegSetValueEx, RegCreateKeyEx, and the rest of the Win32 registry family without a dominating sanitiser. Whoever controls the key path controls which resource the privileged process opens or writes. The data-buffer side of the same calls fires under CWE-15.
Rules — taint.flow.registry-name.*.
CWE-119 — Buffer Operations Out of Bounds
The class umbrella for the variants (120, 121, 122, 125, 787). A detector emits the variant when the specific shape is identifiable; the umbrella covers a banned function whose interface admits no safe use under any caller — gets, getwd, wcscpy. On the dataflow side, the lattice's Read sink at the source argument of a memcpy-class primitive emits CWE-119 as the read-side twin of the CWE-787 destination sink.
CWE-120 — Classic Buffer Overflow
strcpy, strcat, stpcpy, sprintf, vsprintf, gets, and the scanf family — primitives with no destination-size argument — detected at import-match. The fortified twins carry the same CWE at lower severity under a .fortified rule suffix: the runtime aborts on overflow, but the source-level pattern is unchanged.
Rules — dangerous-call.<callee> and dangerous-call.<callee>.fortified.
CWE-121 — Stack-based Buffer Overflow
Two detectors, one heuristic and one proof-grade. The heuristic flags functions carrying a stack canary whose only outgoing call is the canary-failure stub — the manual-copy-loop shape the symbol-keyed rules cannot see — at Low confidence, and it is narrow on purpose: a function that mixes a manual loop with any other libc call slips through. It is also, by volume, the loudest rule in the catalog: 115 of jq's 342 Mach-O arm64 findings come from this one rule. Low confidence is doing real work there.
The proof-grade detector reads three constants out of the binary at a memcpy call site — the frame size from the prologue's sub sp, sp, #F, the destination offset from add x0, sp, #O, and the copy length from the resolved x2 — and fires when O + N > F is total. No taint, no flow, no aliasing: given the call site is reached, the overflow is unconditional. High confidence, Critical severity. It reads the arm64 prologue shape and is arm64-only today.
The overrun predicate additionally requires the destination to start inside the frame. A base at or beyond the frame top — the sp+0x3000-into-a-0xa70-frame class — is a decode artefact, not a buffer, and is rejected even though its offset plus length exceeds the frame size.
Rules — manual-buffer-copy.canary-only-leaf, stack-overflow.proof.constant-frame-overrun.
Limits. Every fire of the proof rule on ripgrep is a false positive: 9 times on the macOS arm64 build and 12 on the Linux arm64 build, all Critical, all in Rust's panic-helper path where the compiler statically constructs error buffers. Those counts are pinned as caps rather than driven to zero, because the arithmetic is genuinely total in each case — what is wrong is the premise that a statically constructed panic buffer is a copy destination. Every other fixture in the matrix — airportd, and the Mach-O, ELF, and PE builds of curl, jq, and ripgrep-on-x86_64 — emits exactly zero.
CWE-122 — Heap-based Buffer Overflow
The dedicated detector catches the allocation-size-versus-copy-length mismatch directly over the IL — p = malloc(S); … memcpy(p, src, L) with L > S — which is the shape name-keyed taint sinks miss when neither value is attacker-tainted. The MSVC pattern of allocating with one size and copying with another motivated it. The fortified CWE-787 detector additionally proves overrun against the dst_size argument regardless of whether the destination is stack or heap, and the lattice's IndexedWrite sink covers the tainted cross-function shape.
Rules — heap-overflow.copy-exceeds-alloc.
CWE-125 — Out-of-bounds Read
Top-25 entry, sibling to CWE-787, four detectors. The fortified-proof rule fires on __memcpy_chk / __memmove_chk / __bcopy_chk where the source resolves to an embedded string literal and the byte count exceeds strlen(literal) + 1 — the runtime reads past the literal's NUL into adjacent constant-string bytes, the info-disclosure shape behind Heartbleed-class bugs. The string-family _chk variants (__strncpy_chk, __strncat_chk, __strlcpy_chk, __strlcat_chk) are excluded because they stop at NUL by definition. High / High.
Three IL-side companions extend the read side past named symbols: a loop-shaped read whose bound is attacker-tainted (the read twin of the CWE-787 loop family), the heap-object proof that a read offset exceeds the allocation, and the kernel user-copy rules where a copyout-family length exceeds the source object. The attacker-indexed read shape fires under CWE-129.
Rules — oob-read.memcpy-chk-literal-overrun, oob-read.loop-bounded.fixed-buffer, bounds.oob-read.object, oob-read.kernel-copy, oob-read.kernel-copy-exceeds-alloc.
CWE-129 — Improper Validation of Array Index
IL detector over scaled-index accesses. An attacker-controlled value that reaches a table[idx] access with no dominating bound check on any path emits at CWE-129 for the read shape and at CWE-787 for the write shape. Detection is deliberately narrow: the index must be a direct external-input source — a value merely derived from input does not seed a candidate — and any recognised comparison on the index suppresses. Precision-first until a corpus diff justifies loosening. Composes with CWE-195, which catches the bound check that exists but compares signed.
Rules — array-index.unvalidated-attacker-index, array-index.unvalidated-attacker-index-write.
CWE-134 — Externally-Controlled Format String
The byte-aware detector resolves the format-string argument at each printf-family call site and emits per-callee rules in two shapes: .non-literal when the format argument does not resolve to a read-only literal at all, and .writable-global when it resolves to an address in a writable section. The family is wider than the POSIX core:
| Surface | Callees |
|---|---|
| POSIX | printf, fprintf, sprintf, snprintf, asprintf, dprintf, syslog, vsyslog, the v-prefixed variants, and glibc's fortified __*_chk twins |
| Windows | sprintf_s, swprintf_s, vsprintf_s, vswprintf_s, _snprintf, _snwprintf, _snprintf_s, _snwprintf_s, _vsnprintf_s, _vsnwprintf_s, wsprintfA / wsprintfW, wvsprintfA / wvsprintfW |
| Linux kernel | printk, vprintk, seq_printf, dev_err, dev_warn, dev_info, dev_notice, dev_crit |
| BSD | err, errx, verr, verrx, warn, warnx |
The flow-proven version is the lattice's FormatString sink.
Rules — format-string.<callee>.non-literal, format-string.<callee>.writable-global.
Limits. Only the ADRP + ADD shape is resolved — the direct page-plus-offset form. ADRP + LDR through the global offset table is skipped, because the GOT slot is writable but its pointee almost always lives in a read-only section, so resolving it would need a pointee read. A binary that genuinely stores the format string itself in the GOT is missed.
CWE-190 — Integer Overflow into Allocation
The malloc(count * size) shape. The byte-aware detector reads the multiplier and multiplicand out of the call site by strict register trace; when both are constants and the product wraps in 64-bit unsigned arithmetic, it fires. High confidence. The wrap chains into CWE-787 on subsequent indexed writes into the undersized buffer. This is one of the rules carried onto x86_64 by the dedicated register tracer rather than the arm64 byte tracer.
Rules — integer-overflow.mul-into-{malloc,calloc,realloc,reallocarray,alloc}.
CWE-195 — Signed to Unsigned Conversion Error
The signed/unsigned confusion family. A bound enforced only by a signed comparison — if (len > MAX) fail; with int len — admits every negative value, which the subsequent unsigned index or size arithmetic reinterprets as huge (-1 becomes 0xFFFF…). Two site kinds carry the check: scaled-index accesses and size/length sinks such as copyin and malloc. Structural and narrow by design — it fires only when the guard provably compares signed and the use is provably unsigned.
Rules — signedness.signed-check-unsigned-index, signedness.signed-check-unsigned-index-write, and the size-sink variants.
CWE-197 — Numeric Truncation Error
A wide length narrowed before it reaches an allocation or a copy: the u64 header field assigned into a u32 before memcpy(dst, src, n) or malloc(n), so the allocation or copy size disagrees with the value the surrounding code validated. For every resolvable call whose callee has a size or length argument, that argument's backward definition closure is checked for an implicit width-narrowing conversion. CWE-681 is the conversion-error parent.
Rules — truncation.size-narrowed-before-alloc, truncation.size-narrowed-before-copy.
CWE-209 — Information Exposure via Error Messages
String scan over __cstring and __objc_methname for stack-trace template fragments (%@\n%@, at line %d), leaked build paths (/Users/, /private/var/), and format-string pointer leaks. Also flags os_log and NSLog call sites whose format string carries %@ against a non-redacted public argument — the case where the redacted-by-default convention was deliberately bypassed. Medium confidence. This is the loudest family on a real Apple daemon: 39 of airportd's 102 findings are os_log disclosure hits, split 22 pointer-redacted, 13 public-data, 3 public-error-context, 1 public-integer.
Rules — info-disclosure.format-pointer-leak, info-disclosure.format-write-n, info-disclosure.os-log.{pointer-redacted,public-data-leak,public-pointer-leak,public-integer,public-error-context}.
CWE-215 — Sensitive Information in Debugging Code
Binary-wide posture across three Mach-O surfaces.
| Rule | Fires when | What it means |
|---|---|---|
posture.debug-info-shipped | a release binary still carries a __DWARF segment | symbol names, source paths, type information, and inline metadata leak directly |
posture.symtab-unstripped | function-name density in the symbol table exceeds the stripped-binary norm | a forgotten strip; the threshold is tuned to let stripped-with-export-list pass |
posture.dsym-path-leak | the constant strings contain an embedded dSYM bundle path | the fingerprint of a release binary linked against a debug archive |
Mach-O only; the ELF equivalent lives in the ELF posture family.
Rules — posture.debug-info-shipped, posture.symtab-unstripped, posture.dsym-path-leak.
CWE-242 — Use of Inherently Dangerous Function
gets specifically — removed from C11 because no caller can use it safely. Import-match. CWE-676 covers the broader banned set; this is the variant for the one function with no safe use at all.
CWE-243 — chroot Jail Without Changing Working Directory
Set-membership call-graph rule. A function that calls chroot without a following chdir("/") leaves the calling thread's working directory outside the new root, and .. traversal from that directory escapes the jail. The correct idiom is chroot(jail); chdir("/") in that order, and the correct order suppresses entirely.
Rules — chroot-jail.no-chdir-or-priv-drop for chroot-only, High / Medium, because it asserts pair existence rather than ordering; chroot-jail.chdir-before-only for the reversed order, Medium / Low, because it approximates control-flow reachability over an unordered call list, which a branch carrying the post-chroot chdir defeats.
CWE-252 — Unchecked Return Value
Two detectors over the same curated must-check set: libc primitives whose return code signals failure (read, write, recv, send, fork, pthread_create), Security.framework (SecKey*, SecItem*), IOKit (IOServiceOpen, IOConnectCall*), and the kernel family. The byte-aware variant classifies the first post-call access to the return register: a clobbering write before any read fires; a check-and-branch is a clean read. The lattice variant raises the same shape over SSA with alias-aware seeding for stack-spilled return values — ClobberedReturn when the value is overwritten before being read on any path, IsolatedReturn when it never reaches a comparison or a branch. The two run independently; neither promotes the other's confidence.
Rules — unchecked-return.{libc,security-framework,iokit,kernel} and the taint.flow.unchecked-return.* parallels.
CWE-269 — Improper Privilege Management
Byte-aware register trace at setuid, seteuid, and setresuid call sites. A uid argument resolving to literal zero fires at High confidence; other constant uids surface at Medium with the resolved value carried in the finding. The setgid family is excluded pending false-positive measurement — the gid-zero shape is rarer than uid-zero, and a noisy rollout costs auditor trust that a detector cannot buy back. The SUID- and SGID-bit variants of chmod also carry CWE-269 in their summaries and fire under CWE-732.
Rules — privilege-escalation.<callee>.zero.
CWE-273 — Improper Check for Dropped Privileges
Byte-aware register trace across setuid, seteuid, setresuid, setreuid, and setregid. Gated on the target uid resolving to a non-zero constant — a real privilege drop, not the escalation CWE-269 catches — and fires when the same caller never subsequently calls getuid, geteuid, or getresuid to confirm the kernel honoured it. The correct shape is setuid(uid); if (getuid() != uid) _exit(1);. Sibling of CWE-252 (which checks the return value) and CWE-269 (the class parent).
Rules — privilege-drop.no-verify.<callee>.
CWE-276 — Incorrect Default Permissions
Byte-aware resolution of both the flags and the mode argument at open, openat, open_nocancel, creat, open64, and openat64. Fires when O_CREAT is set and the mode is world-writable, 0o777, or carries a SUID or SGID bit. Sibling of CWE-732, which covers the chmod and mkdir family; both can co-fire when code opens with a loose mode then tightens it, and they are describing different windows — the open-mode finding flags the default leak, the chmod finding flags a post-create mistake. MITRE Top-25 #25.
Rules — insecure-create-mode.{open,openat,creat}.{world-writable,world-everything,setuid-bit,setgid-bit}, plus an unknown variant.
CWE-285 — Improper Authorization
No binary detector emits CWE-285 — the catalog's one registered-but-pending entry. It is registered for the kernel-side authorization-bypass shape. Userland coverage today is bounded by the CWE-862 CGI detector; kernel kauth-listener and IOKit access-control-bypass patterns are out of scope until a kernel-extension bridge lands. The shared rule catalog carries a CWE-285 setuid / setgid topic, but every one of its rows is gated on a source-level "the argument is not a literal" check the binary import scan cannot enforce, so those rows fire only in the source-C walker.
CWE-287 — Improper Authentication (JWT alg:none)
Two-way import gate. Positive: the binary imports a JWT decoder (jwt_decode, cjwt_decode). Negative: it imports no signature-verification primitive (jwt_verify, jwt_verify_signature, cjwt_verify). Decoder without verifier is the alg: none bypass — an attacker forges a token declaring no algorithm, the decoder accepts the unsigned payload as authentic identity, authentication is gone. CVE-2024-54150 (cjwt none-algorithm), CVE-2024-25638 (cjwt key confusion), CVE-2022-39227 (python-jwt), plus the router web-UI and IoT-firmware JWT-cookie tail. Medium confidence. MITRE Top-25 #13.
Rules — improper-auth.jwt.decode-no-verify.
Limits. Proving the decoded token is used for authorization rather than for displaying claim metadata needs control-flow analysis this detector does not do, so the audit step is to confirm the payload reaches an identity or role check.
CWE-295 — Improper Certificate Validation
The Apple TLS-validation-bypass surface, across imports, strings, and call-graph plus Objective-C class signals:
cert-validation.break-on-server-auth-no-trust—SSLSetSessionOptiondisabling server auth with no matching trust evaluation on the returned object.cert-validation.trust-set-exceptions— imports ofSecTrustSetExceptionsandSecTrustSetOptions, the "trust this leaf regardless of chain" knobs.cert-validation.willsendrequest-deprecated—NSURLConnection'sconnection:willSendRequestForAuthenticationChallenge:andconnection:canAuthenticateAgainstProtectionSpace:, deprecated since iOS 8 / macOS 10.10 and almost always implemented as an unconditionaluseCredential:.cert-validation.urlsession-challenge-impl-no-trust— aURLSession:didReceiveChallenge:completionHandler:implementation that never reaches a trust evaluation.cert-validation.urlsession-challenge-credential-without-trust— thecredentialForTrust:per-caller bypass shape.cert-validation.ats.{allows-arbitrary-loads,allows-arbitrary-loads-webview,allows-arbitrary-loads-media,exception-insecure-http}— App Transport Security exception keys found in the embedded plist.
These compose additively with unchecked-return.security-framework; both can fire on the same caller.
A libcurl companion walks curl_easy_setopt call sites and resolves the option enum and its value: CURLOPT_SSL_VERIFYPEER set to zero fires Critical (peer-certificate trust off — a machine-in-the-middle with any self-signed certificate succeeds); CURLOPT_SSL_VERIFYHOST set to zero fires High (hostname check off — any valid certificate for any host succeeds). This is the dominant binary-detectable certificate-validation-disable shape in consumer router firmware.
Rules — the six cert-validation.* families above, plus tls-verify.curl.{verifypeer,verifyhost}-disabled.
CWE-306 — Missing Authentication for Critical Function
Three-way import gate, and the conjunction is the whole signal — each gate alone fires on ordinary software.
| Gate | Requires | What it establishes |
|---|---|---|
| Positive | bind plus listen plus accept or accept4 | the binary serves network requests |
| Positive | a child-process spawn primitive — fork, execve, posix_spawn, system, popen | the critical capability that, reachable pre-auth, is remote code execution |
| Negative | no authentication primitive anywhere in the imports, across a wide cross-vendor set — crypt, PBKDF2, HMAC and EVP_DigestVerify*, bcrypt, argon2, scrypt, libgcrypt, Apple Security verification, libjwt, krb5, GSS, ldap_simple_bind_s, pam_authenticate | no authentication happens in-process |
The negative gate is permissive on purpose: any one auth import suffices, because authentication is usually delegated, and a binary carrying any of them at least knew it might need to authenticate. All three holding is a pre-auth-RCE capability by construction. 94 Critical CWE-306 CVEs in the 2026 corpus, including pre-auth RCE in Marimo, Langflow, and SmarterMail, plus the IoT camera and container-orchestration management-API tail. MITRE Top-25 #20.
Rules — missing-auth.network-server.spawn-no-auth.
CWE-312 — Cleartext Storage of Sensitive Information
Apple's deprecated keychain accessibility constants leave keychain items decryptable while the device is locked — and, for the non-device-limited form, even before the first unlock after reboot. Detected by direct symbol-import match against the imported global from Security.framework, gated on the binary actually calling SecItemAdd, SecItemUpdate, or SecItemCopyMatching. The data cross-reference index attributes the finding to the function that loads the GOT slot for the constant, so the report names a calling function rather than just the binary.
Rules — keychain.accessible-always (Critical) and keychain.accessible-always-this-device-only (High).
CWE-319 — Cleartext Transmission
Catalog-driven import match: dangerous-call.SSL_CTX_set_verify flags OpenSSL's SSL_CTX_set_verify, the knob that, called with SSL_VERIFY_NONE, disables peer-certificate validation. The import scan cannot resolve the mode argument, so the call shape is surfaced for audit at High severity rather than claimed as a bypass. This rule comes from the shared TOML catalog with both the binary engine and the source-C walker declared as consumers — one entry fanning across two surfaces. The source-side siblings (plaintext ftp_*, http:// client construction) stay source-only.
Rules — dangerous-call.SSL_CTX_set_verify.
CWE-326 — Inadequate Encryption Strength
Byte-aware constant resolution at CCCrypt call sites: AES with a key length below 16 bytes (128 bits) and 3DES below 24 bytes (192 bits), both floors from NIST SP 800-131A. Arm64-only today — it is one of the remaining MOVZ-window rules.
Rules — weak-crypto.cccrypt-aes-short-key, weak-crypto.cccrypt-3des-short-key.
CWE-327 — Broken or Risky Cryptographic Algorithm
DES, 3DES, RC2, RC4, Blowfish, CAST5, MD2, MD4, MD5, and SHA-1 detected at import-match across CommonCrypto, OpenSSL's one-shot and EVP interfaces, and mbedTLS — 49 rules, so a finding names which library's binding was called, not just which algorithm. CCCrypt with the algorithm argument resolved as DES at the call site fires the proof-grade weak-crypto.cccrypt-des at High confidence; the constant-argument ladder runs signature lookup, strict trace, optimistic trace, then a MOVZ-window scan.
Rules — weak-crypto.<algorithm>.<library>[.<mode>], plus weak-crypto.cccrypt-des.
CWE-330 — Insufficiently Random Values
Two shapes, both proof-grade at the call site. predictable-seed.literal-constant fires when a seeding call's argument resolves to a literal — srand(1) produces the same stream on every run of every copy. predictable-rng.bound-zero and predictable-rng.bound-one fire on arc4random_uniform(0) and arc4random_uniform(1), whose output range contains exactly one value: the call is either dead code or a hard-coded bound where a real one was intended. High confidence for both. The low-entropy-source shape is CWE-337; the never-seeded shape is CWE-332.
Rules — predictable-seed.literal-constant, predictable-rng.bound-zero, predictable-rng.bound-one.
CWE-332 — Insufficient Entropy
Import-table heuristic. Fires when the binary imports a libc PRNG output primitive (rand, random, lrand48, mrand48, nrand48, jrand48) but no seeding primitive (srand, srandom, srand48, seed48, lcong48). With no seed call anywhere, the generator runs from its compile-time default state and emits the same byte stream on every process invocation.
Rules — unseeded-prng.no-seed-call.
CWE-337 — Predictable Seed in PRNG
Call-graph co-occurrence: a function that calls a seeding primitive (srand, srandom, srand48, seed48) and a low-entropy source (time, clock, getpid, mach_absolute_time). Rules name both halves.
Rules — predictable-seed.srand-time, predictable-seed.srandom-pid, predictable-seed.srand48-clock, and so on.
Limits. The rule checks co-occurrence, not control-flow ordering or dataflow. A function that calls time() for a log line and separately calls srand(secure_seed) still fires. Tightening this to "the time return value reaches the seed argument register" is what the lattice's return-value flow tracking is for.
CWE-338 — Cryptographically Weak PRNG
rand, random, drand48, lrand48, mrand48, nrand48, and the srand family at import-match. The correct alternatives (SecRandomCopyBytes, arc4random, getrandom, RAND_bytes) are explicitly excluded so a properly written binary does not fire.
Rules — weak-random.<callee>.
CWE-345 — Insufficient Verification of Authenticity
The macOS XPC client-identity surface. NSXPCConnection and NSXPCListener do not validate the peer's code-signing identity by default, so a privileged service exposing a listener without calling setCodeSigningRequirement: (macOS 13+) or hand-rolling validation through the audit token paired with SecCodeCopyGuestWithAttributes accepts method calls from any local process — the canonical macOS local-privilege-escalation primitive.
The detector recognises the listener role from a class-ref import of NSXPCListener plus selector stubs (setExportedInterface:, setExportedObject:, listener:shouldAcceptNewConnection:), and the client role from NSXPCConnection plus initWithMachServiceName:, initWithListenerEndpoint:, setRemoteObjectInterface:, remoteObjectProxy. Strong validators (setCodeSigningRequirement:, auditToken, valueForEntitlement:) suppress the finding entirely. The weak validator processIdentifier — spoofable by forking after connecting — downgrades severity by one tier and annotates the summary rather than suppressing, because silently absorbing pid-only validation would credit a known-broken pattern as if it worked. Per-call-site attribution names the function hosting the unvalidated listener.
Rules — xpc.listener.no-code-signing-requirement (Critical, High under weak-only) and xpc.connection.no-code-signing-requirement (High, Medium under weak-only).
CWE-346 — Origin Validation Error
Shape detector over the origin-comparison surface. Fires when a substring search (strstr, strcasestr, memmem, wcsstr) runs against an embedded trusted-origin literal — the broken check that trusted.com.attacker.io passes because the trusted host appears as a substring of the attacker's origin. The correct check is a full-string or suffix-anchored comparison. CVE-2026-20643 (WebKit Navigation API same-origin bypass) is the reference shape.
Rules — auth.origin-substring-bypass.{strstr,strcasestr,memmem,wcsstr,other}.
CWE-347 — Improper Verification of Cryptographic Signature
Three-way import gate. Fires when the binary imports both dlopen (dynamic code load) and a network-fetch primitive (curl_easy_perform, recv on an accepted socket, an NSURLSession data task) but imports no signature-verification primitive across the major crypto libraries — OpenSSL EVP_DigestVerify* / RSA_verify / ECDSA_verify / ED25519_verify, Apple SecKeyVerifySignature / SecTrustEvaluate*, GPG gpgme_op_verify. The shape is the update-mechanism machine-in-the-middle: download code or data and run it without verifying a signature on the way in. Sparkle CVE-2016-4655 is the historical example; the 2026 corpus carries 46 High-or-above CWE-347 CVEs across auto-updaters, plugin systems, package managers, and IoT firmware loaders. Distinct from CWE-345 (the broader parent) and CWE-494 (the narrower download-without-integrity variant).
Rules — signature-verify.missing.dynamic-load.
CWE-367 — Time-of-check Time-of-use
Site-ordered call-graph detector with eleven shape variants, each naming its pair. A check-family call followed by an open, create, unlink, rename, or rmdir on the same path, where the second call is reachable from the first with no intervening atomic-create gate. The ordering requires a non-zero call-site address on every edge. The temporary-file detector chains in an additional variant when a hardcoded /tmp/ path reaches a sink whose preceding check could be raced.
A separate IL detector covers the kernel double-fetch variant: within a function, every user-to-kernel copy-in is grouped by the SSA identity of its user-address argument, and the same user pointer fetched from two or more distinct call sites is the hand-audit-hard TOCTOU — a concurrent userspace thread can change the value between fetches, so the check on the first fetch does not bind the second. Purely structural over the IL, no taint, and sound toward under-reporting: a re-loaded address produces a different SSA expression and raises no candidate.
Rules — toctou.stat-then-open, toctou.lstat-then-fopen, toctou.access-then-creat, toctou.realpath-then-open, toctou.fstatat-then-openat and the rest of the eleven; predictable-tmpfile.toctou-pair; toctou.user-pointer-double-fetch.
CWE-369 — Divide By Zero
Byte-aware shape detector across arm64 UDIV / SDIV and x86_64 DIV / IDIV. For each division, the preceding 16-instruction window is scanned for a zero-check on the divisor register — CBZ / CBNZ on arm64, TEST r,r or CMP r,0 followed by a conditional jump on x86_64 — and the rule emits when none is found. The divisor filter is deliberately narrow and applies identically on both architectures: only divisors already sitting in the argument-passing window fire, x0–x7 on arm64 and RDI, RSI, RDX, RCX, R8, R9 on x86_64, so internal computed divisors from hash mixing and container block-size constants do not swamp the signal. Rust binaries are skipped entirely.
Rules — divide-by-zero.unchecked-divisor.
Limits. The window scan misses any guard that proves the divisor positive without a literal compare against zero. Two real patterns fire wrongly: cmp x1, #0x1; b.lt skip, which proves x1 >= 1 and is common in C overflow-detection helpers such as SQLite's sqlite3MulInt64; and the subs / ccmp / b.hi chain the compiler emits when it folds two range checks into one conditional compare, as in SQLite's initAvgEq. Both are correct guards. Low confidence is calibrated for exactly this — a divide-by-zero finding is a lead, not a claim.
CWE-377 — Insecure Temporary File
Three independent detectors, each catching a different way a temporary path stops being private.
| Detector | Fires when | Why that is unsafe |
|---|---|---|
| Import-match | the binary imports mktemp, tmpnam, or tempnam; the safe replacements mkstemp, mkdtemp, and mkostemps are explicitly excluded | the path is returned before the file exists, leaving a race window |
| Byte-aware open | the resolved flags argument at open, openat, creat, or fopen sets O_CREAT without O_EXCL | without O_EXCL the kernel does not reject a pre-created path |
| Predictable-path | the resolved path argument at eleven call families — open, openat, fopen, freopen, creat, unlink, remove, rename, stat, lstat, access — is a literal under /tmp/, /var/tmp/, or /private/tmp/ with no XXXXXX randomness marker | a fixed path another process can pre-create or symlink-attack |
Both byte-aware variants fire at High confidence when the strict tracer resolves the constant.
Rules — unsafe-open.create-without-excl.<callee>, predictable-tmpfile.<callee>.static-path.
CWE-401 — Missing Release of Memory
The heap-object model's total-leak query. Fires when an allocation minted by an owning allocator is provably never freed on any path — the whole-function lifecycle meet stays alive — and provably never escapes the function by any route: call argument, return value, global store, or store through an unresolved pointer. The object is keyed on its allocation-site address, so the leak is recognised even when the pointer only ever lives in a stack slot. Low severity (resource exhaustion, not memory unsafety), Medium confidence. Runs on x86_64, arm64, and arm64e only.
Rules — resource.leak.unreleased-object.
Limits. The error-path leak — freed on some paths but not all — is a deliberate deferral: it is the false-positive-prone case that needs path-sensitive return analysis. Any escape excludes the object, because ownership may pass to code the intraprocedural pass cannot see. Low recall, near-zero false positives.
CWE-415 — Double Free
Site-ordered call-graph heuristic: a function that frees the same register or stack-slot value twice with no intervening pointer assignment. Distinguished from CWE-416 by the second operation being a free rather than a use. The lattice's UseAfterFree sink catches the cross-block and aliased shape, emitting at CWE-415 when the second-side callee is itself a deallocator.
Two suppressions carry almost all of the precision. Adjacent free; free; free — struct-field teardown — is excluded, so the rule fires only when non-trivial work separates the two frees. And the malloc-rich shape (three or more allocator calls with exactly one free) is suppressed, because that is buffer-pool and dynamic-array growth code where the free targets a scratch slot while the following allocation operates on a still-live buffer. Before the curation the uncurated "any subsequent call after a free" rule produced 272 hits across four Mach-O arm64 binaries, on safe rebind and cleanup chains: free-then-malloc, free-then-curl_slist_free_all, free-then-fileno, free-then-strdup. Current measurement on the same four: airportd 0, curl 15, jq 16, ripgrep 0, plus one flow-proven taint.flow.uaf.double-free on curl.
Rules — double-free.same-function, taint.flow.uaf.double-free.
Limits. The cost of those suppressions is stated plainly: a genuine double-free that happens to be adjacent, or that sits inside a malloc-rich function, will not fire.
CWE-416 — Use After Free
A layered family. The call-graph heuristic flags a free followed by a use of the same value as a pointer argument to another libc primitive (use-after-free.same-block, Low confidence, broad recall). The Objective-C variant catches the ARC-elided pattern — a release followed by a message send on the same receiver in the same block (use-after-free.objc-release). The lattice's UseAfterFree sink raises the tighter dataflow claim: the freed pointer seeds the lattice, alias-aware seeding propagates it across spills and register copies, and a later use as a pointer argument to a memcpy-class, strcpy-class, objc_msgSend*, or Itanium C++ member call emits taint.flow.uaf.<callee>.
Five further IL rules cover shapes the intra-function lattice cannot see: taint.flow.uaf.object (the heap-object variant, keyed on the allocation site rather than an SSA value, which is what catches the stack-reload and global-escape cases); the interprocedural and wrapper-free variants, which make a local free-wrapper transparent by reflecting the callee's free into the caller; taint.flow.uaf.dangling-global, the cross-entry kernel shape where a pointer is freed in one entry path and used in another, communicating only through a shared global or long-lived struct field; taint.flow.uaf.use-after-realloc, which treats a realloc return as invalidating the old pointer; and taint.flow.uaf.refcount-release, which flags a dereference after a refcount release — a possible free, calibrated accordingly.
Rules — use-after-free.same-block, use-after-free.objc-release, taint.flow.uaf.<callee>, taint.flow.uaf.object, taint.flow.uaf.dangling-global, taint.flow.uaf.use-after-realloc, taint.flow.uaf.refcount-release.
Limits. Objective-C's objc_release is deliberately not in the deallocator set for the syntactic rule. Of 78 airportd hits when it was, 71 were a release of A followed by a message to B where B was not A — the ordinary safe ARC pattern. Receiver-alias tracking in the lattice is what makes ObjC support possible without that noise.
CWE-426 — Untrusted Search Path
Two detectors. The search-path detector reads the Mach-O load commands: LC_RPATH entries pointing at writable or attacker-influenced directories, the install names a dylib publishes, and a setuid helper executing with an inherited, unsanitised PATH.
The environment detector fires when a binary itself calls getenv on a loader variable (DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, LD_PRELOAD, LD_LIBRARY_PATH, the DYLD_FRAMEWORK_* family). The dynamic loader reads those before the program starts, so a binary that checks them itself is making runtime decisions from attacker-influenceable state — High confidence, Medium severity, promoted by the composer when paired with a privileged-execution posture finding. The write direction is strictly worse: setting a loader variable configures the loader for the process's own children. unsetenv on the same variables is the opposite, defensive act, recorded rather than flagged.
Rules — search-path.rpath.{writable,unsafe-relative,absolute}, search-path.install-name.{writable,relative,absolute}, search-path.exec-unclean.setuid-helper, env-var-use.loader-hijack, env-var-set.loader-injection, env-var-set.loader-scrub.
CWE-434 — Unrestricted Upload of File with Dangerous Type
Site-ordered shape. Fires when a file written from a network or request source is followed by a chmod that sets an execute bit on the same path — the web-shell-drop primitive. file-upload.write-chmod-exec is the stronger claim; file-upload.write-chmod-no-exec flags a writable upload sink for the auditor. Co-fires CWE-862 when the handler has no preceding authorization check. MITRE Top-25 #10.
Rules — file-upload.write-chmod-exec, file-upload.write-chmod-no-exec.
CWE-467 — sizeof() on a Pointer Type
Byte-aware shape detection across nine sized primitives — memcpy, memmove, memcmp, strncpy, strncat, strncmp, wmemcpy, wmemmove, wmemcmp — plus fortified twins for four of them: __memcpy_chk, __memmove_chk, __strncpy_chk, __strncat_chk. Fires when the size argument resolves to a literal 4 or 8 — pointer width on 32-bit and 64-bit targets.
memset and bzero are not in the table, so the textbook form — memset(p, 0, sizeof(p)) where the author meant sizeof(*p) — does not fire. What fires is the copy-and-compare form of the same confusion: memcpy(dst, src, sizeof(ptr)).
Rules — sizeof-pointer.<callee> and sizeof-pointer.<callee>.fortified.
Limits. The detector cannot tell whether the literal 4 or 8 is a pointer size or a string length. JSON keyword recognition and four-byte magic comparisons — "true", "this", "\xCA\xFE\xBA\xBE" — fire sizeof-pointer.strncmp legitimately, and that is exactly what the single hit on curl and the single hit on jq are. Ripgrep produces zero, which is the Rust skip working rather than the detector being right. A Low-confidence finding here means: look at the call, and decide whether the literal is a pointer width or a string length.
CWE-476 — NULL Pointer Dereference
Three detectors, and the split between them is instructive. The site-ordered call-graph rule walks every function for a may-return-NULL call (malloc, calloc, realloc, strdup, fopen, fdopen, popen, opendir, getenv, dlopen) immediately followed by a use of its return value as a pointer argument before any branch on null. Medium confidence — but it needs real per-call-site addresses, and where the IL bridge leaves those at the sentinel zero the detector skips the caller entirely, which historically made it Mach-O-arm64-effective only. On the Mach-O arm64 fixtures where it does run it produces 6 findings on curl and 11 on jq.
The object-model detector closes that gap: it tracks the heap object from a may-return-NULL allocation site through spills and reloads and fires at any load or store whose address provably resolves to a live object the function never null-checks on any path. Medium confidence, bias-to-silence — an unknown points-to target asserts nothing. Enabling it on arm64 initially surfaced 67 jq and 2 curl false positives, all with a single root cause: the points-to query resolved a reused register base to the freshly minted allocation, because a cell that last held a strdup result was read without regard to which SSA version of the register the store used. Switching the query to a version-aware form took jq, curl, and ripgrep on arm64 to zero while the positive fixture still fired.
The lattice's Deref sink raises the flow claim: allocator-return taint reaching a load of the tainted register with no dominating null-check. The interprocedural extension adds the case where the caller hands the pointer to a callee that dereferences it.
Rules — null-deref.unchecked-alloc-use, null-deref.unchecked-object, taint.flow.deref.load, taint.flow.deref.store, null-deref.callee-dereferences.
CWE-479 — Signal Handler Use of a Non-reentrant Function
Resolves the handler argument at signal, bsd_signal, and sysv_signal call sites to a code address, walks that handler's forward call list, and fires when any callee is in the POSIX non-async-signal-safe set — around forty rules, one per callee, covering malloc, free, printf, fopen, exit, syslog, strdup, ctime, asctime, the stdio family, and the environment functions. Calling a non-reentrant function from a handler is the classic re-entrancy, deadlock, and heap-corruption bug, and an exploitation primitive when the handler interrupts the allocator. A direct sub-pattern of CWE-362.
Rules — signal-handler.non-async-signal-safe.<callee>, with a generic fallback.
CWE-502 — Deserialization of Untrusted Data
Three deprecated NSKeyedUnarchiver selectors — unarchiveObjectWithData:, unarchiveObjectWithFile:, unarchiveTopLevelObjectWithData:error: — plus the older NSUnarchiver equivalent. Apple deprecated all of them in macOS 10.13 / iOS 11 because they cannot enforce secure coding. The detector recognises both modern Xcode's per-selector message-send stub imports (Tier 1, High confidence) and older toolchains' bare-selector dispatch through generic objc_msgSend paired with the class-ref import (Tier 2, Medium confidence). Self-implemented methods with the same names are filtered out, and the secure replacements (unarchivedObjectOfClass[es]:fromData:error:) are explicitly excluded.
Rules — deserialization.nskeyedunarchiver.*, deserialization.nsunarchiver.*.
CWE-560 — umask() with a chmod-style Argument
Byte-aware register trace at umask call sites. A resolved constant sitting in the chmod-permission space — above 0o177 and not exactly 0o777 — means the author almost certainly confused the umask convention (bits to clear) with chmod's (bits to set). The common confusions get their own rules.
Rules — umask.chmod-600, umask.chmod-644, umask.chmod-666, umask.chmod-755, with umask.chmod-style for the rest.
CWE-601 — Open Redirect
String-shape detector over HTTP redirect construction. Fires when a Location: header is built from a format template interpolating a runtime value — the unvalidated redirect target that phishing and OAuth-token-theft chains rely on. A surface map, not a claim: the binary builds redirect targets dynamically, audit whether the destination is checked against an allow-list.
Rules — open-redirect.location-format-template.
CWE-611 — XML External Entity
Per-call strict register trace of the options argument to libxml2's xmlReadMemory, xmlReadFile, xmlReadDoc, xmlReadFd, and the xmlCtxtRead* variants. Severity reads off the bit combination of XML_PARSE_NOENT (entity expansion), XML_PARSE_DTDLOAD (external DTD), and XML_PARSE_NONET (network block):
| Resolved options | Severity | Exposure |
|---|---|---|
| entity expansion, networking allowed | Critical | full XXE, including server-side request forgery and arbitrary file read |
| entity expansion, networking blocked | High | local file read |
| external DTD, networking allowed | High | DTD-driven exfiltration |
libxml2 is the dominant cross-platform XML parser — Apple's SDK, every Linux distribution, most embedded firmware. CVE-2024-25062 and the SOAP / SAML / XSLT tail are the reference cases.
Rules — xxe.libxml2.{noent-with-network,noent-local-only,dtdload-with-network}.
CWE-668 — Exposure of Resource to Wrong Sphere
Gated on the binary importing an exec-family primitive, every open, openat, and open_nocancel call site is checked for the O_CLOEXEC bit (0x0100_0000 on Darwin) in its resolved flags. A missing O_CLOEXEC means the descriptor is inherited by any spawned child — an authenticated socket, a private file, or a privacy-gated resource opened by a privileged daemon and handed to an unprivileged child.
Rules — fd-leak-on-exec.no-cloexec.{open,openat,open_nocancel}.
CWE-676 — Use of Potentially Dangerous Function
The banned-function umbrella: alloca, _alloca, getwd, wcscpy, wcscat, wcsncpy, wcsncat, and relatives — primitives banned wholesale because their interface admits no safe use under caller invariants the compiler cannot see. Distinct from CWE-242, which is gets alone.
CWE-693 — Protection Mechanism Failure
Four surfaces. The hardening posture detector emits one finding per missing build-time mitigation, in three format-specific families:
| Format | Posture rules |
|---|---|
| Mach-O | posture.no-pie, posture.no-stack-canaries, posture.no-fortify, posture.no-hardened-runtime, posture.no-library-validation, posture.no-restrict, posture.no-heap-nx, posture.rwx-segment, posture.text-writable, posture.allow-stack-exec, posture.unsigned, posture.adhoc-signed, plus the dangerous-entitlement set — get-task-allow, disable-library-validation, allow-unsigned-executable-memory, disable-executable-page-protection, allow-dyld-environment-variables, allow-jit |
| ELF | posture.elf.no-pie, no-relro, partial-relro, no-stack-canaries, no-fortify, exec-stack, no-gnu-stack, no-cet-ibt, no-cet-shadow-stack, no-arm64-bti, no-arm64-pac-returns |
| PE | posture.pe.no-aslr, no-nx, no-cfg, no-gs-cookie, no-high-entropy-va, no-cet-shadow-stack, no-force-integrity, unsigned, signature-invalid |
The DYLD-injection-tolerant compound fires when a Developer-ID-signed binary lacks all three of hardened runtime, library validation, and CS_RESTRICT. Each signal alone is sometimes acceptable — a small internal CLI tool; the combination on a Developer-ID-signed binary that ships to users is the canonical macOS local-privilege-escalation surface. Apple-signed and unsigned or ad-hoc binaries are excluded, because the loader policy differs and the per-mitigation findings already cover them.
The W^X detector resolves the protection-flags argument at mmap, mprotect, vm_protect, and mach_vm_protect and fires when the resolved literal carries both write and execute ((prot & 6) == 6). Distinct from the load-time posture findings — this catches the runtime establishment of a writable-and-executable page. Legitimate JIT engines exist; the auditor decides whether the binary's threat model warrants it. High / High.
The anti-debug detector covers a wide primitive set with per-shape rules, one per platform family:
| Platform | Probes detected |
|---|---|
| macOS | anti-debug.ptrace.deny-attach (the macOS-specific request value 31, the fingerprint of jailbreak detection, anti-tamper, and anti-RE machinery) and anti-debug.ptrace.self-trace; two sysctl companions, one keyed on the (CTL_KERN, KERN_PROC) argument-vector fingerprint and one on the post-call trace-bit mask test that proves the probe's result is actually read; Mach exception-port and task-port probes; csops status queries; core-dump suppression via setrlimit; the orphan check via getppid |
| Linux | PTRACE_TRACEME, /proc/self/status reads, tracer-pid probes, prctl dumpable suppression |
| Windows | IsDebuggerPresent, CheckRemoteDebuggerPresent, NtQueryInformationProcess, NtSetInformationThread hide-from-debugger — each with a .branch-on-return variant that fires only when the result is actually branched on |
Defensive use is legitimate; the finding records the site for the auditor to weigh.
The jailbreak-indicator rule scans constant strings and Objective-C method names against a curated set of iOS jailbreak artefacts — Cydia, Sileo, MobileSubstrate, checkra1n, unc0ver, Taurine, RootHide, Dopamine. A binary embedding these is almost always running an in-app jailbreak check (legitimate banking or DRM defence) or, less often, fingerprinting its host. Medium / Medium, and the matched indicator is surfaced so the auditor can tell which.
Rules — the posture families above, dyld.injection-tolerant, wx-protection.{mmap,mprotect,vm-protect}.write-exec, the anti-debug.* set, anti-debug.jailbreak-check.
CWE-732 — Incorrect Permission Assignment
Two surfaces. The byte-aware permission detector resolves the mode argument at chmod, fchmod, fchmodat, mkdir, and mkdirat and classifies it in priority order:
| Priority | Resolved mode | Severity |
|---|---|---|
| 1 | SUID bit | Critical |
| 2 | SGID bit | High |
| 3 | 0o777 | Critical |
| 4 | world-writable | next in the ladder |
The SUID case earns Critical because a non-root caller that chmods a file to SUID-root creates a persistent escalation primitive — the attacker re-creates the file with their own payload after the chmod.
The TCC entitlement-versus-API mismatch detector fires on sandboxed apps that import privacy-sensitive class refs — AVCaptureDevice, CNContactStore, EKEventStore, CLLocationManager, CBCentralManager — without the matching entitlement. The reverse direction, an entitlement claimed with no corresponding API use, emits a lower-severity finding across camera, microphone, contacts, calendars, reminders, photos, location, Bluetooth, accessibility, screen capture, Apple Events, and the documents folder. Per-call-site attribution names the function that loads the class-ref GOT slot. A third rule fires when a non-Apple binary writes directly to the TCC privacy store — the bypass primitive that grants itself privacy permissions with no consent prompt.
Rules — insecure-permissions.<callee>.<shape>, tcc.sandbox.<resource>-no-entitlement, tcc.<resource>-claimed-no-api-use, tcc.db.direct-write.
Limits. The standard create-the-user-dotdir-if-missing idiom passes mkdir(path, 0777) and relies on the process umask to clamp the on-disk mode to 0o755. The detector reads the mode literal but does not check whether the calling thread reset its umask beforehand, so it fires regardless. Scanning for a umask(0) predecessor in the same function would suppress it; today the auditor confirms by hand.
CWE-782 — Exposed IOCTL with Insufficient Access Control
Import-match audit prompt over the kernel-IPC surface. ioctl opens a device-control surface whose access control lives in the kernel-side driver, and the detector cannot see what gate the corresponding IOUserClient::externalMethod or character-device callback enforces — so the finding is calibrated as a surface map, not a vulnerability claim, at Info severity and Medium confidence. IOServiceOpen opens a user-client RPC connection to a kernel IOKit driver; the audit step is to confirm the driver enforces entitlement, sandbox, or Mach-port checks on the connection request and on each external-method dispatch. The IOConnectCallMethod family is deliberately not in this table: those symbols already fire under unchecked-return.iokit with a tighter claim, and double-firing on every user-client RPC site would dilute both.
Rules — dangerous-call.ioctl, dangerous-call.iokit-userclient.open.
CWE-787 — Out-of-bounds Write
Top-25 #1. Two proof-grade rules over the fortified libc family, three name-independent IL rules, and the dataflow sink.
oob-write.memcpy-chk-proven fires on the four-argument fortified family (__memcpy_chk, __memmove_chk, __bcopy_chk, __strncpy_chk, __strncat_chk, __strlcpy_chk, __strlcat_chk) when both the byte count and the destination size resolve to compile-time constants and the count exceeds the size. The fortified runtime aborts on that path, so given the call site is reached, the source-level copy is unconditionally out of bounds. The compiler emitted the _chk variant precisely because it could not prove safety; the detector inverts the direction of that proof.
oob-write.strcpy-chk-literal-overflow fires on the three-argument family (__strcpy_chk, __strcat_chk) when the source resolves to a string literal and strlen(literal) + 1 exceeds the destination size.
Both are High / Critical and resolve their arguments through the dual-dispatch seam, so they are no longer arm64-only.
Two IL detectors cover the writes that never touch a libc symbol. oob-write.loop-bounded.fixed-buffer keys on the IL shape of a write inside a natural loop — a strided indexed store, or a pointer-walk store the loop advances, which is the inlined rep movs or SSE form — with taint reaching the loop bound. That is the media- and codec-parser kernel-RCE shape (CVE-2022-22675 in AppleAVD's parseHRD, and the CoreAudio and SMB header-supplied-count families), invisible to any symbol-keyed sink because the copy is inlined or buried in a stripped helper. bounds.oob-write.object is the heap-object variant — the rule that proves the b[argc & 0x1f] store above lands 23 bytes past an 8-byte allocation.
The lattice's IndexedWrite sink emits when external input reaches the destination of a memcpy-class primitive or a sized strncpy or read without a dominating sanitiser; the parallel Read sink at the source argument emits CWE-119. The attacker-indexed write shape fires from CWE-129.
Rules — oob-write.memcpy-chk-proven, oob-write.strcpy-chk-literal-overflow, oob-write.loop-bounded.fixed-buffer, bounds.oob-write.object, taint.flow.dest-arg.<callee>.
CWE-789 — Memory Allocation with Excessive Size
Three rule shapes plus the dataflow sink. excessive-alloc.heap fires at allocator call sites whose size argument resolves to a compile-time constant above 1,000,000 bytes. excessive-alloc.heap-range fires on the same surface when the size is not a literal but the interval domain bounds it above the same figure. excessive-alloc.stack fires at function prologues allocating more than 7,500 bytes, read off the arm64 sub sp, sp, #F shape. Both thresholds are taken from cwe_checker's shipped defaults so cross-tool comparison is direct — 7,500 sits between PATH_MAX-class buffers around 4 KB and page-class allocations around 8 KB. The lattice's AllocSize sink covers the dynamic case the constant thresholds cannot see: attacker-controlled input reaching an allocator's size argument, the buffer-sized-from-an-attacker-length-prefix shape.
Rules — excessive-alloc.heap, excessive-alloc.heap-range, excessive-alloc.stack, taint.flow.alloc-size.
CWE-798 — Hard-coded Credentials
Two detectors. The string-scan variant flags shaped tokens with per-provider rules — AWS access key IDs and secret keys, GitHub and GitLab tokens, Slack, Stripe live keys, Discord bot tokens, Twilio, SendGrid, Heroku, npm, Google, OpenAI, Anthropic, JWT signatures, PEM private-key bodies, URL-embedded basic auth, and JDBC or Azure connection strings — at Medium confidence.
The MII prefix is what separates a real PEM key from a parser template. PEM keys are stored with embedded newlines and the strings extractor splits on them, so a -----BEGIN marker alone cannot distinguish the two; PKCS#1 and PKCS#8 RSA private keys always start with MII, so its presence somewhere in the strings means a real key and its absence means a template.
The byte-aware variant fires on strcmp, memcmp, and strncmp call sites where one argument resolves to a literal credential and the other is a runtime value — the compare-attacker-token-against-embedded-secret shape.
Rules — hardcoded-secret.pem.private-key, hardcoded-secret.pem.marker-only (Info), hardcoded-credential.use.{known-prefix,structural,uuid}.
CWE-825 — Expired Pointer Dereference / NULL-page Mapping
The W^X detector's second shape. When mmap is called with a NULL address, MAP_FIXED set, and a non-zero length, the mapping silently claims the NULL page — usually the first sign of a process that intends to make NULL dereferences executable. Resolved by strict register trace of the address and flags arguments. Critical / High. The same call site often co-fires the CWE-693 W^X finding when the protection is also write-plus-execute, and the composer then emits a single stack at elevated severity. Arm64-only today.
Rules — wx-protection.mmap.null-page-fixed, wx-protection.mmap.fixed-address for the non-NULL fixed case.
CWE-829 — Inclusion of Functionality from Untrusted Control Sphere (SMM callout)
The UEFI/SMM firmware detector, built on a dedicated platform model. An SMI handler runs at ring −2 inside SMRAM; if it — or anything reachable from it — calls through a Boot Services or Runtime Services pointer, the target code lives outside SMRAM, and an attacker who reclaims that memory after ExitBootServices executes code in System Management Mode. Detection is reachability plus call-target classification, not taint: SMI handler roots are discovered, the intra-binary call graph is walked from each root, and every reachable call is classified for its SMRAM side against the recovered services-table globals. Conservative throughout — an unresolvable call is unknown and never flagged. See VulHunt vs openbinary for the comparison against the firmware-specialist tools.
Rules — efi.smm-callout.
CWE-862 — Missing Authorization
Two modules. missing-auth.cgi-without-auth-verifier flags a CGI handler that executes a shell command with no preceding authentication-verifier call — the firmware-router cluster. missing-auth.dbus.no-polkit flags a Linux D-Bus interface registered with no PolicyKit authorization-check primitive imported, which is the shape behind CVE-2021-4034 (PwnKit, a KEV entry) and CVE-2021-3560, plus the systemd, NetworkManager, udisks2, and bluez method-handler tail. MITRE Top-25 #11.
Rules — missing-auth.cgi-without-auth-verifier, missing-auth.dbus.no-polkit.
CWE-863 — Incorrect Authorization
A C-API XPC server (xpc_main, xpc_connection_create_mach_service) with no peer-validation primitive in its imports. The C-XPC sibling of the CWE-345 detector: where 345 covers the Objective-C NSXPCConnection and NSXPCListener surface, this covers the lower-level libxpc C API. CVE-2024-44131 (FileProvider TCC bypass) and the bundle-ID-spoofing privilege-escalation tail are the reference cases. MITRE Top-25 #24.
Rules — xpc.c-api.no-audit-token-validation.
CWE-916 — Password Hash With Insufficient Computational Effort
Resolves the iteration-count argument at PBKDF2 derivation sites by strict register trace, across Apple's CCKeyDerivationPBKDF and OpenSSL's PKCS5_PBKDF2_HMAC and _SHA1. Three tiers:
| Iterations | Severity | Basis |
|---|---|---|
| under 1,000 | Critical | trivially brute-forceable — hours per password on one GPU |
| under 10,000 | High | below the NIST SP 800-132 floor |
| under 100,000 | Medium | below the OWASP 2023 baseline of 600,000 for SHA-256 — roughly months per password on the same hardware |
Rules — weak-pbkdf.{cccrypt,openssl}.iter-{critical,high,medium}.
CWE-918 — Server-Side Request Forgery
A binary-wide pre-gate (any URL-fetch primitive imported) plus a string scan for hardcoded cloud instance-metadata endpoints: 169.254.169.254 (AWS IMDSv1 and Azure), metadata.google.internal, 100.100.100.200 (Alibaba), 169.254.170.2 (AWS Fargate task metadata). These literals do not appear in normal application code — they signal either IMDSv1 deprecation lag (the Capital One / CVE-2019-15376 class) or SSRF-exploitation tooling. One finding per string, Critical on the credentials path and High otherwise. The lattice's Ssrf sink covers the flow-proven Win32 case. MITRE Top-25 #19.
Rules — ssrf.cloud-metadata.<provider>, with a -creds suffix on credential paths.
CWE-1240 — Cryptographic Primitive with a Risky Implementation
Algorithm-correct, mode-incorrect crypto. Fires when a CCCrypt call resolves its algorithm argument as AES and its options argument with the ECB bit (0x2) set. ECB leaks plaintext block patterns straight through the ciphertext — the Tux-image attack — and the fix is CBC or GCM. High confidence when both arguments resolve at the call site, High severity.
Rules — weak-crypto.aes-ecb-mode.
CWE-1284 — Improper Validation of Specified Quantity in Input
The lattice's tightest claim. When an external-input source is proven to flow specifically into a Quantity sink — the size argument of memcpy, memmove, bcopy, strncpy, strncat, strlcpy, strlcat, read, or write — with no dominating sanitiser, the detector emits at CWE-1284 rather than the looser CWE-120 a co-occurrence rule would tag. All three sanitiser shapes apply, and a candidate is suppressed when every member of its source set is dominated by a safe edge. The recv-then-snprintf-then-system and recv-then-memcpy chains behind the 2023 Zyxel pre-auth RCE family (CVE-2023-28771, CVE-2023-33009, CVE-2023-33010) are tracked end to end through this sink and the shell sink together.
Rules — taint.flow.quantity-arg.<callee>.
CWE-1395 — Dependency on Vulnerable Third-Party Component
The software-composition edge of the findings surface. The fingerprint layer recovers a library name and version from the binary itself — sonames, version banners — and this detector matches each pair against 30 curated vulnerable or end-of-life version floors across 21 components common in firmware: seven OpenSSL era rules from pre-1.0.0 end-of-life through Heartbleed to the 1.1.1 EOL, plus libcrypto, busybox, dnsmasq, dropbear, expat, glibc, musl, uClibc, bionic, gnutls, hostapd, libblkid, libssh, libssh2, libxml2, the Linux kernel, PHP, samba, wpa_supplicant, and the two xz backdoor releases. Each match names the canonical CVEs. Deliberately conservative: every rule pins the vulnerable range's upper bound, and a version that does not parse does not fire. Distinct from the firmware-tree SBOM surface (CVEs & SBOM) — this fires on the analysed binary's own linked components, on the same findings stream as every other rule.
Rules — vulnerable-component.<name>.<era>.
Quality gates
The fixture corpus. Recall and precision are measured on one corpus: 63 compiled fixtures — 33 positive and 30 negative — shipping as contrastive twins, a vulnerable variant and a -safe twin, each pinning the rule family it expects. A miss on a positive reds the gate; a fire on a -safe twin reds it the same way. A separate 362-case, language-tagged source corpus drives the source-SAST gates over the same shared rule catalog.
Scoring runs by CWE id rather than by rule id, because a binary finding carries an address, not a source line, and because a curated family accept-set (121 covers 787 covers 120; 416 covers 415; 78 and 94 are interchangeable; 129 and 787 are the read and write faces of one shape) stops a correct sibling-CWE finding from scoring as a miss. A clean fixture counts as a false positive only when a High-confidence finding fires; Medium or Low on a clean fixture is tracked debt, not a red.
Run over the current tree, all 63 fixtures score and eighteen distinct CWEs have at least one fixture that actually fires:
cwe-bench — 63 scored, 0 skipped (of 63 cases)
CWE pos neg recall fp_rate
22 1 1 100% 0%
78 6 3 100% 0%
...
787 9 9 89% 22%
918 1 1 100% 0%
gate: 0 regression(s), 3 tracked
· known gap CWE-787 cwe-787-global-oob-macho
· known FP CWE-787 cwe-787-copy-safe
· known FP CWE-787 cwe-787-strcpy-safe
Seventeen of the eighteen sit at 100% recall and a 0% false-positive rate on their own fixtures. The eighteenth is CWE-787 — the class of the heap out-of-bounds write above, the class with by far the most fixtures, and the only one carrying real error in both directions: one positive it misses, two -safe twins it fires on. Every fixture in the corpus is scored, so those three are visible rather than absent; the gate passes because each is tagged, and a tag means the failure is accepted, not fixed.
That is eighteen CWEs with a firing fixture out of the seventy-two the detectors can emit. The other fifty-four have no contrastive twin in this corpus. Some are pinned another way — the per-binary caps hold several of them against real reference binaries — but a cap proves a count did not move, not that the detector catches a bug and stays quiet on the safe version of it.
The coverage script. A coverage script keeps that arithmetic visible by rendering three deliberately unreconciled columns. Each column has a measured blind spot:
| Column | Source | Prints today | Blind spot |
|---|---|---|---|
| claimed | detector source scraped with a literal cwe_id: <N> regex | 68 CWEs across 91 modules | misses four of the 72: CWE-319 reaches the binary engine only through a shared TOML catalog row the scrape never reads; CWE-285 has no detector; CWE-129 and CWE-825 are assigned through a computed variable — the array-index detector picks 129 or 787 by read-versus-write, the mmap rule picks 825 or 693 by NULL-page-versus-W^X — which a literal regex cannot see |
| target | the curated set of CWEs judged meaningfully detectable on a compiled binary | 57 CWEs | 20 claimed ids sit outside it, so "extra" is the normal state rather than an error |
| proven | whether a fixture actually fires | 0 CWEs | it does not read the cwe-bench result, so it under-reports all 18 CWEs that have a firing fixture |
CWE-319 is therefore printed as an undetected gap — a target with no detector — while the catalog routes SSL_CTX_set_verify to the binary engine and a finding for it renders with no CWE name, because 319 is also one of the twelve ids missing from the name registry. One instrument miscounts, another renders blank, and the detector works.
Tagged failures. A positive fixture tagged as an accepted gap that stays inert is reported as tracked debt instead of failing the run; a negative tagged as an accepted false positive that fires is treated the same way. Neither tag asserts anything about the other direction: a tagged gap that starts firing scores as an ordinary true positive and passes silently, so a loosening that fires the right CWE for the wrong reason is not caught here.
Nothing in the binary harness can express the check that would catch it. A case carries only its name, CWE, polarity, architecture, expected detector, and the two tags — no field for a rule id that must not fire. The only zero-findings assertion anywhere is the source-side probe harness's ok: marker, which pins zero findings within a source line span across the source corpus; it has no binary equivalent, because a binary finding carries an address, not a line.
Per-binary regression caps. Every release runs the detector matrix across a fixed set of real binaries — 16 fixture paths for the stack-overflow proof alone, spanning Mach-O arm64 and x86_64, ELF arm64 and x86_64, and PE x86_64 builds of the same four programs, of which 15 are present today — and holds each rule family under a per-binary ceiling calibrated as the measured baseline plus headroom. Whole-binary totals on the four Mach-O arm64 fixtures, measured on the current tree:
airportd 102 findings taint family 5 (cap 7)
curl 153 taint family 62 (cap 64)
jq 342 taint family 108 (cap 119)
ripgrep 25 taint family 0 (pinned 0)
Per-rule caps sit alongside the aggregate so a loosening cannot hide behind an offsetting drop elsewhere — path traversal alone carries airportd 2, curl 22, jq 2, ripgrep 0. Each cap constant carries the date and the reason for its last move, so a re-pin is an argument rather than a number bump.
The ripgrep zeros are not precision. Ripgrep is a Rust binary and the lattice skips it wholesale; the cap exists to catch the skip being dropped, which would produce 2,326 findings.
jq's 342 are dominated by one Low-confidence rule (115 manual-buffer-copy hits) and one heuristic family (16 double-frees); airportd's 102 are dominated by 39 os_log disclosure hits. A raw count is not a quality measurement, which is why the caps are per rule family and why reachable_from_main exists.
False positives and false negatives are both expected. Static analysis over stripped binaries is over-approximation by construction: calls reachable only through indirect dispatch, dynamic class lookup, or unresolved jump tables are misses, and patterns matching a dangerous shape while guarded by a runtime predicate the analyser cannot see are spurious fires. The gates keep the rate measurable; the per-CWE entries above name the specific limitation per detector.
Comparison with other tools
Static binary CWE detection is a small field.
| Tool | Approach | Coverage and limits |
|---|---|---|
| cwe_checker (Fraunhofer FKIE) | open-source, Ghidra-backed abstract interpretation over Pcode | 18 CWE checks with a documented false-positive and false-negative posture per check; architecture coverage inherited from Ghidra's universal lift |
| BinAbsInspector (Tencent KeenLab) | Ghidra-backed abstract-interpretation framework | strong on x86_64 firmware, thin on macOS-specific surfaces |
| Mayhem, ForAllSecure | fuzzing-oriented, classifying crash types back to CWEs after the fact | dynamic exploration rather than static proof — complementary rather than competing |
| BinSkim (Microsoft) | code-signing and hardening audit, done well | PE-only and posture-focused, with no semantic analysis of binary content |
| GrammaTech CodeSentry, Black Duck Binary Analysis, Veracode | mostly SBOM plus CVE-by-version-string with a thin static layer | not direct CWE detection |
cwe_checker is the closest peer, and the overlap is audited module by module — the dangerous-calls, chroot-jail, umask, sizeof-pointer, excessive-alloc, call-order, unchecked-return, use-after-free classification, buffer-overflow object model, interprocedural taint sink, and taint sink table each cite the cwe_checker module they mirror, and the CWE-789 thresholds are matched to its defaults so cross-tool comparison is direct. Its architectural lesson is stated but unevenly applied: never reconstruct a call argument or re-derive an analysis fact inside a detector, because a detector that re-decodes MOVZ or ADRP out of the text section to find a constant is silently arm64-only. Six modules honour it and read the engine-produced argument; the rest still re-decode, and are arm64-only exactly as the rule predicts.
Three mechanisms here have no counterpart in that list.
- The fortified-
_chkinversion — when the compiler emits a_chkvariant it has already admitted it could not prove safety, and it injected the destination size as an argument. Reading both arguments back out proves unsafety arithmetically, covering the Top-25's number one and number six classes with no taint and no symbolic execution. - Sanitiser dominator analysis — the lattice does not merely propagate taint, it records which sanitiser shape touched which source and suppresses a candidate only when every source is provably bounded before the sink.
- A shared rule catalog across binary and source — the sink tables and CWE mapping are one set of data that the binary engine and the source walkers both read, ten languages dispatched by file extension in production, so
system(user_input)fires the same topic whether the input was a C file or a compiled ELF.
What is not claimed:
- It does not replace dynamic analysis: a fuzzer reaches paths no static analyser sees.
- It produces no CVSS scores: severity is a per-finding hint, not a quantitative risk metric.
- Most byte-aware argument resolution remains arm64-only. Six detector modules route through the portable seam; every other call-site argument rule decodes arm64 bytes directly, including the stack-frame overrun proof, CWE-326, the CWE-825 W^X argument resolution, and
PT_DENY_ATTACH. - There is no cross-detector confidence promotion: two detectors at the same caller compose into a compound but never re-grade each other.
- Pointer taint does not run on CIL, JVM,
pycorluac, which is a category decision rather than a gap. - The WebAssembly arm has no feature path from any shipping crate, and the Dalvik arm is additionally pinned out of the taint matrix, so neither fires even with its feature enabled.
- The finding-count numbers above are per-fixture measurements on a checked-in corpus, not a claim about binaries in general.
Limits of a finding
A finding lists what was detected, where, with what confidence, and at what severity. It does not assert that the binary is exploitable, vulnerable in production, or in scope for any compliance regime. Those are judgments that depend on the deployment environment, on reachability from untrusted sources, on the secrecy of any embedded credential, and on the threat model — none of which is in the file.
Product surfaces are where judgment gets applied: grouping by binary, CWE, and severity; baselining against a known-good build so a CI run reports only what a change introduced; ranking by reachability from XPC entry points and exported symbols; tracking resolution status across releases; alerting when a known-exploited CVE shape matches a finding in a corpus.
The fence between facts and judgment is deliberate. Anything that depends on judgment lives in the surface. Anything that depends on truth lives in the engine.
SARIF export
Findings export as SARIF 2.1.0 — one run, one tool driver, rules as the union of every rule that fired, CWE expressed on the rule with a MITRE relationship link, and ATT&CK as a taxonomy component shared with the capability and secret findings. The SARIF path runs no analysis of its own; it is a projection of the already-computed findings envelope, which is how these records land in GitHub code scanning, IDE viewers, and anything else that speaks the interchange format.
Related briefs
Findings are one of four parallel security surfaces over the same input. Indicators tag what the binary does (capability plus MITRE ATT&CK), Malware classifies whether it is malicious (a four-tier verdict over composed signals), and CVEs & SBOM flag known-vulnerable component versions (version-based, not code-shape). All four walk the same lifted analysis and emit records of compatible shape. Security is the orientation map; Engine covers the analysis that produces the facts these rules read.