Skip to main content

kernel_abi_tools/
lib.rs

1//! Linux kernel ABI data for x86_64 and seccomp profiles that make a modern
2//! kernel answer like a selected older kernel: `ENOSYS` for every syscall it
3//! lacks, and `EINVAL` for `madvise` advice values it does not know.
4//!
5//! Two runners apply the simulation (see [`Runner`]): [`local`] installs a
6//! BPF filter built by [`filter`] and runs the program on the host, and
7//! [`container`] runs it in an image under the equivalent OCI profile.
8//!
9//! The profiles are a testing aid, not a security boundary: everything the
10//! simulated kernel has is allowed. Other newer flags on old syscalls and
11//! behavior changes are not modelled yet; see the roadmap.
12//!
13//! The simulation is tier 2 of the testing tiers: it catches what a test
14//! executes, but it is not qualification on a real target.
15//!
16//! ```
17//! use kernel_abi_tools::{distro, filter, missing_syscalls, Target, ENOSYS};
18//!
19//! // What CentOS 7's kernel (Linux 3.10) lacks compared with the newest data.
20//! let centos = distro("centos-7").expect("preset");
21//! let missing = missing_syscalls(centos.kernel, &centos.kernel_backports);
22//! let statx = missing.iter().find(|s| s.name == "statx").expect("statx is newer");
23//! assert_eq!(statx.first.to_string(), "4.11");
24//!
25//! // The local runner's filter answers ENOSYS for it, and allows `read`.
26//! let target = Target::distro(&centos);
27//! let program = filter::program(&target);
28//! let ret = |nr| filter::evaluate(&program, filter::AUDIT_ARCH_X86_64, nr, [0; 6]);
29//! assert_eq!(ret(statx.nr)?, filter::RET_ERRNO | ENOSYS as u32);
30//! assert_eq!(ret(0)?, filter::RET_ALLOW);
31//! # Ok::<(), String>(())
32//! ```
33
34#![warn(missing_docs)]
35
36pub mod container;
37pub mod filter;
38pub mod local;
39
40pub use linux_targets::{distro, distros, Distro, KernelVersion};
41
42const SYSCALL_DATA: &str = include_str!("../data/linux/syscalls-x86_64.tsv");
43const MADVISE_DATA: &str = include_str!("../data/linux/madvise-x86_64.tsv");
44
45/// `ENOSYS` on Linux (all architectures).
46pub const ENOSYS: i32 = 38;
47/// `EINVAL` on Linux (all architectures).
48pub const EINVAL: i32 = 22;
49/// Version of the profile format this crate emits (named in each profile).
50pub const PROVIDER_FORMAT: &str = "kernel-abi-provider-v1";
51
52/// Kernel releases shipped as generic profiles. Chosen to cover the kernels
53/// of the `linux-targets` distribution presets plus common LTS lines.
54pub const GENERIC_KERNELS: &[&str] = &[
55    "3.10", "3.16", "4.4", "4.9", "4.12", "4.14", "4.15", "4.18", "4.19", "5.3", "5.4", "5.10",
56    "5.14", "5.15", "6.1", "6.4", "6.6", "6.8", "6.12",
57];
58
59/// How a program runs under a simulated kernel.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum Runner {
62    /// Host userland under an in-process seccomp filter ([`local::run`]).
63    /// Needs Linux, but no container runtime and no root.
64    Local,
65    /// A container image's userland under the OCI profile
66    /// ([`container::run`]). Needs podman or docker.
67    Container,
68}
69
70impl Runner {
71    /// Parses `local` or `container`.
72    ///
73    /// # Errors
74    ///
75    /// Returns a message for any other name.
76    pub fn parse(s: &str) -> Result<Self, String> {
77        match s {
78            "local" => Ok(Runner::Local),
79            "container" => Ok(Runner::Container),
80            _ => Err(format!(
81                "unknown runner {s:?} (expected local or container)"
82            )),
83        }
84    }
85
86    /// Applies the selection rules: a requested runner wins; otherwise a known
87    /// image (from `--image` or a distribution preset) means the container,
88    /// and no image means the local filter. The container needs an image, and
89    /// the local runner cannot use one.
90    ///
91    /// # Errors
92    ///
93    /// Returns a message when the container runner is requested without an
94    /// image, or the local runner with one.
95    pub fn choose(requested: Option<Runner>, image: Option<&str>) -> Result<Self, String> {
96        let image = image.filter(|i| !i.is_empty());
97        match (requested, image) {
98            (Some(Runner::Local), Some(i)) => Err(format!(
99                "the local runner uses the host userland; drop the image {i:?} or use the container runner"
100            )),
101            (Some(Runner::Container), None) => {
102                Err("the container runner needs an image (--image or --distro)".to_string())
103            }
104            (Some(r), _) => Ok(r),
105            (None, Some(_)) => Ok(Runner::Container),
106            (None, None) => Ok(Runner::Local),
107        }
108    }
109}
110
111/// One x86_64 syscall and the first mainline release whose table lists it.
112#[derive(Clone, Debug)]
113pub struct Syscall {
114    /// The x86_64 (64-bit ABI) syscall number.
115    pub nr: u32,
116    /// The syscall's name in the kernel's table, such as `statx`.
117    pub name: &'static str,
118    /// The first mainline release that has it, or the data's oldest release
119    /// ([`table_floor`]) for syscalls at least that old.
120    pub first: KernelVersion,
121}
122
123/// All x86_64 (64-bit ABI) syscalls, ordered by number.
124pub fn syscalls() -> Vec<Syscall> {
125    data_rows(SYSCALL_DATA)
126        .map(|cols| {
127            let [nr, name, first] = cols[..] else {
128                panic!("syscall data row needs 3 columns: {cols:?}");
129            };
130            Syscall {
131                nr: nr.parse().expect("syscall number"),
132                name,
133                first: KernelVersion::parse(first).expect("syscall first_release"),
134            }
135        })
136        .collect()
137}
138
139/// The oldest release in the data. Syscalls reported at this release may be
140/// older; kernels below it cannot be distinguished from it.
141pub fn table_floor() -> KernelVersion {
142    syscalls()
143        .iter()
144        .map(|s| s.first)
145        .min()
146        .expect("syscall data is empty")
147}
148
149/// The newest release scanned for the data (from its `# Tags scanned:`
150/// header), which can be newer than the last release that added a syscall.
151pub fn table_ceiling() -> KernelVersion {
152    SYSCALL_DATA
153        .lines()
154        .find_map(|l| l.strip_prefix("# Tags scanned: "))
155        .and_then(|range| range.split(" .. ").nth(1))
156        .and_then(|last| KernelVersion::parse(last).ok())
157        .expect("syscall data header names the scanned tags")
158}
159
160/// Syscalls missing from `kernel`, minus any names in `allow` (backports).
161pub fn missing_syscalls(kernel: KernelVersion, allow: &[&str]) -> Vec<Syscall> {
162    syscalls()
163        .into_iter()
164        .filter(|s| s.first > kernel && !allow.contains(&s.name))
165        .collect()
166}
167
168/// What a profile simulates.
169pub struct Target {
170    /// A name for messages and the profile, such as `Linux 4.12`.
171    pub label: String,
172    /// The mainline kernel release whose syscalls are present.
173    pub kernel: KernelVersion,
174    /// Syscalls present although newer than `kernel` (vendor backports).
175    pub allow: Vec<&'static str>,
176}
177
178impl Target {
179    /// A generic mainline kernel, with no backports.
180    pub fn kernel(kernel: KernelVersion) -> Self {
181        Target {
182            label: format!("Linux {kernel}"),
183            kernel,
184            allow: Vec::new(),
185        }
186    }
187
188    /// A distribution preset's kernel: its mainline base plus its backports.
189    pub fn distro(d: &Distro) -> Self {
190        Target {
191            label: format!("{} (Linux {})", d.name, d.kernel),
192            kernel: d.kernel,
193            allow: d.kernel_backports.clone(),
194        }
195    }
196}
197
198/// Syscalls `target` provides: those in its mainline release plus backports.
199pub fn present_syscalls(target: &Target) -> Vec<Syscall> {
200    syscalls()
201        .into_iter()
202        .filter(|s| s.first <= target.kernel || target.allow.contains(&s.name))
203        .collect()
204}
205
206/// One `madvise` advice value and the first mainline release defining it.
207#[derive(Clone, Debug)]
208pub struct MadviseAdvice {
209    /// The advice value passed as `madvise`'s third argument.
210    pub value: u32,
211    /// The constant's name, such as `MADV_FREE`.
212    pub name: &'static str,
213    /// The first mainline release that accepts it, or the data's oldest
214    /// release for values at least that old.
215    pub first: KernelVersion,
216}
217
218/// All `madvise` advice values known to the data, ordered by value.
219pub fn madvise_advice() -> Vec<MadviseAdvice> {
220    data_rows(MADVISE_DATA)
221        .map(|cols| {
222            let [value, name, first] = cols[..] else {
223                panic!("madvise data row needs 3 columns: {cols:?}");
224            };
225            MadviseAdvice {
226                value: value.parse().expect("madvise value"),
227                name,
228                first: KernelVersion::parse(first).expect("madvise first_release"),
229            }
230        })
231        .collect()
232}
233
234/// Whether `kernel` accepts `madvise` advice `value`. Unknown values are
235/// rejected with `EINVAL` by every kernel.
236pub fn madvise_accepts(kernel: KernelVersion, value: u32) -> bool {
237    madvise_advice()
238        .iter()
239        .any(|a| a.value == value && a.first <= kernel)
240}
241
242/// An OCI/Docker/Podman seccomp profile that allows only the syscalls
243/// `target` provides, answers `ENOSYS` for every other syscall, and answers
244/// `EINVAL` for `madvise` advice values `target` does not know.
245///
246/// The profile is an allowlist on purpose. Container runtimes resolve names
247/// with their own libseccomp and silently drop names it does not know, so a
248/// blocklist fails open for exactly the newest syscalls. With an allowlist an
249/// unknown name stays blocked (and `syscall-probe` reports it), and syscalls
250/// newer than this crate's data are blocked too.
251///
252/// `madvise` gets one rule per advice value from 0 to the highest known
253/// value, plus one for anything above it, so no two rules overlap and the
254/// result never depends on how a runtime orders conflicting rules. Values are
255/// compared on the low 32 bits because the kernel reads an `int`.
256pub fn seccomp_profile(target: &Target) -> String {
257    let present = present_syscalls(target);
258    let names: Vec<&str> = present
259        .iter()
260        .map(|s| s.name)
261        .filter(|n| *n != "madvise")
262        .collect();
263    let max_advice = madvise_advice().iter().map(|a| a.value).max().unwrap_or(0);
264    let mut entries = vec![name_list_entry(&names)];
265    for value in 0..=max_advice {
266        entries.push(madvise_entry(
267            "SCMP_CMP_MASKED_EQ",
268            u32::MAX as u64,
269            Some(value as u64),
270            madvise_accepts(target.kernel, value),
271        ));
272    }
273    entries.push(madvise_entry("SCMP_CMP_GT", max_advice as u64, None, false));
274
275    let comment = format!(
276        "kernel-abi-tools {PROVIDER_FORMAT}: simulate {} on x86_64; {} syscalls allowed, \
277         all others return ENOSYS; madvise advice it does not know returns EINVAL. \
278         Testing aid only, not a security boundary.",
279        target.label,
280        present.len()
281    );
282    format!(
283        "{{\n  \"_comment\": {},\n  \"defaultAction\": \"SCMP_ACT_ERRNO\",\n  \
284         \"defaultErrnoRet\": {ENOSYS},\n  \"architectures\": [\"SCMP_ARCH_X86_64\"],\n  \
285         \"syscalls\": [\n{}\n  ]\n}}\n",
286        json_str(&comment),
287        entries.join(",\n")
288    )
289}
290
291fn name_list_entry(names: &[&str]) -> String {
292    let list: Vec<String> = names
293        .iter()
294        .map(|n| format!("        {}", json_str(n)))
295        .collect();
296    format!(
297        "    {{\n      \"names\": [\n{}\n      ],\n      \"action\": \"SCMP_ACT_ALLOW\"\n    }}",
298        list.join(",\n")
299    )
300}
301
302fn madvise_entry(op: &str, value: u64, value_two: Option<u64>, allow: bool) -> String {
303    let action = if allow {
304        "\"action\": \"SCMP_ACT_ALLOW\"".to_string()
305    } else {
306        format!("\"action\": \"SCMP_ACT_ERRNO\", \"errnoRet\": {EINVAL}")
307    };
308    let two = value_two.map_or(String::new(), |v| format!(", \"valueTwo\": {v}"));
309    format!(
310        "    {{ \"names\": [\"madvise\"], {action}, \
311         \"args\": [{{ \"index\": 2, \"value\": {value}{two}, \"op\": \"{op}\" }}] }}"
312    )
313}
314
315fn data_rows(data: &'static str) -> impl Iterator<Item = Vec<&'static str>> {
316    data.lines()
317        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
318        .map(|l| l.split('\t').map(str::trim).collect())
319}
320
321fn json_str(s: &str) -> String {
322    let mut out = String::from("\"");
323    for c in s.chars() {
324        match c {
325            '"' => out.push_str("\\\""),
326            '\\' => out.push_str("\\\\"),
327            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
328            c => out.push(c),
329        }
330    }
331    out.push('"');
332    out
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn kv(s: &str) -> KernelVersion {
340        KernelVersion::parse(s).unwrap()
341    }
342
343    #[test]
344    fn parses_distribution_release_strings() {
345        assert_eq!(kv("4.12.14-122.37-default"), kv("4.12"));
346        assert_eq!(kv("v5.14"), kv("5.14.0"));
347        assert!(kv("4.9") < kv("4.12"));
348        assert!(KernelVersion::parse("linux").is_err());
349    }
350
351    #[test]
352    fn known_syscall_introductions() {
353        let first = |n: &str| syscalls().into_iter().find(|s| s.name == n).unwrap().first;
354        assert_eq!(first("getrandom"), kv("3.17"));
355        assert_eq!(first("copy_file_range"), kv("4.5"));
356        assert_eq!(first("statx"), kv("4.11"));
357        assert_eq!(first("clone3"), kv("5.3"));
358        assert_eq!(first("close_range"), kv("5.9"));
359        assert_eq!(table_floor(), kv("3.3"));
360        // The scan reached 7.2 even though the last new syscall is from 7.0.
361        assert_eq!(table_ceiling(), kv("7.2"));
362    }
363
364    #[test]
365    fn linux_4_12_profile_boundaries() {
366        let names: Vec<_> = missing_syscalls(kv("4.12"), &[])
367            .iter()
368            .map(|s| s.name)
369            .collect();
370        assert!(names.contains(&"rseq"));
371        assert!(names.contains(&"io_uring_setup"));
372        assert!(names.contains(&"clone3"));
373        assert!(!names.contains(&"statx"));
374        assert!(!names.contains(&"copy_file_range"));
375        assert!(!names.contains(&"read"));
376    }
377
378    #[test]
379    fn backports_are_not_blocked() {
380        let names: Vec<_> = missing_syscalls(kv("3.10"), &["getrandom"])
381            .iter()
382            .map(|s| s.name)
383            .collect();
384        assert!(!names.contains(&"getrandom"));
385        assert!(names.contains(&"memfd_create"));
386    }
387
388    #[test]
389    fn profile_is_a_fail_closed_allowlist() {
390        let p = seccomp_profile(&Target::kernel(kv("4.12")));
391        assert!(p.contains("\"defaultAction\": \"SCMP_ACT_ERRNO\""));
392        assert!(p.contains("\"defaultErrnoRet\": 38"));
393        assert!(p.contains("\"statx\""));
394        assert!(!p.contains("\"rseq\""));
395        let all = seccomp_profile(&Target::kernel(table_ceiling()));
396        // Every syscall but madvise is in the name list; madvise has rules.
397        assert_eq!(all.matches("\n        \"").count(), syscalls().len() - 1);
398    }
399
400    #[test]
401    fn madvise_advice_introductions() {
402        let first = |n: &str| {
403            madvise_advice()
404                .into_iter()
405                .find(|a| a.name == n)
406                .unwrap()
407                .first
408        };
409        assert_eq!(first("MADV_FREE"), kv("4.5"));
410        assert_eq!(first("MADV_WIPEONFORK"), kv("4.14"));
411        assert_eq!(first("MADV_POPULATE_READ"), kv("5.14"));
412        assert!(madvise_accepts(kv("4.12"), 4)); // MADV_DONTNEED
413        assert!(madvise_accepts(kv("4.12"), 8)); // MADV_FREE
414        assert!(!madvise_accepts(kv("4.12"), 22)); // MADV_POPULATE_READ
415        assert!(!madvise_accepts(table_ceiling(), 5)); // never defined
416    }
417
418    #[test]
419    fn madvise_rules_cover_every_value_once() {
420        let p = seccomp_profile(&Target::kernel(kv("4.12")));
421        let max = madvise_advice().iter().map(|a| a.value).max().unwrap();
422        let rules = p.matches("\"names\": [\"madvise\"]").count();
423        assert_eq!(rules as u32, max + 2); // 0..=max, plus one for > max
424        assert!(p.contains(
425            "\"action\": \"SCMP_ACT_ERRNO\", \"errnoRet\": 22, \
426             \"args\": [{ \"index\": 2, \"value\": 4294967295, \"valueTwo\": 22,"
427        ));
428        assert!(p.contains(
429            "\"action\": \"SCMP_ACT_ALLOW\", \
430             \"args\": [{ \"index\": 2, \"value\": 4294967295, \"valueTwo\": 8,"
431        ));
432    }
433
434    #[test]
435    fn runner_selection_rules() {
436        use Runner::*;
437        assert_eq!(Runner::choose(None, None), Ok(Local));
438        assert_eq!(Runner::choose(None, Some("img")), Ok(Container));
439        assert_eq!(Runner::choose(None, Some("")), Ok(Local));
440        assert_eq!(Runner::choose(Some(Local), None), Ok(Local));
441        assert_eq!(Runner::choose(Some(Container), Some("img")), Ok(Container));
442        assert!(Runner::choose(Some(Local), Some("img")).is_err());
443        assert!(Runner::choose(Some(Container), None).is_err());
444        assert!(Runner::parse("docker").is_err());
445    }
446
447    #[test]
448    fn every_distro_preset_parses() {
449        let all = distros();
450        assert!(all
451            .iter()
452            .any(|d| d.id == "sles-12-sp5" && d.kernel == kv("4.12")));
453        for d in &all {
454            for b in &d.kernel_backports {
455                assert!(
456                    syscalls().iter().any(|s| s.name == *b),
457                    "{}: unknown backport {b}",
458                    d.id
459                );
460            }
461        }
462    }
463}