Skip to content

Commit e176246

Browse files
committed
sandlock-core: resolve magic-link opens like /dev/stderr to the child's own fd
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 32569bf commit e176246

3 files changed

Lines changed: 206 additions & 15 deletions

File tree

crates/sandlock-core/src/chroot/dispatch.rs

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -436,26 +436,149 @@ pub(crate) async fn handle_chroot_open(
436436
}
437437
}
438438

439-
// Open directly via openat2(RESOLVE_IN_ROOT) — single atomic open
440-
// confined to the chroot root (or mount target), no resolve-then-reopen TOCTOU gap.
441-
let vp_str = virtual_path.to_string_lossy();
442-
let mode = if is_write { 0o666 } else { 0 };
443-
let (resolve_root, resolve_path) = if let Some((mt, sub)) = ctx.mount_target(&virtual_path) {
444-
(mt.to_path_buf(), sub)
445-
} else {
446-
(ctx.root.to_path_buf(), vp_str.to_string())
447-
};
448-
let fd = match openat2_in_root(&resolve_root, &resolve_path, flags as i32, mode) {
449-
Ok(fd) => fd,
450-
Err(errno) => return NotifAction::Errno(errno),
451-
};
439+
// Resolve the path to an fd to hand the child: either a freshly opened tree
440+
// file or a dup of one of the child's own fds (a magic link). See
441+
// `open_in_namespace`.
442+
//
443+
// openat2 rejects a non-zero mode unless O_CREAT/O_TMPFILE is set (stricter
444+
// than openat), so only supply a creation mode when the child asks to create.
445+
// O_TMPFILE is a composite (__O_TMPFILE | O_DIRECTORY), so it must be matched
446+
// as a full mask, not with a bitwise-and that any O_DIRECTORY open would trip.
447+
let flags_i = flags as i32;
448+
let creates = flags_i & libc::O_CREAT != 0 || flags_i & libc::O_TMPFILE == libc::O_TMPFILE;
449+
let mode = if creates { 0o666 } else { 0 };
452450
let newfd_flags = if flags & libc::O_CLOEXEC as u64 != 0 {
453451
libc::O_CLOEXEC as u32
454452
} else {
455453
0
456454
};
457-
let owned = unsafe { OwnedFd::from_raw_fd(fd) };
458-
NotifAction::InjectFdSend { srcfd: owned, newfd_flags }
455+
match open_in_namespace(ctx, notif.pid, &virtual_path, flags as i32, mode) {
456+
Ok(srcfd) => NotifAction::InjectFdSend { srcfd, newfd_flags },
457+
Err(errno) => NotifAction::Errno(errno),
458+
}
459+
}
460+
461+
/// Open `virtual_path` within the child's virtual namespace, returning an fd to
462+
/// inject into the child.
463+
///
464+
/// A path names one of two things:
465+
///
466+
/// 1. A **tree object** reachable by walking the rootfs (or a mount target).
467+
/// `openat2(RESOLVE_IN_ROOT)` opens these atomically and confined, with no
468+
/// resolve-then-reopen TOCTOU gap. This is the overwhelmingly common case.
469+
///
470+
/// 2. An **fd reference** such as `/proc/self/fd/N` (named directly, or reached
471+
/// through a symlink chain like `/dev/stderr -> /proc/self/fd/N` that
472+
/// container images use for logging). This is not a filesystem object at
473+
/// all; it names an entry in the child's *own* fd table, which the child
474+
/// already holds. `openat2(RESOLVE_IN_ROOT)` rightly declines to fabricate
475+
/// it (a magic link points outside the root by definition), so we serve it
476+
/// by dup'ing the child's fd. No new access: the open was already authorized
477+
/// by the caller and the child already owns the fd.
478+
///
479+
/// openat2 success proves category 1 (it refuses magic links), so we only look
480+
/// for category 2 when it can't complete: EXDEV when the magic link sits in the
481+
/// same root, ENOENT/ENOTDIR when it points into another mount (e.g.
482+
/// `/dev/stderr -> /proc/...` resolved within the `/dev` mount). The fd walk is
483+
/// authoritative and returns None for genuine misses, so non-magic paths
484+
/// propagate their original errno unchanged.
485+
fn open_in_namespace(
486+
ctx: &ChrootCtx<'_>,
487+
child_pid: u32,
488+
virtual_path: &Path,
489+
flags: i32,
490+
mode: u32,
491+
) -> Result<OwnedFd, i32> {
492+
let vp_str = virtual_path.to_string_lossy();
493+
494+
// Category 2, named directly: skip the open and dup straight away.
495+
if let Some(child_fd) = magic_self_fd(&vp_str, child_pid) {
496+
return crate::seccomp::notif::dup_fd_from_pid(child_pid, child_fd)
497+
.map_err(|_| libc::EBADF);
498+
}
499+
500+
let (root, sub) = match ctx.mount_target(virtual_path) {
501+
Some((mt, sub)) => (mt.to_path_buf(), sub),
502+
None => (ctx.root.to_path_buf(), vp_str.to_string()),
503+
};
504+
match openat2_in_root(&root, &sub, flags, mode) {
505+
// Category 1.
506+
Ok(fd) => Ok(unsafe { OwnedFd::from_raw_fd(fd) }),
507+
// Category 2, reached through symlinks.
508+
Err(errno) if matches!(errno, libc::EXDEV | libc::ENOENT | libc::ENOTDIR) => {
509+
match resolve_self_fd_magic(ctx, child_pid, &vp_str) {
510+
Some(child_fd) => crate::seccomp::notif::dup_fd_from_pid(child_pid, child_fd)
511+
.map_err(|_| errno),
512+
None => Err(errno),
513+
}
514+
}
515+
Err(errno) => Err(errno),
516+
}
517+
}
518+
519+
/// If `path` is a self-referential `/proc/.../fd/N` magic link for the calling
520+
/// child (`/proc/self`, `/proc/thread-self`, or `/proc/<child_pid>`), return the
521+
/// child fd `N`. The fd component must be a bare number (no trailing path).
522+
fn magic_self_fd(path: &str, child_pid: u32) -> Option<i32> {
523+
let (who, fd_part) = path.strip_prefix("/proc/")?.split_once("/fd/")?;
524+
let is_self =
525+
who == "self" || who == "thread-self" || who.parse::<u32>().ok() == Some(child_pid);
526+
if !is_self || fd_part.contains('/') {
527+
return None;
528+
}
529+
fd_part.parse::<i32>().ok()
530+
}
531+
532+
/// Read the symlink target at `virtual_path`, resolved within the chroot/mounts,
533+
/// without following the final component. Returns None if it is not a symlink.
534+
fn read_symlink_in_root(ctx: &ChrootCtx<'_>, virtual_path: &str) -> Option<String> {
535+
let confined = crate::chroot::resolve::confine(virtual_path);
536+
let (root, sub) = if let Some((mt, sub)) = ctx.mount_target(&confined) {
537+
(mt.to_path_buf(), sub)
538+
} else {
539+
(ctx.root.to_path_buf(), confined.to_string_lossy().into_owned())
540+
};
541+
let fd = openat2_in_root(
542+
&root,
543+
&sub,
544+
libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
545+
0,
546+
)
547+
.ok()?;
548+
let mut buf = [0u8; 4096];
549+
let n = unsafe {
550+
libc::readlinkat(
551+
fd,
552+
c"".as_ptr(),
553+
buf.as_mut_ptr() as *mut libc::c_char,
554+
buf.len(),
555+
)
556+
};
557+
unsafe { libc::close(fd) };
558+
if n <= 0 {
559+
return None; // EINVAL (not a symlink) or read error
560+
}
561+
Some(String::from_utf8_lossy(&buf[..n as usize]).into_owned())
562+
}
563+
564+
/// Walk the symlink chain at `virtual_path` (confined to the chroot/mounts)
565+
/// looking for a terminating self-fd magic link; return the child fd it names.
566+
fn resolve_self_fd_magic(ctx: &ChrootCtx<'_>, child_pid: u32, virtual_path: &str) -> Option<i32> {
567+
let mut cur = virtual_path.to_string();
568+
for _ in 0..40 {
569+
if let Some(fd) = magic_self_fd(&cur, child_pid) {
570+
return Some(fd);
571+
}
572+
let target = read_symlink_in_root(ctx, &cur)?;
573+
let next = if Path::new(&target).is_absolute() {
574+
target
575+
} else {
576+
let parent = Path::new(&cur).parent().unwrap_or_else(|| Path::new("/"));
577+
parent.join(&target).to_string_lossy().into_owned()
578+
};
579+
cur = crate::chroot::resolve::confine(&next).to_string_lossy().into_owned();
580+
}
581+
None
459582
}
460583

461584
// ============================================================

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,51 @@ async fn test_chroot_proc_dirfd_relative_is_virtualized() {
279279
cleanup_rootfs(&rootfs);
280280
}
281281

282+
/// A path that resolves (through symlinks) to a `/proc/self/fd/N` magic link
283+
/// must open as a dup of the child's own fd, not fail. Regression: container
284+
/// images wire logging through `error.log -> /dev/stderr -> /proc/self/fd/2`;
285+
/// openat2(RESOLVE_IN_ROOT) refuses to traverse the magic link out of the
286+
/// resolve root (EXDEV directly, or ENOENT when it points into another mount),
287+
/// so the open handler must recognize the fd reference and hand back a dup of
288+
/// the child's stderr. Before the fix this open failed and killed servers like
289+
/// nginx at startup.
290+
#[tokio::test]
291+
async fn test_chroot_magic_fd_symlink_resolves_to_child_fd() {
292+
let rootfs = build_test_rootfs("magic-fd-link");
293+
std::os::unix::fs::symlink("/dev/stderr", rootfs.join("tmp/errlog")).unwrap();
294+
295+
let policy = minimal_exec_policy(&rootfs)
296+
.fs_mount("/proc", "/proc")
297+
.fs_mount("/dev", "/dev")
298+
.fs_write("/tmp")
299+
.build()
300+
.unwrap();
301+
302+
let result = policy
303+
.clone()
304+
.with_name("test")
305+
.run(&["rootfs-helper", "write-fd-link", "/tmp/errlog", "MAGIC_MARKER"])
306+
.await;
307+
match result {
308+
Ok(r) => {
309+
assert!(
310+
r.success(),
311+
"open of errlog -> /dev/stderr -> /proc/self/fd/2 must succeed, stderr: {}",
312+
r.stderr_str().unwrap_or("")
313+
);
314+
// The write lands on the child's own stderr via the magic link.
315+
assert!(
316+
r.stderr_str().unwrap_or("").contains("MAGIC_MARKER"),
317+
"marker must reach the child's stderr through the magic link, stderr: {:?}",
318+
r.stderr_str()
319+
);
320+
}
321+
Err(e) => eprintln!("Chroot test skipped: {}", e),
322+
}
323+
324+
cleanup_rootfs(&rootfs);
325+
}
326+
282327
/// echo hello > /tmp/test.txt && cat /tmp/test.txt works, file appears in rootfs/tmp
283328
#[tokio::test]
284329
async fn test_chroot_write_file() {

tests/rootfs-helper.c

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,11 +634,34 @@ static int cmd_proc_dirfd(int argc, char **argv) {
634634
return 0;
635635
}
636636

637+
/* ── write-fd-link (open a path that resolves to a magic fd link) ─ */
638+
/*
639+
* Open <path> for writing and write <text> to it. Used to exercise paths that
640+
* resolve (directly or through symlinks) to a `/proc/self/fd/N` magic link, as
641+
* container images do with `error.log -> /dev/stderr`. openat2(RESOLVE_IN_ROOT)
642+
* cannot traverse such a link, so the chroot open handler must recognize the fd
643+
* reference and hand back a dup of the child's own fd instead of failing.
644+
*/
645+
static int cmd_write_fd_link(int argc, char **argv) {
646+
if (argc < 2) { fprintf(stderr, "write-fd-link: need <path> <text>\n"); return 1; }
647+
int fd = open(argv[0], O_WRONLY | O_APPEND);
648+
if (fd < 0) {
649+
fprintf(stderr, "write-fd-link: open %s: %s\n", argv[0], strerror(errno));
650+
return 1;
651+
}
652+
ssize_t n = write(fd, argv[1], strlen(argv[1]));
653+
if (n < 0) { fprintf(stderr, "write-fd-link: write: %s\n", strerror(errno)); return 1; }
654+
close(fd);
655+
fprintf(stdout, "wrote %zd bytes\n", n);
656+
return 0;
657+
}
658+
637659
/* ── dispatch ───────────────────────────────────────────────── */
638660

639661
static int dispatch(const char *cmd, int argc, char **argv) {
640662
if (strcmp(cmd, "chdir") == 0) return cmd_chdir(argc, argv);
641663
if (strcmp(cmd, "proc-dirfd") == 0) return cmd_proc_dirfd(argc, argv);
664+
if (strcmp(cmd, "write-fd-link") == 0) return cmd_write_fd_link(argc, argv);
642665
if (strcmp(cmd, "echo") == 0) return cmd_echo(argc, argv);
643666
if (strcmp(cmd, "cat") == 0) return cmd_cat(argc, argv);
644667
if (strcmp(cmd, "ls") == 0) return cmd_ls(argc, argv);

0 commit comments

Comments
 (0)