Skip to main content

fnprint_core/
lib.rs

1//! Index / match / query, built on the loader + emulator + fingerprint + db.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6use anyhow::Result;
7use fnprint_db::{Corpus, Db};
8use fnprint_emu::{Config, MicroExec};
9use fnprint_loader::{Func, FuncSource};
10use fnprint_sig::Fingerprint;
11use rayon::prelude::*;
12
13/// below this we don't trust a match, thunks and tiny leaves all look alike
14pub const MIN_COMPLEXITY: u32 = 4;
15/// two prints this close are "the same function"
16pub const SAME_THRESH: f64 = 0.88;
17/// fixed seeds used per function. deterministic, so prints are reproducible.
18const SEEDS: [u64; 4] = [0, 0x9e3779b9, 0x1234_5678, 0xdead_beef];
19
20// unicorn/qemu keep process-global TCG (translation) state, so two engines
21// running at once in different rayon threads corrupt each other's translation
22// and the same binary fingerprints differently run to run. serialize the actual
23// emulation behind this lock: the loop still parallelizes, but only one
24// run_explore executes at a time, which restores byte-identical prints. the
25// non-emulation work (loading, fingerprint hashing) still overlaps. a future
26// fix can shard functions across worker processes to get parallelism back
27// without the shared globals; for now correctness wins over the extra cores.
28static EMU_LOCK: Mutex<()> = Mutex::new(());
29
30#[derive(serde::Serialize, serde::Deserialize)]
31pub struct IndexedFunc {
32    pub name: Option<String>,
33    pub entry: u64,
34    pub source: FuncSource,
35    pub fp: Fingerprint,
36}
37
38// re-exported so the cli's privsep worker can render dump output without
39// depending on the trace crate directly.
40pub use fnprint_trace::EffectTrace;
41// re-exported so the cli can validate a worker's fingerprint replies (sig length)
42// without a direct dep on the sig crate.
43pub use fnprint_sig::SIG_LEN;
44
45/// Force the rayon global pool to spawn its worker threads now. The sandboxed
46/// worker calls this before it jails itself, so the jail can forbid clone/clone3
47/// (no thread creation after lockdown) without starving the parallel index.
48pub fn warm_pool() {
49    let _: u64 = (0..256u64).into_par_iter().sum();
50}
51
52pub fn source_str(s: FuncSource) -> &'static str {
53    match s {
54        FuncSource::Symtab => "symtab",
55        FuncSource::DynSym => "dynsym",
56        FuncSource::EhFrame => "eh_frame",
57    }
58}
59
60/// micro-execute + fingerprint every discovered function in an ELF blob.
61pub fn index_bytes(bytes: &[u8], cfg: Config) -> Result<Vec<IndexedFunc>> {
62    let loaded = fnprint_loader::load(bytes)?;
63    let image = &loaded.image;
64
65    // entry -> name, so stubbed calls can be resolved to a symbol
66    let mut symbols: HashMap<u64, String> = HashMap::new();
67    for f in &loaded.funcs {
68        if let Some(n) = &f.name {
69            symbols.insert(f.entry, n.clone());
70        }
71    }
72
73    let out: Vec<IndexedFunc> = loaded
74        .funcs
75        .par_iter()
76        .filter(|f| f.size > 0 && image.code_at(f.entry, 1).is_some())
77        .map(|f: &Func| {
78            // hold the emu lock across engine build + run: engine creation also
79            // touches the shared TCG globals, so both must be serialized. recover
80            // a poisoned lock instead of panicking (a panic here would abort the
81            // whole index under panic=abort).
82            let traces = {
83                let _g = EMU_LOCK.lock().unwrap_or_else(|e| e.into_inner());
84                let ex = MicroExec::new(cfg.clone());
85                // a few deterministic seeds vary the input buffers so behavior
86                // that only shows up on some inputs still makes it into the print.
87                ex.run_explore(image, f, &symbols, &SEEDS)
88            };
89            IndexedFunc {
90                name: f.name.clone(),
91                entry: f.entry,
92                source: f.source,
93                fp: Fingerprint::from_traces(&traces),
94            }
95        })
96        .collect();
97
98    Ok(out)
99}
100
101pub fn index_to_db(bytes: &[u8], binary: &str, db: &Db, cfg: Config) -> Result<usize> {
102    let funcs = index_bytes(bytes, cfg)?;
103    for f in &funcs {
104        db.insert(
105            binary,
106            f.name.as_deref(),
107            f.entry,
108            source_str(f.source),
109            &f.fp,
110        )?;
111    }
112    Ok(funcs.len())
113}
114
115// -------- match (n-day / cross-version diff) --------
116
117pub struct Changed {
118    pub name: String,
119    pub similarity: f64,
120}
121
122#[derive(Default)]
123pub struct MatchReport {
124    pub same: usize,
125    pub changed: Vec<Changed>,
126    pub only_a: Vec<String>,
127    pub only_b: Vec<String>,
128    pub compared: usize,
129    /// present in both but too little signal to judge (tiny/scalar helpers)
130    pub low_signal: usize,
131}
132
133/// align two indexes by symbol name and report which shared functions actually
134/// changed behavior. this is the "what did the vendor quietly patch" view.
135pub fn match_by_name(a: &[IndexedFunc], b: &[IndexedFunc]) -> MatchReport {
136    let mut bmap: HashMap<&str, &IndexedFunc> = HashMap::new();
137    for f in b {
138        if let Some(n) = &f.name {
139            bmap.insert(n.as_str(), f);
140        }
141    }
142    let mut amap: HashMap<&str, &IndexedFunc> = HashMap::new();
143    for f in a {
144        if let Some(n) = &f.name {
145            amap.insert(n.as_str(), f);
146        }
147    }
148
149    let mut rep = MatchReport::default();
150    for (name, fa) in &amap {
151        match bmap.get(name) {
152            Some(fb) => {
153                rep.compared += 1;
154                // don't cry wolf on thunks/scalar helpers: not enough behavior
155                // to tell "changed" from "recompiled the same".
156                if fa.fp.complexity < MIN_COMPLEXITY || fb.fp.complexity < MIN_COMPLEXITY {
157                    rep.low_signal += 1;
158                    continue;
159                }
160                let sim = fa.fp.similarity(&fb.fp);
161                if sim >= SAME_THRESH {
162                    rep.same += 1;
163                } else {
164                    rep.changed.push(Changed {
165                        name: name.to_string(),
166                        similarity: sim,
167                    });
168                }
169            }
170            None => rep.only_a.push(name.to_string()),
171        }
172    }
173    for name in bmap.keys() {
174        if !amap.contains_key(name) {
175            rep.only_b.push(name.to_string());
176        }
177    }
178    rep.changed
179        .sort_by(|x, y| x.similarity.total_cmp(&y.similarity));
180    rep.only_a.sort();
181    rep.only_b.sort();
182    rep
183}
184
185// -------- query (auto-name against a corpus) --------
186
187pub struct Named {
188    pub entry: u64,
189    pub guess: String,
190    pub from_binary: String,
191    pub similarity: f64,
192}
193
194/// best-scoring named function in a corpus for one print. narrows with the LSH
195/// bands first and falls back to the full set if no band hit. returns
196/// (similarity, name, binary). the preloaded `all` is the fallback pool.
197fn best_in_corpus<C: Corpus>(
198    fp: &Fingerprint,
199    db: &C,
200    all: &[fnprint_db::FuncRec],
201) -> Result<Option<(f64, String, String)>> {
202    let cands = db.candidates(fp)?;
203    let pool: &[fnprint_db::FuncRec] = if cands.is_empty() { all } else { &cands };
204    let mut best: Option<(f64, String, String)> = None;
205    for c in pool {
206        if c.fp.complexity < MIN_COMPLEXITY {
207            continue;
208        }
209        let cname = match &c.name {
210            Some(n) => n,
211            None => continue,
212        };
213        let sim = fp.similarity(&c.fp);
214        if best.as_ref().map(|(s, _, _)| sim > *s).unwrap_or(true) {
215            best = Some((sim, cname.clone(), c.binary.clone()));
216        }
217    }
218    Ok(best)
219}
220
221/// for each function in the target that we can trust, pull the best-matching
222/// named function out of the corpus db. withholds tiny/low-signal functions.
223pub fn query_corpus<C: Corpus>(
224    target: &[IndexedFunc],
225    corpus: &C,
226    threshold: f64,
227) -> Result<Vec<Named>> {
228    let named = corpus.all()?; // small corpora, fine to hold in memory
229    let mut out = Vec::new();
230    for f in target {
231        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
232            continue;
233        }
234        if let Some((sim, name, bin)) = best_in_corpus(&f.fp, corpus, &named)? {
235            if sim >= threshold {
236                out.push(Named {
237                    entry: f.entry,
238                    guess: name,
239                    from_binary: bin,
240                    similarity: sim,
241                });
242            }
243        }
244    }
245    out.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
246    Ok(out)
247}
248
249// -------- triage (n-day: vulnerable vs patched, the actionable view) --------
250
251#[derive(Clone, Copy, PartialEq, Eq, Debug)]
252pub enum Verdict {
253    /// leans toward the known-vulnerable version, clear of the patched one
254    Vulnerable,
255    /// leans toward the patched version
256    Patched,
257    /// neither side is close enough, or the two are too close to separate
258    Inconclusive,
259}
260
261pub struct TriageHit {
262    pub entry: u64,
263    pub verdict: Verdict,
264    pub vuln_sim: f64,
265    pub vuln_name: String,
266    pub patched_sim: f64,
267    pub patched_name: String,
268}
269
270impl TriageHit {
271    /// how far the vulnerable side leads the patched side. negative means it
272    /// looks patched. this is the separation the reviewer actually cares about.
273    pub fn margin(&self) -> f64 {
274        self.vuln_sim - self.patched_sim
275    }
276}
277
278fn verdict_order(v: Verdict) -> u8 {
279    // vulnerable-leaning to the top of the review queue, patched to the bottom
280    match v {
281        Verdict::Vulnerable => 0,
282        Verdict::Inconclusive => 1,
283        Verdict::Patched => 2,
284    }
285}
286
287/// rank each target function against a known-vulnerable corpus and a known-patched
288/// corpus and call which side it leans to. a function close to the vulnerable
289/// version and clearly separated from the patched one is a candidate worth a
290/// human's time, which is more useful for n-day work than a single match score.
291///
292/// `min_sim`: a side has to be at least this similar to count as a real lead.
293/// `margin`: how far the two sides must separate before we commit to a verdict.
294/// the result is sorted as a review queue, strongest vulnerable lead first.
295pub fn triage<C: Corpus>(
296    target: &[IndexedFunc],
297    vuln: &C,
298    patched: &C,
299    min_sim: f64,
300    margin: f64,
301) -> Result<Vec<TriageHit>> {
302    let vuln_all = vuln.all()?;
303    let patched_all = patched.all()?;
304    let mut out = Vec::new();
305    for f in target {
306        if f.fp.complexity < MIN_COMPLEXITY || f.fp.shingles == 0 {
307            continue;
308        }
309        let (vuln_sim, vuln_name) = best_in_corpus(&f.fp, vuln, &vuln_all)?
310            .map(|(s, n, _)| (s, n))
311            .unwrap_or((0.0, String::new()));
312        let (patched_sim, patched_name) = best_in_corpus(&f.fp, patched, &patched_all)?
313            .map(|(s, n, _)| (s, n))
314            .unwrap_or((0.0, String::new()));
315
316        let top = vuln_sim.max(patched_sim);
317        let verdict = if top < min_sim {
318            Verdict::Inconclusive
319        } else if vuln_sim - patched_sim >= margin {
320            Verdict::Vulnerable
321        } else if patched_sim - vuln_sim >= margin {
322            Verdict::Patched
323        } else {
324            Verdict::Inconclusive
325        };
326        out.push(TriageHit {
327            entry: f.entry,
328            verdict,
329            vuln_sim,
330            vuln_name,
331            patched_sim,
332            patched_name,
333        });
334    }
335    out.sort_by(|a, b| {
336        verdict_order(a.verdict)
337            .cmp(&verdict_order(b.verdict))
338            .then(b.vuln_sim.total_cmp(&a.vuln_sim))
339    });
340    Ok(out)
341}
342
343// -------- eval (accuracy metrics against symbol-name ground truth) --------
344
345pub struct EvalResult {
346    /// functions in A that we scored (had signal and a same-named twin in B)
347    pub scored: usize,
348    /// top-1 ranked match in B is the same-named function
349    pub rank1: usize,
350    /// sum of 1/rank of the correct match, for mean reciprocal rank
351    pub rr_sum: f64,
352    /// at SAME_THRESH: predicted-same that are actually same-named
353    pub tp: usize,
354    pub fp: usize,
355    /// same-named pairs we failed to call same
356    pub fn_: usize,
357    /// 1-based rank of the correct match for each scored function. lets us ask
358    /// "does reviewing the top k candidates find it", not just the top-1 number.
359    pub ranks: Vec<usize>,
360    /// scored functions where the top-1 similarity was below SAME_THRESH, i.e.
361    /// the tool would decline to make a confident call rather than guess.
362    pub abstained: usize,
363}
364
365impl EvalResult {
366    pub fn rank1_acc(&self) -> f64 {
367        if self.scored == 0 {
368            0.0
369        } else {
370            self.rank1 as f64 / self.scored as f64
371        }
372    }
373    pub fn mrr(&self) -> f64 {
374        if self.scored == 0 {
375            0.0
376        } else {
377            self.rr_sum / self.scored as f64
378        }
379    }
380    pub fn precision(&self) -> f64 {
381        let d = self.tp + self.fp;
382        if d == 0 {
383            0.0
384        } else {
385            self.tp as f64 / d as f64
386        }
387    }
388    pub fn recall(&self) -> f64 {
389        let d = self.tp + self.fn_;
390        if d == 0 {
391            0.0
392        } else {
393            self.tp as f64 / d as f64
394        }
395    }
396    /// fraction of scored functions whose correct match lands in the top k.
397    /// recall@5 answers "if an analyst looks at 5 candidates, do they find it".
398    pub fn recall_at(&self, k: usize) -> f64 {
399        if self.scored == 0 {
400            return 0.0;
401        }
402        let hits = self.ranks.iter().filter(|&&r| r <= k).count();
403        hits as f64 / self.scored as f64
404    }
405    /// how often the tool declined a confident top-1 call. high abstention with
406    /// high precision is the honest tradeoff: quiet when it isn't sure.
407    pub fn abstain_rate(&self) -> f64 {
408        if self.scored == 0 {
409            0.0
410        } else {
411            self.abstained as f64 / self.scored as f64
412        }
413    }
414}
415
416/// rank every signal-bearing function in A against all of B, using symbol names
417/// as ground truth. this is the headline accuracy measurement.
418pub fn eval(a: &[IndexedFunc], b: &[IndexedFunc]) -> EvalResult {
419    let bsig: Vec<&IndexedFunc> = b
420        .iter()
421        .filter(|f| f.fp.complexity >= MIN_COMPLEXITY && f.fp.shingles > 0 && f.name.is_some())
422        .collect();
423
424    let mut res = EvalResult {
425        scored: 0,
426        rank1: 0,
427        rr_sum: 0.0,
428        tp: 0,
429        fp: 0,
430        fn_: 0,
431        ranks: Vec::new(),
432        abstained: 0,
433    };
434
435    for fa in a {
436        if fa.fp.complexity < MIN_COMPLEXITY || fa.fp.shingles == 0 {
437            continue;
438        }
439        let aname = match &fa.name {
440            Some(n) => n.as_str(),
441            None => continue,
442        };
443        // only score functions that actually exist in B (a fair denominator)
444        if !bsig.iter().any(|f| f.name.as_deref() == Some(aname)) {
445            continue;
446        }
447        // rank B by similarity. bsig is prefiltered to named funcs, but use
448        // filter_map + first() so a later filter change can't unwrap or panic.
449        let mut scored: Vec<(f64, &str)> = bsig
450            .iter()
451            .filter_map(|f| f.name.as_deref().map(|n| (fa.fp.similarity(&f.fp), n)))
452            .collect();
453        scored.sort_by(|x, y| y.0.total_cmp(&x.0));
454        let Some(&(top_sim, top_name)) = scored.first() else {
455            continue; // unreachable: the twin check above guarantees a named hit
456        };
457        res.scored += 1;
458
459        if top_name == aname {
460            res.rank1 += 1;
461        }
462        if let Some(pos) = scored.iter().position(|(_, n)| *n == aname) {
463            res.rr_sum += 1.0 / (pos as f64 + 1.0);
464            res.ranks.push(pos + 1); // 1-based rank for recall@k
465        }
466
467        // threshold-based precision/recall on the top-1 call
468        let predicted_same = top_sim >= SAME_THRESH;
469        if !predicted_same {
470            res.abstained += 1; // below threshold, we'd decline to call it
471        }
472        let correct = top_name == aname;
473        match (predicted_same, correct) {
474            (true, true) => res.tp += 1,
475            (true, false) => res.fp += 1,
476            (false, true) => res.fn_ += 1,
477            (false, false) => {}
478        }
479    }
480    res
481}
482
483/// debug helper: micro-execute one named function and return its effect traces
484/// (one per seed/path). used by `fnprint dump` to see what the engine records.
485pub fn dump_traces(
486    bytes: &[u8],
487    name: &str,
488    cfg: Config,
489) -> Result<Vec<fnprint_trace::EffectTrace>> {
490    let loaded = fnprint_loader::load(bytes)?;
491    let image = &loaded.image;
492    let mut symbols: HashMap<u64, String> = HashMap::new();
493    for f in &loaded.funcs {
494        if let Some(n) = &f.name {
495            symbols.insert(f.entry, n.clone());
496        }
497    }
498    let f = loaded
499        .funcs
500        .iter()
501        .find(|f| f.name.as_deref() == Some(name))
502        .ok_or_else(|| anyhow::anyhow!("no function named {name}"))?;
503    let ex = MicroExec::new(cfg);
504    Ok(ex.run_explore(image, f, &symbols, &SEEDS))
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    // a tiny position-independent ELF-less path isn't easy here, so we test the
512    // matcher/eval logic on hand-built indexes instead. loader+emu are covered
513    // in their own crates and end-to-end by the bench harness.
514    fn ifunc(name: &str, sig_seed: u64, complexity: u32) -> IndexedFunc {
515        IndexedFunc {
516            name: Some(name.to_string()),
517            entry: 0,
518            source: FuncSource::Symtab,
519            fp: fnprint_sig::Fingerprint {
520                sig: (0..fnprint_sig::SIG_LEN as u64)
521                    .map(|i| i.wrapping_mul(sig_seed))
522                    .collect(),
523                shingles: 20,
524                complexity,
525                capped: false,
526            },
527        }
528    }
529
530    #[test]
531    fn identical_indexes_report_no_changes() {
532        let a = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
533        let b = vec![ifunc("foo", 3, 10), ifunc("bar", 7, 10)];
534        let rep = match_by_name(&a, &b);
535        assert_eq!(rep.changed.len(), 0);
536        assert_eq!(rep.same, 2);
537    }
538
539    #[test]
540    fn changed_behavior_is_flagged() {
541        let a = vec![ifunc("foo", 3, 10)];
542        let b = vec![ifunc("foo", 999, 10)]; // very different sig
543        let rep = match_by_name(&a, &b);
544        assert_eq!(rep.changed.len(), 1);
545    }
546
547    #[test]
548    fn low_signal_not_called_changed() {
549        // same name, low complexity on one side -> low_signal, never "changed"
550        let a = vec![ifunc("foo", 3, 2)];
551        let b = vec![ifunc("foo", 999, 2)];
552        let rep = match_by_name(&a, &b);
553        assert_eq!(rep.changed.len(), 0);
554        assert_eq!(rep.low_signal, 1);
555    }
556
557    #[test]
558    fn triage_leans_to_matching_side() {
559        // vuln corpus holds the function at seed 3, patched holds it at seed 999.
560        // a target that behaves like seed 3 must come back Vulnerable, and one
561        // like seed 999 must come back Patched.
562        let vuln = Db::open_memory().unwrap();
563        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
564            .unwrap();
565        let patched = Db::open_memory().unwrap();
566        patched
567            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
568            .unwrap();
569
570        let looks_vuln = triage(&[ifunc("x", 3, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
571        assert_eq!(looks_vuln[0].verdict, Verdict::Vulnerable);
572        assert!(looks_vuln[0].margin() > 0.0);
573
574        let looks_patched = triage(&[ifunc("x", 999, 10)], &vuln, &patched, 0.5, 0.1).unwrap();
575        assert_eq!(looks_patched[0].verdict, Verdict::Patched);
576    }
577
578    #[test]
579    fn triage_abstains_when_nothing_close() {
580        // target matches neither side -> below min_sim -> Inconclusive
581        let vuln = Db::open_memory().unwrap();
582        vuln.insert("v1", Some("f"), 0x1000, "symtab", &ifunc("f", 3, 10).fp)
583            .unwrap();
584        let patched = Db::open_memory().unwrap();
585        patched
586            .insert("v2", Some("f"), 0x1000, "symtab", &ifunc("f", 999, 10).fp)
587            .unwrap();
588
589        let hits = triage(&[ifunc("x", 55555, 10)], &vuln, &patched, 0.9, 0.1).unwrap();
590        assert_eq!(hits[0].verdict, Verdict::Inconclusive);
591    }
592}