Add a static trusted sandbox init

This commit is contained in:
ms2k 2026-07-19 22:38:37 +09:00
commit 0f6825c3b9
8 changed files with 502 additions and 1 deletions

9
Cargo.lock generated
View file

@ -1724,6 +1724,7 @@ dependencies = [
"lazaret-prepared-lower", "lazaret-prepared-lower",
"lazaret-runtime-supervisor", "lazaret-runtime-supervisor",
"lazaret-sandbox-contract", "lazaret-sandbox-contract",
"lazaret-sandbox-init",
"lazaret-state-anchor", "lazaret-state-anchor",
"lazaret-storage-catalog", "lazaret-storage-catalog",
"rcgen", "rcgen",
@ -1810,6 +1811,14 @@ dependencies = [
"lazaret-core", "lazaret-core",
] ]
[[package]]
name = "lazaret-sandbox-init"
version = "0.0.0"
dependencies = [
"lazaret-linux",
"rustix",
]
[[package]] [[package]]
name = "lazaret-state-anchor" name = "lazaret-state-anchor"
version = "0.0.0" version = "0.0.0"

View file

@ -10,6 +10,7 @@ members = [
"crates/lazaret-linux", "crates/lazaret-linux",
"crates/lazaret-prepared-lower", "crates/lazaret-prepared-lower",
"crates/lazaret-runtime-supervisor", "crates/lazaret-runtime-supervisor",
"crates/lazaret-sandbox-init",
"crates/lazaret-sandbox-contract", "crates/lazaret-sandbox-contract",
"crates/lazaret-state-anchor", "crates/lazaret-state-anchor",
"crates/lazaret-storage-catalog", "crates/lazaret-storage-catalog",

View file

@ -29,6 +29,7 @@ lazaret-ignored-workspace = { version = "=0.0.0", path = "../lazaret-ignored-wor
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" } lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-prepared-lower = { version = "=0.0.0", path = "../lazaret-prepared-lower" } lazaret-prepared-lower = { version = "=0.0.0", path = "../lazaret-prepared-lower" }
lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" } lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" }
lazaret-sandbox-init = { version = "=0.0.0", path = "../lazaret-sandbox-init" }
lazaret-sandbox-contract = { version = "=0.0.0", path = "../lazaret-sandbox-contract" } lazaret-sandbox-contract = { version = "=0.0.0", path = "../lazaret-sandbox-contract" }
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" } lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" } lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" }

View file

@ -4,6 +4,7 @@ use lazaret_core::configuration::{ConfigurationError, SandboxLayoutV1};
pub const WORKSPACE_DESTINATION: &str = "/workspace"; pub const WORKSPACE_DESTINATION: &str = "/workspace";
pub const CONTROL_ROOT_DESTINATION: &str = "/run/lazaret"; pub const CONTROL_ROOT_DESTINATION: &str = "/run/lazaret";
pub const READINESS_DESTINATION: &str = "/run/lazaret/readiness";
pub const CREDENTIALS_DESTINATION: &str = "/run/lazaret/credentials"; pub const CREDENTIALS_DESTINATION: &str = "/run/lazaret/credentials";
pub const MAILBOX_REQUESTS_DESTINATION: &str = "/run/lazaret/mailbox/requests"; pub const MAILBOX_REQUESTS_DESTINATION: &str = "/run/lazaret/mailbox/requests";
pub const MAILBOX_RESULTS_DESTINATION: &str = "/run/lazaret/mailbox/results"; pub const MAILBOX_RESULTS_DESTINATION: &str = "/run/lazaret/mailbox/results";

View file

@ -0,0 +1,17 @@
[package]
name = "lazaret-sandbox-init"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
name = "lazaret-sandbox-init"
path = "src/main.rs"
[dependencies]
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
rustix.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,445 @@
use std::ffi::{OsStr, OsString};
use std::io::{self, Read, Write};
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::os::unix::ffi::OsStrExt as _;
use std::path::Path;
use std::time::{Duration, Instant};
use lazaret_linux::process::{TargetCommand, exec_target};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::net::netlink::SocketAddrNetlink;
use rustix::net::{AddressFamily, RecvFlags, SendFlags, SocketType, bind, recv, sendto, socket};
pub const PRIVATE_SANDBOX_INIT_ROLE: &str = "--lazaret-private-sandbox-init-v1";
pub const PRIVATE_SESSION_EXEC_ROLE: &str = "--lazaret-private-session-exec-v1";
pub const PRIVATE_SESSION_IDENTITY_ROLE: &str = "--lazaret-private-session-identity-v1";
#[must_use]
pub fn is_private_sandbox_init_role(arguments: &[OsString]) -> bool {
arguments.len() == 1 && arguments[0] == OsStr::new(PRIVATE_SANDBOX_INIT_ROLE)
}
#[must_use]
pub fn dispatch_private_sandbox_init(arguments: &[OsString]) -> Option<io::Result<()>> {
if is_private_sandbox_init_role(arguments) {
return Some(run_sandbox_init());
}
if arguments
.first()
.is_some_and(|argument| argument == PRIVATE_SESSION_EXEC_ROLE)
{
return Some(run_session_exec(&arguments[1..]));
}
if arguments
.first()
.is_some_and(|argument| argument == PRIVATE_SESSION_IDENTITY_ROLE)
{
return Some(report_session_identity(&arguments[1..]));
}
None
}
const READINESS_ROOT: &str = "/run/lazaret/readiness";
const CANARY_PORT: u16 = 39_091;
const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(30);
fn run_sandbox_init() -> io::Result<()> {
let generation = read_bounded(Path::new(READINESS_ROOT).join("generation"), 32)?;
let imported_network = wait_for_imported_network();
let route_mutation_refused = match route_mutation_refused() {
Ok(value) => value,
Err(error) => {
std::fs::write(
Path::new(READINESS_ROOT).join("mutation-error"),
error.to_string(),
)?;
false
}
};
let initial = [
imported_network,
imported_network,
authenticate_canary(Ipv4Addr::new(10, 137, 55, 2), &generation).is_ok(),
authenticate_canary(Ipv4Addr::new(10, 137, 55, 3), &generation).is_err(),
ipv6_absent().unwrap_or(false),
network_capabilities_empty().unwrap_or(false),
route_mutation_refused,
];
write_observation("initial", &initial)?;
wait_for_path("reprobe")?;
let post_cleanup = [
authenticate_canary(Ipv4Addr::new(10, 137, 55, 2), &generation).is_err(),
interface_present("tap0"),
ipv6_absent().unwrap_or(false),
];
write_observation("post-cleanup", &post_cleanup)?;
loop {
std::thread::park();
}
}
fn wait_for_imported_network() -> bool {
let deadline = Instant::now() + OBSERVATION_TIMEOUT;
loop {
if imported_canary_predicate() && interface_present("tap0") {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn authenticate_canary(address: Ipv4Addr, generation: &[u8]) -> io::Result<()> {
let mut challenge = generation.to_vec();
challenge.extend_from_slice(b"sandbox-import");
let mut stream = TcpStream::connect_timeout(
&SocketAddrV4::new(address, CANARY_PORT).into(),
Duration::from_secs(3),
)?;
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
stream.write_all(&challenge)?;
let mut response = vec![0; challenge.len()];
stream.read_exact(&mut response)?;
if response == challenge {
Ok(())
} else {
Err(io::Error::other("sandbox canary response differed"))
}
}
fn interface_present(name: &str) -> bool {
std::fs::read_to_string("/proc/net/dev").is_ok_and(|devices| {
devices.lines().skip(2).any(|line| {
line.split_once(':')
.is_some_and(|(observed, _)| observed.trim() == name)
})
})
}
fn imported_canary_predicate() -> bool {
let Ok(routes) = std::fs::read_to_string("/proc/net/route") else {
return false;
};
route_present(&routes, "canary0", "0237890A", "FFFFFFFF")
}
fn route_present(routes: &str, interface: &str, destination: &str, mask: &str) -> bool {
routes.lines().skip(1).any(|line| {
let columns = line.split_whitespace().collect::<Vec<_>>();
columns.len() >= 8
&& columns[0] == interface
&& columns[1] == destination
&& columns[7] == mask
})
}
fn ipv6_absent() -> io::Result<bool> {
let addresses = read_optional_proc("/proc/net/if_inet6")?;
let routes = read_optional_proc("/proc/net/ipv6_route")?;
Ok(addresses.lines().all(|line| {
let columns = line.split_whitespace().collect::<Vec<_>>();
columns.len() == 6 && columns[5] == "lo" && columns[0] == "00000000000000000000000000000001"
}) && routes.lines().all(|line| {
let columns = line.split_whitespace().collect::<Vec<_>>();
columns.len() == 10
&& columns[9] == "lo"
&& ((columns[0] == "00000000000000000000000000000000" && columns[1] == "00")
|| (columns[0] == "00000000000000000000000000000001" && columns[1] == "80"))
}))
}
fn read_optional_proc(path: &str) -> io::Result<String> {
match std::fs::read_to_string(path) {
Ok(value) => Ok(value),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error),
}
}
fn network_capabilities_empty() -> io::Result<bool> {
let status = std::fs::read_to_string("/proc/self/status")?;
let names = ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"];
Ok(names.into_iter().all(|name| {
status
.lines()
.find_map(|line| line.strip_prefix(name))
.is_some_and(|value| value.trim().bytes().all(|byte| byte == b'0'))
}))
}
fn route_mutation_refused() -> io::Result<bool> {
let descriptor = socket(AddressFamily::NETLINK, SocketType::RAW, None)?;
bind(&descriptor, &SocketAddrNetlink::new(0, 0))?;
let request = route_add_request();
match sendto(
&descriptor,
&request,
SendFlags::empty(),
&SocketAddrNetlink::new(0, 0),
) {
Err(rustix::io::Errno::PERM | rustix::io::Errno::ACCESS) => return Ok(true),
Err(error) => return Err(error.into()),
Ok(length) if length != request.len() => {
return Err(io::Error::other("sandbox route mutation send was partial"));
}
Ok(_) => {}
}
let mut poll_descriptor = [PollFd::new(&descriptor, PollFlags::IN)];
poll(
&mut poll_descriptor,
Some(&Timespec {
tv_sec: 3,
tv_nsec: 0,
}),
)?;
if !poll_descriptor[0].revents().contains(PollFlags::IN) {
return Err(io::Error::other(
"sandbox route mutation returned no acknowledgment",
));
}
let mut response = [0_u8; 128];
let (length, _) = recv(&descriptor, &mut response, RecvFlags::empty())?;
if length < 20 {
return Err(io::Error::other(
"sandbox route mutation acknowledgment is truncated",
));
}
let error = i32::from_ne_bytes(response[16..20].try_into().unwrap());
if error == -1 {
Ok(true)
} else {
Err(io::Error::other(format!(
"sandbox route mutation returned netlink error {error}"
)))
}
}
fn route_add_request() -> Vec<u8> {
let mut bytes = Vec::with_capacity(36);
bytes.extend_from_slice(&36_u32.to_ne_bytes());
bytes.extend_from_slice(&24_u16.to_ne_bytes());
bytes.extend_from_slice(&0x605_u16.to_ne_bytes());
bytes.extend_from_slice(&1_u32.to_ne_bytes());
bytes.extend_from_slice(&0_u32.to_ne_bytes());
bytes.extend_from_slice(&[2, 32, 0, 0, 254, 4, 0, 1]);
bytes.extend_from_slice(&0_u32.to_ne_bytes());
bytes.extend_from_slice(&8_u16.to_ne_bytes());
bytes.extend_from_slice(&1_u16.to_ne_bytes());
bytes.extend_from_slice(&[203, 0, 113, 254]);
bytes
}
fn write_observation(name: &str, predicates: &[bool]) -> io::Result<()> {
let root = Path::new(READINESS_ROOT);
let temporary = root.join(format!("{name}.tmp"));
let destination = root.join(name);
let bytes = predicates
.iter()
.map(|value| if *value { b'1' } else { b'0' })
.collect::<Vec<_>>();
std::fs::write(&temporary, bytes)?;
std::fs::rename(temporary, destination)
}
fn wait_for_path(name: &str) -> io::Result<()> {
let deadline = Instant::now() + OBSERVATION_TIMEOUT;
let path = Path::new(READINESS_ROOT).join(name);
while !path.exists() {
if Instant::now() >= deadline {
return Err(io::Error::other("sandbox readiness command timed out"));
}
std::thread::sleep(Duration::from_millis(5));
}
Ok(())
}
fn read_bounded(path: impl AsRef<Path>, maximum: usize) -> io::Result<Vec<u8>> {
let bytes = std::fs::read(path)?;
if bytes.is_empty() || bytes.len() > maximum {
return Err(io::Error::other("sandbox readiness input has invalid size"));
}
Ok(bytes)
}
fn run_session_exec(arguments: &[OsString]) -> io::Result<()> {
let (separator, argv) = arguments
.split_first()
.filter(|(separator, argv)| {
separator == &OsStr::new("--")
&& !argv.is_empty()
&& !argv[0].is_empty()
&& argv
.iter()
.all(|argument| !argument.as_bytes().contains(&0))
})
.ok_or_else(|| io::Error::other("session exec requires -- and a nonempty argv"))?;
let _ = separator;
establish_process_group_leader()?;
let target = argv[1..]
.iter()
.cloned()
.fold(TargetCommand::new(argv[0].clone()), TargetCommand::argument);
Err(exec_target(target))
}
fn establish_process_group_leader() -> io::Result<()> {
let pid = rustix::process::getpid();
if rustix::process::getpgrp() != pid {
rustix::process::setsid()?;
}
require_process_group_leader(pid, rustix::process::getpgrp())
}
fn require_process_group_leader(
pid: rustix::process::Pid,
process_group: rustix::process::Pid,
) -> io::Result<()> {
if pid != process_group {
return Err(io::Error::other(
"session exec is not its process-group leader",
));
}
Ok(())
}
fn report_session_identity(arguments: &[OsString]) -> io::Result<()> {
match arguments {
[pid] => report_process_identity(parse_positive_pid(pid)?),
[mode, group] if mode == "--group" => report_group_presence(parse_positive_pid(group)?),
_ => Err(io::Error::other("session identity query is invalid")),
}
}
fn parse_positive_pid(value: &OsStr) -> io::Result<u32> {
value
.to_str()
.and_then(|value| value.parse::<u32>().ok())
.filter(|value| *value > 0)
.ok_or_else(|| io::Error::other("session identity number is invalid"))
}
fn report_process_identity(pid: u32) -> io::Result<()> {
let Some((observed_pid, start_ticks, group)) = read_process_identity(pid)? else {
println!("absent");
return Ok(());
};
println!("{observed_pid} {start_ticks} {group}");
Ok(())
}
fn report_group_presence(group: u32) -> io::Result<()> {
println!(
"{}",
if group_is_present_except(group, std::process::id())? {
"present"
} else {
"absent"
}
);
Ok(())
}
fn group_is_present_except(group: u32, observer: u32) -> io::Result<bool> {
for entry in std::fs::read_dir("/proc")? {
let entry = entry?;
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|value| value.parse::<u32>().ok())
else {
continue;
};
if pid == observer {
continue;
}
if read_process_identity(pid)?.is_some_and(|(_, _, observed_group)| observed_group == group)
{
return Ok(true);
}
}
Ok(false)
}
fn read_process_identity(pid: u32) -> io::Result<Option<(u32, u64, u32)>> {
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let close = stat
.rfind(')')
.ok_or_else(|| io::Error::other("guest process stat is malformed"))?;
let observed_pid = stat[..close]
.split_once(' ')
.and_then(|(pid, _)| pid.parse::<u32>().ok())
.ok_or_else(|| io::Error::other("guest process stat PID is malformed"))?;
let fields = stat[close + 1..].split_whitespace().collect::<Vec<_>>();
let group = fields
.get(2)
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(|| io::Error::other("guest process stat group is malformed"))?;
let start_ticks = fields
.get(19)
.and_then(|value| value.parse::<u64>().ok())
.ok_or_else(|| io::Error::other("guest process stat start is malformed"))?;
Ok(Some((observed_pid, start_ticks, group)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_role_dispatch_is_exact_and_closed() {
assert!(is_private_sandbox_init_role(&[OsString::from(
PRIVATE_SANDBOX_INIT_ROLE
)]));
assert!(!is_private_sandbox_init_role(&[]));
assert!(!is_private_sandbox_init_role(&[
OsString::from(PRIVATE_SANDBOX_INIT_ROLE),
OsString::from("extra"),
]));
assert!(!is_private_sandbox_init_role(&[OsString::from(
"--private-sandbox-init"
)]));
}
#[test]
fn private_session_roles_are_shape_separated() {
assert!(dispatch_private_sandbox_init(&[]).is_none());
assert!(
dispatch_private_sandbox_init(&[
OsString::from(PRIVATE_SESSION_EXEC_ROLE),
OsString::from("missing-separator"),
])
.unwrap()
.is_err()
);
assert!(
dispatch_private_sandbox_init(&[
OsString::from(PRIVATE_SESSION_IDENTITY_ROLE),
OsString::from("not-a-pid"),
])
.unwrap()
.is_err()
);
}
#[test]
fn process_group_leader_validation_is_fail_closed() {
let leader = rustix::process::Pid::from_raw(41).unwrap();
let other_group = rustix::process::Pid::from_raw(42).unwrap();
require_process_group_leader(leader, leader).unwrap();
assert!(require_process_group_leader(leader, other_group).is_err());
}
#[test]
fn group_presence_is_independent_of_a_designated_leader() {
let (_, _, group) = read_process_identity(std::process::id()).unwrap().unwrap();
assert!(group_is_present_except(group, u32::MAX).unwrap());
assert!(!group_is_present_except(u32::MAX, u32::MAX).unwrap());
}
}

View file

@ -0,0 +1,14 @@
fn main() {
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
match lazaret_sandbox_init::dispatch_private_sandbox_init(&arguments) {
Some(Ok(())) => {}
Some(Err(error)) => {
eprintln!("sandbox init failed: {error}");
std::process::exit(1);
}
None => {
eprintln!("sandbox init role is invalid");
std::process::exit(2);
}
}
}

View file

@ -18,7 +18,7 @@ elif [ "$#" -ne 0 ]; then
exit 2 exit 2
fi fi
for command in cargo install; do for command in cargo install readelf; do
command -v "$command" >/dev/null 2>&1 || { command -v "$command" >/dev/null 2>&1 || {
printf '%s\n' "install-to-host: $command is required" >&2 printf '%s\n' "install-to-host: $command is required" >&2
exit 1 exit 1
@ -34,6 +34,12 @@ cargo build \
--package lazaret-host-coordinator \ --package lazaret-host-coordinator \
--package lazaret-escalation-client \ --package lazaret-escalation-client \
--bins --bins
env RUSTFLAGS="-C relocation-model=static -C link-arg=-no-pie" cargo build \
--release \
--locked \
--target x86_64-unknown-linux-musl \
--package lazaret-sandbox-init \
--bin lazaret-sandbox-init
target_dir="${CARGO_TARGET_DIR:-target}" target_dir="${CARGO_TARGET_DIR:-target}"
if [[ "$target_dir" != /* ]]; then if [[ "$target_dir" != /* ]]; then
@ -44,5 +50,12 @@ install -d -m 755 "$install_dir"
for executable in lazaret lazaret-host-coordinator lazaret-escalation-client; do for executable in lazaret lazaret-host-coordinator lazaret-escalation-client; do
install -m 755 "$target_dir/release/$executable" "$install_dir/$executable" install -m 755 "$target_dir/release/$executable" "$install_dir/$executable"
done done
sandbox_init="$target_dir/x86_64-unknown-linux-musl/release/lazaret-sandbox-init"
if readelf -l "$sandbox_init" | grep -q 'Requesting program interpreter' ||
! readelf -d "$sandbox_init" 2>&1 | grep -q 'There is no dynamic section'; then
printf '%s\n' "install-to-host: sandbox init is dynamically linked" >&2
exit 1
fi
install -m 755 "$sandbox_init" "$install_dir/lazaret-sandbox-init"
printf '%s\n' "Installed Lazaret in $install_dir" printf '%s\n' "Installed Lazaret in $install_dir"