Skip to main content

bootintel_detectors/
os_hardening.rs

1//! Kernel hardening posture, read from what the kernel announced at boot.
2//!
3//! A port of `api/analysis_engine/detectors/os_hardening.py`, kept in step by
4//! the shared expectation in `tests/fixtures/boot_chain/expect.txt`.
5//!
6//! The boot log states plainly which protections are active: mandatory access
7//! control, memory initialisation, kernel address randomisation. A practitioner
8//! reads `mem auto-init: stack:off, heap alloc:off, heap free:off` and knows
9//! immediately that a whole class of uninitialised-memory bugs stays exploitable
10//! on this device; `grep` hands that back one line at a time with no indication
11//! which of the three mattered.
12//!
13//! # The trap this module is built around
14//!
15//! `selinux=0` means opposite things depending on the line it sits on:
16//!
17//! ```text
18//! cmdline: console=ttyS3 ... selinux=0 scandelay root=/2   (bootintel-1)
19//! Unknown command line parameters: ... selinux=0           (bootintel-6)
20//! ```
21//!
22//! The first is SELinux switched off. The second is the kernel reporting it did
23//! not recognise the parameter, which means SELinux is not compiled in at all: a
24//! different and worse fact. Reporting the second as "disabled by boot
25//! parameter" would describe a device that does not exist while understating the
26//! real finding, so the unknown-parameter line is parsed first and anything
27//! listed there is treated as not applied.
28//!
29//! # What this does not do
30//!
31//! It records facts and raises no findings, because the Rust and browser
32//! detector sets are pinned to the same 14 labels and a fifteenth would break
33//! that parity. The engine raises the findings; both sides share the facts.
34//!
35//! Absence is never evidence: a capture that never mentions KASLR is not a
36//! capture proving it off, and nothing here reports it as such.
37
38use std::sync::LazyLock;
39
40use regex::Regex;
41
42// Character-for-character from the engine module, for the same reason as the
43// boot-chain patterns: reasoning about whether two hand-written tokenisers agree
44// is more expensive than keeping them identical.
45//
46// Unanchored on purpose. The same kernel line arrives with a `[    0.000000]`
47// prefix on one device and a `Feb 25 13:51:14 host kernel:` syslog prefix on
48// three others; anchoring it silently misses those.
49static RE_MEM_AUTO_INIT: LazyLock<Regex> = LazyLock::new(|| {
50    Regex::new(r"(?i)mem auto-init:\s*stack:(\S+?),\s*heap alloc:(\S+?),\s*heap free:(\S+?)\s*$")
51        .unwrap()
52});
53
54// `KASLR disabled due to lack of seed` is the embedded failure mode: the kernel
55// supports randomisation and the bootloader handed it no entropy, so it is off
56// on a device whose vendor believes it is on.
57static RE_KASLR_OFF: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r"(?i)\bKASLR disabled(?:\s+due to\s+(.+?))?\s*$").unwrap());
59static RE_KASLR_ON: LazyLock<Regex> =
60    LazyLock::new(|| Regex::new(r"(?i)\bKASLR enabled\b").unwrap());
61
62static RE_LSM_LIST: LazyLock<Regex> =
63    LazyLock::new(|| Regex::new(r"(?i)\bLSM:\s*initializing\s+lsm=(\S+)").unwrap());
64static RE_APPARMOR_OFF: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(r"(?i)AppArmor:\s*AppArmor disabled by boot time parameter").unwrap()
66});
67static RE_SELINUX_STATE: LazyLock<Regex> = LazyLock::new(|| {
68    Regex::new(r"(?i)SELinux:\s*(Initializing|Permissive|Enforcing|Disabled at runtime)").unwrap()
69});
70static RE_UNKNOWN_PARAMS: LazyLock<Regex> =
71    LazyLock::new(|| Regex::new(r"(?i)Unknown command line parameters:\s*(.+?)\s*$").unwrap());
72static RE_CMDLINE: LazyLock<Regex> =
73    LazyLock::new(|| Regex::new(r"(?i)(?:Kernel command line|cmdline|bootargs)\s*[:=]").unwrap());
74static RE_SELINUX_OFF: LazyLock<Regex> =
75    LazyLock::new(|| Regex::new(r"(?i)\bselinux=0\b").unwrap());
76
77/// Modules that provide mandatory access control, as opposed to the ones every
78/// kernel has. `capability` is always present and enforces nothing of the kind.
79const MAC_MODULES: &[&str] = &["selinux", "apparmor", "smack", "tomoyo"];
80
81/// `mem auto-init: stack:off, heap alloc:off, heap free:off`.
82#[derive(Debug, Default, Clone, PartialEq, Eq)]
83pub struct MemAutoInit {
84    pub stack: String,
85    pub heap_alloc: String,
86    pub heap_free: String,
87}
88
89/// What the kernel said about its own hardening.
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct OsHardening {
92    pub mem_auto_init: Option<MemAutoInit>,
93    /// `enabled` or `disabled`.
94    pub kaslr: Option<String>,
95    pub kaslr_reason: Option<String>,
96    /// The active security modules, as the kernel listed them.
97    pub lsm: Vec<String>,
98    /// The subset of `lsm` that actually provides mandatory access control.
99    pub mac_modules: Vec<String>,
100    /// `disabled_by_parameter`, `not_supported`, or a runtime state.
101    pub selinux: Option<String>,
102    pub apparmor: Option<String>,
103    /// Parameters the kernel listed as unrecognised, and therefore did not apply.
104    pub ignored_kernel_parameters: Option<String>,
105}
106
107impl OsHardening {
108    /// True when the capture said nothing about any of this.
109    pub fn is_empty(&self) -> bool {
110        *self == Self::default()
111    }
112}
113
114fn clip(s: &str, max: usize) -> String {
115    s.chars().take(max).collect()
116}
117
118/// Read the kernel's hardening report.
119pub fn parse(log: &str) -> OsHardening {
120    let mut h = OsHardening::default();
121    for raw in log.lines() {
122        let line = raw.trim_end_matches(['\r', '\n']);
123
124        // First, because it changes what a later `selinux=0` means.
125        if let Some(caps) = RE_UNKNOWN_PARAMS.captures(line) {
126            let ignored = caps[1].to_string();
127            if h.ignored_kernel_parameters.is_none() {
128                h.ignored_kernel_parameters = Some(clip(&ignored, 300));
129            }
130            if RE_SELINUX_OFF.is_match(&ignored) && h.selinux.is_none() {
131                h.selinux = Some("not_supported".to_string());
132            }
133            continue;
134        }
135
136        if let Some(caps) = RE_MEM_AUTO_INIT.captures(line) {
137            if h.mem_auto_init.is_none() {
138                h.mem_auto_init = Some(MemAutoInit {
139                    stack: caps[1].trim().to_string(),
140                    heap_alloc: caps[2].trim().to_string(),
141                    heap_free: caps[3].trim().to_string(),
142                });
143                continue;
144            }
145        }
146
147        if let Some(caps) = RE_KASLR_OFF.captures(line) {
148            if h.kaslr.is_none() {
149                h.kaslr = Some("disabled".to_string());
150                let reason = caps.get(1).map(|m| m.as_str().trim()).unwrap_or_default();
151                if !reason.is_empty() {
152                    h.kaslr_reason = Some(clip(reason, 120));
153                }
154                continue;
155            }
156        }
157
158        if RE_KASLR_ON.is_match(line) && h.kaslr.is_none() {
159            h.kaslr = Some("enabled".to_string());
160            continue;
161        }
162
163        if let Some(caps) = RE_LSM_LIST.captures(line) {
164            if h.lsm.is_empty() {
165                h.lsm = caps[1]
166                    .split(',')
167                    .map(|x| x.trim().to_ascii_lowercase())
168                    .filter(|x| !x.is_empty())
169                    .collect();
170                let mut mac: Vec<String> = h
171                    .lsm
172                    .iter()
173                    .filter(|m| MAC_MODULES.contains(&m.as_str()))
174                    .cloned()
175                    .collect();
176                mac.sort();
177                mac.dedup();
178                h.mac_modules = mac;
179                continue;
180            }
181        }
182
183        if RE_APPARMOR_OFF.is_match(line) && h.apparmor.is_none() {
184            h.apparmor = Some("disabled_by_parameter".to_string());
185            continue;
186        }
187
188        if let Some(caps) = RE_SELINUX_STATE.captures(line) {
189            if h.selinux.is_none() {
190                h.selinux = Some(caps[1].to_ascii_lowercase());
191                continue;
192            }
193        }
194
195        // A `selinux=0` the kernel DID recognise, on a real command line.
196        if RE_CMDLINE.is_match(line) && RE_SELINUX_OFF.is_match(line) && h.selinux.is_none() {
197            h.selinux = Some("disabled_by_parameter".to_string());
198        }
199    }
200    h
201}