rusthound_ce/modules/gpo/
sysvol.rs1use std::collections::HashSet;
13
14use log::{debug, info, warn};
15
16use crate::objects::gpo::Gpo;
17use crate::transport::smb::{connect_sysvol, list_dir, try_read_file, SmbAuth};
18use crate::args::Options;
19
20use super::types::{GppLocalGroup, PrivilegeAssignment, RestrictedGroupDirective};
21use super::{parse_gpttmpl_bytes, parse_groups_xml};
22
23#[derive(Debug, Default)]
25pub struct SysvolGpo {
26 pub guid: String,
27 pub privileges: Vec<PrivilegeAssignment>,
28 pub restricted_groups: Vec<RestrictedGroupDirective>,
29 pub gpp_local_groups: Vec<GppLocalGroup>,
30}
31
32impl SysvolGpo {
33 fn new(guid: String) -> Self {
34 SysvolGpo {
35 guid,
36 ..Default::default()
37 }
38 }
39 fn has_directives(&self) -> bool {
40 !self.privileges.is_empty()
41 || !self.restricted_groups.is_empty()
42 || !self.gpp_local_groups.is_empty()
43 }
44}
45
46struct GpoFiles {
48 gpttmpl: String,
49 groups_machine: String,
50 groups_user: String,
51}
52
53fn gpo_file_paths(policies_root: &str, guid: &str) -> GpoFiles {
54 let base = format!(r"{policies_root}\{guid}");
55 GpoFiles {
56 gpttmpl: format!(r"{base}\Machine\Microsoft\Windows NT\SecEdit\GptTmpl.inf"),
57 groups_machine: format!(r"{base}\Machine\Preferences\Groups\Groups.xml"),
58 groups_user: format!(r"{base}\User\Preferences\Groups\Groups.xml"),
59 }
60}
61
62fn is_gpo_guid(name: &str) -> bool {
64 name.len() >= 3 && name.starts_with('{') && name.ends_with('}')
65}
66
67pub async fn collect_sysvol_targets(common_args: &Options, scope: &ComputerGpoScope) -> anyhow::Result<Vec<SysvolGpo>> {
69 use crate::transport::smb::{nt_hash_from_str, SmbAuth};
70
71 let user = common_args.username.clone().unwrap_or_default();
72 let password = common_args.password.clone().unwrap_or_default();
73 let nt = common_args.hashes.as_deref().and_then(nt_hash_from_str);
74
75 let dc_host: String = common_args
77 .ldapfqdn
78 .clone()
79 .filter(|s| !s.is_empty())
80 .or_else(|| common_args.ip.clone())
81 .unwrap_or_else(|| common_args.domain.clone());
82
83 if dc_host.is_empty() {
84 log::warn!("[gpo] no SMB target (ldapfqdn/ip/domain), skipping SYSVOL collection");
85 return Ok(Vec::new());
86 }
87
88 if common_args.kerberos {
90 let ccache = std::env::var("KRB5CCNAME")
91 .map_err(|_| anyhow::anyhow!("--kerberos set but KRB5CCNAME is not defined"))?;
92 let spn = format!("cifs/{dc_host}");
93 let (gss_blob, session_key) =
94 crate::transport::kerberos::kerberos_material_for(&ccache, &spn, &dc_host).await?;
95 let auth = SmbAuth::Kerberos { gss_blob: &gss_blob, session_key: &session_key };
96 return collect(&dc_host, &common_args.domain, &common_args.domain, &user, auth, scope).await;
97 }
98
99 let auth = match &nt {
101 Some(h) => SmbAuth::Hash(h),
102 None => SmbAuth::Password(&password),
103 };
104 collect(&dc_host, &common_args.domain, &common_args.domain, &user, auth, scope).await
105}
106
107async fn collect(
112 dc_host: &str,
113 domain_fqdn: &str,
114 domain: &str,
115 user: &str,
116 auth: SmbAuth<'_>,
117 scope: &ComputerGpoScope,
118) -> anyhow::Result<Vec<SysvolGpo>> {
119 let mut smb = connect_sysvol(dc_host, domain, user, auth).await?;
120
121 let policies_root = format!(r"{domain_fqdn}\Policies");
122 let entries = list_dir(&mut smb, dc_host, &policies_root).await?;
123
124 let mut out = Vec::new();
125 for entry in entries {
126 if !entry.is_dir || !is_gpo_guid(&entry.name) {
127 continue;
128 }
129 if !scope.allows(&entry.name) {
130 debug!(
131 "[gpo] skipping computer-disabled GPO {} before SYSVOL reads",
132 entry.name
133 );
134 continue;
135 }
136 let files = gpo_file_paths(&policies_root, &entry.name);
137 let mut gpo = SysvolGpo::new(entry.name);
138
139 match try_read_file(&mut smb, dc_host, &files.gpttmpl).await {
141 Ok(Some(bytes)) => match parse_gpttmpl_bytes(&bytes) {
142 Ok(policy) => {
143 gpo.privileges = policy.privilege_rights().to_vec();
144 gpo.restricted_groups = policy.restricted_groups().to_vec();
145 }
146 Err(err) => warn!("[gpo] {} GptTmpl.inf parse: {err}", gpo.guid),
147 },
148 Ok(None) => {} Err(_) => {} }
151
152 for path in [&files.groups_machine, &files.groups_user] {
154 match try_read_file(&mut smb, dc_host, path).await {
155 Ok(Some(bytes)) => match parse_groups_xml(&bytes) {
156 Ok(mut groups) => gpo.gpp_local_groups.append(&mut groups),
157 Err(err) => warn!("[gpo] {} Groups.xml parse: {err}", gpo.guid),
158 },
159 Ok(None) => {}
160 Err(_) => {}
161 }
162 }
163
164 if gpo.has_directives() {
165 debug!(
166 "[gpo] {} -> {} privilege(s), {} restricted group(s), {} GPP group(s)",
167 gpo.guid,
168 gpo.privileges.len(),
169 gpo.restricted_groups.len(),
170 gpo.gpp_local_groups.len()
171 );
172 out.push(gpo);
173 }
174 }
175
176 info!(
177 "[gpo] collected directives from {} GPO(s) on {dc_host} SYSVOL",
178 out.len()
179 );
180 Ok(out)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::collections::HashMap;
187
188 use ldap3::SearchEntry;
189
190 use crate::modules::gpo::local_group::{
191 compute_merged, resolve_privileges, Resolver, TypedPrincipal,
192 };
193 use crate::modules::gpo::{parse_gpttmpl, parse_groups_xml};
194 use crate::objects::gpo::Gpo;
195
196 struct FakeResolver;
197
198 impl Resolver for FakeResolver {
199 fn resolve_name(&self, _name: &str) -> Option<(String, String)> {
200 None
201 }
202
203 fn type_of_sid(&self, _sid: &str) -> Option<String> {
204 Some("User".to_string())
205 }
206 }
207
208 fn parsed_gpo(guid: &str, flags: &str) -> Gpo {
209 let result = SearchEntry {
210 dn: format!("CN={guid},CN=Policies,CN=System,DC=example,DC=local"),
211 attrs: HashMap::from([
212 ("displayName".to_string(), vec![format!("GPO {flags}")]),
213 (
214 "gPCFileSysPath".to_string(),
215 vec![format!(
216 r"\\example.local\SYSVOL\example.local\Policies\{guid}"
217 )],
218 ),
219 ("flags".to_string(), vec![flags.to_string()]),
220 ]),
221 bin_attrs: HashMap::new(),
222 };
223 let mut gpo = Gpo::new();
224 gpo.parse(
225 result,
226 "example.local",
227 &mut HashMap::new(),
228 &mut HashMap::new(),
229 "S-1-5-21-111111111-222222222-333333333",
230 &HashMap::new(),
231 )
232 .unwrap();
233 gpo
234 }
235
236 fn sysvol_policy(guid: &str, principal_rid: u32) -> SysvolGpo {
237 let principal = format!("S-1-5-21-111111111-222222222-333333333-{principal_rid}");
238 let inf = format!(
239 "[Privilege Rights]\nSeRemoteInteractiveLogonRight = *{principal}\n\
240 [Group Membership]\n*S-1-5-32-544__Members = *{principal}\n"
241 );
242 let parsed_inf = parse_gpttmpl(&inf).unwrap();
243 let xml = format!(
244 r#"<Groups>
245<Group><Properties action="U" groupSid="S-1-5-32-555"><Members><Member sid="{principal}" action="ADD"/></Members></Properties></Group>
246<Group><Properties action="U" groupSid="S-1-5-32-562"><Members><Member sid="{principal}" action="ADD"/></Members></Properties></Group>
247<Group><Properties action="U" groupSid="S-1-5-32-580"><Members><Member sid="{principal}" action="ADD"/></Members></Properties></Group>
248</Groups>"#
249 );
250
251 SysvolGpo {
252 guid: guid.to_string(),
253 privileges: parsed_inf.privilege_rights().to_vec(),
254 restricted_groups: parsed_inf.restricted_groups().to_vec(),
255 gpp_local_groups: parse_groups_xml(xml.as_bytes()).unwrap(),
256 }
257 }
258
259 fn assert_no_sid_suffix(principals: &[TypedPrincipal], suffix: &str) {
260 assert!(
261 principals
262 .iter()
263 .all(|principal| !principal.sid.ends_with(suffix)),
264 "found computer-disabled principal ending in {suffix}: {principals:?}",
265 );
266 }
267
268 #[test]
269 fn gpo_file_paths_are_canonical() {
270 let f = gpo_file_paths(
271 r"DOMAIN.LOCAL\Policies",
272 "{31B2F340-016D-11D2-945F-00C04FB984F9}",
273 );
274 assert_eq!(
275 f.gpttmpl,
276 r"DOMAIN.LOCAL\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9}\Machine\Microsoft\Windows NT\SecEdit\GptTmpl.inf"
277 );
278 assert_eq!(
279 f.groups_machine,
280 r"DOMAIN.LOCAL\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9}\Machine\Preferences\Groups\Groups.xml"
281 );
282 assert!(f
283 .groups_user
284 .ends_with(r"\User\Preferences\Groups\Groups.xml"));
285 }
286
287 #[test]
288 fn is_gpo_guid_accepts_guid_folders_only() {
289 assert!(is_gpo_guid("{31B2F340-016D-11D2-945F-00C04FB984F9}"));
290 assert!(!is_gpo_guid("PolicyDefinitions"));
291 assert!(!is_gpo_guid("."));
292 assert!(!is_gpo_guid(".."));
293 assert!(!is_gpo_guid(""));
294 assert!(!is_gpo_guid("{"));
295 }
296
297 #[test]
298 fn has_directives_reflects_content() {
299 let mut g = SysvolGpo::new("{G}".into());
300 assert!(!g.has_directives());
301 g.privileges.push(PrivilegeAssignment::new(
302 "SeDebugPrivilege",
303 vec!["DOMAIN\\alice".into()],
304 ));
305 assert!(g.has_directives());
306 }
307
308 #[test]
309 fn computer_scope_allows_flags_zero_and_one_only() {
310 let guids = [
311 "{AbCdEfAb-CdEf-AbCd-EfAb-CdEfAbCdEfAb}",
312 "{11111111-1111-1111-1111-111111111111}",
313 "{22222222-2222-2222-2222-222222222222}",
314 "{33333333-3333-3333-3333-333333333333}",
315 ];
316 let gpos: Vec<Gpo> = guids
317 .iter()
318 .zip(["0", "1", "2", "3"])
319 .map(|(guid, flags)| parsed_gpo(guid, flags))
320 .collect();
321
322 let scope = ComputerGpoScope::from_gpos(&gpos);
323
324 assert!(scope.allows(guids[0]));
325 assert!(scope.allows(&guids[0].to_ascii_uppercase()));
326 assert!(scope.allows(&guids[0].to_ascii_lowercase()));
327 assert!(scope.allows(guids[1]));
328 assert!(!scope.allows(guids[2]));
329 assert!(!scope.allows(guids[3]));
330 }
331
332 #[test]
333 fn flags_two_and_three_leave_no_local_group_or_user_right_output() {
334 let guids = [
335 "{00000000-0000-0000-0000-000000000000}",
336 "{11111111-1111-1111-1111-111111111111}",
337 "{22222222-2222-2222-2222-222222222222}",
338 "{33333333-3333-3333-3333-333333333333}",
339 ];
340 let metadata: Vec<Gpo> = guids
341 .iter()
342 .zip(["0", "1", "2", "3"])
343 .map(|(guid, flags)| parsed_gpo(guid, flags))
344 .collect();
345 let sysvol: Vec<SysvolGpo> = guids
346 .iter()
347 .enumerate()
348 .map(|(flags, guid)| sysvol_policy(guid, 1000 + flags as u32))
349 .collect();
350 let scope = ComputerGpoScope::from_gpos(&metadata);
351 let applicable = scope.applicable(&sysvol);
352
353 assert_eq!(
354 applicable
355 .iter()
356 .map(|gpo| gpo.guid.as_str())
357 .collect::<Vec<_>>(),
358 vec![guids[0], guids[1]],
359 );
360
361 let merged = compute_merged(&applicable, &FakeResolver);
362 for principals in [
363 &merged.local_admins,
364 &merged.remote_desktop_users,
365 &merged.dcom_users,
366 &merged.psremote_users,
367 ] {
368 assert_no_sid_suffix(principals, "1002");
369 assert_no_sid_suffix(principals, "1003");
370 }
371
372 let privileges = resolve_privileges(&applicable, &FakeResolver);
373 assert_eq!(privileges.len(), 1);
374 assert_no_sid_suffix(&privileges[0].1, "1002");
375 assert_no_sid_suffix(&privileges[0].1, "1003");
376 }
377}
378
379#[derive(Debug, Default)]
381pub struct ComputerGpoScope {
382 guids: HashSet<String>,
383}
384
385impl ComputerGpoScope {
386 pub fn from_gpos(gpos: &[Gpo]) -> Self {
387 let guids = gpos
388 .iter()
389 .filter(|gpo| gpo.computer_configuration_enabled())
390 .filter_map(Gpo::sysvol_guid)
391 .collect();
392 Self { guids }
393 }
394
395 pub(crate) fn allows(&self, guid: &str) -> bool {
396 self.guids.contains(&guid.to_uppercase())
397 }
398
399 #[cfg(test)]
400 pub(crate) fn applicable<'a>(&self, gpos: &'a [SysvolGpo]) -> Vec<&'a SysvolGpo> {
401 gpos.iter().filter(|gpo| self.allows(&gpo.guid)).collect()
402 }
403}