Skip to main content

winevt_core/
binary.rs

1use crc32fast::Hasher;
2
3pub use forensicnomicon::evtx::{
4    CHUNK_RECORDS_OFFSET, CHUNK_SIZE, ELFCHNK_MAGIC, ELFFILE_MAGIC, RECORD_MAGIC,
5};
6
7/// The canonical 5-level severity scale, shared across every SecurityRonin
8/// analyzer via [`forensicnomicon::report`].
9pub 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
113/// CRC32 (ISO 3309) — the variant used throughout EVTX format.
114pub fn compute_checksum(data: &[u8]) -> u32 {
115    let mut h = Hasher::new();
116    h.update(data);
117    h.finalize()
118}
119
120/// Structural integrity anomalies detected in an EVTX file.
121///
122/// These variants represent low-level binary format facts only.
123/// Intent inference (e.g. anti-forensic classification) belongs in the
124/// caller — for example, the `RapidTriage` correlation engine.
125#[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    /// Generic checksum mismatch (caller should prefer the specific variants below).
138    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    /// A record has a zeroed header timestamp consistent with the wevtutil /
170    /// Event Viewer export bug: when exporting with `wevtutil epl` or
171    /// "Save As…", each record's header timestamp is replaced with the
172    /// *previous* record's `BinXml` timestamp; the first record in the export
173    /// therefore has no predecessor and receives timestamp 0.
174    ///
175    /// Reference: Wassenaar, Fox-IT BV (2019).
176    /// "Export corrupts Windows Event Log files"
177    /// <https://blog.fox-it.com/2019/06/04/export-corrupts-windows-event-log-files/>
178    ExportTimestampCorruption {
179        /// Record ID of the affected record (header timestamp is 0).
180        record_id: u64,
181        /// Byte offset of the chunk that contains this record.
182        chunk_offset: u64,
183    },
184    /// A record's stated size spans the magic bytes of a subsequent record,
185    /// consistent with surgical deletion by the NSA `DanderSpritz`
186    /// `eventlogedit` tool.  The tool absorbs the deleted record into the
187    /// preceding record's size field without emitting EID 1102.
188    ///
189    /// Reference: Wassenaar & van Dijk, Fox-IT BV (2017).
190    /// "Detection and recovery of NSA's covered up tracks"
191    /// <https://blog.fox-it.com/2017/12/08/detection-and-recovery-of-nsas-covered-up-tracks/>
192    ///
193    /// Reference implementation (Python):
194    /// Wassenaar, Fox-IT BV — fox-it/danderspritz-evtx
195    /// <https://github.com/fox-it/danderspritz-evtx>
196    /// (MIT License; algorithm independently re-implemented in Rust)
197    SurgicalRecordDeletion {
198        /// Byte offset of the chunk containing the anomaly.
199        chunk_offset: u64,
200        /// Record ID of the absorbing record (its size was inflated).
201        absorbing_record_id: u64,
202        /// The inflated size value read from the absorbing record.
203        stated_size: u32,
204        /// Byte offset within the chunk where the ghost record's magic
205        /// bytes (`0x2A 0x2A 0x00 0x00`) were found inside the absorbing
206        /// record's body.
207        ghost_offset_in_chunk: u64,
208    },
209    /// Chunk data length field falls outside the valid EVTX range [512, 65536].
210    InvalidChunkDataLength(u32),
211    /// The `log_file_guid` field in a chunk header differs from the first chunk's GUID,
212    /// indicating the chunk was transplanted from a different log file.
213    LogFileGuidMismatch {
214        chunk_index: usize,
215        expected: u128,
216        actual: u128,
217    },
218    /// Unexpected bytes follow the last valid chunk in the file.
219    TrailingData {
220        /// Byte offset where unexpected data begins after the last valid chunk.
221        offset: u64,
222        /// Number of unexpected bytes.
223        len: usize,
224    },
225    /// The file ends before all chunks declared in the file header are present.
226    TruncatedFile {
227        /// Chunk count declared in the file header.
228        declared_chunks: u16,
229        /// Chunks actually found in the file.
230        found_chunks: usize,
231    },
232    /// Two chunk byte-ranges overlap, indicating structural corruption.
233    OverlappingChunks {
234        /// Byte offset of the first (earlier) chunk.
235        chunk_a_offset: u64,
236        /// Byte offset of the second (later) chunk whose range overlaps chunk_a.
237        chunk_b_offset: u64,
238    },
239    /// The file header reports zero chunks. The log was cleared and the file
240    /// was recreated but never written to, or the header is corrupt.
241    EmptyLog,
242    /// A record-ID gap whose timestamp delta is too small to account for the
243    /// missing records, suggesting phantom records were injected without
244    /// advancing the clock — a deliberate anti-forensic technique.
245    PhantomRecordInjection {
246        /// First record ID in the gap (inclusive).
247        gap_start_id: u64,
248        /// Last record ID in the gap (inclusive).
249        gap_end_id: u64,
250        /// Timestamp (nanoseconds) of the record immediately before the gap.
251        prev_timestamp_ns: i64,
252        /// Timestamp (nanoseconds) of the record immediately after the gap.
253        next_timestamp_ns: i64,
254    },
255}
256
257impl IntegrityAnomaly {
258    /// Returns the [`Severity`] of this anomaly.
259    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    /// Stable, scheme-prefixed machine code for this anomaly.
292    #[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    // ── Severity ordering ────────────────────────────────────────────────────
355
356    #[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    // ── severity() mapping for every existing variant ────────────────────────
364
365    #[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    // ── New variants: existence + Debug serialisation + severity ─────────────
495
496    #[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}