Skip to content

Commit 67efb10

Browse files
committed
sandlock-core: close fs deny TOCTOU with race-free on-behalf openat
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 69e1825 commit 67efb10

3 files changed

Lines changed: 349 additions & 1 deletion

File tree

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

Lines changed: 278 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,264 @@ fn is_denied_with_symlink_resolve(
374374
false
375375
}
376376

377+
/// `RESOLVE_NO_MAGICLINKS` — forbid `/proc` magic-link redirection during
378+
/// on-behalf resolution while still following ordinary symlinks the way the
379+
/// child's own open would.
380+
const RESOLVE_NO_MAGICLINKS: u64 = 0x02;
381+
382+
/// Kernel `struct open_how` for `openat2`.
383+
#[repr(C)]
384+
struct OpenHow {
385+
flags: u64,
386+
mode: u64,
387+
resolve: u64,
388+
}
389+
390+
fn last_errno(fallback: i32) -> i32 {
391+
io::Error::last_os_error().raw_os_error().unwrap_or(fallback)
392+
}
393+
394+
/// `openat2` relative to `dirfd`. Returns an owned fd or the errno.
395+
fn openat2_at(dirfd: RawFd, path: &std::ffi::CStr, flags: u64, mode: u64, resolve: u64)
396+
-> Result<OwnedFd, i32>
397+
{
398+
use std::os::unix::io::FromRawFd;
399+
let how = OpenHow { flags, mode, resolve };
400+
let fd = unsafe {
401+
libc::syscall(
402+
arch::SYS_OPENAT2,
403+
dirfd,
404+
path.as_ptr(),
405+
&how as *const OpenHow,
406+
std::mem::size_of::<OpenHow>(),
407+
)
408+
} as i32;
409+
if fd < 0 {
410+
Err(last_errno(libc::ENOENT))
411+
} else {
412+
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
413+
}
414+
}
415+
416+
/// Capture an `O_PATH` fd to the directory the child's open resolves against,
417+
/// taken from the child's own view so a concurrent `chdir`/dirfd swap cannot
418+
/// move the resolution base after we read it.
419+
fn open_base_dir(pid: u32, dirfd: i64) -> Result<OwnedFd, i32> {
420+
use std::os::unix::io::FromRawFd;
421+
if dirfd as i32 == libc::AT_FDCWD {
422+
let cwd = std::ffi::CString::new(format!("/proc/{}/cwd", pid)).map_err(|_| libc::EINVAL)?;
423+
let fd = unsafe {
424+
libc::open(cwd.as_ptr(), libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC)
425+
};
426+
if fd < 0 {
427+
return Err(last_errno(libc::EACCES));
428+
}
429+
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
430+
} else {
431+
dup_fd_from_pid(pid, dirfd as i32).map_err(|_| libc::EBADF)
432+
}
433+
}
434+
435+
/// Real path of an open fd via its `/proc/self/fd` magic link.
436+
fn realpath_of_fd(fd: RawFd) -> Option<std::path::PathBuf> {
437+
std::fs::read_link(format!("/proc/self/fd/{}", fd)).ok()
438+
}
439+
440+
fn path_under_any(path: &std::path::Path, list: &[std::path::PathBuf]) -> bool {
441+
list.iter().any(|p| path.starts_with(p))
442+
}
443+
444+
/// Decide whether `realpath` may be opened with `flags` under the deny set
445+
/// and the (conservative) grant lists. Returns `Some(errno)` to refuse,
446+
/// `None` to allow. Never over-allows relative to the configured grants: a
447+
/// path outside every grant is refused, so this can only be stricter than
448+
/// Landlock, never looser (an over-deny is a functional gap, an over-allow
449+
/// would be an escape).
450+
fn deny_open_verdict(
451+
realpath: &std::path::Path,
452+
flags: u64,
453+
policy: &NotifPolicy,
454+
pfs: &super::state::PolicyFnState,
455+
) -> Option<i32> {
456+
if pfs.is_path_denied(&realpath.to_string_lossy()) {
457+
return Some(libc::EACCES);
458+
}
459+
let acc = flags as i32 & libc::O_ACCMODE;
460+
let is_write = acc == libc::O_WRONLY
461+
|| acc == libc::O_RDWR
462+
|| (flags & libc::O_TRUNC as u64) != 0
463+
|| (flags & libc::O_CREAT as u64) != 0;
464+
let allowed = if is_write {
465+
path_under_any(realpath, &policy.chroot_writable)
466+
} else {
467+
path_under_any(realpath, &policy.chroot_readable)
468+
|| path_under_any(realpath, &policy.chroot_writable)
469+
};
470+
if allowed { None } else { Some(libc::EACCES) }
471+
}
472+
473+
/// openat/open argument layout, normalized across the two spellings.
474+
struct OpenArgs {
475+
dirfd: i64,
476+
path_ptr: u64,
477+
flags: u64,
478+
mode: u64,
479+
}
480+
481+
fn decode_open_args(notif: &SeccompNotif) -> OpenArgs {
482+
let a = &notif.data.args;
483+
if notif.data.nr as i64 == libc::SYS_openat {
484+
OpenArgs { dirfd: a[0] as i64, path_ptr: a[1], flags: a[2], mode: a[3] }
485+
} else {
486+
// legacy open(path, flags, mode) — AT_FDCWD implied.
487+
OpenArgs { dirfd: libc::AT_FDCWD as i64, path_ptr: a[0], flags: a[1], mode: a[2] }
488+
}
489+
}
490+
491+
/// Wrap a freshly opened raw fd into an `InjectFdSend`, honoring the child's
492+
/// `O_CLOEXEC` request. Ownership of `raw_fd` moves into the action.
493+
fn inject_open_result(raw_fd: i32, flags: u64) -> NotifAction {
494+
use std::os::unix::io::FromRawFd;
495+
if raw_fd < 0 {
496+
return NotifAction::Errno(last_errno(libc::EACCES));
497+
}
498+
let owned = unsafe { OwnedFd::from_raw_fd(raw_fd) };
499+
let newfd_flags = if flags & libc::O_CLOEXEC as u64 != 0 {
500+
libc::O_CLOEXEC as u32
501+
} else {
502+
0
503+
};
504+
NotifAction::InjectFdSend { srcfd: owned, newfd_flags }
505+
}
506+
507+
/// Existing-file branch: vet the pinned inode behind `probe`, then reopen it
508+
/// race-free via its `/proc/self/fd` magic link with the child's real access
509+
/// mode (binds to the inode, not the original path).
510+
fn reopen_existing_on_behalf(
511+
probe: OwnedFd,
512+
flags: u64,
513+
policy: &NotifPolicy,
514+
pfs: &super::state::PolicyFnState,
515+
) -> NotifAction {
516+
// File exists. Refuse O_CREAT|O_EXCL the way the kernel would.
517+
if (flags & libc::O_CREAT as u64) != 0 && (flags & libc::O_EXCL as u64) != 0 {
518+
return NotifAction::Errno(libc::EEXIST);
519+
}
520+
let realpath = match realpath_of_fd(probe.as_raw_fd()) {
521+
Some(p) => p,
522+
None => return NotifAction::Errno(libc::EACCES),
523+
};
524+
if let Some(errno) = deny_open_verdict(&realpath, flags, policy, pfs) {
525+
return NotifAction::Errno(errno);
526+
}
527+
// Resolution-only flags are stripped from the reopen.
528+
let reopen_flags =
529+
flags as i32 & !(libc::O_CREAT | libc::O_EXCL | libc::O_PATH | libc::O_NOFOLLOW);
530+
let proc_path = match std::ffi::CString::new(format!("/proc/self/fd/{}", probe.as_raw_fd())) {
531+
Ok(c) => c,
532+
Err(_) => return NotifAction::Errno(libc::EIO),
533+
};
534+
let fd = unsafe { libc::open(proc_path.as_ptr(), reopen_flags) };
535+
inject_open_result(fd, flags)
536+
}
537+
538+
/// O_CREAT branch: resolve the parent directory race-free, vet the would-be
539+
/// target, then create the leaf inside that pinned parent (the dir inode is
540+
/// fixed, only the leaf name is appended).
541+
fn create_new_on_behalf(
542+
base: &OwnedFd,
543+
path: &str,
544+
flags: u64,
545+
mode: u64,
546+
policy: &NotifPolicy,
547+
pfs: &super::state::PolicyFnState,
548+
) -> NotifAction {
549+
let p = std::path::Path::new(path);
550+
let file_name = match p.file_name() {
551+
Some(f) => f,
552+
None => return NotifAction::Errno(libc::ENOENT),
553+
};
554+
let parent = p.parent().unwrap_or(std::path::Path::new("."));
555+
let parent_str = match parent.to_str() {
556+
Some("") | None => ".",
557+
Some(s) => s,
558+
};
559+
let c_parent = match std::ffi::CString::new(parent_str) {
560+
Ok(c) => c,
561+
Err(_) => return NotifAction::Errno(libc::EINVAL),
562+
};
563+
let parent_fd = match openat2_at(
564+
base.as_raw_fd(),
565+
&c_parent,
566+
(libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64,
567+
0,
568+
RESOLVE_NO_MAGICLINKS,
569+
) {
570+
Ok(f) => f,
571+
Err(e) => return NotifAction::Errno(e),
572+
};
573+
let parent_real = match realpath_of_fd(parent_fd.as_raw_fd()) {
574+
Some(p) => p,
575+
None => return NotifAction::Errno(libc::EACCES),
576+
};
577+
if let Some(errno) = deny_open_verdict(&parent_real.join(file_name), flags, policy, pfs) {
578+
return NotifAction::Errno(errno);
579+
}
580+
let c_name = match std::ffi::CString::new(file_name.as_encoded_bytes()) {
581+
Ok(c) => c,
582+
Err(_) => return NotifAction::Errno(libc::EINVAL),
583+
};
584+
let create_flags = flags as i32 & !(libc::O_PATH | libc::O_NOFOLLOW);
585+
let fd = unsafe { libc::openat(parent_fd.as_raw_fd(), c_name.as_ptr(), create_flags, mode) };
586+
inject_open_result(fd, flags)
587+
}
588+
589+
/// Perform `openat`/`open` on behalf of the child, race-free, when a deny is
590+
/// active. Resolves once (pinning the inode), enforces deny + grant on the
591+
/// pinned target, then hands the child an fd to that exact inode via
592+
/// `InjectFdSend`. Returns `Continue` only when no allow/deny decision was
593+
/// made on content we resolved (unreadable path / no allowlist configured),
594+
/// matching the precheck's existing soft fall-through.
595+
fn on_behalf_open_for_deny(
596+
notif: &SeccompNotif,
597+
policy: &NotifPolicy,
598+
pfs: &super::state::PolicyFnState,
599+
notif_fd: RawFd,
600+
) -> NotifAction {
601+
// No allowlist configured (Landlock is not allowlisting the filesystem):
602+
// there is no grant to check against, so taking over the open could only
603+
// wrongly deny. Leave it to the existing precheck/kernel path.
604+
if policy.chroot_readable.is_empty() && policy.chroot_writable.is_empty() {
605+
return NotifAction::Continue;
606+
}
607+
608+
let OpenArgs { dirfd, path_ptr, flags, mode } = decode_open_args(notif);
609+
610+
let path = match read_child_cstr(notif_fd, notif.id, notif.pid, path_ptr, 4096) {
611+
Some(p) => p,
612+
None => return NotifAction::Continue, // kernel's re-read fails the same way
613+
};
614+
let c_path = match std::ffi::CString::new(path.clone()) {
615+
Ok(c) => c,
616+
Err(_) => return NotifAction::Errno(libc::EINVAL),
617+
};
618+
let base = match open_base_dir(notif.pid, dirfd) {
619+
Ok(b) => b,
620+
Err(e) => return NotifAction::Errno(e),
621+
};
622+
623+
// Side-effect-free probe; mirror the child's no-follow intent for the
624+
// final component.
625+
let probe_flags = (libc::O_PATH | libc::O_CLOEXEC) as u64 | (flags & libc::O_NOFOLLOW as u64);
626+
match openat2_at(base.as_raw_fd(), &c_path, probe_flags, 0, RESOLVE_NO_MAGICLINKS) {
627+
Ok(probe) => reopen_existing_on_behalf(probe, flags, policy, pfs),
628+
Err(errno) if errno == libc::ENOENT && (flags & libc::O_CREAT as u64) != 0 => {
629+
create_new_on_behalf(&base, &path, flags, mode, policy, pfs)
630+
}
631+
Err(errno) => NotifAction::Errno(errno),
632+
}
633+
}
634+
377635
/// Read the thread-group leader (Tgid) of a thread from `/proc/<tid>/status`.
378636
fn tgid_of(tid: u32) -> Option<u32> {
379637
let status = std::fs::read_to_string(format!("/proc/{}/status", tid)).ok()?;
@@ -1343,8 +1601,27 @@ async fn handle_notification(
13431601
if is_path_denied_for_notif(&pfs, &notif, fd) {
13441602
NotifAction::Errno(libc::EACCES)
13451603
} else {
1604+
let has_denied = pfs.has_denied_paths();
13461605
drop(pfs);
1347-
dispatch_table.dispatch(notif, fd).await
1606+
// Let normal dispatch run first so /proc virtualization and
1607+
// other handlers still win for their paths.
1608+
let action = dispatch_table.dispatch(notif, fd).await;
1609+
// A bare `Continue` for openat/open is the racy window: the
1610+
// supervisor's resolution said "not denied", but the kernel
1611+
// re-resolves after Continue and a racing thread can swap a
1612+
// symlink to reach a denied carve-out inside a granted tree
1613+
// (issue #111). Run the open on-behalf against the pinned
1614+
// inode and inject the fd so the kernel never re-resolves.
1615+
// Other path syscalls keep the best-effort precheck above
1616+
// (documented follow-up — they return no fd to inject).
1617+
let is_openat_family =
1618+
nr == libc::SYS_openat || Some(nr) == arch::sys_open();
1619+
if matches!(action, NotifAction::Continue) && is_openat_family && has_denied {
1620+
let pfs = ctx.policy_fn.lock().await;
1621+
on_behalf_open_for_deny(&notif, policy, &pfs, fd)
1622+
} else {
1623+
action
1624+
}
13481625
}
13491626
} else {
13501627
dispatch_table.dispatch(notif, fd).await

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,16 @@ impl PolicyFnState {
433433
false
434434
}
435435
}
436+
437+
/// Whether any deny rule is currently in effect. Cheap gate for the
438+
/// race-free on-behalf open path: with no denies there is no carve-out
439+
/// to protect and opens are left to the kernel and Landlock.
440+
pub fn has_denied_paths(&self) -> bool {
441+
self.denied_paths
442+
.read()
443+
.map(|d| !d.is_empty())
444+
.unwrap_or(false)
445+
}
436446
}
437447

438448
// ============================================================

crates/sandlock-core/tests/integration/test_landlock.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,3 +1497,64 @@ async fn test_isolate_signals_allows_self() {
14971497

14981498
let _ = std::fs::remove_file(&out);
14991499
}
1500+
1501+
#[tokio::test]
1502+
async fn test_deny_carveout_on_behalf_open_preserves_io() {
1503+
// Issue #111: with a deny active, openat/open run on-behalf in the
1504+
// supervisor (race-free) instead of Continue-ing to the kernel. This
1505+
// checks that the new path preserves normal I/O — allowed reads, allowed
1506+
// creates — while the denied carve-out inside the granted tree stays
1507+
// blocked.
1508+
let dir = temp_file("deny-onbehalf-dir");
1509+
let _ = std::fs::create_dir_all(&dir);
1510+
let ok = dir.join("ok.txt");
1511+
let secret = dir.join("secret.txt");
1512+
std::fs::write(&ok, "ok-data").unwrap();
1513+
std::fs::write(&secret, "secret-data").unwrap();
1514+
let created = dir.join("created.txt");
1515+
let _ = std::fs::remove_file(&created);
1516+
1517+
let policy = Sandbox::builder()
1518+
.fs_read("/usr")
1519+
.fs_read("/lib")
1520+
.fs_read_if_exists("/lib64")
1521+
.fs_read("/bin")
1522+
.fs_read("/etc")
1523+
.fs_read("/proc")
1524+
.fs_read("/dev")
1525+
.fs_write(dir.to_str().unwrap()) // grant the whole tree writable
1526+
.fs_deny(secret.to_str().unwrap()) // carve-out inside the grant
1527+
.build()
1528+
.unwrap();
1529+
1530+
// Allowed read still works (on-behalf probe + reopen path).
1531+
let r = policy
1532+
.clone()
1533+
.with_name("t")
1534+
.run(&["cat", ok.to_str().unwrap()])
1535+
.await
1536+
.unwrap();
1537+
assert!(r.success(), "allowed read must work through on-behalf open");
1538+
1539+
// Allowed create still works (on-behalf O_CREAT parent-resolve path).
1540+
let cmd = format!("echo hi > {}", created.display());
1541+
let w = policy
1542+
.clone()
1543+
.with_name("t")
1544+
.run_interactive(&["sh", "-c", &cmd])
1545+
.await
1546+
.unwrap();
1547+
assert!(w.success(), "allowed create must work through on-behalf open");
1548+
assert_eq!(std::fs::read_to_string(&created).unwrap().trim(), "hi");
1549+
1550+
// The denied carve-out stays blocked.
1551+
let d = policy
1552+
.clone()
1553+
.with_name("t")
1554+
.run(&["cat", secret.to_str().unwrap()])
1555+
.await
1556+
.unwrap();
1557+
assert!(!d.success(), "denied carve-out must stay blocked");
1558+
1559+
let _ = std::fs::remove_dir_all(&dir);
1560+
}

0 commit comments

Comments
 (0)