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