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