Skip to content

Commit 25bcd6b

Browse files
committed
sandlock-core: avoid chdir path-rewrite EFAULT for same-path mounts like /proc
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 5d3108e commit 25bcd6b

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1531,7 +1531,8 @@ pub(crate) async fn handle_chroot_chdir(
15311531
};
15321532

15331533
// Build the full virtual path from AT_FDCWD + path.
1534-
let full_path = if Path::new(&path).is_absolute() {
1534+
let was_absolute = Path::new(&path).is_absolute();
1535+
let full_path = if was_absolute {
15351536
path
15361537
} else {
15371538
match std::fs::read_link(format!("/proc/{}/cwd", notif.pid)) {
@@ -1560,6 +1561,29 @@ pub(crate) async fn handle_chroot_chdir(
15601561
Err(errno) => return NotifAction::Errno(errno),
15611562
};
15621563

1564+
// Native-chdir fast path. The child runs in the host filesystem (sandlock
1565+
// does not chroot(2); it confines via on-behalf openat2 + Landlock), so an
1566+
// absolute chdir resolves against the real host root. When the on-behalf
1567+
// resolved directory's real host path is identical to the path the child
1568+
// asked for (the common case for same-path mounts like /proc and /sys), a
1569+
// raw chdir reaches the exact same directory. Let the kernel run the
1570+
// original syscall unchanged instead of rewriting the argument to the
1571+
// longer /proc/self/fd/N: that rewrite goes through process_vm_writev,
1572+
// which fails with EFAULT when the child passed a read-only string literal
1573+
// (e.g. busybox `top`'s chdir("/proc")), killing the process. Confinement
1574+
// is preserved: every subsequent open/stat/getdents is independently
1575+
// re-resolved on-behalf, and the equality check only holds when no symlink
1576+
// redirection occurred during the confined open.
1577+
if was_absolute
1578+
&& std::fs::read_link(format!("/proc/self/fd/{}", src_fd))
1579+
.ok()
1580+
.as_deref()
1581+
== Some(Path::new(&full_path))
1582+
{
1583+
unsafe { libc::close(src_fd) };
1584+
return NotifAction::Continue;
1585+
}
1586+
15631587
// Inject fd into child and rewrite path to /proc/self/fd/N.
15641588
let addfd = SeccompNotifAddfd {
15651589
id: notif.id,

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,44 @@ async fn test_chroot_getcwd() {
196196
cleanup_rootfs(&rootfs);
197197
}
198198

199+
/// chdir into a same-path mount (/proc) from a READ-ONLY path buffer must
200+
/// succeed. Regression for the busybox-`top` EFAULT: rewriting the child's
201+
/// path argument to /proc/self/fd/N faults when the path lives in read-only
202+
/// memory (a .rodata literal). The handler instead lets the kernel run the
203+
/// original chdir unchanged when the on-behalf-resolved directory is identical
204+
/// to the requested path, which holds for the /proc mount.
205+
#[tokio::test]
206+
async fn test_chroot_chdir_proc_readonly_buffer() {
207+
let rootfs = build_test_rootfs("chdir-proc-ro");
208+
209+
let policy = minimal_exec_policy(&rootfs)
210+
.fs_mount("/proc", "/proc")
211+
.build()
212+
.unwrap();
213+
214+
let result = policy
215+
.clone()
216+
.with_name("test")
217+
.run(&["rootfs-helper", "chdir", "/proc"])
218+
.await;
219+
match result {
220+
Ok(r) => {
221+
assert!(
222+
r.success(),
223+
"chdir(/proc) from a read-only buffer should succeed, stderr: {}",
224+
r.stderr_str().unwrap_or("")
225+
);
226+
// cwd must be /proc (getcwd may render the mount root with a
227+
// trailing slash; the meaningful assertion is that we landed there).
228+
let cwd = r.stdout_str().unwrap_or("").trim().trim_end_matches('/').to_string();
229+
assert_eq!(cwd, "OK /proc", "cwd after chdir should be /proc");
230+
}
231+
Err(e) => eprintln!("Chroot test skipped: {}", e),
232+
}
233+
234+
cleanup_rootfs(&rootfs);
235+
}
236+
199237
/// echo hello > /tmp/test.txt && cat /tmp/test.txt works, file appears in rootfs/tmp
200238
#[tokio::test]
201239
async fn test_chroot_write_file() {

tests/rootfs-helper.c

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <stdio.h>
1818
#include <stdlib.h>
1919
#include <string.h>
20+
#include <sys/mman.h>
2021
#include <sys/stat.h>
2122
#include <sys/syscall.h>
2223
#include <sys/types.h>
@@ -581,9 +582,39 @@ static int cmd_spawn_loop(int argc, char **argv) {
581582
return 0;
582583
}
583584

585+
/* ── chdir (non-standard: chdir from a READ-ONLY path buffer) ─── */
586+
/*
587+
* Copies the target path onto a freshly mmap'd page, flips it to PROT_READ,
588+
* then chdir()s through that read-only pointer. This reproduces how real
589+
* programs (e.g. busybox `top`'s chdir("/proc")) pass a .rodata string
590+
* literal: the chroot chdir handler must not assume the child's path buffer
591+
* is writable, since rewriting it in place would fault. On success prints the
592+
* resulting cwd so the caller can confirm the directory actually changed.
593+
*/
594+
static int cmd_chdir(int argc, char **argv) {
595+
if (argc < 1) { fprintf(stderr, "chdir: missing operand\n"); return 1; }
596+
size_t n = strlen(argv[0]) + 1;
597+
long pg = sysconf(_SC_PAGESIZE);
598+
size_t maplen = ((n + pg - 1) / pg) * pg;
599+
char *ro = mmap(NULL, maplen, PROT_READ | PROT_WRITE,
600+
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
601+
if (ro == MAP_FAILED) { perror("chdir: mmap"); return 1; }
602+
memcpy(ro, argv[0], n);
603+
if (mprotect(ro, maplen, PROT_READ) != 0) { perror("chdir: mprotect"); return 1; }
604+
if (chdir(ro) != 0) {
605+
fprintf(stderr, "chdir: %s: %s\n", argv[0], strerror(errno));
606+
return 1;
607+
}
608+
char buf[4096];
609+
if (!getcwd(buf, sizeof(buf))) { perror("chdir: getcwd"); return 1; }
610+
printf("OK %s\n", buf);
611+
return 0;
612+
}
613+
584614
/* ── dispatch ───────────────────────────────────────────────── */
585615

586616
static int dispatch(const char *cmd, int argc, char **argv) {
617+
if (strcmp(cmd, "chdir") == 0) return cmd_chdir(argc, argv);
587618
if (strcmp(cmd, "echo") == 0) return cmd_echo(argc, argv);
588619
if (strcmp(cmd, "cat") == 0) return cmd_cat(argc, argv);
589620
if (strcmp(cmd, "ls") == 0) return cmd_ls(argc, argv);

0 commit comments

Comments
 (0)