feat(sandbox): restrict proc and devices in private roots
This commit is contained in:
@@ -232,6 +232,33 @@ work 为 nosuid/nodev,保留后续编译和执行能力;tmp 另加 noexec。
|
||||
参见 [pivot_root](https://man7.org/linux/man-pages/man2/pivot_root.2.html) 和
|
||||
[mount](https://man7.org/linux/man-pages/man2/mount.2.html)。
|
||||
|
||||
## 受限 proc/dev 与初始化 FD 防护
|
||||
|
||||
`isolation/proc_dev.rs` 固定 OS 策略,`launcher/proc_dev.rs` 的 `RestrictedRootTarget`
|
||||
在已完成的 `RootedTarget` 上推进独立握手;两个类型均没有用户 exec 方法。
|
||||
fresh exec 的 bootstrap/init 在创建内部 FD 前扫描自己的 FD,只允许 0/1/2,
|
||||
精确排除扫描器自己持有的 FD。任何额外继承文件或目录都在 namespace 操作前拒绝,
|
||||
避免原始 close 与 Rust FD 所有权冲突。初始化后监督器再次确认 init 和 bootstrap
|
||||
的根、cwd 及无目录 FD,拒绝 cwd 留在私有 root/work 以外的情况。
|
||||
|
||||
仅 namespace PID 1 在切根后重新挂载 proc,使用 ro/nosuid/nodev/noexec、
|
||||
`hidepid=2,subset=pid`。监督器核对实际文件系统、挂载选项、PID 1/PPid 0,
|
||||
以及全局 sys/sysrq-trigger/kcore/keys 等入口缺失;选项或挂载能力不支持则拒绝。
|
||||
dev 使用 64 KiB、16 inode 的私有 tmpfs,只创建 root 所有、0666 的四个字符设备:
|
||||
null (1:3)、zero (1:5)、random (1:8)、urandom (1:9),随后把目录挂载设为只读。
|
||||
dev 为 nosuid/noexec,允许这四个设备 I/O 所以不设置 nodev;不提供磁盘、mem、tty 或 pts。
|
||||
固定 dev 的容量是初始化开销,work/tmp 的申请预算和内存计费政策另行管理。
|
||||
|
||||
`proc_dev_vm.rs` 在规定的独立 VM 中验证新 PID 视图、固定设备 I/O、非 root 固定探针对
|
||||
helper/外部 PID、旧根和跨任务文件的访问拒绝,并验证意外继承 FD 的启动拒绝。
|
||||
VM 专用故障注入器让内核对 mknodat 返回 EPERM,验证部分 proc/dev 初始化的真实清理。
|
||||
探针的 UID 切换及故障过滤器仅属于 VM 验收,不能当作生产身份或 seccomp 实现。
|
||||
测试输入尚未安全绑定,工件校验/提取、生产身份/caps/seccomp 和完整用户 stdio/监督
|
||||
仍待实现,因此完整 proc/dev 与输入保护 TODO 暂不标记完成,run 继续拒绝。
|
||||
|
||||
参见 [proc 的 hidepid/subset 选项](https://man7.org/linux/man-pages/man5/procfs.5.html)
|
||||
及 [null/zero 设备](https://man7.org/linux/man-pages/man4/null.4.html)。
|
||||
|
||||
## 可信运行配置与启动前检查
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
|
||||
|
||||
pub mod namespaces;
|
||||
pub(crate) mod proc_dev;
|
||||
pub(crate) mod root;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Fixed post-pivot proc/dev policy. No host mount/device path is request data.
|
||||
use rustix::fs::{AtFlags, FileType, Mode, OFlags, StatVfsMountFlags};
|
||||
use rustix::mount::MountFlags;
|
||||
use std::io;
|
||||
|
||||
const DEV_BYTES: u64 = 64 * 1024;
|
||||
const DEVICES: [(&str, u32); 4] = [("null", 3), ("zero", 5), ("random", 8), ("urandom", 9)];
|
||||
|
||||
fn denied(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
/// The root role already proved single-threaded before pivot; a fresh proc
|
||||
/// mount is needed before repeating that check. Only namespace PID 1 calls us.
|
||||
pub(crate) fn install() -> io::Result<()> {
|
||||
if rustix::process::getpid().as_raw_pid() != 1 {
|
||||
return Err(denied("proc setup requires namespace init"));
|
||||
}
|
||||
rustix::mount::mount(
|
||||
"proc",
|
||||
"/proc",
|
||||
"proc",
|
||||
MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC,
|
||||
Some(c"hidepid=2,subset=pid"),
|
||||
)?;
|
||||
crate::launcher::entry::ensure_single_threaded()?;
|
||||
rustix::mount::mount(
|
||||
"tmpfs",
|
||||
"/dev",
|
||||
"tmpfs",
|
||||
MountFlags::NOSUID | MountFlags::NOEXEC,
|
||||
Some(c"size=65536,nr_inodes=16,mode=0755"),
|
||||
)?;
|
||||
let dev = rustix::fs::open(
|
||||
"/dev",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
// Single-threaded fresh init owns this process-wide umask change. Restore
|
||||
// it even when mknod fails; the caller terminates the namespace on error.
|
||||
let previous = rustix::process::umask(Mode::empty());
|
||||
let created = (|| {
|
||||
for (name, minor) in DEVICES {
|
||||
rustix::fs::mknodat(
|
||||
&dev,
|
||||
name,
|
||||
FileType::CharacterDevice,
|
||||
Mode::from_raw_mode(0o666),
|
||||
rustix::fs::makedev(1, minor),
|
||||
)?;
|
||||
}
|
||||
Ok::<_, io::Error>(())
|
||||
})();
|
||||
rustix::process::umask(previous);
|
||||
created?;
|
||||
drop(dev);
|
||||
// Read-only directory entries still permit I/O on the fixed character
|
||||
// devices. NODEV must remain absent here; all other mounts retain NODEV.
|
||||
rustix::mount::mount_remount(
|
||||
"/dev",
|
||||
MountFlags::RDONLY | MountFlags::NOSUID | MountFlags::NOEXEC,
|
||||
c"",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn verify(pid: u32) -> io::Result<()> {
|
||||
let base = format!("/proc/{pid}/root");
|
||||
let proc = rustix::fs::open(
|
||||
format!("{base}/proc"),
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let required =
|
||||
StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NOEXEC;
|
||||
if rustix::fs::fstatfs(&proc)?.f_type != rustix::fs::PROC_SUPER_MAGIC
|
||||
|| !rustix::fs::fstatvfs(&proc)?
|
||||
.f_flag
|
||||
.contains(required | StatVfsMountFlags::NODEV)
|
||||
{
|
||||
return Err(denied("restricted proc mount mismatch"));
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("{base}/proc/1/status"))?;
|
||||
if !status.lines().any(|line| line == "Pid:\t1")
|
||||
|| !status.lines().any(|line| line == "PPid:\t0")
|
||||
{
|
||||
return Err(denied("proc is not rooted in namespace PID 1"));
|
||||
}
|
||||
let proc_mountinfo = std::fs::read_to_string(format!("{base}/proc/1/mountinfo"))?;
|
||||
let options = proc_mountinfo
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (left, right) = line.split_once(" - ")?;
|
||||
(left.split_whitespace().nth(4) == Some("/proc"))
|
||||
.then(|| right.split_whitespace().nth(2))
|
||||
.flatten()
|
||||
})
|
||||
.ok_or_else(|| denied("missing proc mount options"))?;
|
||||
verify_proc_options(options)?;
|
||||
for path in [
|
||||
"sys",
|
||||
"sysrq-trigger",
|
||||
"kcore",
|
||||
"keys",
|
||||
"meminfo",
|
||||
"cpuinfo",
|
||||
] {
|
||||
match rustix::fs::statat(&proc, path, AtFlags::SYMLINK_NOFOLLOW) {
|
||||
Err(rustix::io::Errno::NOENT) => {}
|
||||
_ => return Err(denied("global proc entry exposed")),
|
||||
}
|
||||
}
|
||||
let dev = rustix::fs::open(
|
||||
format!("{base}/dev"),
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let stat = rustix::fs::fstatvfs(&dev)?;
|
||||
if rustix::fs::fstatfs(&dev)?.f_type as u64 != 0x0102_1994
|
||||
|| stat.f_blocks.checked_mul(stat.f_frsize) != Some(DEV_BYTES)
|
||||
|| !stat.f_flag.contains(required)
|
||||
|| stat.f_flag.contains(StatVfsMountFlags::NODEV)
|
||||
{
|
||||
return Err(denied("restricted dev mount mismatch"));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(format!("{base}/dev"))?
|
||||
.map(|entry| entry.map(|e| e.file_name()))
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
entries.sort();
|
||||
let mut expected: Vec<_> = DEVICES
|
||||
.iter()
|
||||
.map(|(name, _)| std::ffi::OsString::from(name))
|
||||
.collect();
|
||||
expected.sort();
|
||||
if entries != expected {
|
||||
return Err(denied("unexpected dev entry"));
|
||||
}
|
||||
for (name, minor) in DEVICES {
|
||||
let stat = rustix::fs::statat(&dev, name, AtFlags::SYMLINK_NOFOLLOW)?;
|
||||
if FileType::from_raw_mode(stat.st_mode) != FileType::CharacterDevice
|
||||
|| stat.st_rdev != rustix::fs::makedev(1, minor)
|
||||
|| stat.st_uid != 0
|
||||
|| stat.st_gid != 0
|
||||
|| stat.st_mode & 0o7777 != 0o666
|
||||
{
|
||||
return Err(denied("device type/identity/mode mismatch"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_proc_options(options: &str) -> io::Result<()> {
|
||||
let hide: Vec<_> = options
|
||||
.split(',')
|
||||
.filter(|s| s.starts_with("hidepid="))
|
||||
.collect();
|
||||
let subset: Vec<_> = options
|
||||
.split(',')
|
||||
.filter(|s| s.starts_with("subset="))
|
||||
.collect();
|
||||
// Kernel show_options renders HIDEPID_INVISIBLE as a named value, even
|
||||
// when mount received the numeric value. Accept only this exact policy.
|
||||
if !matches!(hide.as_slice(), ["hidepid=2"] | ["hidepid=invisible"])
|
||||
|| !matches!(subset.as_slice(), ["subset=pid"])
|
||||
{
|
||||
return Err(denied("proc restriction options mismatch"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::verify_proc_options;
|
||||
#[test]
|
||||
fn kernel_numeric_and_named_invisible_policy_are_equivalent() {
|
||||
for options in ["rw,hidepid=2,subset=pid", "rw,hidepid=invisible,subset=pid"] {
|
||||
assert!(verify_proc_options(options).is_ok());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn weaker_missing_or_ambiguous_proc_options_are_rejected() {
|
||||
for options in [
|
||||
"rw,subset=pid",
|
||||
"rw,hidepid=invisible",
|
||||
"rw,hidepid=off,subset=pid",
|
||||
"rw,hidepid=noaccess,subset=pid",
|
||||
"rw,hidepid=ptraceable,subset=pid",
|
||||
"rw,hidepid=2,hidepid=off,subset=pid",
|
||||
"rw,hidepid=2,subset=pid,subset=all",
|
||||
] {
|
||||
assert!(verify_proc_options(options).is_err(), "{options}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,10 +208,9 @@ impl RootLease {
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
if !rustix::fs::fstatvfs(&root)?
|
||||
.f_flag
|
||||
.contains(StatVfsMountFlags::RDONLY)
|
||||
{
|
||||
if !rustix::fs::fstatvfs(&root)?.f_flag.contains(
|
||||
StatVfsMountFlags::RDONLY | StatVfsMountFlags::NOSUID | StatVfsMountFlags::NODEV,
|
||||
) {
|
||||
return Err(invalid("image root is not read-only"));
|
||||
}
|
||||
for (name, bytes, tmp) in [
|
||||
@@ -240,6 +239,13 @@ impl RootLease {
|
||||
return Err(invalid("initialization directory FD leaked"));
|
||||
}
|
||||
}
|
||||
let cwd = std::fs::metadata(format!("/proc/{pid}/cwd"))?;
|
||||
let work = std::fs::metadata(format!("/proc/{pid}/root/work"))?;
|
||||
if (cwd.dev(), cwd.ino()) != (actual.dev(), actual.ino())
|
||||
&& (cwd.dev(), cwd.ino()) != (work.dev(), work.ino())
|
||||
{
|
||||
return Err(invalid("initialization cwd is outside private root/work"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn cleanup(&mut self) -> io::Result<()> {
|
||||
|
||||
@@ -77,3 +77,38 @@ pub fn ensure_single_threaded() -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fresh exec roles reject unowned inherited descriptors before creating any
|
||||
/// channel duplicates. Closing unknown raw FDs would risk Rust ownership; fail
|
||||
/// closed instead. The single-threaded scan owns and excludes exactly its FD.
|
||||
pub(crate) fn ensure_inherited_descriptors() -> io::Result<()> {
|
||||
use rustix::fd::AsRawFd;
|
||||
use rustix::fs::{Dir, Mode, OFlags};
|
||||
ensure_single_threaded()?;
|
||||
let fd = rustix::fs::open(
|
||||
"/proc/self/fd",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let mut directory = Dir::new(fd)?;
|
||||
let scanner = directory.fd()?.as_raw_fd();
|
||||
let mut count = 0;
|
||||
while let Some(entry) = directory.read() {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_bytes();
|
||||
if name == b"." || name == b".." {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let fd = std::str::from_utf8(name)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
if count > 128 || !fd.is_some_and(|fd| (0..=2).contains(&fd) || fd == scanner) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"unexpected inherited descriptor",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub mod entry;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod namespaces;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod proc_dev;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod protocol;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod root;
|
||||
|
||||
@@ -39,6 +39,7 @@ fn inherited_channel(parent_pid: u32) -> io::Result<ControlChannel> {
|
||||
/// unshare; changing PID namespaces changes SO_PEERCRED's PID translation.
|
||||
pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()> {
|
||||
super::entry::ensure_single_threaded()?;
|
||||
super::entry::ensure_inherited_descriptors()?;
|
||||
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
|
||||
if expected_parent <= 1
|
||||
|| rustix::process::getppid().map(|pid| pid.as_raw_pid() as u32) != Some(expected_parent)
|
||||
@@ -53,7 +54,7 @@ pub fn bootstrap(expected_parent: u32, initialize_root: bool) -> io::Result<()>
|
||||
return Ok(());
|
||||
}
|
||||
NamespaceCommand::Initialize {} => {}
|
||||
NamespaceCommand::PrepareRoot { .. } => {
|
||||
NamespaceCommand::PrepareRoot { .. } | NamespaceCommand::PrepareProcDev {} => {
|
||||
return Err(denied("root command before namespaces"));
|
||||
}
|
||||
}
|
||||
@@ -130,6 +131,7 @@ fn status_value(status: &str, name: &str) -> io::Result<u32> {
|
||||
/// in the new PID namespace. Later root setup must replace this inherited proc.
|
||||
pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::Result<()> {
|
||||
super::entry::ensure_single_threaded()?;
|
||||
super::entry::ensure_inherited_descriptors()?;
|
||||
if rustix::process::getpid().as_raw_pid() != 1 || rustix::process::getppid().is_some() {
|
||||
return Err(denied("not the namespace init"));
|
||||
}
|
||||
@@ -171,7 +173,20 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::
|
||||
return Err(error);
|
||||
}
|
||||
channel.send(&NamespaceEvent::RootInitialized {})?;
|
||||
let _ = channel.receive::<NamespaceCommand>()?;
|
||||
match channel.receive::<NamespaceCommand>()? {
|
||||
NamespaceCommand::PrepareProcDev {} => {
|
||||
if let Err(error) = crate::isolation::proc_dev::install() {
|
||||
channel.send(&NamespaceEvent::Failed {
|
||||
errno: error.raw_os_error().and_then(|n| u16::try_from(n).ok()),
|
||||
})?;
|
||||
return Err(error);
|
||||
}
|
||||
channel.send(&NamespaceEvent::ProcDevInitialized {})?;
|
||||
let _ = channel.receive::<NamespaceCommand>()?;
|
||||
}
|
||||
NamespaceCommand::Cancel {} => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
NamespaceCommand::Cancel {} => return Ok(()),
|
||||
_ => {}
|
||||
@@ -186,6 +201,8 @@ pub struct NamespacedTarget {
|
||||
control: ControlChannel,
|
||||
init_pidfd: OwnedFd,
|
||||
allow_root: bool,
|
||||
root_ready: bool,
|
||||
proc_dev_ready: bool,
|
||||
init_pid: u32,
|
||||
ids: NamespaceIds,
|
||||
}
|
||||
@@ -276,6 +293,8 @@ impl NamespacedTarget {
|
||||
control,
|
||||
init_pidfd,
|
||||
allow_root,
|
||||
root_ready: false,
|
||||
proc_dev_ready: false,
|
||||
}),
|
||||
Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))),
|
||||
}
|
||||
@@ -285,18 +304,43 @@ impl NamespacedTarget {
|
||||
&mut self,
|
||||
budget: crate::isolation::root::RootBudget,
|
||||
) -> std::io::Result<()> {
|
||||
if !self.allow_root {
|
||||
if !self.allow_root || self.root_ready {
|
||||
return Err(denied("root descriptors not inherited"));
|
||||
}
|
||||
self.control.send(&NamespaceCommand::PrepareRoot {
|
||||
work_bytes: budget.work_bytes,
|
||||
tmp_bytes: budget.tmp_bytes,
|
||||
})?;
|
||||
self.initialize_filesystem(
|
||||
NamespaceCommand::PrepareRoot {
|
||||
work_bytes: budget.work_bytes,
|
||||
tmp_bytes: budget.tmp_bytes,
|
||||
},
|
||||
NamespaceEvent::RootInitialized {},
|
||||
)?;
|
||||
self.root_ready = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> {
|
||||
if !self.root_ready || self.proc_dev_ready {
|
||||
return Err(denied("proc/dev initialization order"));
|
||||
}
|
||||
self.initialize_filesystem(
|
||||
NamespaceCommand::PrepareProcDev {},
|
||||
NamespaceEvent::ProcDevInitialized {},
|
||||
)?;
|
||||
self.proc_dev_ready = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_filesystem(
|
||||
&mut self,
|
||||
command: NamespaceCommand,
|
||||
expected: NamespaceEvent,
|
||||
) -> io::Result<()> {
|
||||
self.control.send(&command)?;
|
||||
self.prepared
|
||||
.cgroup
|
||||
.begin_root_initialization(&[self.prepared.pid(), self.init_pid], &self.init_pidfd)?;
|
||||
match self.control.receive::<NamespaceEvent>()? {
|
||||
NamespaceEvent::RootInitialized {} => {}
|
||||
event if event == expected => {}
|
||||
NamespaceEvent::Failed { errno: Some(errno) } => {
|
||||
return Err(io::Error::from_raw_os_error(i32::from(errno)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! A separately gated restricted-filesystem stage, with no user execution API.
|
||||
use super::blocked::InitializationFailure;
|
||||
use super::root::RootedTarget;
|
||||
use crate::runtime::ValidatedRuntimeConfig;
|
||||
use crate::spec::ValidatedSpec;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct RestrictedRootTarget {
|
||||
root: RootedTarget,
|
||||
}
|
||||
|
||||
impl RestrictedRootTarget {
|
||||
pub fn prepare(
|
||||
config: &ValidatedRuntimeConfig,
|
||||
spec: &ValidatedSpec,
|
||||
executable: &Path,
|
||||
) -> Result<Self, InitializationFailure> {
|
||||
let mut root = RootedTarget::prepare(config, spec, executable)?;
|
||||
if let Err(error) = root.initialize_proc_dev() {
|
||||
return Err(root.refuse_root_error(&error));
|
||||
}
|
||||
Ok(Self { root })
|
||||
}
|
||||
pub fn root(&self) -> &RootedTarget {
|
||||
&self.root
|
||||
}
|
||||
/// Input binding, identity/capabilities/seccomp and supervision are absent.
|
||||
pub fn refuse_remaining_isolation(self) -> InitializationFailure {
|
||||
self.root.refuse_remaining_isolation()
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ pub enum ChildMessage {
|
||||
pub enum NamespaceCommand {
|
||||
Initialize {},
|
||||
PrepareRoot { work_bytes: u64, tmp_bytes: u64 },
|
||||
PrepareProcDev {},
|
||||
Cancel {},
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ pub enum NamespaceCommand {
|
||||
pub enum NamespaceEvent {
|
||||
Initialized { init_pid: u32 },
|
||||
RootInitialized {},
|
||||
ProcDevInitialized {},
|
||||
Failed { errno: Option<u16> },
|
||||
Cancelled {},
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ impl RootedTarget {
|
||||
if let Err(error) = (|| {
|
||||
target.initialize_root(lease.budget)?;
|
||||
lease.verify_root(target.init_pid())?;
|
||||
lease.verify_root(target.bootstrap_pid())?;
|
||||
Ok::<_, io::Error>(())
|
||||
})() {
|
||||
let mut failure = target
|
||||
@@ -95,6 +96,22 @@ impl RootedTarget {
|
||||
self.lease.budget
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_proc_dev(&mut self) -> io::Result<()> {
|
||||
self.target.initialize_proc_dev()?;
|
||||
self.lease.verify_root(self.init_pid())?;
|
||||
self.lease.verify_root(self.bootstrap_pid())?;
|
||||
crate::isolation::proc_dev::verify(self.init_pid())
|
||||
}
|
||||
|
||||
pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure {
|
||||
let mut failure = self
|
||||
.target
|
||||
.prepared
|
||||
.refuse_at(ExecutionStage::RootFilesystem, Some(error));
|
||||
attach_cleanup(&mut failure, self.lease.cleanup().err());
|
||||
failure
|
||||
}
|
||||
|
||||
/// Restricted proc/dev, identity, capabilities and seccomp are still absent.
|
||||
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
|
||||
let mut failure = self
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* VM-only kernel failure injector. It execs one fixed trusted test case. */
|
||||
#define _GNU_SOURCE
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <errno.h>
|
||||
#include <stddef.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
int main(void) {
|
||||
const char *vm=getenv("KF_SANDBOX_INDEPENDENT_VM");
|
||||
if(!vm||vm[0]!='1'||vm[1]||access("/kf/INDEPENDENT_VM",R_OK))return 2;
|
||||
struct sock_filter filter[]={
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,arch)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,AUDIT_ARCH_X86_64,1,0),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_KILL_PROCESS),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,nr)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,__NR_mknodat,0,1),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ERRNO|EPERM),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ALLOW)
|
||||
};
|
||||
struct sock_fprog program={.len=sizeof filter/sizeof *filter,.filter=filter};
|
||||
if(prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0)||prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&program))return 3;
|
||||
setenv("KF_SANDBOX_DENY_MKNOD","1",1);
|
||||
execl("/kf/bin/proc-dev-vm-tests","proc-dev-vm-tests","--ignored","--test-threads=1","--exact","kernel_mknod_failure_rolls_back_proc_dev_and_root","--nocapture",(char*)0);
|
||||
return 4;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Fixed bounded filesystem probe, compiled/run only in the capped test VM. */
|
||||
#define _GNU_SOURCE
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
static int absent(const char *p) {
|
||||
struct stat st;
|
||||
return stat(p,&st)<0 && (errno==ENOENT || errno==EACCES);
|
||||
}
|
||||
static int probe(int mnt,int pidns,int root,const char *peer) {
|
||||
alarm(8);
|
||||
if(fchdir(root)||chroot(".")||chdir("/"))return 10;
|
||||
close(mnt);close(pidns);close(root);
|
||||
int created=open("/dev/new-file",O_CREAT|O_WRONLY|O_CLOEXEC,0600);
|
||||
if(created>=0||errno!=EROFS)return 24;
|
||||
/* Test-only identity, not a production identity implementation. */
|
||||
if(setgroups(0,NULL)||setresgid(100000,100000,100000)||setresuid(100000,100000,100000))return 11;
|
||||
if(getpid()!=2 || !absent("/proc/1/root") || !absent("/proc/1/fd"))return 12;
|
||||
DIR *d=opendir("/proc");if(!d)return 13;struct dirent *e;
|
||||
while((e=readdir(d)))if(e->d_name[0]>='0'&&e->d_name[0]<='9'&&strcmp(e->d_name,"2"))return 14;
|
||||
closedir(d);
|
||||
const char *global[]={"/proc/sys","/proc/sysrq-trigger","/proc/kcore","/proc/keys","/proc/cpuinfo","/proc/meminfo"};
|
||||
for(unsigned i=0;i<sizeof global/sizeof *global;i++)if(!absent(global[i]))return 15;
|
||||
char path[128];snprintf(path,sizeof path,"/proc/%s",peer);if(!absent(path))return 16;
|
||||
int fd=open("/proc/self/status",O_RDONLY|O_CLOEXEC);char buf[1024];
|
||||
if(fd<0 || read(fd,buf,sizeof buf)<=0)return 17;
|
||||
close(fd);
|
||||
fd=open("/dev/null",O_WRONLY|O_CLOEXEC);if(fd<0||write(fd,"discard",7)!=7)return 18;close(fd);
|
||||
unsigned char zero[16]={1};fd=open("/dev/zero",O_RDONLY|O_CLOEXEC);
|
||||
if(fd<0||read(fd,zero,sizeof zero)!=(ssize_t)sizeof zero)return 19;
|
||||
close(fd);for(unsigned i=0;i<sizeof zero;i++)if(zero[i])return 20;
|
||||
fd=open("/dev/urandom",O_RDONLY|O_CLOEXEC);if(fd<0||read(fd,zero,sizeof zero)!=(ssize_t)sizeof zero)return 21;close(fd);
|
||||
fd=open("/dev/random",O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0)return 22;close(fd);
|
||||
if(!absent("/dev/mem")||!absent("/dev/sda")||!absent("/dev/tty")||!absent("/sys")||!absent("/kf")||!absent("/var/lib/kf-judge"))return 23;
|
||||
fd=open("/image-sentinel",O_WRONLY|O_TRUNC|O_CLOEXEC);if(fd>=0||errno!=EROFS)return 25;
|
||||
if(!absent("/proc/self/root/../../kf")||!absent("/proc/self/cwd/../../sys")||!absent("/escape-image/outside-sentinel"))return 26;
|
||||
d=opendir("/proc/self/fd");if(!d)return 27;int scanner=dirfd(d);
|
||||
while((e=readdir(d))) {
|
||||
if(e->d_name[0]=='.'||atoi(e->d_name)==scanner)continue;
|
||||
snprintf(path,sizeof path,"/proc/self/fd/%d",atoi(e->d_name));
|
||||
struct stat st;if(stat(path,&st)||S_ISDIR(st.st_mode))return 28;
|
||||
}
|
||||
closedir(d);return 0;
|
||||
}
|
||||
int main(int argc,char **argv) {
|
||||
alarm(15);if(argc!=3)return 2;
|
||||
for(int i=1;i<3;i++)for(char *p=argv[i];*p;p++)if(*p<'0'||*p>'9')return 2;
|
||||
char path[128];snprintf(path,sizeof path,"/proc/%s/ns/mnt",argv[1]);int mnt=open(path,O_RDONLY|O_CLOEXEC);
|
||||
snprintf(path,sizeof path,"/proc/%s/ns/pid",argv[1]);int pidns=open(path,O_RDONLY|O_CLOEXEC);
|
||||
snprintf(path,sizeof path,"/proc/%s/root",argv[1]);int root=open(path,O_RDONLY|O_DIRECTORY|O_CLOEXEC);
|
||||
if(mnt<0||pidns<0||root<0||setns(mnt,CLONE_NEWNS)||setns(pidns,CLONE_NEWPID))return 3;
|
||||
pid_t child=fork();if(child==0)_exit(probe(mnt,pidns,root,argv[2]));
|
||||
close(mnt);close(pidns);close(root);int status=0;
|
||||
if(child<0||waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status)){fprintf(stderr,"proc/dev probe status=%d\n",status);return 4;}
|
||||
puts("KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1");return 0;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
//! Real restricted proc/dev verification, only in an independently capped VM.
|
||||
mod support;
|
||||
use kf_sandbox::launcher::proc_dev::RestrictedRootTarget;
|
||||
use kf_sandbox::outcome::ExecutionStage;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use support::{Root, binary, children, spec};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
fn restricted_proc_devices_and_root_boundaries_are_real() {
|
||||
let root = Root::new();
|
||||
let before = fs::read_to_string("/proc/self/mountinfo").unwrap();
|
||||
let one =
|
||||
RestrictedRootTarget::prepare(&root.validated(), &spec("proc-one", 64 << 20, 8), &binary())
|
||||
.unwrap();
|
||||
let two =
|
||||
RestrictedRootTarget::prepare(&root.validated(), &spec("proc-two", 64 << 20, 8), &binary())
|
||||
.unwrap();
|
||||
let a = format!("/proc/{}/root", one.root().init_pid());
|
||||
let b = format!("/proc/{}/root", two.root().init_pid());
|
||||
fs::write(format!("{b}/work/peer-only"), "isolated peer").unwrap();
|
||||
assert!(!Path::new(&format!("{a}/work/peer-only")).exists());
|
||||
let output = Command::new("/kf/bin/proc-dev-probe")
|
||||
.arg(one.root().init_pid().to_string())
|
||||
.arg(two.root().init_pid().to_string())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"fixed proc/dev probe: {:?} {:?}",
|
||||
output.stdout,
|
||||
output.stderr
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap().trim(),
|
||||
"KF_PROC_DEV_PROBE pid_subset=1 hidepid=1 devices=1 old_root_absent=1 readonly=1"
|
||||
);
|
||||
for target in [&one, &two] {
|
||||
let info =
|
||||
fs::read_to_string(format!("/proc/{}/mountinfo", target.root().init_pid())).unwrap();
|
||||
assert_eq!(
|
||||
info.lines().count(),
|
||||
5,
|
||||
"only root/work/tmp/proc/dev: {info}"
|
||||
);
|
||||
assert!(!info.contains(" shared:") && !info.contains(" master:"));
|
||||
for pid in [target.root().init_pid(), target.root().bootstrap_pid()] {
|
||||
for fd in fs::read_dir(format!("/proc/{pid}/fd")).unwrap() {
|
||||
assert!(!fs::metadata(fd.unwrap().path()).unwrap().is_dir());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), before);
|
||||
for (target, id) in [(one, "proc-one"), (two, "proc-two")] {
|
||||
let group = root.path.join(target.root().cgroup().name());
|
||||
let error = target.refuse_remaining_isolation();
|
||||
assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem);
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert!(!group.exists());
|
||||
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
|
||||
}
|
||||
println!(
|
||||
"KF_PROC_DEV_PROOF fresh_pid_proc=1 readonly=1 fixed_devices=1 init_and_bootstrap_no_directory_fds=1 peer_isolated=1 host_mounts_unchanged=1 refusal=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM with externally verified whole-VM caps"]
|
||||
fn unexpected_inherited_file_and_directory_fds_refuse_initialization() {
|
||||
let root = Root::new();
|
||||
let before = children();
|
||||
for (path, id) in [
|
||||
("/image-outside-sentinel", "proc-file-fd"),
|
||||
("/var/lib/kf-judge/tasks", "proc-dir-fd"),
|
||||
] {
|
||||
let leak = fs::File::open(path).unwrap();
|
||||
rustix::io::fcntl_setfd(&leak, rustix::io::FdFlags::empty()).unwrap();
|
||||
let Err(error) =
|
||||
RestrictedRootTarget::prepare(&root.validated(), &spec(id, 64 << 20, 8), &binary())
|
||||
else {
|
||||
panic!("unexpected inherited FD must fail closed");
|
||||
};
|
||||
assert_eq!(error.failure.stage, ExecutionStage::Preparation);
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
|
||||
assert!(!root.path.join(format!("kf-{id}")).exists());
|
||||
drop(leak);
|
||||
}
|
||||
assert_eq!(children(), before);
|
||||
println!(
|
||||
"KF_PROC_DEV_FD_PROOF unknown_file_and_directory_rejected=1 no_namespace_started=1 task_removed=1 child_reaped=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires independent VM and fixed mknodat-denying test launcher"]
|
||||
fn kernel_mknod_failure_rolls_back_proc_dev_and_root() {
|
||||
let root = Root::new();
|
||||
assert_eq!(std::env::var("KF_SANDBOX_DENY_MKNOD").as_deref(), Ok("1"));
|
||||
let before = children();
|
||||
let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap();
|
||||
let Err(error) = RestrictedRootTarget::prepare(
|
||||
&root.validated(),
|
||||
&spec("proc-mknod-fail", 64 << 20, 8),
|
||||
&binary(),
|
||||
) else {
|
||||
panic!("kernel mknodat denial must fail root stage");
|
||||
};
|
||||
assert_eq!(error.failure.stage, ExecutionStage::RootFilesystem);
|
||||
assert_eq!(error.failure.errno, Some(1));
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert_eq!(children(), before);
|
||||
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-proc-mknod-fail").exists());
|
||||
assert!(!root.path.join("kf-proc-mknod-fail").exists());
|
||||
assert_eq!(fs::read_to_string("/proc/self/mountinfo").unwrap(), mounts);
|
||||
println!(
|
||||
"KF_PROC_DEV_FAILURE_PROOF errno=1 partial_proc_and_dev_cleanup=1 task_root_removed=1 bootstrap_reaped=1 host_mounts_unchanged=1"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user