Skip to main content

exec_pe_core/
parser.rs

1//! Core PE parser: `parse_pe(&[u8]) -> Result<PeFile, PeError>`.
2
3use std::path::Path;
4
5use crate::error::PeError;
6use crate::rich_header::RichHeader;
7
8/// All forensically-relevant fields extracted from a PE binary.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct PeFile {
11    // ── COFF header ──────────────────────────────────────────────────────────
12    /// COFF machine type (0x8664 AMD64, 0x014C x86, 0xAA64 ARM64).
13    pub machine: u16,
14    /// COFF compile timestamp (Unix seconds; note: frequently zeroed or faked).
15    pub compile_timestamp: u32,
16    /// True when IMAGE_FILE_DLL characteristic is set.
17    pub is_dll: bool,
18    /// True when IMAGE_FILE_EXECUTABLE_IMAGE characteristic is set.
19    pub is_exe: bool,
20
21    // ── Optional header ───────────────────────────────────────────────────────
22    /// Entry-point as an RVA (relative virtual address from image base).
23    /// Zero for DLLs with no explicit entry point.
24    pub entry_point_rva: u32,
25    /// Preferred load address for the image.
26    pub image_base: u64,
27    /// Optional header `CheckSum` field (0 = not set; OS drivers must have a valid checksum).
28    pub checksum: u32,
29
30    // ── Data-directory presence flags ─────────────────────────────────────────
31    /// True when the CLR runtime header (directory[14]) is present — the binary is a .NET assembly.
32    pub is_dotnet: bool,
33    /// Number of TLS callback functions registered.  > 0 means code runs before the entry point.
34    pub tls_callback_count: usize,
35    /// True when a base-relocation table is present.
36    pub has_reloc: bool,
37    /// True when a non-empty Authenticode certificate table is present.
38    pub is_signed: bool,
39
40    // ── Debug information ─────────────────────────────────────────────────────
41    /// PDB file path embedded in the CodeView debug directory entry.
42    /// Contains the full build-machine path, e.g. `C:\Users\attacker\Desktop\payload.pdb`.
43    pub pdb_path: Option<String>,
44
45    // ── Overlay (data after last section) ─────────────────────────────────────
46    /// File offset where overlay data begins (byte immediately after the last section's raw data).
47    pub overlay_offset: Option<u64>,
48    /// Size of the overlay in bytes.
49    pub overlay_size: Option<u64>,
50
51    // ── Rich header (compiler fingerprint) ───────────────────────────────────
52    /// Decoded Rich header, if present.  `None` means the header was absent or
53    /// deliberately stripped — a potential anti-attribution indicator on large files.
54    pub rich_header: Option<RichHeader>,
55
56    // ── Import / export / section tables ─────────────────────────────────────
57    /// Flat list of imported symbol names from all import descriptors.
58    pub imports: Vec<String>,
59    /// Exported symbol names (populated for DLLs).
60    pub exports: Vec<String>,
61    /// Section table with per-section attributes and entropy.
62    pub sections: Vec<PeSection>,
63
64    // ── String extraction ─────────────────────────────────────────────────────
65    /// ASCII strings (≥ 6 printable chars) extracted from all raw data.
66    pub ascii_strings: Vec<String>,
67    /// UTF-16LE strings (≥ 6 printable chars) extracted from all raw data.
68    pub utf16_strings: Vec<String>,
69
70    // ── File-level metadata ───────────────────────────────────────────────────
71    /// SHA-256 hash of the full binary (hex-encoded).
72    pub sha256: String,
73    /// Size of the binary in bytes.
74    pub size: usize,
75}
76
77impl PeFile {
78    /// Combined string table: all ASCII and UTF-16 strings.
79    pub fn all_strings(&self) -> impl Iterator<Item = &str> {
80        self.ascii_strings
81            .iter()
82            .chain(self.utf16_strings.iter())
83            .map(String::as_str)
84    }
85}
86
87/// A single PE section with computed Shannon entropy.
88#[derive(Debug, Clone, serde::Serialize)]
89pub struct PeSection {
90    /// Section name (up to 8 bytes, null-terminated, lossy UTF-8).
91    pub name: String,
92    /// Virtual size in bytes as reported in the section header.
93    pub virtual_size: u32,
94    /// Size of raw data on disk (may be 0 for BSS-style sections).
95    pub raw_size: u32,
96    /// Virtual address (RVA) relative to the image base.
97    pub virtual_address: u32,
98    /// Shannon entropy of the raw section data (0.0 – 8.0).
99    pub entropy: f32,
100    /// True when IMAGE_SCN_MEM_EXECUTE (0x2000_0000) is set.
101    pub is_executable: bool,
102    /// True when IMAGE_SCN_MEM_WRITE (0x8000_0000) is set.
103    pub is_writable: bool,
104    /// True when IMAGE_SCN_MEM_READ (0x4000_0000) is set.
105    pub is_readable: bool,
106}
107
108/// Parse a PE binary from raw bytes.
109///
110/// Returns [`PeError::NotPe`] for non-PE inputs (empty, wrong magic, truncated header).
111/// Returns [`PeError::Structure`] for PEs that pass the magic check but are malformed.
112pub fn parse_pe(bytes: &[u8]) -> Result<PeFile, PeError> {
113    use forensicnomicon::heuristics::pe::MZ_MAGIC;
114    use goblin::pe::PE;
115    use sha2::{Digest, Sha256};
116
117    if bytes.len() < 2 || bytes[0..2] != MZ_MAGIC {
118        return Err(PeError::NotPe);
119    }
120
121    let pe = PE::parse(bytes).map_err(|e| PeError::Structure(e.to_string()))?;
122
123    let machine = pe.header.coff_header.machine;
124    let compile_timestamp = pe.header.coff_header.time_date_stamp;
125    let characteristics = pe.header.coff_header.characteristics;
126    let is_dll = characteristics & 0x2000 != 0;
127    let is_exe = characteristics & 0x0002 != 0;
128
129    let imports: Vec<String> = pe.imports.iter().map(|i| i.name.to_string()).collect();
130    let exports: Vec<String> = pe
131        .exports
132        .iter()
133        .filter_map(|e| e.name.map(str::to_string))
134        .collect();
135
136    let sections = pe
137        .sections
138        .iter()
139        .map(|sec| {
140            let name = String::from_utf8_lossy(&sec.name)
141                .trim_end_matches('\0')
142                .to_string();
143            let offset = sec.pointer_to_raw_data as usize;
144            let raw_size = sec.size_of_raw_data;
145            let data = bytes
146                .get(offset..offset.saturating_add(raw_size as usize))
147                .unwrap_or(&[]);
148            let entropy = crate::strings::compute_entropy(data);
149            PeSection {
150                name,
151                virtual_size: sec.virtual_size,
152                raw_size,
153                virtual_address: sec.virtual_address,
154                entropy,
155                is_executable: sec.characteristics & 0x2000_0000 != 0,
156                is_writable: sec.characteristics & 0x8000_0000 != 0,
157                is_readable: sec.characteristics & 0x4000_0000 != 0,
158            }
159        })
160        .collect();
161
162    let ascii_strings = crate::strings::extract_ascii(bytes, crate::strings::MIN_STRING_LEN);
163    let utf16_strings = crate::strings::extract_utf16le(bytes, crate::strings::MIN_STRING_LEN);
164
165    let sha256 = {
166        let mut hasher = Sha256::new();
167        hasher.update(bytes);
168        hex::encode(hasher.finalize())
169    };
170
171    // ── Optional header fields ────────────────────────────────────────────────
172    let (entry_point_rva, image_base, checksum) = if let Some(oh) = pe.header.optional_header {
173        (
174            oh.standard_fields.address_of_entry_point,
175            oh.windows_fields.image_base,
176            oh.windows_fields.check_sum,
177        )
178    } else {
179        (0, 0, 0)
180    };
181
182    // ── Data directory presence ───────────────────────────────────────────────
183    let is_dotnet = pe.clr_data.is_some();
184    let tls_callback_count = pe.tls_data.as_ref().map_or(0, |t| t.callbacks.len());
185    let has_reloc = pe.relocation_data.is_some();
186    let is_signed = !pe.certificates.is_empty();
187
188    // ── PDB path from CodeView debug directory ────────────────────────────────
189    let pdb_path = pe.debug_data.as_ref().and_then(|d| {
190        d.codeview_pdb70_debug_info.map(|cv| {
191            String::from_utf8_lossy(cv.filename)
192                .trim_end_matches('\0')
193                .to_string()
194        })
195    });
196
197    // ── Overlay detection ─────────────────────────────────────────────────────
198    let last_section_end: u64 = pe
199        .sections
200        .iter()
201        .filter(|s| s.size_of_raw_data > 0)
202        .map(|s| s.pointer_to_raw_data as u64 + s.size_of_raw_data as u64)
203        .max()
204        .unwrap_or(0);
205    let file_size = bytes.len() as u64;
206    let (overlay_offset, overlay_size) = if last_section_end > 0 && file_size > last_section_end {
207        (Some(last_section_end), Some(file_size - last_section_end))
208    } else {
209        (None, None)
210    };
211
212    // ── Rich header ───────────────────────────────────────────────────────────
213    let rich_header = crate::rich_header::parse_rich_header(bytes);
214
215    Ok(PeFile {
216        machine,
217        compile_timestamp,
218        is_dll,
219        is_exe,
220        entry_point_rva,
221        image_base,
222        checksum,
223        is_dotnet,
224        tls_callback_count,
225        has_reloc,
226        is_signed,
227        pdb_path,
228        overlay_offset,
229        overlay_size,
230        rich_header,
231        imports,
232        exports,
233        sections,
234        ascii_strings,
235        utf16_strings,
236        sha256,
237        size: bytes.len(),
238    })
239}
240
241/// Parse a PE binary from a file path.
242///
243/// Reads the entire file into memory then calls [`parse_pe`].
244pub fn parse_pe_path(path: &Path) -> Result<PeFile, PeError> {
245    let bytes = std::fs::read(path)?;
246    parse_pe(&bytes)
247}
248
249#[cfg(test)]
250pub(crate) mod test_helpers {
251    /// Build a minimal valid PE32+ (x64, 0 sections, no imports) for unit tests.
252    ///
253    /// Layout: DOS header (64 B) + PE sig (4 B) + COFF header (20 B) +
254    ///         Optional header PE32+ (240 B) = 328 B, padded to 512 B.
255    pub fn make_minimal_pe_x64(timestamp: u32, is_dll: bool) -> Vec<u8> {
256        let mut pe = vec![0u8; 512];
257
258        // DOS header
259        pe[0] = b'M';
260        pe[1] = b'Z';
261        pe[0x3C] = 0x40; // e_lfanew = 64
262
263        // PE signature at 0x40
264        pe[0x40] = b'P';
265        pe[0x41] = b'E';
266
267        // COFF header at 0x44 (20 bytes)
268        pe[0x44] = 0x64;
269        pe[0x45] = 0x86; // Machine = AMD64
270        pe[0x48..0x4C].copy_from_slice(&timestamp.to_le_bytes()); // TimeDateStamp
271        pe[0x54] = 0xF0; // SizeOfOptionalHeader = 240
272                         // Characteristics: bit 1 = EXE, bit 5 = large addr, bit 13 = DLL
273        pe[0x56] = if is_dll { 0x22 | 0x20 } else { 0x22 }; // 0x22 = exe+large, 0x20 = DLL... wait
274                                                            // Actually: IMAGE_FILE_EXECUTABLE_IMAGE = 0x0002, IMAGE_FILE_LARGE_ADDRESS_AWARE = 0x0020
275                                                            // IMAGE_FILE_DLL = 0x2000
276        if is_dll {
277            let chars: u16 = 0x2022; // DLL | EXECUTABLE | LARGE_ADDRESS_AWARE
278            pe[0x56..0x58].copy_from_slice(&chars.to_le_bytes());
279        } else {
280            let chars: u16 = 0x0022;
281            pe[0x56..0x58].copy_from_slice(&chars.to_le_bytes());
282        }
283
284        // Optional header (PE32+) at 0x58 (240 bytes)
285        pe[0x58] = 0x0B;
286        pe[0x59] = 0x02; // Magic = PE32+
287                         // ImageBase (u64) at 0x58+24 = 0x70
288        pe[0x70] = 0x00;
289        pe[0x71] = 0x00;
290        pe[0x72] = 0x40; // 0x400000
291                         // SectionAlignment at 0x78
292        pe[0x78] = 0x00;
293        pe[0x79] = 0x10; // 0x1000
294                         // FileAlignment at 0x7C
295        pe[0x7C] = 0x00;
296        pe[0x7D] = 0x02; // 0x200
297                         // MajorSubsystemVersion at 0x88
298        pe[0x88] = 0x06;
299        // SizeOfImage at 0x90
300        pe[0x90] = 0x00;
301        pe[0x91] = 0x10; // 0x1000
302                         // SizeOfHeaders at 0x94
303        pe[0x94] = 0x00;
304        pe[0x95] = 0x02; // 0x200
305                         // Subsystem at 0x9C: 2 = GUI
306        pe[0x9C] = 0x02;
307        // SizeOfStackReserve at 0xA0
308        pe[0xA0] = 0x00;
309        pe[0xA1] = 0x00;
310        pe[0xA2] = 0x10; // 0x100000
311                         // SizeOfStackCommit at 0xA8
312        pe[0xA8] = 0x00;
313        pe[0xA9] = 0x10; // 0x1000
314                         // SizeOfHeapReserve at 0xB0
315        pe[0xB0] = 0x00;
316        pe[0xB1] = 0x00;
317        pe[0xB2] = 0x10; // 0x100000
318                         // SizeOfHeapCommit at 0xB8
319        pe[0xB8] = 0x00;
320        pe[0xB9] = 0x10; // 0x1000
321                         // NumberOfRvaAndSizes at 0xC4
322        pe[0xC4] = 0x10; // 16 data directories
323
324        pe
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use test_helpers::make_minimal_pe_x64;
332
333    // ── rejection tests ───────────────────────────────────────────────────────
334
335    #[test]
336    fn rejects_empty_slice() {
337        assert!(matches!(parse_pe(&[]), Err(PeError::NotPe)));
338    }
339
340    #[test]
341    fn rejects_single_byte() {
342        assert!(matches!(parse_pe(&[0x4D]), Err(PeError::NotPe)));
343    }
344
345    #[test]
346    fn rejects_random_bytes() {
347        assert!(parse_pe(b"this is not a PE file at all").is_err());
348    }
349
350    #[test]
351    fn rejects_elf_magic() {
352        let elf = [0x7F, b'E', b'L', b'F', 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
353        assert!(parse_pe(&elf).is_err());
354    }
355
356    #[test]
357    fn rejects_truncated_mz() {
358        assert!(parse_pe(b"MZ").is_err());
359    }
360
361    #[test]
362    fn rejects_mz_with_no_pe_sig() {
363        let mut buf = vec![0u8; 64];
364        buf[0] = b'M';
365        buf[1] = b'Z';
366        buf[0x3C] = 0x40; // e_lfanew points beyond buffer
367        assert!(parse_pe(&buf).is_err());
368    }
369
370    // ── successful parse tests ────────────────────────────────────────────────
371
372    #[test]
373    fn accepts_minimal_x64() {
374        let bytes = make_minimal_pe_x64(0, false);
375        assert!(
376            parse_pe(&bytes).is_ok(),
377            "minimal PE32+ must parse successfully"
378        );
379    }
380
381    #[test]
382    fn extracts_machine_amd64() {
383        let bytes = make_minimal_pe_x64(0, false);
384        let pe = parse_pe(&bytes).expect("minimal PE");
385        assert_eq!(pe.machine, 0x8664);
386    }
387
388    #[test]
389    fn extracts_compile_timestamp() {
390        let ts = 0x5F00_ABCD_u32;
391        let bytes = make_minimal_pe_x64(ts, false);
392        let pe = parse_pe(&bytes).expect("minimal PE");
393        assert_eq!(pe.compile_timestamp, ts);
394    }
395
396    #[test]
397    fn exe_is_not_dll() {
398        let bytes = make_minimal_pe_x64(0, false);
399        let pe = parse_pe(&bytes).expect("minimal PE");
400        assert!(!pe.is_dll);
401        assert!(pe.is_exe);
402    }
403
404    #[test]
405    fn dll_flag_detected() {
406        let bytes = make_minimal_pe_x64(0, true);
407        let pe = parse_pe(&bytes).expect("minimal DLL PE");
408        assert!(pe.is_dll);
409    }
410
411    #[test]
412    fn minimal_pe_has_no_imports() {
413        let bytes = make_minimal_pe_x64(0, false);
414        let pe = parse_pe(&bytes).expect("minimal PE");
415        assert!(pe.imports.is_empty());
416    }
417
418    #[test]
419    fn minimal_pe_has_no_sections() {
420        let bytes = make_minimal_pe_x64(0, false);
421        let pe = parse_pe(&bytes).expect("minimal PE");
422        assert!(pe.sections.is_empty());
423    }
424
425    #[test]
426    fn populates_sha256() {
427        let bytes = make_minimal_pe_x64(0, false);
428        let pe = parse_pe(&bytes).expect("minimal PE");
429        assert_eq!(pe.sha256.len(), 64, "SHA-256 hex string is 64 chars");
430        assert!(pe.sha256.chars().all(|c| c.is_ascii_hexdigit()));
431    }
432
433    #[test]
434    fn populates_size() {
435        let bytes = make_minimal_pe_x64(0, false);
436        let expected_size = bytes.len();
437        let pe = parse_pe(&bytes).expect("minimal PE");
438        assert_eq!(pe.size, expected_size);
439    }
440
441    // ── parse_pe_path tests ───────────────────────────────────────────────────
442
443    #[test]
444    fn parse_pe_path_nonexistent_returns_io_error() {
445        let result = parse_pe_path(Path::new("/nonexistent/rbcw.exe"));
446        assert!(result.is_err());
447    }
448
449    #[test]
450    fn parse_pe_path_non_pe_file_returns_not_pe() {
451        use std::io::Write;
452        let mut tmp = tempfile::NamedTempFile::new().expect("tmp file");
453        tmp.write_all(b"this is plain text, not a PE")
454            .expect("write");
455        let result = parse_pe_path(tmp.path());
456        assert!(result.is_err());
457    }
458
459    // ── new field extraction tests (RED: placeholder values fail) ─────────────
460
461    #[test]
462    fn image_base_extracted_from_optional_header() {
463        let bytes = make_minimal_pe_x64(0, false);
464        let pe = parse_pe(&bytes).expect("minimal PE");
465        // make_minimal_pe_x64 sets ImageBase = 0x400000 at offset 0x70.
466        assert_eq!(
467            pe.image_base, 0x0040_0000,
468            "image_base must be extracted from optional header"
469        );
470    }
471
472    #[test]
473    fn entry_point_rva_is_zero_for_minimal_pe() {
474        let bytes = make_minimal_pe_x64(0, false);
475        let pe = parse_pe(&bytes).expect("minimal PE");
476        assert_eq!(pe.entry_point_rva, 0, "minimal PE has no entry point");
477    }
478
479    #[test]
480    fn minimal_pe_has_no_dotnet() {
481        let bytes = make_minimal_pe_x64(0, false);
482        let pe = parse_pe(&bytes).expect("minimal PE");
483        assert!(!pe.is_dotnet);
484    }
485
486    #[test]
487    fn minimal_pe_has_zero_tls_callbacks() {
488        let bytes = make_minimal_pe_x64(0, false);
489        let pe = parse_pe(&bytes).expect("minimal PE");
490        assert_eq!(pe.tls_callback_count, 0);
491    }
492
493    #[test]
494    fn minimal_pe_has_no_reloc() {
495        let bytes = make_minimal_pe_x64(0, false);
496        let pe = parse_pe(&bytes).expect("minimal PE");
497        assert!(!pe.has_reloc);
498    }
499
500    #[test]
501    fn minimal_pe_is_unsigned() {
502        let bytes = make_minimal_pe_x64(0, false);
503        let pe = parse_pe(&bytes).expect("minimal PE");
504        assert!(!pe.is_signed);
505    }
506
507    #[test]
508    fn minimal_pe_has_no_pdb_path() {
509        let bytes = make_minimal_pe_x64(0, false);
510        let pe = parse_pe(&bytes).expect("minimal PE");
511        assert!(pe.pdb_path.is_none());
512    }
513
514    #[test]
515    fn minimal_pe_has_no_overlay() {
516        let bytes = make_minimal_pe_x64(0, false);
517        let pe = parse_pe(&bytes).expect("minimal PE");
518        assert!(pe.overlay_offset.is_none());
519        assert!(pe.overlay_size.is_none());
520    }
521
522    #[test]
523    fn minimal_pe_has_no_rich_header() {
524        // Our minimal test PE has no DOS stub code, so no Rich header.
525        let bytes = make_minimal_pe_x64(0, false);
526        let pe = parse_pe(&bytes).expect("minimal PE");
527        assert!(pe.rich_header.is_none());
528    }
529}