@@ -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`.
378636fn 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
0 commit comments