Files
Michael Mikovsky 989b5057d0 Add Docs
2026-02-20 14:05:43 -07:00

10 KiB

unshell

A fully modular, pluggable framework for building cross-platform endpoint agents that integrate with existing toolsets.

Design Goals

  • 100% Modular - Every component is replaceable at runtime. Nothing is hardcoded.
  • Tool Integration - Drop in Metasploit payloads, Cobalt Strike beacons, or any external implant
  • Cross-Platform - Full Rust cross-compilation support for Windows, Linux, macOS, and embedded targets
  • Minimal Footprint - Compile-time obfuscation and size optimization for stealthy payloads

Philosophy

Nothing is fixed. Every part of the system is a plugin:

  • Transports - TCP, HTTP, DNS, WebSocket, custom
  • Protocols - Encryption, encoding, framing - all swappable
  • Payloads - Metasploit, Cobalt Strike, custom - just load and run
  • Components - Any Rust struct can be a module
  • Communication - Tree-based routing with replaceable backends

Architecture

unshell/
├── src/tree/           # Hierarchical message routing
│   ├── component.rs    # Component trait (implement for any module)
│   ├── endpoint.rs     # Endpoint manager
│   ├── protocols/      # Pluggable protocol stack
│   └── tcp/           # Example transport implementations
├── ush-obfuscate/     # Compile-time string obfuscation
└── ush-payload/       # Test harness

Core Traits

Everything plugs into these abstractions:

TreeElement - The Foundation

Every node in the tree implements this trait:

use serde_json::Value;
use unshell::tree::TreeElement;

pub trait TreeElement: Send + Sync {
    fn get_type(&self) -> Value;
    fn send_message(&mut self, target: Value, message: Value) -> Value;
}
  • get_type() returns the element's type identifier
  • send_message() handles incoming messages and returns responses

Component - Extensible Modules

use unshell::tree::Component;
use serde_json::Value;

pub trait Component: Send + Sync {
    fn name(&self) -> &str;
    fn status(&self) -> Value;
    fn init(&mut self, config: Value) -> Result<(), String>;
    fn shutdown(&mut self) -> Result<(), String>;
}

Important: The init() method should only configure the component, not establish connections. For TcpClient, use auto_connect: true in config if you need auto-connection after initialization.

Protocol - Any Encoding Layer

use unshell::protocols::Protocol;

pub trait Protocol: Send + Sync {
    fn name(&self) -> &'static str;
    fn encode(&self, data: &[u8]) -> Result<Vec<u8>, ProtocolError>;
    fn decode(&self, data: &[u8]) -> Result<Vec<u8>, ProtocolError>;
}

Transport - Any Connection

// Transports connect to networks - TCP, HTTP, DNS, custom
// Implement send/recv and register with the transport registry

Payload - Any External Implant

// External payloads (Metasploit, Cobalt Strike, etc.) load as components
// They expose the same interface as native components

Tree Message Protocol

Messages follow a JSON-based format defined in src/tree/message.rs:

// Create a request
let msg = TreeMessage::new("rpc.call")
    .to_target(["components", "tcp-client"])
    .with_payload(json!({
        "method": "connect",
        "params": {"address": "127.0.0.1", "port": 443}
    }));

// Send via protocol stack
let encoded = protocol_stack.encode_message(&msg)?;

Message Types

Type Description
req Request - expecting a response
resp Response - reply to a request
event Unsolicited notification
stream Stream data message

ComponentRegistry Usage

use unshell::tree::{ComponentRegistry, Component};

// Create registry
let mut registry = ComponentRegistry::new();

// Register components
let client = Box::new(TcpClient::new("my-client"));
registry.register(client).unwrap();

// List components
let names = registry.list();

// Send to specific component
let result = registry.send_to_component("my-client", json!({"method": "status"}));

// Broadcast to all
let results = registry.broadcast(json!({"method": "status"}));

// Shutdown all gracefully
let shutdown_results = registry.shutdown_all();

// Remove component
registry.remove("my-client");

Logging

The framework includes a feature-gated logging system. Use the logging macros:

use unshell::{info, warn, error};

// Info messages
info!("Component '{}' registered", name);

// Warnings
warn!("Component '{}' not found", name);

// Errors
error!("Connection failed: {}", err);

Enable logging with the log feature:

unshell = { path = ".", features = ["log"] }

Module System

use unshell::{ModuleRuntime, Manager};
use unshell::config::RuntimeConfig;

// Define a module
pub struct MyModule;

// Implement runtime lifecycle
impl ModuleRuntime for MyModule {
    fn init(&mut self, manager: Arc<Mutex<Manager>>) -> Result<()>;
    fn is_running(&self) -> bool;
    fn kill(self: Box<Self>);
}

// Export via FFI for dynamic loading
#[unsafe(no_mangle)]
pub fn get_components() -> Vec<NamedComponent> {
    vec![NamedComponent { name: "mymodule", ... }]
}

Load compiled .so/.dll modules at runtime using libloading or in-memory via memfd_create.

Protocol Stacking

Layer protocols arbitrarily:

use ush_payload::protocols::{ProtocolStack, ProtocolConfig};

// Create stack: base64 -> http -> tcp
let mut stack = ProtocolStack::new();
stack.push(&ProtocolConfig::Base64(Default::default())).unwrap();
stack.push(&ProtocolConfig::Http(Default::default())).unwrap();
stack.push(&ProtocolConfig::Tcp(Default::default())).unwrap();

// Encode: app -> base64 -> http -> tcp -> network
let encoded = stack.encode(data)?;

// Decode: network -> tcp -> http -> base64 -> app
let decoded = stack.decode(&encoded)?;

Order determines encoding: app → base64 → http → tcp → network

Integration Examples

Load a Metasploit Payload

// Load precompiled Metasploit .so
let module = Module::new("meterpreter.so")?;
// Or load from raw bytes (in-memory execution)
let module = Module::new_bytes(&meterpreter_bytes)?;

Use Cobalt Strike Beacon

// Beacon loads as a component with standard interface
let beacon = CobaltBeacon::new(config);
component_registry.register(Box::new(beacon)).unwrap();
// Communicate via tree messages - same as any other component

Custom Transport

// Implement Protocol trait
pub struct DnsTransport { ... }
impl Protocol for DnsTransport {
    fn encode(&self, data: &[u8]) -> Result<Vec<u8>, ProtocolError> {
        // Encode as DNS TXT records
    }
    fn decode(&self, data: &[u8]) -> Result<Vec<u8>, ProtocolError> {
        // Decode DNS responses
    }
}
// Register and use
stack.push(&ProtocolConfig::Custom { name: "dns", config: ... });

Create a Custom Component

use unshell::tree::{Component, TreeElement, Branch};
use serde_json::{json, Value};

pub struct MyComponent {
    name: String,
    config: MyConfig,
}

impl Component for MyComponent {
    fn name(&self) -> &str { &self.name }
    
    fn status(&self) -> Value {
        json!({"active": true, "name": self.name})
    }
    
    fn init(&mut self, config: Value) -> Result<(), String> {
        // Configure only - don't connect here
        self.config = serde_json::from_value(config)?;
        Ok(())
    }
    
    fn shutdown(&mut self) -> Result<(), String> {
        Ok(())
    }
}

// Register in component registry
let mut registry = ComponentRegistry::new();
registry.register(Box::new(MyComponent::new("my-component"))).unwrap();

Cross-Compilation

# Windows x64
rustup target add x86_64-pc-windows-gnu
cargo build --target x86_64-pc-windows-gnu

# ARM64 Linux
rustup target add aarch64-unknown-linux-gnu
cargo build --target aarch64-unknown-linux-gnu

# macOS
rustup target add x86_64-apple-darwin
cargo build --target x86_64-apple-darwin

Building

# Standard build (~500KB)
cargo build

# Size-optimized (~50KB)
cargo build --profile minimize

# With obfuscation
cargo build --features obfuscate

Testing

# Run the test harness
cd ush-payload
cargo run

# Run library tests
cargo test -p ush-payload --lib

Obfuscation

Compile-time string obfuscation to evade static analysis:

use ush_obfuscate::symbol;

const API_KEY: &str = symbol!("SuperSecretKey123");
const C2_URL: &str = symbol!("https://C2Server/endpoint");

Roadmap

  • Protocol registry for runtime registration
  • Payload loader for common frameworks
  • Transport abstraction layer
  • Hot-swap components at runtime

Dependencies

  • libloading - Dynamic library loading
  • serde_json - Serialization
  • crossbeam-channel - Message passing
  • base64 - Encoding
  • thiserror - Error handling

License

MIT / Apache-2.0

Implementation Notes

Obfuscation Macros

The ush-obfuscate crate provides two macros for string obfuscation:

  • sym!() - For static strings that are used at runtime. Uses AES encryption.
  • xor!() - For simple XOR obfuscation.

When the obfuscate feature is enabled, strings are encrypted at compile time. When disabled, they remain as plain strings.

Using Sym Constants

To avoid redundant sym!() calls, define constants in src/tree/symbols.rs:

use crate::obfuscate::sym;

pub const MY_CONSTANT: &str = sym!("MyString");

Then use the constant in your code:

use unshell::tree::symbols::*;

json!({ KEY_NAME: "value" })

Module Organization

  • src/tree/ - Core tree system (routing, components, messages)
  • ush-payload/src/ - Protocol implementations, TCP client/server, connection management

Protocol-specific and transport-specific code belongs in ush-payload, while the tree system handles hierarchical message routing only.

Building for Size

./build.sh  # Builds with minimize profile and strips debug sections

This produces a ~200KB binary (may vary with content).

Component Design Guidelines

  1. init() should configure, not connect: Only establish connections if explicitly requested via config
  2. Use symbols for string constants: All user-facing strings should use sym!() for obfuscation
  3. Log important operations: Use the logging macros for registration, connection, and errors
  4. Return structured responses: Use JSON with success, result, and error fields