Skip to main content

ploit_malper/exploit/
msf.rs

1use std::collections::BTreeMap;
2use std::time::Duration;
3
4use crate::engine::msfrpc::MsfRpcClient;
5use crate::error::{AppError, Result};
6use crate::msgpack::MsgValue;
7
8use super::models::*;
9use super::ExploitFramework;
10
11/// Metasploit implementation of [`ExploitFramework`].
12///
13/// All framework communication is delegated to the existing `MsfRpcClient`,
14/// keeping Metasploit specifics isolated behind the trait.
15pub struct MsfFramework {
16    client: Option<MsfRpcClient>,
17    host: String,
18    port: u16,
19    ssl: bool,
20    username: String,
21    password: String,
22    workspace: String,
23}
24
25impl MsfFramework {
26    pub fn new(host: &str, port: u16, username: &str, password: &str, ssl: bool) -> Self {
27        Self {
28            client: None,
29            host: host.to_string(),
30            port,
31            ssl,
32            username: username.to_string(),
33            password: password.to_string(),
34            workspace: "default".to_string(),
35        }
36    }
37}
38
39impl ExploitFramework for MsfFramework {
40    fn name(&self) -> &'static str {
41        "metasploit"
42    }
43
44    fn workspace(&self) -> &str {
45        &self.workspace
46    }
47
48    fn set_workspace(&mut self, workspace: &str) {
49        self.workspace = workspace.to_string();
50    }
51
52    fn connect(&mut self) -> Result<()> {
53        let mut client = MsfRpcClient::new(
54            &self.host,
55            self.port,
56            &self.username,
57            &self.password,
58            self.ssl,
59        );
60        let login = client
61            .login()
62            .map_err(|e| AppError::Network(format!("MSF-RPC login transport failure: {e}")))?;
63        if !login.success {
64            return Err(AppError::Message(format!(
65                "MSF-RPC authentication failed: {}",
66                login.error
67            )));
68        }
69        self.client = Some(client);
70        Ok(())
71    }
72
73    fn disconnect(&mut self) {
74        if let Some(client) = self.client.as_mut() {
75            client.logout();
76        }
77        self.client = None;
78    }
79
80    fn is_connected(&self) -> bool {
81        self.client
82            .as_ref()
83            .is_some_and(MsfRpcClient::is_authenticated)
84    }
85
86    fn module_info(&mut self, module_type: &str, name: &str) -> Result<ModuleInfo> {
87        let client = self.client()?;
88        let raw = client.module_info(module_type, name)?;
89        let mut info = parse_module_info(module_type, name, &raw)?;
90        // `module.info` returns metadata only; the real datastore options come
91        // from `module.options`. Merge them so required options and defaults
92        // reflect the connected instance rather than offline hints.
93        if let Ok(options_raw) = client.module_options(module_type, name) {
94            let options = parse_options(&options_raw);
95            info.options = options.clone();
96            // "Required" here means required AND without a default: an option
97            // that is required:true but carries a framework default (e.g.
98            // ShowProgress, THREADS) never needs operator input. This keeps
99            // `required_options` aligned with the offline path.
100            info.required_options = options
101                .iter()
102                .filter(|opt| opt.required && opt.default.is_none())
103                .map(|opt| opt.name.clone())
104                .collect();
105        }
106        Ok(info)
107    }
108
109    fn run_module(
110        &mut self,
111        module_type: &str,
112        name: &str,
113        options: &BTreeMap<String, String>,
114        payload: Option<&str>,
115    ) -> Result<ModuleRunResult> {
116        let client = self.client()?;
117        let raw = client.module_execute(module_type, name, options, payload)?;
118        parse_run_result(&raw)
119    }
120
121    fn monitor_job(&mut self, job_id: &str, timeout: Duration) -> Result<JobStatus> {
122        let client = self.client()?;
123        let started = std::time::Instant::now();
124        loop {
125            match client.job_list() {
126                Ok(list) => {
127                    let present = job_present(&list, job_id);
128                    if !present {
129                        return Ok(JobStatus {
130                            job_id: job_id.to_string(),
131                            running: false,
132                            status: "completed".to_string(),
133                            error: None,
134                        });
135                    }
136                }
137                Err(e) => {
138                    return Ok(JobStatus {
139                        job_id: job_id.to_string(),
140                        running: false,
141                        status: "failed".to_string(),
142                        error: Some(e.to_string()),
143                    });
144                }
145            }
146            if started.elapsed() >= timeout {
147                return Ok(JobStatus {
148                    job_id: job_id.to_string(),
149                    running: true,
150                    status: "still_running".to_string(),
151                    error: None,
152                });
153            }
154            std::thread::sleep(Duration::from_millis(1000));
155        }
156    }
157
158    fn list_sessions(&mut self) -> Result<Vec<SessionInfo>> {
159        let client = self.client()?;
160        let raw = client
161            .session_list()
162            .map_err(|e| AppError::Network(e.to_string()))?;
163        Ok(parse_sessions(&raw))
164    }
165
166    fn list_loot(&mut self) -> Result<Vec<LootInfo>> {
167        let workspace = self.workspace.clone();
168        let client = self.client()?;
169        let raw = client
170            .db_loots(&workspace)
171            .map_err(|e| AppError::Network(e.to_string()))?;
172        Ok(parse_loot(&raw))
173    }
174}
175
176impl MsfFramework {
177    fn client(&mut self) -> Result<&mut MsfRpcClient> {
178        self.client.as_mut().ok_or_else(|| {
179            AppError::Message("MSF-RPC not connected; call connect() first".to_string())
180        })
181    }
182}
183
184fn parse_module_info(module_type: &str, name: &str, raw: &MsgValue) -> Result<ModuleInfo> {
185    let rank = raw
186        .get("rank")
187        .and_then(MsgValue::as_str)
188        .map(str::to_string)
189        .or_else(|| {
190            // Modern servers report rank as a numeric value; translate it back
191            // to the familiar label so the plan stays readable.
192            raw.get("rank").and_then(MsgValue::as_u64).map(rank_label)
193        })
194        .unwrap_or_default();
195    let description = raw
196        .get("description")
197        .and_then(MsgValue::as_str)
198        .unwrap_or("")
199        .to_string();
200
201    let mut platforms = parse_strings(raw, "platforms");
202    if platforms.is_empty() {
203        if let Some(single) = raw.get("platform").and_then(MsgValue::as_str) {
204            platforms.push(single.to_string());
205        }
206    }
207    let targets = parse_strings(raw, "targets");
208
209    let options = parse_options(raw);
210    // Canonical definition: an option is "required" only when it is marked
211    // required AND carries no default. Options that are required but defaulted
212    // (ShowProgress, THREADS, ...) never need operator input, so they must not
213    // surface in the required list.
214    let required_options = options
215        .iter()
216        .filter(|opt| opt.required && opt.default.is_none())
217        .map(|opt| opt.name.clone())
218        .collect();
219
220    Ok(ModuleInfo {
221        name: name.to_string(),
222        module_type: module_type.to_string(),
223        rank,
224        description,
225        platforms,
226        targets,
227        options,
228        required_options,
229    })
230}
231
232/// Translate Metasploit's numeric rank value to the familiar label.
233fn rank_label(rank: u64) -> String {
234    match rank {
235        0 => "manual".to_string(),
236        100 => "low".to_string(),
237        200 => "average".to_string(),
238        300 => "normal".to_string(),
239        400 => "good".to_string(),
240        500 => "great".to_string(),
241        600 => "excellent".to_string(),
242        other => format!("rank-{other}"),
243    }
244}
245
246fn parse_options(raw: &MsgValue) -> Vec<ModuleOption> {
247    let mut options = Vec::new();
248    if let Some(map) = raw.get("options").and_then(MsgValue::as_map) {
249        for (name, opt_val) in map {
250            let required = opt_val.get("required").is_some_and(|v| match v {
251                MsgValue::Bool(b) => *b,
252                MsgValue::String(s) => s == "true",
253                MsgValue::UInt(n) => *n != 0,
254                MsgValue::Int(n) => *n != 0,
255                _ => false,
256            });
257            let default = opt_val.get("default").and_then(parse_default_value);
258            let description = opt_val
259                .get("desc")
260                .and_then(MsgValue::as_str)
261                .unwrap_or("")
262                .to_string();
263            options.push(ModuleOption {
264                name: name.clone(),
265                required,
266                default,
267                description,
268            });
269        }
270    }
271    options
272}
273
274/// Convert a framework option default into its canonical string form so every
275/// scalar (bool/int/uint/float/string) is represented identically. A
276/// non-scalar or absent default maps to `None` ("no default"); an empty string
277/// default stays `Some("")` — it is distinct from "no default".
278fn parse_default_value(value: &MsgValue) -> Option<String> {
279    match value {
280        MsgValue::Bool(b) => Some(b.to_string()),
281        MsgValue::Int(n) => Some(n.to_string()),
282        MsgValue::UInt(n) => Some(n.to_string()),
283        MsgValue::Float(f) => Some(format_float(f)),
284        MsgValue::String(s) => Some(s.clone()),
285        _ => None,
286    }
287}
288
289/// Stable string form for a float default. `to_string` on f64 yields the
290/// shortest round-trippable representation (e.g. `2.0` -> `"2"`, `2.5` ->
291/// `"2.5"`), which is what MSF expects for a datastore value.
292fn format_float(value: &f64) -> String {
293    value.to_string()
294}
295
296fn parse_run_result(raw: &MsgValue) -> Result<ModuleRunResult> {
297    let raw_text = format!("{raw:?}");
298
299    let error_flag = match raw.get("error") {
300        Some(MsgValue::Bool(b)) => *b,
301        Some(MsgValue::String(s)) => s == "true",
302        Some(MsgValue::UInt(n)) => *n != 0,
303        _ => false,
304    };
305    if error_flag {
306        let message = raw
307            .get("error_message")
308            .or_else(|| raw.get("error_string"))
309            .and_then(MsgValue::as_str)
310            .unwrap_or("module execution failed")
311            .to_string();
312        return Ok(ModuleRunResult {
313            job_id: None,
314            success: false,
315            message,
316            raw: raw_text,
317        });
318    }
319
320    let job_id = raw
321        .get("job_id")
322        .and_then(MsgValue::as_u64)
323        .map(|id| id.to_string());
324    let message = raw
325        .get("message")
326        .and_then(MsgValue::as_str)
327        .unwrap_or("module started")
328        .to_string();
329    Ok(ModuleRunResult {
330        job_id,
331        success: true,
332        message,
333        raw: raw_text,
334    })
335}
336
337fn job_present(list: &MsgValue, job_id: &str) -> bool {
338    if let Some(map) = list.get("jobs").and_then(MsgValue::as_map) {
339        map.keys().any(|id| id == job_id)
340    } else if let Some(map) = list.as_map() {
341        map.get("jobs").is_none() && map.keys().any(|id| id == job_id)
342    } else {
343        false
344    }
345}
346
347fn parse_sessions(raw: &MsgValue) -> Vec<SessionInfo> {
348    let mut sessions = Vec::new();
349    if let Some(map) = raw.get("sessions").and_then(MsgValue::as_map) {
350        for (id, val) in map {
351            let typ = val
352                .get("type")
353                .or_else(|| val.get("session_type"))
354                .and_then(MsgValue::as_str)
355                .unwrap_or("")
356                .to_string();
357            let target_host = val
358                .get("target_host")
359                .or_else(|| val.get("host"))
360                .and_then(MsgValue::as_str)
361                .unwrap_or("")
362                .to_string();
363            let platform = val
364                .get("platform")
365                .and_then(MsgValue::as_str)
366                .unwrap_or("")
367                .to_string();
368            let info = val
369                .get("info")
370                .and_then(MsgValue::as_str)
371                .unwrap_or("")
372                .to_string();
373            sessions.push(SessionInfo {
374                id: id.clone(),
375                typ,
376                target_host,
377                platform,
378                info,
379            });
380        }
381    }
382    sessions
383}
384
385fn parse_loot(raw: &MsgValue) -> Vec<LootInfo> {
386    let mut loot = Vec::new();
387    if let Some(array) = raw.get("loot").and_then(MsgValue::as_array) {
388        for val in array {
389            let id = val
390                .get("loot_id")
391                .or_else(|| val.get("id"))
392                .and_then(MsgValue::as_str)
393                .unwrap_or("")
394                .to_string();
395            let name = val
396                .get("name")
397                .and_then(MsgValue::as_str)
398                .unwrap_or("")
399                .to_string();
400            let content_type = val
401                .get("content_type")
402                .and_then(MsgValue::as_str)
403                .unwrap_or("")
404                .to_string();
405            let host = val
406                .get("host")
407                .and_then(MsgValue::as_str)
408                .unwrap_or("")
409                .to_string();
410            let path = val
411                .get("path")
412                .and_then(MsgValue::as_str)
413                .unwrap_or("")
414                .to_string();
415            loot.push(LootInfo {
416                id,
417                name,
418                content_type,
419                host,
420                path,
421            });
422        }
423    }
424    loot
425}
426
427fn parse_strings(raw: &MsgValue, key: &str) -> Vec<String> {
428    match raw.get(key) {
429        Some(MsgValue::Array(arr)) => arr
430            .iter()
431            .filter_map(MsgValue::as_str)
432            .map(str::to_string)
433            .collect(),
434        // Modern servers report `targets`/`platforms` as a map keyed by the
435        // numeric index, e.g. {0=>"Automatic Target", 1=>"Windows 7", ...}.
436        // The decoder stringifies integer keys, so sort them back into order.
437        Some(MsgValue::Map(map)) => {
438            let mut items: Vec<&String> = map.keys().collect();
439            items.sort();
440            items
441                .into_iter()
442                .filter_map(|k| map.get(k))
443                .filter_map(MsgValue::as_str)
444                .map(str::to_string)
445                .collect()
446        }
447        _ => Vec::new(),
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::collections::BTreeMap;
455
456    fn raw_map(pairs: &[(&str, MsgValue)]) -> MsgValue {
457        MsgValue::Map(
458            pairs
459                .iter()
460                .map(|(k, v)| (k.to_string(), v.clone()))
461                .collect(),
462        )
463    }
464
465    #[test]
466    fn parse_strings_reads_array_form() {
467        let raw = raw_map(&[(
468            "targets",
469            MsgValue::Array(vec![
470                MsgValue::String("Linux x86".to_string()),
471                MsgValue::String("Linux x86_64".to_string()),
472            ]),
473        )]);
474        assert_eq!(
475            parse_strings(&raw, "targets"),
476            vec!["Linux x86", "Linux x86_64"]
477        );
478    }
479
480    #[test]
481    fn parse_strings_reads_indexed_map_form() {
482        // Modern servers encode targets as {0=>"A", 1=>"B", ...}; the decoder
483        // stringifies the integer keys so they must be re-sorted here.
484        let mut targets = BTreeMap::new();
485        targets.insert("1".to_string(), MsgValue::String("Windows 7".to_string()));
486        targets.insert(
487            "0".to_string(),
488            MsgValue::String("Automatic Target".to_string()),
489        );
490        targets.insert("2".to_string(), MsgValue::String("Windows 8".to_string()));
491        let raw = raw_map(&[("targets", MsgValue::Map(targets))]);
492        assert_eq!(
493            parse_strings(&raw, "targets"),
494            vec!["Automatic Target", "Windows 7", "Windows 8"]
495        );
496    }
497
498    #[test]
499    fn parse_module_info_extracts_indexed_targets() {
500        // Full-shaped module.info with integer-keyed targets (as returned by
501        // exploit modules like ms17_010_eternalblue).
502        let mut targets = BTreeMap::new();
503        targets.insert(
504            "0".to_string(),
505            MsgValue::String("Automatic Target".to_string()),
506        );
507        targets.insert("1".to_string(), MsgValue::String("Windows 7".to_string()));
508        let raw = raw_map(&[
509            ("name", MsgValue::String("eternalblue".to_string())),
510            ("description", MsgValue::String("MS17-010".to_string())),
511            ("targets", MsgValue::Map(targets)),
512            ("options", MsgValue::Map(BTreeMap::new())),
513        ]);
514        let info = parse_module_info("exploit", "windows/smb/ms17_010_eternalblue", &raw).unwrap();
515        assert_eq!(info.targets, vec!["Automatic Target", "Windows 7"]);
516    }
517
518    #[test]
519    fn parse_default_value_stringifies_all_scalars() {
520        assert_eq!(
521            parse_default_value(&MsgValue::Bool(true)),
522            Some("true".to_string())
523        );
524        assert_eq!(
525            parse_default_value(&MsgValue::Bool(false)),
526            Some("false".to_string())
527        );
528        assert_eq!(
529            parse_default_value(&MsgValue::Int(-3)),
530            Some("-3".to_string())
531        );
532        assert_eq!(
533            parse_default_value(&MsgValue::UInt(2)),
534            Some("2".to_string())
535        );
536        assert_eq!(
537            parse_default_value(&MsgValue::Float(2.0)),
538            Some("2".to_string())
539        );
540        assert_eq!(
541            parse_default_value(&MsgValue::Float(2.5)),
542            Some("2.5".to_string())
543        );
544        assert_eq!(
545            parse_default_value(&MsgValue::String("eth0".to_string())),
546            Some("eth0".to_string())
547        );
548        assert_eq!(parse_default_value(&MsgValue::Nil), None);
549        assert_eq!(parse_default_value(&MsgValue::Map(BTreeMap::new())), None);
550    }
551
552    #[test]
553    fn required_with_default_is_not_in_required_options() {
554        // Simulates auxiliary/scanner/http/robots_txt: options that are
555        // required:true but carry bool/int defaults (ShowProgress, THREADS)
556        // must NOT surface in `required_options`.
557        let mut options = BTreeMap::new();
558        options.insert(
559            "ShowProgress".to_string(),
560            MsgValue::Map(BTreeMap::from([
561                ("required".to_string(), MsgValue::Bool(true)),
562                ("default".to_string(), MsgValue::Bool(false)),
563                (
564                    "desc".to_string(),
565                    MsgValue::String("show progress".to_string()),
566                ),
567            ])),
568        );
569        options.insert(
570            "THREADS".to_string(),
571            MsgValue::Map(BTreeMap::from([
572                ("required".to_string(), MsgValue::Bool(true)),
573                ("default".to_string(), MsgValue::UInt(2)),
574                ("desc".to_string(), MsgValue::String("threads".to_string())),
575            ])),
576        );
577        options.insert(
578            "RPORT".to_string(),
579            MsgValue::Map(BTreeMap::from([
580                ("required".to_string(), MsgValue::Bool(true)),
581                ("desc".to_string(), MsgValue::String("port".to_string())),
582            ])),
583        );
584
585        let raw = raw_map(&[
586            ("description", MsgValue::String("robots".to_string())),
587            ("options", MsgValue::Map(options)),
588        ]);
589        let info = parse_module_info("auxiliary", "scanner/http/robots_txt", &raw).unwrap();
590
591        // ShowProgress/THREADS keep their defaults and are dropped from the
592        // required list; RPORT has no default so it stays required.
593        assert!(info.required_options.contains(&"RPORT".to_string()));
594        assert!(!info.required_options.contains(&"ShowProgress".to_string()));
595        assert!(!info.required_options.contains(&"THREADS".to_string()));
596
597        // Defaults are stringified so downstream resolution can auto-apply.
598        let progress = info
599            .options
600            .iter()
601            .find(|o| o.name == "ShowProgress")
602            .unwrap();
603        assert_eq!(progress.default.as_deref(), Some("false"));
604        let threads = info.options.iter().find(|o| o.name == "THREADS").unwrap();
605        assert_eq!(threads.default.as_deref(), Some("2"));
606        let rport = info.options.iter().find(|o| o.name == "RPORT").unwrap();
607        assert_eq!(rport.default, None);
608    }
609}