1use crate::parser::{PeFile, PeSection};
12use forensicnomicon::report::{Category, Evidence, Location, Observation, Severity};
13
14#[derive(Debug, Clone, PartialEq, serde::Serialize)]
19pub enum PeAnomaly {
20 WritableExecutableSection { section_name: String },
24
25 EntryPointOutsideSections { entry_point_rva: u32 },
29
30 VirtualOnlySection { section_name: String },
34
35 LargeVirtualToRawRatio { section_name: String, ratio: u32 },
39
40 TlsCallbacksPresent { count: usize },
44
45 OverlayPresent { offset: u64, size: u64 },
49
50 RichHeaderAbsent,
54}
55
56pub fn detect_structural_anomalies(pe: &PeFile) -> Vec<PeAnomaly> {
61 let mut out = Vec::new();
62
63 for sec in &pe.sections {
64 if sec.is_writable && sec.is_executable {
66 out.push(PeAnomaly::WritableExecutableSection {
67 section_name: sec.name.clone(),
68 });
69 }
70
71 if sec.raw_size == 0 && sec.virtual_size > 0 {
73 out.push(PeAnomaly::VirtualOnlySection {
74 section_name: sec.name.clone(),
75 });
76 }
77
78 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 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 if pe.tls_callback_count > 0 {
102 out.push(PeAnomaly::TlsCallbacksPresent {
103 count: pe.tls_callback_count,
104 });
105 }
106
107 if let (Some(offset), Some(size)) = (pe.overlay_offset, pe.overlay_size) {
109 out.push(PeAnomaly::OverlayPresent { offset, size });
110 }
111
112 if pe.rich_header.is_none() && pe.size > 4096 {
114 out.push(PeAnomaly::RichHeaderAbsent);
115 }
116
117 out
118}
119
120pub 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 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 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 #[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 #[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; 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 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; 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; pe.rich_header = None;
451 let anomalies = detect_structural_anomalies(&pe);
452 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}