Skip to content

Commit d811f4c

Browse files
committed
fixes
1 parent c78be3a commit d811f4c

6 files changed

Lines changed: 296 additions & 94 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ iced-x86 = { version = "1", default-features = false, features = ["std", "decode
7979
# Three direct deps (`miniz_oxide`, `smallvec`, `thiserror`); ~240×
8080
# faster on `matches()` and ~70% less resident memory than the
8181
# upstream-FLIRT engine we briefly piloted on.
82-
fast-flirt = "0.2.1"
82+
fast-flirt = "0.2.2"
8383
# 0.4.2: rayon for inter-function parallelism in `find_capabilities`.
8484
# Each function's analysis is pure — reads the extractor, evaluates
8585
# rules, returns matches. Parallelising the outer loop gives ~4-8×

src/extractor/dnfile.rs

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,34 @@ struct Instruction {
2222
}
2323

2424
impl super::Instruction for Instruction {
25+
/// CIL is a stack-based ISA — there is no notion of a "stack
26+
/// variable" in the x86 sense. Locals are addressed by index
27+
/// (`ldloc.N` / `stloc.N`), not by frame offset, and the
28+
/// stack-string heuristic that drives `is_mov_imm_to_stack` on
29+
/// x86 backends doesn't apply. Python capa makes the same call —
30+
/// it doesn't define this method on the dnfile extractor at all.
31+
/// Safe default `false` matches Python's behaviour: the
32+
/// stack-string detector skips .NET methods.
33+
///
34+
/// 0.4.x: previously `unimplemented!()`. Replaced in 0.5.0 — the
35+
/// panic was a latent footgun for any caller that held a
36+
/// `&dyn Instruction` without knowing which extractor produced it.
2537
fn is_mov_imm_to_stack(&self) -> Result<bool> {
26-
unimplemented!()
38+
Ok(false)
2739
}
40+
41+
/// CIL string literals come from the `#US` heap via `ldstr`, not
42+
/// as byte arrays moved to a stack frame, so there are no
43+
/// "printable bytes" to count on an instruction. Python capa
44+
/// doesn't define this for the dnfile extractor. Safe default
45+
/// `0` keeps the stack-string aggregator at 0 contributions for
46+
/// .NET methods — same outcome as Python.
47+
///
48+
/// 0.4.x: previously `unimplemented!()`.
2849
fn get_printable_len(&self) -> Result<u64> {
29-
unimplemented!()
50+
Ok(0)
3051
}
52+
3153
fn as_any(&self) -> &dyn std::any::Any {
3254
self
3355
}
@@ -36,17 +58,41 @@ impl super::Instruction for Instruction {
3658
#[derive(Debug, Clone)]
3759
struct Function {
3860
f: cil::function::Function,
61+
/// Python capa convention: `calls_to` = set of CALLERS (incoming
62+
/// references — addresses of methods that call this one).
3963
calls_to: HashSet<u64>,
64+
/// Python capa convention: `calls_from` = set of CALLEES (outgoing
65+
/// references — addresses of methods this one calls).
4066
calls_from: HashSet<u64>,
67+
/// Materialised caller list for the `inrefs` trait method. Same
68+
/// content as `calls_to`, kept as `Vec` because the trait returns
69+
/// `&Vec<u64>`. Populated at `get_functions` construction.
70+
inrefs: Vec<u64>,
71+
/// Per-block successor map for the `blockrefs` trait method. The
72+
/// .NET extractor treats each method as a single basic block
73+
/// (matches `get_blocks` below), so this is always
74+
/// `{first_insn_offset: vec![]}` — one entry, no successors. A
75+
/// future CIL CFG split (br/brtrue/brfalse/switch walking) would
76+
/// expand this; tracked as a follow-up.
77+
blockrefs: HashMap<u64, Vec<u64>>,
4178
}
4279

4380
impl super::Function for Function {
81+
/// Callers of this method. 0.4.x: `unimplemented!()`. 0.5.0:
82+
/// populated from `calls_to` at construction.
4483
fn inrefs(&self) -> &Vec<u64> {
45-
unimplemented!()
84+
&self.inrefs
4685
}
86+
87+
/// Per-basic-block successor map. 0.4.x: `unimplemented!()`.
88+
/// 0.5.0: returns a single-entry map matching `get_blocks`'
89+
/// single-block-per-method shape. Proper CIL CFG split is a
90+
/// follow-up — it requires walking br/brtrue/brfalse/switch
91+
/// targets and re-segmenting the instruction list.
4792
fn blockrefs(&self) -> &HashMap<u64, Vec<u64>> {
48-
unimplemented!()
93+
&self.blockrefs
4994
}
95+
5096
fn offset(&self) -> u64 {
5197
self.f.offset as u64
5298
}
@@ -179,11 +225,27 @@ impl<'data> super::Extractor for Extractor<'data> {
179225
}
180226

181227
fn get_functions(&self) -> Result<std::collections::BTreeMap<u64, Box<dyn super::Function>>> {
182-
let mut methods: std::collections::HashMap<u64, Function> =
183-
std::collections::HashMap::new();
184-
let mut calls_to_map = HashMap::new();
228+
// 0.5.0: rebuilt to match Python capa's `calls_to` (callers,
229+
// incoming) / `calls_from` (callees, outgoing) convention.
230+
// Pre-0.5.0 had two bugs that compounded into a silently-wrong
231+
// call graph for every .NET assembly:
232+
// 1. `calls_to` was initialised empty and never populated.
233+
// 2. The reverse-index loop overwrote `f.calls_from` with the
234+
// caller set instead of populating `f.calls_to`, which
235+
// both inverted the `calls from` characteristic feature
236+
// and broke the `recursive call` detector (it checked
237+
// `f.calls_to`, which was always empty).
238+
//
239+
// First pass builds `callers_of[target] = {caller, ...}` and
240+
// records each function's outgoing callee set. Second pass
241+
// assembles the `Function` values with both fields filled in
242+
// the correct direction.
243+
let mut callers_of: HashMap<u64, HashSet<u64>> = HashMap::new();
244+
let mut callees_of: HashMap<u64, HashSet<u64>> = HashMap::new();
245+
185246
for f in self.pe().net()?.functions() {
186-
let mut calls_from = HashSet::new();
247+
let caller_addr = f.offset as u64;
248+
let mut my_callees: HashSet<u64> = HashSet::new();
187249
for insn in &f.instructions {
188250
if ![
189251
OpCodeValue::Call,
@@ -195,25 +257,40 @@ impl<'data> super::Extractor for Extractor<'data> {
195257
{
196258
continue;
197259
}
198-
let address = insn.operand.value()?;
199-
let ee = calls_to_map.entry(address as u64).or_insert(HashSet::new());
200-
ee.insert(f.offset as u64);
201-
calls_from.insert(address as u64);
260+
let target = insn.operand.value()? as u64;
261+
my_callees.insert(target);
262+
callers_of.entry(target).or_default().insert(caller_addr);
263+
}
264+
callees_of.insert(caller_addr, my_callees);
265+
}
266+
267+
let mut methods: HashMap<u64, Function> = HashMap::new();
268+
for f in self.pe().net()?.functions() {
269+
let addr = f.offset as u64;
270+
let calls_to = callers_of.remove(&addr).unwrap_or_default();
271+
let calls_from = callees_of.remove(&addr).unwrap_or_default();
272+
// `inrefs` is just the materialised caller list (the trait
273+
// returns `&Vec<u64>`; `calls_to` is a `HashSet`).
274+
let inrefs: Vec<u64> = calls_to.iter().copied().collect();
275+
// Single-block-per-method shape matches `get_blocks`.
276+
// First instruction's offset is the block key; no
277+
// intra-function successors at this granularity.
278+
let mut blockrefs: HashMap<u64, Vec<u64>> = HashMap::new();
279+
if let Some(first) = f.instructions.first() {
280+
blockrefs.insert(first.offset as u64, Vec::new());
202281
}
203282
methods.insert(
204-
f.offset as u64,
283+
addr,
205284
Function {
206285
f: f.clone(),
207-
calls_to: HashSet::new(),
286+
calls_to,
208287
calls_from,
288+
inrefs,
289+
blockrefs,
209290
},
210291
);
211292
}
212-
for (a, calls_from) in calls_to_map.into_iter() {
213-
if let Some(f) = methods.get_mut(&a) {
214-
f.calls_from = calls_from;
215-
}
216-
}
293+
217294
Ok(methods
218295
.into_iter()
219296
.map(|(a, b)| (a, Box::new(b) as Box<dyn super::Function>))
@@ -654,7 +731,13 @@ impl<'data> Extractor<'data> {
654731
&self,
655732
f: &Function,
656733
) -> Result<Vec<(crate::rules::features::Feature, u64)>> {
657-
Ok(f.calls_to
734+
// 0.5.0: read `calls_from` (callees) not `calls_to` (callers).
735+
// Pre-0.5.0 this was a copy-paste from
736+
// `extract_function_call_to_features` that silently emitted the
737+
// caller set for every `characteristic: calls from` rule on
738+
// .NET assemblies. Matches Python capa's
739+
// `extract_function_calls_from` in dnfile/function.py.
740+
Ok(f.calls_from
658741
.iter()
659742
.map(|a| {
660743
(

src/extractor/smda.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,24 @@ impl<'data> super::Extractor for Extractor<'data> {
201201
f: &Box<dyn super::Function>,
202202
) -> Result<Vec<(crate::rules::features::Feature, u64)>> {
203203
let mut res = vec![]; //extract function calls to
204+
205+
// 0.5.0 note: function-scope FunctionName emission was
206+
// considered (E2 in the parity audit) for parity with Python
207+
// capa, but reverted after empirical measurement showed:
208+
// 1. The entire capa-rules corpus contains exactly one
209+
// `function-name:` rule, and it's file-scoped, not
210+
// function-scoped. So function-scope emission unlocks
211+
// zero rule matches today.
212+
// 2. The emission added ~16% to mimikatz analysis time
213+
// (~0.9s on a 5s baseline) for zero functional benefit
214+
// on real workloads — every emission was a wasted
215+
// String + HashSet alloc that downstream rule eval had
216+
// to probe past.
217+
// File-scope FunctionName via `extract_file_function_names`
218+
// covers the one rule that exists. If a future Python-capa
219+
// release ships function-scope name rules and they're added
220+
// to the corpus, revisit this.
221+
204222
for inref in f.inrefs() {
205223
res.push((
206224
crate::rules::features::Feature::Characteristic(

src/flirt.rs

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@
3737
//! policy. For most MSVC patterns this gives the same answer
3838
//! because the head-bytes + CRC are unique enough. Refinement is
3939
//! tracked as a follow-up.
40-
//! - **No .pat.gz support yet** — only `.sig` (binary) and `.pat`
41-
//! (ASCII) are accepted. The Mandiant FLARE corpus ships as
42-
//! `.sig`, so this isn't a practical limitation for the default
43-
//! workflow.
40+
//! - **`.pat.gz` is supported as of 0.5.0** via fast-flirt 0.2.2's
41+
//! `add_pat_gz` (pure-Rust gunzip — gzip framing stripped, deflate
42+
//! stream inflated via `miniz_oxide`). Pre-0.5.0 `.pat.gz` files
43+
//! were counted and surfaced in the load summary but skipped.
4444
//!
4545
//! ## Why a hand-rolled walker rather than `fast_flirt::FlirtSet::load_dir`
4646
//!
@@ -81,12 +81,10 @@ pub struct FlirtMatcher {
8181
}
8282

8383
impl FlirtMatcher {
84-
/// Load all `.sig` and `.pat` files from a directory tree
85-
/// (recursive). Files that fail to parse are reported through
84+
/// Load all `.sig`, `.pat`, and `.pat.gz` files from a directory
85+
/// tree (recursive). Files that fail to parse are reported through
8686
/// `logger` and skipped — they don't abort the build, matching
87-
/// Python capa's best-effort behaviour. `.pat.gz` files are
88-
/// counted and surfaced in the final summary (gzipped pat is a
89-
/// 0.4.x limitation — gunzip ahead of time as a workaround).
87+
/// Python capa's best-effort behaviour.
9088
///
9189
/// Returns an error if `path` can't be read or contains no valid
9290
/// signatures. A successful matcher with zero signatures would
@@ -108,7 +106,6 @@ impl FlirtMatcher {
108106
// that's not what we want here.
109107
let mut builder = FlirtSetBuilder::new();
110108
let mut source_count = 0usize;
111-
let mut gz_skipped = 0usize;
112109

113110
for entry in walkdir::WalkDir::new(path)
114111
.follow_links(false)
@@ -149,11 +146,22 @@ impl FlirtMatcher {
149146
}
150147
}
151148
} else if lower.ends_with(".pat.gz") {
152-
// 0.4.x limitation: gzipped pat files not yet
153-
// unpacked. Counted separately so the summary line
154-
// can call attention to it.
155-
gz_skipped += 1;
156-
false
149+
// 0.5.0: gzipped .pat unpacked via fast-flirt's
150+
// pure-Rust gunzip (miniz_oxide). Pre-0.5.0 these
151+
// were silently counted-and-skipped.
152+
match std::fs::read(p) {
153+
Ok(bytes) => match builder.add_pat_gz(&bytes) {
154+
Ok(_) => true,
155+
Err(e) => {
156+
logger(&format!("flirt: failed to parse .pat.gz {}: {}", name, e));
157+
false
158+
}
159+
},
160+
Err(e) => {
161+
logger(&format!("flirt: failed to read .pat.gz {}: {}", name, e));
162+
false
163+
}
164+
}
157165
} else if lower.ends_with(".pat") {
158166
match std::fs::read_to_string(p) {
159167
Ok(text) => match builder.add_pat(&text) {
@@ -185,29 +193,18 @@ impl FlirtMatcher {
185193
let sig_count = set.len();
186194
if sig_count == 0 {
187195
return Err(Error::InvalidRuleFile(format!(
188-
"flirt: no signatures loaded from {} ({} sources attempted, {} .pat.gz skipped)",
196+
"flirt: no signatures loaded from {} ({} sources attempted)",
189197
path.display(),
190198
source_count,
191-
gz_skipped
192199
)));
193200
}
194201

195-
if gz_skipped > 0 {
196-
logger(&format!(
197-
"flirt: loaded {} signatures from {} files in {} ({} .pat.gz skipped — gunzip to enable)",
198-
sig_count,
199-
source_count,
200-
path.display(),
201-
gz_skipped
202-
));
203-
} else {
204-
logger(&format!(
205-
"flirt: loaded {} signatures from {} files in {}",
206-
sig_count,
207-
source_count,
208-
path.display()
209-
));
210-
}
202+
logger(&format!(
203+
"flirt: loaded {} signatures from {} files in {}",
204+
sig_count,
205+
source_count,
206+
path.display()
207+
));
211208

212209
Ok(Self {
213210
set,

0 commit comments

Comments
 (0)