diff --git a/bin/cluelesshd/Cargo.toml b/bin/cluelesshd/Cargo.toml index aa12397..84eede1 100644 --- a/bin/cluelesshd/Cargo.toml +++ b/bin/cluelesshd/Cargo.toml @@ -12,7 +12,7 @@ tokio = { version = "1.39.2", features = ["full"] } tracing.workspace = true eyre.workspace = true tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } -rustix = { version = "0.38.35", features = ["pty", "termios", "procfs", "process", "stdio", "net", "fs", "thread"] } +rustix = { version = "0.38.35", features = ["pty", "termios", "procfs", "process", "stdio", "net", "fs", "thread", "pipe"] } users = "0.11.0" futures = "0.3.30" thiserror = "1.0.63" diff --git a/bin/cluelesshd/src/connection.rs b/bin/cluelesshd/src/connection.rs index b66305c..9f27478 100644 --- a/bin/cluelesshd/src/connection.rs +++ b/bin/cluelesshd/src/connection.rs @@ -1,6 +1,5 @@ use std::{ os::fd::{FromRawFd, OwnedFd}, - path::Path, pin::Pin, sync::Arc, }; @@ -18,20 +17,15 @@ use cluelessh_tokio::{ Channel, }; use eyre::{bail, ensure, Result, WrapErr}; -use rustix::{ - fs::UnmountFlags, - process::WaitOptions, - thread::{Pid, UnshareFlags}, -}; use tokio::{ fs::File, io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::TcpStream, sync::mpsc, }; -use tracing::{debug, error, info, info_span, warn, Instrument}; +use tracing::{debug, error, info, info_span, warn}; -pub async fn connection() -> Result<()> { +pub fn connection() -> Result<()> { let mut memfd = unsafe { MemFd::::from_raw_fd(PRIVSEP_CONNECTION_STATE_FD) } .wrap_err("failed to open memfd")?; @@ -40,32 +34,19 @@ pub async fn connection() -> Result<()> { crate::setup_tracing(&state.config); let span = info_span!("connection", addr = %state.peer_addr); + let _guard = span.enter(); - connection_inner(state).instrument(span).await + crate::sandbox::drop_privileges(&state)?; + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(connection_inner(state)) } async fn connection_inner(state: SerializedConnectionState) -> Result<()> { let config = state.config; - if rustix::process::getuid().is_root() { - unshare_namespaces()?; - } - - if let Some(uid) = state.setgid { - debug!(?uid, "Setting GID to drop privileges"); - let result = unsafe { libc::setgid(uid) }; - if result == -1 { - return Err(std::io::Error::last_os_error()).wrap_err("failed to setgid"); - } - } - if let Some(uid) = state.setuid { - debug!(?uid, "Setting UID to drop privileges"); - let result = unsafe { libc::setuid(uid) }; - if result == -1 { - return Err(std::io::Error::last_os_error()).wrap_err("failed to setuid"); - } - } - let stream = unsafe { std::net::TcpStream::from_raw_fd(PRIVSEP_CONNECTION_STREAM_FD) }; let stream = TcpStream::from_std(stream)?; @@ -130,69 +111,6 @@ async fn connection_inner(state: SerializedConnectionState) -> Result<()> { Ok(()) } -fn unshare_namespaces() -> Result<()> { - // The complicated incarnation to get a mount namespace working. - rustix::thread::unshare( - UnshareFlags::NEWNS - | UnshareFlags::NEWNET - | UnshareFlags::NEWIPC - | UnshareFlags::NEWPID - | UnshareFlags::NEWTIME, - ) - .wrap_err("unsharing namespaces")?; - - // After creating the PID namespace, we fork immediately so we can get PID 1. - // We never exec, we just let the child live on happily. - // The parent immediately waits for it, and then doesn't do anything really. - // TODO: this is a bit sus.... - unsafe { - let result = libc::fork(); - if result == -1 { - return Err(std::io::Error::last_os_error()).wrap_err("setting propagation flags")?; - } - if result > 0 { - // Parent - let code = rustix::process::waitpid( - Some(Pid::from_raw_unchecked(result)), - WaitOptions::empty(), - ); - match code { - Err(_) => libc::exit(2), - Ok(None) => libc::exit(1), - Ok(Some(code)) => libc::exit(code.as_raw() as i32), - } - } - } - - let result = unsafe { - libc::mount( - c"none".as_ptr(), - c"/".as_ptr(), - std::ptr::null(), - libc::MS_REC | libc::MS_PRIVATE, - std::ptr::null(), - ) - }; - if result == -1 { - return Err(std::io::Error::last_os_error()).wrap_err("setting propagation flags")?; - } - - let new_root = Path::new("empty-new-root"); - let old_root = &new_root.join("old-root"); - - std::fs::create_dir_all(new_root)?; - std::fs::create_dir_all(&old_root)?; - - rustix::fs::bind_mount(new_root, new_root).wrap_err("bind mount the empty dir")?; - - rustix::process::pivot_root(new_root, old_root).wrap_err("pivoting root")?; - - // TODO: can we get rid of it entirely? - rustix::fs::unmount("/old-root", UnmountFlags::DETACH).wrap_err("unmounting old root")?; - - Ok(()) -} - async fn handle_connection( mut conn: cluelessh_tokio::server::ServerConnection, rpc_client: Arc, @@ -480,7 +398,6 @@ impl SessionState { .pty_req(width_chars, height_rows, width_px, height_px, term_modes) .await?; - self.pty_term = Some(term); self.writer = Some(Box::pin(File::from_std(std::fs::File::from( diff --git a/bin/cluelesshd/src/main.rs b/bin/cluelesshd/src/main.rs index ef3f4cb..7fb7acb 100644 --- a/bin/cluelesshd/src/main.rs +++ b/bin/cluelesshd/src/main.rs @@ -3,6 +3,7 @@ mod config; mod connection; mod pty; mod rpc; +mod sandbox; use std::{ io::{Read, Seek, SeekFrom}, @@ -35,12 +36,11 @@ struct Args { config: Option, } -#[tokio::main(flavor = "current_thread")] -async fn main() -> eyre::Result<()> { +fn main() -> eyre::Result<()> { match std::env::var("CLUELESSH_PRIVSEP_PROCESS") { Ok(privsep_process) => match privsep_process.as_str() { "connection" => { - if let Err(err) = connection::connection().await { + if let Err(err) = connection::connection() { error!(?err, "Error in connection child process"); } Ok(()) @@ -59,14 +59,10 @@ async fn main() -> eyre::Result<()> { warn!("Daemon not started as root. This disables several security mitigations and permits logging in as any other user"); } - let addr: SocketAddr = SocketAddr::new(config.net.ip, config.net.port); - info!(%addr, "Starting server"); - - let listener = TcpListener::bind(addr) - .await - .wrap_err_with(|| format!("trying to listen on {addr}"))?; - - main_process(config, listener).await + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(main_process(config)) } } } @@ -121,7 +117,7 @@ struct SerializedConnectionState { setgid: Option, } -async fn main_process(config: Config, listener: TcpListener) -> Result<()> { +async fn main_process(config: Config) -> Result<()> { let user = match &config.security.unprivileged_user { Some(user) => Some( users::get_user_by_name(user).ok_or_else(|| eyre!("unprivileged {user} not found"))?, @@ -159,6 +155,13 @@ async fn main_process(config: Config, listener: TcpListener) -> Result<()> { .map(|key| key.private_key.public_key()) .collect::>(); + let addr: SocketAddr = SocketAddr::new(config.net.ip, config.net.port); + info!(%addr, "Starting server"); + + let listener = TcpListener::bind(addr) + .await + .wrap_err_with(|| format!("trying to listen on {addr}"))?; + loop { let (next_stream, peer_addr) = listener.accept().await?; diff --git a/bin/cluelesshd/src/sandbox.rs b/bin/cluelesshd/src/sandbox.rs new file mode 100644 index 0000000..2424d7d --- /dev/null +++ b/bin/cluelesshd/src/sandbox.rs @@ -0,0 +1,191 @@ +use std::{ + fs::File, + io::{Read, Write}, + path::Path, +}; + +use eyre::{bail, Result, WrapErr}; +use rustix::{ + fs::UnmountFlags, + process::WaitOptions, + thread::{Pid, UnshareFlags}, +}; +use tracing::{debug, trace}; + +use crate::SerializedConnectionState; + +#[tracing::instrument(skip(state), ret)] +pub fn drop_privileges(state: &SerializedConnectionState) -> Result<()> { + if rustix::process::getuid().is_root() { + crate::sandbox::unshare_namespaces()?; + } else { + // TODO: We can still do it if we're careful with the uid map. + debug!("Not unsharing namespaces as the daemon was not started as root"); + } + + if let Some(gid) = state.setgid { + debug!(?gid, "Setting GID to drop privileges"); + let result = unsafe { libc::setgid(gid) }; + if result == -1 { + return Err(std::io::Error::last_os_error()).wrap_err("failed to setgid"); + } + } + if let Some(uid) = state.setuid { + debug!(?uid, "Setting UID to drop privileges"); + let result = unsafe { libc::setuid(uid) }; + if result == -1 { + return Err(std::io::Error::last_os_error()).wrap_err("failed to setuid"); + } + } + + rustix::thread::set_no_new_privs(true)?; + + Ok(()) +} + +enum Fork { + Child, + Parent(rustix::process::Pid), +} + +unsafe fn fork() -> Result { + unsafe { + let result = libc::fork(); + if result == -1 { + return Err(std::io::Error::last_os_error()).wrap_err("setting propagation flags")?; + } + if result > 0 { + Ok(Fork::Parent(Pid::from_raw_unchecked(result))) + } else { + Ok(Fork::Child) + } + } +} + +fn pipe() -> Result<(File, File)> { + let (read, write) = rustix::pipe::pipe()?; + + Ok((File::from(read), File::from(write))) +} + +/// Unshare namespaces to set up a sandbox. +/// If this fails, there might be zombie child processes. +/// Therefore, the caller must exit if this function fails. +#[tracing::instrument] +pub fn unshare_namespaces() -> Result<()> { + let (mut child_ready_read, mut child_ready_write) = pipe()?; + let (mut uid_map_ready_read, mut uid_map_ready_write) = pipe()?; + + match unsafe { fork()? } { + Fork::Parent(child) => { + // In an error condition, we will not wait on the child. + // But this is fine, as any error from this function will cause the caller to exit. + let mut read = [0; 1]; + child_ready_read.read_exact(&mut read)?; + if read[0] != 1 { + bail!("child failed to write"); + } + + trace!("Parent: child is ready"); + + let result1 = std::fs::write( + format!("/proc/{}/uid_map", child.as_raw_nonzero().get()), + "0 1000000 1000000", + ); + let result2 = std::fs::write( + format!("/proc/{}/gid_map", child.as_raw_nonzero().get()), + "0 1000000 1000000", + ); + + let result = result1.and(result2); + + let value = if result.is_ok() { 1 } else { 0 }; + trace!(?value, "Parent: signaling uid_map write result"); + + uid_map_ready_write.write_all(&[value])?; + + result?; + + let code = rustix::process::waitpid(Some(child), WaitOptions::empty()); + match code { + Err(_) => std::process::exit(2), + Ok(None) => std::process::exit(1), + Ok(Some(code)) => std::process::exit(code.as_raw() as i32), + } + } + Fork::Child => {} // Move on + } + + // The complicated incarnation to get a mount namespace working. + let result = rustix::thread::unshare( + UnshareFlags::NEWNS + | UnshareFlags::NEWNET + | UnshareFlags::NEWIPC + | UnshareFlags::NEWPID + | UnshareFlags::NEWTIME + | UnshareFlags::NEWUTS + | UnshareFlags::NEWUSER, + ) + .wrap_err("unsharing namespaces"); + + let value = if result.is_ok() { 1 } else { 0 }; + + trace!(?value, "Child: signaling unshare result"); + child_ready_write.write_all(&[value])?; + + result?; + + let mut read = [0; 1]; + uid_map_ready_read.read_exact(&mut read)?; + if read[0] != 1 { + bail!("parent failed to write"); + } + trace!("Child: uid mappings set up, continue"); + + //std::thread::sleep(std::time::Duration::from_secs(1000)); + + // After creating the PID namespace, we fork immediately so we can get PID 1. + // We never exec, we just let the child live on happily. + // The parent immediately waits for it, and then doesn't do anything really. + // TODO: this is a bit sus.... + + match unsafe { fork()? } { + Fork::Parent(child) => { + let code = rustix::process::waitpid(Some(child), WaitOptions::empty()); + match code { + Err(_) => std::process::exit(2), + Ok(None) => std::process::exit(1), + Ok(Some(code)) => std::process::exit(code.as_raw() as i32), + } + } + Fork::Child => {} // Move on + } + + let result = unsafe { + libc::mount( + c"none".as_ptr(), + c"/".as_ptr(), + std::ptr::null(), + libc::MS_REC | libc::MS_PRIVATE, + std::ptr::null(), + ) + }; + if result == -1 { + return Err(std::io::Error::last_os_error()).wrap_err("setting propagation flags")?; + } + + let new_root = Path::new("empty-new-root"); + let old_root = &new_root.join("old-root"); + + std::fs::create_dir_all(new_root)?; + std::fs::create_dir_all(&old_root)?; + + rustix::fs::bind_mount(new_root, new_root).wrap_err("bind mount the empty dir")?; + + rustix::process::pivot_root(new_root, old_root).wrap_err("pivoting root")?; + + // TODO: can we get rid of it entirely? + rustix::fs::unmount("/old-root", UnmountFlags::DETACH).wrap_err("unmounting old root")?; + + Ok(()) +}