Skip to main content

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}