Add a static trusted sandbox init
This commit is contained in:
parent
02fc29acb1
commit
0f6825c3b9
2 changed files with 502 additions and 1 deletions
445
crates/lazaret-sandbox-init/src/lib.rs
Normal file
445
crates/lazaret-sandbox-init/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
14
crates/lazaret-sandbox-init/src/main.rs
Normal file
14
crates/lazaret-sandbox-init/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue