Skip to main content

memf_linux/
proc_cmdline.rs

1//! Pure-logic process command-line parsing and forensic classification.
2//!
3//! Parses `/proc/<pid>/cmdline`-style byte slices (NUL-separated argv fields)
4//! into a structured [`ProcessCmdline`] and provides heuristic classifiers for
5//! common attacker-controlled process patterns (SSH tunnels, cryptominers).
6
7/// Parsed process command line.
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct ProcessCmdline {
10    /// Process ID.
11    pub pid: u32,
12    /// Process name (`task_struct.comm`, max 16 chars).
13    pub comm: String,
14    /// Executable path (first NUL-delimited field).
15    pub exe: String,
16    /// Arguments after the executable (remaining NUL-delimited fields).
17    pub args: Vec<String>,
18    /// Full command line reconstructed as a space-joined string.
19    pub cmdline_raw: String,
20}
21
22/// Parse a `/proc/<pid>/cmdline` byte slice (NUL-separated fields) into a
23/// [`ProcessCmdline`].
24///
25/// The first NUL-delimited field becomes `exe`; subsequent fields become
26/// `args`. `cmdline_raw` is all fields joined with spaces.
27pub fn parse_proc_cmdline(pid: u32, comm: &str, bytes: &[u8]) -> ProcessCmdline {
28    let fields: Vec<String> = bytes
29        .split(|&b| b == 0)
30        .filter_map(|chunk| {
31            if chunk.is_empty() {
32                None
33            } else {
34                Some(String::from_utf8_lossy(chunk).into_owned())
35            }
36        })
37        .collect();
38
39    let exe = fields.first().cloned().unwrap_or_default();
40    let args = fields.get(1..).unwrap_or(&[]).to_vec();
41    let cmdline_raw = fields.join(" ");
42
43    ProcessCmdline {
44        pid,
45        comm: comm.to_string(),
46        exe,
47        args,
48        cmdline_raw,
49    }
50}
51
52/// Returns `true` if this cmdline looks like an SSH port-forwarding tunnel.
53///
54/// Matches when the executable contains `"ssh"` and the arguments include
55/// `-L`, `-R`, or `-D`.
56pub fn is_ssh_tunnel_cmdline(cmdline: &ProcessCmdline) -> bool {
57    if !cmdline.exe.contains("ssh") {
58        return false;
59    }
60    cmdline
61        .args
62        .iter()
63        .any(|a| matches!(a.as_str(), "-L" | "-R" | "-D"))
64}
65
66/// Returns `true` if this cmdline looks like a cryptominer.
67///
68/// Matches when the executable contains `"xmrig"` or the arguments contain
69/// `"stratum+"` or `"--pool"`.
70pub fn is_miner_cmdline(cmdline: &ProcessCmdline) -> bool {
71    if cmdline.exe.contains("xmrig") {
72        return true;
73    }
74    cmdline
75        .args
76        .iter()
77        .any(|a| a.contains("stratum+") || a == "--pool")
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    // --- parse_proc_cmdline ---
85
86    #[test]
87    fn parse_ssh_tunnel_cmdline() {
88        let cmdline = parse_proc_cmdline(941, "ssh", b"ssh\0-L\x003333:pool:3333\0");
89        assert_eq!(cmdline.pid, 941);
90        assert_eq!(cmdline.comm, "ssh");
91        assert_eq!(cmdline.exe, "ssh");
92        assert_eq!(cmdline.args, vec!["-L", "3333:pool:3333"]);
93    }
94
95    #[test]
96    fn parse_xmrig_cmdline() {
97        let cmdline = parse_proc_cmdline(
98            100,
99            "xmrig",
100            b"xmrig\0--pool\0stratum+tcp://pool:3333\0-u\0user\0",
101        );
102        assert_eq!(cmdline.exe, "xmrig");
103        assert!(cmdline.args.contains(&"--pool".to_string()));
104        assert!(cmdline.args.iter().any(|a| a.starts_with("stratum+")));
105    }
106
107    #[test]
108    fn parse_empty_bytes_produces_empty_exe() {
109        let cmdline = parse_proc_cmdline(1, "init", b"");
110        assert_eq!(cmdline.exe, "");
111        assert!(cmdline.args.is_empty());
112        assert_eq!(cmdline.cmdline_raw, "");
113    }
114
115    #[test]
116    fn parse_single_exe_no_args() {
117        let cmdline = parse_proc_cmdline(2, "bash", b"/bin/bash\0");
118        assert_eq!(cmdline.exe, "/bin/bash");
119        assert!(cmdline.args.is_empty());
120        assert_eq!(cmdline.cmdline_raw, "/bin/bash");
121    }
122
123    #[test]
124    fn parse_cmdline_raw_space_joins_all_fields() {
125        let cmdline = parse_proc_cmdline(3, "python3", b"python3\0-m\0http.server\x008080\0");
126        assert_eq!(cmdline.cmdline_raw, "python3 -m http.server 8080");
127    }
128
129    // --- is_ssh_tunnel_cmdline ---
130
131    #[test]
132    fn ssh_tunnel_forward_l_is_tunnel() {
133        let cmdline = parse_proc_cmdline(941, "ssh", b"ssh\0-L\x003333:pool:3333\0");
134        assert!(is_ssh_tunnel_cmdline(&cmdline));
135    }
136
137    #[test]
138    fn ssh_tunnel_forward_r_is_tunnel() {
139        let cmdline = parse_proc_cmdline(942, "ssh", b"ssh\0-R\x008080:localhost:80\0user@host\0");
140        assert!(is_ssh_tunnel_cmdline(&cmdline));
141    }
142
143    #[test]
144    fn ssh_tunnel_socks_d_is_tunnel() {
145        let cmdline = parse_proc_cmdline(943, "ssh", b"ssh\0-D\x001080\0user@host\0");
146        assert!(is_ssh_tunnel_cmdline(&cmdline));
147    }
148
149    #[test]
150    fn bash_is_not_ssh_tunnel() {
151        let cmdline = parse_proc_cmdline(1, "bash", b"/bin/bash\0-c\0true\0");
152        assert!(!is_ssh_tunnel_cmdline(&cmdline));
153    }
154
155    #[test]
156    fn ssh_without_tunnel_flags_is_not_tunnel() {
157        // Plain ssh login, no port-forwarding flags
158        let cmdline = parse_proc_cmdline(944, "ssh", b"ssh\0user@host\0");
159        assert!(!is_ssh_tunnel_cmdline(&cmdline));
160    }
161
162    // --- is_miner_cmdline ---
163
164    #[test]
165    fn xmrig_exe_is_miner() {
166        let cmdline = parse_proc_cmdline(200, "xmrig", b"xmrig\0--pool\0pool.minexmr.com:443\0");
167        assert!(is_miner_cmdline(&cmdline));
168    }
169
170    #[test]
171    fn stratum_arg_is_miner() {
172        let cmdline = parse_proc_cmdline(201, "miner", b"./miner\0stratum+tcp://pool:3333\0");
173        assert!(is_miner_cmdline(&cmdline));
174    }
175
176    #[test]
177    fn pool_flag_is_miner() {
178        let cmdline = parse_proc_cmdline(202, "miner2", b"miner2\0--pool\0pool.example.com\0");
179        assert!(is_miner_cmdline(&cmdline));
180    }
181
182    #[test]
183    fn bash_is_not_miner() {
184        let cmdline = parse_proc_cmdline(1, "bash", b"/bin/bash\0-c\0echo hello\0");
185        assert!(!is_miner_cmdline(&cmdline));
186    }
187
188    // --- struct fields ---
189
190    #[test]
191    fn process_cmdline_clone_and_debug() {
192        let cmdline = parse_proc_cmdline(5, "sh", b"sh\0-c\0true\0");
193        let cloned = cmdline.clone();
194        let dbg = format!("{cloned:?}");
195        assert!(dbg.contains("ProcessCmdline"));
196    }
197
198    #[test]
199    fn process_cmdline_serializes_to_json() {
200        let cmdline = parse_proc_cmdline(6, "nginx", b"nginx\0-g\0daemon off;\0");
201        let json = serde_json::to_string(&cmdline).unwrap();
202        assert!(json.contains("\"pid\":6"));
203        assert!(json.contains("\"exe\":\"nginx\""));
204    }
205}