Skip to main content

exec_pe_core/
anomalies.rs

1//! Structural PE anomaly detection — pure computation over a parsed [`PeFile`].
2//!
3//! These heuristics fire on PE header fields that are valid by the spec but
4//! statistically associated with malware: writable+executable sections,
5//! entry points outside all sections, large virtual/raw size ratios, TLS
6//! callbacks, and appended overlay data.
7//!
8//! All functions are pure (no I/O).  The caller provides a fully-parsed
9//! [`PeFile`] from [`crate::parser::parse_pe`].
10
11use crate::parser::{PeFile, PeSection};
12use forensicnomicon::report::{Category, Evidence, Location, Observation, Severity};
13
14/// A structural anomaly found in a PE binary.
15///
16/// Individual anomalies are low-to-medium confidence signals; clusters of
17/// multiple anomalies on the same binary are high confidence.
18#[derive(Debug, Clone, PartialEq, serde::Serialize)]
19pub enum PeAnomaly {
20    /// A section has both executable and writable characteristics (W+X).
21    /// Legitimate code sections are executable but not writable; shellcode
22    /// injected at runtime needs both.
23    WritableExecutableSection { section_name: String },
24
25    /// The entry-point RVA falls outside the virtual address range of every
26    /// defined section.  This is the classic hallmark of shellcode loading
27    /// or in-memory PE patching.
28    EntryPointOutsideSections { entry_point_rva: u32 },
29
30    /// A section's raw size on disk is zero but its virtual size is non-zero.
31    /// The runtime loader expands this section, which is where packed malware
32    /// decompresses into.
33    VirtualOnlySection { section_name: String },
34
35    /// A section's virtual size exceeds its raw size by more than `ratio`×.
36    /// Legitimate compressed resources occasionally show this; ratios > 20
37    /// almost always indicate runtime decompression of an encrypted payload.
38    LargeVirtualToRawRatio { section_name: String, ratio: u32 },
39
40    /// TLS (Thread Local Storage) callbacks are registered.  These execute
41    /// *before* the PE entry point, giving malware a window for anti-debug
42    /// and anti-VM checks before the main payload runs.
43    TlsCallbacksPresent { count: usize },
44
45    /// Extra bytes are appended after the last section's raw data.
46    /// Legitimate packers (installers, SFX archives) use overlays; so do
47    /// malware droppers that store an encrypted second stage here.
48    OverlayPresent { offset: u64, size: u64 },
49
50    /// No `Rich` header was found in the DOS stub area of a large binary.
51    /// Every MSVC/MinGW binary emits a Rich header; its absence on a file
52    /// > 4 KiB suggests deliberate stripping for anti-attribution.
53    RichHeaderAbsent,
54}
55
56/// Compute structural anomalies from a fully-parsed [`PeFile`].
57///
58/// Returns one [`PeAnomaly`] per anomaly found.  An empty `Vec` means the
59/// binary looks structurally normal (not necessarily benign).
60pub fn detect_structural_anomalies(pe: &PeFile) -> Vec<PeAnomaly> {
61    let mut out = Vec::new();
62
63    for sec in &pe.sections {
64        // W+X section — code injection target
65        if sec.is_writable && sec.is_executable {
66            out.push(PeAnomaly::WritableExecutableSection {
67                section_name: sec.name.clone(),
68            });
69        }
70
71        // Virtual-only section (raw_size=0, virtual_size>0) — runtime decompression area
72        if sec.raw_size == 0 && sec.virtual_size > 0 {
73            out.push(PeAnomaly::VirtualOnlySection {
74                section_name: sec.name.clone(),
75            });
76        }
77
78        // Large virtual/raw ratio (> 10×) — indicates decompression.
79        // `checked_div` yields `None` when raw_size == 0 (skipping div-by-zero).
80        if let Some(ratio) = sec.virtual_size.checked_div(sec.raw_size) {
81            if ratio > 10 {
82                out.push(PeAnomaly::LargeVirtualToRawRatio {
83                    section_name: sec.name.clone(),
84                    ratio,
85                });
86            }
87        }
88    }
89
90    // Entry point outside all sections (only meaningful when sections exist and EP is non-zero)
91    if pe.entry_point_rva > 0
92        && !pe.sections.is_empty()
93        && !entry_point_in_section(pe.entry_point_rva, &pe.sections)
94    {
95        out.push(PeAnomaly::EntryPointOutsideSections {
96            entry_point_rva: pe.entry_point_rva,
97        });
98    }
99
100    // TLS callbacks registered
101    if pe.tls_callback_count > 0 {
102        out.push(PeAnomaly::TlsCallbacksPresent {
103            count: pe.tls_callback_count,
104        });
105    }
106
107    // Overlay data appended after last section
108    if let (Some(offset), Some(size)) = (pe.overlay_offset, pe.overlay_size) {
109        out.push(PeAnomaly::OverlayPresent { offset, size });
110    }
111
112    // Rich header absent on a binary large enough to have been compiled (> 4 KiB)
113    if pe.rich_header.is_none() && pe.size > 4096 {
114        out.push(PeAnomaly::RichHeaderAbsent);
115    }
116
117    out
118}
119
120/// Return `true` when `entry_rva` falls within the virtual address range of
121/// at least one section (`[va, va + virtual_size)`).
122pub fn entry_point_in_section(entry_rva: u32, sections: &[PeSection]) -> bool {
123    sections.iter().any(|s| {
124        let end = s.virtual_address.saturating_add(s.virtual_size.max(1));
125        entry_rva >= s.virtual_address && entry_rva < end
126    })
127}
128
129impl Observation for PeAnomaly {
130    fn severity(&self) -> Option<Severity> {
131        use PeAnomaly::{
132            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
133            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
134        };
135        Some(match self {
136            EntryPointOutsideSections { .. } => Severity::High,
137            WritableExecutableSection { .. }
138            | VirtualOnlySection { .. }
139            | LargeVirtualToRawRatio { .. } => Severity::Medium,
140            TlsCallbacksPresent { .. } | OverlayPresent { .. } | RichHeaderAbsent => Severity::Low,
141        })
142    }
143
144    fn category(&self) -> Category {
145        use PeAnomaly::{OverlayPresent, RichHeaderAbsent, TlsCallbacksPresent};
146        match self {
147            TlsCallbacksPresent { .. } | RichHeaderAbsent => Category::Concealment,
148            OverlayPresent { .. } => Category::Residue,
149            _ => Category::Structure,
150        }
151    }
152
153    fn code(&self) -> &'static str {
154        use PeAnomaly::{
155            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
156            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
157        };
158        match self {
159            WritableExecutableSection { .. } => "PE-WX-SECTION",
160            EntryPointOutsideSections { .. } => "PE-ENTRYPOINT-OOB",
161            VirtualOnlySection { .. } => "PE-VIRTUAL-ONLY-SECTION",
162            LargeVirtualToRawRatio { .. } => "PE-VSIZE-RATIO",
163            TlsCallbacksPresent { .. } => "PE-TLS-CALLBACKS",
164            OverlayPresent { .. } => "PE-OVERLAY",
165            RichHeaderAbsent => "PE-RICH-ABSENT",
166        }
167    }
168
169    fn note(&self) -> String {
170        use PeAnomaly::{
171            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
172            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
173        };
174        match self {
175            WritableExecutableSection { section_name } => {
176                format!("section '{section_name}' is both writable and executable (W+X)")
177            }
178            EntryPointOutsideSections { entry_point_rva } => {
179                format!("entry-point RVA {entry_point_rva:#x} falls outside every defined section")
180            }
181            VirtualOnlySection { section_name } => {
182                format!("section '{section_name}' has zero raw size but a non-zero virtual size")
183            }
184            LargeVirtualToRawRatio {
185                section_name,
186                ratio,
187            } => {
188                format!("section '{section_name}' virtual size exceeds its raw size by ~{ratio}x")
189            }
190            TlsCallbacksPresent { count } => {
191                format!("{count} TLS callback(s) execute before the entry point")
192            }
193            OverlayPresent { offset, size } => {
194                format!(
195                    "{size} bytes of overlay data appended after the last section at {offset:#x}"
196                )
197            }
198            RichHeaderAbsent => {
199                "no Rich header in the DOS stub — consistent with anti-attribution stripping"
200                    .to_string()
201            }
202        }
203    }
204
205    fn mitre(&self) -> &'static [&'static str] {
206        use PeAnomaly::{
207            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
208            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
209        };
210        match self {
211            WritableExecutableSection { .. } | EntryPointOutsideSections { .. } => &["T1055"],
212            TlsCallbacksPresent { .. } => &["T1055.005"],
213            VirtualOnlySection { .. } | LargeVirtualToRawRatio { .. } => &["T1027.002"],
214            RichHeaderAbsent => &["T1027"],
215            OverlayPresent { .. } => &[],
216        }
217    }
218
219    fn evidence(&self) -> Vec<Evidence> {
220        use PeAnomaly::{
221            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
222            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
223        };
224        let ev = |field: &str, value: String, location: Option<Location>| Evidence {
225            field: field.to_string(),
226            value,
227            location,
228        };
229        match self {
230            WritableExecutableSection { section_name } | VirtualOnlySection { section_name } => {
231                vec![ev("section", section_name.clone(), None)]
232            }
233            EntryPointOutsideSections { entry_point_rva } => vec![ev(
234                "entry_point_rva",
235                format!("{entry_point_rva:#x}"),
236                Some(Location::Rva(u64::from(*entry_point_rva))),
237            )],
238            LargeVirtualToRawRatio {
239                section_name,
240                ratio,
241            } => vec![
242                ev("section", section_name.clone(), None),
243                ev("ratio", ratio.to_string(), None),
244            ],
245            TlsCallbacksPresent { count } => vec![ev("count", count.to_string(), None)],
246            OverlayPresent { offset, size } => vec![
247                ev("size", size.to_string(), None),
248                ev(
249                    "offset",
250                    format!("{offset:#x}"),
251                    Some(Location::ByteOffset(*offset)),
252                ),
253            ],
254            RichHeaderAbsent => Vec::new(),
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::parser::{PeFile, PeSection};
263
264    // ── Helpers ───────────────────────────────────────────────────────────────
265
266    fn section(name: &str, va: u32, vsize: u32, rsize: u32, exec: bool, write: bool) -> PeSection {
267        PeSection {
268            name: name.to_string(),
269            virtual_size: vsize,
270            raw_size: rsize,
271            virtual_address: va,
272            entropy: 5.0,
273            is_executable: exec,
274            is_writable: write,
275            is_readable: true,
276        }
277    }
278
279    fn base_pe() -> PeFile {
280        PeFile {
281            machine: 0x8664,
282            compile_timestamp: 0,
283            is_dll: false,
284            is_exe: true,
285            imports: vec![],
286            exports: vec![],
287            sections: vec![section(".text", 0x1000, 0x500, 0x600, true, false)],
288            ascii_strings: vec![],
289            utf16_strings: vec![],
290            sha256: "0".repeat(64),
291            size: 0x800,
292            // New fields — defaults for a "clean" PE
293            entry_point_rva: 0x1000,
294            image_base: 0x0040_0000,
295            checksum: 0,
296            is_dotnet: false,
297            tls_callback_count: 0,
298            has_reloc: false,
299            is_signed: false,
300            pdb_path: None,
301            overlay_offset: None,
302            overlay_size: None,
303            rich_header: None,
304        }
305    }
306
307    // ── entry_point_in_section tests ──────────────────────────────────────────
308
309    #[test]
310    fn ep_inside_section_returns_true() {
311        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
312        assert!(entry_point_in_section(0x1500, &secs));
313    }
314
315    #[test]
316    fn ep_at_section_start_returns_true() {
317        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
318        assert!(entry_point_in_section(0x1000, &secs));
319    }
320
321    #[test]
322    fn ep_outside_all_sections_returns_false() {
323        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
324        assert!(!entry_point_in_section(0x5000, &secs));
325    }
326
327    #[test]
328    fn ep_in_one_of_multiple_sections_returns_true() {
329        let secs = vec![
330            section(".text", 0x1000, 0x500, 0x600, true, false),
331            section(".data", 0x2000, 0x200, 0x200, false, true),
332        ];
333        assert!(entry_point_in_section(0x2100, &secs));
334    }
335
336    // ── detect_structural_anomalies tests ─────────────────────────────────────
337
338    #[test]
339    fn clean_pe_has_no_anomalies() {
340        let pe = base_pe();
341        let anomalies = detect_structural_anomalies(&pe);
342        assert!(
343            anomalies.is_empty(),
344            "clean PE should produce no anomalies, got: {anomalies:?}"
345        );
346    }
347
348    #[test]
349    fn wx_section_produces_anomaly() {
350        let mut pe = base_pe();
351        pe.sections = vec![section(".rwx", 0x1000, 0x500, 0x600, true, true)];
352        let anomalies = detect_structural_anomalies(&pe);
353        assert!(
354            anomalies
355                .iter()
356                .any(|a| matches!(a, PeAnomaly::WritableExecutableSection { .. })),
357            "W+X section must produce anomaly"
358        );
359    }
360
361    #[test]
362    fn ep_outside_sections_produces_anomaly() {
363        let mut pe = base_pe();
364        pe.entry_point_rva = 0x9999; // outside .text at [0x1000, 0x1500)
365        let anomalies = detect_structural_anomalies(&pe);
366        assert!(
367            anomalies
368                .iter()
369                .any(|a| matches!(a, PeAnomaly::EntryPointOutsideSections { .. })),
370            "EP outside sections must produce anomaly"
371        );
372    }
373
374    #[test]
375    fn virtual_only_section_produces_anomaly() {
376        let mut pe = base_pe();
377        pe.sections = vec![section(".bss", 0x3000, 0x1000, 0, false, true)];
378        let anomalies = detect_structural_anomalies(&pe);
379        assert!(
380            anomalies
381                .iter()
382                .any(|a| matches!(a, PeAnomaly::VirtualOnlySection { .. })),
383            "virtual-only section must produce anomaly"
384        );
385    }
386
387    #[test]
388    fn large_virtual_raw_ratio_produces_anomaly() {
389        // virtual = 100 000, raw = 512 → ratio = 195
390        let mut pe = base_pe();
391        pe.sections = vec![section(".packed", 0x1000, 100_000, 512, true, false)];
392        let anomalies = detect_structural_anomalies(&pe);
393        assert!(
394            anomalies
395                .iter()
396                .any(|a| matches!(a, PeAnomaly::LargeVirtualToRawRatio { .. })),
397            "large v/r ratio must produce anomaly"
398        );
399    }
400
401    #[test]
402    fn tls_callbacks_produce_anomaly() {
403        let mut pe = base_pe();
404        pe.tls_callback_count = 3;
405        let anomalies = detect_structural_anomalies(&pe);
406        assert!(
407            anomalies
408                .iter()
409                .any(|a| matches!(a, PeAnomaly::TlsCallbacksPresent { count: 3 })),
410            "TLS callbacks must produce anomaly with correct count"
411        );
412    }
413
414    #[test]
415    fn overlay_produces_anomaly() {
416        let mut pe = base_pe();
417        pe.overlay_offset = Some(0x8000);
418        pe.overlay_size = Some(512);
419        let anomalies = detect_structural_anomalies(&pe);
420        assert!(
421            anomalies.iter().any(|a| matches!(
422                a,
423                PeAnomaly::OverlayPresent {
424                    offset: 0x8000,
425                    size: 512
426                }
427            )),
428            "overlay must produce anomaly with correct offset and size"
429        );
430    }
431
432    #[test]
433    fn missing_rich_header_on_large_binary_produces_anomaly() {
434        let mut pe = base_pe();
435        pe.size = 1_000_000; // 1 MB — too large to legitimately lack a Rich header
436        pe.rich_header = None;
437        let anomalies = detect_structural_anomalies(&pe);
438        assert!(
439            anomalies
440                .iter()
441                .any(|a| matches!(a, PeAnomaly::RichHeaderAbsent)),
442            "missing Rich header on large binary must produce anomaly"
443        );
444    }
445
446    #[test]
447    fn small_binary_without_rich_header_no_anomaly() {
448        let mut pe = base_pe();
449        pe.size = 512; // tiny — Rich header absence is fine
450        pe.rich_header = None;
451        let anomalies = detect_structural_anomalies(&pe);
452        // Should NOT produce RichHeaderAbsent for tiny files
453        assert!(
454            !anomalies
455                .iter()
456                .any(|a| matches!(a, PeAnomaly::RichHeaderAbsent)),
457            "small binary should not flag missing Rich header"
458        );
459    }
460
461    #[test]
462    fn multiple_anomalies_all_reported() {
463        let mut pe = base_pe();
464        pe.sections = vec![section(".evil", 0x1000, 50_000, 0, true, true)];
465        pe.entry_point_rva = 0xFFFF;
466        pe.tls_callback_count = 1;
467        let anomalies = detect_structural_anomalies(&pe);
468        assert!(
469            anomalies.len() >= 3,
470            "W+X + EP-outside + TLS should give ≥ 3 anomalies"
471        );
472    }
473}