peripheral_core/shellbag.rs
1//! ShellBags: decode `BagMRU` folder-navigation history from a Windows user hive.
2//!
3//! Windows records every folder a user browses in Explorer as a `BagMRU` tree.
4//! Two hives carry it: `NTUSER.DAT`
5//! (`Software\Microsoft\Windows\Shell\BagMRU`) and the per-user `UsrClass.dat`
6//! (`Local Settings\Software\Microsoft\Windows\Shell\BagMRU`). Each node is a
7//! folder; the shell-item bytes for a child folder live in the **parent** key as
8//! a `REG_BINARY` value named with the child's numeric slot ("0", "1", …). The
9//! full browsed path to a node is the sequence of shell items collected walking
10//! from the root down to it.
11//!
12//! For USB forensics the interesting subset is folders browsed on a **removable /
13//! drive-letter volume** (a [`ShellItemKind::Volume`] item in the path, e.g.
14//! `E:\`): a shellbag entry attests that `E:\some\folder` was browsed, which
15//! corroborates that the volume was mounted at that letter and names the
16//! directories touched on it. This decoder walks the tree, delegates shell-item
17//! parsing to the fuzzed [`shellitem`] primitive, reconstructs the path, and
18//! surfaces one [`ShellbagEntry`] per drive-letter-referencing node.
19//!
20//! This is a reader (no findings): the forensic correlation (tying the drive
21//! letter to the physical device that carried it) lives in `usb-forensic`.
22//!
23//! # Robustness
24//!
25//! The hive is attacker-controllable. Parsing is panic-free: shell-item decoding
26//! is bounds-checked by `shellitem`, the tree walk is iterative (no native
27//! recursion, so no stack overflow), and a visited-offset set guarantees
28//! termination on a crafted cyclic hive (a valid REGF hive is a tree).
29
30use crate::Provenance;
31use shellitem::{parse_idlist, reconstruct_path, ShellItem, ShellItemKind};
32use std::collections::HashSet;
33use std::io::Cursor;
34use winreg_core::hive::Hive;
35use winreg_core::key::Key;
36
37/// The two `BagMRU` roots: `NTUSER.DAT` (`Software\...\Shell\BagMRU`) and the
38/// per-user `UsrClass.dat` (`Local Settings\Software\...\Shell\BagMRU`).
39const BAGMRU_PATHS: &[&str] = &[
40 "Software\\Microsoft\\Windows\\Shell\\BagMRU",
41 "Local Settings\\Software\\Microsoft\\Windows\\Shell\\BagMRU",
42];
43
44/// One browsed folder from a `BagMRU` tree that references a drive-letter volume.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ShellbagEntry {
47 /// The reconstructed browsed path (e.g. `My Computer\E:\\photos`), joined from
48 /// the shell items on the path from the root to this node.
49 pub path: String,
50 /// The drive letter of the volume item in the path, upper-cased (e.g. `E`);
51 /// `None` when the volume item carried no clean drive-letter name.
52 pub drive_letter: Option<char>,
53 /// The node key's last-written time (when the folder's shellbag was last
54 /// updated), epoch seconds UTC; `None` when the hive recorded none.
55 pub last_write: Option<i64>,
56 /// Where the record was decoded from.
57 pub source: Provenance,
58}
59
60/// Parse drive-letter `BagMRU` shellbag entries from an already-opened user hive
61/// (`NTUSER.DAT` or `UsrClass.dat`). `file` is recorded on each record's
62/// [`Provenance`]. Total over a valid hive — never panics.
63#[must_use]
64pub fn parse_shellbags(hive: &Hive<Cursor<Vec<u8>>>, file: &str) -> Vec<ShellbagEntry> {
65 let mut out = Vec::new();
66 for &root_path in BAGMRU_PATHS {
67 let Ok(Some(root)) = hive.open_key(root_path) else {
68 continue;
69 };
70 walk(root, root_path, file, &mut out);
71 }
72 out
73}
74
75/// Iterative depth-first walk of one `BagMRU` tree. Each stack frame carries the
76/// node key, the shell items accumulated from the root to it, and its key path.
77/// The shell-item bytes for a child slot live in the **parent** key's value named
78/// by the child index, so a child's full path is the parent's items plus that
79/// slot's item. A visited-offset set guarantees termination if a crafted hive is
80/// cyclic (a valid REGF hive is a tree, so it never revisits).
81fn walk(root: Key<'_>, root_path: &str, file: &str, out: &mut Vec<ShellbagEntry>) {
82 let mut visited: HashSet<u32> = HashSet::new();
83 let mut stack: Vec<(Key<'_>, Vec<ShellItem>, String)> =
84 vec![(root, Vec::new(), root_path.to_string())];
85 while let Some((key, parent_items, key_path)) = stack.pop() {
86 if !visited.insert(key.offset().0) {
87 continue; // cov:unreachable: only a crafted cyclic hive revisits a cell; a valid tree hive never does
88 }
89 let Ok(subkeys) = key.subkeys() else {
90 continue; // cov:unreachable: subkeys() only errors on hive corruption
91 };
92 for sub in subkeys {
93 let name = sub.name();
94 // BagMRU child slots are numeric ("0", "1", …); skip any non-slot
95 // sibling (e.g. a stray key that is not an ITEMIDLIST index).
96 if name.parse::<u32>().is_err() {
97 continue;
98 }
99 let bytes = key
100 .value(&name)
101 .ok()
102 .flatten()
103 .and_then(|v| v.raw_data().ok())
104 .unwrap_or_default();
105 let mut items = parent_items.clone();
106 items.extend(parse_idlist(&bytes));
107 let sub_path = format!("{key_path}\\{name}");
108 if let Some(entry) = volume_entry(&items, &sub, &sub_path, file) {
109 out.push(entry);
110 }
111 stack.push((sub, items, sub_path));
112 }
113 }
114}
115
116/// Build a [`ShellbagEntry`] for a node whose reconstructed path references a
117/// drive-letter volume; `None` when no [`ShellItemKind::Volume`] item is present
118/// (a non-volume node such as `My Computer` or `Control Panel`).
119fn volume_entry(
120 items: &[ShellItem],
121 key: &Key<'_>,
122 key_path: &str,
123 file: &str,
124) -> Option<ShellbagEntry> {
125 let volume = items.iter().find(|i| i.kind == ShellItemKind::Volume)?;
126 Some(ShellbagEntry {
127 path: reconstruct_path(items),
128 drive_letter: drive_letter(volume),
129 last_write: last_written_epoch(key),
130 source: Provenance {
131 file: file.to_string(),
132 line: 0,
133 key_path: Some(key_path.to_string()),
134 },
135 })
136}
137
138/// A key's last-written time as epoch seconds UTC; `None` when the hive recorded
139/// none.
140fn last_written_epoch(key: &Key<'_>) -> Option<i64> {
141 Some(key.last_written()?.as_second())
142}
143
144/// The upper-cased drive letter of a volume shell item whose name is a
145/// `X:`-prefixed drive string (`E:\` → `E`); `None` for a nameless (`0x2e` GUID)
146/// volume or any non-`letter:` name.
147fn drive_letter(volume: &ShellItem) -> Option<char> {
148 let mut chars = volume.name.as_deref()?.chars();
149 let letter = chars.next()?;
150 (letter.is_ascii_alphabetic() && chars.next() == Some(':')).then(|| letter.to_ascii_uppercase())
151}
152
153#[cfg(test)]
154#[allow(clippy::unwrap_used, clippy::expect_used)]
155mod tests {
156 use super::*;
157 use std::io::Cursor;
158 use winreg_core::hive::Hive;
159
160 /// A synthetic `NTUSER` hive whose `BagMRU` tree is
161 /// `Desktop → My Computer → E:\ → photos`, with each slot value holding a
162 /// genuine libfwsi shell item (root / volume-0x2f / file-entry-0x31), plus a
163 /// non-numeric `Foo` sibling the walker must skip. All key last-writes are
164 /// epoch 1_600_000_000. See `tests/data/README.md` for the generator recipe.
165 fn hive() -> Hive<Cursor<Vec<u8>>> {
166 const BYTES: &[u8] = include_bytes!("../../tests/data/synthetic_bagmru.hive");
167 Hive::from_bytes(BYTES.to_vec()).expect("valid REGF")
168 }
169
170 #[test]
171 fn surfaces_the_volume_and_the_folder_browsed_on_it() {
172 let entries = parse_shellbags(&hive(), "NTUSER.DAT");
173 // Two drive-letter nodes: E:\ itself and E:\photos. The My-Computer node
174 // (no volume in its path) is not surfaced.
175 assert_eq!(entries.len(), 2);
176 assert!(entries.iter().all(|e| e.drive_letter == Some('E')));
177
178 let folder = entries
179 .iter()
180 .find(|e| e.path.contains("photos"))
181 .expect("the browsed E:\\photos folder is surfaced");
182 assert!(folder.path.contains("E:"));
183 assert_eq!(folder.last_write, Some(1_600_000_000));
184 assert_eq!(folder.source.file, "NTUSER.DAT");
185 assert!(folder
186 .source
187 .key_path
188 .as_deref()
189 .is_some_and(|k| k.contains("BagMRU")));
190 }
191
192 #[test]
193 fn a_hive_without_bagmru_yields_nothing() {
194 const SYS: &[u8] = include_bytes!("../../tests/data/synthetic_usb_system.hive");
195 let hive = Hive::from_bytes(SYS.to_vec()).expect("valid REGF");
196 assert!(parse_shellbags(&hive, "NTUSER.DAT").is_empty());
197 }
198}