Skip to main content

winreg_artifacts/
shimcache.rs

1//! ShimCache (`AppCompatCache`) registry artifact extractor.
2//!
3//! ShimCache is stored in the SYSTEM hive and records application execution
4//! metadata for compatibility checking. It is evidence of program execution.
5//!
6//! Key path: `SYSTEM\CurrentControlSet\Control\Session Manager\AppCompatCache`
7//! Value name: `AppCompatCache` (`REG_BINARY`)
8
9use std::io::Cursor;
10
11use winreg_core::hive::Hive;
12use winreg_core::key::filetime_to_datetime;
13
14// AppCompatCache header signatures + entry-body field offsets are facts about
15// the format and live in the KNOWLEDGE leaf. See `forensicnomicon::appcompatcache`
16// for the per-build table and the full authoritative-source list (Mandiant
17// whitepaper, Eric Zimmerman's AppCompatCacheParser, libyal winreg-kb).
18use forensicnomicon::appcompatcache as fmt;
19
20// ---------------------------------------------------------------------------
21// Output type
22// ---------------------------------------------------------------------------
23
24/// A single entry decoded from the `AppCompatCache` (ShimCache) binary blob.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct ShimcacheEntry {
27    /// Executable path extracted from the cache entry. Empty if unparseable.
28    pub path: String,
29    /// Last modified time as ISO 8601, or `None` if unavailable.
30    pub last_modified: Option<String>,
31    /// Size of the raw `AppCompatCache` `REG_BINARY` blob.
32    pub raw_size: usize,
33    /// Position in the cache (0 = most recently executed).
34    pub entry_index: usize,
35}
36
37// ---------------------------------------------------------------------------
38// Key / value paths
39// ---------------------------------------------------------------------------
40
41/// Key path suffix below the `ControlSet` (`CurrentControlSet` on live hives,
42/// `ControlSet00N` on offline ones).
43const APPCOMPAT_SUFFIX: &str = "Control\\Session Manager\\AppCompatCache";
44const APPCOMPAT_VALUE: &str = "AppCompatCache";
45
46// ---------------------------------------------------------------------------
47// Format signatures
48// ---------------------------------------------------------------------------
49
50/// Windows 8 / Server 2012 legacy header first byte (`0x80`), per libyal
51/// winreg-kb. Some 8.x hives carry this; others (Case-001 DC01) open with a
52/// `0x00000000` first dword, so the format is gated by the entry marker at
53/// `forensicnomicon::appcompatcache::WIN8X_ENTRY_STREAM_OFFSET`, not this byte.
54const WIN8_HEADER_SIG: u8 = 0x80;
55
56/// Entry-body layout for the `"00ts"`/`"10ts"` cache-entry stream. The entry
57/// *framing* (`sig(4) | unknown(4) | ce_data_size(4)`) is identical across
58/// families; only the body differs (see `forensicnomicon::appcompatcache`).
59#[derive(Clone, Copy)]
60enum EntryBodyLayout {
61    /// Win10 (0x30/0x34 header): FILETIME immediately follows the path.
62    Win10,
63    /// Win8.0/8.1 & Server 2012/2012 R2: `package_len(2) | package |
64    /// insertion_flags(4) | shim_flags(4)` precede the FILETIME.
65    Win8x,
66}
67
68// ---------------------------------------------------------------------------
69// Public parse function
70// ---------------------------------------------------------------------------
71
72/// Extract ShimCache entries from a SYSTEM hive.
73///
74/// Resolves the active `ControlSet`, then reads
75/// `<ControlSet>\Control\Session Manager\AppCompatCache`. Live hives expose a
76/// `CurrentControlSet` symlink; **offline** hives do not — they carry
77/// `ControlSet00N` selected by `Select\Current`, so we resolve that.
78///
79/// Returns an empty `Vec` if the key or value is absent.
80/// Returns a single sentinel entry (empty path) if the blob exists but the
81/// format is unrecognised.
82pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<ShimcacheEntry> {
83    // `Select\Current` (REG_DWORD) names the active set on an offline hive;
84    // default to set 1 when the Select key is absent.
85    let current = hive
86        .open_key("Select")
87        .ok()
88        .flatten()
89        .and_then(|sel| sel.value("Current").ok().flatten())
90        .and_then(|v| v.raw_data().ok())
91        .filter(|d| d.len() >= 4)
92        .map_or(1u32, |d| u32::from_le_bytes([d[0], d[1], d[2], d[3]]));
93
94    // Try the live symlink, the Select-resolved set, then ControlSet001.
95    let candidates = [
96        format!("CurrentControlSet\\{APPCOMPAT_SUFFIX}"),
97        format!("ControlSet{current:03}\\{APPCOMPAT_SUFFIX}"),
98        format!("ControlSet001\\{APPCOMPAT_SUFFIX}"),
99    ];
100    let Some(key) = candidates
101        .iter()
102        .find_map(|p| hive.open_key(p).ok().flatten())
103    else {
104        return Vec::new();
105    };
106
107    // Read the REG_BINARY value.
108    let blob: Vec<u8> = match key.value(APPCOMPAT_VALUE) {
109        Ok(Some(v)) => match v.raw_data() {
110            Ok(d) => d,
111            Err(_) => return Vec::new(),
112        },
113        _ => return Vec::new(),
114    };
115
116    let raw_size = blob.len();
117
118    // Blobs shorter than 4 bytes cannot contain a valid signature.
119    if raw_size < 4 {
120        return Vec::new();
121    }
122
123    let sig = u32::from_le_bytes([blob[0], blob[1], blob[2], blob[3]]);
124
125    // Win10 (1507 = 0x30, 1607+ = 0x34): the first dword is the header length;
126    // the `"10ts"` entries follow it and carry the FILETIME right after the path.
127    if sig == fmt::WIN10_1507_HEADER_LEN || sig == fmt::WIN10_1607_HEADER_LEN {
128        return parse_win10_entries(
129            &blob,
130            sig as usize,
131            raw_size,
132            b"10ts",
133            EntryBodyLayout::Win10,
134        );
135    }
136    // Header-less `"10ts"` stream (some synthetic/edge captures put entries at 0).
137    if sig == fmt::ENTRY_MARKER_WIN81_WIN10_U32 {
138        return parse_win10_entries(&blob, 0, raw_size, b"10ts", EntryBodyLayout::Win10);
139    }
140    // Win8.0/8.1 & Server 2012/2012 R2: a 128-byte header followed by entries
141    // tagged "00ts" (8.0/2012) or "10ts" (8.1/2012 R2). The header's first dword
142    // varies in the wild (0x80 per libyal; 0x00000000 on the Case-001 DC01 Server
143    // 2012 R2 hive), so classify by the marker at offset 128 exactly as Eric
144    // Zimmerman's AppCompatCacheParser does — independent of the first dword. The
145    // Win8.x body carries package_len + insertion/shim flags BEFORE the FILETIME,
146    // so it must be decoded with the Win8x layout (Win10 reads the wrong offset).
147    if blob.len() >= fmt::WIN8X_ENTRY_STREAM_OFFSET + 4 {
148        let marker = &blob[fmt::WIN8X_ENTRY_STREAM_OFFSET..fmt::WIN8X_ENTRY_STREAM_OFFSET + 4];
149        if marker == fmt::ENTRY_MARKER_WIN80 || marker == fmt::ENTRY_MARKER_WIN81_WIN10 {
150            return parse_win10_entries(
151                &blob,
152                fmt::WIN8X_ENTRY_STREAM_OFFSET,
153                raw_size,
154                marker,
155                EntryBodyLayout::Win8x,
156            );
157        }
158    }
159    // Win8 0x80 header without a marker at offset 128 (legacy fixed parser).
160    if blob[0] == WIN8_HEADER_SIG {
161        return parse_win10(&blob, raw_size);
162    }
163    // Last resort: locate the first "10ts" marker anywhere and parse from there
164    // with the Win10 body layout (headerless/synthetic captures).
165    if let Some(pos) = blob
166        .windows(4)
167        .position(|w| w == fmt::ENTRY_MARKER_WIN81_WIN10)
168    {
169        return parse_win10_entries(&blob, pos, raw_size, b"10ts", EntryBodyLayout::Win10);
170    }
171    // No "10ts" entries anywhere — genuinely unrecognised. Return a sentinel so
172    // the caller still records that a blob was present.
173    vec![ShimcacheEntry {
174        path: String::new(),
175        last_modified: None,
176        raw_size,
177        entry_index: 0,
178    }]
179}
180
181/// Parse a stream of Win10 `"10ts"` `AppCompatCache` entries beginning at `start`.
182///
183/// Each entry: `"10ts" | unknown(4) | ce_data_size(4) | body[ce_data_size]`,
184/// where the body is `path_size(2) | path(UTF-16LE) | FILETIME(8) | data_size(4)
185/// | data`.
186/// Parse a `"00ts"`/`"10ts"` entry stream beginning at `start`, tagged
187/// `entry_sig`, with bodies decoded per `layout`.
188///
189/// Each entry: `sig(4) | unknown(4) | ce_data_size(4) | body[ce_data_size]`
190/// (`forensicnomicon::appcompatcache::ENTRY_FRAMING_LEN`).
191fn parse_win10_entries(
192    blob: &[u8],
193    start: usize,
194    raw_size: usize,
195    entry_sig: &[u8],
196    layout: EntryBodyLayout,
197) -> Vec<ShimcacheEntry> {
198    let mut entries = Vec::new();
199    let mut offset = start;
200    let mut entry_index = 0;
201
202    while offset + fmt::ENTRY_FRAMING_LEN <= blob.len() {
203        if &blob[offset..offset + 4] != entry_sig {
204            break;
205        }
206        // offset+4: unknown (4 bytes), then the cache-entry data size.
207        let ce_data_size = u32::from_le_bytes([
208            blob[offset + 8],
209            blob[offset + 9],
210            blob[offset + 10],
211            blob[offset + 11],
212        ]) as usize;
213        let body_start = offset + fmt::ENTRY_FRAMING_LEN;
214        let body_end = match body_start.checked_add(ce_data_size) {
215            Some(e) if e <= blob.len() => e,
216            _ => break,
217        };
218
219        let (path, last_modified) = decode_win10_entry_body(&blob[body_start..body_end], layout);
220        entries.push(ShimcacheEntry {
221            path,
222            last_modified,
223            raw_size,
224            entry_index,
225        });
226
227        offset = body_end;
228        entry_index += 1;
229    }
230
231    entries
232}
233
234/// Decode a `"00ts"`/`"10ts"` entry body.
235///
236/// `Win10`: `path_size(2) | path(UTF-16LE) | FILETIME(8) | data_size(4) | data`
237/// — FILETIME at `path_end` (`WIN10_PATH_TO_FILETIME` = 0).
238///
239/// `Win8x`: `path_size(2) | path | package_len(2) | package | insertion_flags(4)
240/// | shim_flags(4) | FILETIME(8) | data_size(4) | data` — FILETIME at
241/// `path_end + 2 + package_len + WIN8X_PATH_TO_FILETIME_FIXED`. Offsets and the
242/// authoritative sources live in `forensicnomicon::appcompatcache`.
243fn decode_win10_entry_body(body: &[u8], layout: EntryBodyLayout) -> (String, Option<String>) {
244    if body.len() < 2 {
245        return (String::new(), None);
246    }
247    let path_size = u16::from_le_bytes([body[0], body[1]]) as usize;
248    let path_end = 2 + path_size;
249    let path = if path_size > 0 && path_end <= body.len() {
250        decode_utf16le(&body[2..path_end])
251    } else {
252        String::new()
253    };
254    let ft_offset = match layout {
255        EntryBodyLayout::Win10 => path_end.checked_add(fmt::WIN10_PATH_TO_FILETIME),
256        EntryBodyLayout::Win8x => {
257            // Read package_len(u16) at path_end, then skip it + the package data
258            // + insertion/shim flags to reach the FILETIME.
259            if path_end + 2 <= body.len() {
260                let package_len = u16::from_le_bytes([body[path_end], body[path_end + 1]]) as usize;
261                path_end.checked_add(2 + package_len + fmt::WIN8X_PATH_TO_FILETIME_FIXED)
262            } else {
263                None
264            }
265        }
266    };
267    let last_modified = ft_offset
268        .filter(|&o| o.checked_add(8).is_some_and(|end| end <= body.len()))
269        .and_then(|o| {
270            let ft = winreg_core::bytes::le_u64(body, o);
271            filetime_to_datetime(ft).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
272        });
273    (path, last_modified)
274}
275
276// ---------------------------------------------------------------------------
277// Win10 parser
278// ---------------------------------------------------------------------------
279
280/// Parse the Windows 10 `AppCompatCache` format.
281///
282/// Header (128 bytes):
283///   Bytes 0-3:   signature `0x73743031` ("10ts" LE)
284///   Bytes 4-7:   number of entries (u32 LE)
285///   Bytes 8-127: padding
286///
287/// Each entry starts with:
288///   Bytes 0-3:   entry signature `0x73743031`
289///   Bytes 4-7:   entry data length (u32 LE) — length of the body *after* these 8 bytes
290///   Then entry body (variable):
291///     Bytes 0-1:  path length in bytes (u16 LE)
292///     Bytes 2-7:  padding / reserved
293///     Bytes 8-15: `LastModifiedTime` (FILETIME, u64 LE)
294///     Bytes 16-17: path data offset within the entry body (u16 LE, often 0x20)
295///     ... path data (UTF-16LE) at body offset indicated by `path_offset_in_body`
296///
297/// In practice the layout is approximately:
298///   `entry_data_len` (from header) bytes of body, containing:
299///     [0..2]   `path_len`  (u16 LE) — byte count of UTF-16LE path
300///     [8..16]  `last_modified` (u64 LE FILETIME)
301///     [16..18] `path_offset`   (u16 LE) — offset within body to the path data
302///     [`path_offset` .. `path_offset` + `path_len`] path bytes (UTF-16LE)
303fn parse_win10(blob: &[u8], raw_size: usize) -> Vec<ShimcacheEntry> {
304    // The cache header is 128 bytes for Win10.
305    const HEADER_SIZE: usize = 128;
306
307    if blob.len() < HEADER_SIZE {
308        return Vec::new();
309    }
310
311    let entry_count = u32::from_le_bytes([blob[4], blob[5], blob[6], blob[7]]) as usize;
312    if entry_count == 0 {
313        return Vec::new();
314    }
315
316    let mut entries = Vec::with_capacity(entry_count);
317    let mut offset = HEADER_SIZE;
318    let mut entry_index = 0;
319
320    while offset + 8 <= blob.len() && entry_index < entry_count {
321        // Each entry starts with a 4-byte signature.
322        let entry_sig = u32::from_le_bytes([
323            blob[offset],
324            blob[offset + 1],
325            blob[offset + 2],
326            blob[offset + 3],
327        ]);
328
329        if entry_sig != fmt::ENTRY_MARKER_WIN81_WIN10_U32 {
330            break;
331        }
332
333        let entry_data_len = u32::from_le_bytes([
334            blob[offset + 4],
335            blob[offset + 5],
336            blob[offset + 6],
337            blob[offset + 7],
338        ]) as usize;
339
340        let body_start = offset + 8;
341        let body_end = body_start + entry_data_len;
342
343        if body_end > blob.len() {
344            break;
345        }
346
347        let body = &blob[body_start..body_end];
348
349        let (path, last_modified) = decode_entry_body(body);
350
351        entries.push(ShimcacheEntry {
352            path,
353            last_modified,
354            raw_size,
355            entry_index,
356        });
357
358        offset = body_end;
359        entry_index += 1;
360    }
361
362    entries
363}
364
365/// Decode a single Win10 entry body.
366///
367/// Layout (best-effort; fields may be absent for short bodies):
368///   [0..2]   `path_len`  (u16 LE) — byte count of the UTF-16LE path
369///   [8..16]  `last_modified` (u64 LE FILETIME)
370///   [16..18] `path_data_offset` (u16 LE) — offset within body to path bytes
371fn decode_entry_body(body: &[u8]) -> (String, Option<String>) {
372    if body.len() < 2 {
373        return (String::new(), None);
374    }
375
376    let path_len = u16::from_le_bytes([body[0], body[1]]) as usize;
377
378    let last_modified: Option<String> = if body.len() >= 16 {
379        let ft = winreg_core::bytes::le_u64(body, 8);
380        filetime_to_datetime(ft).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
381    } else {
382        None
383    };
384
385    let path: String = if path_len == 0 || body.len() < 18 {
386        String::new()
387    } else {
388        let path_offset = u16::from_le_bytes([body[16], body[17]]) as usize;
389        let path_end = path_offset + path_len;
390        if path_offset < body.len() && path_end <= body.len() {
391            decode_utf16le(&body[path_offset..path_end])
392        } else {
393            String::new()
394        }
395    };
396
397    (path, last_modified)
398}
399
400/// Decode UTF-16LE bytes to a `String`, stopping at the first null.
401fn decode_utf16le(data: &[u8]) -> String {
402    let u16s: Vec<u16> = data
403        .chunks_exact(2)
404        .map(|c| u16::from_le_bytes([c[0], c[1]]))
405        .collect();
406    let trimmed: &[u16] = match u16s.iter().position(|&c| c == 0) {
407        Some(pos) => &u16s[..pos],
408        None => &u16s,
409    };
410    String::from_utf16_lossy(trimmed).clone()
411}