Skip to content

Commit b32eb72

Browse files
committed
AArch64 thread-through + Mach-O end-to-end
1 parent d811f4c commit b32eb72

13 files changed

Lines changed: 1239 additions & 154 deletions

File tree

CHANGELOG.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,181 @@
33
All notable changes to **capa** are documented here.
44
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## [0.5.0] — AArch64 thread-through
7+
8+
Threads the AArch64 surface from smda 0.6.x into the analysis
9+
pipeline. No API breaks at the `AnalyzeBuilder` / `FileCapabilities`
10+
level; the `Extractor` trait gains one new method (`arch()`) so
11+
downstream consumers implementing the trait need to add it.
12+
13+
### Dependency bumps
14+
15+
- **smda 0.5 → 0.6.** Picks up AArch64 (Apple-silicon Mach-O,
16+
Linux EM_AARCH64, Windows ARM64 PE), the seven AArch64 analyser
17+
ports (jump-table, indirect-call, tail-call, .pdata, NOP,
18+
exit-syscall, is_api_thunk), ELF GOT/PLT API resolution, and
19+
the 0.6.2 hygiene patches. Semver-compatible with all future
20+
0.6.x.
21+
22+
### Added
23+
24+
- typed iced operand walking in `extract_insn_offset_features`.
25+
Pre-0.5.0 split formatted operand strings on commas and ran
26+
regexes; missed offsets buried in SIB-displacement forms like
27+
`[rax + rcx*4 + 0x10]`. Now consults `op_kind(i)`, `memory_base()`,
28+
and `memory_displacement64()`
29+
- real `translate_com_features` impl backed by a generated
30+
~29 k-entry GUID database (`src/rules/com_db.rs`, ~5.8 MB) for
31+
every COM class and interface known to upstream Python capa.
32+
Rule-load-time rewrite of `com/class: WbemLocator`
33+
`or: [bytes: <guid1>, bytes: <guid2>]`. Database regenerated via
34+
`scripts/gen_com_tables.py`.
35+
- scope-keyed feature dump
36+
(`map_features_by_scope: HashMap<&'static str, …>`) so the
37+
`--map-features` CLI flag surfaces feature counts per scope
38+
(file / function / basic_block / instruction). New CLI summary
39+
table in `capa_cli`.
40+
41+
### Added — AArch64 thread-through (task #236)
42+
43+
- **`Extractor::arch()`** trait method. Both `Smda` and `DnFile`
44+
impls provided. Surfaces the real `FileArchitecture` from the
45+
underlying disassembler instead of the previous bitness-based
46+
guess.
47+
- **`FileCapabilities::get_arch`** now calls `extractor.arch()`
48+
instead of mapping `bitness == 64 → AMD64` unconditionally —
49+
which had been silently mislabelling every AArch64 binary that
50+
smda 0.6 started producing.
51+
- **`extract_insn_mnemonic_features`** branches on
52+
`Instruction::mnemonic_aarch64()` so ARM64 instructions emit
53+
their disarm64 mnemonic (`ldr`, `str`, `bl`, …) instead of the
54+
iced sentinel string `"invalid"`. Rules `mnemonic: ldr` etc.
55+
now fire on ARM64 binaries.
56+
57+
### Added — AArch64 instruction-scope feature parity
58+
59+
- **`extract_insn_offset_features` ARM64 path.** Routes through
60+
smda 0.6's `disassembler::aarch64_ops` decoders instead of the
61+
x86-only iced operand walk (which returned sentinel/zero on
62+
ARM64 and silently dropped every offset feature). Now emits:
63+
- `LDR/STR Xt, [Xn, #imm12]``Offset(imm12)` +
64+
`OperandOffset(1, imm12)` — base = SP (R31) / X29 (frame
65+
pointer) skipped, matching the x86 path's EBP/RBP filter.
66+
- `ADR Xd, label``Number(label_va)` — mirrors
67+
x86 LEA's "constant address into register" emission.
68+
- `ADRP Xd, page``Number(page_va)` — same shape
69+
at 4 KiB granularity (compilers emit ADRP+ADD/LDR to
70+
materialise a full address; the page VA is close enough for
71+
the typical "constant in code" pattern that `number:` rules
72+
look for).
73+
- **`extract_insn_peb_access_characteristic_features` ARM64
74+
path.** Windows on ARM64 reserves x18 as the TEB pointer
75+
(Microsoft "platform register" ABI; PEB lives at
76+
`[x18 + 0x60]`, mirroring `gs:[0x60]` on x64). Loads with
77+
base = x18 are flagged as `peb access` regardless of
78+
displacement — same "touches TEB" granularity as the x86
79+
`fs:`/`gs:` segment-read match. Stores are excluded; writing
80+
through x18 is exotic and doesn't pattern-match the rule.
81+
82+
### Fixed — Mach-O closeout (audit findings)
83+
84+
- **Fat (universal) Mach-O now routed to smda instead of
85+
rejected.** `is_macho_magic` previously excluded `cafebabe` /
86+
`cafebabf` (and their byte-swapped variants), so every
87+
Apple-Silicon system binary that ships as a universal binary
88+
(`/bin/ls`, `/usr/bin/file`, …) returned
89+
`UnsupportedFormatError` even though smda's
90+
`extract_macho_with_offset` already has fat-slice selection
91+
wired up. Now accepted; smda picks the matching slice via
92+
the `MachoArchPreference::HostNative` default. Caveat: the
93+
`cafebabe` magic is also Java `.class` file magic — goblin's
94+
`Mach::parse` rejects class files cleanly (the nfat_arch
95+
sanity check fails), so a misrouted `.class` surfaces as a
96+
parse error rather than silent misinterpretation.
97+
- **`security::get_security_checks` no longer aborts on
98+
Mach-O.** Pre-0.5.0 returned `UnsupportedBinaryFormat` for
99+
every Mach-O input, which `from_file` propagates via `?`
100+
so even with the fat-Mach-O fix above, no Mach-O could
101+
actually reach the capability engine. The PE/ELF security
102+
checklist (ASLR, DEP, SEH, CHECKSUM, RELRO, CANARY, …) has
103+
no 1:1 equivalent on Darwin, so the path now returns an
104+
empty `Vec` and lets analysis continue — mirrors how
105+
`from_buffer` skips security checks for shellcode.
106+
- **`extract_insn_nzxor_characteristic_features` ARM64 path.**
107+
Pre-0.5.0 the function checked iced's x86 `Mnemonic` enum
108+
only, which is `INVALID` on AArch64 decodes — so the `nzxor`
109+
characteristic never fired on ARM64 binaries. Now branches
110+
on `insn.decoded` and detects AArch64 `EOR` / `EOR3` /
111+
`EORS` via `mnemonic_aarch64()`, with a self-XOR exclusion
112+
(`Rn == Rm`, the AArch64 zeroing idiom) mirroring the x86
113+
`dst == src` filter. Security-cookie filter is skipped on
114+
ARM64 — the RBP-relative cookie pattern doesn't exist
115+
verbatim (Darwin uses `__stack_chk_guard` loads instead).
116+
- **Mach-O OS now reports `MACOS`, not the `LINUX`
117+
placeholder.** Added `Os::MACOS` and `Os::IOS` to the
118+
public `consts::Os` enum (additive — non-breaking for
119+
matchers using `_`). `extract_os` + `get_os` route Mach-O
120+
through `MACOS`. Rules `os: macos` now fire on Mach-O
121+
input; `os: linux` rules no longer fire incorrectly. iOS
122+
vs macOS isn't distinguishable from cputype alone
123+
(CPU_TYPE_ARM64 covers both); future work could promote to
124+
`IOS` based on `LC_VERSION_MIN_IPHONEOS`.
125+
126+
### Dependency bumps (continued)
127+
128+
- **smda 0.6.2 → 0.6.4.** Picks up two Mach-O fixes that
129+
capa-rs 0.5.0 depends on:
130+
- **0.6.3 tail-call resolver fix:** when
131+
`resolve_tailcalls(true)` is active (capa_cli default),
132+
`TailCallAnalyser::resolve_tailcalls` used to fatally
133+
propagate `CollisionError` from `analyse_function`
134+
aborting the whole `Disassembler::parse` call. Now treats
135+
CollisionError as expected (skip candidate, continue),
136+
matching the main candidate loop's `.ok()` pattern. Pre-fix
137+
repro: `SMDAError(CollisionError(0x100003698))` in ~95 ms
138+
on every Apple-Silicon /bin/ls invocation.
139+
- **0.6.4 Mach-O imports → `disassembly.apis` bridge:**
140+
`analyse_buffer` had an explicit ELF→apis bridge and PE was
141+
handled by the WinApiResolver, but Mach-O had neither.
142+
Imports stopped at file-scope `binary_info.imports` and
143+
never reached `disassembly.apis` / `addr_to_api`, so
144+
`Function::apirefs` was empty for every Mach-O function and
145+
capa's `extract_insn_api_features` emitted zero
146+
`Feature::Api` for Mach-O input — making /bin/ls match zero
147+
capa rules. Now a Mach-O-symmetric block in `analyse_buffer`
148+
walks `macho::extract_macho_dynamic_apis` (new in 0.6.4) and
149+
populates both `apis` and `addr_to_api` directly. Coverage:
150+
`__DATA,__got` / `__DATA,__la_symbol_ptr` slot VAs, reached
151+
by the canonical ARM64 PIC patterns (`adrp+ldr+blr` inlined
152+
or via __TEXT,__stubs thunks).
153+
154+
### Added — Mach-O security checklist
155+
156+
- **Real `security/macho.rs` module** replacing the placeholder
157+
`Ok(Vec::new())` that pre-0.5.0 shipped (an empty Security
158+
Checks table on every Mach-O input). Nine checks emitted:
159+
- **PIE**`MH_PIE` (`0x200000`), Darwin ASLR equivalent.
160+
- **DATA-EXEC-PREVENT**`MH_NO_HEAP_EXECUTION` (`0x01000000`)
161+
OR no `__DATA*` segment with `VM_PROT_EXECUTE` in
162+
`initprot`.
163+
- **STACK-CANARY**`___stack_chk_guard` /
164+
`___stack_chk_fail` in the symbol table.
165+
- **RESTRICT** — presence of a `__RESTRICT` segment
166+
(anti-`DYLD_INSERT_LIBRARIES` marker).
167+
- **CODE-SIGNATURE**`LC_CODE_SIGNATURE` load command with
168+
non-zero `datasize`.
169+
- **TWO-LEVEL-NAMESPACE**`MH_TWOLEVEL` (`0x80`).
170+
- **NO-UNDEF-SYMS**`MH_NOUNDEFS` (`0x01`).
171+
- **HARDENED-RUNTIME**`Unknown` (requires `CS_SuperBlob`
172+
`CS_CodeDirectory.flags & CS_RUNTIME (0x10000)` walk;
173+
deferred to a follow-up, ~80 LOC of big-endian parsing
174+
inside `__LINKEDIT`).
175+
- **ALLOW-JIT**`Unknown` (requires
176+
`CS_EmbeddedEntitlements` PLIST parsing; deferred).
177+
Fat binaries: first parseable slice's checks are reported,
178+
same convention as smda's `MachoArchPreference::HostNative`
179+
picks for analysis.
180+
6181
## [0.4.3] — 2026-05-27 — FLIRT polish
7182

8183
### Added

Cargo.toml

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "capa"
3-
version = "0.4.3"
3+
version = "0.5.0"
44
description = "File capability extractor."
55
authors = ["Marirs <marirs@gmail.com>", "Andrey Mnatsakanov <andrey@3ig.kiev.ua>", "Jorge Alejandro Durán Royo<jorge.duran.royo@gmail.com>"]
66
keywords = ["capa", "fce", "capability", "aslr", "reverse"]
@@ -16,7 +16,13 @@ homepage = "https://github.com/marirs/capa-rs"
1616
# them as a separate `flirt-sigs.tar.gz` artifact alongside the CLI
1717
# binaries; source builds (`git clone`) get them automatically since
1818
# the directory lives in the repo.
19-
exclude = ["data", "flirt-sigs"]
19+
# `scripts/` (0.5.0) stays excluded — the only file is
20+
# `gen_com_tables.py`, the Python generator that produced
21+
# `src/rules/com_db.rs` (the 29k-entry COM GUID database, included!
22+
# at build time). The generated table ships in src/; the generator
23+
# itself is only useful for regenerating from a newer upstream
24+
# Python capa, which crates.io consumers don't need.
25+
exclude = ["data", "flirt-sigs", "scripts"]
2026
edition = "2024"
2127
# 0.3.21: bumped from 1.85 → 1.95 because smda 0.5.x sets MSRV 1.95
2228
# (needed for some 2024-edition features it leans on). Pure-Rust dep
@@ -31,10 +37,19 @@ petgraph = "0.8"
3137
regex = "1.11.1"
3238
fancy-regex = "0.18"
3339
serde = { version = "1", features = ["derive"] }
34-
# 0.3.21: smda 0.2 → 0.5 picks up months of security fixes + the new
35-
# function-name pipeline (Go pclntab, MinGW DWARF, Delphi VMT, Rust
36-
# demangling), structural function hashes, and Mach-O loader.
37-
smda = "0.5"
40+
# 0.5.0: smda 0.5 → 0.6.4 picks up the AArch64 backend
41+
# (`FileArchitecture::Aarch64`, ARM64 Mach-O / ELF / PE routing),
42+
# the seven AArch64 analyser ports (jump-table, indirect-call,
43+
# tail-call, .pdata, NOP, exit-syscall, is_api_thunk), GOT/PLT
44+
# ELF API resolution, the code-hygiene patches, the 0.6.3
45+
# tail-call resolver fix (so `resolve_tailcalls(true)` survives
46+
# analysis without aborting on benign CollisionErrors), and the
47+
# 0.6.4 Mach-O imports → `disassembly.apis` bridge (so
48+
# `Feature::Api` actually surfaces on Mach-O input — pre-0.6.4
49+
# every Mach-O analysis returned zero API features and therefore
50+
# zero capa-rule matches). `0.6.4` is the minimum; semver-
51+
# compatible with future 0.6.x.
52+
smda = "0.6.4"
3853
thiserror = "2.0.12"
3954
walkdir = "2.5.0"
4055
yaml-rust = "0.4.5"

examples/capa_cli.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,23 @@ fn main() {
164164
}
165165
println!();
166166

167+
// 0.5.0 (D3): print scope-keyed feature dump summary
168+
// when `-m` is set. The full per-feature breakdown
169+
// is in the JSON output (`-o`); this stdout view
170+
// gives a quick "how many of each feature type per
171+
// scope" sanity check.
172+
if map_features {
173+
if let Some(by_scope) = data.get("map_features_by_scope") {
174+
if let Some(obj) = by_scope.as_object() {
175+
if !obj.is_empty() {
176+
let tbl = get_features_by_scope_summary(obj);
177+
tbl.printstd();
178+
println!();
179+
}
180+
}
181+
}
182+
}
183+
167184
//print tags
168185

169186
if let Some(tags) = data.get("tags") {
@@ -344,6 +361,52 @@ fn get_namespace(namespace: &Map<String, Value>) -> Table {
344361
tbl
345362
}
346363

364+
/// 0.5.0 (D3): summarises the scope-keyed feature dump as
365+
/// `scope → feature_type → count` so users running with `-m` get a
366+
/// quick visual signal that scope info is being tracked. The full
367+
/// per-value breakdown is in the JSON output (`-o`).
368+
fn get_features_by_scope_summary(by_scope: &Map<String, Value>) -> Table {
369+
let mut tbl = Table::new();
370+
tbl.set_titles(Row::new(vec![
371+
Cell::new_align("Features by Scope (D3 dump)", Alignment::CENTER).with_hspan(3),
372+
]));
373+
tbl.set_titles(Row::new(vec![
374+
Cell::new_align("Scope", Alignment::LEFT),
375+
Cell::new_align("Feature Type", Alignment::LEFT),
376+
Cell::new_align("Distinct Values", Alignment::RIGHT),
377+
]));
378+
379+
// Sort scopes for stable output (file → function → basic_block → instruction is roughly outer-to-inner).
380+
let mut scopes: Vec<&String> = by_scope.keys().collect();
381+
scopes.sort_by_key(|s| match s.as_str() {
382+
"file" => 0,
383+
"function" => 1,
384+
"basic_block" => 2,
385+
"instruction" => 3,
386+
_ => 99,
387+
});
388+
for scope in scopes {
389+
let scope_obj = by_scope.get(scope).and_then(|v| v.as_object());
390+
let Some(scope_obj) = scope_obj else { continue };
391+
let mut ftypes: Vec<&String> = scope_obj.keys().collect();
392+
ftypes.sort();
393+
for ftype in ftypes {
394+
let count = scope_obj
395+
.get(ftype)
396+
.and_then(|v| v.as_object())
397+
.map(|m| m.len())
398+
.unwrap_or(0);
399+
tbl.add_row(Row::new(vec![
400+
Cell::new(scope).with_style(Attr::ForegroundColor(color::CYAN)),
401+
Cell::new(ftype),
402+
Cell::new(&count.to_string()),
403+
]));
404+
}
405+
}
406+
407+
tbl
408+
}
409+
347410
/// Gets Verbose information and returns as a TABLE for stdout
348411
fn get_verbose_info(extra: &Map<String, Value>) -> Table {
349412
let mut tbl = Table::new();

0 commit comments

Comments
 (0)