Skip to content

Commit 32569bf

Browse files
committed
sandlock-core: resolve dirfd/cwd-relative open paths to the virtual namespace under chroot
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 96c4929 commit 32569bf

8 files changed

Lines changed: 149 additions & 26 deletions

File tree

crates/sandlock-core/src/ca_inject.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ pub(crate) fn handle_ca_inject_open(
3939
inject_paths: &[PathBuf],
4040
ca_pem: &[u8],
4141
notif_fd: RawFd,
42+
chroot_root: Option<&std::path::Path>,
43+
chroot_mounts: &[(PathBuf, PathBuf)],
4244
) -> Option<NotifAction> {
43-
let resolved = crate::procfs::resolve_open_target(notif, notif_fd)?;
45+
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
4446
if !path_matches(&resolved, inject_paths) {
4547
return None;
4648
}

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

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use std::sync::Arc;
4747

4848
use tokio::sync::Mutex;
4949

50-
use crate::chroot::resolve::{confine, resolve_existing_in_root, resolve_in_root, to_virtual_path};
50+
use crate::chroot::resolve::{confine, resolve_existing_in_root, resolve_in_root};
5151
use crate::sys::fs::openat2_in_root;
5252
use crate::seccomp::notif::{read_child_mem, write_child_mem, NotifAction};
5353
use crate::seccomp::state::{ChrootState, CowState};
@@ -165,22 +165,7 @@ impl ChrootCtx<'_> {
165165
/// Inverse: given a host path, return the virtual path.
166166
/// Checks mount targets first, then falls back to chroot root.
167167
fn host_to_virtual(&self, host_path: &Path) -> Option<PathBuf> {
168-
// Check mounts first (longest prefix match)
169-
let mut best: Option<(&Path, &Path, usize)> = None;
170-
for (vp, hp) in self.mounts {
171-
if host_path.starts_with(hp) {
172-
let len = hp.as_os_str().len();
173-
if best.is_none() || len > best.unwrap().2 {
174-
best = Some((vp.as_path(), hp.as_path(), len));
175-
}
176-
}
177-
}
178-
if let Some((mount_vp, mount_hp, _)) = best {
179-
let rel = host_path.strip_prefix(mount_hp).ok()?;
180-
return Some(mount_vp.join(rel));
181-
}
182-
// Fall back to chroot root
183-
to_virtual_path(self.root, host_path)
168+
crate::chroot::resolve::host_to_virtual(self.root, self.mounts, host_path)
184169
}
185170
}
186171

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ pub fn to_virtual_path(chroot_root: &Path, host_path: &Path) -> Option<PathBuf>
3636
.map(|rel| PathBuf::from("/").join(rel))
3737
}
3838

39+
/// Inverse of mount/chroot resolution: map a real host path back to the
40+
/// sandbox's virtual path.
41+
///
42+
/// The chroot root is just the virtual `/` mount, so this is a single
43+
/// most-specific-prefix lookup over `{ "/" => chroot_root } ∪ mounts` —
44+
/// the same rule the kernel uses to pick among overlapping mounts. Returns
45+
/// None when the host path is under neither the root nor any mount.
46+
pub fn host_to_virtual(
47+
chroot_root: &Path,
48+
mounts: &[(PathBuf, PathBuf)],
49+
host_path: &Path,
50+
) -> Option<PathBuf> {
51+
std::iter::once((Path::new("/"), chroot_root))
52+
.chain(mounts.iter().map(|(v, h)| (v.as_path(), h.as_path())))
53+
.filter(|(_, source)| host_path.starts_with(source))
54+
.max_by_key(|(_, source)| source.as_os_str().len())
55+
.map(|(virtual_base, source)| {
56+
// strip_prefix cannot fail: the filter above already matched it.
57+
virtual_base.join(host_path.strip_prefix(source).expect("prefix matched"))
58+
})
59+
}
60+
3961
/// Resolve a virtual path within the chroot using `openat2(RESOLVE_IN_ROOT)`.
4062
///
4163
/// The kernel resolves all symlinks and `..` components, keeping the result

crates/sandlock-core/src/procfs.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,12 @@ pub(crate) async fn handle_proc_open(
412412
// sensitive-path deny, the per-PID filter, and the virtualization
413413
// string-matches below all see the same canonical form regardless of
414414
// how the caller spelled it (dirfd-relative, `..`-laden, etc.).
415-
let resolved = match resolve_open_target(notif, notif_fd) {
415+
let resolved = match resolve_open_target(
416+
notif,
417+
notif_fd,
418+
policy.chroot_root.as_deref(),
419+
&policy.chroot_mounts,
420+
) {
416421
Some(p) => p,
417422
None => return NotifAction::Continue,
418423
};
@@ -600,8 +605,10 @@ pub(crate) fn handle_hostname_open(
600605
notif: &SeccompNotif,
601606
hostname: &str,
602607
notif_fd: RawFd,
608+
chroot_root: Option<&std::path::Path>,
609+
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
603610
) -> Option<NotifAction> {
604-
let resolved = resolve_open_target(notif, notif_fd)?;
611+
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
605612
if resolved != std::path::Path::new("/etc/hostname") {
606613
return None;
607614
}
@@ -620,8 +627,10 @@ pub(crate) fn handle_etc_hosts_open(
620627
notif: &SeccompNotif,
621628
etc_hosts_content: &str,
622629
notif_fd: RawFd,
630+
chroot_root: Option<&std::path::Path>,
631+
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
623632
) -> Option<NotifAction> {
624-
let resolved = resolve_open_target(notif, notif_fd)?;
633+
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
625634
if resolved != std::path::Path::new("/etc/hosts") {
626635
return None;
627636
}
@@ -646,6 +655,8 @@ pub(crate) fn handle_etc_hosts_open(
646655
pub(crate) fn resolve_open_target(
647656
notif: &SeccompNotif,
648657
notif_fd: RawFd,
658+
chroot_root: Option<&std::path::Path>,
659+
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
649660
) -> Option<std::path::PathBuf> {
650661
let nr = notif.data.nr as i64;
651662
let (dirfd, path_ptr): (i64, u64) = if Some(nr) == crate::arch::sys_open() {
@@ -659,7 +670,7 @@ pub(crate) fn resolve_open_target(
659670
return None;
660671
};
661672
let path = read_path(notif, path_ptr, notif_fd)?;
662-
resolve_to_normalized_absolute(notif.pid, dirfd, &path)
673+
resolve_to_normalized_absolute(notif.pid, dirfd, &path, chroot_root, chroot_mounts)
663674
}
664675

665676
/// Lexical normalization of `(pid, dirfd, path)`:
@@ -677,6 +688,8 @@ fn resolve_to_normalized_absolute(
677688
pid: u32,
678689
dirfd: i64,
679690
path: &str,
691+
chroot_root: Option<&std::path::Path>,
692+
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
680693
) -> Option<std::path::PathBuf> {
681694
use std::path::{Component, Path, PathBuf};
682695

@@ -688,6 +701,18 @@ fn resolve_to_normalized_absolute(
688701
} else {
689702
std::fs::read_link(format!("/proc/{}/fd/{}", pid, dirfd as i32)).ok()?
690703
};
704+
// The dirfd/cwd symlink target is the *real* host directory. Under
705+
// chroot, sandlock services /proc, /etc and /dev via on-behalf opens,
706+
// so that target is e.g. `<chroot>/proc` while the child's absolute
707+
// spelling of the same file is `/proc/...`. Map the base back into the
708+
// sandbox's virtual namespace so relative and absolute spellings
709+
// resolve identically and the open-family shims (proc synthesis,
710+
// /etc/hosts, /etc/hostname, random seed, CA inject) match either way.
711+
let base = match chroot_root {
712+
Some(root) => crate::chroot::resolve::host_to_virtual(root, chroot_mounts, &base)
713+
.unwrap_or(base),
714+
None => base,
715+
};
691716
base.join(path)
692717
};
693718

crates/sandlock-core/src/random.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,13 @@ pub(crate) fn handle_random_open(
5656
notif: &SeccompNotif,
5757
rng: &mut ChaCha8Rng,
5858
notif_fd: RawFd,
59+
chroot_root: Option<&std::path::Path>,
60+
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
5961
) -> Option<NotifAction> {
6062
// Resolve the open path so dirfd-relative or non-canonical spellings
6163
// (`/dev/../dev/urandom`, `openat(open("/dev"), "urandom", ...)`)
6264
// can't sidestep the seed and read real kernel entropy.
63-
let resolved = crate::procfs::resolve_open_target(notif, notif_fd)?;
65+
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
6466
let path = resolved.to_str()?;
6567
if path != "/dev/urandom" && path != "/dev/random" {
6668
return None;

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,19 @@ pub(crate) fn build_dispatch_table(
376376
if policy.has_random_seed {
377377
for nr in open_family_syscalls() {
378378
let __sup = Arc::clone(ctx);
379+
let policy_rand = Arc::clone(policy);
379380
table.register(nr, move |cx: &HandlerCtx| {
380381
let notif = cx.notif;
381382
let sup = Arc::clone(&__sup);
383+
let policy = Arc::clone(&policy_rand);
382384
let notif_fd = cx.notif_fd;
383385
async move {
384386
let mut tr = sup.time_random.lock().await;
385387
if let Some(ref mut rng) = tr.random_state {
386-
if let Some(action) = crate::random::handle_random_open(&notif, rng, notif_fd) {
388+
if let Some(action) = crate::random::handle_random_open(
389+
&notif, rng, notif_fd,
390+
policy.chroot_root.as_deref(), &policy.chroot_mounts,
391+
) {
387392
return action;
388393
}
389394
}
@@ -430,12 +435,17 @@ pub(crate) fn build_dispatch_table(
430435
let etc_hosts = policy.virtual_etc_hosts.clone();
431436
for nr in open_family_syscalls() {
432437
let etc_hosts = etc_hosts.clone();
438+
let policy_hosts = Arc::clone(policy);
433439
table.register(nr, move |cx: &HandlerCtx| {
434440
let notif = cx.notif;
435441
let notif_fd = cx.notif_fd;
436442
let etc_hosts = etc_hosts.clone();
443+
let policy = Arc::clone(&policy_hosts);
437444
async move {
438-
if let Some(action) = crate::procfs::handle_etc_hosts_open(&notif, &etc_hosts, notif_fd) {
445+
if let Some(action) = crate::procfs::handle_etc_hosts_open(
446+
&notif, &etc_hosts, notif_fd,
447+
policy.chroot_root.as_deref(), &policy.chroot_mounts,
448+
) {
439449
action
440450
} else {
441451
NotifAction::Continue
@@ -457,14 +467,17 @@ pub(crate) fn build_dispatch_table(
457467
for nr in open_family_syscalls() {
458468
let ca_pem = std::sync::Arc::clone(&ca_pem);
459469
let inject_paths = std::sync::Arc::clone(&inject_paths);
470+
let policy_ca = Arc::clone(policy);
460471
table.register(nr, move |cx: &HandlerCtx| {
461472
let notif = cx.notif;
462473
let notif_fd = cx.notif_fd;
463474
let ca_pem = std::sync::Arc::clone(&ca_pem);
464475
let inject_paths = std::sync::Arc::clone(&inject_paths);
476+
let policy = Arc::clone(&policy_ca);
465477
async move {
466478
crate::ca_inject::handle_ca_inject_open(
467479
&notif, &inject_paths, &ca_pem, notif_fd,
480+
policy.chroot_root.as_deref(), &policy.chroot_mounts,
468481
)
469482
.unwrap_or(NotifAction::Continue)
470483
}
@@ -571,12 +584,17 @@ pub(crate) fn build_dispatch_table(
571584
});
572585
for nr in open_family_syscalls() {
573586
let hostname = hostname_for_open.clone();
587+
let policy_hostname = Arc::clone(policy);
574588
table.register(nr, move |cx: &HandlerCtx| {
575589
let notif = cx.notif;
576590
let notif_fd = cx.notif_fd;
577591
let hostname = hostname.clone();
592+
let policy = Arc::clone(&policy_hostname);
578593
async move {
579-
if let Some(action) = crate::procfs::handle_hostname_open(&notif, &hostname, notif_fd) {
594+
if let Some(action) = crate::procfs::handle_hostname_open(
595+
&notif, &hostname, notif_fd,
596+
policy.chroot_root.as_deref(), &policy.chroot_mounts,
597+
) {
580598
action
581599
} else {
582600
NotifAction::Continue

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,51 @@ async fn test_chroot_chdir_proc_readonly_buffer() {
234234
cleanup_rootfs(&rootfs);
235235
}
236236

237+
/// A dirfd-relative /proc read under a chroot with NO /proc mount must still
238+
/// hit the synthesis shim. Regression: resolve_open_target took the proc
239+
/// dirfd's real symlink target (`<chroot>/proc`) as the base, so
240+
/// `openat(open("/proc"), "meminfo")` normalized to `<chroot>/proc/meminfo` and
241+
/// missed the `== "/proc/meminfo"` synthesis match, falling through to the
242+
/// chroot handler which ENOENT'd on the empty rootfs procfs, while the absolute
243+
/// spelling was synthesized. The resolver now maps the dirfd base back into the
244+
/// virtual namespace so both spellings synthesize identically.
245+
#[tokio::test]
246+
async fn test_chroot_proc_dirfd_relative_is_virtualized() {
247+
let rootfs = build_test_rootfs("proc-dirfd");
248+
249+
// No fs_mount for /proc: it is the empty rootfs dir, so the file can only
250+
// come from synthesis. max_memory engages the /proc/meminfo shim.
251+
let policy = minimal_exec_policy(&rootfs)
252+
.max_memory(sandlock_core::sandbox::ByteSize::mib(256))
253+
.build()
254+
.unwrap();
255+
256+
let result = policy
257+
.clone()
258+
.with_name("test")
259+
.run(&["rootfs-helper", "proc-dirfd", "meminfo"])
260+
.await;
261+
match result {
262+
Ok(r) => {
263+
assert!(
264+
r.success(),
265+
"dirfd-relative /proc/meminfo must be synthesized (not ENOENT), stderr: {}",
266+
r.stderr_str().unwrap_or("")
267+
);
268+
let out = r.stdout_str().unwrap_or("");
269+
assert!(out.contains("MemTotal"), "expected synthesized meminfo, got: {:?}", out);
270+
assert!(
271+
out.contains("262144"),
272+
"expected virtual 256MiB MemTotal (262144 kB), got: {:?}",
273+
out
274+
);
275+
}
276+
Err(e) => eprintln!("Chroot test skipped: {}", e),
277+
}
278+
279+
cleanup_rootfs(&rootfs);
280+
}
281+
237282
/// echo hello > /tmp/test.txt && cat /tmp/test.txt works, file appears in rootfs/tmp
238283
#[tokio::test]
239284
async fn test_chroot_write_file() {

tests/rootfs-helper.c

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,10 +611,34 @@ static int cmd_chdir(int argc, char **argv) {
611611
return 0;
612612
}
613613

614+
/* ── proc-dirfd (non-standard: read /proc/<name> via a proc dirfd) ─ */
615+
/*
616+
* Opens /proc as a directory, then openat()s <name> RELATIVE to that fd and
617+
* dumps it. This is the dirfd-relative spelling of a /proc access: the open
618+
* shims must resolve it to the virtual /proc path (not the real
619+
* <chroot>/proc) so virtualization/synthesis applies the same as an absolute
620+
* open. Prints the file contents on success.
621+
*/
622+
static int cmd_proc_dirfd(int argc, char **argv) {
623+
if (argc < 1) { fprintf(stderr, "proc-dirfd: missing operand\n"); return 1; }
624+
int dfd = open("/proc", O_RDONLY | O_DIRECTORY);
625+
if (dfd < 0) { fprintf(stderr, "proc-dirfd: open /proc: %s\n", strerror(errno)); return 1; }
626+
int fd = openat(dfd, argv[0], O_RDONLY);
627+
if (fd < 0) { fprintf(stderr, "proc-dirfd: openat %s: %s\n", argv[0], strerror(errno)); return 1; }
628+
char buf[8192];
629+
ssize_t n = read(fd, buf, sizeof(buf));
630+
if (n < 0) { fprintf(stderr, "proc-dirfd: read: %s\n", strerror(errno)); return 1; }
631+
write(STDOUT_FILENO, buf, n);
632+
close(fd);
633+
close(dfd);
634+
return 0;
635+
}
636+
614637
/* ── dispatch ───────────────────────────────────────────────── */
615638

616639
static int dispatch(const char *cmd, int argc, char **argv) {
617640
if (strcmp(cmd, "chdir") == 0) return cmd_chdir(argc, argv);
641+
if (strcmp(cmd, "proc-dirfd") == 0) return cmd_proc_dirfd(argc, argv);
618642
if (strcmp(cmd, "echo") == 0) return cmd_echo(argc, argv);
619643
if (strcmp(cmd, "cat") == 0) return cmd_cat(argc, argv);
620644
if (strcmp(cmd, "ls") == 0) return cmd_ls(argc, argv);

0 commit comments

Comments
 (0)