Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/sandlock-core/src/ca_inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ pub(crate) fn handle_ca_inject_open(
notif_fd: RawFd,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(PathBuf, PathBuf)],
processes: &crate::seccomp::state::ProcessIndex,
) -> Option<NotifAction> {
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts, processes)?;
if !path_matches(&resolved, inject_paths) {
return None;
}
Expand Down
728 changes: 474 additions & 254 deletions crates/sandlock-core/src/chroot/dispatch.rs

Large diffs are not rendered by default.

49 changes: 44 additions & 5 deletions crates/sandlock-core/src/chroot/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ pub fn host_to_virtual(
.max_by_key(|(_, source)| source.as_os_str().len())
.map(|(virtual_base, source)| {
// strip_prefix cannot fail: the filter above already matched it.
virtual_base.join(host_path.strip_prefix(source).expect("prefix matched"))
let rest = host_path.strip_prefix(source).expect("prefix matched");
// join("") appends a separator, so the mount point itself would
// render as "/proc/" and reach the child that way through getcwd.
if rest.as_os_str().is_empty() {
virtual_base.to_path_buf()
} else {
virtual_base.join(rest)
}
})
}

Expand All @@ -73,11 +80,32 @@ pub fn resolve_in_root(chroot_root: &Path, child_path: &str) -> Option<(PathBuf,
return Some(result);
}

// Full path doesn't exist — resolve parent directory and append the
// missing filename. This is needed for O_CREAT targets where the
// final component will be created.
// Full path doesn't exist — resolve the parent and append the missing
// filename. This is needed for O_CREAT targets where the final
// component will be created.
resolve_in_root_nofollow(chroot_root, child_path)
}

/// Resolve a virtual path *without* following a final symlink.
///
/// The parent is resolved by the kernel (following intermediate symlinks,
/// confined to `chroot_root`) and the final component is appended verbatim,
/// so the caller acts on the last component itself.
///
/// This is what the no-follow family needs. `lstat` must describe the link,
/// `unlink` and `rename` must remove and move the link, and `lchown` must own
/// it: resolving through the final component would silently redirect every
/// one of them onto the target. It is also how an `O_CREAT` target resolves,
/// since a name that does not exist yet cannot be walked to.
pub fn resolve_in_root_nofollow(
chroot_root: &Path,
child_path: &str,
) -> Option<(PathBuf, PathBuf)> {
let confined = confine(child_path);
let file_name = confined.file_name()?;
// "/" has no final component to leave unresolved.
let Some(file_name) = confined.file_name() else {
return resolve_existing_in_root(chroot_root, child_path);
};
let parent = confined.parent().unwrap_or(Path::new("/"));

match openat2_in_root(
Expand Down Expand Up @@ -263,6 +291,17 @@ mod tests {
);
}

#[test]
fn host_to_virtual_at_a_mount_point_renders_without_a_trailing_slash() {
// The rendered bytes matter, not just Path equality (which ignores a
// trailing separator): getcwd copies this string into the child, and a
// child sitting exactly on a mount point saw "/proc/".
let mounts = vec![(PathBuf::from("/proc"), PathBuf::from("/proc"))];
let virtual_path = host_to_virtual(Path::new("/rootfs"), &mounts, Path::new("/proc"))
.expect("a mount point maps to its own virtual path");
assert_eq!(virtual_path.to_string_lossy(), "/proc");
}

#[test]
fn test_confine_escape_attempt() {
// Deeply nested .. should always clamp at /
Expand Down
56 changes: 35 additions & 21 deletions crates/sandlock-core/src/procfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub(crate) fn is_sensitive_proc(path: &str) -> bool {
///
/// Returns `None` for non-numeric components like `/proc/self/...`,
/// `/proc/cpuinfo`, etc. Those are handled elsewhere or are safe.
fn extract_proc_pid(path: &str) -> Option<i32> {
pub(crate) fn extract_proc_pid(path: &str) -> Option<i32> {
let rest = path.strip_prefix("/proc/")?;
// Take the next path component (up to '/' or end of string).
let component = rest.split('/').next()?;
Expand Down Expand Up @@ -417,6 +417,7 @@ pub(crate) async fn handle_proc_open(
notif_fd,
policy.chroot_root.as_deref(),
&policy.chroot_mounts,
processes,
) {
Some(p) => p,
None => return NotifAction::Continue,
Expand Down Expand Up @@ -607,8 +608,9 @@ pub(crate) fn handle_hostname_open(
notif_fd: RawFd,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
processes: &ProcessIndex,
) -> Option<NotifAction> {
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts, processes)?;
if resolved != std::path::Path::new("/etc/hostname") {
return None;
}
Expand All @@ -629,8 +631,9 @@ pub(crate) fn handle_etc_hosts_open(
notif_fd: RawFd,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
processes: &ProcessIndex,
) -> Option<NotifAction> {
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
let resolved = resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts, processes)?;
if resolved != std::path::Path::new("/etc/hosts") {
return None;
}
Expand All @@ -657,6 +660,7 @@ pub(crate) fn resolve_open_target(
notif_fd: RawFd,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
processes: &ProcessIndex,
) -> Option<std::path::PathBuf> {
let nr = notif.data.nr as i64;
let (dirfd, path_ptr): (i64, u64) = if Some(nr) == crate::arch::sys_open() {
Expand All @@ -670,7 +674,7 @@ pub(crate) fn resolve_open_target(
return None;
};
let path = read_path(notif, path_ptr, notif_fd)?;
resolve_to_normalized_absolute(notif.pid, dirfd, &path, chroot_root, chroot_mounts)
resolve_to_normalized_absolute(notif.pid, dirfd, &path, chroot_root, chroot_mounts, processes)
}

/// Lexical normalization of `(pid, dirfd, path)`:
Expand All @@ -690,30 +694,40 @@ fn resolve_to_normalized_absolute(
path: &str,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
processes: &ProcessIndex,
) -> Option<std::path::PathBuf> {
use std::path::{Component, Path, PathBuf};

// The dirfd/cwd symlink target is the *real* host directory. Under
// chroot, sandlock services /proc, /etc and /dev via on-behalf opens,
// so that target is e.g. `<chroot>/proc` while the child's absolute
// spelling of the same file is `/proc/...`. Map the base back into the
// sandbox's virtual namespace so relative and absolute spellings
// resolve identically and the open-family shims (proc synthesis,
// /etc/hosts, /etc/hostname, random seed, CA inject) match either way.
let to_virtual = |host: PathBuf| match chroot_root {
Some(root) => {
crate::chroot::resolve::host_to_virtual(root, chroot_mounts, &host).unwrap_or(host)
}
None => host,
};

let joined: PathBuf = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
let base = if dirfd as i32 == libc::AT_FDCWD {
std::fs::read_link(format!("/proc/{}/cwd", pid)).ok()?
} else {
std::fs::read_link(format!("/proc/{}/fd/{}", pid, dirfd as i32)).ok()?
};
// The dirfd/cwd symlink target is the *real* host directory. Under
// chroot, sandlock services /proc, /etc and /dev via on-behalf opens,
// so that target is e.g. `<chroot>/proc` while the child's absolute
// spelling of the same file is `/proc/...`. Map the base back into the
// sandbox's virtual namespace so relative and absolute spellings
// resolve identically and the open-family shims (proc synthesis,
// /etc/hosts, /etc/hostname, random seed, CA inject) match either way.
let base = match chroot_root {
Some(root) => crate::chroot::resolve::host_to_virtual(root, chroot_mounts, &base)
.unwrap_or(base),
None => base,
} else if dirfd as i32 == libc::AT_FDCWD {
// Under chroot the supervisor services chdir itself and the child's
// real cwd never moves, so its own notion is the only current one,
// and it is already virtual. Falling back to the kernel's is right
// only for a task that has never moved, which is when nothing is
// tracked.
let base = match i32::try_from(pid).ok().and_then(|p| processes.virtual_cwd(p)) {
Some(tracked) => tracked,
None => to_virtual(std::fs::read_link(format!("/proc/{}/cwd", pid)).ok()?),
};
base.join(path)
} else {
let base = std::fs::read_link(format!("/proc/{}/fd/{}", pid, dirfd as i32)).ok()?;
to_virtual(base).join(path)
};

let mut out = PathBuf::new();
Expand Down
3 changes: 2 additions & 1 deletion crates/sandlock-core/src/random.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ pub(crate) fn handle_random_open(
notif_fd: RawFd,
chroot_root: Option<&std::path::Path>,
chroot_mounts: &[(std::path::PathBuf, std::path::PathBuf)],
processes: &crate::seccomp::state::ProcessIndex,
) -> Option<NotifAction> {
// Resolve the open path so dirfd-relative or non-canonical spellings
// (`/dev/../dev/urandom`, `openat(open("/dev"), "urandom", ...)`)
// can't sidestep the seed and read real kernel entropy.
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts)?;
let resolved = crate::procfs::resolve_open_target(notif, notif_fd, chroot_root, chroot_mounts, processes)?;
let path = resolved.to_str()?;
if path != "/dev/urandom" && path != "/dev/random" {
return None;
Expand Down
Loading
Loading