fnprint-core 0.2.5

Index, match, and query pipelines for fnprint
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
//! Index / match / query, built on the loader + emulator + fingerprint + db.

use std::collections::HashMap;
use std::sync::Mutex;

use anyhow::Result;
use fnprint_db::{Corpus, Db};
use fnprint_emu::{Config, MicroExec};
use fnprint_loader::{Func, FuncSource};
use fnprint_sig::Fingerprint;
use rayon::prelude::*;

/// below this we don't trust a match, thunks and tiny leaves all look alike
pub const MIN_COMPLEXITY: u32 = 4;
/// two prints this close are "the same function"
pub const SAME_THRESH: f64 = 0.88;
/// fixed seeds used per function. deterministic, so prints are reproducible.
const SEEDS: [u64; 4] = [0, 0x9e3779b9, 0x1234_5678, 0xdead_beef];

// unicorn/qemu keep process-global TCG (translation) state, so two engines
// running at once in different rayon threads corrupt each other's translation
// and the same binary fingerprints differently run to run. serialize the actual
// emulation behind this lock: the loop still parallelizes, but only one
// run_explore executes at a time, which restores byte-identical prints. the
// non-emulation work (loading, fingerprint hashing) still overlaps. a future
// fix can shard functions across worker processes to get parallelism back
// without the shared globals; for now correctness wins over the extra cores.
static EMU_LOCK: Mutex<()> = Mutex::new(());

#[derive(serde::Serialize, serde::Deserialize)]
pub struct IndexedFunc {
    pub name: Option<String>,
    pub entry: u64,
    pub source: FuncSource,
    pub fp: Fingerprint,
}

// re-exported so the cli's privsep worker can render dump output without
// depending on the trace crate directly.
pub use fnprint_trace::EffectTrace;
// re-exported so the cli can validate a worker's fingerprint replies (sig length)
// without a direct dep on the sig crate.
pub use fnprint_sig::SIG_LEN;

/// Force the rayon global pool to spawn its worker threads now. The sandboxed
/// worker calls this before it jails itself, so the jail can forbid clone/clone3
/// (no thread creation after lockdown) without starving the parallel index.
pub fn warm_pool() {
    let _: u64 = (0..256u64).into_par_iter().sum();
}

pub fn source_str(s: FuncSource) -> &'static str {
    match s {
        FuncSource::Symtab => "symtab",
        FuncSource::DynSym => "dynsym",
        FuncSource::EhFrame => "eh_frame",
    }
}

/// micro-execute + fingerprint every discovered function in an ELF blob.
pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
    let loaded = fnprint_loader::load(bytes)?;
    let image = &loaded.image;

    // entry -> name, so stubbed calls can be resolved to a symbol
    let mut symbols: HashMap<u64, String> = HashMap::new();
    for f in &loaded.funcs {
        if let Some(n) = &f.name {
            symbols.insert(f.entry, n.clone());
        }
    }

    let out: Vec<IndexedFunc> = loaded
        .funcs
        .par_iter()
        .filter(|f| f.size > 0 && image.code_at(f.entry, 1).is_some())
        .map(|f: &Func| {
            // hold the emu lock across engine build + run: engine creation also
            // touches the shared TCG globals, so both must be serialized. recover
            // a poisoned lock instead of panicking (a panic here would abort the
            // whole index under panic=abort).
            let traces = {
                let _g = EMU_LOCK.lock().unwrap_or_else(|e| e.into_inner());
                let ex = MicroExec::new(cfg.clone());
                // a few deterministic seeds vary the input buffers so behavior
                // that only shows up on some inputs still makes it into the print.
                ex.run_explore(image, f, &symbols, &SEEDS)
            };
            IndexedFunc {
                name: f.name.clone(),
                entry: f.entry,
                source: f.source,
                fp: Fingerprint::from_traces(&traces),
            }
        })
        .collect();

    Ok(out)
}

pub fn index_to_db(bytes: &[u8], binary: &str, db: &Db, cfg: Config) -> Result<usize> {
    let funcs = index_bytes(bytes, cfg)?;
    for f in &funcs {
        db.insert(
            binary,
            f.name.as_deref(),
            f.entry,
            source_str(f.source),
            &f.fp,
        )?;
    }
    Ok(funcs.len())
}

// -------- match (n-day / cross-version diff) --------

pub struct Changed {
    pub name: String,
    pub similarity: f64,
}

#[derive(Default)]
pub struct MatchReport {
    pub same: usize,
    pub changed: Vec<Changed>,
    pub only_a: Vec<String>,
    pub only_b: Vec<String>,
    pub compared: usize,
    /// present in both but too little signal to judge (tiny/scalar helpers)
    pub low_signal: usize,
}

/// align two indexes by symbol name and report which shared functions actually
/// changed behavior. this is the "what did the vendor quietly patch" view.
pub fn match_by_name(a: &[IndexedFunc], b: &[IndexedFunc]) -> MatchReport {
    let mut bmap: HashMap<&str, &IndexedFunc> = HashMap::new();
    for f in b {
        if let Some(n) = &f.name {
            bmap.insert(n.as_str(), f);
        }
    }
    let mut amap: HashMap<&str, &IndexedFunc> = HashMap::new();
    for f in a {
        if let Some(n) = &f.name {
            amap.insert(n.as_str(), f);
        }
    }

    let mut rep = MatchReport::default();
    for (name, fa) in &amap {
        match bmap.get(name) {
            Some(fb) => {
                rep.compared += 1;
                // don't cry wolf on thunks/scalar helpers: not enough behavior
                // to tell "changed" from "recompiled the same".
                if fa.fp.complexity < MIN_COMPLEXITY || fb.fp.complexity < MIN_COMPLEXITY {
                    rep.low_signal += 1;
                    continue;
                }
                let sim = fa.fp.similarity(&fb.fp);
                if sim >= SAME_THRESH {
                    rep.same += 1;
                } else {
                    rep.changed.push(Changed {
                        name: name.to_string(),
                        similarity: sim,
                    });
                }
            }
            None => rep.only_a.push(name.to_string()),
        }
    }
    for name in bmap.keys() {
        if !amap.contains_key(name) {
            rep.only_b.push(name.to_string());
        }
    }
    rep.changed
        .sort_by(|x, y| x.similarity.total_cmp(&y.similarity));
    rep.only_a.sort();
    rep.only_b.sort();
    rep
}

// -------- query (auto-name against a corpus) --------

pub struct Named {
    pub entry: u64,
    pub guess: String,
    pub from_binary: String,
    pub similarity: f64,
}

/// best-scoring named function in a corpus for one print. narrows with the LSH
/// bands first and falls back to the full set if no band hit. returns
/// (similarity, name, binary). the preloaded `all` is the fallback pool.
fn best_in_corpus<C: Corpus>(
    fp: &Fingerprint,
    db: &C,
    all: &[fnprint_db::FuncRec],
) -> Result<Option<(f64, String, String)>> {
    let cands = db.candidates(fp)?;
    let pool: &[fnprint_db::FuncRec] = if cands.is_empty() { all } else { &cands };
    let mut best: Option<(f64, String, String)> = None;
    for c in pool {
        if c.fp.complexity < MIN_COMPLEXITY {
            continue;
        }
        let cname = match &c.name {
            Some(n) => n,
            None => continue,
        };
        let sim = fp.similarity(&c.fp);
        if best.as_ref().map(|(s, _, _)| sim > *s).unwrap_or(true) {
            best = Some((sim, cname.clone(), c.binary.clone()));
        }
    }
    Ok(best)
}

/// for each function in the target that we can trust, pull the best-matching
/// named function out of the corpus db. withholds tiny/low-signal functions.
pub fn query_corpus<C: Corpus>(
    target: &[IndexedFunc],
    corpus: &C,
    threshold: f64,
) -> Result<Vec<Named>> {
    let named = corpus.all()?; // small corpora, fine to hold in memory
    let mut out = Vec::new();
    for f in target {
        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
            continue;
        }
        if let Some((sim, name, bin)) = best_in_corpus(&f.fp, corpus, &named)? {
            if sim >= threshold {
                out.push(Named {
                    entry: f.entry,
                    guess: name,
                    from_binary: bin,
                    similarity: sim,
                });
            }
        }
    }
    out.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
    Ok(out)
}

// -------- triage (n-day: vulnerable vs patched, the actionable view) --------

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Verdict {
    /// leans toward the known-vulnerable version, clear of the patched one
    Vulnerable,
    /// leans toward the patched version
    Patched,
    /// neither side is close enough, or the two are too close to separate
    Inconclusive,
}

pub struct TriageHit {
    pub entry: u64,
    pub verdict: Verdict,
    pub vuln_sim: f64,
    pub vuln_name: String,
    pub patched_sim: f64,
    pub patched_name: String,
}

impl TriageHit {
    /// how far the vulnerable side leads the patched side. negative means it
    /// looks patched. this is the separation the reviewer actually cares about.
    pub fn margin(&self) -> f64 {
        self.vuln_sim - self.patched_sim
    }
}

fn verdict_order(v: Verdict) -> u8 {
    // vulnerable-leaning to the top of the review queue, patched to the bottom
    match v {
        Verdict::Vulnerable => 0,
        Verdict::Inconclusive => 1,
        Verdict::Patched => 2,
    }
}

/// rank each target function against a known-vulnerable corpus and a known-patched
/// corpus and call which side it leans to. a function close to the vulnerable
/// version and clearly separated from the patched one is a candidate worth a
/// human's time, which is more useful for n-day work than a single match score.
///
/// `min_sim`: a side has to be at least this similar to count as a real lead.
/// `margin`: how far the two sides must separate before we commit to a verdict.
/// the result is sorted as a review queue, strongest vulnerable lead first.
pub fn triage<C: Corpus>(
    target: &[IndexedFunc],
    vuln: &C,
    patched: &C,
    min_sim: f64,
    margin: f64,
) -> Result<Vec<TriageHit>> {
    let vuln_all = vuln.all()?;
    let patched_all = patched.all()?;
    let mut out = Vec::new();
    for f in target {
        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
            continue;
        }
        let (vuln_sim, vuln_name) = best_in_corpus(&f.fp, vuln, &vuln_all)?
            .map(|(s, n, _)| (s, n))
            .unwrap_or((0.0, String::new()));
        let (patched_sim, patched_name) = best_in_corpus(&f.fp, patched, &patched_all)?
            .map(|(s, n, _)| (s, n))
            .unwrap_or((0.0, String::new()));

        let top = vuln_sim.max(patched_sim);
        let verdict = if top < min_sim {
            Verdict::Inconclusive
        } else if vuln_sim - patched_sim >= margin {
            Verdict::Vulnerable
        } else if patched_sim - vuln_sim >= margin {
            Verdict::Patched
        } else {
            Verdict::Inconclusive
        };
        out.push(TriageHit {
            entry: f.entry,
            verdict,
            vuln_sim,
            vuln_name,
            patched_sim,
            patched_name,
        });
    }
    out.sort_by(|a, b| {
        verdict_order(a.verdict)
            .cmp(&verdict_order(b.verdict))
            .then(b.vuln_sim.total_cmp(&a.vuln_sim))
    });
    Ok(out)
}

// -------- eval (accuracy metrics against symbol-name ground truth) --------

pub struct EvalResult {
    /// functions in A that we scored (had signal and a same-named twin in B)
    pub scored: usize,
    /// top-1 ranked match in B is the same-named function
    pub rank1: usize,
    /// sum of 1/rank of the correct match, for mean reciprocal rank
    pub rr_sum: f64,
    /// at SAME_THRESH: predicted-same that are actually same-named
    pub tp: usize,
    pub fp: usize,
    /// same-named pairs we failed to call same
    pub fn_: usize,
    /// 1-based rank of the correct match for each scored function. lets us ask
    /// "does reviewing the top k candidates find it", not just the top-1 number.
    pub ranks: Vec<usize>,
    /// scored functions where the top-1 similarity was below SAME_THRESH, i.e.
    /// the tool would decline to make a confident call rather than guess.
    pub abstained: usize,
}

impl EvalResult {
    pub fn rank1_acc(&self) -> f64 {
        if self.scored == 0 {
            0.0
        } else {
            self.rank1 as f64 / self.scored as f64
        }
    }
    pub fn mrr(&self) -> f64 {
        if self.scored == 0 {
            0.0
        } else {
            self.rr_sum / self.scored as f64
        }
    }
    pub fn precision(&self) -> f64 {
        let d = self.tp + self.fp;
        if d == 0 {
            0.0
        } else {
            self.tp as f64 / d as f64
        }
    }
    pub fn recall(&self) -> f64 {
        let d = self.tp + self.fn_;
        if d == 0 {
            0.0
        } else {
            self.tp as f64 / d as f64
        }
    }
    /// fraction of scored functions whose correct match lands in the top k.
    /// recall@5 answers "if an analyst looks at 5 candidates, do they find it".
    pub fn recall_at(&self, k: usize) -> f64 {
        if self.scored == 0 {
            return 0.0;
        }
        let hits = self.ranks.iter().filter(|&&r| r <= k).count();
        hits as f64 / self.scored as f64
    }
    /// how often the tool declined a confident top-1 call. high abstention with
    /// high precision is the honest tradeoff: quiet when it isn't sure.
    pub fn abstain_rate(&self) -> f64 {
        if self.scored == 0 {
            0.0
        } else {
            self.abstained as f64 / self.scored as f64
        }
    }
}

/// rank every signal-bearing function in A against all of B, using symbol names
/// as ground truth. this is the headline accuracy measurement.
pub fn eval(a: &[IndexedFunc], b: &[IndexedFunc]) -> EvalResult {
    let bsig: Vec<&IndexedFunc> = b
        .iter()
        .filter(|f| f.fp.complexity >= MIN_COMPLEXITY && f.fp.shingles > 0 && f.name.is_some())
        .collect();

    let mut res = EvalResult {
        scored: 0,
        rank1: 0,
        rr_sum: 0.0,
        tp: 0,
        fp: 0,
        fn_: 0,
        ranks: Vec::new(),
        abstained: 0,
    };

    for fa in a {
        if fa.fp.complexity < MIN_COMPLEXITY || fa.fp.shingles == 0 {
            continue;
        }
        let aname = match &fa.name {
            Some(n) => n.as_str(),
            None => continue,
        };
        // only score functions that actually exist in B (a fair denominator)
        if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
            continue;
        }
        // rank B by similarity. bsig is prefiltered to named funcs, but use
        // filter_map + first() so a later filter change can't unwrap or panic.
        let mut scored: Vec<(f64, &str)> = bsig
            .iter()
            .filter_map(|f| f.name.as_deref().map(|n| (fa.fp.similarity(&f.fp), n)))
            .collect();
        scored.sort_by(|x, y| y.0.total_cmp(&x.0));
        let Some(&(top_sim, top_name)) = scored.first() else {
            continue; // unreachable: the twin check above guarantees a named hit
        };
        res.scored += 1;

        if top_name == aname {
            res.rank1 += 1;
        }
        if let Some(pos) = scored.iter().position(|(_, n)| *n == aname) {
            res.rr_sum += 1.0 / (pos as f64 + 1.0);
            res.ranks.push(pos + 1); // 1-based rank for recall@k
        }

        // threshold-based precision/recall on the top-1 call
        let predicted_same = top_sim >= SAME_THRESH;
        if !predicted_same {
            res.abstained += 1; // below threshold, we'd decline to call it
        }
        let correct = top_name == aname;
        match (predicted_same, correct) {
            (true, true) => res.tp += 1,
            (true, false) => res.fp += 1,
            (false, true) => res.fn_ += 1,
            (false, false) => {}
        }
    }
    res
}

/// debug helper: micro-execute one named function and return its effect traces
/// (one per seed/path). used by `fnprint dump` to see what the engine records.
pub fn dump_traces(
    bytes: &[u8],
    name: &str,
    cfg: Config,
) -> Result<Vec<fnprint_trace::EffectTrace>> {
    let loaded = fnprint_loader::load(bytes)?;
    let image = &loaded.image;
    let mut symbols: HashMap<u64, String> = HashMap::new();
    for f in &loaded.funcs {
        if let Some(n) = &f.name {
            symbols.insert(f.entry, n.clone());
        }
    }
    let f = loaded
        .funcs
        .iter()
        .find(|f| f.name.as_deref() == Some(name))
        .ok_or_else(|| anyhow::anyhow!("no function named {name}"))?;
    let ex = MicroExec::new(cfg);
    Ok(ex.run_explore(image, f, &symbols, &SEEDS))
}

#[cfg(test)]
mod tests {
    use super::*;

    // a tiny position-independent ELF-less path isn't easy here, so we test the
    // matcher/eval logic on hand-built indexes instead. loader+emu are covered
    // in their own crates and end-to-end by the bench harness.
    fn ifunc(name: &str, sig_seed: u64, complexity: u32) -> IndexedFunc {
        IndexedFunc {
            name: Some(name.to_string()),
            entry: 0,
            source: FuncSource::Symtab,
            fp: fnprint_sig::Fingerprint {
                sig: (0..fnprint_sig::SIG_LEN as u64)
                    .map(|i| i.wrapping_mul(sig_seed))
                    .collect(),
                shingles: 20,
                complexity,
                capped: false,
            },
        }
    }

    #[test]
    fn identical_indexes_report_no_changes() {
        let a = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
        let b = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
        let rep = match_by_name(&a, &b);
        assert_eq!(rep.changed.len(), 0);
        assert_eq!(rep.same, 2);
    }

    #[test]
    fn changed_behavior_is_flagged() {
        let a = vec![ifunc("foo", 3, 10)];
        let b = vec![ifunc("foo", 999, 10)]; // very different sig
        let rep = match_by_name(&a, &b);
        assert_eq!(rep.changed.len(), 1);
    }

    #[test]
    fn low_signal_not_called_changed() {
        // same name, low complexity on one side -> low_signal, never "changed"
        let a = vec![ifunc("foo", 3, 2)];
        let b = vec![ifunc("foo", 999, 2)];
        let rep = match_by_name(&a, &b);
        assert_eq!(rep.changed.len(), 0);
        assert_eq!(rep.low_signal, 1);
    }

    #[test]
    fn triage_leans_to_matching_side() {
        // vuln corpus holds the function at seed 3, patched holds it at seed 999.
        // a target that behaves like seed 3 must come back Vulnerable, and one
        // like seed 999 must come back Patched.
        let vuln = Db::open_memory().unwrap();
        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
            .unwrap();
        let patched = Db::open_memory().unwrap();
        patched
            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
            .unwrap();

        let looks_vuln = triage(&[ifunc("x", 3, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
        assert_eq!(looks_vuln[0].verdict, Verdict::Vulnerable);
        assert!(looks_vuln[0].margin() > 0.0);

        let looks_patched = triage(&[ifunc("x", 999, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
        assert_eq!(looks_patched[0].verdict, Verdict::Patched);
    }

    #[test]
    fn triage_abstains_when_nothing_close() {
        // target matches neither side -> below min_sim -> Inconclusive
        let vuln = Db::open_memory().unwrap();
        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
            .unwrap();
        let patched = Db::open_memory().unwrap();
        patched
            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
            .unwrap();

        let hits = triage(&[ifunc("x", 55555, 10)], &vuln, &patched, 0.9, 0.1).unwrap();
        assert_eq!(hits[0].verdict, Verdict::Inconclusive);
    }
}