Skip to main content

exec_pe_core/
rich_header.rs

1//! Rich header parsing — compiler fingerprint between DOS stub and PE signature.
2//!
3//! The Rich header records every compiler/linker tool version used to build
4//! the binary, XOR-encoded with a 4-byte key.  It is an invaluable attribution
5//! signal: identical `(product_id, build_id)` tuples across samples indicate
6//! the same toolchain, and therefore likely the same threat actor or campaign.
7//!
8//! # Format (all DWORDs are little-endian)
9//!
10//! ```text
11//!   [XOR(DanS, key)] [pad0^key] [pad1^key] [pad2^key]
12//!   [XOR(comp_id0, key)] [XOR(use_count0, key)]
13//!   ...
14//!   "Rich"  [xor_key]
15//! ```
16//!
17//! - `DanS` = 0x536E_6144 (`DanS` read as u32 LE)
18//! - `comp_id` = `(product_id << 16) | build_id`
19//! - `use_count` = number of objects compiled with that tool version
20//! - `xor_key` = raw DWORD after the `Rich` terminator
21
22/// One entry in the Rich header: a specific compiler/linker tool version.
23#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
24pub struct RichEntry {
25    /// Compiler / linker product identifier (high 16 bits of comp_id).
26    pub product_id: u16,
27    /// Build number (low 16 bits of comp_id).
28    pub build_id: u16,
29    /// Number of object files compiled with this exact tool version.
30    pub use_count: u32,
31}
32
33/// Decoded Rich header — compiler fingerprint from the DOS stub area.
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct RichHeader {
36    /// Decoded tool-version entries in the order they appear.
37    pub entries: Vec<RichEntry>,
38    /// XOR key used to encode the header (also a rudimentary checksum).
39    pub xor_key: u32,
40}
41
42/// Parse the Rich header from raw PE bytes.
43///
44/// Searches only within the DOS stub area (offset 0x40 → `e_lfanew`).
45/// Returns `None` if no `Rich` marker is found or if the header is malformed.
46pub fn parse_rich_header(bytes: &[u8]) -> Option<RichHeader> {
47    if bytes.len() < 0x40 {
48        return None;
49    }
50
51    // e_lfanew: 4-byte LE u32 at 0x3C — points to the PE signature.
52    let e_lfanew = read_u32_le(bytes, 0x3C)? as usize;
53    if e_lfanew < 0x44 || e_lfanew > bytes.len() {
54        return None;
55    }
56
57    // The Rich header lives between end of standard DOS header (0x40) and e_lfanew.
58    let stub_area = bytes.get(0x40..e_lfanew)?;
59
60    // Locate "Rich" terminator within the stub area.
61    let rich_rel = find_pattern(stub_area, b"Rich")?;
62    let rich_abs = 0x40 + rich_rel;
63
64    // XOR key is the DWORD immediately after "Rich".
65    let xor_key = read_u32_le(bytes, rich_abs + 4)?;
66
67    // Find the XOR-encoded "DanS" (0x536E_6144) marker at the beginning.
68    // We scan the stub area in 4-byte steps.
69    const DANS: u32 = 0x536E_6144;
70    let mut dans_rel: Option<usize> = None;
71    let mut i = 0usize;
72    while i + 4 <= rich_rel {
73        if let Some(raw) = read_u32_le(stub_area, i) {
74            if raw ^ xor_key == DANS {
75                dans_rel = Some(i);
76                break;
77            }
78        }
79        i += 4;
80    }
81    let dans_rel = dans_rel?;
82
83    // Entries start after DanS (4 B) + 3 padding DWORDs (12 B) = 16 bytes.
84    let entries_start = dans_rel + 16;
85    if entries_start > rich_rel {
86        return Some(RichHeader {
87            entries: vec![],
88            xor_key,
89        });
90    }
91
92    let mut entries = Vec::new();
93    let mut pos = entries_start;
94    while pos + 8 <= rich_rel {
95        let comp_id = read_u32_le(stub_area, pos)? ^ xor_key;
96        let use_count = read_u32_le(stub_area, pos + 4)? ^ xor_key;
97        entries.push(RichEntry {
98            product_id: (comp_id >> 16) as u16,
99            build_id: (comp_id & 0xFFFF) as u16,
100            use_count,
101        });
102        pos += 8;
103    }
104
105    Some(RichHeader { entries, xor_key })
106}
107
108// ── private helpers (used by both impl and tests) ─────────────────────────────
109
110pub(crate) fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
111    bytes
112        .get(offset..offset + 4)
113        .and_then(|s| <[u8; 4]>::try_from(s).ok())
114        .map(u32::from_le_bytes)
115}
116
117pub(crate) fn find_pattern(haystack: &[u8], needle: &[u8]) -> Option<usize> {
118    haystack.windows(needle.len()).position(|w| w == needle)
119}
120
121#[cfg(test)]
122pub(crate) mod test_helpers {
123    use super::*;
124
125    /// Build raw bytes containing a valid Rich header in the DOS stub area.
126    ///
127    /// `e_lfanew` is set dynamically so the stub area exactly contains
128    /// `entries` with `xor_key`.  The bytes end with `b"PE\0\0"` at `e_lfanew`
129    /// so goblin (if used) does not reject them.
130    pub fn make_pe_with_rich(entries: &[(u16, u16, u32)], xor_key: u32) -> Vec<u8> {
131        const DANS: u32 = 0x536E_6144; // b"DanS" as LE u32
132
133        let mut stub: Vec<u8> = Vec::new();
134        // DanS XOR'd + 3 padding DWORDs
135        stub.extend_from_slice(&(DANS ^ xor_key).to_le_bytes());
136        for _ in 0..3 {
137            stub.extend_from_slice(&xor_key.to_le_bytes()); // 0x00000000 ^ key
138        }
139        // Entries
140        for &(prod, build, count) in entries {
141            let comp_id = ((prod as u32) << 16) | (build as u32);
142            stub.extend_from_slice(&(comp_id ^ xor_key).to_le_bytes());
143            stub.extend_from_slice(&(count ^ xor_key).to_le_bytes());
144        }
145        // "Rich" + key
146        stub.extend_from_slice(b"Rich");
147        stub.extend_from_slice(&xor_key.to_le_bytes());
148
149        let e_lfanew: u32 = 0x40 + stub.len() as u32;
150        let mut buf = vec![0u8; e_lfanew as usize + 4];
151        buf[0] = b'M';
152        buf[1] = b'Z';
153        buf[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes());
154        buf[0x40..e_lfanew as usize].copy_from_slice(&stub);
155        buf[e_lfanew as usize..].copy_from_slice(b"PE\0\0");
156        buf
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{test_helpers::make_pe_with_rich, *};
163
164    #[test]
165    fn returns_none_for_empty_bytes() {
166        assert!(parse_rich_header(&[]).is_none());
167    }
168
169    #[test]
170    fn returns_none_when_no_rich_marker() {
171        // Minimal PE: dos header ends at 0x40, PE sig immediately follows → no stub area.
172        let mut pe = vec![0u8; 68];
173        pe[0] = b'M';
174        pe[1] = b'Z';
175        let e_lfanew: u32 = 0x40;
176        pe[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes());
177        pe[0x40..0x44].copy_from_slice(b"PE\0\0");
178        assert!(parse_rich_header(&pe).is_none());
179    }
180
181    #[test]
182    fn returns_none_for_truncated_input() {
183        let buf = [b'M', b'Z'];
184        assert!(parse_rich_header(&buf).is_none());
185    }
186
187    #[test]
188    fn parses_single_entry_correctly() {
189        let key = 0xDEAD_BEEF_u32;
190        let buf = make_pe_with_rich(&[(0x0103, 0x6B6B, 5)], key);
191        let rh = parse_rich_header(&buf).expect("Rich header must be found");
192        assert_eq!(rh.xor_key, key);
193        assert_eq!(rh.entries.len(), 1);
194        assert_eq!(rh.entries[0].product_id, 0x0103);
195        assert_eq!(rh.entries[0].build_id, 0x6B6B);
196        assert_eq!(rh.entries[0].use_count, 5);
197    }
198
199    #[test]
200    fn parses_multiple_entries_in_order() {
201        let key = 0x1234_5678_u32;
202        let expected = [
203            (0x0001, 0x6B00, 1),
204            (0x010C, 0x6B1A, 12),
205            (0x0103, 0x6B6B, 42),
206        ];
207        let buf = make_pe_with_rich(&expected, key);
208        let rh = parse_rich_header(&buf).expect("Rich header must be found");
209        assert_eq!(rh.entries.len(), 3);
210        assert_eq!(rh.entries[0].product_id, 0x0001);
211        assert_eq!(rh.entries[1].use_count, 12);
212        assert_eq!(rh.entries[2].product_id, 0x0103);
213        assert_eq!(rh.entries[2].build_id, 0x6B6B);
214    }
215
216    #[test]
217    fn zero_entry_rich_header_parses_successfully() {
218        let key = 0xCAFE_BABE_u32;
219        let buf = make_pe_with_rich(&[], key);
220        let rh = parse_rich_header(&buf).expect("zero-entry Rich header");
221        assert!(rh.entries.is_empty());
222        assert_eq!(rh.xor_key, key);
223    }
224
225    #[test]
226    fn xor_key_is_correct() {
227        let expected_key = 0x0101_0202_u32;
228        let buf = make_pe_with_rich(&[(1, 2, 3)], expected_key);
229        let rh = parse_rich_header(&buf).unwrap();
230        assert_eq!(rh.xor_key, expected_key);
231    }
232}