Skip to main content

linux_abi_scan/
lib.rs

1//! Static inspection of a built x86_64 Linux executable or shared object
2//! against a target's glibc and kernel floors: **tier 1** of the testing
3//! tiers.
4//!
5//! | Tier | How | Reliability |
6//! | --- | --- | --- |
7//! | 1. Inspect (this crate) | Read the ELF file; nothing runs | Exact for symbols, versions, and ELF notes; heuristic for syscalls in code |
8//! | 2. Simulate | Run tests under a seccomp filter or in a container (`kernel-abi-tools`) | Catches what actually executes; syscall availability and `madvise` only |
9//! | 3. Qualify | Run on a real host at the target's minimums (e.g. SLES 12 SP5) | The only qualification |
10//!
11//! A scan reports what the file *could* do, not what it *does*: it cannot see
12//! syscalls made inside the C library, numbers computed at run time,
13//! libraries loaded with `dlopen`, new flags on old syscalls, or whether a
14//! program falls back when a call fails. Findings carry a [`Confidence`].
15//!
16//! The kernel data is the same that the seccomp profiles are built from
17//! (`kernel_abi_tools::syscalls`, `madvise_advice`, and a preset's
18//! backports), so tiers 1 and 2 cannot disagree about what a kernel has.
19
20pub mod elf;
21
22use kernel_abi_tools::{
23    madvise_accepts, madvise_advice, present_syscalls, syscalls, KernelVersion, Target,
24};
25use linux_targets::{GlibcVersion, Selection};
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt::Write as _;
28use std::path::Path;
29
30/// What to check against. `None` skips that side's checks (host glibc or
31/// host kernel), and the report says so.
32pub struct ScanTarget {
33    pub glibc: Option<GlibcVersion>,
34    pub kernel: Option<Target>,
35    pub label: String,
36}
37
38impl ScanTarget {
39    /// The floors of a `linux_targets` selection; a preset's kernel includes
40    /// its sourced backports.
41    pub fn from_selection(sel: &Selection) -> Self {
42        let kernel = match (&sel.distro, sel.kernel) {
43            (Some(d), _) => Some(Target::distro(d)),
44            (None, Some(k)) => Some(Target::kernel(k)),
45            (None, None) => None,
46        };
47        ScanTarget {
48            glibc: sel.glibc,
49            kernel,
50            label: sel.describe(),
51        }
52    }
53}
54
55/// How a finding was established.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
57pub enum Confidence {
58    /// Read from ELF metadata the loader itself uses.
59    Exact,
60    /// Recovered from machine code by pattern matching.
61    Heuristic,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
65pub enum Severity {
66    /// The target does not provide something the file requires.
67    Error,
68    /// Possibly a problem: optional use, or a heuristic finding.
69    Warning,
70    /// Compatible, or context.
71    Info,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct Finding {
76    pub severity: Severity,
77    pub confidence: Confidence,
78    /// `glibc-version`, `glibc-wrapper`, `syscall-call`, `syscall-insn`,
79    /// `madvise`, `abi-tag`, `relr`, or `scope`.
80    pub check: &'static str,
81    pub subject: String,
82    pub detail: String,
83    /// Code address of the call site, for code findings.
84    pub address: Option<u64>,
85}
86
87#[derive(Debug)]
88pub struct Report {
89    pub target: String,
90    pub findings: Vec<Finding>,
91}
92
93impl Report {
94    pub fn count(&self, severity: Severity) -> usize {
95        self.findings
96            .iter()
97            .filter(|f| f.severity == severity)
98            .count()
99    }
100
101    /// 0 when nothing fails; 1 when an error (or, with `strict`, a warning)
102    /// was found.
103    pub fn exit_code(&self, strict: bool) -> i32 {
104        let failing = self.count(Severity::Error)
105            + if strict {
106                self.count(Severity::Warning)
107            } else {
108                0
109            };
110        i32::from(failing > 0)
111    }
112
113    /// Human-readable report; `verbose` includes informational findings.
114    pub fn to_text(&self, verbose: bool) -> String {
115        let mut out = format!("target: {}\n", self.target);
116        for f in &self.findings {
117            if f.severity == Severity::Info && !verbose {
118                continue;
119            }
120            let sev = match f.severity {
121                Severity::Error => "ERROR",
122                Severity::Warning => "WARN",
123                Severity::Info => "info",
124            };
125            let conf = f.confidence.as_str();
126            let at = f.address.map_or(String::new(), |a| format!(" at {a:#x}"));
127            writeln!(
128                out,
129                "{sev:5} {conf:9} {:13} {}{at}: {}",
130                f.check, f.subject, f.detail
131            )
132            .unwrap();
133        }
134        writeln!(
135            out,
136            "{} errors, {} warnings, {} info. Static inspection is tier 1: confirm with the \
137             seccomp/container simulation (tier 2) and on a real target host (tier 3).",
138            self.count(Severity::Error),
139            self.count(Severity::Warning),
140            self.count(Severity::Info)
141        )
142        .unwrap();
143        out
144    }
145
146    /// Stable TSV with a header, for tools.
147    pub fn to_tsv(&self) -> String {
148        let mut out = String::from("severity\tconfidence\tcheck\tsubject\taddress\tdetail\n");
149        for f in &self.findings {
150            writeln!(
151                out,
152                "{}\t{}\t{}\t{}\t{}\t{}",
153                f.severity.as_str(),
154                f.confidence.as_str(),
155                f.check,
156                f.subject,
157                f.address.map_or(String::new(), |a| format!("{a:#x}")),
158                f.detail.replace(['\t', '\n'], " ")
159            )
160            .unwrap();
161        }
162        out
163    }
164}
165
166impl Severity {
167    pub fn as_str(self) -> &'static str {
168        match self {
169            Severity::Error => "error",
170            Severity::Warning => "warning",
171            Severity::Info => "info",
172        }
173    }
174}
175
176impl Confidence {
177    pub fn as_str(self) -> &'static str {
178        match self {
179            Confidence::Exact => "exact",
180            Confidence::Heuristic => "heuristic",
181        }
182    }
183}
184
185/// glibc functions whose syscall has a different name.
186const WRAPPER_ALIASES: &[(&str, &str)] = &[
187    ("fstatat", "newfstatat"),
188    ("fstatat64", "newfstatat"),
189    ("posix_fadvise", "fadvise64"),
190    ("posix_fadvise64", "fadvise64"),
191    ("prlimit", "prlimit64"),
192    ("pread", "pread64"),
193    ("pwrite", "pwrite64"),
194    ("preadv64", "preadv"),
195    ("pwritev64", "pwritev"),
196    ("preadv64v2", "preadv2"),
197    ("pwritev64v2", "pwritev2"),
198    ("fallocate64", "fallocate"),
199];
200
201/// Syscalls that Rust's standard library calls with a fallback when the
202/// kernel answers `ENOSYS`. A finding for one of these is still reported,
203/// because the scan cannot tell whether the call came from std.
204const RUST_STD_FALLBACKS: &[&str] = &[
205    "statx",
206    "getrandom",
207    "copy_file_range",
208    "pidfd_open",
209    "pidfd_send_signal",
210    "pidfd_getfd",
211    "clone3",
212    "futex_waitv",
213];
214
215/// How far back from a call site to look for the constant argument.
216const LOOKBACK: usize = 64;
217
218/// Scans the ELF file at `path`.
219pub fn scan_file(path: &Path, target: &ScanTarget) -> Result<Report, String> {
220    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
221    scan(&bytes, target).map_err(|e| format!("{}: {e}", path.display()))
222}
223
224/// Scans an x86_64 ELF64 executable or shared object.
225pub fn scan(bytes: &[u8], target: &ScanTarget) -> Result<Report, String> {
226    let elf = elf::Elf::parse(bytes)?;
227    let mut findings = Vec::new();
228    glibc_checks(&elf, target, &mut findings);
229    kernel_checks(&elf, target, &mut findings);
230    findings.sort_by(|a, b| {
231        (a.severity, a.confidence, a.check, a.address, &a.subject).cmp(&(
232            b.severity,
233            b.confidence,
234            b.check,
235            b.address,
236            &b.subject,
237        ))
238    });
239    Ok(Report {
240        target: target.label.clone(),
241        findings,
242    })
243}
244
245fn glibc_version(v: &str) -> Option<GlibcVersion> {
246    GlibcVersion::parse(v.strip_prefix("GLIBC_")?).ok()
247}
248
249fn glibc_checks(elf: &elf::Elf, target: &ScanTarget, out: &mut Vec<Finding>) {
250    let Some(floor) = target.glibc else {
251        out.push(scope(
252            "glibc",
253            "no glibc floor selected; glibc checks skipped (host glibc)",
254        ));
255        return;
256    };
257    let mut seen = BTreeSet::new();
258    for need in &elf.needs {
259        if !seen.insert((need.file.clone(), need.version.clone())) {
260            continue;
261        }
262        let subject = format!("{} {}", need.file, need.version);
263        let (severity, detail) = if need.version == "GLIBC_ABI_DT_RELR" {
264            continue; // reported by the DT_RELR check
265        } else if need.version == "GLIBC_PRIVATE" {
266            (
267                Severity::Warning,
268                "glibc-internal interface; not stable across releases".to_string(),
269            )
270        } else if let Some(v) = glibc_version(&need.version) {
271            if v <= floor {
272                (Severity::Info, format!("within the glibc {floor} floor"))
273            } else if need.weak {
274                (
275                    Severity::Warning,
276                    format!("newer than glibc {floor}, but a weak requirement"),
277                )
278            } else {
279                (
280                    Severity::Error,
281                    format!("needs glibc {v}; the target has {floor}"),
282                )
283            }
284        } else {
285            continue; // GCC_*, GLIBCXX_*, and other libraries' versions
286        };
287        out.push(Finding {
288            severity,
289            confidence: Confidence::Exact,
290            check: "glibc-version",
291            subject,
292            detail,
293            address: None,
294        });
295    }
296    if elf.relr {
297        let ok = floor
298            >= GlibcVersion {
299                major: 2,
300                minor: 36,
301            };
302        out.push(Finding {
303            severity: if ok { Severity::Info } else { Severity::Error },
304            confidence: Confidence::Exact,
305            check: "relr",
306            subject: "DT_RELR".into(),
307            detail: if ok {
308                format!("relative relocations in RELR form; supported from glibc 2.36 (floor {floor})")
309            } else {
310                format!("relative relocations in RELR form need glibc 2.36; the target has {floor} (link without -z pack-relative-relocs)")
311            },
312            address: None,
313        });
314    }
315}
316
317fn kernel_checks(elf: &elf::Elf, target: &ScanTarget, out: &mut Vec<Finding>) {
318    let Some(kernel) = &target.kernel else {
319        out.push(scope(
320            "kernel",
321            "no kernel floor selected; kernel checks skipped (host kernel, not simulated)",
322        ));
323        return;
324    };
325    let all = syscalls();
326    let by_name: BTreeMap<&str, &kernel_abi_tools::Syscall> =
327        all.iter().map(|s| (s.name, s)).collect();
328    let by_nr: BTreeMap<u32, &kernel_abi_tools::Syscall> = all.iter().map(|s| (s.nr, s)).collect();
329    let present: BTreeSet<u32> = present_syscalls(kernel).iter().map(|s| s.nr).collect();
330    let fallback = |name: &str| {
331        if RUST_STD_FALLBACKS.contains(&name) {
332            "; Rust's standard library falls back when it returns ENOSYS (other code may not)"
333        } else {
334            ""
335        }
336    };
337
338    if let Some([a, b, c]) = elf.abi_tag {
339        let tag = KernelVersion { major: a, minor: b };
340        let (severity, detail) = if tag <= kernel.kernel {
341            (
342                Severity::Info,
343                format!(
344                    "declares Linux {a}.{b}.{c} or later; within the {} floor",
345                    kernel.kernel
346                ),
347            )
348        } else {
349            (
350                Severity::Warning,
351                format!(
352                    "declares Linux {a}.{b}.{c} or later, above the {} floor; glibc's loader refuses shared objects whose tag exceeds the running kernel",
353                    kernel.kernel
354                ),
355            )
356        };
357        out.push(Finding {
358            severity,
359            confidence: Confidence::Exact,
360            check: "abi-tag",
361            subject: "NT_GNU_ABI_TAG".into(),
362            detail,
363            address: None,
364        });
365    }
366
367    // Imported glibc wrappers for syscalls.
368    for imp in &elf.imports {
369        let syscall_name = WRAPPER_ALIASES
370            .iter()
371            .find(|(f, _)| *f == imp.name)
372            .map_or(imp.name.as_str(), |(_, s)| s);
373        let Some(sc) = by_name.get(syscall_name) else {
374            continue;
375        };
376        let version = imp
377            .version
378            .as_deref()
379            .map_or(String::new(), |v| format!("@{v}"));
380        let subject = format!("{}{version}", imp.name);
381        let (severity, detail) = if present.contains(&sc.nr) {
382            (
383                Severity::Info,
384                format!(
385                    "syscall {} (Linux {}) is available on {}",
386                    sc.name, sc.first, kernel.label
387                ),
388            )
389        } else if imp.weak {
390            (
391                Severity::Warning,
392                format!(
393                    "weak import of a wrapper for {} (Linux {}), which {} lacks; the program must handle its absence{}",
394                    sc.name, sc.first, kernel.label, fallback(sc.name)
395                ),
396            )
397        } else {
398            (
399                Severity::Error,
400                format!(
401                    "wrapper for {} (Linux {}), which {} lacks: calls fail with ENOSYS",
402                    sc.name, sc.first, kernel.label
403                ),
404            )
405        };
406        out.push(Finding {
407            severity,
408            confidence: Confidence::Exact,
409            check: "glibc-wrapper",
410            subject,
411            detail,
412            address: None,
413        });
414    }
415
416    // Machine code: calls to syscall()/madvise() and raw syscall instructions.
417    let sites = code_sites(elf);
418    let mut unresolved = [0usize; 3];
419    for site in &sites {
420        match site.kind {
421            SiteKind::SyscallFn | SiteKind::SyscallInsn => {
422                let (check, via, slot) = if site.kind == SiteKind::SyscallFn {
423                    ("syscall-call", "syscall()", 0)
424                } else {
425                    ("syscall-insn", "syscall instruction", 1)
426                };
427                let Some(nr) = site.constant else {
428                    unresolved[slot] += 1;
429                    continue;
430                };
431                let (severity, subject, detail) = match by_nr.get(&(nr as u32)) {
432                    Some(sc) if present.contains(&sc.nr) => (
433                        Severity::Info,
434                        sc.name.to_string(),
435                        format!("{via} with number {nr} (Linux {}); available", sc.first),
436                    ),
437                    Some(sc) => (
438                        Severity::Warning,
439                        sc.name.to_string(),
440                        format!(
441                            "{via} with number {nr}: {} (Linux {}) is missing on {}{}",
442                            sc.name,
443                            sc.first,
444                            kernel.label,
445                            fallback(sc.name)
446                        ),
447                    ),
448                    None => (
449                        Severity::Warning,
450                        format!("#{nr}"),
451                        format!("{via} with number {nr}, which no kernel in the data defines"),
452                    ),
453                };
454                out.push(Finding {
455                    severity,
456                    confidence: Confidence::Heuristic,
457                    check,
458                    subject,
459                    detail,
460                    address: Some(site.address),
461                });
462            }
463            SiteKind::Madvise => {
464                let Some(advice) = site.constant else {
465                    unresolved[2] += 1;
466                    continue;
467                };
468                let name = madvise_advice()
469                    .into_iter()
470                    .find(|a| a.value as u64 == advice)
471                    .map_or(format!("advice {advice}"), |a| {
472                        format!("{} ({advice})", a.name)
473                    });
474                if madvise_accepts(kernel.kernel, advice as u32) {
475                    continue; // accepted advice is not worth a line
476                }
477                out.push(Finding {
478                    severity: Severity::Warning,
479                    confidence: Confidence::Heuristic,
480                    check: "madvise",
481                    subject: name,
482                    detail: format!(
483                        "madvise() with advice that {} rejects with EINVAL",
484                        kernel.label
485                    ),
486                    address: Some(site.address),
487                });
488            }
489        }
490    }
491    for (count, what) in [
492        (unresolved[0], "syscall() calls"),
493        (unresolved[1], "syscall instructions"),
494        (unresolved[2], "madvise() calls"),
495    ] {
496        if count > 0 {
497            out.push(scope(
498                "code",
499                &format!("{count} {what} take a value not known statically; only tier 2 or 3 can check them"),
500            ));
501        }
502    }
503}
504
505fn scope(subject: &str, detail: &str) -> Finding {
506    Finding {
507        severity: Severity::Info,
508        confidence: Confidence::Exact,
509        check: "scope",
510        subject: subject.into(),
511        detail: detail.into(),
512        address: None,
513    }
514}
515
516#[derive(Clone, Copy, Debug, PartialEq, Eq)]
517enum SiteKind {
518    SyscallFn,
519    SyscallInsn,
520    Madvise,
521}
522
523#[derive(Debug)]
524struct Site {
525    kind: SiteKind,
526    address: u64,
527    constant: Option<u64>,
528}
529
530fn rel32(b: &[u8], at: usize) -> Option<i64> {
531    Some(i32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?) as i64)
532}
533
534fn imm32(b: &[u8], at: usize) -> Option<u64> {
535    Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?) as u64)
536}
537
538/// Finds the last write of a constant to a register in `window` (the bytes
539/// just before a call site). `mov r32, imm32` is `op imm32`; `mov r64,
540/// imm32` is `48 c7 modrm imm32`; `xor r32, r32` is `31/33 modrm`.
541///
542/// A `syscall` or an indirect `call` clobbers rax and the argument
543/// registers, so the search starts after the last one in the window: a
544/// constant set before it does not belong to this site.
545fn last_constant(window: &[u8], mov_op: u8, modrm: u8) -> Option<u64> {
546    let start = (0..window.len().saturating_sub(1))
547        .rev()
548        .find_map(|k| match (window[k], window[k + 1]) {
549            (0x0f, 0x05) => Some(k + 2),
550            (0xff, 0x15) => Some((k + 6).min(window.len())),
551            _ => None,
552        })
553        .unwrap_or(0);
554    let window = &window[start..];
555    let mut best: Option<(usize, u64)> = None;
556    let mut consider = |end: usize, value: u64| {
557        if best.is_none_or(|(e, _)| end > e) {
558            best = Some((end, value));
559        }
560    };
561    for j in 0..window.len() {
562        if window[j] == mov_op && j + 5 <= window.len() {
563            consider(j + 5, imm32(window, j + 1)?);
564        }
565        if window[j..].starts_with(&[0x48, 0xc7, modrm]) && j + 7 <= window.len() {
566            consider(j + 7, imm32(window, j + 3)?);
567        }
568        if (window[j] == 0x31 || window[j] == 0x33) && window.get(j + 1) == Some(&modrm) {
569            consider(j + 2, 0);
570        }
571    }
572    best.map(|(_, v)| v)
573}
574
575/// Every call to `syscall`/`madvise` through the GOT or a PLT stub, and every
576/// raw `syscall` instruction, with the constant argument when one is found.
577fn code_sites(elf: &elf::Elf) -> Vec<Site> {
578    let slots_of = |name: &str| -> BTreeSet<u64> {
579        elf.imports
580            .iter()
581            .filter(|i| i.name == name)
582            .flat_map(|i| i.slots.iter().copied())
583            .collect()
584    };
585    let syscall_slots = slots_of("syscall");
586    let madvise_slots = slots_of("madvise");
587
588    // PLT stub address -> GOT slot it jumps through.
589    let mut stubs: BTreeMap<u64, u64> = BTreeMap::new();
590    for c in elf.code.iter().filter(|c| c.name.starts_with(".plt")) {
591        let b = c.bytes;
592        for i in 0..b.len() {
593            if b[i..].starts_with(&[0xff, 0x25]) {
594                if let Some(rel) = rel32(b, i + 2) {
595                    let slot = (c.addr as i64 + i as i64 + 6 + rel) as u64;
596                    let mut start = i;
597                    if start >= 1 && b[start - 1] == 0xf2 {
598                        start -= 1; // bnd
599                    }
600                    if start >= 4 && b[start - 4..start] == [0xf3, 0x0f, 0x1e, 0xfa] {
601                        start -= 4; // endbr64
602                    }
603                    stubs.insert(c.addr + start as u64, slot);
604                }
605            }
606        }
607    }
608
609    let mut sites = Vec::new();
610    let mut seen = BTreeSet::new();
611    for c in elf.code.iter().filter(|c| !c.name.starts_with(".plt")) {
612        let b = c.bytes;
613        for i in 0..b.len().saturating_sub(1) {
614            let here = c.addr + i as u64;
615            let slot = match (b[i], b[i + 1]) {
616                (0xe8 | 0xe9, _) => rel32(b, i + 1)
617                    .map(|rel| (here as i64 + 5 + rel) as u64)
618                    .and_then(|t| stubs.get(&t).copied()),
619                (0xff, 0x15 | 0x25) => rel32(b, i + 2).map(|rel| (here as i64 + 6 + rel) as u64),
620                _ => None,
621            };
622            let window = &b[i.saturating_sub(LOOKBACK)..i];
623            let kind = match slot {
624                Some(s) if syscall_slots.contains(&s) => {
625                    Some((SiteKind::SyscallFn, last_constant(window, 0xbf, 0xff)))
626                }
627                Some(s) if madvise_slots.contains(&s) => {
628                    Some((SiteKind::Madvise, last_constant(window, 0xba, 0xd2)))
629                }
630                _ if b[i] == 0x0f && b[i + 1] == 0x05 => {
631                    // A raw syscall needs its number in eax just before it;
632                    // without one the two bytes are likely part of another
633                    // instruction, so they are not counted.
634                    last_constant(window, 0xb8, 0xc0)
635                        .filter(|nr| *nr < 1024)
636                        .map(|nr| (SiteKind::SyscallInsn, Some(nr)))
637                }
638                _ => None,
639            };
640            if let Some((kind, constant)) = kind {
641                if seen.insert(here) {
642                    sites.push(Site {
643                        kind,
644                        address: here,
645                        constant,
646                    });
647                }
648            }
649        }
650    }
651    sites
652}
653
654#[cfg(test)]
655mod tests;