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 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 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 if pe.tls_callback_count > 0 {
101 out.push(PeAnomaly::TlsCallbacksPresent {
102 count: pe.tls_callback_count,
103 });
104 }
105
106 if let (Some(offset), Some(size)) = (pe.overlay_offset, pe.overlay_size) {
108 out.push(PeAnomaly::OverlayPresent { offset, size });
109 }
110
111 if pe.rich_header.is_none() && pe.size > 4096 {
113 out.push(PeAnomaly::RichHeaderAbsent);
114 }
115
116 out
117}
118
119pub 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 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 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 #[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 #[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; 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 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; 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; pe.rich_header = None;
450 let anomalies = detect_structural_anomalies(&pe);
451 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}