1use crc32fast::Hasher;
2
3pub use forensicnomicon::evtx::{
4 CHUNK_RECORDS_OFFSET, CHUNK_SIZE, ELFCHNK_MAGIC, ELFFILE_MAGIC, RECORD_MAGIC,
5};
6
7pub use forensicnomicon::report::Severity;
10
11#[derive(Debug, Clone, serde::Serialize)]
12pub struct EvtxFileHeader {
13 pub first_chunk_number: u64,
14 pub last_chunk_number: u64,
15 pub next_record_id: u64,
16 pub minor_version: u16,
17 pub major_version: u16,
18 pub chunk_count: u16,
19 pub file_flags: u32,
20 pub checksum: u32,
21}
22
23impl EvtxFileHeader {
24 pub fn parse(buf: &[u8]) -> Option<Self> {
25 if buf.len() < 128 {
26 return None;
27 }
28 if buf[0..8] != ELFFILE_MAGIC {
29 return None;
30 }
31 Some(Self {
32 first_chunk_number: u64::from_le_bytes(buf[8..16].try_into().ok()?),
33 last_chunk_number: u64::from_le_bytes(buf[16..24].try_into().ok()?),
34 next_record_id: u64::from_le_bytes(buf[24..32].try_into().ok()?),
35 minor_version: u16::from_le_bytes(buf[36..38].try_into().ok()?),
36 major_version: u16::from_le_bytes(buf[38..40].try_into().ok()?),
37 chunk_count: u16::from_le_bytes(buf[42..44].try_into().ok()?),
38 file_flags: u32::from_le_bytes(buf[0x78..0x7C].try_into().ok()?),
39 checksum: u32::from_le_bytes(buf[0x7C..0x80].try_into().ok()?),
40 })
41 }
42}
43
44#[derive(Debug, Clone, serde::Serialize)]
45pub struct EvtxChunkHeader {
46 pub first_event_record_number: u64,
47 pub last_event_record_number: u64,
48 pub first_event_record_id: u64,
49 pub last_event_record_id: u64,
50 pub header_size: u32,
51 pub last_event_record_data_offset: u32,
52 pub free_space_offset: u32,
53 pub event_records_checksum: u32,
54 pub header_checksum: u32,
55}
56
57impl EvtxChunkHeader {
58 pub fn parse(buf: &[u8]) -> Option<Self> {
59 if buf.len() < 0x7C {
60 return None;
61 }
62 if buf[0..8] != ELFCHNK_MAGIC {
63 return None;
64 }
65 let last_event_record_data_offset = u32::from_le_bytes(buf[44..48].try_into().ok()?);
66 let free_space_offset = u32::from_le_bytes(buf[48..52].try_into().ok()?);
67 if u64::from(last_event_record_data_offset) > CHUNK_SIZE
68 || u64::from(free_space_offset) > CHUNK_SIZE
69 {
70 return None;
71 }
72 Some(Self {
73 first_event_record_number: u64::from_le_bytes(buf[8..16].try_into().ok()?),
74 last_event_record_number: u64::from_le_bytes(buf[16..24].try_into().ok()?),
75 first_event_record_id: u64::from_le_bytes(buf[24..32].try_into().ok()?),
76 last_event_record_id: u64::from_le_bytes(buf[32..40].try_into().ok()?),
77 header_size: u32::from_le_bytes(buf[40..44].try_into().ok()?),
78 last_event_record_data_offset,
79 free_space_offset,
80 event_records_checksum: u32::from_le_bytes(buf[52..56].try_into().ok()?),
81 header_checksum: u32::from_le_bytes(buf[0x78..0x7C].try_into().ok()?),
82 })
83 }
84}
85
86#[derive(Debug, Clone, serde::Serialize)]
87pub struct EvtxRecordHeader {
88 pub size: u32,
89 pub record_id: u64,
90 pub timestamp: u64,
91}
92
93impl EvtxRecordHeader {
94 pub fn parse(buf: &[u8]) -> Option<Self> {
95 if buf.len() < 24 {
96 return None;
97 }
98 if buf[0..4] != RECORD_MAGIC {
99 return None;
100 }
101 let size = u32::from_le_bytes(buf[4..8].try_into().ok()?);
102 if size < 24 || u64::from(size) > CHUNK_SIZE {
103 return None;
104 }
105 Some(Self {
106 size,
107 record_id: u64::from_le_bytes(buf[8..16].try_into().ok()?),
108 timestamp: u64::from_le_bytes(buf[16..24].try_into().ok()?),
109 })
110 }
111}
112
113pub fn compute_checksum(data: &[u8]) -> u32 {
115 let mut h = Hasher::new();
116 h.update(data);
117 h.finalize()
118}
119
120#[derive(Debug, Clone, serde::Serialize)]
126pub enum IntegrityAnomaly {
127 LogCleared {
128 channel: String,
129 timestamp: u64,
130 user_sid: Option<String>,
131 },
132 RecordIdGap {
133 chunk_offset: u64,
134 expected: u64,
135 found: u64,
136 },
137 ChecksumMismatch,
139 ChunkChecksumMismatch {
140 chunk_offset: u64,
141 computed: u32,
142 stored: u32,
143 },
144 RecordChecksumMismatch {
145 chunk_offset: u64,
146 computed: u32,
147 stored: u32,
148 },
149 NextRecordIdInconsistency {
150 header_next: u64,
151 actual_highest: u64,
152 },
153 TimestampAnomaly {
154 chunk_offset: u64,
155 record_id: u64,
156 prev_ts: u64,
157 this_ts: u64,
158 },
159 FileHeaderChecksumMismatch {
160 computed: u32,
161 stored: u32,
162 },
163 FileNotCleanlyShutdown,
164 FileFull,
165 ChunkCountMismatch {
166 header_count: u16,
167 actual_count: usize,
168 },
169 ExportTimestampCorruption {
179 record_id: u64,
181 chunk_offset: u64,
183 },
184 SurgicalRecordDeletion {
198 chunk_offset: u64,
200 absorbing_record_id: u64,
202 stated_size: u32,
204 ghost_offset_in_chunk: u64,
208 },
209 InvalidChunkDataLength(u32),
211 LogFileGuidMismatch {
214 chunk_index: usize,
215 expected: u128,
216 actual: u128,
217 },
218 TrailingData {
220 offset: u64,
222 len: usize,
224 },
225 TruncatedFile {
227 declared_chunks: u16,
229 found_chunks: usize,
231 },
232 OverlappingChunks {
234 chunk_a_offset: u64,
236 chunk_b_offset: u64,
238 },
239 EmptyLog,
242 PhantomRecordInjection {
246 gap_start_id: u64,
248 gap_end_id: u64,
250 prev_timestamp_ns: i64,
252 next_timestamp_ns: i64,
254 },
255}
256
257impl IntegrityAnomaly {
258 pub fn severity(&self) -> Severity {
260 match self {
261 IntegrityAnomaly::SurgicalRecordDeletion { .. } => Severity::Critical,
262
263 IntegrityAnomaly::ChunkChecksumMismatch { .. }
264 | IntegrityAnomaly::RecordChecksumMismatch { .. }
265 | IntegrityAnomaly::FileHeaderChecksumMismatch { .. }
266 | IntegrityAnomaly::LogFileGuidMismatch { .. }
267 | IntegrityAnomaly::NextRecordIdInconsistency { .. }
268 | IntegrityAnomaly::RecordIdGap { .. }
269 | IntegrityAnomaly::ChunkCountMismatch { .. }
270 | IntegrityAnomaly::InvalidChunkDataLength(_)
271 | IntegrityAnomaly::TrailingData { .. }
272 | IntegrityAnomaly::TruncatedFile { .. }
273 | IntegrityAnomaly::OverlappingChunks { .. } => Severity::High,
274
275 IntegrityAnomaly::PhantomRecordInjection { .. } => Severity::High,
276
277 IntegrityAnomaly::TimestampAnomaly { .. }
278 | IntegrityAnomaly::ExportTimestampCorruption { .. }
279 | IntegrityAnomaly::LogCleared { .. }
280 | IntegrityAnomaly::FileNotCleanlyShutdown
281 | IntegrityAnomaly::FileFull
282 | IntegrityAnomaly::ChecksumMismatch
283 | IntegrityAnomaly::EmptyLog => Severity::Medium,
284 }
285 }
286}
287
288
289
290impl IntegrityAnomaly {
291 #[must_use]
293 pub fn code(&self) -> &'static str {
294 match self {
295 Self::LogCleared { .. } => "WINEVT-LOG-CLEARED",
296 Self::RecordIdGap { .. } => "WINEVT-RECORD-ID-GAP",
297 Self::ChecksumMismatch => "WINEVT-CHECKSUM-MISMATCH",
298 Self::ChunkChecksumMismatch { .. } => "WINEVT-CHUNK-CHECKSUM-MISMATCH",
299 Self::RecordChecksumMismatch { .. } => "WINEVT-RECORD-CHECKSUM-MISMATCH",
300 Self::NextRecordIdInconsistency { .. } => "WINEVT-NEXT-RECORD-ID-INCONSISTENCY",
301 Self::TimestampAnomaly { .. } => "WINEVT-TIMESTAMP-ANOMALY",
302 Self::FileHeaderChecksumMismatch { .. } => "WINEVT-FILE-HEADER-CHECKSUM-MISMATCH",
303 Self::FileNotCleanlyShutdown => "WINEVT-FILE-NOT-CLEANLY-SHUTDOWN",
304 Self::FileFull => "WINEVT-FILE-FULL",
305 Self::ChunkCountMismatch { .. } => "WINEVT-CHUNK-COUNT-MISMATCH",
306 Self::ExportTimestampCorruption { .. } => "WINEVT-EXPORT-TIMESTAMP-CORRUPTION",
307 Self::SurgicalRecordDeletion { .. } => "WINEVT-SURGICAL-RECORD-DELETION",
308 Self::InvalidChunkDataLength(..) => "WINEVT-INVALID-CHUNK-DATA-LENGTH",
309 Self::LogFileGuidMismatch { .. } => "WINEVT-LOG-FILE-GUID-MISMATCH",
310 Self::TrailingData { .. } => "WINEVT-TRAILING-DATA",
311 Self::TruncatedFile { .. } => "WINEVT-TRUNCATED-FILE",
312 Self::OverlappingChunks { .. } => "WINEVT-OVERLAPPING-CHUNKS",
313 Self::EmptyLog => "WINEVT-EMPTY-LOG",
314 Self::PhantomRecordInjection { .. } => "WINEVT-PHANTOM-RECORD-INJECTION",
315 }
316 }
317}
318
319impl forensicnomicon::report::Observation for IntegrityAnomaly {
320 fn severity(&self) -> Option<Severity> {
321 Some(self.severity())
322 }
323 fn code(&self) -> &'static str {
324 self.code()
325 }
326 fn note(&self) -> String {
327 self.code()
328 .strip_prefix("WINEVT-")
329 .unwrap_or_default()
330 .to_ascii_lowercase()
331 .replace('-', " ")
332 }
333}
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn integrity_anomaly_has_checksum_variant() {
340 let a = IntegrityAnomaly::ChecksumMismatch;
341 let s = format!("{a:?}");
342 assert!(s.contains("ChecksumMismatch"));
343 }
344
345 #[test]
346 fn constants_match_forensicnomicon() {
347 assert_eq!(ELFFILE_MAGIC, forensicnomicon::evtx::ELFFILE_MAGIC);
348 assert_eq!(ELFCHNK_MAGIC, forensicnomicon::evtx::ELFCHNK_MAGIC);
349 assert_eq!(RECORD_MAGIC, forensicnomicon::evtx::RECORD_MAGIC);
350 assert_eq!(CHUNK_SIZE, forensicnomicon::evtx::CHUNK_SIZE);
351 assert_eq!(CHUNK_RECORDS_OFFSET, forensicnomicon::evtx::CHUNK_RECORDS_OFFSET);
352 }
353
354 #[test]
357 fn severity_ordering() {
358 assert!(Severity::Info < Severity::Medium);
359 assert!(Severity::Medium < Severity::High);
360 assert!(Severity::High < Severity::Critical);
361 }
362
363 #[test]
366 fn severity_surgical_record_deletion_is_critical() {
367 let a = IntegrityAnomaly::SurgicalRecordDeletion {
368 chunk_offset: 0,
369 absorbing_record_id: 1,
370 stated_size: 100,
371 ghost_offset_in_chunk: 50,
372 };
373 assert_eq!(a.severity(), Severity::Critical);
374 }
375
376 #[test]
377 fn severity_chunk_checksum_mismatch_is_error() {
378 let a = IntegrityAnomaly::ChunkChecksumMismatch {
379 chunk_offset: 0,
380 computed: 1,
381 stored: 2,
382 };
383 assert_eq!(a.severity(), Severity::High);
384 }
385
386 #[test]
387 fn severity_record_checksum_mismatch_is_error() {
388 let a = IntegrityAnomaly::RecordChecksumMismatch {
389 chunk_offset: 0,
390 computed: 1,
391 stored: 2,
392 };
393 assert_eq!(a.severity(), Severity::High);
394 }
395
396 #[test]
397 fn severity_file_header_checksum_mismatch_is_error() {
398 let a = IntegrityAnomaly::FileHeaderChecksumMismatch {
399 computed: 1,
400 stored: 2,
401 };
402 assert_eq!(a.severity(), Severity::High);
403 }
404
405 #[test]
406 fn severity_log_file_guid_mismatch_is_error() {
407 let a = IntegrityAnomaly::LogFileGuidMismatch {
408 chunk_index: 1,
409 expected: 0,
410 actual: 1,
411 };
412 assert_eq!(a.severity(), Severity::High);
413 }
414
415 #[test]
416 fn severity_next_record_id_inconsistency_is_error() {
417 let a = IntegrityAnomaly::NextRecordIdInconsistency {
418 header_next: 5,
419 actual_highest: 3,
420 };
421 assert_eq!(a.severity(), Severity::High);
422 }
423
424 #[test]
425 fn severity_record_id_gap_is_error() {
426 let a = IntegrityAnomaly::RecordIdGap {
427 chunk_offset: 0,
428 expected: 5,
429 found: 10,
430 };
431 assert_eq!(a.severity(), Severity::High);
432 }
433
434 #[test]
435 fn severity_chunk_count_mismatch_is_error() {
436 let a = IntegrityAnomaly::ChunkCountMismatch {
437 header_count: 5,
438 actual_count: 3,
439 };
440 assert_eq!(a.severity(), Severity::High);
441 }
442
443 #[test]
444 fn severity_invalid_chunk_data_length_is_error() {
445 let a = IntegrityAnomaly::InvalidChunkDataLength(999);
446 assert_eq!(a.severity(), Severity::High);
447 }
448
449 #[test]
450 fn severity_timestamp_anomaly_is_warning() {
451 let a = IntegrityAnomaly::TimestampAnomaly {
452 chunk_offset: 0,
453 record_id: 1,
454 prev_ts: 100,
455 this_ts: 50,
456 };
457 assert_eq!(a.severity(), Severity::Medium);
458 }
459
460 #[test]
461 fn severity_export_timestamp_corruption_is_warning() {
462 let a = IntegrityAnomaly::ExportTimestampCorruption {
463 record_id: 1,
464 chunk_offset: 0,
465 };
466 assert_eq!(a.severity(), Severity::Medium);
467 }
468
469 #[test]
470 fn severity_log_cleared_is_warning() {
471 let a = IntegrityAnomaly::LogCleared {
472 channel: "Security".to_string(),
473 timestamp: 0,
474 user_sid: None,
475 };
476 assert_eq!(a.severity(), Severity::Medium);
477 }
478
479 #[test]
480 fn severity_file_not_cleanly_shutdown_is_warning() {
481 assert_eq!(IntegrityAnomaly::FileNotCleanlyShutdown.severity(), Severity::Medium);
482 }
483
484 #[test]
485 fn severity_file_full_is_warning() {
486 assert_eq!(IntegrityAnomaly::FileFull.severity(), Severity::Medium);
487 }
488
489 #[test]
490 fn severity_checksum_mismatch_is_warning() {
491 assert_eq!(IntegrityAnomaly::ChecksumMismatch.severity(), Severity::Medium);
492 }
493
494 #[test]
497 fn trailing_data_exists_and_debug() {
498 let a = IntegrityAnomaly::TrailingData { offset: 65536, len: 128 };
499 let s = format!("{a:?}");
500 assert!(s.contains("TrailingData"));
501 }
502
503 #[test]
504 fn trailing_data_severity_is_error() {
505 let a = IntegrityAnomaly::TrailingData { offset: 0, len: 1 };
506 assert_eq!(a.severity(), Severity::High);
507 }
508
509 #[test]
510 fn truncated_file_exists_and_debug() {
511 let a = IntegrityAnomaly::TruncatedFile { declared_chunks: 10, found_chunks: 7 };
512 let s = format!("{a:?}");
513 assert!(s.contains("TruncatedFile"));
514 }
515
516 #[test]
517 fn truncated_file_severity_is_error() {
518 let a = IntegrityAnomaly::TruncatedFile { declared_chunks: 10, found_chunks: 7 };
519 assert_eq!(a.severity(), Severity::High);
520 }
521
522 #[test]
523 fn overlapping_chunks_exists_and_debug() {
524 let a = IntegrityAnomaly::OverlappingChunks { chunk_a_offset: 512, chunk_b_offset: 1024 };
525 let s = format!("{a:?}");
526 assert!(s.contains("OverlappingChunks"));
527 }
528
529 #[test]
530 fn overlapping_chunks_severity_is_error() {
531 let a = IntegrityAnomaly::OverlappingChunks { chunk_a_offset: 0, chunk_b_offset: 512 };
532 assert_eq!(a.severity(), Severity::High);
533 }
534}