Skip to content

Commit 6efc199

Browse files
committed
xor-zero number(0)
1 parent 163a97f commit 6efc199

8 files changed

Lines changed: 635 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,34 @@
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.2] — xor-zero number(0), regex /i fast path, rule pre-pruning
7+
8+
### Fixed — feature extraction parity
9+
10+
- **`xor reg, reg` now emits `Number(0)`**
11+
Self-XOR is the canonical register-zeroing idiom; previously
12+
`extract_insn_nzxor_characteristic_features` returned an empty feature set
13+
for it, so rules matching on `number: 0` at the produced register missed the
14+
pattern entirely.
15+
16+
### Performance
17+
18+
- **Pre-prune rules with unsatisfiable global-feature constraints**
19+
New `RuleSet::filter_rules_by_meta_features` walks
20+
each rule's statement AST and discards rules whose `os`/`arch`/`format`
21+
constraints can't be met by the binary's globals, before the per-function
22+
evaluation loop. Transitive dependencies of kept rules are preserved.
23+
- **Regex `/i` fast path via lowercased-string lookup**
24+
Pure-literal patterns ending in `/i` now do an O(1) `HashSet` lookup
25+
against the lowercased feature values instead of compiling and running a
26+
regex. Builds on the 0.4.2 lowercase canonicalization.
27+
- **String pre-filter at file load** New
28+
`RuleSet::prepare_for_file` populates an `impossible_string_rule_names` set
29+
by checking each rule's required strings against the file's string blob.
30+
Impossible rules are skipped during per-function matching unless their scope
31+
contains `Characteristic("stack string")` (since stack-built strings aren't
32+
in the file's string set).
33+
634
## [0.5.1] — Mach-O closeout: zero `Unknown` rows, stub-VA API resolution, iOS distinction
735

836
Closes every `Unknown` placeholder in the 0.5.0 Mach-O security

Cargo.toml

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,15 @@
11
[package]
22
name = "capa"
3-
version = "0.5.1"
3+
version = "0.5.2"
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"]
77
readme = "README.md"
88
license-file = "LICENSE"
99
repository = "https://github.com/marirs/capa-rs"
1010
homepage = "https://github.com/marirs/capa-rs"
11-
# Drop `examples` from `exclude` so the [[example]] target below ships
12-
# with the crate (was inconsistent: example target referenced an
13-
# excluded path). `data/` stays excluded — those are test fixtures.
14-
# `flirt-sigs/` stays excluded — 70 MB of FLIRT .sig files would
15-
# blow past crates.io's 10 MB upload limit. The GitHub release ships
16-
# them as a separate `flirt-sigs.tar.gz` artifact alongside the CLI
17-
# binaries; source builds (`git clone`) get them automatically since
18-
# the directory lives in the repo.
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.
2511
exclude = ["data", "flirt-sigs", "scripts"]
2612
edition = "2024"
27-
# 0.3.21: bumped from 1.85 → 1.95 because smda 0.5.x sets MSRV 1.95
28-
# (needed for some 2024-edition features it leans on). Pure-Rust dep
29-
# tree below — no aws-lc / openssl / native-tls.
3013
rust-version = "1.95"
3114

3215
[dependencies]
@@ -37,26 +20,12 @@ petgraph = "0.8"
3720
regex = "1.11.1"
3821
fancy-regex = "0.18"
3922
serde = { version = "1", features = ["derive"] }
40-
# 0.5.1: smda 0.6.4 → 0.6.5. Picks up the Mach-O closeout:
41-
# `__TEXT,__stubs` walker via `LC_DYSYMTAB.indirectsymoff` so
42-
# direct `bl _stub` calls resolve to their API names (0.6.4 only
43-
# covered the GOT-slot indirect form, missing the most common
44-
# ARM64 PIC call shape), and `MachoArchPreference` plumbed through
45-
# `BinaryInfo` so post-parse passes honour the slice preference
46-
# set at parse time. `0.6.5` is the minimum; semver-compatible
47-
# with future 0.6.x.
4823
smda = "0.6.5"
4924
thiserror = "2.0.12"
5025
walkdir = "2.5.0"
5126
yaml-rust = "0.4.5"
5227
goblin = { version = "0.10.0", features = ["alloc"] }
5328
maplit = "1.0.2"
54-
# 0.4.2: dnfile 0.4 → 0.5 picks up `Send + Sync` supertrait bounds on
55-
# the public trait family (MDTableTrait, MDTableRowTrait,
56-
# MDTableRowTraitT, CodedIndex) so `DnPe<'_>` — and the capa-rs
57-
# `dnfile::Extractor<'_>` that wraps it — can cross thread
58-
# boundaries. Required by the rayon-parallel function loop in
59-
# `find_capabilities`. No runtime behaviour change.
6029
dnfile = "0.5"
6130
lazy_static = "1.5.0"
6231
parking_lot = "0.12.3"
@@ -66,49 +35,12 @@ memmap2 = "0.9.5"
6635
scroll = "0.13"
6736
once_cell = "1.21.1"
6837
dynamic-loader-cache = "0.2.2"
69-
# (0.5.1) Pure-Rust PLIST parser for Mach-O code-signing
70-
# entitlements blob (CS_EmbeddedEntitlements, magic 0xfade7171).
71-
# Used by security/macho.rs to detect ALLOW-JIT
72-
# (com.apple.security.cs.allow-jit). No openssl / aws-lc /
73-
# native-tls in the transitive tree — matches the project's
74-
# pure-Rust stance.
7538
plist = { version = "1", default-features = false }
76-
# 0.3.21 introduced ouroboros as a self-referential wrapper around
77-
# `buf: Vec<u8>` + `DisassemblyReport<'_>` so the public Extractor::new
78-
# could keep its 0.3.20 signature. 0.4.0 completes the zero-copy
79-
# refactor — `Extractor<'a>` takes `&'a [u8]` from the caller and
80-
# borrows the smda/dnfile reports directly. The ouroboros dep is gone.
81-
# 0.3.21: iced-x86 is already in the transitive dep tree via smda.
82-
# Pulling it in directly lets the smda extractor compare instructions
83-
# against typed `Mnemonic` / `FlowControl` enums instead of re-formatting
84-
# strings — the same hot-path perf win smda itself got from the iced
85-
# decoder swap. Match the feature set smda uses to keep monomorphisation
86-
# overhead identical.
8739
iced-x86 = { version = "1", default-features = false, features = ["std", "decoder", "intel", "instr_info"] }
88-
# 0.4.3: fast-flirt is our pure-Rust FLIRT engine — Apache-2.0,
89-
# zero-copy arena + multi-level prefix trie matcher. capa-rs uses it
90-
# to identify statically-linked library functions (MSVC CRT, ATL/MFC,
91-
# OpenSSL, zlib, boost, libcurl, lua, protobuf, DirectX, Intel libs,
92-
# etc.) and exclude them from the user-facing capability output via
93-
# the existing `lib: true` rule-skip path. Always compiled in —
94-
# `AnalyzeBuilder::signatures` is the opt-in (no signatures path →
95-
# no FLIRT, identical behaviour to pre-0.4.3).
96-
#
97-
# Three direct deps (`miniz_oxide`, `smallvec`, `thiserror`); ~240×
98-
# faster on `matches()` and ~70% less resident memory than the
99-
# upstream-FLIRT engine we briefly piloted on.
100-
fast-flirt = "0.2.2"
101-
# 0.4.2: rayon for inter-function parallelism in `find_capabilities`.
102-
# Each function's analysis is pure — reads the extractor, evaluates
103-
# rules, returns matches. Parallelising the outer loop gives ~4-8×
104-
# end-to-end speedup on multi-core machines for binaries with more
105-
# than a handful of functions. Also used in `rules::get_rules` to
106-
# parallelise YAML parsing across the rule corpus.
10740
rayon = "1.10"
10841

10942
[dev-dependencies]
11043
clap = { version = "4.5", features = ["cargo", "derive"] }
111-
11244
prettytable-rs = "0.10.0"
11345

11446
[lib]

src/extractor/smda.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,17 @@ impl<'data> Extractor<'data> {
10051005
let rn = (a.opcode >> 5) & 0x1f;
10061006
let rm = (a.opcode >> 16) & 0x1f;
10071007
if rn == rm {
1008+
// 0.5.2 (upstream parity #2997): `eor xd, xn, xn` is the
1009+
// AArch64 zeroing idiom — same shape as x86 `xor eax, eax`.
1010+
// Emit Number(0) at the instruction so rules matching on
1011+
// `number: 0` see the produced value, rather than dropping
1012+
// the case silently (pre-0.5.2 behaviour).
1013+
res.push((
1014+
crate::rules::features::Feature::Number(
1015+
crate::rules::features::NumberFeature::new(f.bitness, &0_i128, "")?,
1016+
),
1017+
insn.offset,
1018+
));
10081019
return Ok(res);
10091020
}
10101021
// Security-cookie filter is x86-specific (stack-canary
@@ -1031,6 +1042,16 @@ impl<'data> Extractor<'data> {
10311042
if let Some(o) = insn.format_operands() {
10321043
let operands: Vec<String> = o.split(',').map(|s| s.trim().to_string()).collect();
10331044
if operands[0] == operands[1] {
1045+
// 0.5.2 (upstream parity #2997): `xor eax, eax` (and the SSE
1046+
// / packed variants Xorpd/Xorps/Pxor) zero the destination
1047+
// register. Emit Number(0) for rules matching the produced
1048+
// value, instead of dropping the case silently.
1049+
res.push((
1050+
crate::rules::features::Feature::Number(
1051+
crate::rules::features::NumberFeature::new(f.bitness, &0_i128, "")?,
1052+
),
1053+
insn.offset,
1054+
));
10341055
return Ok(res);
10351056
}
10361057
}
@@ -1928,3 +1949,100 @@ pub(crate) fn classify_macho_os(buf: &[u8]) -> Result<Os> {
19281949
_ => Ok(Os::MACOS),
19291950
}
19301951
}
1952+
1953+
// 0.5.2 (upstream parity #2997): integration test for the
1954+
// `xor reg, reg` → `Number(0)` correctness fix. This is the first
1955+
// extractor-level test in the crate — placed inline rather than in
1956+
// `tests/` so we keep crate-internal access to the `Mnemonic` import,
1957+
// the `SmdaExtractor::report()` accessor, and the public
1958+
// `extract_insn_nzxor_characteristic_features` method without
1959+
// having to widen any visibility.
1960+
#[cfg(test)]
1961+
mod tests {
1962+
use super::*;
1963+
use crate::rules::features::{CharacteristicFeature, Feature, NumberFeature};
1964+
1965+
/// Walks every instruction in `data/Demo64.dll`, finds any
1966+
/// `xor reg, reg` (or `xorpd`/`xorps`/`pxor`) where both operands
1967+
/// are the same register, and asserts that
1968+
/// `extract_insn_nzxor_characteristic_features` emits `Number(0)`
1969+
/// at the instruction and does NOT emit `Characteristic("nzxor")`
1970+
/// — mirroring the upstream Python parity-test pattern from
1971+
/// `mandiant/capa#2997`.
1972+
///
1973+
/// Fails loudly if no self-XOR site is discovered, so we can't pass
1974+
/// vacuously on a binary that happens to contain none.
1975+
#[test]
1976+
fn upstream_parity_2997_xor_self_emits_number_zero() {
1977+
let path = "data/Demo64.dll";
1978+
let bytes = std::fs::read(path).unwrap_or_else(|e| {
1979+
panic!("test fixture missing: {path}: {e}");
1980+
});
1981+
let extractor = Extractor::new(path, false, false, &bytes).expect("smda parse Demo64.dll");
1982+
1983+
let report = extractor.report();
1984+
let functions = report.get_functions().expect("smda get_functions");
1985+
1986+
// PartialEq on NumberFeature / CharacteristicFeature is value-only
1987+
// (see `impl PartialEq for NumberFeature` in src/rules/features.rs),
1988+
// so the bitness / description we construct here are irrelevant for
1989+
// the assertion — only the value matters.
1990+
let want_zero =
1991+
Feature::Number(NumberFeature::new(64, &0_i128, "").expect("NumberFeature::new"));
1992+
let want_nzxor = Feature::Characteristic(
1993+
CharacteristicFeature::new("nzxor", "").expect("CharacteristicFeature::new"),
1994+
);
1995+
1996+
let mut sites_checked = 0_usize;
1997+
for smda_func in functions.values() {
1998+
let Ok(blocks) = smda_func.get_blocks() else {
1999+
continue;
2000+
};
2001+
for instrs in blocks.values() {
2002+
for insn in instrs {
2003+
// Mirror the extractor's own self-XOR detection so the
2004+
// test exercises the exact branch the fix added a
2005+
// `Number(0)` push to.
2006+
if !matches!(
2007+
insn.mnemonic_enum(),
2008+
Mnemonic::Xor | Mnemonic::Xorpd | Mnemonic::Xorps | Mnemonic::Pxor
2009+
) {
2010+
continue;
2011+
}
2012+
let Some(operand_str) = insn.format_operands() else {
2013+
continue;
2014+
};
2015+
let parts: Vec<&str> = operand_str.split(',').map(|s| s.trim()).collect();
2016+
if parts.len() < 2 || parts[0] != parts[1] {
2017+
continue;
2018+
}
2019+
2020+
sites_checked += 1;
2021+
let res = extractor
2022+
.extract_insn_nzxor_characteristic_features(smda_func, insn)
2023+
.expect("extract_insn_nzxor_characteristic_features");
2024+
2025+
assert!(
2026+
res.iter().any(|(f, _)| f == &want_zero),
2027+
"self-XOR at {:#x} did not emit Number(0); features were {:?}",
2028+
insn.offset,
2029+
res.iter().map(|(f, _)| f).collect::<Vec<_>>(),
2030+
);
2031+
assert!(
2032+
res.iter().all(|(f, _)| f != &want_nzxor),
2033+
"self-XOR at {:#x} incorrectly tagged as nzxor",
2034+
insn.offset,
2035+
);
2036+
}
2037+
}
2038+
}
2039+
2040+
assert!(
2041+
sites_checked > 0,
2042+
"no `xor reg, reg` site found in {path} — \
2043+
the test cannot verify the fix; switch the fixture to a \
2044+
binary that contains at least one self-XOR.",
2045+
);
2046+
eprintln!("upstream parity #2997: verified {sites_checked} self-XOR site(s) in {path}");
2047+
}
2048+
}

src/extractor/smda.rs:1982:13

Whitespace-only changes.

src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,23 @@ fn find_capabilities(
11261126
)> {
11271127
use rayon::prelude::*;
11281128

1129+
// 0.5.2 (upstream parity mandiant/capa#2929): pre-prune rules whose
1130+
// global-feature constraints (os/arch/format) can't be satisfied by
1131+
// this binary, *before* the per-function matching loop. Eliminates
1132+
// those rules from every per-function / per-basic-block /
1133+
// per-instruction evaluation downstream.
1134+
//
1135+
// Globals are constant per binary, so this is a one-shot up-front
1136+
// call that pays off across thousands of per-function evaluations.
1137+
// The owned filtered `RuleSet` is held in a local so all
1138+
// `&rules::Rule` references taken later point into it.
1139+
let mut globals_map: HashMap<crate::rules::features::Feature, Vec<u64>> = HashMap::new();
1140+
for (feat, va) in extractor.extract_global_features()? {
1141+
globals_map.entry(feat).or_default().push(va);
1142+
}
1143+
let pruned_ruleset = ruleset.filter_rules_by_meta_features(&globals_map)?;
1144+
let ruleset: &rules::RuleSet = &pruned_ruleset;
1145+
11291146
let mut all_function_matches: HashMap<&rules::Rule, Vec<(u64, (bool, Vec<u64>))>> =
11301147
HashMap::new();
11311148
let mut all_bb_matches: HashMap<&rules::Rule, Vec<(u64, (bool, Vec<u64>))>> = HashMap::new();

src/rules/features.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ pub enum Feature {
109109
}
110110

111111
impl Feature {
112+
/// 0.5.2 (upstream parity #2929): is this a "global" feature —
113+
/// one that's constant per binary (OS, architecture, file
114+
/// format), determined once from headers, and therefore
115+
/// suitable for pre-pruning rules whose constraints can't be
116+
/// satisfied?
117+
///
118+
/// Mirrors Python capa's `capa.features.common.is_global_feature`.
119+
pub fn is_global_feature(&self) -> bool {
120+
matches!(self, Feature::Os(_) | Feature::Arch(_) | Feature::Format(_))
121+
}
122+
112123
pub fn new(t: RuleFeatureType, value: &Value, description: &str) -> Result<Feature> {
113124
// let readpro = "property/read".to_string();
114125

@@ -1530,6 +1541,11 @@ impl ArchFeature {
15301541
}
15311542
Ok((false, vec![]))
15321543
}
1544+
1545+
/// 0.5.2 (upstream parity #2929): see `OsFeature::value`.
1546+
pub fn value(&self) -> &str {
1547+
&self.value
1548+
}
15331549
}
15341550

15351551
impl Hash for ArchFeature {
@@ -1689,6 +1705,13 @@ impl OsFeature {
16891705
}
16901706
Ok((false, vec![]))
16911707
}
1708+
1709+
/// 0.5.2 (upstream parity #2929): expose the canonicalised
1710+
/// (lowercased) OS string so the `filter_rules_by_meta_features`
1711+
/// pre-prune walker can detect the `os: any` wildcard.
1712+
pub fn value(&self) -> &str {
1713+
&self.value
1714+
}
16921715
}
16931716

16941717
impl Hash for OsFeature {
@@ -1744,6 +1767,11 @@ impl FormatFeature {
17441767
}
17451768
Ok((false, vec![]))
17461769
}
1770+
1771+
/// 0.5.2 (upstream parity #2929): see `OsFeature::value`.
1772+
pub fn value(&self) -> &str {
1773+
&self.value
1774+
}
17471775
}
17481776

17491777
impl Hash for FormatFeature {

0 commit comments

Comments
 (0)