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();
93 for step in &plan {
94 match framework.module_exists(&step.module_type, &step.module_name) {
95 Ok(true) => {
96 let info = framework
97 .module_info(&step.module_type, &step.module_name)
98 .ok();
99 verified.push((step.clone(), info));
100 }
101 Ok(false) => {
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!("[!] Could not query module {}: {e}", step.module_name);
117 persist(
118 &mut storage,
119 &run.stable_id,
120 step,
121 false,
122 Some(ExploitStatus::Skipped),
123 None,
124 )?;
125 }
126 }
127 }
128
129 if verified.is_empty() {
130 println!("[!] No modules are available on the connected instance.");
131 return Ok(());
132 }
133
134 render_plan(&verified);
135
136 let selected: Vec<bool> = if opts.yes {
138 vec![true; verified.len()]
139 } else {
140 select_steps(&verified)?
141 };
142
143 let chosen: Vec<(ExploitStep, Option<ModuleInfo>)> = verified
144 .into_iter()
145 .zip(selected.iter())
146 .filter(|(_, sel)| **sel)
147 .map(|(step, _)| step)
148 .collect();
149
150 if chosen.is_empty() {
151 println!("[!] No modules selected; recording plan as skipped.");
152 for step in &plan {
153 persist(&mut storage, &run.stable_id, step, false, None, None)?;
154 }
155 return Ok(());
156 }
157
158 if !opts.yes
159 && !prompt_bool_with_default(
160 &format!("Run {} selected module(s) now?", chosen.len()),
161 false,
162 )?
163 {
164 println!("[!] Execution declined; recording selection as skipped.");
165 for (step, _) in &chosen {
166 persist(
167 &mut storage,
168 &run.stable_id,
169 step,
170 true,
171 Some(ExploitStatus::Skipped),
172 None,
173 )?;
174 }
175 return Ok(());
176 }
177
178 println!();
180 for (step, info) in &chosen {
181 println!(
182 "[+] Executing {} against {} ...",
183 step.module_name, step.host
184 );
185
186 let options = resolve_options(info.as_ref(), step, opts.yes)?;
187 let missing: Vec<String> = options
188 .iter()
189 .filter(|(_, value)| value.is_empty())
190 .map(|(key, _)| key.clone())
191 .collect();
192 if !missing.is_empty() {
193 println!(
194 "[!] Required options missing ({}); skipping {}.",
195 missing.join(", "),
196 step.module_name
197 );
198 persist(
199 &mut storage,
200 &run.stable_id,
201 step,
202 true,
203 Some(ExploitStatus::Skipped),
204 None,
205 )?;
206 continue;
207 }
208
209 let mut step_clone = step.clone();
210 step_clone.inferred_options = options;
211 let payload = opts.payload_override.as_deref();
212 match execute_step(
213 framework.as_mut(),
214 &step_clone,
215 payload,
216 Duration::from_secs(job_timeout),
217 ) {
218 Ok(outcome) => {
219 render_outcome(&outcome);
220 persist(
221 &mut storage,
222 &run.stable_id,
223 &outcome.step,
224 true,
225 Some(outcome.status),
226 Some(&outcome),
227 )?;
228 }
229 Err(e) => {
230 println!("[!] Execution error for {}: {e}", step.module_name);
231 persist(
232 &mut storage,
233 &run.stable_id,
234 step,
235 true,
236 Some(ExploitStatus::Failed),
237 None,
238 )?;
239 }
240 }
241 }
242
243 framework.disconnect();
244 println!();
245 println!("[+] Exploit session complete. Records written to the database.");
246 Ok(())
247}
248
249fn build_framework(
250 config_mgr: &ConfigManager,
251 opts: &ExploitOptions,
252) -> Result<Box<dyn ExploitFramework>> {
253 if !config_mgr.is_configured() {
254 return Err(AppError::Message(
255 "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
256 ));
257 }
258 let cfg = config_mgr.get_msfrpc_config().clone();
259 let mut framework =
260 MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
261 framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
262 Ok(Box::new(framework))
263}
264
265fn dry_run(
268 storage: &mut Box<dyn crate::db::Storage>,
269 run: &crate::db::models::ScanRun,
270 plan: &[ExploitStep],
271 opts: &ExploitOptions,
272) -> Result<()> {
273 println!();
274 println!("[+] Dry run — no modules will be executed.");
275 render_plan_dry(plan);
276 println!();
277 if let Some(payload) = &opts.payload_override {
278 println!("[+] Payload override: {payload}");
279 }
280 println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
281 for step in plan {
282 persist(
283 storage,
284 &run.stable_id,
285 step,
286 false,
287 Some(ExploitStatus::DryRun),
288 None,
289 )?;
290 }
291 println!("[+] Preview recorded; nothing was executed.");
292 Ok(())
293}
294
295fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
296 println!();
297 println!("Exploitation plan (verified against the connected instance):");
298 println!(
299 "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
300 "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
301 );
302 println!("{}", "-".repeat(90));
303 for (i, (step, info)) in verified.iter().enumerate() {
304 let missing = if step.missing_options.is_empty() {
305 "none".to_string()
306 } else {
307 step.missing_options.join(",")
308 };
309 println!(
310 "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
311 i + 1,
312 step.module_type,
313 step.rank,
314 step.confidence,
315 short(&step.module_name, 16),
316 short(&missing, 6),
317 short(&step.payload_compatibility, 10),
318 );
319 println!(
320 " host={} port={} cve={} vuln={}",
321 step.host,
322 step.service_port
323 .map(|p| p.to_string())
324 .unwrap_or_else(|| "-".to_string()),
325 step.cve.as_deref().unwrap_or("-"),
326 short(&step.vulnerability, 60),
327 );
328 if !step.inferred_options.is_empty() {
329 let opts = step
330 .inferred_options
331 .iter()
332 .map(|(k, v)| format!("{}={}", k, v))
333 .collect::<Vec<_>>()
334 .join(" ");
335 println!(" options: {opts}");
336 }
337 if let Some(info) = info {
338 if !info.description.is_empty() {
339 println!(" desc: {}", short(&info.description, 80));
340 }
341 let defaults = info
342 .options
343 .iter()
344 .filter(|o| o.default.is_some())
345 .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
346 .collect::<Vec<_>>()
347 .join(" ");
348 if !defaults.is_empty() {
349 println!(" defaults: {defaults}");
350 }
351 }
352 }
353}
354
355fn render_plan_dry(plan: &[ExploitStep]) {
356 println!();
357 println!("Planned modules (offline metadata):");
358 println!(
359 "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
360 "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
361 );
362 println!("{}", "-".repeat(90));
363 for (i, step) in plan.iter().enumerate() {
364 println!(
365 "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
366 i + 1,
367 step.module_type,
368 step.rank,
369 short(&step.module_name, 16),
370 short(step.cve.as_deref().unwrap_or("-"), 16),
371 step.confidence,
372 short(&step.payload_compatibility, 10),
373 );
374 println!(
375 " target={} port={} vuln={}",
376 step.host,
377 step.service_port
378 .map(|p| p.to_string())
379 .unwrap_or_else(|| "-".to_string()),
380 short(&step.vulnerability, 60),
381 );
382 if !step.missing_options.is_empty() {
383 println!(
384 " required options to fill: {}",
385 step.missing_options.join(", ")
386 );
387 } else {
388 println!(" required options: fully inferred");
389 }
390 }
391}
392
393fn render_outcome(outcome: &ExploitOutcome) {
394 let status = outcome.status.as_str();
395 println!(" -> {} status={}", outcome.step.module_name, status);
396 if let Some(job) = &outcome.job_id {
397 println!(" job_id={}", job);
398 }
399 if let Some(session) = &outcome.session_id {
400 println!(" session_id={}", session);
401 }
402 if !outcome.loot.is_empty() {
403 println!(" loot={}", outcome.loot.join(","));
404 }
405 if let Some(error) = &outcome.error {
406 println!(" error={}", short(error, 120));
407 }
408}
409
410fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
411 let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
412 loop {
413 println!();
414 println!("Module checklist — [x] = recommended, [ ] = optional:");
415 for (i, (step, _)) in verified.iter().enumerate() {
416 let mark = if selected[i] { "x" } else { " " };
417 println!(
418 " [{}] {:<3} {} [{}] host={}",
419 mark,
420 i + 1,
421 step.module_name,
422 step.rank,
423 step.host
424 );
425 }
426 print!(" Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
427 io::stdout().flush()?;
428 let mut buffer = String::new();
429 io::stdin().read_line(&mut buffer)?;
430 let input = buffer.trim().to_lowercase();
431 if input.is_empty() {
432 break;
433 }
434 match input.as_str() {
435 "a" => selected.iter_mut().for_each(|s| *s = true),
436 "n" => selected.iter_mut().for_each(|s| *s = false),
437 _ => {
438 for token in input.split([',', ' ', '\t']) {
439 if token.is_empty() {
440 continue;
441 }
442 match token.parse::<usize>() {
443 Ok(idx) if idx >= 1 && idx <= selected.len() => {
444 let slot = &mut selected[idx - 1];
445 *slot = !*slot;
446 }
447 _ => println!(" ignoring '{}' (not a module number)", token),
448 }
449 }
450 }
451 }
452 }
453 Ok(selected)
454}
455
456fn resolve_options(
460 info: Option<&ModuleInfo>,
461 step: &ExploitStep,
462 yes: bool,
463) -> Result<std::collections::BTreeMap<String, String>> {
464 let mut options = step.inferred_options.clone();
465
466 let required: Vec<String> = match info {
467 Some(info) => info.required_options.to_vec(),
468 None => step.missing_options.clone(),
469 };
470
471 let default_for = |name: &str| -> Option<String> {
472 info.and_then(|info| {
473 info.options
474 .iter()
475 .find(|o| o.name == name)
476 .and_then(|o| o.default.clone())
477 })
478 };
479
480 for name in required {
481 if options.contains_key(&name) {
482 continue;
483 }
484 let default = default_for(&name);
485 if yes {
486 if let Some(default) = default {
487 options.insert(name.clone(), default);
488 } else {
489 options.insert(name.clone(), String::new());
490 }
491 continue;
492 }
493 let value = prompt_text(
494 &format!("Required option {} for {}", name, step.module_name),
495 &default.unwrap_or_default(),
496 )?;
497 options.insert(name.clone(), value);
498 }
499
500 Ok(options)
501}
502
503fn persist(
507 storage: &mut Box<dyn crate::db::Storage>,
508 run_id: &str,
509 step: &ExploitStep,
510 selected: bool,
511 status: Option<ExploitStatus>,
512 outcome: Option<&ExploitOutcome>,
513) -> Result<()> {
514 let mut exec =
515 ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
516 exec.module_type = step.module_type.clone();
517 exec.host = step.host.clone();
518 exec.options = step.inferred_options.clone();
519 exec.selected = selected;
520 match outcome {
521 Some(outcome) => {
522 exec.status = outcome.status.as_str().to_string();
523 exec.start_time = outcome.start_time.clone();
524 exec.finish_time = outcome.finish_time.clone();
525 exec.job_id = outcome.job_id.clone();
526 exec.session_id = outcome.session_id.clone();
527 exec.error = outcome.error.clone();
528 exec.loot = outcome.loot.clone();
529 }
530 None => {
531 exec.status = status
532 .unwrap_or(if selected {
533 ExploitStatus::Planned
534 } else {
535 ExploitStatus::Skipped
536 })
537 .as_str()
538 .to_string();
539 }
540 }
541 storage.upsert_exploit_execution(&exec)
542}
543
544fn prompt_text(prompt: &str, default: &str) -> Result<String> {
545 print!(" {} [{}]: ", prompt, default);
546 io::stdout().flush()?;
547 let mut buffer = String::new();
548 io::stdin().read_line(&mut buffer)?;
549 let value = buffer.trim();
550 Ok(if value.is_empty() {
551 default.to_string()
552 } else {
553 value.to_string()
554 })
555}
556
557fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
558 let suffix = if default { "[Y/n]" } else { "[y/N]" };
559 loop {
560 print!(" {} {}: ", prompt, suffix);
561 io::stdout().flush()?;
562 let mut buffer = String::new();
563 io::stdin().read_line(&mut buffer)?;
564 let value = buffer.trim().to_lowercase();
565 if value.is_empty() {
566 return Ok(default);
567 }
568 match value.as_str() {
569 "y" | "yes" | "true" => return Ok(true),
570 "n" | "no" | "false" => return Ok(false),
571 _ => println!(" Please answer yes or no."),
572 }
573 }
574}
575
576fn short(value: &str, width: usize) -> String {
577 let count = value.chars().count();
578 if count <= width {
579 return value.to_string();
580 }
581 if width <= 1 {
582 return "…".to_string();
583 }
584 let mut out: String = value.chars().take(width - 1).collect();
585 out.push('…');
586 out
587}