Add confined host escalation transport

This commit is contained in:
ms2k 2026-07-15 19:48:42 +09:00
commit 407b75b1ff

View file

@ -1,18 +1,299 @@
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::os::fd::AsFd;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use lazaret_core::identity::{DeviceId, InodeId, MountId, MountIdentity, NamespaceIdentity};
use rustix::fs::{
AtFlags, FlockOperation, MemfdFlags, SealFlags, StatxFlags, fcntl_add_seals, fcntl_get_seals,
flock, fstat, makedev, memfd_create, statx,
AtFlags, FlockOperation, MemfdFlags, Mode, OFlags, RawDir, RenameFlags, SealFlags, StatxFlags,
fchmod, fcntl_add_seals, fcntl_get_seals, flock, fstat, makedev, memfd_create, openat,
renameat_with, statat, statx, unlinkat,
};
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MailboxEntryIdentity {
device: u64,
inode: u64,
}
#[derive(Debug)]
pub struct RegularFileMailbox {
directory: File,
}
impl AsFd for RegularFileMailbox {
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
self.directory.as_fd()
}
}
impl RegularFileMailbox {
/// # Errors
/// Refuses entries that are not invoking-user-owned mode-0700 directories opened without links.
pub fn openat_private(
parent: impl AsFd,
name: &std::ffi::OsStr,
invoking_uid: u32,
) -> io::Result<Self> {
validate_mailbox_name(name)?;
let directory = File::from(openat(
parent.as_fd(),
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
Self::from_private_directory(directory, invoking_uid)
}
/// # Errors
/// Refuses paths that are not invoking-user-owned mode-0700 directories opened without links.
pub fn open_private(path: &Path, invoking_uid: u32) -> io::Result<Self> {
let directory = File::from(openat(
rustix::fs::CWD,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
Self::from_private_directory(directory, invoking_uid)
}
fn from_private_directory(directory: File, invoking_uid: u32) -> io::Result<Self> {
let metadata = directory.metadata()?;
if !metadata.file_type().is_dir()
|| metadata.uid() != invoking_uid
|| metadata.mode() & 0o7777 != 0o700
{
return Err(io::Error::other(
"mailbox directory is not invoking-user-owned mode 0700",
));
}
Ok(Self { directory })
}
/// # Errors
/// Refuses a path that is not a directory opened without following its final component.
pub fn open(path: &Path) -> io::Result<Self> {
let directory = File::from(openat(
rustix::fs::CWD,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
Ok(Self { directory })
}
/// Publishes one complete regular-file record using a no-replace atomic rename.
///
/// # Errors
/// Refuses invalid names, existing destinations, or incomplete durable publication.
pub fn publish(
&self,
name: &std::ffi::OsStr,
bytes: &[u8],
) -> io::Result<MailboxEntryIdentity> {
validate_mailbox_name(name)?;
let temporary_name = loop {
let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let candidate = std::ffi::OsString::from_vec(
format!(".lazaret-{}-{sequence}.tmp", std::process::id()).into_bytes(),
);
match openat(
self.directory.as_fd(),
&candidate,
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
) {
Ok(fd) => break (candidate, fd),
Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
};
let (temporary_name, fd) = temporary_name;
let mut file = File::from(fd);
let result = (|| {
fchmod(file.as_fd(), Mode::RUSR | Mode::WUSR)?;
file.write_all(bytes)?;
file.sync_all()?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file()
|| metadata.nlink() != 1
|| usize::try_from(metadata.len()).ok() != Some(bytes.len())
{
return Err(io::Error::other("mailbox temporary record changed"));
}
renameat_with(
self.directory.as_fd(),
&temporary_name,
self.directory.as_fd(),
name,
RenameFlags::NOREPLACE,
)?;
self.directory.sync_all()?;
Ok(MailboxEntryIdentity {
device: metadata.dev(),
inode: metadata.ino(),
})
})();
if result.is_err() {
let _ = unlinkat(self.directory.as_fd(), &temporary_name, AtFlags::empty());
}
result
}
/// Reads and removes one exact regular-file entry after bounding it before allocation.
///
/// # Errors
/// Refuses links, non-regular files, oversize records, replacement, growth, or short reads.
pub fn consume(&self, name: &std::ffi::OsStr, maximum: usize) -> io::Result<Vec<u8>> {
let (bytes, identity) = self.read(name, maximum)?;
self.remove_if_matches(name, identity)?;
Ok(bytes)
}
/// Reads one exact regular-file entry after bounding it before allocation.
///
/// # Errors
/// Refuses links, non-regular files, oversize records, replacement, growth, or short reads.
pub fn read(
&self,
name: &std::ffi::OsStr,
maximum: usize,
) -> io::Result<(Vec<u8>, MailboxEntryIdentity)> {
validate_mailbox_name(name)?;
let mut file = File::from(openat(
self.directory.as_fd(),
name,
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let before = file.metadata()?;
let length = usize::try_from(before.len())
.map_err(|_| io::Error::other("mailbox record length is out of range"))?;
if !before.file_type().is_file() || before.nlink() != 1 {
return Err(io::Error::other(
"mailbox entry is linked or is not a regular file",
));
}
if length > maximum {
return Err(io::Error::other("mailbox record exceeds the size limit"));
}
let mut bytes = Vec::with_capacity(length.saturating_add(1));
Read::by_ref(&mut file)
.take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
.read_to_end(&mut bytes)?;
let after = file.metadata()?;
if bytes.len() != length
|| after.dev() != before.dev()
|| after.ino() != before.ino()
|| after.len() != before.len()
|| after.nlink() != 1
|| !after.file_type().is_file()
{
return Err(io::Error::other("mailbox record changed while being read"));
}
Ok((
bytes,
MailboxEntryIdentity {
device: before.dev(),
inode: before.ino(),
},
))
}
/// Removes a host-published record only while the pathname retains its exact identity.
///
/// # Errors
/// Refuses a missing or replaced pathname.
pub fn remove_if_matches(
&self,
name: &std::ffi::OsStr,
expected: MailboxEntryIdentity,
) -> io::Result<()> {
validate_mailbox_name(name)?;
let quarantine = loop {
let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let candidate = std::ffi::OsString::from_vec(
format!(".lazaret-remove-{}-{sequence}.tmp", std::process::id()).into_bytes(),
);
match renameat_with(
self.directory.as_fd(),
name,
self.directory.as_fd(),
&candidate,
RenameFlags::NOREPLACE,
) {
Ok(()) => break candidate,
Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
};
let current = statat(
self.directory.as_fd(),
&quarantine,
AtFlags::SYMLINK_NOFOLLOW,
)?;
if current.st_dev != expected.device || current.st_ino != expected.inode {
let restore = renameat_with(
self.directory.as_fd(),
&quarantine,
self.directory.as_fd(),
name,
RenameFlags::NOREPLACE,
);
return match restore {
Ok(()) => Err(io::Error::other("mailbox entry identity mismatch")),
Err(error) => Err(io::Error::other(format!(
"mailbox entry identity mismatch and restore failed: {error}"
))),
};
}
unlinkat(self.directory.as_fd(), &quarantine, AtFlags::empty())?;
self.directory.sync_all()
}
/// # Errors
/// Returns an error when the retained directory cannot be enumerated.
pub fn names(&self) -> io::Result<Vec<std::ffi::OsString>> {
let fd: OwnedFd = openat(
self.directory.as_fd(),
".",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let mut buffer = vec![std::mem::MaybeUninit::<u8>::uninit(); 8192];
let mut directory = RawDir::new(fd, &mut buffer);
let mut names = Vec::new();
while let Some(entry) = directory.next() {
let entry = entry?;
let name = entry.file_name().to_bytes();
if name != b"." && name != b".." {
names.push(std::ffi::OsString::from_vec(name.to_vec()));
}
}
Ok(names)
}
}
fn validate_mailbox_name(name: &std::ffi::OsStr) -> io::Result<()> {
let bytes = name.as_bytes();
if bytes.is_empty()
|| bytes.len() > 255
|| bytes == b"."
|| bytes == b".."
|| bytes.contains(&0)
|| bytes.contains(&b'/')
{
Err(io::Error::other("invalid mailbox entry name"))
} else {
Ok(())
}
}
const REQUIRED_SEALS: SealFlags = SealFlags::SHRINK
.union(SealFlags::GROW)
.union(SealFlags::WRITE)
@ -159,12 +440,13 @@ pub fn require_mount_identity(path: &Path, expected: MountIdentity) -> io::Resul
mod tests {
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::os::unix::fs::symlink;
use rustix::fs::{MemfdFlags, memfd_create};
use super::{
atomic_replace, current_mount_identity, namespace_identity, require_mount_identity,
require_write_sealed, sealed_memfd, try_exclusive_flock,
RegularFileMailbox, atomic_replace, current_mount_identity, namespace_identity,
require_mount_identity, require_write_sealed, sealed_memfd, try_exclusive_flock,
};
fn test_directory(name: &str) -> std::path::PathBuf {
@ -265,4 +547,69 @@ mod tests {
assert!(require_mount_identity(&old, expected).is_ok());
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn mailbox_publication_is_atomic_and_no_replace() {
let directory = test_directory("mailbox-publish");
let mailbox = RegularFileMailbox::open(&directory).unwrap();
let name = std::ffi::OsStr::new("request.ready");
mailbox.publish(name, b"complete").unwrap();
assert!(mailbox.publish(name, b"replacement").is_err());
assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"complete");
assert!(
mailbox
.names()
.unwrap()
.iter()
.all(|entry| !entry.as_encoded_bytes().ends_with(b".tmp"))
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn mailbox_refuses_links_nonregular_and_oversize_records() {
let directory = test_directory("mailbox-hostile");
let mailbox = RegularFileMailbox::open(&directory).unwrap();
std::fs::write(directory.join("target"), b"target").unwrap();
symlink("target", directory.join("symlink.ready")).unwrap();
std::fs::hard_link(directory.join("target"), directory.join("hardlink.ready")).unwrap();
std::fs::create_dir(directory.join("directory.ready")).unwrap();
std::fs::write(directory.join("oversize.ready"), [0_u8; 17]).unwrap();
assert!(
mailbox
.consume(std::ffi::OsStr::new("symlink.ready"), 16)
.is_err()
);
assert!(
mailbox
.consume(std::ffi::OsStr::new("hardlink.ready"), 16)
.is_err()
);
assert!(
mailbox
.consume(std::ffi::OsStr::new("directory.ready"), 16)
.is_err()
);
assert!(
mailbox
.consume(std::ffi::OsStr::new("oversize.ready"), 16)
.is_err()
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn mailbox_removes_only_the_identity_it_read() {
let directory = test_directory("mailbox-replacement");
let mailbox = RegularFileMailbox::open(&directory).unwrap();
let name = std::ffi::OsStr::new("result.ready");
let identity = mailbox.publish(name, b"first").unwrap();
std::fs::rename(directory.join(name), directory.join("old")).unwrap();
std::fs::write(directory.join(name), b"foreign").unwrap();
assert!(mailbox.remove_if_matches(name, identity).is_err());
assert_eq!(std::fs::read(directory.join(name)).unwrap(), b"foreign");
std::fs::remove_dir_all(directory).unwrap();
}
}