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}