diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e0712..ef1136f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ This file records each release. The format follows [Keep a Changelog](https://ke ### Added - A socket has the owner and the mode of its inode, as sockfs of Linux. A new socket has the file system user and group IDs of the process and the mode 0777. `fchown`, `fchmod` and `fchownat` with `AT_EMPTY_PATH` change them. After the release of the socket, a change of the owner gives `ENOENT`, as `sockfs_setattr` of Linux for CVE-2019-8912. LTP af_alg07 tests this (#541). +- AppArmor checks the capabilities, as capability.c of Linux. A capability that the profile does not allow gives `EPERM` and a record of the class `cap`, with the rules `audit` and `quiet` and the skip of a repeated record. Each check of a capability calls the `capable` hook of the security modules after the check of the capability module, also the checks in a user namespace and the checks of a file, as `ns_capable` and `capable_wrt_inode_uidgid` of Linux (#540). + +### Fixed + +- AppArmor finds the parent of a hat that has the name of a program, as `aa_split_fqname` of Linux. In `P///bin/x` the parent is `P` and the hat is `/bin/x`. Before, the parent was `P/`, so a `change_hat` from that hat to a sibling hat gave `ECHILD` (#540). ## [0.5.25] - 2026-11-13 diff --git a/core/rk-syscall/src/ns.rs b/core/rk-syscall/src/ns.rs index 6a1771c..acd74d6 100644 --- a/core/rk-syscall/src/ns.rs +++ b/core/rk-syscall/src/ns.rs @@ -297,7 +297,6 @@ pub(crate) fn may_join_mnt_ns(cred: &Cred, ns: &MountNamespace) -> Result<(), Er if !cred.ns_capable(ns.owner(), cap::SYS_ADMIN) || !cred.has_cap(cap::SYS_CHROOT) || !cred.has_cap(cap::SYS_ADMIN) - || !rk_vfs::security::capable(cred, cap::SYS_ADMIN, 0) { return Err(Errno::EPERM); } diff --git a/core/rk-syscall/src/process.rs b/core/rk-syscall/src/process.rs index 1dfc6c1..d3826f0 100644 --- a/core/rk-syscall/src/process.rs +++ b/core/rk-syscall/src/process.rs @@ -316,10 +316,7 @@ fn new_cgroup_ns(process: &Process, cred: &Cred) -> Result<(), Errno> { /// `EPERM` without the capabilities. fn may_install(cred: &Cred, owner: &UserNs) -> Result<(), Errno> { let cap = rk_vfs::cred::cap::SYS_ADMIN; - if !cred.ns_capable(owner, cap) - || !cred.has_cap(cap) - || !rk_vfs::security::capable(cred, cap, 0) - { + if !cred.ns_capable(owner, cap) || !cred.has_cap(cap) { return Err(Errno::EPERM); } Ok(()) @@ -334,10 +331,7 @@ fn may_install(cred: &Cred, owner: &UserNs) -> Result<(), Errno> { /// `EPERM` without the capabilities. fn may_join_cgroup_ns(old: &Cred, cred: &Cred, ns: u32) -> Result<(), Errno> { let cap = rk_vfs::cred::cap::SYS_ADMIN; - if !old.ns_capable(&crate::ns::cgroup_owner(ns), cap) - || !old.ns_capable(&cred.user_ns, cap) - || !rk_vfs::security::capable(old, cap, 0) - { + if !old.ns_capable(&crate::ns::cgroup_owner(ns), cap) || !old.ns_capable(&cred.user_ns, cap) { return Err(Errno::EPERM); } Ok(()) diff --git a/fs/rk-vfs/src/cred.rs b/fs/rk-vfs/src/cred.rs index 118c99e..7e62637 100644 --- a/fs/rk-vfs/src/cred.rs +++ b/fs/rk-vfs/src/cred.rs @@ -288,19 +288,21 @@ impl Cred { /// The process has the capability in the first user namespace, and no security module /// denies it, as `capable` and `security_capable` of Linux. pub fn capable(&self, cap: u32) -> bool { - self.ns_capable(user_ns::init(), cap) && crate::security::capable(self, cap, 0) + self.ns_capable(user_ns::init(), cap) } /// The process has the capability in its own user namespace: the capability is in its - /// effective set, as `ns_capable(current_user_ns(), cap)` of Linux. + /// effective set, and no security module denies it, as `ns_capable(current_user_ns(), cap)` + /// of Linux. pub fn has_cap(&self, cap: u32) -> bool { - self.cap_effective & cap::bit(cap) != 0 + self.cap_effective & cap::bit(cap) != 0 && crate::security::capable_lsm(self, cap, 0) } /// The process has the capability in the user namespace `ns`, as `ns_capable` and /// `cap_capable` of Linux. The capabilities apply in the namespace of the process and in the /// namespaces below it. The owner of a namespace has all the capabilities in it, if the - /// process is in the parent of the namespace. + /// process is in the parent of the namespace. After this check of the capability module, the + /// other security modules check the capability, as `security_capable` of Linux. pub fn ns_capable(&self, ns: &UserNs, cap: u32) -> bool { let mut ns = ns; loop { @@ -312,7 +314,7 @@ impl Cred { } let Some(parent) = ns.parent() else { return false }; if **parent == *self.user_ns && ns.owner() == self.euid { - return true; + return crate::security::capable_lsm(self, cap, 0); } ns = parent; } diff --git a/fs/rk-vfs/src/security/mod.rs b/fs/rk-vfs/src/security/mod.rs index f016382..ad289c8 100644 --- a/fs/rk-vfs/src/security/mod.rs +++ b/fs/rk-vfs/src/security/mod.rs @@ -326,6 +326,16 @@ pub fn capable(cred: &Cred, cap: u32, opts: u32) -> bool { call_int_hook!(capable, cred, cap, opts).is_ok() } +/// `security_capable` after the capability module: the other active modules check the +/// capability `cap` of the task with `cred`. The caller has done the check of the capability +/// module, `cap_capable` of Linux, in the user namespace of the request. +pub fn capable_lsm(cred: &Cred, cap: u32, opts: u32) -> bool { + modules() + .iter() + .filter(|m| m.info.id.id != id::CAPABILITY) + .all(|m| m.info.hooks.capable(cred, cap, opts).is_ok()) +} + /// `security_capget`. pub fn capget(target: &Task<'_>) -> Result<()> { call_int_hook!(capget, target) diff --git a/sec/rk-lsm-apparmor/src/capability.rs b/sec/rk-lsm-apparmor/src/capability.rs new file mode 100644 index 0000000..9fdab18 --- /dev/null +++ b/sec/rk-lsm-apparmor/src/capability.rs @@ -0,0 +1,143 @@ +//! The mediation of the capabilities, as capability.c of Linux. +//! +//! A confined task has a capability only if each profile of its label allows it. The check +//! comes after the check of the capability module, so a task without the capability in its +//! effective set gets no record. A denial gives `EPERM` and a record of the class `cap`: +//! +//! ```text +//! apparmor="DENIED" operation="capable" class="cap" profile="P" pid=PID comm="COMM" +//! capability=13 capname="net_raw" +//! ``` + +use crate::audit::{self, Kind}; +use crate::domain; +use crate::policy::{Mode, Profile}; +use alloc::format; +use alloc::sync::Arc; +use rk_abi::Errno; +use rk_frame::sync::SpinLock; +use rk_vfs::cred::Cred; +use rk_vfs::inode::Result; + +/// `CAP_OPT_NOAUDIT`: the caller asks for no record. +pub const CAP_OPT_NOAUDIT: u32 = 1 << 1; + +/// The last profile with a record of a denial and its capabilities, as the `audit_cache` of +/// Linux. A second denial of the same capability for the same profile gives no record. Linux +/// has one cache for each CPU. This kernel has one. +static CACHE: SpinLock<(usize, u64)> = SpinLock::new((0, 0)); + +/// The name of the capability `cap` in the record, as `capability_names` of Linux. +fn cap_name(cap: u32) -> &'static str { + crate::features::CAPS_MASK.split(' ').nth(cap as usize).unwrap_or("?") +} + +/// The bit of the capability `cap` in a set of the policy. +fn bit(cap: u32) -> u64 { + 1u64.checked_shl(cap).unwrap_or(0) +} + +/// Logs the check of the capability `cap` for the profile `p` with the result `error`, as +/// `audit_caps` of Linux. +/// +/// # Errors +/// +/// Gives `error`, except for a denial that complain mode allows. +fn audit_caps( + p: &Arc, + cap: u32, + info: Option<&'static str>, + error: Option, +) -> Result<()> { + let rules = p.rules.as_deref(); + let audit_all = rules.is_some_and(|r| r.audit); + let caps = rules.map(|r| r.caps).unwrap_or_default(); + let kind = match error { + // An allowed capability has a record only if the policy audits it. + None if !audit_all && caps.audit & bit(cap) == 0 => return Ok(()), + None => Kind::Audit, + Some(_) if p.mode == Mode::Kill => Kind::Killed, + // A rule with `quiet` hides the denial, also in complain mode, as on Linux. + Some(e) if caps.quiet & bit(cap) != 0 && !audit_all => return Err(e), + Some(_) => domain::mode_kind(Some(p)), + }; + // The same denial again gives no record. + { + let mut cache = CACHE.lock(); + let key = Arc::as_ptr(p) as usize; + if cache.0 == key && cache.1 & bit(cap) != 0 { + // As on Linux, a repeated denial does not kill the task again. + let kind = if p.mode == Mode::Complain { Kind::Allowed } else { Kind::Denied }; + return domain::outcome(kind, error); + } + if cache.0 != key { + *cache = (key, 0); + } + cache.1 |= bit(cap); + } + let fq = p.fqname(); + let d = audit::Data { + op: Some("capable"), + class: Some("cap"), + info, + error: error.map_or(0, |e| -i32::from(e.get())), + profile: Some(&fq), + name: None, + }; + let mut tail = format!(" capability={cap} capname="); + audit::untrusted(&mut tail, cap_name(cap).as_bytes()); + audit::log(kind, &d, &tail); + domain::outcome(kind, error) +} + +/// The check of the capability `cap` for the profile `p`, `profile_capable` of Linux. +/// +/// # Errors +/// +/// Gives `EPERM` if the profile does not allow the capability, except in complain mode. +fn profile_capable(p: &Arc, cap: u32, opts: u32) -> Result<()> { + let caps = p.rules.as_ref().map(|r| r.caps).unwrap_or_default(); + let error = (caps.allow & bit(cap) == 0).then_some(Errno::EPERM); + let mut info = None; + if opts & CAP_OPT_NOAUDIT != 0 { + if p.mode != Mode::Complain { + return error.map_or(Ok(()), Err); + } + // Complain mode logs the request, but tells that it is optional. + info = Some("optional: no audit"); + } + audit_caps(p, cap, info, error) +} + +/// `apparmor_capable` and `aa_capable` of Linux: the task with `cred` uses the capability +/// `cap`. Each profile of the label checks it. +/// +/// # Errors +/// +/// Gives the error of the last profile that denies the capability. +pub fn capable(cred: &Cred, cap: u32, opts: u32) -> Result<()> { + if domain::label_id(cred) == 0 { + return Ok(()); + } + let mut result = Ok(()); + for p in domain::label_of(cred).iter().filter(|p| p.mode != Mode::Unconfined) { + if let Err(e) = profile_capable(p, cap, opts) { + result = Err(e); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_capability_has_the_name_of_linux() { + assert_eq!(cap_name(0), "chown"); + assert_eq!(cap_name(13), "net_raw"); + assert_eq!(cap_name(21), "sys_admin"); + assert_eq!(cap_name(40), "checkpoint_restore"); + assert_eq!(bit(64), 0); + } +} diff --git a/sec/rk-lsm-apparmor/src/features.rs b/sec/rk-lsm-apparmor/src/features.rs index 6b17121..9c3cbd3 100644 --- a/sec/rk-lsm-apparmor/src/features.rs +++ b/sec/rk-lsm-apparmor/src/features.rs @@ -32,7 +32,7 @@ impl Value { } /// The capabilities, `AA_SFS_CAPS_MASK`. -const CAPS_MASK: &str = "chown dac_override dac_read_search fowner fsetid kill setgid setuid \ +pub(crate) const CAPS_MASK: &str = "chown dac_override dac_read_search fowner fsetid kill setgid setuid \ setpcap linux_immutable net_bind_service net_broadcast net_admin net_raw ipc_lock ipc_owner \ sys_module sys_rawio sys_chroot sys_ptrace sys_pacct sys_admin sys_boot sys_nice sys_resource \ sys_time sys_tty_config mknod lease audit_write audit_control setfcap mac_override mac_admin \ diff --git a/sec/rk-lsm-apparmor/src/lib.rs b/sec/rk-lsm-apparmor/src/lib.rs index d638813..f7adbac 100644 --- a/sec/rk-lsm-apparmor/src/lib.rs +++ b/sec/rk-lsm-apparmor/src/lib.rs @@ -6,7 +6,8 @@ //! unpack of the binary policy ([`unpack`]) with its DFA tables ([`dfa`]), the policy namespace //! that holds the profiles ([`policy`]), the audit records ([`audit`]), the label of a task //! with its change at an exec ([`domain`]), the changes that a task asks for with -//! `change_hat` and `change_profile` ([`task`]) and the checks of the open files ([`file`]). +//! `change_hat` and `change_profile` ([`task`]), the checks of the files ([`file`]) and the +//! checks of the capabilities ([`capability`]). //! //! The module is active when the command line has `apparmor=1`, or when `lsm=` names it. With //! `apparmor=0` it is not active. This is the value 0 of the old @@ -19,6 +20,7 @@ extern crate alloc; pub mod audit; +pub mod capability; pub mod dfa; pub mod domain; pub mod features; @@ -46,6 +48,10 @@ use rk_vfs::security::{ struct AppArmor; impl SecurityHooks for AppArmor { + fn capable(&self, cred: &Cred, cap: u32, opts: u32) -> Result<()> { + capability::capable(cred, cap, opts) + } + fn bprm_creds_for_exec(&self, bprm: &mut Bprm<'_>) -> Result<()> { domain::bprm_creds_for_exec(bprm) } diff --git a/sec/rk-lsm-apparmor/src/policy.rs b/sec/rk-lsm-apparmor/src/policy.rs index 1e1f160..91c1576 100644 --- a/sec/rk-lsm-apparmor/src/policy.rs +++ b/sec/rk-lsm-apparmor/src/policy.rs @@ -107,13 +107,26 @@ impl Profile { /// The name without the names of the parents, `base.name` of Linux. pub fn base_name(&self) -> &str { - self.name.rfind("//").map_or(&self.name, |i| &self.name[i + 2..]) + last_sep(&self.name).map_or(&self.name, |i| &self.name[i + 2..]) } } /// The name of the parent of the profile `name`. fn parent_of(name: &str) -> Option<&str> { - name.rfind("//").map(|i| &name[..i]) + last_sep(name).map(|i| &name[..i]) +} + +/// The position of the last `//` that separates the names of a profile and its parent. As +/// `aa_split_fqname` of Linux, the search goes from the start, so in `P///bin/x` the parent is `P` +/// and the hat is `/bin/x`, which is the name of a program. +fn last_sep(name: &str) -> Option { + let mut at = 0; + let mut last = None; + while let Some(i) = name[at..].find("//") { + last = Some(at + i); + at += i + 2; + } + last } /// A policy namespace, `struct aa_ns` of Linux. @@ -494,6 +507,14 @@ mod tests { ns.profiles().iter().map(|p| alloc::format!("{} ({})", p.name, p.mode.name())).collect() } + #[test] + fn a_hat_with_the_name_of_a_program_has_the_right_parent() { + assert_eq!(parent_of("/bin/w///bin/x"), Some("/bin/w")); + assert_eq!(parent_of("a//b//c"), Some("a//b")); + assert_eq!(parent_of("/bin/w"), None); + assert_eq!(&"/bin/w///bin/x"[last_sep("/bin/w///bin/x").unwrap() + 2..], "/bin/x"); + } + #[test] fn root_may_change_the_policy_and_a_user_may_not() { let root = Cred::root(); diff --git a/sec/rk-lsm-apparmor/src/unpack.rs b/sec/rk-lsm-apparmor/src/unpack.rs index bed0df8..9cc4d92 100644 --- a/sec/rk-lsm-apparmor/src/unpack.rs +++ b/sec/rk-lsm-apparmor/src/unpack.rs @@ -1236,6 +1236,8 @@ mod tests { let tmp = allow(&t.file, class::FILE, b"/tmp/a/b", 0); assert_eq!(tmp & (perm::READ | perm::WRITE), perm::READ | perm::WRITE); assert_eq!(allow(&t.file, class::FILE, b"/etc/passwd", 0) & perm::READ, 0); + // The profile has no capability rule. + assert_eq!(t.caps, Caps::default()); } /// A small policy: the profile `rk_min` in complain mode, with no rules. diff --git a/tests/user/init.c b/tests/user/init.c index d839be3..851d480 100644 --- a/tests/user/init.c +++ b/tests/user/init.c @@ -15840,7 +15840,8 @@ static int aa_write(const char *path, const char *data, size_t len, int flags) * With the argument "exec", it checks that it has no label for the next exec. With the argument * "deny", it checks that rk_test in enforce mode denies an open, a lock, a stat, a change of the * attributes, a truncate to the size argv[2], a link, a rename, a create, a remove and an exec - * with EACCES, and gives 0 or the number of the first check that failed. It does not read its + * with EACCES, and chroot and sethostname with EPERM, because the rules allow no capability. It + * gives 0 or the number of the first check that failed. It does not read its * label, because the rules do not allow it. The names are in /etc, which has no rule. */ static int aa_label_helper(int argc, char **argv) @@ -15879,6 +15880,10 @@ static int aa_label_helper(int argc, char **argv) return 14; if (unlink("/etc/rk_test.aa") == 0 || errno != EACCES) return 15; + if (chroot("/") == 0 || errno != EPERM) + return 16; + if (sethostname("rk-aa", 5) == 0 || errno != EPERM) + return 17; execv("/tmp/rk-sub", args); return errno == EACCES ? 0 : 5; } @@ -16206,7 +16211,7 @@ static void test_apparmor(void) execv("/usr/bin/rk-test", five); _exit(127); } - check("apparmor: in enforce mode, an open, a lock, a path operation and an exec that the rules do not allow give EACCES", pid > 0 && waitpid(pid, &status, 0) == pid && WIFEXITED(status) && WEXITSTATUS(status) == 0); + check("apparmor: in enforce mode, an open, a lock, a path operation and an exec that the rules do not allow give EACCES, and a capability gives EPERM", pid > 0 && waitpid(pid, &status, 0) == pid && WIFEXITED(status) && WEXITSTATUS(status) == 0); if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) printf("init: the AppArmor enforce checks failed at the check %d\n", WIFEXITED(status) ? WEXITSTATUS(status) : -1); pid = fork(); @@ -16224,6 +16229,7 @@ static void test_apparmor(void) check("apparmor: the kernel log has the denials, as on Linux", n > 0 && strstr(buf, "apparmor=\"DENIED\" operation=\"open\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk_test.aa\" pid=") != NULL && strstr(buf, "apparmor=\"DENIED\" operation=\"file_lock\" class=\"file\" profile=\"rk_test\" name=\"/tmp/rk-aa-w\" pid=") != NULL && strstr(buf, "apparmor=\"DENIED\" operation=\"exec\" class=\"file\" profile=\"rk_test\" name=\"/tmp/rk-sub\" pid=") != NULL); check("apparmor: the kernel log has the denials of the path operations, as on Linux", strstr(buf, "operation=\"getattr\"") == NULL && strstr(buf, "apparmor=\"DENIED\" operation=\"chmod\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk_test.aa\" pid=") != NULL && strstr(buf, "operation=\"truncate\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk_test.aa\" pid=") != NULL && strstr(buf, "operation=\"rename_src\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk_test.aa\" pid=") != NULL && strstr(buf, "operation=\"unlink\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk_test.aa\" pid=") != NULL); check("apparmor: the kernel log has the denials of the link and the creates, as on Linux", strstr(buf, "operation=\"link\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk-aa-l\" pid=") != NULL && strstr(buf, " requested_mask=\"l\" denied_mask=\"l\" fsuid=0 ouid=0 target=\"/etc/rk_test.aa\"") != NULL && strstr(buf, "operation=\"mkdir\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk-aa-d/\" pid=") != NULL && strstr(buf, "operation=\"rmdir\" class=\"file\" profile=\"rk_test\" name=\"/usr/bin/\" pid=") != NULL && strstr(buf, "operation=\"symlink\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk-aa-s\" pid=") != NULL && strstr(buf, "operation=\"mknod\" class=\"file\" profile=\"rk_test\" name=\"/etc/rk-aa-n\" pid=") != NULL); + check("apparmor: the kernel log has the denials of the capabilities, as on Linux", strstr(buf, "apparmor=\"DENIED\" operation=\"capable\" class=\"cap\" profile=\"rk_test\" pid=") != NULL && strstr(buf, " capability=18 capname=\"sys_chroot\"") != NULL && strstr(buf, " capability=21 capname=\"sys_admin\"") != NULL); check("apparmor: the kernel log has the missing profile in enforce mode", strstr(buf, "apparmor=\"DENIED\" operation=\"change_profile\" class=\"file\" info=\"label not found\" error=-2 profile=\"rk_test\" name=\"rk_none\" pid=") != NULL); } check("apparmor: a remove of rk_test removes its children", aa_write("/sys/kernel/security/apparmor/.remove", "rk_test", 7, 0) == 0 && aa_read("/sys/kernel/security/apparmor/profiles", buf, 64) == 0);