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