Skip to main content

smolder_tools/cli/
remote_exec_tools.rs

1//! Standalone remote execution binaries.
2
3use std::path::PathBuf;
4
5use super::common::{
6    connect_remote_exec, next_value, parse_duration, parse_exec_target,
7    psexec_service_binary_from_env, run_interactive_exec, AuthArgAccumulator, AuthOptions,
8    ExecTarget,
9};
10use crate::prelude::{ExecMode, ExecRequest};
11
12/// One standalone remote execution workflow.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RemoteExecTool {
15    /// Runs the inline service command workflow.
16    SmbExec,
17    /// Runs the staged service payload workflow.
18    PsExec,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct ParsedRemoteExecArgs {
23    auth: AuthOptions,
24    target: ExecTarget,
25    request: ExecRequest,
26    service_binary: Option<PathBuf>,
27    interactive: bool,
28}
29
30impl RemoteExecTool {
31    fn usage(self, program: &str) -> String {
32        match self {
33            Self::SmbExec => format!(
34                "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]"
35            ),
36            Self::PsExec => format!(
37                "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]"
38            ),
39        }
40    }
41
42    fn expected_program(self) -> &'static str {
43        match self {
44            Self::SmbExec => "smbexec",
45            Self::PsExec => "psexec",
46        }
47    }
48
49    fn mode(self) -> ExecMode {
50        match self {
51            Self::SmbExec => ExecMode::SmbExec,
52            Self::PsExec => ExecMode::PsExec,
53        }
54    }
55
56    fn label(self) -> &'static str {
57        match self {
58            Self::SmbExec => "smbexec",
59            Self::PsExec => "psexec",
60        }
61    }
62}
63
64/// Runs one standalone remote execution tool.
65pub async fn run_remote_exec_tool(
66    tool: RemoteExecTool,
67    args: Vec<String>,
68) -> Result<i32, String> {
69    let parsed = parse_args(tool, args)?;
70    let exec = connect_remote_exec(
71        &parsed.auth,
72        &parsed.target,
73        tool.mode(),
74        parsed.service_binary.as_deref(),
75    )
76    .await?;
77
78    if matches!(tool, RemoteExecTool::PsExec) && parsed.interactive {
79        return run_interactive_exec(&exec, parsed.request).await;
80    }
81
82    let result = exec.run(parsed.request).await.map_err(|error| error.to_string())?;
83    print!("{}", String::from_utf8_lossy(&result.stdout));
84    if !result.stderr.is_empty() {
85        eprint!("{}", String::from_utf8_lossy(&result.stderr));
86    }
87
88    Ok(result.exit_code)
89}
90
91fn parse_args(tool: RemoteExecTool, args: Vec<String>) -> Result<ParsedRemoteExecArgs, String> {
92    let program = args
93        .first()
94        .cloned()
95        .unwrap_or_else(|| tool.expected_program().to_string());
96    let usage = tool.usage(&program);
97    if args.len() < 2 {
98        return Err(usage);
99    }
100
101    let mut auth = AuthArgAccumulator::default();
102    let mut positionals = Vec::new();
103    let mut command_text = None;
104    let mut workdir = None;
105    let mut timeout = None;
106    let mut service_binary = None;
107    let mut interactive = false;
108
109    let mut index = 1;
110    while index < args.len() {
111        let token = &args[index];
112        if token == "-h" || token == "--help" {
113            return Err(tool.usage(&program));
114        }
115        if auth.parse_flag(&args, &mut index, token)? {
116            index += 1;
117            continue;
118        }
119        if let Some(value) = token.strip_prefix("--command=") {
120            command_text = Some(value.to_string());
121            index += 1;
122            continue;
123        }
124        if let Some(value) = token.strip_prefix("--workdir=") {
125            workdir = Some(value.to_string());
126            index += 1;
127            continue;
128        }
129        if let Some(value) = token.strip_prefix("--timeout=") {
130            timeout = Some(parse_duration(value)?);
131            index += 1;
132            continue;
133        }
134        if let Some(value) = token.strip_prefix("--service-binary=") {
135            service_binary = Some(PathBuf::from(value));
136            index += 1;
137            continue;
138        }
139
140        match token.as_str() {
141            "--command" => {
142                command_text = Some(next_value(&args, &mut index, "--command")?);
143            }
144            "--workdir" => {
145                workdir = Some(next_value(&args, &mut index, "--workdir")?);
146            }
147            "--timeout" => {
148                let value = next_value(&args, &mut index, "--timeout")?;
149                timeout = Some(parse_duration(&value)?);
150            }
151            "--service-binary" => {
152                service_binary = Some(PathBuf::from(next_value(
153                    &args,
154                    &mut index,
155                    "--service-binary",
156                )?));
157            }
158            "--interactive" => {
159                interactive = true;
160            }
161            _ if token.starts_with("--") => {
162                return Err(format!("unknown option: {token}\n\n{}", tool.usage(&program)));
163            }
164            _ => {
165                positionals.push(token.as_str());
166            }
167        }
168        index += 1;
169    }
170
171    if positionals.len() != 1 {
172        return Err(format!(
173            "`{}` expects exactly 1 target SMB URL\n\n{}",
174            tool.label(),
175            tool.usage(&program)
176        ));
177    }
178
179    let auth = auth.resolve(&usage)?;
180    let target = parse_exec_target(positionals[0])?;
181    let request = match (tool, interactive, command_text) {
182        (RemoteExecTool::SmbExec, _, Some(command_text))
183        | (RemoteExecTool::PsExec, false, Some(command_text))
184        | (RemoteExecTool::PsExec, true, Some(command_text)) => {
185            let mut request = ExecRequest::command(command_text);
186            if let Some(workdir) = workdir {
187                request = request.with_working_directory(workdir);
188            }
189            if let Some(timeout) = timeout {
190                request = request.with_timeout(timeout);
191            }
192            request
193        }
194        (RemoteExecTool::PsExec, true, None) => {
195            let mut request = ExecRequest::command(String::new());
196            if let Some(workdir) = workdir {
197                request = request.with_working_directory(workdir);
198            }
199            if let Some(timeout) = timeout {
200                request = request.with_timeout(timeout);
201            }
202            request
203        }
204        _ => {
205            return Err(format!(
206                "missing --command for `{}`\n\n{}",
207                tool.label(),
208                tool.usage(&program)
209            ))
210        }
211    };
212
213    Ok(ParsedRemoteExecArgs {
214        auth,
215        target,
216        request,
217        service_binary: if matches!(tool, RemoteExecTool::PsExec) {
218            service_binary.or_else(psexec_service_binary_from_env)
219        } else {
220            None
221        },
222        interactive,
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use std::path::PathBuf;
229    use std::time::Duration;
230
231    use super::{parse_args, RemoteExecTool};
232    use crate::cli::common::ExecTarget;
233    use crate::prelude::ExecRequest;
234
235    #[test]
236    fn parse_smbexec_command_with_target_only_url() {
237        let options = parse_args(
238            RemoteExecTool::SmbExec,
239            vec![
240                "smbexec".to_string(),
241                "smb://server:1445".to_string(),
242                "--command=whoami".to_string(),
243                "--timeout=30s".to_string(),
244                "--username=user".to_string(),
245                "--password=pass".to_string(),
246            ],
247        )
248        .expect("parser should accept smbexec arguments");
249
250        assert_eq!(options.auth.username, "user");
251        assert_eq!(options.auth.password, "pass");
252        assert_eq!(
253            options.target,
254            ExecTarget {
255                host: "server".to_string(),
256                port: 1445,
257            }
258        );
259        assert_eq!(
260            options.request,
261            ExecRequest::command("whoami").with_timeout(Duration::from_secs(30))
262        );
263        assert_eq!(options.service_binary, None);
264        assert!(!options.interactive);
265    }
266
267    #[test]
268    fn parse_psexec_command_accepts_workdir() {
269        let options = parse_args(
270            RemoteExecTool::PsExec,
271            vec![
272                "psexec".to_string(),
273                "smb://server".to_string(),
274                "--command".to_string(),
275                "dir".to_string(),
276                "--workdir".to_string(),
277                "C:\\Temp".to_string(),
278                "--username=user".to_string(),
279                "--password=pass".to_string(),
280            ],
281        )
282        .expect("parser should accept psexec arguments");
283
284        assert_eq!(options.auth.username, "user");
285        assert_eq!(options.auth.password, "pass");
286        assert_eq!(
287            options.target,
288            ExecTarget {
289                host: "server".to_string(),
290                port: 445,
291            }
292        );
293        assert_eq!(
294            options.request,
295            ExecRequest::command("dir").with_working_directory("C:\\Temp")
296        );
297        assert_eq!(options.service_binary, None);
298        assert!(!options.interactive);
299    }
300
301    #[test]
302    fn parse_psexec_command_accepts_service_binary() {
303        let options = parse_args(
304            RemoteExecTool::PsExec,
305            vec![
306                "psexec".to_string(),
307                "smb://server".to_string(),
308                "--command=dir".to_string(),
309                "--service-binary".to_string(),
310                "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe".to_string(),
311                "--username=user".to_string(),
312                "--password=pass".to_string(),
313            ],
314        )
315        .expect("parser should accept psexec service-binary arguments");
316
317        assert_eq!(
318            options.service_binary,
319            Some(PathBuf::from(
320                "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe"
321            ))
322        );
323        assert!(!options.interactive);
324    }
325
326    #[test]
327    fn parse_interactive_psexec_allows_missing_command() {
328        let options = parse_args(
329            RemoteExecTool::PsExec,
330            vec![
331                "psexec".to_string(),
332                "smb://server".to_string(),
333                "--interactive".to_string(),
334                "--username=user".to_string(),
335                "--password=pass".to_string(),
336            ],
337        )
338        .expect("parser should accept interactive psexec arguments");
339
340        assert!(options.interactive);
341        assert_eq!(options.request, ExecRequest::command(String::new()));
342    }
343
344    #[cfg(feature = "kerberos")]
345    #[test]
346    fn parse_smbexec_command_accepts_kerberos_flags() {
347        let options = parse_args(
348            RemoteExecTool::SmbExec,
349            vec![
350                "smbexec".to_string(),
351                "smb://127.0.0.1".to_string(),
352                "--command".to_string(),
353                "whoami".to_string(),
354                "--kerberos".to_string(),
355                "--username".to_string(),
356                "smolder@LAB.EXAMPLE".to_string(),
357                "--password".to_string(),
358                "Passw0rd!".to_string(),
359                "--target-host".to_string(),
360                "DESKTOP-PTNJUS5.lab.example".to_string(),
361                "--realm".to_string(),
362                "LAB.EXAMPLE".to_string(),
363                "--kdc-url".to_string(),
364                "tcp://dc1.lab.example:1088".to_string(),
365            ],
366        )
367        .expect("parser should accept kerberos smbexec arguments");
368
369        assert!(matches!(options.auth.mode, crate::cli::common::AuthMode::Kerberos));
370        assert_eq!(
371            options.auth.kerberos.target_host.as_deref(),
372            Some("DESKTOP-PTNJUS5.lab.example")
373        );
374        assert_eq!(options.auth.kerberos.realm.as_deref(), Some("LAB.EXAMPLE"));
375        assert_eq!(
376            options.auth.kerberos.kdc_url.as_deref(),
377            Some("tcp://dc1.lab.example:1088")
378        );
379    }
380}