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 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
268fn 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
459fn 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
506fn 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}