smolder 0.3.0

High-level SMB workflows, CLI tools, and remote execution for Smolder
Documentation
//! Standalone remote execution binaries.

use std::path::PathBuf;

use super::common::{
    connect_remote_exec, next_value, parse_duration, parse_exec_target,
    psexec_service_binary_from_env, run_interactive_exec, AuthArgAccumulator, AuthOptions,
    ExecTarget,
};
use crate::prelude::{ExecMode, ExecRequest};

/// One standalone remote execution workflow.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteExecTool {
    /// Runs the inline service command workflow.
    SmbExec,
    /// Runs the staged service payload workflow.
    PsExec,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedRemoteExecArgs {
    auth: AuthOptions,
    target: ExecTarget,
    request: ExecRequest,
    service_binary: Option<PathBuf>,
    interactive: bool,
}

impl RemoteExecTool {
    fn usage(self, program: &str) -> String {
        match self {
            Self::SmbExec => format!(
                "Usage:\n  {program} smb://host[:port] --command COMMAND [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]"
            ),
            Self::PsExec => format!(
                "Usage:\n  {program} smb://host[:port] --command COMMAND [--service-binary PATH] [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]\n  {program} smb://host[:port] --interactive [--command COMMAND] [--service-binary PATH] [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]"
            ),
        }
    }

    fn expected_program(self) -> &'static str {
        match self {
            Self::SmbExec => "smbexec",
            Self::PsExec => "psexec",
        }
    }

    fn mode(self) -> ExecMode {
        match self {
            Self::SmbExec => ExecMode::SmbExec,
            Self::PsExec => ExecMode::PsExec,
        }
    }

    fn label(self) -> &'static str {
        match self {
            Self::SmbExec => "smbexec",
            Self::PsExec => "psexec",
        }
    }
}

/// Runs one standalone remote execution tool.
pub async fn run_remote_exec_tool(
    tool: RemoteExecTool,
    args: Vec<String>,
) -> Result<i32, String> {
    let parsed = parse_args(tool, args)?;
    let exec = connect_remote_exec(
        &parsed.auth,
        &parsed.target,
        tool.mode(),
        parsed.service_binary.as_deref(),
    )
    .await?;

    if matches!(tool, RemoteExecTool::PsExec) && parsed.interactive {
        return run_interactive_exec(&exec, parsed.request).await;
    }

    let result = exec.run(parsed.request).await.map_err(|error| error.to_string())?;
    print!("{}", String::from_utf8_lossy(&result.stdout));
    if !result.stderr.is_empty() {
        eprint!("{}", String::from_utf8_lossy(&result.stderr));
    }

    Ok(result.exit_code)
}

fn parse_args(tool: RemoteExecTool, args: Vec<String>) -> Result<ParsedRemoteExecArgs, String> {
    let program = args
        .first()
        .cloned()
        .unwrap_or_else(|| tool.expected_program().to_string());
    let usage = tool.usage(&program);
    if args.len() < 2 {
        return Err(usage);
    }

    let mut auth = AuthArgAccumulator::default();
    let mut positionals = Vec::new();
    let mut command_text = None;
    let mut workdir = None;
    let mut timeout = None;
    let mut service_binary = None;
    let mut interactive = false;

    let mut index = 1;
    while index < args.len() {
        let token = &args[index];
        if token == "-h" || token == "--help" {
            return Err(tool.usage(&program));
        }
        if auth.parse_flag(&args, &mut index, token)? {
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--command=") {
            command_text = Some(value.to_string());
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--workdir=") {
            workdir = Some(value.to_string());
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--timeout=") {
            timeout = Some(parse_duration(value)?);
            index += 1;
            continue;
        }
        if let Some(value) = token.strip_prefix("--service-binary=") {
            service_binary = Some(PathBuf::from(value));
            index += 1;
            continue;
        }

        match token.as_str() {
            "--command" => {
                command_text = Some(next_value(&args, &mut index, "--command")?);
            }
            "--workdir" => {
                workdir = Some(next_value(&args, &mut index, "--workdir")?);
            }
            "--timeout" => {
                let value = next_value(&args, &mut index, "--timeout")?;
                timeout = Some(parse_duration(&value)?);
            }
            "--service-binary" => {
                service_binary = Some(PathBuf::from(next_value(
                    &args,
                    &mut index,
                    "--service-binary",
                )?));
            }
            "--interactive" => {
                interactive = true;
            }
            _ if token.starts_with("--") => {
                return Err(format!("unknown option: {token}\n\n{}", tool.usage(&program)));
            }
            _ => {
                positionals.push(token.as_str());
            }
        }
        index += 1;
    }

    if positionals.len() != 1 {
        return Err(format!(
            "`{}` expects exactly 1 target SMB URL\n\n{}",
            tool.label(),
            tool.usage(&program)
        ));
    }

    let auth = auth.resolve(&usage)?;
    let target = parse_exec_target(positionals[0])?;
    let request = match (tool, interactive, command_text) {
        (RemoteExecTool::SmbExec, _, Some(command_text))
        | (RemoteExecTool::PsExec, false, Some(command_text))
        | (RemoteExecTool::PsExec, true, Some(command_text)) => {
            let mut request = ExecRequest::command(command_text);
            if let Some(workdir) = workdir {
                request = request.with_working_directory(workdir);
            }
            if let Some(timeout) = timeout {
                request = request.with_timeout(timeout);
            }
            request
        }
        (RemoteExecTool::PsExec, true, None) => {
            let mut request = ExecRequest::command(String::new());
            if let Some(workdir) = workdir {
                request = request.with_working_directory(workdir);
            }
            if let Some(timeout) = timeout {
                request = request.with_timeout(timeout);
            }
            request
        }
        _ => {
            return Err(format!(
                "missing --command for `{}`\n\n{}",
                tool.label(),
                tool.usage(&program)
            ))
        }
    };

    Ok(ParsedRemoteExecArgs {
        auth,
        target,
        request,
        service_binary: if matches!(tool, RemoteExecTool::PsExec) {
            service_binary.or_else(psexec_service_binary_from_env)
        } else {
            None
        },
        interactive,
    })
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::time::Duration;

    use super::{parse_args, RemoteExecTool};
    use crate::cli::common::ExecTarget;
    use crate::prelude::ExecRequest;

    #[test]
    fn parse_smbexec_command_with_target_only_url() {
        let options = parse_args(
            RemoteExecTool::SmbExec,
            vec![
                "smbexec".to_string(),
                "smb://server:1445".to_string(),
                "--command=whoami".to_string(),
                "--timeout=30s".to_string(),
                "--username=user".to_string(),
                "--password=pass".to_string(),
            ],
        )
        .expect("parser should accept smbexec arguments");

        assert_eq!(options.auth.username, "user");
        assert_eq!(options.auth.password, "pass");
        assert_eq!(
            options.target,
            ExecTarget {
                host: "server".to_string(),
                port: 1445,
            }
        );
        assert_eq!(
            options.request,
            ExecRequest::command("whoami").with_timeout(Duration::from_secs(30))
        );
        assert_eq!(options.service_binary, None);
        assert!(!options.interactive);
    }

    #[test]
    fn parse_psexec_command_accepts_workdir() {
        let options = parse_args(
            RemoteExecTool::PsExec,
            vec![
                "psexec".to_string(),
                "smb://server".to_string(),
                "--command".to_string(),
                "dir".to_string(),
                "--workdir".to_string(),
                "C:\\Temp".to_string(),
                "--username=user".to_string(),
                "--password=pass".to_string(),
            ],
        )
        .expect("parser should accept psexec arguments");

        assert_eq!(options.auth.username, "user");
        assert_eq!(options.auth.password, "pass");
        assert_eq!(
            options.target,
            ExecTarget {
                host: "server".to_string(),
                port: 445,
            }
        );
        assert_eq!(
            options.request,
            ExecRequest::command("dir").with_working_directory("C:\\Temp")
        );
        assert_eq!(options.service_binary, None);
        assert!(!options.interactive);
    }

    #[test]
    fn parse_psexec_command_accepts_service_binary() {
        let options = parse_args(
            RemoteExecTool::PsExec,
            vec![
                "psexec".to_string(),
                "smb://server".to_string(),
                "--command=dir".to_string(),
                "--service-binary".to_string(),
                "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe".to_string(),
                "--username=user".to_string(),
                "--password=pass".to_string(),
            ],
        )
        .expect("parser should accept psexec service-binary arguments");

        assert_eq!(
            options.service_binary,
            Some(PathBuf::from(
                "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe"
            ))
        );
        assert!(!options.interactive);
    }

    #[test]
    fn parse_interactive_psexec_allows_missing_command() {
        let options = parse_args(
            RemoteExecTool::PsExec,
            vec![
                "psexec".to_string(),
                "smb://server".to_string(),
                "--interactive".to_string(),
                "--username=user".to_string(),
                "--password=pass".to_string(),
            ],
        )
        .expect("parser should accept interactive psexec arguments");

        assert!(options.interactive);
        assert_eq!(options.request, ExecRequest::command(String::new()));
    }

    #[cfg(feature = "kerberos")]
    #[test]
    fn parse_smbexec_command_accepts_kerberos_flags() {
        let options = parse_args(
            RemoteExecTool::SmbExec,
            vec![
                "smbexec".to_string(),
                "smb://127.0.0.1".to_string(),
                "--command".to_string(),
                "whoami".to_string(),
                "--kerberos".to_string(),
                "--username".to_string(),
                "smolder@LAB.EXAMPLE".to_string(),
                "--password".to_string(),
                "Passw0rd!".to_string(),
                "--target-host".to_string(),
                "DESKTOP-PTNJUS5.lab.example".to_string(),
                "--realm".to_string(),
                "LAB.EXAMPLE".to_string(),
                "--kdc-url".to_string(),
                "tcp://dc1.lab.example:1088".to_string(),
            ],
        )
        .expect("parser should accept kerberos smbexec arguments");

        assert!(matches!(options.auth.mode, crate::cli::common::AuthMode::Kerberos));
        assert_eq!(
            options.auth.kerberos.target_host.as_deref(),
            Some("DESKTOP-PTNJUS5.lab.example")
        );
        assert_eq!(options.auth.kerberos.realm.as_deref(), Some("LAB.EXAMPLE"));
        assert_eq!(
            options.auth.kerberos.kdc_url.as_deref(),
            Some("tcp://dc1.lab.example:1088")
        );
    }
}