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 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 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 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 println!();
183 for (step, info) in &chosen {
184 println!(
185 "[+] Executing {} against {} ...",
186 step.module_name, step.host
187 );
188
189 let resolved = resolve_options(info.as_ref(), step, opts.yes)?;
190 if !resolved.missing.is_empty() {
191 println!(
192 "[!] Required options missing ({}); skipping {}.",
193 resolved.missing.join(", "),
194 step.module_name
195 );
196 persist(
197 &mut storage,
198 &run.stable_id,
199 step,
200 true,
201 Some(ExploitStatus::Skipped),
202 None,
203 )?;
204 continue;
205 }
206
207 let mut step_clone = step.clone();
208 step_clone.inferred_options = resolved.options;
209 let payload = opts.payload_override.as_deref();
210 match execute_step(
211 framework.as_mut(),
212 &step_clone,
213 payload,
214 Duration::from_secs(job_timeout),
215 ) {
216 Ok(outcome) => {
217 render_outcome(&outcome);
218 persist(
219 &mut storage,
220 &run.stable_id,
221 &outcome.step,
222 true,
223 Some(outcome.status),
224 Some(&outcome),
225 )?;
226 }
227 Err(e) => {
228 println!("[!] Execution error for {}: {e}", step.module_name);
229 persist(
230 &mut storage,
231 &run.stable_id,
232 step,
233 true,
234 Some(ExploitStatus::Failed),
235 None,
236 )?;
237 }
238 }
239 }
240
241 framework.disconnect();
242 println!();
243 println!("[+] Exploit session complete. Records written to the database.");
244 Ok(())
245}
246
247fn build_framework(
248 config_mgr: &ConfigManager,
249 opts: &ExploitOptions,
250) -> Result<Box<dyn ExploitFramework>> {
251 if !config_mgr.is_configured() {
252 return Err(AppError::Message(
253 "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
254 ));
255 }
256 let cfg = config_mgr.get_msfrpc_config().clone();
257 let mut framework =
258 MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
259 framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
260 Ok(Box::new(framework))
261}
262
263fn dry_run(
266 storage: &mut Box<dyn crate::db::Storage>,
267 run: &crate::db::models::ScanRun,
268 plan: &[ExploitStep],
269 opts: &ExploitOptions,
270) -> Result<()> {
271 println!();
272 println!("[+] Dry run — no modules will be executed.");
273 render_plan_dry(plan);
274 println!();
275 if let Some(payload) = &opts.payload_override {
276 println!("[+] Payload override: {payload}");
277 }
278 println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
279 for step in plan {
280 persist(
281 storage,
282 &run.stable_id,
283 step,
284 false,
285 Some(ExploitStatus::DryRun),
286 None,
287 )?;
288 }
289 println!("[+] Preview recorded; nothing was executed.");
290 Ok(())
291}
292
293fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
294 println!();
295 println!("Exploitation plan (verified against the connected instance):");
296 println!(
297 "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
298 "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
299 );
300 println!("{}", "-".repeat(90));
301 for (i, (step, info)) in verified.iter().enumerate() {
302 let missing = if step.missing_options.is_empty() {
303 "none".to_string()
304 } else {
305 step.missing_options.join(",")
306 };
307 println!(
308 "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
309 i + 1,
310 step.module_type,
311 step.rank,
312 step.confidence,
313 short(&step.module_name, 16),
314 short(&missing, 6),
315 short(&step.payload_compatibility, 10),
316 );
317 println!(
318 " host={} port={} cve={} vuln={}",
319 step.host,
320 step.service_port
321 .map(|p| p.to_string())
322 .unwrap_or_else(|| "-".to_string()),
323 step.cve.as_deref().unwrap_or("-"),
324 short(&step.vulnerability, 60),
325 );
326 if !step.inferred_options.is_empty() {
327 let opts = step
328 .inferred_options
329 .iter()
330 .map(|(k, v)| format!("{}={}", k, v))
331 .collect::<Vec<_>>()
332 .join(" ");
333 println!(" options: {opts}");
334 }
335 if let Some(info) = info {
336 if !info.description.is_empty() {
337 println!(" desc: {}", short(&info.description, 80));
338 }
339 let defaults = info
340 .options
341 .iter()
342 .filter(|o| o.default.is_some())
343 .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
344 .collect::<Vec<_>>()
345 .join(" ");
346 if !defaults.is_empty() {
347 println!(" defaults: {defaults}");
348 }
349 }
350 }
351}
352
353fn render_plan_dry(plan: &[ExploitStep]) {
354 println!();
355 println!("Planned modules (offline metadata):");
356 println!(
357 "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
358 "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
359 );
360 println!("{}", "-".repeat(90));
361 for (i, step) in plan.iter().enumerate() {
362 println!(
363 "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
364 i + 1,
365 step.module_type,
366 step.rank,
367 short(&step.module_name, 16),
368 short(step.cve.as_deref().unwrap_or("-"), 16),
369 step.confidence,
370 short(&step.payload_compatibility, 10),
371 );
372 println!(
373 " target={} port={} vuln={}",
374 step.host,
375 step.service_port
376 .map(|p| p.to_string())
377 .unwrap_or_else(|| "-".to_string()),
378 short(&step.vulnerability, 60),
379 );
380 if !step.missing_options.is_empty() {
381 println!(
382 " required options to fill: {}",
383 step.missing_options.join(", ")
384 );
385 } else {
386 println!(" required options: fully inferred");
387 }
388 }
389}
390
391fn render_outcome(outcome: &ExploitOutcome) {
392 let status = outcome.status.as_str();
393 println!(" -> {} status={}", outcome.step.module_name, status);
394 if let Some(job) = &outcome.job_id {
395 println!(" job_id={}", job);
396 }
397 if let Some(session) = &outcome.session_id {
398 println!(" session_id={}", session);
399 }
400 if !outcome.loot.is_empty() {
401 println!(" loot={}", outcome.loot.join(","));
402 }
403 if let Some(error) = &outcome.error {
404 println!(" error={}", short(error, 120));
405 }
406}
407
408fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
409 if crate::frontend::tui::interactive() {
412 let rows: Vec<crate::frontend::tui::PickerRow> = verified
413 .iter()
414 .map(|(step, _)| crate::frontend::tui::PickerRow {
415 text: step.module_name.clone(),
416 detail: Some(format!(
417 "rank={} conf={} host={} port={}",
418 step.rank,
419 step.confidence,
420 step.host,
421 step.service_port
422 .map(|p| p.to_string())
423 .unwrap_or_else(|| "-".to_string())
424 )),
425 selected: step.default_selected,
426 })
427 .collect();
428
429 let first = verified
430 .iter()
431 .position(|(s, _)| s.default_selected)
432 .unwrap_or(0);
433 return match crate::frontend::tui::pick("Select modules to exploit", rows, first)? {
434 Some(chosen) => Ok(verified
435 .iter()
436 .enumerate()
437 .map(|(i, _)| chosen.contains(&i))
438 .collect()),
439 None => Ok(verified.iter().map(|_| false).collect()),
440 };
441 }
442
443 let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
445 loop {
446 println!();
447 println!("Module checklist — [x] = recommended, [ ] = optional:");
448 for (i, (step, _)) in verified.iter().enumerate() {
449 let mark = if selected[i] { "x" } else { " " };
450 println!(
451 " [{}] {:<3} {} [{}] host={}",
452 mark,
453 i + 1,
454 step.module_name,
455 step.rank,
456 step.host
457 );
458 }
459 print!(" Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
460 io::stdout().flush()?;
461 let mut buffer = String::new();
462 io::stdin().read_line(&mut buffer)?;
463 let input = buffer.trim().to_lowercase();
464 if input.is_empty() {
465 break;
466 }
467 match input.as_str() {
468 "a" => selected.iter_mut().for_each(|s| *s = true),
469 "n" => selected.iter_mut().for_each(|s| *s = false),
470 _ => {
471 for token in input.split([',', ' ', '\t']) {
472 if token.is_empty() {
473 continue;
474 }
475 match token.parse::<usize>() {
476 Ok(idx) if idx >= 1 && idx <= selected.len() => {
477 let slot = &mut selected[idx - 1];
478 *slot = !*slot;
479 }
480 _ => println!(" ignoring '{}' (not a module number)", token),
481 }
482 }
483 }
484 }
485 }
486 Ok(selected)
487}
488
489struct ResolvedOptions {
492 options: std::collections::BTreeMap<String, String>,
494 missing: Vec<String>,
496}
497
498fn resolve_options(
523 info: Option<&ModuleInfo>,
524 step: &ExploitStep,
525 yes: bool,
526) -> Result<ResolvedOptions> {
527 const UI_OPTIONS: &[&str] = &["ShowProgress", "ShowProgressPercent", "Verbose", "VERBOSE"];
530
531 let mut options = step.inferred_options.clone();
532 let mut missing: Vec<String> = Vec::new();
533
534 if let Some(info) = info {
537 for opt in &info.options {
538 if let Some(default) = &opt.default {
539 options
540 .entry(opt.name.clone())
541 .or_insert_with(|| default.clone());
542 }
543 }
544 }
545
546 let required: Vec<String> = match info {
547 Some(info) => info.required_options.clone(),
548 None => step.missing_options.clone(),
549 };
550
551 let interactive = crate::frontend::tui::interactive();
552
553 for name in required {
556 if options.contains_key(&name) {
557 continue;
558 }
559 if UI_OPTIONS.contains(&name.as_str()) {
560 continue;
561 }
562 if yes || !interactive {
563 missing.push(name);
565 continue;
566 }
567 let value = prompt_text(
568 &format!("Required option {} for {}", name, step.module_name),
569 "",
570 )?;
571 if value.trim().is_empty() {
572 missing.push(name);
573 } else {
574 options.insert(name, value);
575 }
576 }
577
578 Ok(ResolvedOptions { options, missing })
579}
580
581fn persist(
585 storage: &mut Box<dyn crate::db::Storage>,
586 run_id: &str,
587 step: &ExploitStep,
588 selected: bool,
589 status: Option<ExploitStatus>,
590 outcome: Option<&ExploitOutcome>,
591) -> Result<()> {
592 let mut exec =
593 ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
594 exec.module_type = step.module_type.clone();
595 exec.host = step.host.clone();
596 exec.options = step.inferred_options.clone();
597 exec.selected = selected;
598 match outcome {
599 Some(outcome) => {
600 exec.status = outcome.status.as_str().to_string();
601 exec.start_time = outcome.start_time.clone();
602 exec.finish_time = outcome.finish_time.clone();
603 exec.job_id = outcome.job_id.clone();
604 exec.session_id = outcome.session_id.clone();
605 exec.error = outcome.error.clone();
606 exec.loot = outcome.loot.clone();
607 }
608 None => {
609 exec.status = status
610 .unwrap_or(if selected {
611 ExploitStatus::Planned
612 } else {
613 ExploitStatus::Skipped
614 })
615 .as_str()
616 .to_string();
617 }
618 }
619 storage.upsert_exploit_execution(&exec)
620}
621
622fn prompt_text(prompt: &str, default: &str) -> Result<String> {
623 print!(" {} [{}]: ", prompt, default);
624 io::stdout().flush()?;
625 let mut buffer = String::new();
626 io::stdin().read_line(&mut buffer)?;
627 let value = buffer.trim();
628 Ok(if value.is_empty() {
629 default.to_string()
630 } else {
631 value.to_string()
632 })
633}
634
635fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
636 let suffix = if default { "[Y/n]" } else { "[y/N]" };
637 loop {
638 print!(" {} {}: ", prompt, suffix);
639 io::stdout().flush()?;
640 let mut buffer = String::new();
641 io::stdin().read_line(&mut buffer)?;
642 let value = buffer.trim().to_lowercase();
643 if value.is_empty() {
644 return Ok(default);
645 }
646 match value.as_str() {
647 "y" | "yes" | "true" => return Ok(true),
648 "n" | "no" | "false" => return Ok(false),
649 _ => println!(" Please answer yes or no."),
650 }
651 }
652}
653
654fn short(value: &str, width: usize) -> String {
655 let count = value.chars().count();
656 if count <= width {
657 return value.to_string();
658 }
659 if width <= 1 {
660 return "…".to_string();
661 }
662 let mut out: String = value.chars().take(width - 1).collect();
663 out.push('…');
664 out
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use std::collections::BTreeMap;
671
672 fn module_info_with(options: Vec<ModuleOption>, required: Vec<String>) -> ModuleInfo {
673 ModuleInfo {
674 name: "m".to_string(),
675 module_type: MODULE_AUXILIARY.to_string(),
676 rank: "normal".to_string(),
677 description: String::new(),
678 platforms: Vec::new(),
679 targets: Vec::new(),
680 options,
681 required_options: required,
682 }
683 }
684
685 fn option(name: &str, required: bool, default: Option<&str>) -> ModuleOption {
686 ModuleOption {
687 name: name.to_string(),
688 required,
689 default: default.map(str::to_string),
690 description: String::new(),
691 }
692 }
693
694 fn step(inferred: &[(&str, &str)]) -> ExploitStep {
695 let inferred_options: BTreeMap<String, String> = inferred
696 .iter()
697 .map(|(k, v)| (k.to_string(), v.to_string()))
698 .collect();
699 ExploitStep {
700 module_type: MODULE_AUXILIARY.to_string(),
701 module_name: "auxiliary/scanner/http/robots_txt".to_string(),
702 cve: None,
703 vulnerability: "robots".to_string(),
704 rank: "normal".to_string(),
705 asset_id: "a1".to_string(),
706 finding_id: "f1".to_string(),
707 host: "10.0.0.1".to_string(),
708 service_port: Some(80),
709 service_protocol: Some("tcp".to_string()),
710 inferred_options,
711 missing_options: Vec::new(),
712 payload_compatibility: "generic".to_string(),
713 confidence: "high".to_string(),
714 default_selected: true,
715 }
716 }
717
718 #[test]
719 fn required_with_default_auto_applies_without_prompting() {
720 let info = module_info_with(
724 vec![
725 option("ShowProgress", true, Some("false")),
726 option("THREADS", true, Some("2")),
727 option("RPORT", true, None),
728 ],
729 vec![
730 "ShowProgress".to_string(),
731 "THREADS".to_string(),
732 "RPORT".to_string(),
733 ],
734 );
735 let step = step(&[("RHOSTS", "10.0.0.1")]);
736 let resolved = resolve_options(Some(&info), &step, true).unwrap();
737
738 assert_eq!(
739 resolved.options.get("ShowProgress").map(String::as_str),
740 Some("false")
741 );
742 assert_eq!(
743 resolved.options.get("THREADS").map(String::as_str),
744 Some("2")
745 );
746 assert_eq!(
748 resolved.options.get("RHOSTS").map(String::as_str),
749 Some("10.0.0.1")
750 );
751 assert!(resolved.missing.contains(&"RPORT".to_string()));
753 }
754
755 #[test]
756 fn inferred_options_always_win_over_defaults() {
757 let info = module_info_with(
758 vec![option("RHOSTS", true, Some("default.ignored"))],
759 vec!["RHOSTS".to_string()],
760 );
761 let step = step(&[("RHOSTS", "10.0.0.9")]);
762 let resolved = resolve_options(Some(&info), &step, true).unwrap();
763
764 assert_eq!(
765 resolved.options.get("RHOSTS").map(String::as_str),
766 Some("10.0.0.9")
767 );
768 assert!(resolved.missing.is_empty());
769 }
770
771 #[test]
772 fn non_interactive_yes_marks_missing_without_prompt() {
773 let info = module_info_with(vec![option("LHOST", true, None)], vec!["LHOST".to_string()]);
776 let step = step(&[]);
777 let resolved = resolve_options(Some(&info), &step, true).unwrap();
778
779 assert!(resolved.missing.contains(&"LHOST".to_string()));
780 assert!(!resolved.options.contains_key("LHOST"));
781 }
782
783 #[test]
784 fn optional_options_are_omitted_not_empty() {
785 let info = module_info_with(
788 vec![
789 option("OptionalOne", false, None),
790 option("OptionalNow", false, None),
791 ],
792 Vec::new(),
793 );
794 let step = step(&[]);
795 let resolved = resolve_options(Some(&info), &step, true).unwrap();
796
797 assert!(resolved.missing.is_empty());
798 assert!(!resolved.options.contains_key("OptionalOne"));
799 assert!(!resolved.options.contains_key("OptionalNow"));
800 }
801
802 #[test]
803 fn no_info_uses_step_missing_options() {
804 let mut step = step(&[]);
805 step.missing_options = vec!["RHOSTS".to_string()];
806 let resolved = resolve_options(None, &step, true).unwrap();
807 assert!(resolved.missing.contains(&"RHOSTS".to_string()));
808 }
809}