Skip to main content

ploit_malper/exploit/
cli.rs

1use std::io::{self, Write};
2use std::time::Duration;
3
4use crate::config::ConfigManager;
5use crate::db::models::ExploitExecution;
6use crate::error::{AppError, Result};
7use crate::exploit::models::*;
8use crate::exploit::msf::MsfFramework;
9use crate::exploit::planner::Planner;
10use crate::exploit::{execute_step, ExploitFramework};
11use crate::frontend::{open_backend, Args};
12
13const DEFAULT_JOB_TIMEOUT_S: u64 = 300;
14
15pub fn usage() {
16    println!(
17        "Usage: ploit-malper exploit <run_id> [--dry-run] [--yes] [--module <name>] \
18         [--target <host>] [--payload <name>] [--workspace <name>] [--job-timeout <secs>] \
19         [--backend pocketbase|sqlite] [--pocketbase-url URL] [--sqlite-path PATH] [--verbose]"
20    );
21}
22
23pub fn cmd_exploit(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
24    let parsed = Args::parse(args)?;
25    if parsed.has("help") {
26        usage();
27        return Ok(());
28    }
29
30    let run_ref = parsed.positionals().first().cloned().ok_or_else(|| {
31        AppError::Message(
32            "exploit requires a run id (e.g. ploit-malper exploit <run_id>)".to_string(),
33        )
34    })?;
35
36    let opts = ExploitOptions {
37        dry_run: parsed.has("dry-run"),
38        yes: parsed.has("yes"),
39        module_filter: parsed.get("module").map(str::to_string),
40        target_filter: parsed.get("target").map(str::to_string),
41        payload_override: parsed.get("payload").map(str::to_string),
42        workspace: parsed.get("workspace").map(str::to_string),
43        backend: parsed.get("backend").map(str::to_string),
44        pocketbase_url: parsed.get("pocketbase-url").map(str::to_string),
45        sqlite_path: parsed.get("sqlite-path").map(str::to_string),
46        verbose: parsed.has("verbose"),
47    };
48    let job_timeout = parsed
49        .get("job-timeout")
50        .map(|v| v.parse::<u64>())
51        .transpose()
52        .map_err(|_| AppError::Message("--job-timeout must be a number of seconds".to_string()))?
53        .unwrap_or(DEFAULT_JOB_TIMEOUT_S);
54
55    let mut storage = open_backend(config_mgr, &parsed)?;
56    let run = Planner::resolve_run(storage.as_mut(), &run_ref)?;
57
58    println!(
59        "[+] Run: {} (target={})",
60        short(&run.stable_id, 16),
61        run.target
62    );
63
64    let plan = Planner::build_plan(
65        storage.as_mut(),
66        &run,
67        opts.module_filter.as_deref(),
68        opts.target_filter.as_deref(),
69    )?;
70
71    if opts.dry_run {
72        return dry_run(&mut storage, &run, &plan, &opts);
73    }
74
75    let mut framework = build_framework(config_mgr, &opts)?;
76    if let Err(e) = framework.connect() {
77        // Record the whole plan as skipped so the attempt is auditable.
78        println!("[!] Could not connect to the exploitation framework: {e}");
79        for step in &plan {
80            persist(&mut storage, &run.stable_id, step, false, None, None)?;
81        }
82        println!("[!] Plan recorded as skipped; nothing was executed.");
83        return Ok(());
84    }
85    println!(
86        "[+] Connected to {} (workspace '{}')",
87        framework.name(),
88        framework.workspace()
89    );
90
91    // Verify every planned module exists on the connected instance. A module
92    // that is missing is skipped (auditable); a lookup that fails for any other
93    // RPC reason is also skipped but reported distinctly so the operator can
94    // tell "module absent" apart from "RPC down".
95    let mut verified: Vec<(ExploitStep, Option<ModuleInfo>)> = Vec::new();
96    for step in &plan {
97        match framework.module_info(&step.module_type, &step.module_name) {
98            Ok(info) => {
99                verified.push((step.clone(), Some(info)));
100            }
101            Err(crate::error::AppError::RpcModuleNotFound(_)) => {
102                println!(
103                    "[!] Module {} not present on the connected instance; marking skipped.",
104                    step.module_name
105                );
106                persist(
107                    &mut storage,
108                    &run.stable_id,
109                    step,
110                    false,
111                    Some(ExploitStatus::Skipped),
112                    None,
113                )?;
114            }
115            Err(e) => {
116                println!(
117                    "[!] Could not query module {} (RPC failure, not a missing module): {e}",
118                    step.module_name
119                );
120                persist(
121                    &mut storage,
122                    &run.stable_id,
123                    step,
124                    false,
125                    Some(ExploitStatus::Skipped),
126                    None,
127                )?;
128            }
129        }
130    }
131
132    if verified.is_empty() {
133        println!("[!] No modules are available on the connected instance.");
134        return Ok(());
135    }
136
137    render_plan(&verified);
138
139    // Interactive checklist: toggle modules, Enter to run the selected set.
140    let selected: Vec<bool> = if opts.yes {
141        vec![true; verified.len()]
142    } else {
143        select_steps(&verified)?
144    };
145
146    let chosen: Vec<(ExploitStep, Option<ModuleInfo>)> = verified
147        .into_iter()
148        .zip(selected.iter())
149        .filter(|(_, sel)| **sel)
150        .map(|(step, _)| step)
151        .collect();
152
153    if chosen.is_empty() {
154        println!("[!] No modules selected; recording plan as skipped.");
155        for step in &plan {
156            persist(&mut storage, &run.stable_id, step, false, None, None)?;
157        }
158        return Ok(());
159    }
160
161    if !opts.yes
162        && !prompt_bool_with_default(
163            &format!("Run {} selected module(s) now?", chosen.len()),
164            false,
165        )?
166    {
167        println!("[!] Execution declined; recording selection as skipped.");
168        for (step, _) in &chosen {
169            persist(
170                &mut storage,
171                &run.stable_id,
172                step,
173                true,
174                Some(ExploitStatus::Skipped),
175                None,
176            )?;
177        }
178        return Ok(());
179    }
180
181    // Execute each selected step sequentially.
182    println!();
183    for (step, info) in &chosen {
184        println!(
185            "[+] Executing {} against {} ...",
186            step.module_name, step.host
187        );
188
189        let options = resolve_options(info.as_ref(), step, opts.yes)?;
190        let missing: Vec<String> = options
191            .iter()
192            .filter(|(_, value)| value.is_empty())
193            .map(|(key, _)| key.clone())
194            .collect();
195        if !missing.is_empty() {
196            println!(
197                "[!] Required options missing ({}); skipping {}.",
198                missing.join(", "),
199                step.module_name
200            );
201            persist(
202                &mut storage,
203                &run.stable_id,
204                step,
205                true,
206                Some(ExploitStatus::Skipped),
207                None,
208            )?;
209            continue;
210        }
211
212        let mut step_clone = step.clone();
213        step_clone.inferred_options = options;
214        let payload = opts.payload_override.as_deref();
215        match execute_step(
216            framework.as_mut(),
217            &step_clone,
218            payload,
219            Duration::from_secs(job_timeout),
220        ) {
221            Ok(outcome) => {
222                render_outcome(&outcome);
223                persist(
224                    &mut storage,
225                    &run.stable_id,
226                    &outcome.step,
227                    true,
228                    Some(outcome.status),
229                    Some(&outcome),
230                )?;
231            }
232            Err(e) => {
233                println!("[!] Execution error for {}: {e}", step.module_name);
234                persist(
235                    &mut storage,
236                    &run.stable_id,
237                    step,
238                    true,
239                    Some(ExploitStatus::Failed),
240                    None,
241                )?;
242            }
243        }
244    }
245
246    framework.disconnect();
247    println!();
248    println!("[+] Exploit session complete. Records written to the database.");
249    Ok(())
250}
251
252fn build_framework(
253    config_mgr: &ConfigManager,
254    opts: &ExploitOptions,
255) -> Result<Box<dyn ExploitFramework>> {
256    if !config_mgr.is_configured() {
257        return Err(AppError::Message(
258            "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
259        ));
260    }
261    let cfg = config_mgr.get_msfrpc_config().clone();
262    let mut framework =
263        MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
264    framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
265    Ok(Box::new(framework))
266}
267
268/// Dry-run: show the full plan with module metadata and record it as previews.
269/// Never contacts the framework.
270fn dry_run(
271    storage: &mut Box<dyn crate::db::Storage>,
272    run: &crate::db::models::ScanRun,
273    plan: &[ExploitStep],
274    opts: &ExploitOptions,
275) -> Result<()> {
276    println!();
277    println!("[+] Dry run — no modules will be executed.");
278    render_plan_dry(plan);
279    println!();
280    if let Some(payload) = &opts.payload_override {
281        println!("[+] Payload override: {payload}");
282    }
283    println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
284    for step in plan {
285        persist(
286            storage,
287            &run.stable_id,
288            step,
289            false,
290            Some(ExploitStatus::DryRun),
291            None,
292        )?;
293    }
294    println!("[+] Preview recorded; nothing was executed.");
295    Ok(())
296}
297
298fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
299    println!();
300    println!("Exploitation plan (verified against the connected instance):");
301    println!(
302        "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
303        "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
304    );
305    println!("{}", "-".repeat(90));
306    for (i, (step, info)) in verified.iter().enumerate() {
307        let missing = if step.missing_options.is_empty() {
308            "none".to_string()
309        } else {
310            step.missing_options.join(",")
311        };
312        println!(
313            "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
314            i + 1,
315            step.module_type,
316            step.rank,
317            step.confidence,
318            short(&step.module_name, 16),
319            short(&missing, 6),
320            short(&step.payload_compatibility, 10),
321        );
322        println!(
323            "      host={} port={} cve={} vuln={}",
324            step.host,
325            step.service_port
326                .map(|p| p.to_string())
327                .unwrap_or_else(|| "-".to_string()),
328            step.cve.as_deref().unwrap_or("-"),
329            short(&step.vulnerability, 60),
330        );
331        if !step.inferred_options.is_empty() {
332            let opts = step
333                .inferred_options
334                .iter()
335                .map(|(k, v)| format!("{}={}", k, v))
336                .collect::<Vec<_>>()
337                .join(" ");
338            println!("      options: {opts}");
339        }
340        if let Some(info) = info {
341            if !info.description.is_empty() {
342                println!("      desc: {}", short(&info.description, 80));
343            }
344            let defaults = info
345                .options
346                .iter()
347                .filter(|o| o.default.is_some())
348                .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
349                .collect::<Vec<_>>()
350                .join(" ");
351            if !defaults.is_empty() {
352                println!("      defaults: {defaults}");
353            }
354        }
355    }
356}
357
358fn render_plan_dry(plan: &[ExploitStep]) {
359    println!();
360    println!("Planned modules (offline metadata):");
361    println!(
362        "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
363        "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
364    );
365    println!("{}", "-".repeat(90));
366    for (i, step) in plan.iter().enumerate() {
367        println!(
368            "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
369            i + 1,
370            step.module_type,
371            step.rank,
372            short(&step.module_name, 16),
373            short(step.cve.as_deref().unwrap_or("-"), 16),
374            step.confidence,
375            short(&step.payload_compatibility, 10),
376        );
377        println!(
378            "      target={} port={} vuln={}",
379            step.host,
380            step.service_port
381                .map(|p| p.to_string())
382                .unwrap_or_else(|| "-".to_string()),
383            short(&step.vulnerability, 60),
384        );
385        if !step.missing_options.is_empty() {
386            println!(
387                "      required options to fill: {}",
388                step.missing_options.join(", ")
389            );
390        } else {
391            println!("      required options: fully inferred");
392        }
393    }
394}
395
396fn render_outcome(outcome: &ExploitOutcome) {
397    let status = outcome.status.as_str();
398    println!("      -> {} status={}", outcome.step.module_name, status);
399    if let Some(job) = &outcome.job_id {
400        println!("         job_id={}", job);
401    }
402    if let Some(session) = &outcome.session_id {
403        println!("         session_id={}", session);
404    }
405    if !outcome.loot.is_empty() {
406        println!("         loot={}", outcome.loot.join(","));
407    }
408    if let Some(error) = &outcome.error {
409        println!("         error={}", short(error, 120));
410    }
411}
412
413fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
414    let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
415    loop {
416        println!();
417        println!("Module checklist — [x] = recommended, [ ] = optional:");
418        for (i, (step, _)) in verified.iter().enumerate() {
419            let mark = if selected[i] { "x" } else { " " };
420            println!(
421                "  [{}] {:<3} {}  [{}] host={}",
422                mark,
423                i + 1,
424                step.module_name,
425                step.rank,
426                step.host
427            );
428        }
429        print!("  Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
430        io::stdout().flush()?;
431        let mut buffer = String::new();
432        io::stdin().read_line(&mut buffer)?;
433        let input = buffer.trim().to_lowercase();
434        if input.is_empty() {
435            break;
436        }
437        match input.as_str() {
438            "a" => selected.iter_mut().for_each(|s| *s = true),
439            "n" => selected.iter_mut().for_each(|s| *s = false),
440            _ => {
441                for token in input.split([',', ' ', '\t']) {
442                    if token.is_empty() {
443                        continue;
444                    }
445                    match token.parse::<usize>() {
446                        Ok(idx) if idx >= 1 && idx <= selected.len() => {
447                            let slot = &mut selected[idx - 1];
448                            *slot = !*slot;
449                        }
450                        _ => println!("    ignoring '{}' (not a module number)", token),
451                    }
452                }
453            }
454        }
455    }
456    Ok(selected)
457}
458
459/// Fill any required option not already inferred. Without `--yes` the missing
460/// options are prompted for; with `--yes` framework defaults are used and the
461/// value is left empty when no default exists.
462fn resolve_options(
463    info: Option<&ModuleInfo>,
464    step: &ExploitStep,
465    yes: bool,
466) -> Result<std::collections::BTreeMap<String, String>> {
467    let mut options = step.inferred_options.clone();
468
469    let required: Vec<String> = match info {
470        Some(info) => info.required_options.to_vec(),
471        None => step.missing_options.clone(),
472    };
473
474    let default_for = |name: &str| -> Option<String> {
475        info.and_then(|info| {
476            info.options
477                .iter()
478                .find(|o| o.name == name)
479                .and_then(|o| o.default.clone())
480        })
481    };
482
483    for name in required {
484        if options.contains_key(&name) {
485            continue;
486        }
487        let default = default_for(&name);
488        if yes {
489            if let Some(default) = default {
490                options.insert(name.clone(), default);
491            } else {
492                options.insert(name.clone(), String::new());
493            }
494            continue;
495        }
496        let value = prompt_text(
497            &format!("Required option {} for {}", name, step.module_name),
498            &default.unwrap_or_default(),
499        )?;
500        options.insert(name.clone(), value);
501    }
502
503    Ok(options)
504}
505
506/// Persist one step as an `ExploitExecution` record. When `outcome` is given,
507/// the record reflects the finished execution; otherwise it reflects the
508/// planned/skipped state.
509fn persist(
510    storage: &mut Box<dyn crate::db::Storage>,
511    run_id: &str,
512    step: &ExploitStep,
513    selected: bool,
514    status: Option<ExploitStatus>,
515    outcome: Option<&ExploitOutcome>,
516) -> Result<()> {
517    let mut exec =
518        ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
519    exec.module_type = step.module_type.clone();
520    exec.host = step.host.clone();
521    exec.options = step.inferred_options.clone();
522    exec.selected = selected;
523    match outcome {
524        Some(outcome) => {
525            exec.status = outcome.status.as_str().to_string();
526            exec.start_time = outcome.start_time.clone();
527            exec.finish_time = outcome.finish_time.clone();
528            exec.job_id = outcome.job_id.clone();
529            exec.session_id = outcome.session_id.clone();
530            exec.error = outcome.error.clone();
531            exec.loot = outcome.loot.clone();
532        }
533        None => {
534            exec.status = status
535                .unwrap_or(if selected {
536                    ExploitStatus::Planned
537                } else {
538                    ExploitStatus::Skipped
539                })
540                .as_str()
541                .to_string();
542        }
543    }
544    storage.upsert_exploit_execution(&exec)
545}
546
547fn prompt_text(prompt: &str, default: &str) -> Result<String> {
548    print!("  {} [{}]: ", prompt, default);
549    io::stdout().flush()?;
550    let mut buffer = String::new();
551    io::stdin().read_line(&mut buffer)?;
552    let value = buffer.trim();
553    Ok(if value.is_empty() {
554        default.to_string()
555    } else {
556        value.to_string()
557    })
558}
559
560fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
561    let suffix = if default { "[Y/n]" } else { "[y/N]" };
562    loop {
563        print!("  {} {}: ", prompt, suffix);
564        io::stdout().flush()?;
565        let mut buffer = String::new();
566        io::stdin().read_line(&mut buffer)?;
567        let value = buffer.trim().to_lowercase();
568        if value.is_empty() {
569            return Ok(default);
570        }
571        match value.as_str() {
572            "y" | "yes" | "true" => return Ok(true),
573            "n" | "no" | "false" => return Ok(false),
574            _ => println!("  Please answer yes or no."),
575        }
576    }
577}
578
579fn short(value: &str, width: usize) -> String {
580    let count = value.chars().count();
581    if count <= width {
582        return value.to_string();
583    }
584    if width <= 1 {
585        return "…".to_string();
586    }
587    let mut out: String = value.chars().take(width - 1).collect();
588    out.push('…');
589    out
590}