Skip to main content

max / makenotwork

19.5 KB · 601 lines History Blame Raw
1 //! Layer 2: Structural analysis of PE/ELF/Mach-O binaries.
2 //!
3 //! Uses the `goblin` crate to inspect binary imports and sections for
4 //! suspicious patterns commonly found in malware.
5
6 use crate::storage::FileType;
7
8 use super::{ErrorPolicy, LayerResult, LayerVerdict};
9
10 /// In-process deterministic layer. An `Error` here typically means the binary
11 /// parser refused the input, fail closed so admins can inspect the file.
12 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailClosed;
13
14 /// Suspicious Windows API imports commonly used in malware
15 const SUSPICIOUS_PE_IMPORTS: &[&str] = &[
16 "VirtualAlloc",
17 "VirtualAllocEx",
18 "CreateRemoteThread",
19 "WriteProcessMemory",
20 "NtUnmapViewOfSection",
21 "QueueUserAPC",
22 "SetThreadContext",
23 "NtCreateThreadEx",
24 ];
25
26 /// Suspicious ELF syscall-related symbols
27 const SUSPICIOUS_ELF_SYMBOLS: &[&str] = &["ptrace", "process_vm_writev", "memfd_create"];
28
29 /// Suspicious Mach-O symbols (macOS equivalents of PE/ELF patterns).
30 /// Note: dlopen and posix_spawn are excluded, they are ubiquitous in
31 /// legitimate macOS apps (audio plugins, plugin hosts, runtime loaders).
32 const SUSPICIOUS_MACHO_SYMBOLS: &[&str] = &[
33 "ptrace",
34 "task_for_pid",
35 "mach_vm_write",
36 "mach_vm_protect",
37 "thread_create_running",
38 ];
39
40 /// Path-based entry. Mmaps the spooled file so goblin's parsers walk OS
41 /// page cache rather than a heap buffer; suitable for files larger than
42 /// `SCAN_MAX_MEMORY_BYTES`. Delegates to `analyze_binary` for the actual
43 /// analysis.
44 /// Path-based variant, retained only as a buffered-vs-path equivalence oracle in
45 /// tests. The live pipeline scans the mmap slice via [`analyze_binary`];
46 /// `#[cfg(test)]` makes wiring this into production a compile error (ultra-fuzz N2).
47 #[cfg(test)]
48 pub fn analyze_binary_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
49 if file_type != FileType::Download {
50 return LayerResult {
51 layer: "structural",
52 verdict: LayerVerdict::Skip,
53 detail: Some("Not a download file".to_string()),
54 };
55 }
56 match crate::scanning::spool::mmap_read(path) {
57 Ok(map) => analyze_binary(&map, file_type),
58 Err(e) => LayerResult {
59 layer: "structural",
60 verdict: LayerVerdict::Error,
61 detail: Some(e),
62 },
63 }
64 }
65
66 /// Analyze a binary for suspicious structural patterns.
67 /// Only runs for Download file types; returns Skip for Audio/Cover.
68 pub fn analyze_binary(data: &[u8], file_type: FileType) -> LayerResult {
69 if file_type != FileType::Download {
70 return LayerResult {
71 layer: "structural",
72 verdict: LayerVerdict::Skip,
73 detail: Some("Not a download file".to_string()),
74 };
75 }
76
77 // Try to parse as a known binary format
78 match goblin::Object::parse(data) {
79 Ok(goblin::Object::PE(pe)) => analyze_pe(&pe),
80 Ok(goblin::Object::Elf(elf)) => analyze_elf(&elf),
81 Ok(goblin::Object::Mach(mach)) => analyze_mach(&mach),
82 Ok(_) => {
83 // Archive, unknown, or other format, skip structural analysis
84 LayerResult {
85 layer: "structural",
86 verdict: LayerVerdict::Skip,
87 detail: Some("Not an executable binary".to_string()),
88 }
89 }
90 Err(_) if looks_like_executable(data) => {
91 // The file CLAIMS to be a binary (executable magic header) but goblin
92 // refused to parse it, an evasion signal, not "not applicable". Fail
93 // closed for admin review, which is what this layer's ERROR_POLICY
94 // already documents (Run #2 Security MINOR: the Skip arm made that
95 // FailClosed posture unreachable from the buffered path).
96 LayerResult {
97 layer: "structural",
98 verdict: LayerVerdict::Error,
99 detail: Some("Executable magic header but unparseable binary".to_string()),
100 }
101 }
102 Err(_) => {
103 // No executable magic, a genuine non-binary download (zip, pdf,
104 // audio, sample pack). Structural analysis is not applicable; skip.
105 LayerResult {
106 layer: "structural",
107 verdict: LayerVerdict::Skip,
108 detail: Some("Not a recognized binary format".to_string()),
109 }
110 }
111 }
112 }
113
114 /// True if `data` begins with a known executable magic number (PE/ELF/Mach-O,
115 /// including byte-swapped and fat/universal). Used so the layer fails closed
116 /// ONLY when a file claims to be a binary but goblin can't parse it, a
117 /// non-binary download has no such magic and is correctly skipped, so changing
118 /// the parse-error arm doesn't hold every legitimate non-executable upload.
119 fn looks_like_executable(data: &[u8]) -> bool {
120 matches!(
121 data,
122 [0x4D, 0x5A, ..]
123 | [0x7F, b'E', b'L', b'F', ..]
124 | [0xFE, 0xED, 0xFA, 0xCE | 0xCF, ..]
125 | [0xCE | 0xCF, 0xFA, 0xED, 0xFE, ..]
126 | [0xCA, 0xFE, 0xBA, 0xBE, ..]
127 | [0xBE, 0xBA, 0xFE, 0xCA, ..] // fat/universal (little-endian)
128 )
129 }
130
131 fn analyze_pe(pe: &goblin::pe::PE) -> LayerResult {
132 let import_names: Vec<&str> = pe.imports.iter().map(|i| i.name.as_ref()).collect();
133 let section_names: Vec<String> = pe
134 .sections
135 .iter()
136 .map(|s| {
137 String::from_utf8_lossy(&s.name)
138 .trim_end_matches('\0')
139 .to_string()
140 })
141 .collect();
142
143 let warnings = check_pe_warnings(&import_names, &section_names);
144
145 if warnings.is_empty() {
146 LayerResult {
147 layer: "structural",
148 verdict: LayerVerdict::Pass,
149 detail: Some("PE binary".to_string()),
150 }
151 } else {
152 LayerResult {
153 layer: "structural",
154 verdict: LayerVerdict::Fail,
155 detail: Some(warnings.join("; ")),
156 }
157 }
158 }
159
160 /// Check PE import names and section names for suspicious patterns.
161 /// Extracted for testability, the goblin parsing layer just feeds names in.
162 fn check_pe_warnings(import_names: &[&str], section_names: &[String]) -> Vec<String> {
163 let mut warnings = Vec::new();
164
165 for name in import_names {
166 if SUSPICIOUS_PE_IMPORTS.contains(name) {
167 warnings.push(format!("Suspicious import: {name}"));
168 }
169 }
170
171 for name in section_names {
172 if name == "UPX0" || name == "UPX1" || name == "UPX2" {
173 warnings.push(format!("Packed section detected: {name}"));
174 }
175 if name == ".vmp0" || name == ".vmp1" {
176 warnings.push(format!("VMProtect section detected: {name}"));
177 }
178 }
179
180 warnings
181 }
182
183 fn analyze_elf(elf: &goblin::elf::Elf) -> LayerResult {
184 let symbol_names: Vec<&str> = elf
185 .dynsyms
186 .iter()
187 .filter_map(|sym| elf.dynstrtab.get_at(sym.st_name))
188 .collect();
189
190 let warnings = check_elf_warnings(&symbol_names);
191
192 if warnings.is_empty() {
193 LayerResult {
194 layer: "structural",
195 verdict: LayerVerdict::Pass,
196 detail: Some("ELF binary".to_string()),
197 }
198 } else {
199 LayerResult {
200 layer: "structural",
201 verdict: LayerVerdict::Fail,
202 detail: Some(warnings.join("; ")),
203 }
204 }
205 }
206
207 /// Check ELF dynamic symbol names for suspicious patterns.
208 /// Extracted for testability.
209 fn check_elf_warnings(symbol_names: &[&str]) -> Vec<String> {
210 let mut warnings = Vec::new();
211
212 for name in symbol_names {
213 if SUSPICIOUS_ELF_SYMBOLS.contains(name) {
214 warnings.push(format!("Suspicious symbol: {name}"));
215 }
216 }
217
218 warnings
219 }
220
221 fn analyze_mach(mach: &goblin::mach::Mach) -> LayerResult {
222 // Collect symbols across EVERY Mach-O slice, not just the first, a fat
223 // binary can hide a malicious arch behind a benign one (Run #2 Security
224 // MINOR). An import-table parse error is surfaced as Error (fail closed)
225 // rather than swallowed to an empty symbol set that would Pass.
226 let symbols: Vec<String> = match mach {
227 goblin::mach::Mach::Binary(macho) => match collect_macho_symbols(macho) {
228 Ok(s) => s,
229 Err(e) => return mach_import_error(&e),
230 },
231 goblin::mach::Mach::Fat(fat) => {
232 let mut all = Vec::new();
233 for arch in fat.into_iter().filter_map(std::result::Result::ok) {
234 if let goblin::mach::SingleArch::MachO(macho) = arch {
235 match collect_macho_symbols(&macho) {
236 Ok(s) => all.extend(s),
237 Err(e) => return mach_import_error(&e),
238 }
239 }
240 }
241 all
242 }
243 };
244
245 let symbol_refs: Vec<&str> = symbols.iter().map(std::string::String::as_str).collect();
246 let warnings = check_mach_warnings(&symbol_refs);
247
248 if warnings.is_empty() {
249 LayerResult {
250 layer: "structural",
251 verdict: LayerVerdict::Pass,
252 detail: Some("Mach-O binary".to_string()),
253 }
254 } else {
255 LayerResult {
256 layer: "structural",
257 verdict: LayerVerdict::Fail,
258 detail: Some(warnings.join("; ")),
259 }
260 }
261 }
262
263 fn mach_import_error(e: &goblin::error::Error) -> LayerResult {
264 LayerResult {
265 layer: "structural",
266 verdict: LayerVerdict::Error,
267 detail: Some(format!("Mach-O import table failed to parse: {e}")),
268 }
269 }
270
271 fn collect_macho_symbols(macho: &goblin::mach::MachO) -> Result<Vec<String>, goblin::error::Error> {
272 Ok(macho
273 .imports()?
274 .iter()
275 .map(|imp| imp.name.to_string())
276 .collect())
277 }
278
279 /// Check Mach-O import names for suspicious patterns.
280 /// Extracted for testability.
281 fn check_mach_warnings(symbol_names: &[&str]) -> Vec<String> {
282 let mut warnings = Vec::new();
283
284 for name in symbol_names {
285 // Mach-O imports are prefixed with underscore
286 let stripped = name.strip_prefix('_').unwrap_or(name);
287 if SUSPICIOUS_MACHO_SYMBOLS.contains(&stripped) {
288 warnings.push(format!("Suspicious import: {stripped}"));
289 }
290 }
291
292 warnings
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use super::*;
298
299 // Skip behavior
300
301 #[test]
302 fn audio_file_skipped() {
303 let result = analyze_binary(b"not a binary", FileType::Audio);
304 assert_eq!(result.verdict, LayerVerdict::Skip);
305 }
306
307 #[test]
308 fn cover_file_skipped() {
309 let result = analyze_binary(b"not a binary", FileType::Cover);
310 assert_eq!(result.verdict, LayerVerdict::Skip);
311 }
312
313 #[test]
314 fn random_data_skipped() {
315 let result = analyze_binary(b"this is just random text data", FileType::Download);
316 assert_eq!(result.verdict, LayerVerdict::Skip);
317 }
318
319 #[test]
320 fn executable_magic_but_unparseable_fails_closed() {
321 // "MZ" claims to be a PE but the rest is garbage goblin can't parse.
322 // The layer's ERROR_POLICY is FailClosed, so this must hold for review,
323 // not silently Skip (Run #2 Security MINOR).
324 let mut data = vec![0x4D, 0x5A]; // "MZ"
325 data.extend_from_slice(&[0xFFu8; 64]);
326 let result = analyze_binary(&data, FileType::Download);
327 assert_eq!(result.verdict, LayerVerdict::Error);
328 }
329
330 #[test]
331 fn elf_magic_but_unparseable_fails_closed() {
332 let mut data = vec![0x7F, b'E', b'L', b'F'];
333 data.extend_from_slice(&[0x00u8; 8]); // truncated/garbage ELF
334 let result = analyze_binary(&data, FileType::Download);
335 assert_eq!(result.verdict, LayerVerdict::Error);
336 }
337
338 #[test]
339 fn non_executable_magic_still_skipped() {
340 // A ZIP (no executable magic) is a legitimate non-binary download and
341 // must NOT be held for review by the structural layer.
342 let result = analyze_binary(&[0x50, 0x4B, 0x03, 0x04, 0x00, 0x00], FileType::Download);
343 assert_eq!(result.verdict, LayerVerdict::Skip);
344 }
345
346 // PE import detection
347
348 #[test]
349 fn pe_clean_imports_pass() {
350 let imports = &["GetLastError", "CreateFileW", "ReadFile"];
351 let sections = &[];
352 let warnings = check_pe_warnings(imports, sections);
353 assert!(warnings.is_empty());
354 }
355
356 #[test]
357 fn pe_virtual_alloc_detected() {
358 let imports = &["GetLastError", "VirtualAlloc", "ReadFile"];
359 let warnings = check_pe_warnings(imports, &[]);
360 assert_eq!(warnings.len(), 1);
361 assert!(warnings[0].contains("VirtualAlloc"));
362 }
363
364 #[test]
365 fn pe_virtual_alloc_ex_detected() {
366 let imports = &["VirtualAllocEx"];
367 let warnings = check_pe_warnings(imports, &[]);
368 assert_eq!(warnings.len(), 1);
369 assert!(warnings[0].contains("VirtualAllocEx"));
370 }
371
372 #[test]
373 fn pe_create_remote_thread_detected() {
374 let imports = &["CreateRemoteThread"];
375 let warnings = check_pe_warnings(imports, &[]);
376 assert_eq!(warnings.len(), 1);
377 assert!(warnings[0].contains("CreateRemoteThread"));
378 }
379
380 #[test]
381 fn pe_write_process_memory_detected() {
382 let imports = &["WriteProcessMemory"];
383 let warnings = check_pe_warnings(imports, &[]);
384 assert_eq!(warnings.len(), 1);
385 assert!(warnings[0].contains("WriteProcessMemory"));
386 }
387
388 #[test]
389 fn pe_multiple_suspicious_imports() {
390 let imports = &[
391 "VirtualAlloc",
392 "CreateRemoteThread",
393 "WriteProcessMemory",
394 "GetLastError",
395 ];
396 let warnings = check_pe_warnings(imports, &[]);
397 assert_eq!(warnings.len(), 3);
398 }
399
400 #[test]
401 fn pe_nt_apis_detected() {
402 let imports = &["NtUnmapViewOfSection", "NtCreateThreadEx"];
403 let warnings = check_pe_warnings(imports, &[]);
404 assert_eq!(warnings.len(), 2);
405 }
406
407 #[test]
408 fn pe_queue_user_apc_detected() {
409 let imports = &["QueueUserAPC"];
410 let warnings = check_pe_warnings(imports, &[]);
411 assert_eq!(warnings.len(), 1);
412 }
413
414 #[test]
415 fn pe_set_thread_context_detected() {
416 let imports = &["SetThreadContext"];
417 let warnings = check_pe_warnings(imports, &[]);
418 assert_eq!(warnings.len(), 1);
419 }
420
421 // PE section detection
422
423 #[test]
424 fn pe_normal_sections_pass() {
425 let sections = vec![
426 ".text".to_string(),
427 ".data".to_string(),
428 ".rsrc".to_string(),
429 ];
430 let warnings = check_pe_warnings(&[], &sections);
431 assert!(warnings.is_empty());
432 }
433
434 #[test]
435 fn pe_upx_sections_detected() {
436 let sections = vec!["UPX0".to_string(), "UPX1".to_string()];
437 let warnings = check_pe_warnings(&[], &sections);
438 assert_eq!(warnings.len(), 2);
439 assert!(warnings[0].contains("Packed section"));
440 assert!(warnings[1].contains("Packed section"));
441 }
442
443 #[test]
444 fn pe_upx2_section_detected() {
445 let sections = vec!["UPX2".to_string()];
446 let warnings = check_pe_warnings(&[], &sections);
447 assert_eq!(warnings.len(), 1);
448 }
449
450 #[test]
451 fn pe_vmprotect_sections_detected() {
452 let sections = vec![".vmp0".to_string(), ".vmp1".to_string()];
453 let warnings = check_pe_warnings(&[], &sections);
454 assert_eq!(warnings.len(), 2);
455 assert!(warnings[0].contains("VMProtect"));
456 }
457
458 #[test]
459 fn pe_mixed_suspicious_imports_and_sections() {
460 let imports = &["VirtualAlloc", "CreateRemoteThread"];
461 let sections = vec!["UPX0".to_string(), ".text".to_string()];
462 let warnings = check_pe_warnings(imports, &sections);
463 assert_eq!(warnings.len(), 3); // 2 imports + 1 section
464 }
465
466 // ELF symbol detection
467
468 #[test]
469 fn elf_clean_symbols_pass() {
470 let symbols = &["printf", "malloc", "free", "exit"];
471 let warnings = check_elf_warnings(symbols);
472 assert!(warnings.is_empty());
473 }
474
475 #[test]
476 fn elf_ptrace_detected() {
477 let symbols = &["printf", "ptrace", "exit"];
478 let warnings = check_elf_warnings(symbols);
479 assert_eq!(warnings.len(), 1);
480 assert!(warnings[0].contains("ptrace"));
481 }
482
483 #[test]
484 fn elf_process_vm_writev_detected() {
485 let symbols = &["process_vm_writev"];
486 let warnings = check_elf_warnings(symbols);
487 assert_eq!(warnings.len(), 1);
488 assert!(warnings[0].contains("process_vm_writev"));
489 }
490
491 #[test]
492 fn elf_memfd_create_detected() {
493 let symbols = &["memfd_create"];
494 let warnings = check_elf_warnings(symbols);
495 assert_eq!(warnings.len(), 1);
496 assert!(warnings[0].contains("memfd_create"));
497 }
498
499 #[test]
500 fn elf_multiple_suspicious_symbols() {
501 let symbols = &["ptrace", "memfd_create", "process_vm_writev", "printf"];
502 let warnings = check_elf_warnings(symbols);
503 assert_eq!(warnings.len(), 3);
504 }
505
506 #[test]
507 fn elf_empty_symbols_pass() {
508 let symbols: &[&str] = &[];
509 let warnings = check_elf_warnings(symbols);
510 assert!(warnings.is_empty());
511 }
512
513 // Exact matching (no false positives from substrings)
514
515 #[test]
516 fn pe_import_no_false_positive_on_substring() {
517 // VirtualAllocExNuma should NOT match (not in suspicious list)
518 let imports = &["VirtualAllocExNuma"];
519 let warnings = check_pe_warnings(imports, &[]);
520 assert!(warnings.is_empty());
521 }
522
523 #[test]
524 fn pe_import_exact_match() {
525 let imports = &["VirtualAlloc"];
526 let warnings = check_pe_warnings(imports, &[]);
527 assert_eq!(warnings.len(), 1);
528 }
529
530 #[test]
531 fn elf_symbol_no_false_positive_on_substring() {
532 // "ptrace_scope" should NOT match (only "ptrace" is suspicious)
533 let symbols = &["ptrace_scope"];
534 let warnings = check_elf_warnings(symbols);
535 assert!(warnings.is_empty());
536 }
537
538 #[test]
539 fn elf_symbol_exact_match() {
540 let symbols = &["ptrace"];
541 let warnings = check_elf_warnings(symbols);
542 assert_eq!(warnings.len(), 1);
543 }
544
545 // Mach-O symbol detection
546
547 #[test]
548 fn mach_clean_symbols_pass() {
549 let symbols = &["_printf", "_malloc", "_free", "_exit"];
550 let warnings = check_mach_warnings(symbols);
551 assert!(warnings.is_empty());
552 }
553
554 #[test]
555 fn mach_task_for_pid_detected() {
556 let symbols = &["_printf", "_task_for_pid", "_exit"];
557 let warnings = check_mach_warnings(symbols);
558 assert_eq!(warnings.len(), 1);
559 assert!(warnings[0].contains("task_for_pid"));
560 }
561
562 #[test]
563 fn mach_vm_write_detected() {
564 let symbols = &["_mach_vm_write"];
565 let warnings = check_mach_warnings(symbols);
566 assert_eq!(warnings.len(), 1);
567 assert!(warnings[0].contains("mach_vm_write"));
568 }
569
570 #[test]
571 fn mach_multiple_suspicious_symbols() {
572 let symbols = &["_ptrace", "_task_for_pid", "_mach_vm_write", "_printf"];
573 let warnings = check_mach_warnings(symbols);
574 assert_eq!(warnings.len(), 3);
575 }
576
577 #[test]
578 fn mach_no_underscore_prefix_still_matches() {
579 let symbols = &["ptrace"];
580 let warnings = check_mach_warnings(symbols);
581 assert_eq!(warnings.len(), 1);
582 }
583
584 #[test]
585 fn mach_no_false_positive_on_substring() {
586 let symbols = &["_task_for_pid_extra"];
587 let warnings = check_mach_warnings(symbols);
588 assert!(warnings.is_empty());
589 }
590
591 #[test]
592 fn path_entry_matches_buffered_on_plain_text() {
593 let data = b"this is not a binary";
594 let buffered = analyze_binary(data, FileType::Download);
595 let tmp = tempfile::NamedTempFile::new().unwrap();
596 std::fs::write(tmp.path(), data).unwrap();
597 let path_based = analyze_binary_path(tmp.path(), FileType::Download);
598 assert_eq!(buffered.verdict, path_based.verdict);
599 }
600 }
601