mirror of
https://github.com/Noratrieb/cluelessh.git
synced 2026-01-17 01:45:04 +01:00
userns
This commit is contained in:
parent
533b8cda1e
commit
a081ecc8c8
4 changed files with 216 additions and 105 deletions
|
|
@ -12,7 +12,7 @@ tokio = { version = "1.39.2", features = ["full"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
eyre.workspace = true
|
eyre.workspace = true
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
|
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"
|
users = "0.11.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
thiserror = "1.0.63"
|
thiserror = "1.0.63"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use std::{
|
use std::{
|
||||||
os::fd::{FromRawFd, OwnedFd},
|
os::fd::{FromRawFd, OwnedFd},
|
||||||
path::Path,
|
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
@ -18,20 +17,15 @@ use cluelessh_tokio::{
|
||||||
Channel,
|
Channel,
|
||||||
};
|
};
|
||||||
use eyre::{bail, ensure, Result, WrapErr};
|
use eyre::{bail, ensure, Result, WrapErr};
|
||||||
use rustix::{
|
|
||||||
fs::UnmountFlags,
|
|
||||||
process::WaitOptions,
|
|
||||||
thread::{Pid, UnshareFlags},
|
|
||||||
};
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||||
net::TcpStream,
|
net::TcpStream,
|
||||||
sync::mpsc,
|
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 =
|
let mut memfd =
|
||||||
unsafe { MemFd::<SerializedConnectionState>::from_raw_fd(PRIVSEP_CONNECTION_STATE_FD) }
|
unsafe { MemFd::<SerializedConnectionState>::from_raw_fd(PRIVSEP_CONNECTION_STATE_FD) }
|
||||||
.wrap_err("failed to open memfd")?;
|
.wrap_err("failed to open memfd")?;
|
||||||
|
|
@ -40,32 +34,19 @@ pub async fn connection() -> Result<()> {
|
||||||
crate::setup_tracing(&state.config);
|
crate::setup_tracing(&state.config);
|
||||||
|
|
||||||
let span = info_span!("connection", addr = %state.peer_addr);
|
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<()> {
|
async fn connection_inner(state: SerializedConnectionState) -> Result<()> {
|
||||||
let config = state.config;
|
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 = unsafe { std::net::TcpStream::from_raw_fd(PRIVSEP_CONNECTION_STREAM_FD) };
|
||||||
let stream = TcpStream::from_std(stream)?;
|
let stream = TcpStream::from_std(stream)?;
|
||||||
|
|
||||||
|
|
@ -130,69 +111,6 @@ async fn connection_inner(state: SerializedConnectionState) -> Result<()> {
|
||||||
Ok(())
|
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(
|
async fn handle_connection(
|
||||||
mut conn: cluelessh_tokio::server::ServerConnection<TcpStream>,
|
mut conn: cluelessh_tokio::server::ServerConnection<TcpStream>,
|
||||||
rpc_client: Arc<rpc::Client>,
|
rpc_client: Arc<rpc::Client>,
|
||||||
|
|
@ -480,7 +398,6 @@ impl SessionState {
|
||||||
.pty_req(width_chars, height_rows, width_px, height_px, term_modes)
|
.pty_req(width_chars, height_rows, width_px, height_px, term_modes)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
||||||
self.pty_term = Some(term);
|
self.pty_term = Some(term);
|
||||||
|
|
||||||
self.writer = Some(Box::pin(File::from_std(std::fs::File::from(
|
self.writer = Some(Box::pin(File::from_std(std::fs::File::from(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ mod config;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod pty;
|
mod pty;
|
||||||
mod rpc;
|
mod rpc;
|
||||||
|
mod sandbox;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io::{Read, Seek, SeekFrom},
|
io::{Read, Seek, SeekFrom},
|
||||||
|
|
@ -35,12 +36,11 @@ struct Args {
|
||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
fn main() -> eyre::Result<()> {
|
||||||
async fn main() -> eyre::Result<()> {
|
|
||||||
match std::env::var("CLUELESSH_PRIVSEP_PROCESS") {
|
match std::env::var("CLUELESSH_PRIVSEP_PROCESS") {
|
||||||
Ok(privsep_process) => match privsep_process.as_str() {
|
Ok(privsep_process) => match privsep_process.as_str() {
|
||||||
"connection" => {
|
"connection" => {
|
||||||
if let Err(err) = connection::connection().await {
|
if let Err(err) = connection::connection() {
|
||||||
error!(?err, "Error in connection child process");
|
error!(?err, "Error in connection child process");
|
||||||
}
|
}
|
||||||
Ok(())
|
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");
|
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);
|
tokio::runtime::Builder::new_current_thread()
|
||||||
info!(%addr, "Starting server");
|
.enable_all()
|
||||||
|
.build()?
|
||||||
let listener = TcpListener::bind(addr)
|
.block_on(main_process(config))
|
||||||
.await
|
|
||||||
.wrap_err_with(|| format!("trying to listen on {addr}"))?;
|
|
||||||
|
|
||||||
main_process(config, listener).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +117,7 @@ struct SerializedConnectionState {
|
||||||
setgid: Option<u32>,
|
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 {
|
let user = match &config.security.unprivileged_user {
|
||||||
Some(user) => Some(
|
Some(user) => Some(
|
||||||
users::get_user_by_name(user).ok_or_else(|| eyre!("unprivileged {user} not found"))?,
|
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())
|
.map(|key| key.private_key.public_key())
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
loop {
|
||||||
let (next_stream, peer_addr) = listener.accept().await?;
|
let (next_stream, peer_addr) = listener.accept().await?;
|
||||||
|
|
||||||
|
|
|
||||||
191
bin/cluelesshd/src/sandbox.rs
Normal file
191
bin/cluelesshd/src/sandbox.rs
Normal 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(())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue