This commit is contained in:
nora 2024-08-29 17:12:51 +02:00
parent 533b8cda1e
commit a081ecc8c8
4 changed files with 216 additions and 105 deletions

View file

@ -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"

View file

@ -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::<SerializedConnectionState>::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<TcpStream>,
rpc_client: Arc<rpc::Client>,
@ -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(

View file

@ -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<PathBuf>,
}
#[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<u32>,
}
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::<Vec<_>>();
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?;

View file

@ -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<Fork> {
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(())
}