Skip to main content

ploit_malper/engine/
msfrpc.rs

1use std::collections::BTreeMap;
2use std::process::{Command, Stdio};
3
4use crate::error::Result;
5use crate::models::{
6    MSFHostCheckResult, MSFHostInfo, MSFHostsResult, MSFLoginResult, MSFWorkspaceInfo,
7    MSFWorkspacesResult,
8};
9use crate::msgpack::{decode, encode, MsgValue};
10
11#[derive(Debug, Clone)]
12pub struct MsfRpcClient {
13    host: String,
14    port: u16,
15    ssl: bool,
16    username: String,
17    password: String,
18    token: Option<String>,
19}
20
21impl MsfRpcClient {
22    pub fn new(host: &str, port: u16, username: &str, password: &str, ssl: bool) -> Self {
23        Self {
24            host: host.to_string(),
25            port,
26            ssl,
27            username: username.to_string(),
28            password: password.to_string(),
29            token: None,
30        }
31    }
32
33    pub fn login(&mut self) -> Result<MSFLoginResult> {
34        let url = build_url(&self.host, self.port, self.ssl);
35        // Array format: [method, ...params]
36        let payload = encode(&msg_array(vec![
37            msg_str("auth.login"),
38            msg_str(&self.username),
39            msg_str(&self.password),
40        ]));
41
42        match send_request(&url, &payload, 10) {
43            Ok(val) => {
44                let success = val.get("result").and_then(MsgValue::as_str) == Some("success");
45                let token = val
46                    .get("token")
47                    .and_then(MsgValue::as_str)
48                    .unwrap_or("")
49                    .to_string();
50
51                if success && !token.is_empty() {
52                    self.token = Some(token.clone());
53                    Ok(MSFLoginResult {
54                        success: true,
55                        token,
56                        error: String::new(),
57                    })
58                } else {
59                    let error_msg = val
60                        .get("error_message")
61                        .or_else(|| val.get("error_string"))
62                        .and_then(MsgValue::as_str)
63                        .unwrap_or("Authentication failed");
64                    Ok(MSFLoginResult {
65                        success: false,
66                        token: String::new(),
67                        error: error_msg.to_string(),
68                    })
69                }
70            }
71            Err(error) => Ok(MSFLoginResult {
72                success: false,
73                token: String::new(),
74                error,
75            }),
76        }
77    }
78
79    pub fn logout(&mut self) {
80        if let Some(token) = self.token.clone() {
81            let url = build_url(&self.host, self.port, self.ssl);
82            // Array format: [method, token]
83            let payload = encode(&msg_array(vec![msg_str("auth.logout"), msg_str(&token)]));
84            let _ = send_request(&url, &payload, 5);
85        }
86        self.token = None;
87    }
88
89    pub fn is_authenticated(&self) -> bool {
90        self.token.is_some()
91    }
92
93    pub fn get_workspaces(&self) -> Result<MSFWorkspacesResult> {
94        let token = match self.token.as_ref() {
95            Some(token) => token.clone(),
96            None => {
97                return Ok(MSFWorkspacesResult {
98                    workspaces: vec![],
99                    error: "Not authenticated".to_string(),
100                });
101            }
102        };
103
104        let url = build_url(&self.host, self.port, self.ssl);
105        // Array format: [method, token]
106        let payload = encode(&msg_array(vec![msg_str("db.workspaces"), msg_str(&token)]));
107
108        match send_request(&url, &payload, 15) {
109            Ok(val) => Ok(MSFWorkspacesResult {
110                workspaces: extract_workspaces(&val),
111                error: String::new(),
112            }),
113            Err(error) => Ok(MSFWorkspacesResult {
114                workspaces: vec![],
115                error,
116            }),
117        }
118    }
119
120    pub fn get_hosts(&self, workspace: Option<&str>) -> Result<MSFHostsResult> {
121        let token = match self.token.as_ref() {
122            Some(token) => token.clone(),
123            None => {
124                return Ok(MSFHostsResult {
125                    hosts: vec![],
126                    error: "Not authenticated".to_string(),
127                });
128            }
129        };
130
131        let url = build_url(&self.host, self.port, self.ssl);
132        // Array format: [method, token, options_hash]
133        // The RPC dispatcher extracts the token from args for auth,
134        // so we need the workspace filter as the next parameter
135        let mut params = vec![msg_str("db.hosts"), msg_str(&token)];
136        if let Some(workspace) = workspace {
137            params.push(MsgValue::Map(BTreeMap::from([(
138                "workspace".to_string(),
139                msg_str(workspace),
140            )])));
141        } else {
142            params.push(MsgValue::Map(BTreeMap::new()));
143        }
144        let payload = encode(&msg_array(params));
145
146        match send_request(&url, &payload, 15) {
147            Ok(val) => Ok(MSFHostsResult {
148                hosts: extract_hosts(&val),
149                error: String::new(),
150            }),
151            Err(error) => Ok(MSFHostsResult {
152                hosts: vec![],
153                error,
154            }),
155        }
156    }
157
158    pub fn check_host_exists(
159        &self,
160        address: &str,
161        workspace: Option<&str>,
162    ) -> Result<MSFHostCheckResult> {
163        let hosts = self.get_hosts(workspace)?;
164        Ok(MSFHostCheckResult {
165            exists: hosts.hosts.iter().any(|host| host.address == address),
166            error: hosts.error,
167        })
168    }
169
170    /// Generic authenticated RPC call: `[method, token, ...params]`.
171    ///
172    /// Used by the exploitation layer for module discovery, execution and job
173    /// monitoring. Returns the raw msgpack response so the caller can map it
174    /// to strongly typed models.
175    pub fn rpc_call(
176        &self,
177        method: &str,
178        params: &[MsgValue],
179        timeout_secs: u64,
180    ) -> Result<MsgValue> {
181        let token = self
182            .token
183            .clone()
184            .ok_or_else(|| crate::error::AppError::Message("not authenticated".to_string()))?;
185        let url = build_url(&self.host, self.port, self.ssl);
186        let mut args = vec![msg_str(method), msg_str(&token)];
187        args.extend_from_slice(params);
188        let payload = encode(&msg_array(args));
189        send_request(&url, &payload, timeout_secs).map_err(crate::error::AppError::Network)
190    }
191
192    /// Verify that a module exists on the connected Metasploit instance.
193    /// `module_type` is `exploit` or `auxiliary` (the types the planner emits).
194    pub fn module_exists(&self, module_type: &str, name: &str) -> Result<bool> {
195        let method = if module_type == "auxiliary" {
196            "module.auxiliary"
197        } else {
198            "module.exploit"
199        };
200        let val = self.rpc_call(method, &[msg_str(name)], 15)?;
201        let result = val.get("result").and_then(MsgValue::as_str).unwrap_or("");
202        Ok(result == "success")
203    }
204
205    /// Fetch full module metadata (options, targets, rank, description, ...).
206    pub fn module_info(&self, module_type: &str, name: &str) -> Result<MsgValue> {
207        self.rpc_call("module.info", &[msg_str(module_type), msg_str(name)], 15)
208    }
209
210    /// Execute a module. Returns a map containing `job_id` on success or an
211    /// error payload on failure.
212    pub fn module_execute(
213        &self,
214        module_type: &str,
215        name: &str,
216        options: &BTreeMap<String, String>,
217        payload: Option<&str>,
218    ) -> Result<MsgValue> {
219        let mut opts = BTreeMap::new();
220        for (key, value) in options {
221            opts.insert(key.clone(), msg_str(value));
222        }
223        if let Some(payload) = payload {
224            opts.insert("PAYLOAD".to_string(), msg_str(payload));
225        }
226        self.rpc_call(
227            "module.execute",
228            &[msg_str(module_type), msg_str(name), MsgValue::Map(opts)],
229            30,
230        )
231    }
232
233    /// List all running jobs. Response map: `{ "jobs": { "0": {...}, ... } }`.
234    pub fn job_list(&self) -> Result<MsgValue> {
235        self.rpc_call("job.list", &[], 15)
236    }
237
238    /// Fetch status of a single job by id.
239    pub fn job_info(&self, job_id: &str) -> Result<MsgValue> {
240        self.rpc_call("job.info", &[msg_str(job_id)], 15)
241    }
242
243    /// Stop a running job by id.
244    pub fn job_stop(&self, job_id: &str) -> Result<MsgValue> {
245        self.rpc_call("job.stop", &[msg_str(job_id)], 15)
246    }
247
248    /// List current sessions. Response map: `{ "sessions": { "1": {...}, ... } }`.
249    pub fn session_list(&self) -> Result<MsgValue> {
250        self.rpc_call("session.list", &[], 15)
251    }
252
253    /// List loot records for a workspace.
254    pub fn db_loots(&self, workspace: &str) -> Result<MsgValue> {
255        let mut opts = BTreeMap::new();
256        if !workspace.is_empty() {
257            opts.insert("workspace".to_string(), msg_str(workspace));
258        }
259        self.rpc_call("db.loots", &[MsgValue::Map(opts)], 15)
260    }
261}
262
263/// Convenience constructors for msgpack values used across the RPC layer.
264fn msg_str(value: &str) -> MsgValue {
265    MsgValue::String(value.to_string())
266}
267
268fn msg_array(values: Vec<MsgValue>) -> MsgValue {
269    MsgValue::Array(values)
270}
271
272fn build_url(host: &str, port: u16, ssl: bool) -> String {
273    let scheme = if ssl { "https" } else { "http" };
274    format!("{}://{}:{}/api/", scheme, host, port)
275}
276
277fn send_request(
278    url: &str,
279    payload: &[u8],
280    timeout_secs: u64,
281) -> std::result::Result<MsgValue, String> {
282    let mut child = Command::new("curl")
283        .arg("-sS")
284        .arg("--max-time")
285        .arg(timeout_secs.to_string())
286        .arg("-H")
287        .arg("Content-Type: binary/message-pack")
288        .arg("-H")
289        .arg("Accept: binary/message-pack")
290        .arg("-i")
291        .arg("--data-binary")
292        .arg("@-")
293        .arg(url)
294        .stdin(Stdio::piped())
295        .stdout(Stdio::piped())
296        .stderr(Stdio::piped())
297        .spawn()
298        .map_err(|e| format!("failed to spawn curl: {e}"))?;
299
300    if let Some(stdin) = child.stdin.as_mut() {
301        use std::io::Write;
302        stdin
303            .write_all(payload)
304            .map_err(|e| format!("failed to write request body: {e}"))?;
305    }
306
307    let output = child
308        .wait_with_output()
309        .map_err(|e| format!("curl wait failed: {e}"))?;
310
311    let stdout = &output.stdout;
312    if stdout.is_empty() {
313        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
314        if !stderr.is_empty() {
315            return Err(stderr);
316        }
317        return Err("empty response from server".to_string());
318    }
319
320    // Split headers from body (curl -i includes headers in stdout)
321    let body = if let Some(pos) = find_header_body_boundary(stdout) {
322        &stdout[pos..]
323    } else {
324        &stdout[..]
325    };
326
327    if body.is_empty() {
328        return Err("empty response body from server".to_string());
329    }
330
331    decode(body).map_err(|e| {
332        let dump_len = body.len().min(512);
333        let hex_dump: Vec<String> = body[..dump_len]
334            .iter()
335            .map(|b| format!("{:02x}", b))
336            .collect();
337        let utf8_dump = String::from_utf8_lossy(&body[..dump_len]);
338        format!(
339            "{}\n  response body hex ({} bytes): {}\n  response body utf8: {}",
340            e,
341            dump_len,
342            hex_dump.join(" "),
343            utf8_dump
344        )
345    })
346}
347
348fn find_header_body_boundary(data: &[u8]) -> Option<usize> {
349    data.windows(4)
350        .position(|w| w == b"\r\n\r\n")
351        .map(|p| p + 4)
352}
353
354fn extract_workspaces(val: &MsgValue) -> Vec<MSFWorkspaceInfo> {
355    let mut workspaces = Vec::new();
356    if let Some(map) = val.as_map() {
357        if let Some(MsgValue::Array(ws_arr)) = map.get("workspaces") {
358            for ws in ws_arr {
359                if let Some(ws_map) = ws.as_map() {
360                    let name = ws_map
361                        .get("name")
362                        .and_then(MsgValue::as_str)
363                        .unwrap_or("")
364                        .to_string();
365                    let scope = ws_map
366                        .get("scope")
367                        .and_then(MsgValue::as_str)
368                        .unwrap_or("")
369                        .to_string();
370                    let host_count = ws_map
371                        .get("hosts_count")
372                        .or_else(|| ws_map.get("host_count"))
373                        .and_then(MsgValue::as_u64)
374                        .unwrap_or(0);
375
376                    workspaces.push(MSFWorkspaceInfo {
377                        name,
378                        scope,
379                        host_count,
380                    });
381                }
382            }
383        }
384    }
385    workspaces
386}
387
388fn extract_hosts(val: &MsgValue) -> Vec<MSFHostInfo> {
389    let mut hosts = Vec::new();
390    if let Some(map) = val.as_map() {
391        if let Some(MsgValue::Array(host_arr)) = map.get("hosts") {
392            for host in host_arr {
393                if let Some(host_map) = host.as_map() {
394                    let address = host_map
395                        .get("address")
396                        .and_then(MsgValue::as_str)
397                        .unwrap_or("")
398                        .to_string();
399                    let os_name = host_map
400                        .get("os_name")
401                        .and_then(MsgValue::as_str)
402                        .unwrap_or("")
403                        .to_string();
404                    let os_flavor = host_map
405                        .get("os_flavor")
406                        .and_then(MsgValue::as_str)
407                        .unwrap_or("")
408                        .to_string();
409                    let state = host_map
410                        .get("state")
411                        .and_then(MsgValue::as_str)
412                        .unwrap_or("")
413                        .to_string();
414                    let notes_count = host_map
415                        .get("notes_count")
416                        .and_then(MsgValue::as_u64)
417                        .unwrap_or(0);
418
419                    hosts.push(MSFHostInfo {
420                        address,
421                        os_name,
422                        os_flavor,
423                        state,
424                        notes_count,
425                    });
426                }
427            }
428        }
429    }
430    hosts
431}