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        } // cov:unreachable: while-guard i+4 <= rich_rel, and rich_rel+4 <= stub_area.len() (find_pattern of a 4-byte needle), so read_u32_le is always Some here
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
124    /// Build raw bytes containing a valid Rich header in the DOS stub area.
125    ///
126    /// `e_lfanew` is set dynamically so the stub area exactly contains
127    /// `entries` with `xor_key`.  The bytes end with `b"PE\0\0"` at `e_lfanew`
128    /// so goblin (if used) does not reject them.
129    pub fn make_pe_with_rich(entries: &[(u16, u16, u32)], xor_key: u32) -> Vec<u8> {
130        const DANS: u32 = 0x536E_6144; // b"DanS" as LE u32
131
132        let mut stub: Vec<u8> = Vec::new();
133        // DanS XOR'd + 3 padding DWORDs
134        stub.extend_from_slice(&(DANS ^ xor_key).to_le_bytes());
135        for _ in 0..3 {
136            stub.extend_from_slice(&xor_key.to_le_bytes()); // 0x00000000 ^ key
137        }
138        // Entries
139        for &(prod, build, count) in entries {
140            let comp_id = (u32::from(prod) << 16) | u32::from(build);
141            stub.extend_from_slice(&(comp_id ^ xor_key).to_le_bytes());
142            stub.extend_from_slice(&(count ^ xor_key).to_le_bytes());
143        }
144        // "Rich" + key
145        stub.extend_from_slice(b"Rich");
146        stub.extend_from_slice(&xor_key.to_le_bytes());
147
148        let e_lfanew: u32 = 0x40 + stub.len() as u32;
149        let mut buf = vec![0u8; e_lfanew as usize + 4];
150        buf[0] = b'M';
151        buf[1] = b'Z';
152        buf[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes());
153        buf[0x40..e_lfanew as usize].copy_from_slice(&stub);
154        buf[e_lfanew as usize..].copy_from_slice(b"PE\0\0");
155        buf
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{test_helpers::make_pe_with_rich, *};
162
163    #[test]
164    fn returns_none_for_empty_bytes() {
165        assert!(parse_rich_header(&[]).is_none());
166    }
167
168    #[test]
169    fn returns_none_when_no_rich_marker() {
170        // Minimal PE: dos header ends at 0x40, PE sig immediately follows → no stub area.
171        let mut pe = vec![0u8; 68];
172        pe[0] = b'M';
173        pe[1] = b'Z';
174        let e_lfanew: u32 = 0x40;
175        pe[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes());
176        pe[0x40..0x44].copy_from_slice(b"PE\0\0");
177        assert!(parse_rich_header(&pe).is_none());
178    }
179
180    #[test]
181    fn returns_none_for_truncated_input() {
182        let buf = [b'M', b'Z'];
183        assert!(parse_rich_header(&buf).is_none());
184    }
185
186    #[test]
187    fn parses_single_entry_correctly() {
188        let key = 0xDEAD_BEEF_u32;
189        let buf = make_pe_with_rich(&[(0x0103, 0x6B6B, 5)], key);
190        let rh = parse_rich_header(&buf).expect("Rich header must be found");
191        assert_eq!(rh.xor_key, key);
192        assert_eq!(rh.entries.len(), 1);
193        assert_eq!(rh.entries[0].product_id, 0x0103);
194        assert_eq!(rh.entries[0].build_id, 0x6B6B);
195        assert_eq!(rh.entries[0].use_count, 5);
196    }
197
198    #[test]
199    fn parses_multiple_entries_in_order() {
200        let key = 0x1234_5678_u32;
201        let expected = [
202            (0x0001, 0x6B00, 1),
203            (0x010C, 0x6B1A, 12),
204            (0x0103, 0x6B6B, 42),
205        ];
206        let buf = make_pe_with_rich(&expected, key);
207        let rh = parse_rich_header(&buf).expect("Rich header must be found");
208        assert_eq!(rh.entries.len(), 3);
209        assert_eq!(rh.entries[0].product_id, 0x0001);
210        assert_eq!(rh.entries[1].use_count, 12);
211        assert_eq!(rh.entries[2].product_id, 0x0103);
212        assert_eq!(rh.entries[2].build_id, 0x6B6B);
213    }
214
215    #[test]
216    fn zero_entry_rich_header_parses_successfully() {
217        let key = 0xCAFE_BABE_u32;
218        let buf = make_pe_with_rich(&[], key);
219        let rh = parse_rich_header(&buf).expect("zero-entry Rich header");
220        assert!(rh.entries.is_empty());
221        assert_eq!(rh.xor_key, key);
222    }
223
224    #[test]
225    fn xor_key_is_correct() {
226        let expected_key = 0x0101_0202_u32;
227        let buf = make_pe_with_rich(&[(1, 2, 3)], expected_key);
228        let rh = parse_rich_header(&buf).unwrap();
229        assert_eq!(rh.xor_key, expected_key);
230    }
231
232    const DANS: u32 = 0x536E_6144;
233
234    /// Wrap a hand-built stub area (bytes from 0x40) into an MZ buffer whose
235    /// `e_lfanew` points just past it at a `PE\0\0` signature.
236    fn wrap_stub(stub: &[u8]) -> Vec<u8> {
237        let e_lfanew = 0x40 + stub.len() as u32;
238        let mut buf = vec![0u8; e_lfanew as usize + 4];
239        buf[0] = b'M';
240        buf[1] = b'Z';
241        buf[0x3C..0x40].copy_from_slice(&e_lfanew.to_le_bytes());
242        buf[0x40..e_lfanew as usize].copy_from_slice(stub);
243        buf[e_lfanew as usize..].copy_from_slice(b"PE\0\0");
244        buf
245    }
246
247    #[test]
248    fn dans_marker_found_after_leading_junk_dword() {
249        // A non-DanS DWORD precedes DanS, so the scan must advance past it (the
250        // no-match branch of the DanS search) before matching.
251        let key = 0x1122_3344_u32;
252        let mut stub = Vec::new();
253        stub.extend_from_slice(&0u32.to_le_bytes()); // 0 ^ key = key != DanS
254        stub.extend_from_slice(&(DANS ^ key).to_le_bytes()); // DanS at rel 4
255        for _ in 0..3 {
256            stub.extend_from_slice(&key.to_le_bytes()); // 3 padding DWORDs
257        }
258        let comp_id = (0x0103u32 << 16) | 0x6B6B;
259        stub.extend_from_slice(&(comp_id ^ key).to_le_bytes());
260        stub.extend_from_slice(&(7u32 ^ key).to_le_bytes());
261        stub.extend_from_slice(b"Rich");
262        stub.extend_from_slice(&key.to_le_bytes());
263
264        let rh = parse_rich_header(&wrap_stub(&stub)).expect("DanS found after junk");
265        assert_eq!(rh.xor_key, key);
266        assert_eq!(rh.entries.len(), 1);
267        assert_eq!(rh.entries[0].product_id, 0x0103);
268        assert_eq!(rh.entries[0].use_count, 7);
269    }
270
271    #[test]
272    fn dans_immediately_before_rich_yields_empty_entries() {
273        // DanS sits right before "Rich": entries_start (dans_rel + 16) exceeds the
274        // "Rich" position, so the header parses as valid-but-empty rather than
275        // reading past it.
276        let key = 0xAABB_CCDD_u32;
277        let mut stub = Vec::new();
278        stub.extend_from_slice(&(DANS ^ key).to_le_bytes()); // DanS at rel 0
279        stub.extend_from_slice(b"Rich"); // rich_rel = 4 < entries_start (16)
280        stub.extend_from_slice(&key.to_le_bytes());
281
282        let rh = parse_rich_header(&wrap_stub(&stub)).expect("empty-but-valid Rich header");
283        assert!(rh.entries.is_empty());
284        assert_eq!(rh.xor_key, key);
285    }
286}