Skip to content

Commit 20b4e69

Browse files
committed
sandlock-core: deny by file-handle identity at open to close hardlink/rename bypass
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 9bfa01a commit 20b4e69

6 files changed

Lines changed: 265 additions & 42 deletions

File tree

crates/sandlock-core/src/policy_fn.rs

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -147,22 +147,22 @@ pub struct PolicyContext {
147147
ceiling: LivePolicy,
148148
restricted: HashSet<&'static str>,
149149
pid_overrides: Arc<RwLock<HashMap<u32, HashSet<IpAddr>>>>,
150-
denied_paths: Arc<RwLock<HashSet<String>>>,
150+
denied: Arc<crate::seccomp::state::DeniedSet>,
151151
}
152152

153153
impl PolicyContext {
154154
pub(crate) fn new(
155155
live: Arc<RwLock<LivePolicy>>,
156156
ceiling: LivePolicy,
157157
pid_overrides: Arc<RwLock<HashMap<u32, HashSet<IpAddr>>>>,
158-
denied_paths: Arc<RwLock<HashSet<String>>>,
158+
denied: Arc<crate::seccomp::state::DeniedSet>,
159159
) -> Self {
160160
Self {
161161
live,
162162
ceiling,
163163
restricted: HashSet::new(),
164164
pid_overrides,
165-
denied_paths,
165+
denied,
166166
}
167167
}
168168

@@ -246,16 +246,16 @@ impl PolicyContext {
246246
// ---- Filesystem restriction ----
247247

248248
/// Deny access to a path (and all children). Checked by the supervisor
249-
/// on openat/stat/access syscalls. Takes effect immediately.
249+
/// on openat/stat/access syscalls. Takes effect immediately. The file's
250+
/// inode identity is captured too, so the deny survives hardlinks and
251+
/// renames to a non-denied name.
250252
pub fn deny_path(&self, path: &str) {
251-
let mut denied = self.denied_paths.write().unwrap();
252-
denied.insert(path.to_string());
253+
self.denied.deny(path);
253254
}
254255

255256
/// Remove a previously denied path.
256257
pub fn allow_path(&self, path: &str) {
257-
let mut denied = self.denied_paths.write().unwrap();
258-
denied.remove(path);
258+
self.denied.allow(path);
259259
}
260260

261261
// ---- Internal ----
@@ -337,14 +337,14 @@ pub(crate) fn spawn_policy_fn(
337337
live: Arc<RwLock<LivePolicy>>,
338338
ceiling: LivePolicy,
339339
pid_overrides: Arc<RwLock<HashMap<u32, HashSet<IpAddr>>>>,
340-
denied_paths: Arc<RwLock<HashSet<String>>>,
340+
denied: Arc<crate::seccomp::state::DeniedSet>,
341341
) -> tokio::sync::mpsc::UnboundedSender<PolicyEvent> {
342342
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<PolicyEvent>();
343343

344344
std::thread::Builder::new()
345345
.name("sandlock-policy-fn".to_string())
346346
.spawn(move || {
347-
let mut ctx = PolicyContext::new(live, ceiling, pid_overrides, denied_paths);
347+
let mut ctx = PolicyContext::new(live, ceiling, pid_overrides, denied);
348348

349349
while let Some(pe) = rx.blocking_recv() {
350350
let verdict = callback(pe.event, &mut ctx);
@@ -389,8 +389,8 @@ mod tests {
389389
}));
390390
let ceiling = test_live();
391391
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
392-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
393-
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied_paths);
392+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
393+
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied);
394394

395395
let ip: IpAddr = "127.0.0.1".parse().unwrap();
396396
ctx.grant_network(&[ip]).unwrap();
@@ -406,8 +406,8 @@ mod tests {
406406
}));
407407
let ceiling = test_live();
408408
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
409-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
410-
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied_paths);
409+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
410+
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied);
411411

412412
// Try to grant an IP not in ceiling — should be silently ignored
413413
let foreign: IpAddr = "8.8.8.8".parse().unwrap();
@@ -420,8 +420,8 @@ mod tests {
420420
let live = Arc::new(RwLock::new(test_live()));
421421
let ceiling = test_live();
422422
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
423-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
424-
let mut ctx = PolicyContext::new(live, ceiling, pid_overrides, denied_paths);
423+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
424+
let mut ctx = PolicyContext::new(live, ceiling, pid_overrides, denied);
425425

426426
ctx.restrict_network(&[]);
427427
let ip: IpAddr = "127.0.0.1".parse().unwrap();
@@ -433,8 +433,8 @@ mod tests {
433433
let live = Arc::new(RwLock::new(test_live()));
434434
let ceiling = test_live();
435435
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
436-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
437-
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied_paths);
436+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
437+
let mut ctx = PolicyContext::new(live.clone(), ceiling, pid_overrides, denied);
438438

439439
ctx.restrict_max_memory(256 * 1024 * 1024);
440440
assert_eq!(live.read().unwrap().max_memory_bytes, 256 * 1024 * 1024);
@@ -445,8 +445,8 @@ mod tests {
445445
let live = Arc::new(RwLock::new(test_live()));
446446
let ceiling = test_live();
447447
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
448-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
449-
let ctx = PolicyContext::new(live, ceiling, pid_overrides.clone(), denied_paths);
448+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
449+
let ctx = PolicyContext::new(live, ceiling, pid_overrides.clone(), denied);
450450

451451
let localhost: IpAddr = "127.0.0.1".parse().unwrap();
452452
ctx.restrict_pid_network(1234, &[localhost]);
@@ -462,8 +462,8 @@ mod tests {
462462
let live = Arc::new(RwLock::new(test_live()));
463463
let ceiling = test_live();
464464
let pid_overrides = Arc::new(RwLock::new(HashMap::new()));
465-
let denied_paths = Arc::new(RwLock::new(HashSet::new()));
466-
let ctx = PolicyContext::new(live, ceiling, pid_overrides.clone(), denied_paths);
465+
let denied = Arc::new(crate::seccomp::state::DeniedSet::default());
466+
let ctx = PolicyContext::new(live, ceiling, pid_overrides.clone(), denied);
467467

468468
let localhost: IpAddr = "127.0.0.1".parse().unwrap();
469469
ctx.restrict_pid_network(1234, &[localhost]);

crates/sandlock-core/src/sandbox.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,10 +1659,10 @@ impl Sandbox {
16591659

16601660
let mut policy_fn_state = PolicyFnState::new();
16611661

1662-
if let Ok(mut denied) = policy_fn_state.denied_paths.write() {
1663-
for path in &self.fs_denied {
1664-
denied.insert(path.to_string_lossy().into_owned());
1665-
}
1662+
for path in &self.fs_denied {
1663+
// Captures the path prefix and the file's inode identity, so
1664+
// the deny survives hardlinks/renames to a non-denied name.
1665+
policy_fn_state.denied.deny(&path.to_string_lossy());
16661666
}
16671667

16681668
if let Some(ref callback) = self.policy_fn {
@@ -1687,11 +1687,11 @@ impl Sandbox {
16871687
};
16881688
let ceiling = live.clone();
16891689
let live = std::sync::Arc::new(std::sync::RwLock::new(live));
1690-
let denied_paths = policy_fn_state.denied_paths.clone();
1690+
let denied = policy_fn_state.denied.clone();
16911691
let pid_overrides = net_state.pid_ip_overrides.clone();
16921692
policy_fn_state.live_policy = Some(live.clone());
16931693
let tx = crate::policy_fn::spawn_policy_fn(
1694-
callback.clone(), live, ceiling, pid_overrides, denied_paths,
1694+
callback.clone(), live, ceiling, pid_overrides, denied,
16951695
);
16961696
policy_fn_state.event_tx = Some(tx);
16971697
}

crates/sandlock-core/src/seccomp/notif.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,16 @@ fn is_denied_with_symlink_resolve(
370370
if policy_fn_state.is_path_denied(&real.to_string_lossy()) {
371371
return true;
372372
}
373+
// Identity check: catches a denied file reached via a hardlink or a
374+
// pre-existing alias, where the resolved path itself is not denied
375+
// but the file identity is. Best-effort here (path-based precheck);
376+
// the race-free authority is the fd identity check in the on-behalf
377+
// open.
378+
if let Some(id) = super::state::file_id_of_path(&real.to_string_lossy()) {
379+
if policy_fn_state.is_id_denied(&id) {
380+
return true;
381+
}
382+
}
373383
}
374384
false
375385
}
@@ -437,6 +447,7 @@ fn realpath_of_fd(fd: RawFd) -> Option<std::path::PathBuf> {
437447
std::fs::read_link(format!("/proc/self/fd/{}", fd)).ok()
438448
}
439449

450+
440451
fn path_under_any(path: &std::path::Path, list: &[std::path::PathBuf]) -> bool {
441452
list.iter().any(|p| path.starts_with(p))
442453
}
@@ -551,6 +562,15 @@ fn reopen_existing_on_behalf(
551562
if let Some(errno) = deny_open_verdict(&realpath, flags, policy, pfs) {
552563
return NotifAction::Errno(errno);
553564
}
565+
// Identity check on the pinned file: a denied file reached via a hardlink,
566+
// a rename to a non-denied name, or a pre-existing alias has a realpath
567+
// that is not denied, but its handle identity is. Race-free — this is the
568+
// exact file the child will receive.
569+
if let Some(id) = super::state::file_id_of_fd(probe.as_raw_fd()) {
570+
if pfs.is_id_denied(&id) {
571+
return NotifAction::Errno(libc::EACCES);
572+
}
573+
}
554574
// Resolution-only flags are stripped from the reopen.
555575
let reopen_flags =
556576
flags as i32 & !(libc::O_CREAT | libc::O_EXCL | libc::O_PATH | libc::O_NOFOLLOW);

crates/sandlock-core/src/seccomp/state.rs

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,115 @@ impl TimeRandomState {
401401
}
402402
}
403403

404+
// ============================================================
405+
// DeniedSet — denied paths plus captured file identities
406+
// ============================================================
407+
408+
/// The filesystem deny set: path prefixes plus the file-handle identities
409+
/// captured when each path was denied.
410+
///
411+
/// The path set is the primary, race-free boundary enforced at `open`. The
412+
/// identity set makes the deny robust against namespace games (hardlinks,
413+
/// renames, and pre-existing aliases): a [`FileId`] is the kernel file handle,
414+
/// which encodes the inode and a generation number, so it travels with the
415+
/// file's identity rather than the name used to reach it and is immune to
416+
/// inode reuse. An open is denied if the opened file's identity matches, no
417+
/// matter which path led to it. With `AT_HANDLE_FID` the kernel encodes an
418+
/// identity FID for essentially every filesystem (generic inode FID where
419+
/// NFS-export ops are absent); the rare path that still fails captures no
420+
/// identity and relies on the always-on path prefix.
421+
#[derive(Default)]
422+
pub struct DeniedSet {
423+
paths: std::sync::RwLock<HashSet<String>>,
424+
ids: std::sync::RwLock<HashSet<FileId>>,
425+
}
426+
427+
/// A file's stable identity: its kernel file handle, keyed by the superblock
428+
/// device so identical handles from different filesystems cannot collide.
429+
#[derive(Clone, PartialEq, Eq, Hash)]
430+
pub(crate) struct FileId {
431+
dev: u64,
432+
handle_type: i32,
433+
handle: Vec<u8>,
434+
}
435+
436+
/// Identity of a path, following symlinks (the open will resolve to the same
437+
/// target). `None` if it cannot be resolved or no handle can be encoded. The
438+
/// `(handle_type, handle)` FID comes from [`crate::sys::fs::file_handle`]; it is
439+
/// keyed by the superblock `dev` so handles from different filesystems cannot
440+
/// collide.
441+
pub(crate) fn file_id_of_path(path: &str) -> Option<FileId> {
442+
use std::os::unix::fs::MetadataExt;
443+
let dev = std::fs::metadata(path).ok()?.dev();
444+
let c = std::ffi::CString::new(path).ok()?;
445+
let (handle_type, handle) =
446+
crate::sys::fs::file_handle(libc::AT_FDCWD, &c, libc::AT_SYMLINK_FOLLOW)?;
447+
Some(FileId { dev, handle_type, handle })
448+
}
449+
450+
/// Identity of an open fd.
451+
pub(crate) fn file_id_of_fd(fd: std::os::unix::io::RawFd) -> Option<FileId> {
452+
let mut st: libc::stat = unsafe { std::mem::zeroed() };
453+
if unsafe { libc::fstat(fd, &mut st) } != 0 {
454+
return None;
455+
}
456+
let empty = std::ffi::CString::new("").ok()?;
457+
let (handle_type, handle) = crate::sys::fs::file_handle(fd, &empty, libc::AT_EMPTY_PATH)?;
458+
Some(FileId { dev: st.st_dev as u64, handle_type, handle })
459+
}
460+
461+
impl DeniedSet {
462+
/// Deny `path` (and its subtree, by prefix). Also captures the file's
463+
/// handle identity if it exists now, so the deny still applies after the
464+
/// file is hardlinked or renamed to a non-denied name.
465+
pub fn deny(&self, path: &str) {
466+
if let Ok(mut p) = self.paths.write() {
467+
p.insert(path.to_string());
468+
}
469+
if let Some(id) = file_id_of_path(path) {
470+
if let Ok(mut i) = self.ids.write() {
471+
i.insert(id);
472+
}
473+
}
474+
}
475+
476+
/// Stop denying `path`, dropping its captured identity too (best-effort:
477+
/// only if the path still resolves). A leftover identity would only ever
478+
/// over-deny, which is fail-safe.
479+
pub fn allow(&self, path: &str) {
480+
if let Ok(mut p) = self.paths.write() {
481+
p.remove(path);
482+
}
483+
if let Some(id) = file_id_of_path(path) {
484+
if let Ok(mut i) = self.ids.write() {
485+
i.remove(&id);
486+
}
487+
}
488+
}
489+
490+
/// True if `path` is at or beneath a denied path (lexical prefix).
491+
pub fn is_path_denied(&self, path: &str) -> bool {
492+
self.paths.read().map_or(false, |denied| {
493+
let path = std::path::Path::new(path);
494+
denied
495+
.iter()
496+
.any(|d| path.starts_with(std::path::Path::new(d)))
497+
})
498+
}
499+
500+
/// True if `id` is a denied file identity (catches hardlinks, renames, and
501+
/// pre-existing aliases regardless of the path used).
502+
pub(crate) fn is_id_denied(&self, id: &FileId) -> bool {
503+
self.ids.read().map_or(false, |s| s.contains(id))
504+
}
505+
506+
/// Whether any deny rule is in effect.
507+
pub fn is_empty(&self) -> bool {
508+
self.paths.read().map_or(true, |p| p.is_empty())
509+
&& self.ids.read().map_or(true, |i| i.is_empty())
510+
}
511+
}
512+
404513
// ============================================================
405514
// PolicyFnState — dynamic policy callback state
406515
// ============================================================
@@ -411,37 +520,34 @@ pub struct PolicyFnState {
411520
pub event_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::policy_fn::PolicyEvent>>,
412521
/// Shared live policy for dynamic updates (None if no policy_fn).
413522
pub live_policy: Option<std::sync::Arc<std::sync::RwLock<crate::policy_fn::LivePolicy>>>,
414-
/// Dynamically denied paths from policy_fn.
415-
pub denied_paths: std::sync::Arc<std::sync::RwLock<HashSet<String>>>,
523+
/// Dynamically denied paths and inode identities from policy_fn / fs_deny.
524+
pub denied: std::sync::Arc<DeniedSet>,
416525
}
417526

418527
impl PolicyFnState {
419528
pub fn new() -> Self {
420529
Self {
421530
event_tx: None,
422531
live_policy: None,
423-
denied_paths: std::sync::Arc::new(std::sync::RwLock::new(HashSet::new())),
532+
denied: std::sync::Arc::new(DeniedSet::default()),
424533
}
425534
}
426535

427-
/// Check if a path is dynamically denied.
536+
/// Check if a path is at or beneath a denied path.
428537
pub fn is_path_denied(&self, path: &str) -> bool {
429-
if let Ok(denied) = self.denied_paths.read() {
430-
let path = std::path::Path::new(path);
431-
denied.iter().any(|d| path.starts_with(std::path::Path::new(d)))
432-
} else {
433-
false
434-
}
538+
self.denied.is_path_denied(path)
539+
}
540+
541+
/// Check if an opened file's handle identity is denied.
542+
pub(crate) fn is_id_denied(&self, id: &FileId) -> bool {
543+
self.denied.is_id_denied(id)
435544
}
436545

437546
/// Whether any deny rule is currently in effect. Cheap gate for the
438547
/// race-free on-behalf open path: with no denies there is no carve-out
439548
/// to protect and opens are left to the kernel and Landlock.
440549
pub fn has_denied_paths(&self) -> bool {
441-
self.denied_paths
442-
.read()
443-
.map(|d| !d.is_empty())
444-
.unwrap_or(false)
550+
!self.denied.is_empty()
445551
}
446552
}
447553

0 commit comments

Comments
 (0)