1#![warn(missing_docs)]
59
60pub mod elf;
61
62use kernel_abi_tools::{
63 madvise_accepts, madvise_advice, present_syscalls, syscalls, KernelVersion, Target,
64};
65use linux_targets::{GlibcVersion, Selection};
66use std::collections::{BTreeMap, BTreeSet};
67use std::fmt::Write as _;
68use std::path::Path;
69
70pub struct ScanTarget {
73 pub glibc: Option<GlibcVersion>,
75 pub kernel: Option<Target>,
78 pub label: String,
80}
81
82impl ScanTarget {
83 pub fn from_selection(sel: &Selection) -> Self {
86 let kernel = match (&sel.distro, sel.kernel) {
87 (Some(d), _) => Some(Target::distro(d)),
88 (None, Some(k)) => Some(Target::kernel(k)),
89 (None, None) => None,
90 };
91 ScanTarget {
92 glibc: sel.glibc,
93 kernel,
94 label: sel.describe(),
95 }
96 }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
101pub enum Confidence {
102 Exact,
104 Heuristic,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
110pub enum Severity {
111 Error,
113 Warning,
115 Info,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct Finding {
122 pub severity: Severity,
124 pub confidence: Confidence,
126 pub check: &'static str,
129 pub subject: String,
132 pub detail: String,
134 pub address: Option<u64>,
136}
137
138#[derive(Debug)]
140pub struct Report {
141 pub target: String,
143 pub findings: Vec<Finding>,
145}
146
147impl Report {
148 pub fn count(&self, severity: Severity) -> usize {
150 self.findings
151 .iter()
152 .filter(|f| f.severity == severity)
153 .count()
154 }
155
156 pub fn exit_code(&self, strict: bool) -> i32 {
159 let failing = self.count(Severity::Error)
160 + if strict {
161 self.count(Severity::Warning)
162 } else {
163 0
164 };
165 i32::from(failing > 0)
166 }
167
168 pub fn to_text(&self, verbose: bool) -> String {
170 let mut out = format!("target: {}\n", self.target);
171 for f in &self.findings {
172 if f.severity == Severity::Info && !verbose {
173 continue;
174 }
175 let sev = match f.severity {
176 Severity::Error => "ERROR",
177 Severity::Warning => "WARN",
178 Severity::Info => "info",
179 };
180 let conf = f.confidence.as_str();
181 let at = f.address.map_or(String::new(), |a| format!(" at {a:#x}"));
182 writeln!(
183 out,
184 "{sev:5} {conf:9} {:13} {}{at}: {}",
185 f.check, f.subject, f.detail
186 )
187 .unwrap();
188 }
189 writeln!(
190 out,
191 "{} errors, {} warnings, {} info. Static inspection is tier 1: confirm with the \
192 seccomp/container simulation (tier 2) and on a real target host (tier 3).",
193 self.count(Severity::Error),
194 self.count(Severity::Warning),
195 self.count(Severity::Info)
196 )
197 .unwrap();
198 out
199 }
200
201 pub fn to_tsv(&self) -> String {
203 let mut out = String::from("severity\tconfidence\tcheck\tsubject\taddress\tdetail\n");
204 for f in &self.findings {
205 writeln!(
206 out,
207 "{}\t{}\t{}\t{}\t{}\t{}",
208 f.severity.as_str(),
209 f.confidence.as_str(),
210 f.check,
211 f.subject,
212 f.address.map_or(String::new(), |a| format!("{a:#x}")),
213 f.detail.replace(['\t', '\n'], " ")
214 )
215 .unwrap();
216 }
217 out
218 }
219}
220
221impl Severity {
222 pub fn as_str(self) -> &'static str {
224 match self {
225 Severity::Error => "error",
226 Severity::Warning => "warning",
227 Severity::Info => "info",
228 }
229 }
230}
231
232impl Confidence {
233 pub fn as_str(self) -> &'static str {
235 match self {
236 Confidence::Exact => "exact",
237 Confidence::Heuristic => "heuristic",
238 }
239 }
240}
241
242const WRAPPER_ALIASES: &[(&str, &str)] = &[
244 ("fstatat", "newfstatat"),
245 ("fstatat64", "newfstatat"),
246 ("posix_fadvise", "fadvise64"),
247 ("posix_fadvise64", "fadvise64"),
248 ("prlimit", "prlimit64"),
249 ("pread", "pread64"),
250 ("pwrite", "pwrite64"),
251 ("preadv64", "preadv"),
252 ("pwritev64", "pwritev"),
253 ("preadv64v2", "preadv2"),
254 ("pwritev64v2", "pwritev2"),
255 ("fallocate64", "fallocate"),
256];
257
258const RUST_STD_FALLBACKS: &[&str] = &[
262 "statx",
263 "getrandom",
264 "copy_file_range",
265 "pidfd_open",
266 "pidfd_send_signal",
267 "pidfd_getfd",
268 "clone3",
269 "futex_waitv",
270];
271
272const LOOKBACK: usize = 64;
274
275pub fn scan_file(path: &Path, target: &ScanTarget) -> Result<Report, String> {
282 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
283 scan(&bytes, target).map_err(|e| format!("{}: {e}", path.display()))
284}
285
286pub fn scan(bytes: &[u8], target: &ScanTarget) -> Result<Report, String> {
297 let elf = elf::Elf::parse(bytes)?;
298 let mut findings = Vec::new();
299 glibc_checks(&elf, target, &mut findings);
300 kernel_checks(&elf, target, &mut findings);
301 findings.sort_by(|a, b| {
302 (a.severity, a.confidence, a.check, a.address, &a.subject).cmp(&(
303 b.severity,
304 b.confidence,
305 b.check,
306 b.address,
307 &b.subject,
308 ))
309 });
310 Ok(Report {
311 target: target.label.clone(),
312 findings,
313 })
314}
315
316fn glibc_version(v: &str) -> Option<GlibcVersion> {
317 GlibcVersion::parse(v.strip_prefix("GLIBC_")?).ok()
318}
319
320fn glibc_checks(elf: &elf::Elf, target: &ScanTarget, out: &mut Vec<Finding>) {
321 let Some(floor) = target.glibc else {
322 out.push(scope(
323 "glibc",
324 "no glibc floor selected; glibc checks skipped (host glibc)",
325 ));
326 return;
327 };
328 let mut seen = BTreeSet::new();
329 for need in &elf.needs {
330 if !seen.insert((need.file.clone(), need.version.clone())) {
331 continue;
332 }
333 let subject = format!("{} {}", need.file, need.version);
334 let (severity, detail) = if need.version == "GLIBC_ABI_DT_RELR" {
335 continue; } else if need.version == "GLIBC_PRIVATE" {
337 (
338 Severity::Warning,
339 "glibc-internal interface; not stable across releases".to_string(),
340 )
341 } else if let Some(v) = glibc_version(&need.version) {
342 if v <= floor {
343 (Severity::Info, format!("within the glibc {floor} floor"))
344 } else if need.weak {
345 (
346 Severity::Warning,
347 format!("newer than glibc {floor}, but a weak requirement"),
348 )
349 } else {
350 (
351 Severity::Error,
352 format!("needs glibc {v}; the target has {floor}"),
353 )
354 }
355 } else {
356 continue; };
358 out.push(Finding {
359 severity,
360 confidence: Confidence::Exact,
361 check: "glibc-version",
362 subject,
363 detail,
364 address: None,
365 });
366 }
367 if elf.relr {
368 let ok = floor
369 >= GlibcVersion {
370 major: 2,
371 minor: 36,
372 };
373 out.push(Finding {
374 severity: if ok { Severity::Info } else { Severity::Error },
375 confidence: Confidence::Exact,
376 check: "relr",
377 subject: "DT_RELR".into(),
378 detail: if ok {
379 format!("relative relocations in RELR form; supported from glibc 2.36 (floor {floor})")
380 } else {
381 format!("relative relocations in RELR form need glibc 2.36; the target has {floor} (link without -z pack-relative-relocs)")
382 },
383 address: None,
384 });
385 }
386}
387
388fn kernel_checks(elf: &elf::Elf, target: &ScanTarget, out: &mut Vec<Finding>) {
389 let Some(kernel) = &target.kernel else {
390 out.push(scope(
391 "kernel",
392 "no kernel floor selected; kernel checks skipped (host kernel, not simulated)",
393 ));
394 return;
395 };
396 let all = syscalls();
397 let by_name: BTreeMap<&str, &kernel_abi_tools::Syscall> =
398 all.iter().map(|s| (s.name, s)).collect();
399 let by_nr: BTreeMap<u32, &kernel_abi_tools::Syscall> = all.iter().map(|s| (s.nr, s)).collect();
400 let present: BTreeSet<u32> = present_syscalls(kernel).iter().map(|s| s.nr).collect();
401 let fallback = |name: &str| {
402 if RUST_STD_FALLBACKS.contains(&name) {
403 "; Rust's standard library falls back when it returns ENOSYS (other code may not)"
404 } else {
405 ""
406 }
407 };
408
409 if let Some([a, b, c]) = elf.abi_tag {
410 let tag = KernelVersion { major: a, minor: b };
411 let (severity, detail) = if tag <= kernel.kernel {
412 (
413 Severity::Info,
414 format!(
415 "declares Linux {a}.{b}.{c} or later; within the {} floor",
416 kernel.kernel
417 ),
418 )
419 } else {
420 (
421 Severity::Warning,
422 format!(
423 "declares Linux {a}.{b}.{c} or later, above the {} floor; glibc's loader refuses shared objects whose tag exceeds the running kernel",
424 kernel.kernel
425 ),
426 )
427 };
428 out.push(Finding {
429 severity,
430 confidence: Confidence::Exact,
431 check: "abi-tag",
432 subject: "NT_GNU_ABI_TAG".into(),
433 detail,
434 address: None,
435 });
436 }
437
438 for imp in &elf.imports {
440 let syscall_name = WRAPPER_ALIASES
441 .iter()
442 .find(|(f, _)| *f == imp.name)
443 .map_or(imp.name.as_str(), |(_, s)| s);
444 let Some(sc) = by_name.get(syscall_name) else {
445 continue;
446 };
447 let version = imp
448 .version
449 .as_deref()
450 .map_or(String::new(), |v| format!("@{v}"));
451 let subject = format!("{}{version}", imp.name);
452 let (severity, detail) = if present.contains(&sc.nr) {
453 (
454 Severity::Info,
455 format!(
456 "syscall {} (Linux {}) is available on {}",
457 sc.name, sc.first, kernel.label
458 ),
459 )
460 } else if imp.weak {
461 (
462 Severity::Warning,
463 format!(
464 "weak import of a wrapper for {} (Linux {}), which {} lacks; the program must handle its absence{}",
465 sc.name, sc.first, kernel.label, fallback(sc.name)
466 ),
467 )
468 } else {
469 (
470 Severity::Error,
471 format!(
472 "wrapper for {} (Linux {}), which {} lacks: calls fail with ENOSYS",
473 sc.name, sc.first, kernel.label
474 ),
475 )
476 };
477 out.push(Finding {
478 severity,
479 confidence: Confidence::Exact,
480 check: "glibc-wrapper",
481 subject,
482 detail,
483 address: None,
484 });
485 }
486
487 let sites = code_sites(elf);
489 let mut unresolved = [0usize; 3];
490 for site in &sites {
491 match site.kind {
492 SiteKind::SyscallFn | SiteKind::SyscallInsn => {
493 let (check, via, slot) = if site.kind == SiteKind::SyscallFn {
494 ("syscall-call", "syscall()", 0)
495 } else {
496 ("syscall-insn", "syscall instruction", 1)
497 };
498 let Some(nr) = site.constant else {
499 unresolved[slot] += 1;
500 continue;
501 };
502 let (severity, subject, detail) = match by_nr.get(&(nr as u32)) {
503 Some(sc) if present.contains(&sc.nr) => (
504 Severity::Info,
505 sc.name.to_string(),
506 format!("{via} with number {nr} (Linux {}); available", sc.first),
507 ),
508 Some(sc) => (
509 Severity::Warning,
510 sc.name.to_string(),
511 format!(
512 "{via} with number {nr}: {} (Linux {}) is missing on {}{}",
513 sc.name,
514 sc.first,
515 kernel.label,
516 fallback(sc.name)
517 ),
518 ),
519 None => (
520 Severity::Warning,
521 format!("#{nr}"),
522 format!("{via} with number {nr}, which no kernel in the data defines"),
523 ),
524 };
525 out.push(Finding {
526 severity,
527 confidence: Confidence::Heuristic,
528 check,
529 subject,
530 detail,
531 address: Some(site.address),
532 });
533 }
534 SiteKind::Madvise => {
535 let Some(advice) = site.constant else {
536 unresolved[2] += 1;
537 continue;
538 };
539 let name = madvise_advice()
540 .into_iter()
541 .find(|a| a.value as u64 == advice)
542 .map_or(format!("advice {advice}"), |a| {
543 format!("{} ({advice})", a.name)
544 });
545 if madvise_accepts(kernel.kernel, advice as u32) {
546 continue; }
548 out.push(Finding {
549 severity: Severity::Warning,
550 confidence: Confidence::Heuristic,
551 check: "madvise",
552 subject: name,
553 detail: format!(
554 "madvise() with advice that {} rejects with EINVAL",
555 kernel.label
556 ),
557 address: Some(site.address),
558 });
559 }
560 }
561 }
562 for (count, what) in [
563 (unresolved[0], "syscall() calls"),
564 (unresolved[1], "syscall instructions"),
565 (unresolved[2], "madvise() calls"),
566 ] {
567 if count > 0 {
568 out.push(scope(
569 "code",
570 &format!("{count} {what} take a value not known statically; only tier 2 or 3 can check them"),
571 ));
572 }
573 }
574}
575
576fn scope(subject: &str, detail: &str) -> Finding {
577 Finding {
578 severity: Severity::Info,
579 confidence: Confidence::Exact,
580 check: "scope",
581 subject: subject.into(),
582 detail: detail.into(),
583 address: None,
584 }
585}
586
587#[derive(Clone, Copy, Debug, PartialEq, Eq)]
588enum SiteKind {
589 SyscallFn,
590 SyscallInsn,
591 Madvise,
592}
593
594#[derive(Debug)]
595struct Site {
596 kind: SiteKind,
597 address: u64,
598 constant: Option<u64>,
599}
600
601fn rel32(b: &[u8], at: usize) -> Option<i64> {
602 Some(i32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?) as i64)
603}
604
605fn imm32(b: &[u8], at: usize) -> Option<u64> {
606 Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?) as u64)
607}
608
609fn last_constant(window: &[u8], mov_op: u8, modrm: u8) -> Option<u64> {
617 let start = (0..window.len().saturating_sub(1))
618 .rev()
619 .find_map(|k| match (window[k], window[k + 1]) {
620 (0x0f, 0x05) => Some(k + 2),
621 (0xff, 0x15) => Some((k + 6).min(window.len())),
622 _ => None,
623 })
624 .unwrap_or(0);
625 let window = &window[start..];
626 let mut best: Option<(usize, u64)> = None;
627 let mut consider = |end: usize, value: u64| {
628 if best.is_none_or(|(e, _)| end > e) {
629 best = Some((end, value));
630 }
631 };
632 for j in 0..window.len() {
633 if window[j] == mov_op && j + 5 <= window.len() {
634 consider(j + 5, imm32(window, j + 1)?);
635 }
636 if window[j..].starts_with(&[0x48, 0xc7, modrm]) && j + 7 <= window.len() {
637 consider(j + 7, imm32(window, j + 3)?);
638 }
639 if (window[j] == 0x31 || window[j] == 0x33) && window.get(j + 1) == Some(&modrm) {
640 consider(j + 2, 0);
641 }
642 }
643 best.map(|(_, v)| v)
644}
645
646fn code_sites(elf: &elf::Elf) -> Vec<Site> {
649 let slots_of = |name: &str| -> BTreeSet<u64> {
650 elf.imports
651 .iter()
652 .filter(|i| i.name == name)
653 .flat_map(|i| i.slots.iter().copied())
654 .collect()
655 };
656 let syscall_slots = slots_of("syscall");
657 let madvise_slots = slots_of("madvise");
658
659 let mut stubs: BTreeMap<u64, u64> = BTreeMap::new();
661 for c in elf.code.iter().filter(|c| c.name.starts_with(".plt")) {
662 let b = c.bytes;
663 for i in 0..b.len() {
664 if b[i..].starts_with(&[0xff, 0x25]) {
665 if let Some(rel) = rel32(b, i + 2) {
666 let slot = (c.addr as i64 + i as i64 + 6 + rel) as u64;
667 let mut start = i;
668 if start >= 1 && b[start - 1] == 0xf2 {
669 start -= 1; }
671 if start >= 4 && b[start - 4..start] == [0xf3, 0x0f, 0x1e, 0xfa] {
672 start -= 4; }
674 stubs.insert(c.addr + start as u64, slot);
675 }
676 }
677 }
678 }
679
680 let mut sites = Vec::new();
681 let mut seen = BTreeSet::new();
682 for c in elf.code.iter().filter(|c| !c.name.starts_with(".plt")) {
683 let b = c.bytes;
684 for i in 0..b.len().saturating_sub(1) {
685 let here = c.addr + i as u64;
686 let slot = match (b[i], b[i + 1]) {
687 (0xe8 | 0xe9, _) => rel32(b, i + 1)
688 .map(|rel| (here as i64 + 5 + rel) as u64)
689 .and_then(|t| stubs.get(&t).copied()),
690 (0xff, 0x15 | 0x25) => rel32(b, i + 2).map(|rel| (here as i64 + 6 + rel) as u64),
691 _ => None,
692 };
693 let window = &b[i.saturating_sub(LOOKBACK)..i];
694 let kind = match slot {
695 Some(s) if syscall_slots.contains(&s) => {
696 Some((SiteKind::SyscallFn, last_constant(window, 0xbf, 0xff)))
697 }
698 Some(s) if madvise_slots.contains(&s) => {
699 Some((SiteKind::Madvise, last_constant(window, 0xba, 0xd2)))
700 }
701 _ if b[i] == 0x0f && b[i + 1] == 0x05 => {
702 last_constant(window, 0xb8, 0xc0)
706 .filter(|nr| *nr < 1024)
707 .map(|nr| (SiteKind::SyscallInsn, Some(nr)))
708 }
709 _ => None,
710 };
711 if let Some((kind, constant)) = kind {
712 if seen.insert(here) {
713 sites.push(Site {
714 kind,
715 address: here,
716 constant,
717 });
718 }
719 }
720 }
721 }
722 sites
723}
724
725#[cfg(test)]
726mod tests;