Hack together SFTP/modern SCP auditing support
Diff
Cargo.lock | 20 +-
pisshoff-server/Cargo.toml | 4 +-
pisshoff-server/src/main.rs | 1 +-
pisshoff-server/src/server.rs | 83 +++--
pisshoff-server/src/subsystem/mod.rs | 19 +-
pisshoff-server/src/subsystem/sftp.rs | 618 +++++++++++++++++++++++++++++++++++-
pisshoff-types/src/audit.rs | 13 +-
7 files changed, 741 insertions(+), 17 deletions(-)
@@ -840,6 +840,12 @@ dependencies = [
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -874,6 +880,16 @@ dependencies = [
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1038,18 +1054,22 @@ name = "pisshoff-server"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"bitflags 2.3.3",
"bytes",
"clap",
"fastrand",
"futures",
"insta",
"itertools",
"nix",
"nom",
"parking_lot",
"pisshoff-types",
"serde",
"serde_json",
"shlex",
"strum",
"test-case",
"thrussh",
"thrussh-keys",
@@ -9,15 +9,19 @@ edition = "2021"
pisshoff-types = { path = "../pisshoff-types" }
anyhow = "1.0"
async-trait = "0.1"
bitflags = "2.3"
bytes = "1.4"
clap = { version = "4.3", features = ["derive", "env", "cargo"] }
futures = "0.3"
parking_lot = "0.12"
fastrand = "1.9"
itertools = "0.10"
nom = "7.1"
nix = { version = "0.26", features = ["hostname"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
strum = { version = "0.24", features = ["derive"] }
shlex = "1.1"
thrussh = "0.34"
thrussh-keys = "0.22"
@@ -20,6 +20,7 @@ mod config;
mod file_system;
mod server;
mod state;
mod subsystem;
#[tokio::main]
async fn main() {
@@ -1,16 +1,18 @@
use crate::audit::{
ExecCommandEvent, SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent, WindowAdjustedEvent,
WindowChangeRequestEvent,
};
use crate::file_system::FileSystem;
use crate::{
audit::{
AuditLog, AuditLogAction, LoginAttemptEvent, OpenDirectTcpIpEvent, OpenX11Event,
PtyRequestEvent, X11RequestEvent,
},
audit::{
ExecCommandEvent, SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent,
WindowAdjustedEvent, WindowChangeRequestEvent,
},
command::run_command,
config::Config,
file_system::FileSystem,
state::State,
subsystem,
subsystem::Subsystem as SubsystemTrait,
};
use futures::{
future::{BoxFuture, InspectErr},
@@ -18,6 +20,7 @@ use futures::{
};
use std::{
borrow::Cow,
collections::HashMap,
future::Future,
net::SocketAddr,
pin::Pin,
@@ -30,6 +33,7 @@ use thrussh::{
};
use thrussh_keys::key::PublicKey;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::Mutex;
use tracing::{debug, error, info, info_span, instrument::Instrumented, Instrument, Span};
pub static KEYBOARD_INTERACTIVE_PROMPT: &[(Cow<'static, str>, bool)] =
@@ -76,6 +80,7 @@ impl thrussh::server::Server for Server {
},
username: None,
file_system: None,
subsystem: HashMap::new(),
}
}
}
@@ -86,6 +91,7 @@ pub struct Connection {
audit_log: AuditLog,
username: Option<String>,
file_system: Option<FileSystem>,
subsystem: HashMap<ChannelId, Arc<Mutex<Subsystem>>>,
}
impl Connection {
@@ -244,13 +250,21 @@ impl thrussh::server::Handler for Connection {
self.finished(session).boxed().wrap(Span::current())
}
fn channel_eof(self, channel: ChannelId, mut session: Session) -> Self::FutureUnit {
fn channel_eof(mut self, channel: ChannelId, mut session: Session) -> Self::FutureUnit {
let span = info_span!(parent: &self.span, "channel_eof");
let _entered = span.enter();
info!("In here");
if self.subsystem.remove(&channel).is_some() {
session.channel_success(channel);
} else {
session.channel_failure(channel);
}
if self.subsystem.is_empty() {
session.exit_status_request(channel, 0);
session.close(channel);
}
session.channel_success(channel);
self.finished(session).boxed().wrap(Span::current())
}
@@ -310,18 +324,33 @@ impl thrussh::server::Handler for Connection {
let span = info_span!(parent: &self.span, "data");
let _entered = span.enter();
let data = shlex::split(String::from_utf8_lossy(data).as_ref());
let subsystem = self.subsystem.get(&channel).unwrap().clone();
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),
}));
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::Sftp(ref mut inner) => {
inner
.data(&mut self.audit_log, channel, &data, &mut session)
.await;
}
}
session.data(channel, SHELL_PROMPT.to_string().into());
self.finished(session).await
}
.boxed()
@@ -448,6 +477,8 @@ impl thrussh::server::Handler for Connection {
self.audit_log.push_action(AuditLogAction::ShellRequested);
session.data(channel, SHELL_PROMPT.to_string().into());
self.subsystem
.insert(channel, Arc::new(Mutex::new(Subsystem::Shell)));
session.channel_success(channel);
self.finished(session).boxed().wrap(Span::current())
@@ -497,7 +528,19 @@ impl thrussh::server::Handler for Connection {
name: Box::from(name),
}));
session.channel_failure(channel);
let subsystem = match name {
subsystem::sftp::Sftp::NAME => Some(Subsystem::Sftp(subsystem::sftp::Sftp::default())),
_ => None,
};
if let Some(subsystem) = subsystem {
self.subsystem
.insert(channel, Arc::new(Mutex::new(subsystem)));
session.channel_success(channel);
} else {
session.channel_failure(channel);
}
self.finished(session).boxed().wrap(Span::current())
}
@@ -594,6 +637,12 @@ impl Drop for Connection {
}
}
#[derive(Debug, Clone)]
pub enum Subsystem {
Shell,
Sftp(subsystem::sftp::Sftp),
}
type HandlerResult<T> = Result<T, <Connection as thrussh::server::Handler>::Error>;
type HandlerFuture<T> = ServerFuture<
<Connection as thrussh::server::Handler>::Error,
@@ -0,0 +1,19 @@
use async_trait::async_trait;
use pisshoff_types::audit::AuditLog;
use thrussh::server::Session;
use thrussh::ChannelId;
pub mod sftp;
#[async_trait]
pub trait Subsystem {
const NAME: &'static str;
async fn data(
&mut self,
audit_log: &mut AuditLog,
channel: ChannelId,
data: &[u8],
session: &mut Session,
);
}
@@ -0,0 +1,618 @@
use crate::subsystem::Subsystem;
use async_trait::async_trait;
use nom::{
bytes::complete::take,
combinator::{map_res, opt},
error::ErrorKind,
number::complete::{be_u32, be_u64, be_u8},
IResult,
};
use pisshoff_types::audit::{AuditLog, AuditLogAction, MkdirEvent, WriteFileEvent};
use std::{collections::HashMap, io::Write, mem::size_of, str::FromStr};
use strum::FromRepr;
use thrussh::{server::Session, ChannelId};
use tracing::{debug, error, trace, warn};
use uuid::Uuid;
#[derive(Default, Clone, Debug)]
pub struct Sftp {
open_files: HashMap<Uuid, String>,
pending_data: bytes::BytesMut,
}
#[async_trait]
impl Subsystem for Sftp {
const NAME: &'static str = "sftp";
#[allow(clippy::too_many_lines)]
async fn data(
&mut self,
audit_log: &mut AuditLog,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) {
self.pending_data.extend_from_slice(data);
loop {
let data = self.pending_data.split();
let packet = match WirePacket::parse(&data) {
Ok((rest, packet)) => {
self.pending_data.extend_from_slice(rest);
packet
}
Err(e) if e.is_incomplete() => {
self.pending_data.unsplit(data);
break;
}
Err(nom::Err::Error(nom::error::Error {
code: ErrorKind::Eof,
..
})) => {
self.pending_data.unsplit(data);
break;
}
Err(e) => {
error!("Bad SFTP packet {e:?}");
break;
}
};
match packet.typ {
PacketType::Init => {
session.data(
channel,
WirePacket::new(PacketType::Version, packet.request_id.min(6), &[])
.to_bytes()
.into(),
);
}
PacketType::Stat | PacketType::Lstat => {
let (_data, stat) = StatPacket::parse(packet.data).unwrap();
trace!("SFTP stat packet: {stat:?}");
session.data(
channel,
StatusResponse {
code: StatusCode::NoSuchFile,
message: "No such file or directory",
}
.to_packet(packet.request_id)
.into(),
);
}
PacketType::Open => {
let (_data, open) = OpenPacket::parse(packet.data).unwrap();
trace!("SFTP open packet: {open:?}");
let uuid = Uuid::new_v4();
self.open_files.insert(uuid, open.path.to_string());
session.data(
channel,
HandleResponse(uuid).to_packet(packet.request_id).into(),
);
}
PacketType::FSetStat | PacketType::SetStat => {
let (_data, set_stat) = FSetStatPacket::parse(packet.data).unwrap();
trace!("SFTP fsetstat packet: {set_stat:?}");
session.data(
channel,
StatusResponse {
code: StatusCode::Ok,
message: "",
}
.to_packet(packet.request_id)
.into(),
);
}
PacketType::Write => {
let (_data, write_packet) = WritePacket::parse(packet.data).unwrap();
let path = self
.open_files
.get(&Uuid::from_str(write_packet.handle).unwrap())
.unwrap();
debug!(
"Received write for {path} at offset {}: {:?}",
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(),
}));
session.data(
channel,
StatusResponse {
code: StatusCode::Ok,
message: "",
}
.to_packet(packet.request_id)
.into(),
);
}
PacketType::Close => {
let (_data, close_packet) = ClosePacket::parse(packet.data).unwrap();
trace!("SFTP close packet: {close_packet:?}");
self.open_files
.remove(&Uuid::from_str(close_packet.handle).unwrap())
.unwrap();
session.data(
channel,
StatusResponse {
code: StatusCode::Ok,
message: "",
}
.to_packet(packet.request_id)
.into(),
);
}
PacketType::RealPath => {
let (_data, real_path) = RealPathPacket::parse(packet.data).unwrap();
trace!("SFTP realpath packet: {real_path:?}");
#[allow(clippy::wildcard_in_or_patterns)]
match real_path.control {
Some(2) => {
session.data(
channel,
StatusResponse {
code: StatusCode::NoSuchFile,
message: "No such file or directory",
}
.to_packet(packet.request_id)
.into(),
);
}
Some(0 | 1) | _ => {
session.data(
channel,
NameResponse {
files: &[NameResponseFile {
name: real_path.path,
long_name: real_path.path,
attrs: FileAttrs {
typ: FileType::Unknown,
},
}],
}
.to_packet(packet.request_id)
.into(),
);
}
}
}
PacketType::Mkdir => {
let (_data, mkdir) = MkdirPacket::parse(packet.data).unwrap();
trace!("SFTP mkdir packet: {mkdir:?}");
audit_log.push_action(AuditLogAction::Mkdir(MkdirEvent {
path: mkdir.path.to_string().into_boxed_str(),
}));
session.data(
channel,
StatusResponse {
code: StatusCode::Ok,
message: "",
}
.to_packet(packet.request_id)
.into(),
);
}
_ => {
warn!("Unknown SFTP packet {packet:?}");
}
}
}
session.channel_success(channel);
session.flush_pending(channel);
}
}
fn take_length_delimited_string(rest: &[u8]) -> IResult<&[u8], &str> {
let (rest, length) = be_u32(rest)?;
map_res(take(length), std::str::from_utf8)(rest)
}
#[derive(Debug)]
struct MkdirPacket<'a> {
path: &'a str,
}
impl<'a> MkdirPacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, path) = take_length_delimited_string(rest)?;
Ok((rest, Self { path }))
}
}
#[derive(Debug)]
struct RealPathPacket<'a> {
path: &'a str,
control: Option<u8>,
}
impl<'a> RealPathPacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, path) = take_length_delimited_string(rest)?;
let (rest, control) = opt(be_u8)(rest)?;
Ok((rest, Self { path, control }))
}
}
#[derive(Debug)]
struct WritePacket<'a> {
handle: &'a str,
offset: u64,
data: &'a str,
}
impl<'a> WritePacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, handle) = take_length_delimited_string(rest)?;
let (rest, offset) = be_u64(rest)?;
let (rest, data) = take_length_delimited_string(rest)?;
Ok((
rest,
Self {
handle,
offset,
data,
},
))
}
}
#[derive(Debug)]
struct ClosePacket<'a> {
handle: &'a str,
}
impl<'a> ClosePacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, handle) = take_length_delimited_string(rest)?;
Ok((rest, Self { handle }))
}
}
#[derive(Debug)]
#[allow(dead_code)]
struct OpenPacket<'a> {
path: &'a str,
desired_access: u32,
flags: u32,
}
impl<'a> OpenPacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, path) = take_length_delimited_string(rest)?;
let (rest, desired_access) = be_u32(rest)?;
let (rest, flags) = be_u32(rest)?;
Ok((
rest,
Self {
path,
desired_access,
flags,
},
))
}
}
#[derive(Debug)]
#[allow(dead_code)]
struct FSetStatPacket<'a> {
handle: &'a str,
}
impl<'a> FSetStatPacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, handle) = take_length_delimited_string(rest)?;
Ok((rest, Self { handle }))
}
}
#[derive(Debug)]
#[allow(dead_code)]
struct StatPacket<'a> {
path: &'a str,
flags: u32,
}
impl<'a> StatPacket<'a> {
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, path) = take_length_delimited_string(rest)?;
let (rest, flags) = opt(be_u32)(rest)?;
Ok((
rest,
Self {
path,
flags: flags.unwrap_or(0),
},
))
}
}
#[derive(Debug)]
struct WirePacket<'a> {
length: u32,
typ: PacketType,
request_id: u32,
data: &'a [u8],
}
impl<'a> WirePacket<'a> {
fn new(typ: PacketType, request_id: u32, data: &'a [u8]) -> Self {
Self {
length: u32::try_from(size_of::<u8>() + size_of::<u32>() + data.len()).unwrap(),
typ,
request_id,
data,
}
}
fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(
size_of::<u32>() + size_of::<u8>() + size_of::<u32>() + self.data.len(),
);
out.extend_from_slice(&self.length.to_be_bytes());
out.push(self.typ as u8);
out.extend_from_slice(&self.request_id.to_be_bytes());
out.extend_from_slice(self.data);
out
}
fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
let (rest, length) = be_u32(rest)?;
let (rest, typ) = be_u8(rest)?;
let (rest, request_id) = be_u32(rest)?;
let (rest, data) = take(
length - u32::try_from(size_of::<u8>() + size_of::<u32>()).unwrap_or(u32::MAX),
)(rest)?;
let Some(typ) = PacketType::from_repr(typ) else {
return Err(nom::Err::Failure(nom::error::Error::new(rest, nom::error::ErrorKind::Verify)));
};
Ok((
rest,
Self {
length,
typ,
request_id,
data,
},
))
}
}
#[derive(Copy, Clone, Debug, FromRepr)]
#[repr(u8)]
pub enum PacketType {
Init = 1,
Version = 2,
Open = 3,
Close = 4,
Read = 5,
Write = 6,
Lstat = 7,
Fstat = 8,
SetStat = 9,
FSetStat = 10,
OpenDir = 11,
ReadDir = 12,
Remove = 13,
Mkdir = 14,
Rmdir = 15,
RealPath = 16,
Stat = 17,
Rename = 18,
ReadLink = 19,
Link = 21,
Block = 22,
Unblock = 23,
Status = 101,
Handle = 102,
Data = 103,
Name = 104,
Attrs = 105,
Extended = 200,
ExtendedReply = 201,
}
pub struct StatusResponse<'a> {
code: StatusCode,
message: &'a str,
}
impl Response for StatusResponse<'_> {
const TYPE: PacketType = PacketType::Status;
fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(size_of::<u32>() + size_of::<u32>() + self.message.len());
out.extend_from_slice(&(self.code as u32).to_be_bytes());
out.extend_from_slice(
&u32::try_from(self.message.len())
.unwrap_or(u32::MAX)
.to_be_bytes(),
);
out.extend_from_slice(self.message.as_bytes());
out
}
}
pub struct HandleResponse(Uuid);
impl Response for HandleResponse {
const TYPE: PacketType = PacketType::Handle;
fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(size_of::<u32>() + 36);
out.extend_from_slice(&36_u32.to_be_bytes());
write!(out, "{}", self.0).unwrap();
out
}
}
pub struct NameResponse<'a> {
files: &'a [NameResponseFile<'a>],
}
impl Response for NameResponse<'_> {
const TYPE: PacketType = PacketType::Name;
fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(size_of::<u32>());
out.extend_from_slice(
&u32::try_from(self.files.len())
.unwrap_or(u32::MAX)
.to_be_bytes(),
);
for file in self.files {
out.extend_from_slice(&file.to_bytes());
}
out.push(1);
out
}
}
pub struct NameResponseFile<'a> {
name: &'a str,
long_name: &'a str,
attrs: FileAttrs,
}
impl NameResponseFile<'_> {
fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(
size_of::<u32>() + self.name.len() + size_of::<u32>() + self.long_name.len(),
);
out.extend_from_slice(
&u32::try_from(self.name.len())
.unwrap_or(u32::MAX)
.to_be_bytes(),
);
out.extend_from_slice(self.name.as_bytes());
out.extend_from_slice(
&u32::try_from(self.long_name.len())
.unwrap_or(u32::MAX)
.to_be_bytes(),
);
out.extend_from_slice(self.long_name.as_bytes());
out.extend_from_slice(&self.attrs.to_bytes());
out
}
}
#[derive(Copy, Clone, Debug)]
#[repr(u8)]
#[allow(dead_code)]
enum FileType {
Regular = 1,
Directory = 2,
Symlink = 3,
Special = 4,
Unknown = 5,
Socket = 6,
CharDevice = 7,
BlockDevice = 8,
Fifo = 9,
}
#[derive(Copy, Clone, Debug)]
struct FileAttrs {
typ: FileType,
}
impl FileAttrs {
fn to_bytes(self) -> Vec<u8> {
let mut out = Vec::with_capacity(size_of::<u32>() + size_of::<u8>());
out.extend_from_slice(&0_u32.to_be_bytes());
out.push(self.typ as u8);
out
}
}
#[derive(Copy, Clone, Debug)]
#[repr(u32)]
#[allow(dead_code)]
enum StatusCode {
Ok = 0,
Eof = 1,
NoSuchFile = 2,
PermissionDenied = 3,
Failure = 4,
BadMessage = 5,
NoConnection = 6,
ConnectionLost = 7,
OpUnsupported = 8,
InvalidHandle = 9,
NoSuchPath = 10,
FileAlreadyExists = 11,
WriteProtect = 12,
NoMedia = 13,
NoSpaceOnFilesystem = 14,
QuotaExceeded = 15,
UnknownPrincipal = 16,
LockConflict = 17,
DirNotEmpty = 18,
NotADirectory = 19,
InvalidFilename = 20,
LinkLoop = 21,
CannotDelete = 22,
InvalidParameter = 23,
FileIsADirectory = 24,
ByteRangeLockConflict = 25,
ByteRangeLockRefused = 26,
DeletePending = 27,
FileCorrupt = 28,
OwnerInvalid = 29,
GroupInvalid = 30,
NoMatchingByteRangeLock = 31,
}
trait Response {
const TYPE: PacketType;
fn to_bytes(&self) -> Vec<u8>;
fn to_packet(&self, request_id: u32) -> Vec<u8> {
WirePacket::new(Self::TYPE, request_id, &self.to_bytes()).to_bytes()
}
}
@@ -80,6 +80,19 @@ pub enum AuditLogAction {
Signal(SignalEvent),
TcpIpForward(TcpIpForwardEvent),
CancelTcpIpForward(TcpIpForwardEvent),
Mkdir(MkdirEvent),
WriteFile(WriteFileEvent),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MkdirEvent {
pub path: Box<str>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WriteFileEvent {
pub path: Box<str>,
pub content: Box<str>,
}
#[derive(Debug, Serialize, Deserialize)]