From 8c3fbd6bbc7a4df9bee4973f5c9e55e6b4b95c58 Mon Sep 17 00:00:00 2001 From: luytan Date: Fri, 14 Aug 2026 21:28:11 +0200 Subject: [PATCH 1/2] feat: replace devid key by a name key Co-authored-by: GLM --- crates/cardwire-daemon/src/core/inode.rs | 45 ++-- crates/cardwire-ebpf-userspace/src/lib.rs | 255 +++++----------------- crates/cardwire-ebpf/src/helpers.rs | 158 +++++++++++++- crates/cardwire-ebpf/src/main.rs | 183 +++------------- crates/cardwire-ebpf/src/maps.rs | 13 +- 5 files changed, 267 insertions(+), 387 deletions(-) diff --git a/crates/cardwire-daemon/src/core/inode.rs b/crates/cardwire-daemon/src/core/inode.rs index 3bd844ff..6a1d9f5e 100644 --- a/crates/cardwire-daemon/src/core/inode.rs +++ b/crates/cardwire-daemon/src/core/inode.rs @@ -92,7 +92,7 @@ pub fn render_to_inode(render: u32) -> Result { warn!("failed to get inode for {}: {}", render_path, e); e })?; - Ok(InodeKey::new(metadata.dev(), metadata.ino())) + Ok(InodeKey::new(&format!("renderD{}", render), metadata.ino())) } pub fn card_to_inode(card: u32) -> Result { @@ -101,7 +101,7 @@ pub fn card_to_inode(card: u32) -> Result { warn!("failed to get inode for {}: {}", card_path, e); e })?; - Ok(InodeKey::new(metadata.dev(), metadata.ino())) + Ok(InodeKey::new(&format!("card{}", card), metadata.ino())) } // Here return a list of inode that contain the pci card, the audio card and their parents @@ -117,13 +117,13 @@ pub fn pci_to_inode( // First get the link ino let pci_path = format!("/sys/bus/pci/devices/{}", pci); if let Ok(metadata) = fs::metadata(&pci_path) { - inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); + inodes.push(InodeKey::new(pci, metadata.ino())); } // Now without following link let pci_path = format!("/sys/bus/pci/devices/{}", pci); if let Ok(metadata) = fs::symlink_metadata(&pci_path) { - inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); + inodes.push(InodeKey::new(pci, metadata.ino())); } }; @@ -153,7 +153,7 @@ pub fn single_pci_to_inode(pci: &str) -> Result { warn!("failed to get inode for {}: {}", pci_path, e); e })?; - Ok(InodeKey::new(metadata.dev(), metadata.ino())) + Ok(InodeKey::new(pci, metadata.ino())) } pub fn nvidia_to_inode(nvidia_minor: u32) -> Result { @@ -162,7 +162,10 @@ pub fn nvidia_to_inode(nvidia_minor: u32) -> Result { warn!("failed to get inode for {}: {}", nvidia_path, e); e })?; - Ok(InodeKey::new(metadata.dev(), metadata.ino())) + Ok(InodeKey::new( + &format!("nvidia{}", nvidia_minor), + metadata.ino(), + )) } /// The only gpu vendor that need it's backlight to be blocked is nvidia @@ -172,7 +175,10 @@ pub fn backlight_to_inode(nvidia_minor: u32) -> Result { warn!("failed to get inode for {}: {}", nvidia_path, e); e })?; - Ok(InodeKey::new(metadata.dev(), metadata.ino())) + Ok(InodeKey::new( + &format!("nvidia_{}", nvidia_minor), + metadata.ino(), + )) } pub fn exp_nvidia_inodes() -> Result> { @@ -181,7 +187,7 @@ pub fn exp_nvidia_inodes() -> Result> { // Get nvidiactl inode let nvidiactl = "/dev/nvidiactl"; if let Ok(metadata) = fs::metadata(nvidiactl) { - inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); + inodes.push(InodeKey::new("nvidiactl", metadata.ino())); } // Now try to find the vulkan file @@ -202,7 +208,10 @@ pub fn exp_nvidia_inodes() -> Result> { }; for entry in entries.flatten() { - let name = entry.file_name(); + let entry_name = entry.file_name(); + let Some(name) = entry_name.to_str() else { + continue; + }; if (name == "nvidia_icd.json" || name == "nvidia_icd.x86_64.json" @@ -210,7 +219,7 @@ pub fn exp_nvidia_inodes() -> Result> { && let Ok(metadata) = fs::metadata(entry.path()) && metadata.is_file() { - inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); + inodes.push(InodeKey::new(name, metadata.ino())); } } } @@ -232,7 +241,7 @@ pub fn sys_drm_inodes(render: u32, card: u32) -> Result> { // we matched with the blocked device, get the inodes without following the link let inode_res = fs::symlink_metadata(entry.path()); if let Ok(meta) = inode_res { - inodes.push(InodeKey::new(meta.dev(), meta.ino())); + inodes.push(InodeKey::new(&entry_name, meta.ino())); } } } @@ -247,12 +256,12 @@ pub fn sys_hwmon(pci: &str) -> Result> { for entry in fs::read_dir(sysfs_pci_path)? { let entry = entry?; - // First add hwmon from the sysfs pci folder - if let Ok(meta) = fs::metadata(entry.path()) { - inodes.push(InodeKey::new(meta.dev(), meta.ino())); - } - // Then add from /sys/class/hwmon if let Ok(hwmon_entry) = entry.file_name().into_string() { + // First add hwmon from the sysfs pci folder + if let Ok(meta) = fs::metadata(entry.path()) { + inodes.push(InodeKey::new(&hwmon_entry, meta.ino())); + } + // Then add from /sys/class/hwmon let hwmon_path = format!("/sys/class/hwmon/{}", hwmon_entry); let hwmon_path = Path::new(&hwmon_path); // skip if folder doesnt exist @@ -260,10 +269,10 @@ pub fn sys_hwmon(pci: &str) -> Result> { continue; } if let Ok(meta) = fs::metadata(hwmon_path) { - inodes.push(InodeKey::new(meta.dev(), meta.ino())); + inodes.push(InodeKey::new(&hwmon_entry, meta.ino())); } if let Ok(meta) = fs::symlink_metadata(hwmon_path) { - inodes.push(InodeKey::new(meta.dev(), meta.ino())); + inodes.push(InodeKey::new(&hwmon_entry, meta.ino())); } } } diff --git a/crates/cardwire-ebpf-userspace/src/lib.rs b/crates/cardwire-ebpf-userspace/src/lib.rs index 73b4d073..20458750 100644 --- a/crates/cardwire-ebpf-userspace/src/lib.rs +++ b/crates/cardwire-ebpf-userspace/src/lib.rs @@ -5,7 +5,7 @@ use std::{fs, path::Path, sync::Arc}; pub use crate::errors::{CardwireEbpfError, CardwireEbpfResult}; use aya::{ - Btf, Ebpf, maps::{Array, HashMap, MapError, RingBuf}, programs::{FEntry, Lsm, TracePoint} + Btf, Ebpf, maps::{Array, HashMap, MapError, RingBuf}, programs::{Lsm, TracePoint} }; use aya_log::EbpfLogger; use log::{Log, error, info, warn}; @@ -38,38 +38,34 @@ unsafe impl aya::Pod for InodeState {} #[repr(C, align(8))] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct InodeKey { - pub dev: u64, + /// Entry name (dentry d_name / dirent d_name), zero-padded to 64 bytes + pub name: [u8; 64], pub ino: u64, } unsafe impl aya::Pod for InodeKey {} impl InodeKey { - /// Build a key from the `st_dev`/`st_ino` of a stat() result - pub fn new(st_dev: u64, ino: u64) -> Self { - Self { - dev: kernel_dev(st_dev), + /// Build a key from an entry's name and inode number + /// + /// The name is truncated to 63 bytes: the eBPF side reads names through + /// bpf_probe_read_*_str, which reserves the last byte for a NUL + pub fn new(name: &str, ino: u64) -> Self { + let mut key = Self { + name: [0u8; 64], ino, + }; + + let len = name.len().min(63); + key.name[..len].copy_from_slice(&name.as_bytes()[..len]); + if name.len() > 63 { + warn!( + "inode key name {} is longer than 63 bytes, truncating", + name + ); } - } -} -/// Width of the minor field in the kernel's dev_t, MKDEV shifts the major by -/// this much -const MINOR_BITS: u32 = 20; - -/// Repack a glibc `st_dev` into the kernel's dev_t, the eBPF side keys on -/// `(*sb).s_dev` which is already in that form -/// -/// MKDEV gives each number one contiguous field. glibc instead cuts both in -/// half and interleaves them: minor bits 0-7 sit at bits 0-7, major bits 0-11 -/// at 8-19, the rest of minor at 20+, the rest of major at 44+. Each line -/// below rejoins one number's two halves, and the wide mask discards the other -/// number's bits that the shift dragged into range. -fn kernel_dev(st_dev: u64) -> u64 { - let major = ((st_dev >> 8) & 0x0000_0fff) | ((st_dev >> 32) & 0xffff_f000); - let minor = (st_dev & 0x0000_00ff) | ((st_dev >> 12) & 0xffff_ff00); - - (major << MINOR_BITS) | minor + key + } } impl EbpfBlocker { @@ -118,38 +114,6 @@ impl EbpfBlocker { .attach("sched", "sched_process_exit") .map_err(CardwireEbpfError::aya)?; - // iterate_dir runs between the two getdents64 tracepoints and supplies - // the device id the dirents lack - // - // Unlike the getdents64 exit hook below, this one writes no userspace - // memory, so lockdown is not what stops it. It can still fail to load on - // kernels without bpf trampoline support, or when the build renamed the - // symbol we attach by name, so degrade instead of refusing to start - let mut did_iterate_dir_success = false; - - let iterate_dir_program: &mut FEntry = ebpf - .program_mut("fentry_iterate_dir") - .ok_or_else(|| CardwireEbpfError::missing_lsm("fentry_iterate_dir"))? - .try_into() - .map_err(CardwireEbpfError::aya)?; - - match iterate_dir_program - .load("iterate_dir", &btf) - .map_err(CardwireEbpfError::aya) - .and_then(|_| iterate_dir_program.attach().map_err(CardwireEbpfError::aya)) - { - Ok(_) => { - did_iterate_dir_success = true; - } - Err(err) => { - warn!( - "Failed to load or attach iterate_dir (fentry unsupported, or symbol not attachable): {}", - err - ); - warn!("no device id for dirents, directory listings will not be filtered"); - } - }; - /* This part can get rejected by the kernel if the lockdown is enabled, we warn but we do not exit carwired, it will just run in a weakened state sys_exit_getdents64 re-write userspace memory to hide an entry (file/folder), it can be rejected @@ -165,31 +129,27 @@ impl EbpfBlocker { .map_err(CardwireEbpfError::aya)?; // Try to load the program into the kernel, if success attach it, else just warn the user - // Without the device id from iterate_dir the exit hook cannot build a (dev, ino) key, so it - // would fail open on every entry: skip it entirely - if did_iterate_dir_success { - match cardwire_sys_exit_getdents64 - .load() - .map_err(CardwireEbpfError::aya) - { - Ok(_) => { - did_sys_exit_getdents64_success = true; - cardwire_sys_exit_getdents64 - .attach("syscalls", "sys_exit_getdents64") - .map_err(CardwireEbpfError::aya)?; - } - Err(err) => { - // If we cannot load the program, it usually mean the kernel lockdown is enabled - let lockdown = is_lockdown_enabled(); - warn!( - "Failed to load sys_exit_getdents64. Lockdown status: {}", - lockdown - ); - warn!("{}", err); - warn!("falling back to a weakened cardwired..."); - } - }; - } + match cardwire_sys_exit_getdents64 + .load() + .map_err(CardwireEbpfError::aya) + { + Ok(_) => { + did_sys_exit_getdents64_success = true; + cardwire_sys_exit_getdents64 + .attach("syscalls", "sys_exit_getdents64") + .map_err(CardwireEbpfError::aya)?; + } + Err(err) => { + // If we cannot load the program, it usually mean the kernel lockdown is enabled + let lockdown = is_lockdown_enabled(); + warn!( + "Failed to load sys_exit_getdents64. Lockdown status: {}", + lockdown + ); + warn!("{}", err); + warn!("falling back to a weakened cardwired..."); + } + }; // Now we try to load sys_enter_getdents64 @@ -605,131 +565,30 @@ mod tests { assert_eq!(key[15], 0); } - /// MKDEV, as the kernel builds s_dev - fn mkdev(major: u64, minor: u64) -> u64 { - (major << MINOR_BITS) | minor - } - - #[test] - fn anonymous_devices_are_unchanged_by_the_conversion() { - // tmpfs, sysfs and procfs sit on major 0, where both encodings agree - for minor in [7u64, 25, 28, 50] { - assert_eq!(kernel_dev(minor), mkdev(0, minor)); - } - } - #[test] - fn real_block_devices_are_re_encoded() { - // an nvme partition: glibc packs 259:4 as 66308, the kernel as MKDEV(259, 4) - assert_eq!(kernel_dev(66308), mkdev(259, 4)); - assert_ne!(kernel_dev(66308), 66308); - - // sd-style major 8 - assert_eq!(kernel_dev(2049), mkdev(8, 1)); - } + fn same_inode_with_different_names_is_not_the_same_key() { + let card = InodeKey::new("card1", 259); + let render = InodeKey::new("renderD129", 259); - #[test] - fn conversion_round_trips_every_major_minor_split() { - // exercise values that land in the high half of each split field - for (major, minor) in [ - (0u64, 0u64), - (8, 1), - (259, 4), - (4095, 255), - (4096, 256), - (0xffff, 0xfffff), - ] { - let st_dev = ((major & 0xfff) << 8) - | ((major & !0xfff) << 32) - | (minor & 0xff) - | ((minor & !0xff) << 12); - - assert_eq!( - kernel_dev(st_dev), - mkdev(major, minor), - "major {major} minor {minor}" - ); - } + assert_ne!(card, render); + assert_eq!(card.ino, render.ino); } #[test] - fn same_inode_on_different_filesystems_is_not_the_same_key() { - let gpu = InodeKey::new(7, 259); - let unrelated = InodeKey::new(66308, 259); + fn short_names_are_zero_padded() { + let key = InodeKey::new("sys", 13670); - assert_ne!(gpu, unrelated); - assert_eq!(gpu.ino, unrelated.ino); + assert_eq!(&key.name[..3], b"sys"); + assert_eq!(&key.name[3..], &[0u8; 61]); + assert_eq!(key.ino, 13670); } #[test] - fn conversion_matches_the_running_kernel() { - use std::{collections::BTreeMap, fs, os::unix::fs::MetadataExt}; - - let Ok(mountinfo) = fs::read_to_string("/proc/self/mountinfo") else { - return; // not available in every build sandbox - }; - - // stat() on an autofs mount point triggers the automount, so the device id - // we read back is the mounted filesystem's rather than the one mountinfo - // listed. Network filesystems can hang the stat outright, there is no - // timeout to lean on - const SKIPPED_TYPES: &[&str] = &[ - "autofs", - "nfs", - "nfs4", - "cifs", - "smb3", - "fuse", - "fuse.sshfs", - "afs", - "ceph", - ]; - - // Later entries shadow earlier ones when two filesystems share a path - let mut mounts: BTreeMap = BTreeMap::new(); - for line in mountinfo.lines() { - let fields: Vec<&str> = line.split_whitespace().collect(); - let ([major, minor], Some(path)) = ( - match fields.get(2).and_then(|f| f.split_once(':')) { - Some((major, minor)) => match (major.parse(), minor.parse()) { - (Ok(major), Ok(minor)) => [major, minor], - _ => continue, - }, - None => continue, - }, - fields.get(4), - ) else { - continue; - }; - - // The optional fields end at a lone "-", the filesystem type follows it - let fs_type = fields - .iter() - .position(|field| *field == "-") - .and_then(|separator| fields.get(separator + 1)); - match fs_type { - Some(fs_type) if SKIPPED_TYPES.contains(fs_type) => continue, - Some(_) => {} - // A line we cannot classify is not worth stat'ing blindly - None => continue, - } - - mounts.insert((*path).to_owned(), (major, minor)); - } - - let mut checked = 0; - for (path, (major, minor)) in mounts { - let Ok(meta) = fs::metadata(&path) else { - continue; - }; - assert_eq!( - kernel_dev(meta.dev()), - mkdev(major, minor), - "device id mismatch for {path}" - ); - checked += 1; - } + fn names_longer_than_63_bytes_are_truncated() { + let long = "x".repeat(100); + let key = InodeKey::new(&long, 1); - assert!(checked > 0, "no mount point could be stat'd"); + assert_eq!(&key.name[..63], &long.as_bytes()[..63]); + assert_eq!(&key.name[63..], &[0u8; 1]); } } diff --git a/crates/cardwire-ebpf/src/helpers.rs b/crates/cardwire-ebpf/src/helpers.rs index d880904e..75046148 100644 --- a/crates/cardwire-ebpf/src/helpers.rs +++ b/crates/cardwire-ebpf/src/helpers.rs @@ -1,5 +1,5 @@ use aya_ebpf::helpers::{ - bpf_get_current_comm, bpf_get_current_pid_tgid, bpf_probe_read_kernel, generated::bpf_get_current_task + bpf_get_current_comm, bpf_get_current_pid_tgid, bpf_probe_read_kernel, bpf_probe_read_kernel_str_bytes, bpf_probe_read_user, bpf_probe_read_user_str_bytes, bpf_probe_write_user, generated::bpf_get_current_task }; use crate::{ @@ -8,24 +8,62 @@ use crate::{ } }; -use crate::vmlinux::{inode, task_struct}; +use crate::vmlinux::{dentry, inode, linux_dirent64, task_struct}; -/// Build the block-map key for an inode +/// Build the block-map key for a dentry, keying on the entry's name and inode /// -/// None means the inode carries no superblock. Every live inode has one, so -/// this cannot happen on a healthy kernel: callers log it to make clear the -/// fault is upstream and not in cardwire +/// The name is copied with bpf_probe_read_kernel_str_bytes, which stops at the +/// NUL and zero-fills the rest of the buffer: the result is byte-identical to +/// the zero-padded key userspace builds from the path's basename +#[inline(always)] +pub unsafe fn dentry_key(d: *const dentry) -> Option { + if d.is_null() { + return None; + } + + // The dentry may have been reconstructed from inode->i_dentry.first + // (inode_permission), which the verifier refuses to dereference directly: + // read the fields through probe reads instead + let inode_ptr: *mut inode = + unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*d).d_inode)) }.ok()?; + if inode_ptr.is_null() { + return None; + } + + let name_ptr: *const u8 = + unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*d).__bindgen_anon_1.d_name.name)) } + .ok()?; + if name_ptr.is_null() { + return None; + } + + let ino: u64 = + unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*inode_ptr).i_ino)) }.ok()?; + + let mut name = [0u8; 64]; + unsafe { bpf_probe_read_kernel_str_bytes(name_ptr, &mut name) }.ok()?; + + Some(InodeKey { name, ino }) +} + +/// Build the block-map key for an inode, keying on the entry's name and inode #[inline(always)] pub unsafe fn inode_key(inode_ptr: *const inode) -> Option { - let sb = unsafe { (*inode_ptr).i_sb }; - if sb.is_null() { + if inode_ptr.is_null() { + return None; + } + + let alias = unsafe { (*inode_ptr).__bindgen_anon_2.i_dentry.first }; + if alias.is_null() { return None; } - Some(InodeKey { - dev: unsafe { (*sb).s_dev } as u64, - ino: unsafe { (*inode_ptr).i_ino }, - }) + // The workspace profile enables overflow-checks, so pointer arithmetic + // must be wrapping or rustc emits a panic branch + let d = (alias as usize).wrapping_sub(core::mem::offset_of!(dentry, __bindgen_anon_3)) + as *mut dentry; + + unsafe { dentry_key(d) } } /// Verify if the file is inside CW_BLOCKED_INO or not @@ -240,3 +278,99 @@ pub unsafe fn is_nvidia_setting_enabled() -> bool { None => false, } } + +/// The scan ran to completion (or hit a non-fatal stop condition) +pub const SCAN_OK: u32 = 0; +/// A dirent header could not be read, the syscall result must not be trusted +pub const SCAN_READ_FAILED: u32 = 1; +/// A hidden entry could not be merged into the previous one, the scan stopped +pub const SCAN_WRITE_FAILED: u32 = 2; + +/// State shared between the getdents64 exit hook and the bpf_loop callback +#[repr(C)] +pub struct ScanCtx { + /// Cursor: address of the dirent currently being inspected + pub dirent_ptr: u64, + /// First address past the getdents64 buffer (base + retval) + pub end: u64, + /// Address of the last visible entry before the cursor, 0 if none yet + pub prev_ptr: u64, + /// d_reclen of prev_ptr, updated when hidden entries are merged into it + pub prev_reclen: u16, + /// One of the SCAN_* constants + pub status: u32, +} + +/// One iteration of the getdents64 buffer scan +/// +/// This must run as a bpf_loop callback, never as a plain `for` loop body: +/// the verifier explores bounded loops iteration by iteration and walks the +/// callee body again on every one of them, which pushed the program past the +/// 1M insn verification limit. bpf_loop callbacks are verified exactly once +/// and the iteration bound is enforced at runtime +/// +/// Returns 0 to continue the scan, 1 to stop it +pub unsafe extern "C" fn scan_dirent(_index: u32, scan: *mut ScanCtx) -> u64 { + let scan = unsafe { &mut *scan }; + + // Check before reading + if scan + .dirent_ptr + .wrapping_add(core::mem::size_of::() as u64) + > scan.end + { + return 1; + } + + let dirent = match unsafe { bpf_probe_read_user(scan.dirent_ptr as *const linux_dirent64) } { + Ok(dirent) => dirent, + Err(_) => return 1, + }; + + let reclen = dirent.d_reclen; + + // Malformed + if reclen == 0 || reclen > 512 { + return 1; + } + + // The workspace profile enables overflow-checks, so pointer arithmetic + // must be wrapping or rustc emits a panic branch + let name_pos = scan + .dirent_ptr + .wrapping_add(core::mem::offset_of!(linux_dirent64, d_name) as u64) + as *const u8; + let mut name = [0u8; 64]; + if unsafe { bpf_probe_read_user_str_bytes(name_pos, &mut name) }.is_err() { + scan.status = SCAN_READ_FAILED; + return 1; + } + + let blocked = unsafe { + is_inode_blocked(InodeKey { + name, + ino: dirent.d_ino, + }) + }; + if blocked { + // We can't hide the first entry + if scan.prev_ptr != 0 { + let new_reclen = scan.prev_reclen.wrapping_add(reclen); + + let reclen_ptr = scan.prev_ptr.wrapping_add(16) as *mut u16; + if unsafe { bpf_probe_write_user(reclen_ptr, &new_reclen) }.is_err() { + scan.status = SCAN_WRITE_FAILED; + return 1; + } + + scan.prev_reclen = new_reclen; + } + } else { + scan.prev_ptr = scan.dirent_ptr; + scan.prev_reclen = reclen; + } + + scan.dirent_ptr = scan.dirent_ptr.wrapping_add(reclen as u64); + + 0 +} diff --git a/crates/cardwire-ebpf/src/main.rs b/crates/cardwire-ebpf/src/main.rs index 40d35b45..581571e2 100644 --- a/crates/cardwire-ebpf/src/main.rs +++ b/crates/cardwire-ebpf/src/main.rs @@ -2,16 +2,14 @@ #![no_main] use aya_ebpf::{ - helpers::{bpf_get_current_pid_tgid, bpf_probe_read_user, bpf_probe_write_user}, macros::{fentry, lsm, tracepoint}, programs::{FEntryContext, LsmContext, TracePointContext} + helpers::{bpf_get_current_pid_tgid, bpf_loop}, macros::{lsm, tracepoint}, programs::{LsmContext, TracePointContext} }; use aya_log_ebpf::{error, warn}; use crate::{ helpers::{ - inode_key, is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart - }, maps::{ - CW_ALLOWED_PID, CW_DIRENT, CW_DIRENT_DEV, CW_EXEC_EVENTS, CW_FORCED_PID, ExecEvent, InodeKey - }, vmlinux::{dentry, file, inode, linux_dirent64, path} + SCAN_OK, SCAN_READ_FAILED, SCAN_WRITE_FAILED, ScanCtx, dentry_key, inode_key, is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart, scan_dirent + }, maps::{CW_ALLOWED_PID, CW_DIRENT, CW_EXEC_EVENTS, CW_FORCED_PID, ExecEvent, InodeKey}, vmlinux::{dentry, file, inode, path} }; #[allow( @@ -129,17 +127,10 @@ unsafe fn try_file_open(ctx: LsmContext) -> Result { return ReturnCode::SUCCESS; } - // Get a mutable ptr to the inode - let inode_ptr: *mut inode = unsafe { (*d).d_inode }; - - if inode_ptr.is_null() { - return ReturnCode::SUCCESS; - } - - let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + let Some(key) = (unsafe { dentry_key(d) }) else { error!( &ctx, - "EBPF inode_key() got an inode with no superblock in file_open, this is a kernel bug, skipping" + "EBPF dentry_key() could not build a key in file_open, skipping" ); return ReturnCode::SUCCESS; }; @@ -209,7 +200,7 @@ unsafe fn try_inode_permission(ctx: LsmContext) -> Result { let Some(key) = (unsafe { inode_key(inode_ptr) }) else { error!( &ctx, - "EBPF inode_key() got an inode with no superblock in inode_permission, this is a kernel bug, skipping" + "EBPF inode_key() could not build a key in inode_permission, skipping" ); return ReturnCode::SUCCESS; }; @@ -280,17 +271,10 @@ unsafe fn try_inode_getattr(ctx: LsmContext) -> Result { return ReturnCode::SUCCESS; } - // Get a mutable ptr to the inode, in inode_permission it's the first argument - let inode_ptr: *mut inode = unsafe { (*dentry_ptr).d_inode }; - - if inode_ptr.is_null() { - return ReturnCode::SUCCESS; - } - - let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + let Some(key) = (unsafe { dentry_key(dentry_ptr) }) else { error!( &ctx, - "EBPF inode_key() got an inode with no superblock in inode_getattr, this is a kernel bug, skipping" + "EBPF dentry_key() could not build a key in inode_getattr, skipping" ); return ReturnCode::SUCCESS; }; @@ -360,73 +344,6 @@ unsafe fn try_tracepoint_enter_getdents64(ctx: TracePointContext) -> Result u32 { - match unsafe { try_fentry_iterate_dir(ctx) } { - Ok(ret) => ret as u32, - Err(ret) => ret as u32, - } -} - -unsafe fn try_fentry_iterate_dir(ctx: FEntryContext) -> Result { - if is_comm_whitelisted() { - return ReturnCode::SUCCESS; - } - - match is_cardwired() { - Some(res) => { - if res { - return ReturnCode::SUCCESS; - } - } - None => return ReturnCode::SUCCESS, - } - - match unsafe { is_hybrid() } { - Some(res) => { - if res { - return ReturnCode::SUCCESS; - } - } - None => return ReturnCode::SUCCESS, - } - - // Only the getdents64 exit hook drains this map, recording for iterate_dir's - // other callers fills it for good and inserts then start failing open - let tid = bpf_get_current_pid_tgid() as u32; - if unsafe { CW_DIRENT.get(tid) }.is_none() { - return ReturnCode::SUCCESS; - } - - let file_ptr: *const file = ctx.arg(0); - if file_ptr.is_null() { - return ReturnCode::SUCCESS; - } - - let d: *mut dentry = unsafe { (*file_ptr).__bindgen_anon_1.f_path.dentry }; - if d.is_null() { - return ReturnCode::SUCCESS; - } - - let inode_ptr: *mut inode = unsafe { (*d).d_inode }; - if inode_ptr.is_null() { - return ReturnCode::SUCCESS; - } - - let Some(key) = (unsafe { inode_key(inode_ptr) }) else { - error!( - &ctx, - "EBPF inode_key() got an inode with no superblock in iterate_dir, this is a kernel bug, skipping" - ); - return ReturnCode::SUCCESS; - }; - - CW_DIRENT_DEV.insert(tid, key.dev, 0)?; - - ReturnCode::SUCCESS -} - #[tracepoint] pub fn tracepoint_exit_getdents64(ctx: TracePointContext) -> u32 { match unsafe { try_tracepoint_exit_getdents64(ctx) } { @@ -438,18 +355,14 @@ pub fn tracepoint_exit_getdents64(ctx: TracePointContext) -> u32 { unsafe fn try_tracepoint_exit_getdents64(ctx: TracePointContext) -> Result { let tid = bpf_get_current_pid_tgid() as u32; - // Drain both maps up front to avoid a map leak on an early return + // Drain the map up front to avoid a map leak on an early return let dirp = unsafe { CW_DIRENT.get(tid) }.copied(); let _ = CW_DIRENT.remove(tid); - let dir_dev = unsafe { CW_DIRENT_DEV.get(tid) }.copied(); - let _ = CW_DIRENT_DEV.remove(tid); - let (Some(dirp), Some(dir_dev)) = (dirp, dir_dev) else { + let Some(dirp) = dirp else { return ReturnCode::SUCCESS; }; - let dirent_ptr = dirp as *const linux_dirent64; - let retval = match unsafe { ctx.read_at::(16) } { Ok(ret) => ret as u64, Err(_) => return ReturnCode::SUCCESS, @@ -459,62 +372,34 @@ unsafe fn try_tracepoint_exit_getdents64(ctx: TracePointContext) -> Result() as u64) > end { - break; - } - - let dirent = match unsafe { bpf_probe_read_user(dirent_ptr) } { - Ok(dirent) => dirent, - Err(_) => break, - }; - - let reclen = dirent.d_reclen; + let mut scan = ScanCtx { + dirent_ptr: dirp, + end: dirp.wrapping_add(retval), + prev_ptr: 0, + prev_reclen: 0, + status: SCAN_OK, + }; - // Malformed - if reclen == 0 || reclen > 512 { - break; - } + // The callback reference must be transmuted from the fn pointer, never + // routed through an integer: the relocation that tags it as + // BPF_PSEUDO_FUNC only survives if the function symbol reaches ld_imm64 + // untouched + let callback = unsafe { + core::mem::transmute:: u64, *mut core::ffi::c_void>( + scan_dirent, + ) + }; + let scan_ptr: *mut core::ffi::c_void = core::ptr::addr_of_mut!(scan).cast(); + let _ = unsafe { bpf_loop(512, callback, scan_ptr, 0) }; - let blocked = unsafe { - is_inode_blocked(InodeKey { - dev: dir_dev, - ino: dirent.d_ino, - }) - }; - if blocked { - // We can't hide the first entry - if prev_ptr != 0 { - let new_reclen = prev_reclen.wrapping_add(reclen); - - let reclen_ptr = (prev_ptr.wrapping_add(16)) as *mut u16; - match unsafe { bpf_probe_write_user(reclen_ptr, &new_reclen) } { - Ok(_) => {} - Err(err) => { - error!(&ctx, "failed to write new reclen {}", err); - break; - } - }; - - prev_reclen = new_reclen; - } - } else { - prev_ptr = dirent_ptr as u64; - prev_reclen = reclen; + match scan.status { + SCAN_WRITE_FAILED => { + error!(&ctx, "failed to write new reclen"); + ReturnCode::SUCCESS } - - dirent_ptr = (dirent_ptr as u64).wrapping_add(reclen as u64) as *const linux_dirent64; + SCAN_READ_FAILED => Err(-1), + _ => ReturnCode::SUCCESS, } - - ReturnCode::SUCCESS } #[tracepoint] diff --git a/crates/cardwire-ebpf/src/maps.rs b/crates/cardwire-ebpf/src/maps.rs index 302721dc..dfae64f8 100644 --- a/crates/cardwire-ebpf/src/maps.rs +++ b/crates/cardwire-ebpf/src/maps.rs @@ -35,14 +35,15 @@ pub struct InodeState { } /* - dev is the superblock's s_dev, userspace converts its st_dev to match + Key = entry name (dentry d_name / dirent d_name), zero-padded to 64 bytes + and inode number Layout must stay identical to cardwire-ebpf-userspace's InodeKey, the kernel hashes the raw key bytes so any drift turns every lookup into a silent miss */ #[repr(C, align(8))] #[derive(Copy, Clone)] pub struct InodeKey { - pub dev: u64, + pub name: [u8; 64], pub ino: u64, } @@ -93,14 +94,6 @@ pub static CW_ALLOWED_COMM: HashMap<[u8; 16], u8> = #[map] pub static CW_DIRENT: HashMap = HashMap::::with_max_entries(1024, 0); -/* - Device id of the directory being read, recorded by iterate_dir - Key = TID - Value = superblock device id -*/ -#[map] -pub static CW_DIRENT_DEV: HashMap = HashMap::::with_max_entries(1024, 0); - #[repr(C, align(8))] #[allow(dead_code)] pub struct ExecEvent { From cfee2287b89e5cb4e94f6ee8fd683b62a66fd532 Mon Sep 17 00:00:00 2001 From: luytan Date: Fri, 14 Aug 2026 21:54:20 +0200 Subject: [PATCH 2/2] fix(ebpf): harden the getdents64 scan and the tracepoint loading - raise the bpf_loop bound to 1366 entries (32768/24+1) so a full buffer can never be silently truncated, and reject records shorter than their own header - restore the write errno in the exit hook's reclen log and warn on a negative bpf_loop return - key builders now report why they failed: anonymous inodes (epoll, eventfd, dma-buf) log at debug instead of spamming errors, real probe read failures still log at error - derive the d_reclen offset from the type instead of a hard-coded 16 - sys_exit/sys_enter_getdents64: attach failures now degrade to weakened mode instead of aborting the daemon, and the enter hook only loads once the exit hook is attached Co-authored-by: GLM --- crates/cardwire-ebpf-userspace/src/lib.rs | 26 +++++-- crates/cardwire-ebpf/src/helpers.rs | 89 ++++++++++++++++++----- crates/cardwire-ebpf/src/main.rs | 80 ++++++++++++++------ 3 files changed, 148 insertions(+), 47 deletions(-) diff --git a/crates/cardwire-ebpf-userspace/src/lib.rs b/crates/cardwire-ebpf-userspace/src/lib.rs index 20458750..0c8591be 100644 --- a/crates/cardwire-ebpf-userspace/src/lib.rs +++ b/crates/cardwire-ebpf-userspace/src/lib.rs @@ -134,10 +134,19 @@ impl EbpfBlocker { .map_err(CardwireEbpfError::aya) { Ok(_) => { - did_sys_exit_getdents64_success = true; - cardwire_sys_exit_getdents64 + // The flag gates sys_enter_getdents64, which would otherwise + // fill CW_DIRENT with no exit hook to drain it: only raise it + // once the exit hook is actually attached + match cardwire_sys_exit_getdents64 .attach("syscalls", "sys_exit_getdents64") - .map_err(CardwireEbpfError::aya)?; + .map_err(CardwireEbpfError::aya) + { + Ok(_) => did_sys_exit_getdents64_success = true, + Err(err) => { + warn!("Failed to attach sys_exit_getdents64: {}", err); + warn!("falling back to a weakened cardwired..."); + } + } } Err(err) => { // If we cannot load the program, it usually mean the kernel lockdown is enabled @@ -165,9 +174,16 @@ impl EbpfBlocker { .map_err(CardwireEbpfError::aya) { Ok(_) => { - cardwire_sys_enter_getdents64 + match cardwire_sys_enter_getdents64 .attach("syscalls", "sys_enter_getdents64") - .map_err(CardwireEbpfError::aya)?; + .map_err(CardwireEbpfError::aya) + { + Ok(_) => {} + Err(err) => { + warn!("Failed to attach sys_enter_getdents64: {}", err); + warn!("falling back to a weakened cardwired..."); + } + } } Err(err) => { let lockdown = is_lockdown_enabled(); diff --git a/crates/cardwire-ebpf/src/helpers.rs b/crates/cardwire-ebpf/src/helpers.rs index 75046148..beac1067 100644 --- a/crates/cardwire-ebpf/src/helpers.rs +++ b/crates/cardwire-ebpf/src/helpers.rs @@ -10,52 +10,81 @@ use crate::{ use crate::vmlinux::{dentry, inode, linux_dirent64, task_struct}; +/// Outcome of building a block-map key from a dentry or an inode +pub enum KeyBuild { + /// A usable key + Key(InodeKey), + /// No name to key on: null dentry/inode, or an anonymous inode (epoll fds, + /// eventfds, dma-bufs). Expected while processes run, callers should skip + /// silently + Unnamed, + /// Kernel memory could not be read. Unexpected, callers should log it + ProbeFailed, +} + /// Build the block-map key for a dentry, keying on the entry's name and inode /// /// The name is copied with bpf_probe_read_kernel_str_bytes, which stops at the /// NUL and zero-fills the rest of the buffer: the result is byte-identical to /// the zero-padded key userspace builds from the path's basename #[inline(always)] -pub unsafe fn dentry_key(d: *const dentry) -> Option { +pub unsafe fn dentry_key(d: *const dentry) -> KeyBuild { if d.is_null() { - return None; + return KeyBuild::Unnamed; } // The dentry may have been reconstructed from inode->i_dentry.first // (inode_permission), which the verifier refuses to dereference directly: // read the fields through probe reads instead - let inode_ptr: *mut inode = - unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*d).d_inode)) }.ok()?; + let inode_ptr = match unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*d).d_inode)) } { + Ok(inode_ptr) => inode_ptr, + Err(_) => return KeyBuild::ProbeFailed, + }; if inode_ptr.is_null() { - return None; + return KeyBuild::Unnamed; } - let name_ptr: *const u8 = - unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*d).__bindgen_anon_1.d_name.name)) } - .ok()?; + let name_ptr = match unsafe { + bpf_probe_read_kernel(core::ptr::addr_of!((*d).__bindgen_anon_1.d_name.name)) + } { + Ok(name_ptr) => name_ptr, + Err(_) => return KeyBuild::ProbeFailed, + }; if name_ptr.is_null() { - return None; + return KeyBuild::Unnamed; } - let ino: u64 = - unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*inode_ptr).i_ino)) }.ok()?; + let ino = match unsafe { bpf_probe_read_kernel(core::ptr::addr_of!((*inode_ptr).i_ino)) } { + Ok(ino) => ino, + Err(_) => return KeyBuild::ProbeFailed, + }; let mut name = [0u8; 64]; - unsafe { bpf_probe_read_kernel_str_bytes(name_ptr, &mut name) }.ok()?; + if unsafe { bpf_probe_read_kernel_str_bytes(name_ptr, &mut name) }.is_err() { + return KeyBuild::ProbeFailed; + } - Some(InodeKey { name, ino }) + KeyBuild::Key(InodeKey { name, ino }) } /// Build the block-map key for an inode, keying on the entry's name and inode +/// +/// inode_permission receives no dentry, so the name comes from +/// inode->i_dentry.first. An inode exposed under several names (bind mounts, +/// hard links) can therefore be keyed under an alias the caller didn't use and +/// fail open. Accepted: cardwire's targets (sysfs entries, DRM device nodes) +/// have no aliases, and walking i_dentry would need an unbounded loop the +/// verifier rejects #[inline(always)] -pub unsafe fn inode_key(inode_ptr: *const inode) -> Option { +pub unsafe fn inode_key(inode_ptr: *const inode) -> KeyBuild { if inode_ptr.is_null() { - return None; + return KeyBuild::Unnamed; } let alias = unsafe { (*inode_ptr).__bindgen_anon_2.i_dentry.first }; if alias.is_null() { - return None; + // Anonymous inode (epoll, eventfd, dma-buf, ...): no name by design + return KeyBuild::Unnamed; } // The workspace profile enables overflow-checks, so pointer arithmetic @@ -286,6 +315,18 @@ pub const SCAN_READ_FAILED: u32 = 1; /// A hidden entry could not be merged into the previous one, the scan stopped pub const SCAN_WRITE_FAILED: u32 = 2; +/// Largest getdents64 return value the hook scans, must match the retval guard +/// in the exit hook +const GETDENTS_BUF_MAX: u64 = 32768; + +/// Iteration bound for the dirent scan +/// +/// One buffer holds at most GETDENTS_BUF_MAX / sizeof(linux_dirent64) +/// header-sized records, plus one iteration to observe the bounds-check miss +/// that ends the scan, so the bound can never truncate a buffer silently +pub const MAX_DIRENTS: u32 = + (GETDENTS_BUF_MAX / core::mem::size_of::() as u64) as u32 + 1; + /// State shared between the getdents64 exit hook and the bpf_loop callback #[repr(C)] pub struct ScanCtx { @@ -299,6 +340,9 @@ pub struct ScanCtx { pub prev_reclen: u16, /// One of the SCAN_* constants pub status: u32, + /// Kernel return code of the failed write, valid when status is + /// SCAN_WRITE_FAILED + pub errno: i32, } /// One iteration of the getdents64 buffer scan @@ -329,8 +373,9 @@ pub unsafe extern "C" fn scan_dirent(_index: u32, scan: *mut ScanCtx) -> u64 { let reclen = dirent.d_reclen; - // Malformed - if reclen == 0 || reclen > 512 { + // Malformed: a record shorter than its own header can't be valid, and + // advancing by it would also break the MAX_DIRENTS bound + if (reclen as usize) < core::mem::size_of::() || reclen > 512 { return 1; } @@ -357,9 +402,13 @@ pub unsafe extern "C" fn scan_dirent(_index: u32, scan: *mut ScanCtx) -> u64 { if scan.prev_ptr != 0 { let new_reclen = scan.prev_reclen.wrapping_add(reclen); - let reclen_ptr = scan.prev_ptr.wrapping_add(16) as *mut u16; - if unsafe { bpf_probe_write_user(reclen_ptr, &new_reclen) }.is_err() { + let reclen_ptr = scan + .prev_ptr + .wrapping_add(core::mem::offset_of!(linux_dirent64, d_reclen) as u64) + as *mut u16; + if let Err(err) = unsafe { bpf_probe_write_user(reclen_ptr, &new_reclen) } { scan.status = SCAN_WRITE_FAILED; + scan.errno = err; return 1; } diff --git a/crates/cardwire-ebpf/src/main.rs b/crates/cardwire-ebpf/src/main.rs index 581571e2..fa124cbf 100644 --- a/crates/cardwire-ebpf/src/main.rs +++ b/crates/cardwire-ebpf/src/main.rs @@ -4,11 +4,11 @@ use aya_ebpf::{ helpers::{bpf_get_current_pid_tgid, bpf_loop}, macros::{lsm, tracepoint}, programs::{LsmContext, TracePointContext} }; -use aya_log_ebpf::{error, warn}; +use aya_log_ebpf::{debug, error, warn}; use crate::{ helpers::{ - SCAN_OK, SCAN_READ_FAILED, SCAN_WRITE_FAILED, ScanCtx, dentry_key, inode_key, is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart, scan_dirent + KeyBuild, MAX_DIRENTS, SCAN_OK, SCAN_READ_FAILED, SCAN_WRITE_FAILED, ScanCtx, dentry_key, inode_key, is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart, scan_dirent }, maps::{CW_ALLOWED_PID, CW_DIRENT, CW_EXEC_EVENTS, CW_FORCED_PID, ExecEvent, InodeKey}, vmlinux::{dentry, file, inode, path} }; @@ -127,12 +127,22 @@ unsafe fn try_file_open(ctx: LsmContext) -> Result { return ReturnCode::SUCCESS; } - let Some(key) = (unsafe { dentry_key(d) }) else { - error!( - &ctx, - "EBPF dentry_key() could not build a key in file_open, skipping" - ); - return ReturnCode::SUCCESS; + let key = match unsafe { dentry_key(d) } { + KeyBuild::Key(key) => key, + KeyBuild::Unnamed => { + debug!( + &ctx, + "EBPF dentry_key() found no name in file_open, skipping" + ); + return ReturnCode::SUCCESS; + } + KeyBuild::ProbeFailed => { + error!( + &ctx, + "EBPF dentry_key() could not read the dentry in file_open, skipping" + ); + return ReturnCode::SUCCESS; + } }; match unsafe { is_inode_blocked(key) } { @@ -197,12 +207,22 @@ unsafe fn try_inode_permission(ctx: LsmContext) -> Result { return ReturnCode::SUCCESS; } - let Some(key) = (unsafe { inode_key(inode_ptr) }) else { - error!( - &ctx, - "EBPF inode_key() could not build a key in inode_permission, skipping" - ); - return ReturnCode::SUCCESS; + let key = match unsafe { inode_key(inode_ptr) } { + KeyBuild::Key(key) => key, + KeyBuild::Unnamed => { + debug!( + &ctx, + "EBPF inode_key() found an unnamed inode in inode_permission, skipping" + ); + return ReturnCode::SUCCESS; + } + KeyBuild::ProbeFailed => { + error!( + &ctx, + "EBPF inode_key() could not read the inode in inode_permission, skipping" + ); + return ReturnCode::SUCCESS; + } }; match unsafe { is_inode_blocked(key) } { @@ -271,12 +291,22 @@ unsafe fn try_inode_getattr(ctx: LsmContext) -> Result { return ReturnCode::SUCCESS; } - let Some(key) = (unsafe { dentry_key(dentry_ptr) }) else { - error!( - &ctx, - "EBPF dentry_key() could not build a key in inode_getattr, skipping" - ); - return ReturnCode::SUCCESS; + let key = match unsafe { dentry_key(dentry_ptr) } { + KeyBuild::Key(key) => key, + KeyBuild::Unnamed => { + debug!( + &ctx, + "EBPF dentry_key() found no name in inode_getattr, skipping" + ); + return ReturnCode::SUCCESS; + } + KeyBuild::ProbeFailed => { + error!( + &ctx, + "EBPF dentry_key() could not read the dentry in inode_getattr, skipping" + ); + return ReturnCode::SUCCESS; + } }; match unsafe { is_inode_blocked(key) } { @@ -378,6 +408,7 @@ unsafe fn try_tracepoint_exit_getdents64(ctx: TracePointContext) -> Result Result { - error!(&ctx, "failed to write new reclen"); + error!(&ctx, "failed to write new reclen {}", scan.errno); ReturnCode::SUCCESS } SCAN_READ_FAILED => Err(-1),