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        if sec.raw_size > 0 {
80            let ratio = sec.virtual_size / 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 && !pe.sections.is_empty() {
92        if !entry_point_in_section(pe.entry_point_rva, &pe.sections) {
93            out.push(PeAnomaly::EntryPointOutsideSections {
94                entry_point_rva: pe.entry_point_rva,
95            });
96        }
97    }
98
99    // TLS callbacks registered
100    if pe.tls_callback_count > 0 {
101        out.push(PeAnomaly::TlsCallbacksPresent {
102            count: pe.tls_callback_count,
103        });
104    }
105
106    // Overlay data appended after last section
107    if let (Some(offset), Some(size)) = (pe.overlay_offset, pe.overlay_size) {
108        out.push(PeAnomaly::OverlayPresent { offset, size });
109    }
110
111    // Rich header absent on a binary large enough to have been compiled (> 4 KiB)
112    if pe.rich_header.is_none() && pe.size > 4096 {
113        out.push(PeAnomaly::RichHeaderAbsent);
114    }
115
116    out
117}
118
119/// Return `true` when `entry_rva` falls within the virtual address range of
120/// at least one section (`[va, va + virtual_size)`).
121pub fn entry_point_in_section(entry_rva: u32, sections: &[PeSection]) -> bool {
122    sections.iter().any(|s| {
123        let end = s.virtual_address.saturating_add(s.virtual_size.max(1));
124        entry_rva >= s.virtual_address && entry_rva < end
125    })
126}
127
128impl Observation for PeAnomaly {
129    fn severity(&self) -> Option<Severity> {
130        use PeAnomaly::{
131            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
132            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
133        };
134        Some(match self {
135            EntryPointOutsideSections { .. } => Severity::High,
136            WritableExecutableSection { .. }
137            | VirtualOnlySection { .. }
138            | LargeVirtualToRawRatio { .. } => Severity::Medium,
139            TlsCallbacksPresent { .. } | OverlayPresent { .. } | RichHeaderAbsent => Severity::Low,
140        })
141    }
142
143    fn category(&self) -> Category {
144        use PeAnomaly::{OverlayPresent, RichHeaderAbsent, TlsCallbacksPresent};
145        match self {
146            TlsCallbacksPresent { .. } | RichHeaderAbsent => Category::Concealment,
147            OverlayPresent { .. } => Category::Residue,
148            _ => Category::Structure,
149        }
150    }
151
152    fn code(&self) -> &'static str {
153        use PeAnomaly::{
154            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
155            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
156        };
157        match self {
158            WritableExecutableSection { .. } => "PE-WX-SECTION",
159            EntryPointOutsideSections { .. } => "PE-ENTRYPOINT-OOB",
160            VirtualOnlySection { .. } => "PE-VIRTUAL-ONLY-SECTION",
161            LargeVirtualToRawRatio { .. } => "PE-VSIZE-RATIO",
162            TlsCallbacksPresent { .. } => "PE-TLS-CALLBACKS",
163            OverlayPresent { .. } => "PE-OVERLAY",
164            RichHeaderAbsent => "PE-RICH-ABSENT",
165        }
166    }
167
168    fn note(&self) -> String {
169        use PeAnomaly::{
170            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
171            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
172        };
173        match self {
174            WritableExecutableSection { section_name } => {
175                format!("section '{section_name}' is both writable and executable (W+X)")
176            }
177            EntryPointOutsideSections { entry_point_rva } => {
178                format!("entry-point RVA {entry_point_rva:#x} falls outside every defined section")
179            }
180            VirtualOnlySection { section_name } => {
181                format!("section '{section_name}' has zero raw size but a non-zero virtual size")
182            }
183            LargeVirtualToRawRatio {
184                section_name,
185                ratio,
186            } => {
187                format!("section '{section_name}' virtual size exceeds its raw size by ~{ratio}x")
188            }
189            TlsCallbacksPresent { count } => {
190                format!("{count} TLS callback(s) execute before the entry point")
191            }
192            OverlayPresent { offset, size } => {
193                format!(
194                    "{size} bytes of overlay data appended after the last section at {offset:#x}"
195                )
196            }
197            RichHeaderAbsent => {
198                "no Rich header in the DOS stub — consistent with anti-attribution stripping"
199                    .to_string()
200            }
201        }
202    }
203
204    fn mitre(&self) -> &'static [&'static str] {
205        use PeAnomaly::{
206            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
207            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
208        };
209        match self {
210            WritableExecutableSection { .. } | EntryPointOutsideSections { .. } => &["T1055"],
211            TlsCallbacksPresent { .. } => &["T1055.005"],
212            VirtualOnlySection { .. } | LargeVirtualToRawRatio { .. } => &["T1027.002"],
213            RichHeaderAbsent => &["T1027"],
214            OverlayPresent { .. } => &[],
215        }
216    }
217
218    fn evidence(&self) -> Vec<Evidence> {
219        use PeAnomaly::{
220            EntryPointOutsideSections, LargeVirtualToRawRatio, OverlayPresent, RichHeaderAbsent,
221            TlsCallbacksPresent, VirtualOnlySection, WritableExecutableSection,
222        };
223        let ev = |field: &str, value: String, location: Option<Location>| Evidence {
224            field: field.to_string(),
225            value,
226            location,
227        };
228        match self {
229            WritableExecutableSection { section_name } | VirtualOnlySection { section_name } => {
230                vec![ev("section", section_name.clone(), None)]
231            }
232            EntryPointOutsideSections { entry_point_rva } => vec![ev(
233                "entry_point_rva",
234                format!("{entry_point_rva:#x}"),
235                Some(Location::Rva(u64::from(*entry_point_rva))),
236            )],
237            LargeVirtualToRawRatio {
238                section_name,
239                ratio,
240            } => vec![
241                ev("section", section_name.clone(), None),
242                ev("ratio", ratio.to_string(), None),
243            ],
244            TlsCallbacksPresent { count } => vec![ev("count", count.to_string(), None)],
245            OverlayPresent { offset, size } => vec![
246                ev("size", size.to_string(), None),
247                ev(
248                    "offset",
249                    format!("{offset:#x}"),
250                    Some(Location::ByteOffset(*offset)),
251                ),
252            ],
253            RichHeaderAbsent => Vec::new(),
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::parser::{PeFile, PeSection};
262
263    // ── Helpers ───────────────────────────────────────────────────────────────
264
265    fn section(name: &str, va: u32, vsize: u32, rsize: u32, exec: bool, write: bool) -> PeSection {
266        PeSection {
267            name: name.to_string(),
268            virtual_size: vsize,
269            raw_size: rsize,
270            virtual_address: va,
271            entropy: 5.0,
272            is_executable: exec,
273            is_writable: write,
274            is_readable: true,
275        }
276    }
277
278    fn base_pe() -> PeFile {
279        PeFile {
280            machine: 0x8664,
281            compile_timestamp: 0,
282            is_dll: false,
283            is_exe: true,
284            imports: vec![],
285            exports: vec![],
286            sections: vec![section(".text", 0x1000, 0x500, 0x600, true, false)],
287            ascii_strings: vec![],
288            utf16_strings: vec![],
289            sha256: "0".repeat(64),
290            size: 0x800,
291            // New fields — defaults for a "clean" PE
292            entry_point_rva: 0x1000,
293            image_base: 0x0040_0000,
294            checksum: 0,
295            is_dotnet: false,
296            tls_callback_count: 0,
297            has_reloc: false,
298            is_signed: false,
299            pdb_path: None,
300            overlay_offset: None,
301            overlay_size: None,
302            rich_header: None,
303        }
304    }
305
306    // ── entry_point_in_section tests ──────────────────────────────────────────
307
308    #[test]
309    fn ep_inside_section_returns_true() {
310        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
311        assert!(entry_point_in_section(0x1500, &secs));
312    }
313
314    #[test]
315    fn ep_at_section_start_returns_true() {
316        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
317        assert!(entry_point_in_section(0x1000, &secs));
318    }
319
320    #[test]
321    fn ep_outside_all_sections_returns_false() {
322        let secs = vec![section(".text", 0x1000, 0x1000, 0x1000, true, false)];
323        assert!(!entry_point_in_section(0x5000, &secs));
324    }
325
326    #[test]
327    fn ep_in_one_of_multiple_sections_returns_true() {
328        let secs = vec![
329            section(".text", 0x1000, 0x500, 0x600, true, false),
330            section(".data", 0x2000, 0x200, 0x200, false, true),
331        ];
332        assert!(entry_point_in_section(0x2100, &secs));
333    }
334
335    // ── detect_structural_anomalies tests ─────────────────────────────────────
336
337    #[test]
338    fn clean_pe_has_no_anomalies() {
339        let pe = base_pe();
340        let anomalies = detect_structural_anomalies(&pe);
341        assert!(
342            anomalies.is_empty(),
343            "clean PE should produce no anomalies, got: {anomalies:?}"
344        );
345    }
346
347    #[test]
348    fn wx_section_produces_anomaly() {
349        let mut pe = base_pe();
350        pe.sections = vec![section(".rwx", 0x1000, 0x500, 0x600, true, true)];
351        let anomalies = detect_structural_anomalies(&pe);
352        assert!(
353            anomalies
354                .iter()
355                .any(|a| matches!(a, PeAnomaly::WritableExecutableSection { .. })),
356            "W+X section must produce anomaly"
357        );
358    }
359
360    #[test]
361    fn ep_outside_sections_produces_anomaly() {
362        let mut pe = base_pe();
363        pe.entry_point_rva = 0x9999; // outside .text at [0x1000, 0x1500)
364        let anomalies = detect_structural_anomalies(&pe);
365        assert!(
366            anomalies
367                .iter()
368                .any(|a| matches!(a, PeAnomaly::EntryPointOutsideSections { .. })),
369            "EP outside sections must produce anomaly"
370        );
371    }
372
373    #[test]
374    fn virtual_only_section_produces_anomaly() {
375        let mut pe = base_pe();
376        pe.sections = vec![section(".bss", 0x3000, 0x1000, 0, false, true)];
377        let anomalies = detect_structural_anomalies(&pe);
378        assert!(
379            anomalies
380                .iter()
381                .any(|a| matches!(a, PeAnomaly::VirtualOnlySection { .. })),
382            "virtual-only section must produce anomaly"
383        );
384    }
385
386    #[test]
387    fn large_virtual_raw_ratio_produces_anomaly() {
388        // virtual = 100 000, raw = 512 → ratio = 195
389        let mut pe = base_pe();
390        pe.sections = vec![section(".packed", 0x1000, 100_000, 512, true, false)];
391        let anomalies = detect_structural_anomalies(&pe);
392        assert!(
393            anomalies
394                .iter()
395                .any(|a| matches!(a, PeAnomaly::LargeVirtualToRawRatio { .. })),
396            "large v/r ratio must produce anomaly"
397        );
398    }
399
400    #[test]
401    fn tls_callbacks_produce_anomaly() {
402        let mut pe = base_pe();
403        pe.tls_callback_count = 3;
404        let anomalies = detect_structural_anomalies(&pe);
405        assert!(
406            anomalies
407                .iter()
408                .any(|a| matches!(a, PeAnomaly::TlsCallbacksPresent { count: 3 })),
409            "TLS callbacks must produce anomaly with correct count"
410        );
411    }
412
413    #[test]
414    fn overlay_produces_anomaly() {
415        let mut pe = base_pe();
416        pe.overlay_offset = Some(0x8000);
417        pe.overlay_size = Some(512);
418        let anomalies = detect_structural_anomalies(&pe);
419        assert!(
420            anomalies.iter().any(|a| matches!(
421                a,
422                PeAnomaly::OverlayPresent {
423                    offset: 0x8000,
424                    size: 512
425                }
426            )),
427            "overlay must produce anomaly with correct offset and size"
428        );
429    }
430
431    #[test]
432    fn missing_rich_header_on_large_binary_produces_anomaly() {
433        let mut pe = base_pe();
434        pe.size = 1_000_000; // 1 MB — too large to legitimately lack a Rich header
435        pe.rich_header = None;
436        let anomalies = detect_structural_anomalies(&pe);
437        assert!(
438            anomalies
439                .iter()
440                .any(|a| matches!(a, PeAnomaly::RichHeaderAbsent)),
441            "missing Rich header on large binary must produce anomaly"
442        );
443    }
444
445    #[test]
446    fn small_binary_without_rich_header_no_anomaly() {
447        let mut pe = base_pe();
448        pe.size = 512; // tiny — Rich header absence is fine
449        pe.rich_header = None;
450        let anomalies = detect_structural_anomalies(&pe);
451        // Should NOT produce RichHeaderAbsent for tiny files
452        assert!(
453            !anomalies
454                .iter()
455                .any(|a| matches!(a, PeAnomaly::RichHeaderAbsent)),
456            "small binary should not flag missing Rich header"
457        );
458    }
459
460    #[test]
461    fn multiple_anomalies_all_reported() {
462        let mut pe = base_pe();
463        pe.sections = vec![section(".evil", 0x1000, 50_000, 0, true, true)];
464        pe.entry_point_rva = 0xFFFF;
465        pe.tls_callback_count = 1;
466        let anomalies = detect_structural_anomalies(&pe);
467        assert!(
468            anomalies.len() >= 3,
469            "W+X + EP-outside + TLS should give ≥ 3 anomalies"
470        );
471    }
472}