Security
lsns, the util-linux tool that lists namespaces — a 67,752-byte stripped arm64 ELF — produces 42 CWE findings: one critical, six high, spread across ten CWE classes. Downloader.exe, a 7,680-byte PE catalogued on MalwareBazaar as StormKitty since 9 January 2023, produces five: no Control Flow Guard, no stack cookie, no Authenticode signature, no CET shadow stack, no load-time integrity check. Every one of the trojan's five findings is a complaint about its build flags. None of them is about what it does.
That is not a scanner failing. It is two different questions asked of two different files, and only one of them was ever "is this thing hostile?" Four such questions get asked of a compiled binary, and their answers move independently of each other.
| Question | Answered in | A hit is | A hit is not |
|---|---|---|---|
| What is wrong with this code? | Findings — MITRE CWE | a call site where a defect class is structurally present, with rule id, severity, confidence | proof of exploitability; that needs a deployment and a threat model the engine does not have |
| What does this binary do? | Capabilities and Indicators — capability catalog, capa categories, MITRE ATT&CK | a named behavior with its evidence and technique ids | a judgment — a stealer's recon sweep and lsns earn the same discovery.files_walk_stat row |
| Is this binary malicious? | Malware | one label plus the point ledger that produced it, every row citing a rule and a call site | attribution — a family row reports strings shared with samples labelled X, never that this is X |
| Does it bundle something already known-vulnerable? | CVEs & SBOM — MITRE CVE | a component, a version, and the published affected ranges that version falls inside | a statement about the binary's own code |
Verdict labels
The verdict carries five labels: Benign, Suspicious, LikelyMalicious, Malicious, and NotEnoughEvidence. The fifth is an abstention with two independent causes, because neither a crashed analysis nor a barely-analyzed one may serialize as the same clean answer a binary earns by being analyzed with nothing found.
| NotEnoughEvidence fires when | The condition |
|---|---|
| The findings-and-malware pass never completed | the assessment did not run to completion — an engine panic leaves the threat block at its default |
| Coverage was too thin to stand behind any verdict | unsupported architecture; or the file reads as packed, or as tiny-imports-plus-high-entropy code; or no functions were discovered at all; or under 8 functions were discovered and under 5% of them carry a name |
On a packed sample the coverage gate is the dominant path, and it never downgrades a real detection: a Malicious tier, a LikelyMalicious tier reached by a proven or definitive ledger row, and an independent anomaly flag are each exempt. A missing coverage block reads as sufficient — abstention needs an affirmative thin-coverage signal, never an absent field.
Source code
Source files share the binary side's CWE taxonomy, finding schema and rule catalog (Source SAST): system(user_input) fires the same CWE-78 topic whether the C file or the compiled ELF was uploaded. The shared catalog is nonetheless a source-side asset — the binary engine's real detector surface is hand-written Rust, not the catalog.
| Count | What it counts |
|---|---|
| 10 | source languages dispatched by file extension in production |
| 9 | further walkers that exist, reachable only from the evaluation harness |
| 1,190 | catalog rows carrying a detector list |
| 109 | of those 1,190 naming the binary engine, 9% — every other row is source-only |
| 106 | catalog rows naming more than one engine |
| 96 | of those 106: libc primitives split between C source and compiled binaries — literally the same row firing on both |
| 10 | the remaining multi-engine rows: 9 shared Java/Kotlin, 1 spanning binary, C and Python |
Catalog depth is uneven by language, and the thin end is where a source scan will miss things:
| Engine | Catalog rows |
|---|---|
| source-python | 321 |
| source-php | 223 |
| source-javascript | 201 |
| source-c | 123 |
| binary | 109 |
| source-ruby | 107 |
| source-java | 100 |
| source-go | 74 |
| source-swift | 22 |
| source-kotlin | 9 |
| source-csharp | 8 |
Worked example: lsns
lsns fires 42 findings across ten CWE classes, one indicator, no capabilities, and reads Benign:
sha256 001037f69f7c75a77e5e13b7f95e679627f9fc9477b789ffe0ffb4b39dfa7a6e
ELF arm64, 67,752 bytes, stripped, glibc
findings 42 CWE-134 ×17 CWE-252 ×7 CWE-787 ×4 CWE-209 ×3 CWE-782 ×3
CWE-125 ×2 CWE-415 ×2 CWE-693 ×2 CWE-367 ×1 CWE-15 ×1
1 critical, 6 high, 29 medium, 1 low, 5 info
reachable from entry point: 0 unreachable: 38 unknown: 4
indicators 1 discovery.files_walk_stat — T1083 File and Directory Discovery
evidence: imported `fstatat`
capabilities 0
verdict Benign
The one critical finding is a compound: seven independent detectors converging on the same function at 0x2a00, spanning five CWE classes — CWE-15, CWE-125, CWE-134, CWE-252 and CWE-787. A compound emits one tier above the strongest finding it composes, capped at Critical, so a Critical compound needs a High constituent — lsns has six. Two cases suppress that escalation and emit at the strongest constituent's own severity: a caller with 45 or more outgoing calls, where convergence reflects caller complexity rather than corroboration, and a stack of entirely low-confidence constituents.
The single indicator, discovery.files_walk_stat, is a structural shape and not a judgment. It is opendir plus readdir plus a per-entry stat companion — the read-directory-then-stat-each-entry loop. That is exactly the ATT&CK technique a stealer earns for its recon sweep, and lsns earns it for the same structural reason: it walks /proc and stats what it finds. The indicator surface reports the shape and stops. Nothing in the verdict moved.
The triage filter fails on this file: reachable_from_main is the field that turns 42 findings into a short list, and here the entry-point closure was computed, placed 38 of the 42 outside it, and left 4 unmeasured — so it orders nothing. The binary is stripped, every caller is an address (sub_2a00, sub_4f90), and its table-output path registers callbacks with libsmartcols.
Indirect calls are not a blanket blind spot — the closure traverses most of them and names what it drops:
| Indirect edge | In the closure |
|---|---|
| 16 recovered kinds: jump tables, C++ and Objective-C devirtualization, blocks, function-pointer constants and function-pointer tables, GOT and import binds, P/Invoke thunks, hash-resolved API calls, heap function-pointer fields, XPC handlers, targets recovered by emulation or by value-range analysis | traversed like any direct edge |
| An indirect call that resolved to no target | dropped |
A callback registered through an API outside the curated 14: pthread_create, atexit, at_quick_exit, qsort, qsort_r, bsearch, the four dispatch_*_f variants, CreateThread, the three signal-family entry points | dropped — the callee never becomes a node |
scols_column_set_wrapfunc is not among the 14, so the call to it resolves while the function lsns hands it never becomes a node. sigaction is excluded for a different reason: its handler lives in a struct field rather than a register argument.
A global reliability gate decides whether the field is stamped at all. Entry roots come from seven tiers — the format's own entry point, main, daemon run-loop callers, __libc_start_main callees, .init_array and TLS constructors, exports, and C++ exception landing pads — and a closure covering under 10% of the call graph's caller nodes leaves reachable_from_main unset on every finding rather than stamped from a closure that reached almost nothing. So unreachable means the closure was judged reliable and this caller sat outside it; unset means the question was not answered (Findings).
Worked example: the StormKitty downloader
The StormKitty downloader fires five findings — all of them build posture, none a code defect — plus nine capabilities that between them quote a live payload URL and name reflective stage-two execution, and it still reads Benign:
sha256 000a12ffb6c5dfcc0fada157d40500965861ca5e63d4f44fa4a679f7024c6f69
PE x86, 7,680 bytes, .NET, unsigned
findings 5 posture.pe.no-cfg posture.pe.no-gs-cookie posture.pe.unsigned
posture.pe.no-cet-shadow-stack posture.pe.no-force-integrity
CWE-693 ×3, CWE-345 ×2 — all build-posture, no code defect
indicators 0
capabilities 9
cdn-hosted-payload.fetch-remote-payload
T1105 Ingress Tool Transfer, T1102 Web Service
evidence: the string
https://cdn.discordapp.com/attachments/1053623990066876427/
1054133763724021820/Xumrybezs.dat
co-located with the download primitive HttpWebRequest
nursery-load-code.load-net-assembly T1620
nursery-load-code.invoke-net-assembly-method T1620
nursery-data-manipulation.compress-data-using-
gzip-in-net T1560.002
communication.send-http-request
communication.create-http-request
communication.receive-http-response
communication.send-data
host-interaction.suspend-thread
verdict Benign
Read the findings alone and this file is a slightly under-hardened build. Read the capabilities and it is a stager: it fetches stage two from an allowlist-friendly host, runs it through a .NET GZip stream, loads it as a .NET assembly and invokes a method on it. The two T1620 rows are the stronger evidence — reflective execution of code that arrived over the wire — and carry no more verdict weight than the URL does. Reading one surface and calling it a security review is how a 7.5 KB downloader passes.
Surface availability
Three of the four surfaces are open to any caller who can see the binary; the findings surface is not, and no plan buys it.
| Surface | Gate | Below the gate |
|---|---|---|
| Capabilities | visibility only, no feature flag | nothing to fall below — a caller who cannot see the binary gets a not-found |
| Malware and the threat ledger | Free floor | same |
| CVEs & SBOM | Free floor | Free reads per-severity counts and an upgrade hint; per-CVE detail is Pro |
| Findings and embedded secrets | operator-issued grant only | absent, including from the SARIF export — Pro does not clear the floor, and neither does anything else sold self-serve |
A second gate crosses the whole binary envelope rather than one surface: a Free or anonymous caller reading a binary whose architecture is outside x86, x86-64, arm and arm64 gets counts only, with no analysis in the envelope. Both worked examples sit inside that set; MIPS, PowerPC and RISC-V firmware do not. An absent arch string or the literal unknown fails open; every other unrecognized value fails closed.
Reading order by task
Your own code starts at findings; a third-party binary starts at capabilities and indicators; a bundled-component image starts at CVEs; a pipeline gate rides the SARIF export. Each order carries its own caveats.
| Task | Order | The catch |
|---|---|---|
| Auditing your own code before shipping | Findings | the surface is grant-only — no plan tier clears it; severity carries no deployment context; reachable_from_main only filters when the closure clears the 10% reliability gate |
| Auditing a third-party binary | Capabilities and Indicators → Malware → Findings | findings describe code quality, which says little about intent |
| Auditing firmware or a bundled-component image | CVEs & SBOM first; findings, indicators and malware still run | component identification is what makes a several-thousand-file image tractable, but CVE rows carry no reachability at all, and a MIPS/PowerPC/RISC-V image is Pro-view-gated |
| Wiring a pipeline | SARIF 2.1.0 export | an ungated caller gets a valid, empty document rather than an error; there is no built-in diff against a known-good build |
Auditing your own code before shipping. Findings is the surface. The catalog covers the memory-safety, crypto, injection, identity and build-posture classes that come up in review. Treat the output as facts about the code and apply your own deployment context; reachable_from_main is the highest-leverage filter when the closure is reliable, and on a stripped lsns it was computed and placed 38 of the 42 findings outside it — a short list of 0. The per-CWE pages document each rule's confidence and false-positive posture.
Auditing a third-party binary. Capabilities and Indicators are the first surfaces — what it does, before what is wrong with it. Then Malware for the label and the ledger that produced it. Findings come last here: they describe code quality, and the StormKitty case shows how little that says about intent.
Auditing firmware or a container of bundled components. CVEs & SBOM is the surface, and identification is what makes an image of several thousand files tractable. Most of it comes from the image's own package metadata: eleven package-manager and lockfile readers — apk, dpkg, opkg, rpm (including the raw rpm header), npm, PyPI, Packagist, RubyGems, Go, Cargo and Maven. Identified components then match against an operator-fetched advisory database: CVE-List and OSV affected-version ranges across thirteen ecosystems, enriched with CVSS, EPSS and CISA KEV.
The curated fallback plane, for images with no package database, is small — not broad upstream coverage:
| Curated table | Entries | What it covers |
|---|---|---|
| Version banners scanned out of a binary | 7 rules | 6 upstream products — busybox, openssl, dropbear (two symbol prefixes), dnsmasq, the Linux kernel, openssh |
| Path patterns identifying a component by file location | 6 patterns | 2 Zyxel-specific (weblogin.cgi, zysh) and 4 generic (samba, apache-httpd, cups, ipsec/pluto) |
| Firmware-version CVE rules | 8 rules | Zyxel model strings only — AAZF.4/5/6, 7.3.245.300, V4.6/4.70/4.71/4.72 |
Findings, indicators and malware still run on every member. Reachability on a CVE row is a declared stub, always unset, so the filter the findings surface leans on does not exist here.
Wiring a pipeline. The SARIF 2.1.0 export is the surface, so a gate rides GitHub code scanning or any SARIF-speaking consumer rather than bespoke JSON. A binary's document carries CWE findings, secrets, CVE rows, capability hits and the threat ledger; source-file findings are a separate document, and no route fuses the two. Rule ids are stable string identifiers rather than positional indexes, which is what makes suppression and baselining possible on the consumer's side — the export emits no baseline state of its own and there is no built-in diff against a known-good build. Two binaries can be compared directly, and the comparison diffs indicator hits, attributed families, secret findings and the ATT&CK-tactic-organized capability inventory; a capability present in v1.1 and absent in v1.0 is the alertable event. The verdict is not on that surface — the compare response carries no tier, so a Benign-to-Malicious move must be read off the binary detail.
Three limits decide whether a gate is telling the truth:
| Limit | Value | Consequence |
|---|---|---|
| Gated families are dropped, not errored | — | no findings grant means no CWE or secret results, no CVE feature means no CVE results — the document stays valid and the gate passes green on it |
| Results per run | 25,000 | beyond that, results are kept by severity rank and the run reports an omitted count |
| Members per package document | 1,024 | a several-thousand-file firmware image — the case this export is most wanted for — is truncated |
Capability promotion to the verdict
Of the 923 entries the capability catalog holds, a capability moves the verdict only if its id appears in a curated promotion table; every other entry is enrichment worth zero points.
| Rule group | Count | Effect on the verdict |
|---|---|---|
| Capability ids in the promotion table | exactly 35, spanning 23 families | carries weight, at the band of the hand-written detector it replaced |
| Every other capability entry in the catalog | the balance of the 923 | none — a recorded fact worth zero points |
| Hand-written Rust detectors, never moved to the catalog | roughly 35 | unaffected by promotion; they stay in Rust |
35 promoted ids do not mean up to 35 ledger rows. Three guardrails stand between a fired capability and a point:
| Guardrail | Effect |
|---|---|
A capability id is family.shape, and promoted ids sharing a family collapse into one row carrying the highest band that fired | ceiling of 23 rows; a family authored as a strong shape plus a broader weak one reproduces the retired detector's per-shape banding rather than double-counting it |
| A capability marked ineligible for scoring, set on verified platform-signed binaries | hard-excluded, not down-weighted — on a platform-signed binary the promotion path is inert entirely |
| Capabilities never fire inside code matched as a known static library | a vulnerable-looking shape vendored in from a linked library scores nothing |
cdn-hosted-payload.fetch-remote-payload fired correctly on the StormKitty sample, named the technique, and quoted the Discord CDN URL — and the stored verdict is still Benign, because that capability id is not one of the 35. Letting every catalog rule score instead reopens the individually-scored path the verdict was recalibrated away from, where a loose capability rule floors a benign binary that a tighter detector's corroboration would have spared.
That rule carries its own measurement, taken 3 August 2026: 9 hits across the 1,723 malware samples then in the corpus, spanning seven distinct families, and 0 across 82 benign binaries. The corpus has since grown to 1,729; the measurement has not been re-run against the six additions. A looser arm keyed on a CDN host plus a payload-shaped file extension was cut — it added one malware sample the strict arm already caught, and it was the only arm producing benign matches, on a shell script and a README fetching pinned test fixtures from raw.githubusercontent.com. Fetching from a public CDN stays dual-use in principle, which is the other reason the id was never promoted.
Promotion is frozen at those 35 ids. The stated condition for resuming is a standing recall-and-benign-false-positive gate over the full malware corpus wired into CI. That gate does not exist, so each further promotion would rest on an ad-hoc local run that leaves no regression fence behind it.
False positives on known-clean binaries
The false-positive surface is measurable without a labelled set, by crossing a CWE with a known-clean provenance: a curated distribution binary firing a memory-corruption class is a false-positive candidate by construction.
| Rule class | Firing, of 1,475 Linux-distro-packaged corpus binaries | Share of the curated set |
|---|---|---|
| CWE-787 out-of-bounds write | 718 | very close to half |
| CWE-125 out-of-bounds read | 732 | very close to half |
Ranking the rules by how heavily they fire on that known-clean set ranks the over-firing rules, and that ranking is the working triage queue.
Out of scope
Five answers are deliberately not produced, and reading the output as though they were is the standard error.
Severity is not CVSS. The severity on a finding is a per-instance hint about the runtime context around a source-level bug. It is not a risk score and does not compose into one, because the inputs CVSS needs are deployment facts the engine cannot see. CVE rows are the exception and carry upstream CVSS, EPSS and KEV verbatim, because those describe a published advisory rather than this binary.
No remediation guidance, except on build posture. 18 build-posture rules close their summary with the exact flag that fixes them — /GUARD:CF, /GS (default for MSVC), signtool.exe, -Wl,-z,relro,-z,now for full RELRO, -fPIE -fpie linked -pie, -D_FORTIFY_SOURCE=2 -O1 — and all five of StormKitty's findings carry one. Nothing else does: a code-defect finding states what is present, not how to patch it, and CVE detail is the upstream fields — name, severity, description — with no fix instructions and no "upgrade to version X".
No feed fetch during analysis. Nothing is fetched at analysis time, so the same bytes against the same snapshot give the same answer. The ATT&CK technique catalog is compiled in as three tables over one pinned MITRE CTI snapshot — 430 macOS-relevant techniques, 835 full-enterprise, and a 712-technique native-binary denominator, 1,977 rows in all — and the curated firmware CVE tables are in-tree constants; neither moves without a rebuild. The advisory database is a local snapshot the operator refreshes on a schedule that defaults to off.
No dynamic analysis. Nothing is executed on a CPU, sandboxed, hooked or instrumented. A sample's own decryptor is interpreted in a bounded IL emulator to recover the strings and configuration it hides (Emulator), and that is not a runtime trace: no syscall reaches a kernel, and no branch is taken because of a real environment. Excluding its shared-library rules, capa's pinned snapshot declares a dynamic scope of call on 271 rules and span of calls on 410; none of that class of evidence is reproduced here. A fuzzer reaches paths a static analyzer never will.
No automatic family attribution. A family fingerprint reports strings shared with samples attributed to family X. It never claims the binary is family X, and a nearest-neighbour attribution alone is capped below the top tier so it can never carry a Malicious verdict on its own.
The shared analysis record
All four surfaces read one analysis record. The same recovered call graph, the same SSA-form IL, the same constant-propagation results, the same string and import inventories, the same code-signature parse feed the CWE detectors, the capability matcher, the verdict ledger and the component extractor. Almost none of that record is stated in the file — the call graph, the function boundaries and the argument values are all recovered (Engine). The taxonomies above it differ; the input does not.