From 7b10c579e287b88c94b1f62f497b20214438d7b2 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Wed, 12 Jul 2023 02:45:40 +0100 Subject: [PATCH] Hack together support for legacy scp protocol --- Cargo.lock | 4 ++++ pisshoff-server/src/command.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++---- pisshoff-server/src/command/scp.rs | 276 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pisshoff-server/src/server.rs | 61 ++++++++++++++++++++++--------------------------------------- pisshoff-server/src/subsystem/mod.rs | 5 +++-- pisshoff-server/src/subsystem/sftp.rs | 25 +++++++++++++++---------- pisshoff-server/src/subsystem/shell.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pisshoff-types/Cargo.toml | 1 + pisshoff-types/src/audit.rs | 3 ++- 9 files changed, 456 insertions(+), 56 deletions(-) create mode 100644 pisshoff-server/src/command/scp.rs create mode 100644 pisshoff-server/src/subsystem/shell.rs diff --git a/Cargo.lock b/Cargo.lock index e213c04..2cce4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,9 @@ name = "bytes" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +dependencies = [ + "serde", +] [[package]] name = "cc" @@ -1104,6 +1107,7 @@ dependencies = [ name = "pisshoff-types" version = "0.1.0" dependencies = [ + "bytes", "serde", "strum", "time", diff --git a/pisshoff-server/src/command.rs b/pisshoff-server/src/command.rs index b6f754f..8446ae6 100644 --- a/pisshoff-server/src/command.rs +++ b/pisshoff-server/src/command.rs @@ -1,6 +1,8 @@ +pub mod scp; pub mod uname; -use crate::server::Connection; +use crate::{command::scp::Scp, server::Connection}; +use async_trait::async_trait; use itertools::{Either, Itertools}; use std::{f32, fmt::Write, str::FromStr, time::Duration}; use thrussh::{server::Session, ChannelId}; @@ -10,9 +12,9 @@ pub async fn run_command( channel: ChannelId, session: &mut Session, conn: &mut Connection, -) { +) -> Option { let Some(command) = args.get(0) else { - return; + return None; }; match command.as_str() { @@ -61,7 +63,7 @@ pub async fn run_command( channel, "-bash: cd: too many arguments\n".to_string().into(), ); - return; + return None; } conn.file_system().cd(args.get(1).map(String::as_str)); @@ -85,6 +87,10 @@ pub async fn run_command( let out = uname::execute(&args[1..]); session.data(channel, out.into()); } + "scp" => match Scp::new(&args[1..], channel, session) { + Ok(v) => return Some(ConcreteLongRunningCommand::Scp(v)), + Err(e) => session.data(channel, e.to_string().into()), + }, other => { // TODO: fix stderr displaying out of order session.data( @@ -93,6 +99,47 @@ pub async fn run_command( ); } } + + None +} + +#[async_trait] +pub trait LongRunningCommand: Sized { + fn new( + params: &[String], + channel: ChannelId, + session: &mut Session, + ) -> Result; + + async fn data( + self, + connection: &mut Connection, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Option; +} + +#[derive(Debug, Clone)] +pub enum ConcreteLongRunningCommand { + Scp(Scp), +} + +impl ConcreteLongRunningCommand { + pub async fn data( + self, + connection: &mut Connection, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Option { + match self { + Self::Scp(cmd) => cmd + .data(connection, channel, data, session) + .await + .map(Self::Scp), + } + } } #[derive(Debug, Copy, Clone, PartialEq, Eq)] diff --git a/pisshoff-server/src/command/scp.rs b/pisshoff-server/src/command/scp.rs new file mode 100644 index 0000000..9e47f33 --- /dev/null +++ b/pisshoff-server/src/command/scp.rs @@ -0,0 +1,276 @@ +use crate::{ + command::{Arg, LongRunningCommand}, + server::Connection, +}; +use async_trait::async_trait; +use bytes::{Buf, BytesMut}; +use nom::{ + bytes::complete::{tag, take, take_until}, + character::complete::{digit1, u64}, + combinator::{map, map_res}, + IResult, +}; +use pisshoff_types::audit::{AuditLogAction, WriteFileEvent}; +use std::{path::PathBuf, str::FromStr}; +use thrussh::{server::Session, ChannelId}; +use tracing::warn; + +const HELP: &str = "usage: scp [-346ABCOpqRrsTv] [-c cipher] [-D sftp_server_path] [-F ssh_config] + [-i identity_file] [-J destination] [-l limit] [-o ssh_option] + [-P port] [-S program] [-X sftp_option] source ... target\n"; + +const AMBIGUOUS_TARGET: &str = "scp: ambiguous target\n"; + +const SUCCESS: &str = "\0"; + +// https://web.archive.org/web/20170215184048/https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works +#[derive(Debug, Clone)] +pub struct Scp { + path: PathBuf, + pending_data: BytesMut, + state: State, +} + +#[async_trait] +impl LongRunningCommand for Scp { + fn new( + params: &[String], + channel: ChannelId, + session: &mut Session, + ) -> Result { + let mut path = None; + let mut transfer = false; + + for param in super::argparse(params) { + match param { + Arg::Short('t') => { + transfer = true; + } + Arg::Short('r' | 'v') => { + // this is an allowed param, do nothing + } + Arg::Operand(p) => { + path = Some(p); + } + _ => { + return Err(HELP); + } + } + } + + let Some(path) = path else { + return Err(AMBIGUOUS_TARGET); + }; + + if !transfer { + return Err(HELP); + } + + // signal to the client we've started listening + session.data(channel, SUCCESS.to_string().into()); + + Ok(Self { + path: PathBuf::new().join(path), + pending_data: BytesMut::new(), + state: State::Waiting, + }) + } + + async fn data( + mut self, + connection: &mut Connection, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Option { + self.pending_data.extend_from_slice(data); + + let mut exit = false; + while !self.pending_data.is_empty() && !exit { + let next_state = match self.state { + State::Waiting => { + match Receive::parse(&self.pending_data) { + Ok((rest, res)) => { + let mut state = State::Waiting; + + match res { + Receive::FileCopy { + length, file_name, .. + } => { + state = State::ReceivingFile(length, self.path.join(file_name)); + } + Receive::DirectoryCopy { directory_name, .. } => { + self.path.push(directory_name); + } + Receive::EndDirectory => { + self.path.pop(); + } + Receive::AccessTime { .. } => {} + } + + self.pending_data + .advance(self.pending_data.len() - rest.len()); + + // signal to the client we received their message and we're now listening for + // more data + session.data(channel, SUCCESS.to_string().into()); + + state + } + Err(error) => { + warn!(%error, "Rejecting scp modes payload"); + return None; + } + } + } + State::ReceivingFile(length, path) => { + if self.pending_data.len() < length { + // keep waiting for more data... + exit = true; + State::ReceivingFile(length, path) + } else { + // we've received the whole file, lets print and start waiting again + let data = self.pending_data.split_to(length); + + connection + .audit_log() + .push_action(AuditLogAction::WriteFile(WriteFileEvent { + path: Box::from(path.to_string_lossy().into_owned()), + content: data.freeze(), + })); + + State::AwaitingSeparator + } + } + State::AwaitingSeparator => { + if self.pending_data.starts_with(&[0]) { + self.pending_data.advance(1); + + // signal to the client we received their message and we're now listening for + // more data + session.data(channel, SUCCESS.to_string().into()); + } + + State::Waiting + } + }; + + self.state = next_state; + } + + Some(self) + } +} + +#[derive(Clone, Debug)] +enum State { + Waiting, + ReceivingFile(usize, PathBuf), + AwaitingSeparator, +} + +#[derive(Debug)] +#[allow(dead_code)] +enum Receive<'a> { + FileCopy { + mode: &'a str, + length: usize, + file_name: &'a str, + }, + DirectoryCopy { + mode: &'a str, + length: u64, + directory_name: &'a str, + }, + EndDirectory, + AccessTime { + modified_time: u64, + modified_time_micros: u64, + access_time: u64, + access_time_micros: u64, + }, +} + +enum ReceiveType { + FileCopy, + DirectoryCopy, + EndDirectory, + AccessTime, +} + +impl<'a> Receive<'a> { + fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Receive<'a>> { + let (rest, typ) = nom::branch::alt(( + map(tag("C"), |_| ReceiveType::FileCopy), + map(tag("D"), |_| ReceiveType::DirectoryCopy), + map(tag("E"), |_| ReceiveType::EndDirectory), + map(tag("T"), |_| ReceiveType::AccessTime), + ))(rest)?; + + match typ { + ReceiveType::FileCopy => { + let (rest, mode) = map_res(take(4_usize), std::str::from_utf8)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, length) = + map_res(map_res(digit1, std::str::from_utf8), usize::from_str)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, file_name) = map_res(take_until("\n"), std::str::from_utf8)(rest)?; + let (rest, _) = tag("\n")(rest)?; + + Ok(( + rest, + Receive::FileCopy { + mode, + length, + file_name, + }, + )) + } + ReceiveType::DirectoryCopy => { + let (rest, mode) = map_res(take(4_usize), std::str::from_utf8)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, length) = u64(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, directory_name) = map_res(take_until("\n"), std::str::from_utf8)(rest)?; + let (rest, _) = tag("\n")(rest)?; + + Ok(( + rest, + Receive::DirectoryCopy { + mode, + length, + directory_name, + }, + )) + } + ReceiveType::EndDirectory => { + let (rest, _) = tag("\n")(rest)?; + Ok((rest, Receive::EndDirectory)) + } + ReceiveType::AccessTime => { + let (rest, modified_time) = + map_res(map_res(digit1, std::str::from_utf8), u64::from_str)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, modified_time_micros) = + map_res(map_res(digit1, std::str::from_utf8), u64::from_str)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, access_time) = + map_res(map_res(digit1, std::str::from_utf8), u64::from_str)(rest)?; + let (rest, _) = tag(" ")(rest)?; + let (rest, access_time_micros) = + map_res(map_res(digit1, std::str::from_utf8), u64::from_str)(rest)?; + let (rest, _) = tag("\n")(rest)?; + + Ok(( + rest, + Receive::AccessTime { + modified_time, + modified_time_micros, + access_time, + access_time_micros, + }, + )) + } + } + } +} diff --git a/pisshoff-server/src/server.rs b/pisshoff-server/src/server.rs index 04109a2..e390492 100644 --- a/pisshoff-server/src/server.rs +++ b/pisshoff-server/src/server.rs @@ -4,15 +4,13 @@ use crate::{ PtyRequestEvent, X11RequestEvent, }, audit::{ - ExecCommandEvent, SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent, - WindowAdjustedEvent, WindowChangeRequestEvent, + SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent, WindowAdjustedEvent, + WindowChangeRequestEvent, }, - command::run_command, config::Config, file_system::FileSystem, state::State, - subsystem, - subsystem::Subsystem as SubsystemTrait, + subsystem::{self, shell::Shell, Subsystem as SubsystemTrait}, }; use futures::{ future::{BoxFuture, InspectErr}, @@ -38,7 +36,6 @@ use tracing::{debug, error, info, info_span, instrument::Instrumented, Instrumen pub static KEYBOARD_INTERACTIVE_PROMPT: &[(Cow<'static, str>, bool)] = &[(Cow::Borrowed("Password: "), false)]; -pub const SHELL_PROMPT: &str = "bash-5.1$ "; #[derive(Clone)] pub struct Server { @@ -107,6 +104,10 @@ impl Connection { self.file_system.as_mut().unwrap() } + pub fn audit_log(&mut self) -> &mut AuditLog { + &mut self.audit_log + } + fn try_login(&mut self, user: &str, password: &str) -> bool { self.username = Some(user.to_string()); @@ -255,15 +256,13 @@ impl thrussh::server::Handler for Connection { let _entered = span.enter(); if self.subsystem.remove(&channel).is_some() { + session.exit_status_request(channel, 0); session.channel_success(channel); } else { session.channel_failure(channel); } - if self.subsystem.is_empty() { - session.exit_status_request(channel, 0); - session.close(channel); - } + session.close(channel); self.finished(session).boxed().wrap(Span::current()) } @@ -332,22 +331,11 @@ impl thrussh::server::Handler for Connection { let mut subsystem = subsystem.lock().await; match &mut *subsystem { - Subsystem::Shell => { - let data = shlex::split(String::from_utf8_lossy(&data).as_ref()); - if let Some(args) = data { - run_command(&args, channel, &mut session, &mut self).await; - self.audit_log - .push_action(AuditLogAction::ExecCommand(ExecCommandEvent { - args: Box::from(args), - })); - } - - session.data(channel, SHELL_PROMPT.to_string().into()); + Subsystem::Shell(ref mut inner) => { + inner.data(&mut self, channel, &data, &mut session).await; } Subsystem::Sftp(ref mut inner) => { - inner - .data(&mut self.audit_log, channel, &data, &mut session) - .await; + inner.data(&mut self, channel, &data, &mut session).await; } } @@ -476,9 +464,9 @@ impl thrussh::server::Handler for Connection { self.audit_log.push_action(AuditLogAction::ShellRequested); - session.data(channel, SHELL_PROMPT.to_string().into()); + let shell = Shell::new(true, channel, &mut session); self.subsystem - .insert(channel, Arc::new(Mutex::new(Subsystem::Shell))); + .insert(channel, Arc::new(Mutex::new(Subsystem::Shell(shell)))); session.channel_success(channel); self.finished(session).boxed().wrap(Span::current()) @@ -493,21 +481,16 @@ impl thrussh::server::Handler for Connection { let span = info_span!(parent: &self.span, "exec_request"); let _entered = span.enter(); - let data = shlex::split(String::from_utf8_lossy(data).as_ref()); + let data = data.to_vec(); async move { - if let Some(args) = data { - run_command(&args, channel, &mut session, &mut self).await; - self.audit_log - .push_action(AuditLogAction::ExecCommand(ExecCommandEvent { - args: Box::from(args), - })); - - session.channel_success(channel); - } else { - session.channel_failure(channel); - } + let mut shell = Shell::new(false, channel, &mut session); + shell.data(&mut self, channel, &data, &mut session).await; + self.subsystem + .insert(channel, Arc::new(Mutex::new(Subsystem::Shell(shell)))); + + session.channel_success(channel); self.finished(session).await } .boxed() @@ -639,7 +622,7 @@ impl Drop for Connection { #[derive(Debug, Clone)] pub enum Subsystem { - Shell, + Shell(subsystem::shell::Shell), Sftp(subsystem::sftp::Sftp), } diff --git a/pisshoff-server/src/subsystem/mod.rs b/pisshoff-server/src/subsystem/mod.rs index 55e9bda..395a06e 100644 --- a/pisshoff-server/src/subsystem/mod.rs +++ b/pisshoff-server/src/subsystem/mod.rs @@ -1,9 +1,10 @@ +use crate::server::Connection; use async_trait::async_trait; -use pisshoff_types::audit::AuditLog; use thrussh::server::Session; use thrussh::ChannelId; pub mod sftp; +pub mod shell; #[async_trait] pub trait Subsystem { @@ -11,7 +12,7 @@ pub trait Subsystem { async fn data( &mut self, - audit_log: &mut AuditLog, + connection: &mut Connection, channel: ChannelId, data: &[u8], session: &mut Session, diff --git a/pisshoff-server/src/subsystem/sftp.rs b/pisshoff-server/src/subsystem/sftp.rs index 9c12069..83d5370 100644 --- a/pisshoff-server/src/subsystem/sftp.rs +++ b/pisshoff-server/src/subsystem/sftp.rs @@ -1,5 +1,6 @@ -use crate::subsystem::Subsystem; +use crate::{server::Connection, subsystem::Subsystem}; use async_trait::async_trait; +use bytes::Bytes; use nom::{ bytes::complete::take, combinator::{map_res, opt}, @@ -7,7 +8,7 @@ use nom::{ number::complete::{be_u32, be_u64, be_u8}, IResult, }; -use pisshoff_types::audit::{AuditLog, AuditLogAction, MkdirEvent, WriteFileEvent}; +use pisshoff_types::audit::{AuditLogAction, MkdirEvent, WriteFileEvent}; use std::{collections::HashMap, io::Write, mem::size_of, str::FromStr}; use strum::FromRepr; use thrussh::{server::Session, ChannelId}; @@ -28,7 +29,7 @@ impl Subsystem for Sftp { #[allow(clippy::too_many_lines)] async fn data( &mut self, - audit_log: &mut AuditLog, + connection: &mut Connection, channel: ChannelId, data: &[u8], session: &mut Session, @@ -128,10 +129,12 @@ impl Subsystem for Sftp { write_packet.offset, write_packet.data ); - audit_log.push_action(AuditLogAction::WriteFile(WriteFileEvent { - path: path.to_string().into_boxed_str(), - content: write_packet.data.to_string().into_boxed_str(), - })); + connection + .audit_log() + .push_action(AuditLogAction::WriteFile(WriteFileEvent { + path: path.to_string().into_boxed_str(), + content: Bytes::copy_from_slice(write_packet.data.as_bytes()), + })); session.data( channel, @@ -205,9 +208,11 @@ impl Subsystem for Sftp { trace!("SFTP mkdir packet: {mkdir:?}"); - audit_log.push_action(AuditLogAction::Mkdir(MkdirEvent { - path: mkdir.path.to_string().into_boxed_str(), - })); + connection + .audit_log() + .push_action(AuditLogAction::Mkdir(MkdirEvent { + path: mkdir.path.to_string().into_boxed_str(), + })); session.data( channel, diff --git a/pisshoff-server/src/subsystem/shell.rs b/pisshoff-server/src/subsystem/shell.rs new file mode 100644 index 0000000..0776c34 --- /dev/null +++ b/pisshoff-server/src/subsystem/shell.rs @@ -0,0 +1,82 @@ +use crate::{ + command::{run_command, ConcreteLongRunningCommand}, + server::Connection, + subsystem::Subsystem, +}; +use async_trait::async_trait; +use pisshoff_types::audit::{AuditLogAction, ExecCommandEvent}; +use thrussh::{server::Session, ChannelId}; + +pub const SHELL_PROMPT: &str = "bash-5.1$ "; + +#[derive(Clone, Debug)] +pub struct Shell { + interactive: bool, + state: State, +} + +impl Shell { + pub fn new(interactive: bool, channel: ChannelId, session: &mut Session) -> Self { + if interactive { + session.data(channel, SHELL_PROMPT.to_string().into()); + } + + Self { + interactive, + state: State::Prompt, + } + } +} + +#[async_trait] +impl Subsystem for Shell { + const NAME: &'static str = "shell"; + + async fn data( + &mut self, + connection: &mut Connection, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) { + let next = match std::mem::take(&mut self.state) { + State::Prompt => { + let Some(args) = shlex::split(String::from_utf8_lossy(data).as_ref()) else { + return; + }; + + connection + .audit_log() + .push_action(AuditLogAction::ExecCommand(ExecCommandEvent { + args: Box::from(args.clone()), + })); + + run_command(&args, channel, session, connection) + .await + .map_or(State::Prompt, State::Running) + } + State::Running(command) => command + .data(connection, channel, data, session) + .await + .map_or(State::Prompt, State::Running), + }; + + if matches!(next, State::Prompt) { + if self.interactive { + session.data(channel, SHELL_PROMPT.to_string().into()); + } else { + session.exit_status_request(channel, 0); + session.close(channel); + } + } + + self.state = next; + } +} + +#[derive(Debug, Clone, Default)] +enum State { + #[default] + Prompt, + Running(ConcreteLongRunningCommand), +} diff --git a/pisshoff-types/Cargo.toml b/pisshoff-types/Cargo.toml index e224dc1..1987279 100644 --- a/pisshoff-types/Cargo.toml +++ b/pisshoff-types/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +bytes = { version = "1.4", features = ["serde"] } uuid = "1.3" time = { version = "0.3", features = ["serde", "formatting", "parsing"] } serde = { version = "1.0", features = ["derive"] } diff --git a/pisshoff-types/src/audit.rs b/pisshoff-types/src/audit.rs index 834abbc..99dcad0 100644 --- a/pisshoff-types/src/audit.rs +++ b/pisshoff-types/src/audit.rs @@ -1,3 +1,4 @@ +use bytes::Bytes; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::{ @@ -92,7 +93,7 @@ pub struct MkdirEvent { #[derive(Debug, Serialize, Deserialize)] pub struct WriteFileEvent { pub path: Box, - pub content: Box, + pub content: Bytes, } #[derive(Debug, Serialize, Deserialize)] -- libgit2 1.7.2