Skip to main content

winreg_artifacts/
amcache.rs

1//! Amcache registry artifact extractor.
2//!
3//! Amcache.hve records application execution evidence. Two on-disk schemas
4//! exist and a hive carries exactly one of them depending on OS version:
5//!
6//! - **Modern (Win10 1607+):** `Root\InventoryApplicationFile` — one subkey per
7//!   file, rich values (`LowerCaseLongPath`, `FileId`, `Size`, `Publisher`, …).
8//! - **Legacy (Win8 / Server 2012 R2):** `Root\File\{VolumeGUID}\{seq}` — one
9//!   subkey per file under a per-volume GUID, sparse numeric values
10//!   (`15` = full path, `101` = SHA-1 with the same `0000` prefix).
11//!
12//! [`parse`] decodes both, so it surfaces execution evidence regardless of the
13//! host's Windows version.
14
15use std::io::Cursor;
16
17use winreg_core::hive::Hive;
18use winreg_core::key::filetime_to_datetime;
19
20// ---------------------------------------------------------------------------
21// Output type
22// ---------------------------------------------------------------------------
23
24/// A single file entry from `Root\InventoryApplicationFile`.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct AmcacheEntry {
27    /// Full lowercase file path (`LowerCaseLongPath`).
28    pub file_path: String,
29    /// SHA-1 hash: the `FileId` value with the leading `0000` prefix stripped.
30    /// Empty string if the value is absent.
31    pub sha1: String,
32    /// File size in bytes (`Size` as `REG_DWORD`).
33    pub size: u64,
34    /// PE link timestamp string, e.g. `"01/15/2023 10:30:00"` (`LinkDate`).
35    pub link_date: Option<String>,
36    /// Publisher name (`Publisher`).
37    pub publisher: String,
38    /// Product name (`ProductName`).
39    pub product_name: String,
40    /// Product version string (`ProductVersion`).
41    pub product_version: String,
42    /// Binary file version string (`BinFileVersion`).
43    pub bin_file_version: String,
44    /// The subkey name (hash identifier for this entry).
45    pub key_name: String,
46    /// Key `LastWriteTime` as ISO 8601, or `None` if unavailable.
47    pub last_written: Option<String>,
48}
49
50// ---------------------------------------------------------------------------
51// Key paths
52// ---------------------------------------------------------------------------
53
54/// Path to the `InventoryApplicationFile` container key (relative to hive root).
55const INVENTORY_APP_FILE: &str = "Root\\InventoryApplicationFile";
56
57/// Path to the legacy `File` container key (per-volume subkeys beneath it).
58const ROOT_FILE: &str = "Root\\File";
59
60// ---------------------------------------------------------------------------
61// Public parse function
62// ---------------------------------------------------------------------------
63
64/// Extract all execution entries from an Amcache hive, across BOTH the modern
65/// (`Root\InventoryApplicationFile`) and legacy (`Root\File\{VolumeGUID}`)
66/// schemas. A real hive carries one or the other; decoding both means the
67/// caller surfaces execution evidence regardless of the host's Windows version.
68///
69/// Missing values produce empty strings or zero rather than errors. Returns an
70/// empty `Vec` when neither container key is present.
71#[must_use]
72pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<AmcacheEntry> {
73    let mut entries = parse_inventory_app_file(hive);
74    entries.extend(parse_root_file(hive));
75    entries
76}
77
78/// Decode the modern `Root\InventoryApplicationFile` schema (Win10 1607+).
79fn parse_inventory_app_file(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<AmcacheEntry> {
80    let Ok(Some(container)) = hive.open_key(INVENTORY_APP_FILE) else {
81        return Vec::new();
82    };
83
84    let Ok(subkeys) = container.subkeys() else {
85        return Vec::new();
86    };
87
88    let mut entries = Vec::with_capacity(subkeys.len());
89
90    for subkey in subkeys {
91        let key_name = subkey.name();
92
93        let last_written = filetime_to_datetime(subkey.last_written_raw())
94            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
95
96        // Helper: read a REG_SZ value, returning empty string on any error.
97        let read_sz = |name: &str| -> String {
98            subkey
99                .value(name)
100                .ok()
101                .flatten()
102                .and_then(|v| v.as_string().ok())
103                .unwrap_or_default()
104        };
105
106        let file_path = read_sz("LowerCaseLongPath");
107
108        // FileId: strip the leading "0000" prefix if present.
109        let file_id_raw = read_sz("FileId");
110        let sha1 = file_id_raw
111            .strip_prefix("0000")
112            .map_or_else(|| file_id_raw.clone(), ToString::to_string);
113
114        let size = u64::from(
115            subkey
116                .value("Size")
117                .ok()
118                .flatten()
119                .and_then(|v| v.as_u32().ok())
120                .unwrap_or(0),
121        );
122
123        let link_date_raw = read_sz("LinkDate");
124        let link_date = if link_date_raw.is_empty() {
125            None
126        } else {
127            Some(link_date_raw)
128        };
129
130        let publisher = read_sz("Publisher");
131        let product_name = read_sz("ProductName");
132        let product_version = read_sz("ProductVersion");
133        let bin_file_version = read_sz("BinFileVersion");
134
135        entries.push(AmcacheEntry {
136            file_path,
137            sha1,
138            size,
139            link_date,
140            publisher,
141            product_name,
142            product_version,
143            bin_file_version,
144            key_name,
145            last_written,
146        });
147    }
148
149    entries
150}
151
152/// Decode the legacy `Root\File\{VolumeGUID}\{seq}` schema (Win8 / Server
153/// 2012 R2). Each per-volume GUID subkey holds one subkey per file; the
154/// forensically relevant values are numeric: `15` = full path, `101` = SHA-1
155/// (carrying the same `0000` prefix the modern `FileId` does). The richer
156/// modern fields (size, publisher, …) do not exist here and stay empty/zero.
157fn parse_root_file(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<AmcacheEntry> {
158    let Ok(Some(container)) = hive.open_key(ROOT_FILE) else {
159        return Vec::new();
160    };
161    let Ok(volumes) = container.subkeys() else {
162        return Vec::new();
163    };
164
165    let mut entries = Vec::new();
166    for volume in volumes {
167        let Ok(files) = volume.subkeys() else {
168            continue; // cov:unreachable: a volume key returned by subkeys() is enumerable
169        };
170        for file in files {
171            let read_sz = |name: &str| -> String {
172                file.value(name)
173                    .ok()
174                    .flatten()
175                    .and_then(|v| v.as_string().ok())
176                    .unwrap_or_default()
177            };
178
179            let file_path = read_sz("15");
180            let sha1_raw = read_sz("101");
181            let sha1 = sha1_raw
182                .strip_prefix("0000")
183                .map_or_else(|| sha1_raw.clone(), ToString::to_string);
184            let last_written = filetime_to_datetime(file.last_written_raw())
185                .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
186
187            entries.push(AmcacheEntry {
188                file_path,
189                sha1,
190                size: 0,
191                link_date: None,
192                publisher: String::new(),
193                product_name: String::new(),
194                product_version: String::new(),
195                bin_file_version: String::new(),
196                key_name: file.name(),
197                last_written,
198            });
199        }
200    }
201
202    entries
203}