winreg_artifacts/sam.rs
1//! Windows SAM hive artifact extractor.
2//!
3//! Extracts local user account data from a SAM registry hive.
4//!
5//! Key paths (SAM hive):
6//! - `SAM\Domains\Account\Users\Names\<username>` — username subkeys
7//! - `SAM\Domains\Account\Users\<RID_hex>\F` — account flags / timestamps
8//! - `SAM\Domains\Account\Users\<RID_hex>\V` — binary user data (not decoded here)
9
10use std::io::Cursor;
11
12use winreg_core::hive::Hive;
13use winreg_core::key::filetime_to_datetime;
14
15// ── Output type ───────────────────────────────────────────────────────────────
16
17/// Information about a local user account from the SAM hive.
18#[derive(Debug, Clone, serde::Serialize)]
19pub struct SamUserEntry {
20 /// Account username.
21 pub username: String,
22 /// Relative Identifier (RID), e.g. 500 for Administrator.
23 pub rid: u32,
24 /// Last login timestamp (ISO 8601), from F record bytes 8-15 (FILETIME).
25 pub last_login: Option<String>,
26 /// Password last set timestamp (ISO 8601), from F record bytes 16-23.
27 pub password_last_set: Option<String>,
28 /// Account expiry timestamp (ISO 8601), from F record bytes 24-31. `None` = never.
29 pub account_expires: Option<String>,
30 /// Login count, from F record bytes 66-67 (u16 LE).
31 pub login_count: u16,
32 /// Account control flags, from F record bytes 56-59 (u32 LE).
33 pub account_flags: u32,
34 /// Whether the account is disabled (`account_flags & 0x0001`).
35 pub is_disabled: bool,
36 /// Whether the account is locked (`account_flags & 0x0010`).
37 pub is_locked: bool,
38}
39
40// ── F record field offsets ────────────────────────────────────────────────────
41
42const F_LAST_LOGIN_OFF: usize = 8;
43const F_PASSWORD_LAST_SET_OFF: usize = 16;
44const F_ACCOUNT_EXPIRES_OFF: usize = 24;
45const F_ACCOUNT_FLAGS_OFF: usize = 56;
46const F_LOGIN_COUNT_OFF: usize = 66;
47// (`read_u32`/`read_u16`/`read_filetime` bounds-check each access, so no separate
48// minimum-length guard is needed.)
49
50const ACCOUNT_DISABLED: u32 = 0x0001;
51const ACCOUNT_LOCKED: u32 = 0x0010;
52
53// ── Public API ────────────────────────────────────────────────────────────────
54
55/// Parse local user accounts from a SAM hive.
56///
57/// Walks `SAM\Domains\Account\Users\Names` for usernames. For each username
58/// finds the corresponding `Users\<RID_hex>` key and reads its `F` value to
59/// extract timestamps and account flags.
60pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<SamUserEntry> {
61 let mut results = Vec::new();
62
63 let Ok(Some(names_key)) = hive.open_key("SAM\\Domains\\Account\\Users\\Names") else {
64 return results;
65 };
66
67 let Ok(Some(users_key)) = hive.open_key("SAM\\Domains\\Account\\Users") else {
68 return results;
69 };
70
71 let Ok(username_keys) = names_key.subkeys() else {
72 return results;
73 };
74
75 for name_key in username_keys {
76 let username = name_key.name();
77 if username.is_empty() {
78 continue;
79 }
80
81 // The RID is the per-account identity, stored as the TYPE field of the
82 // `Names\<username>` default value (the canonical SAM layout); the F
83 // record lives under `Users\<RID hex>`.
84 let rid_opt = rid_and_f(&name_key, &users_key);
85 let Some((rid, f_data)) = rid_opt else {
86 // No matching RID found — include with defaults
87 results.push(SamUserEntry {
88 username,
89 rid: 0,
90 last_login: None,
91 password_last_set: None,
92 account_expires: None,
93 login_count: 0,
94 account_flags: 0,
95 is_disabled: false,
96 is_locked: false,
97 });
98 continue;
99 };
100
101 let entry = parse_f_record(&username, rid, &f_data);
102 results.push(entry);
103 }
104
105 results
106}
107
108// ── Helpers ───────────────────────────────────────────────────────────────────
109
110/// Find the RID and F record bytes for a username by scanning Users\ subkeys.
111///
112/// The subkey names under `SAM\Domains\Account\Users` are 8-digit uppercase hex
113/// RID strings (e.g. `"000001F4"` for RID 500). We match by name: if the subkey
114/// name is a valid hex RID (not "Names"), read its `F` value.
115fn rid_and_f(
116 name_key: &winreg_core::key::Key<'_>,
117 users_key: &winreg_core::key::Key<'_>,
118) -> Option<(u32, Vec<u8>)> {
119 use winreg_format::flags::ValueType;
120
121 // The account's RID is the TYPE field of the `Names\<username>` default
122 // (unnamed) value — the canonical SAM layout (e.g. Administrator = 500,
123 // Guest = 501). Real RIDs (>= 500) always decode to `Unknown(rid)`.
124 let rid = match name_key.value("").ok().flatten()?.data_type() {
125 ValueType::Unknown(rid) => rid,
126 _ => return None,
127 };
128
129 // The F record lives under `Users\<RID as 8-digit uppercase hex>`.
130 let rid_hex = format!("{rid:08X}");
131 let f_data = users_key
132 .subkey(&rid_hex)
133 .ok()
134 .flatten()?
135 .value("F")
136 .ok()
137 .flatten()?
138 .raw_data()
139 .ok()?;
140 Some((rid, f_data))
141}
142
143/// Build a `SamUserEntry` by decoding the F record binary data.
144fn parse_f_record(username: &str, rid: u32, f: &[u8]) -> SamUserEntry {
145 let last_login = read_filetime(f, F_LAST_LOGIN_OFF);
146 let password_last_set = read_filetime(f, F_PASSWORD_LAST_SET_OFF);
147 let account_expires = read_filetime(f, F_ACCOUNT_EXPIRES_OFF);
148 let account_flags = read_u32(f, F_ACCOUNT_FLAGS_OFF);
149 let login_count = read_u16(f, F_LOGIN_COUNT_OFF);
150
151 SamUserEntry {
152 username: username.to_string(),
153 rid,
154 last_login,
155 password_last_set,
156 account_expires,
157 login_count,
158 account_flags,
159 is_disabled: (account_flags & ACCOUNT_DISABLED) != 0,
160 is_locked: (account_flags & ACCOUNT_LOCKED) != 0,
161 }
162}
163
164/// Read a FILETIME (u64 LE) at `offset` from `data` and convert to ISO 8601.
165/// Returns `None` if the data is too short or the FILETIME is zero.
166fn read_filetime(data: &[u8], offset: usize) -> Option<String> {
167 if offset + 8 > data.len() {
168 return None;
169 }
170 let ft = u64::from_le_bytes(data[offset..offset + 8].try_into().ok()?);
171 let dt = filetime_to_datetime(ft)?;
172 Some(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
173}
174
175/// Read a u32 LE at `offset` from `data`. Returns 0 if out of bounds.
176fn read_u32(data: &[u8], offset: usize) -> u32 {
177 if offset + 4 > data.len() {
178 return 0;
179 }
180 u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap_or([0; 4]))
181}
182
183/// Read a u16 LE at `offset` from `data`. Returns 0 if out of bounds.
184fn read_u16(data: &[u8], offset: usize) -> u16 {
185 if offset + 2 > data.len() {
186 return 0;
187 }
188 u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap_or([0; 2]))
189}