1pub 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
30pub struct ScanTarget {
33 pub glibc: Option<GlibcVersion>,
34 pub kernel: Option<Target>,
35 pub label: String,
36}
37
38impl ScanTarget {
39 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
57pub enum Confidence {
58 Exact,
60 Heuristic,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
65pub enum Severity {
66 Error,
68 Warning,
70 Info,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct Finding {
76 pub severity: Severity,
77 pub confidence: Confidence,
78 pub check: &'static str,
81 pub subject: String,
82 pub detail: String,
83 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 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 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 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
185const 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
201const 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
215const LOOKBACK: usize = 64;
217
218pub 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
224pub 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; } 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; };
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 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 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; }
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
538fn 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
575fn 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 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; }
600 if start >= 4 && b[start - 4..start] == [0xf3, 0x0f, 0x1e, 0xfa] {
601 start -= 4; }
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 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;