Skip to main content

rusthound_ce/modules/gpo/
sysvol.rs

1//! SYSVOL collection for Group Policy.
2//!
3//! Connects to the DC SYSVOL share, walks the Policies directory and reads the
4//! per GPO template files, then feeds the existing parsers:
5//!   * GptTmpl.inf  -> Privilege Rights (#47) and Restricted Groups (#56)
6//!   * Groups.xml   -> GPP local group membership (#56)
7//!
8//! This layer only retrieves and parses directives. Mapping them to graph edges
9//! (GPO GUID -> links -> affected computers, name -> SID) belongs to a later
10//! layer, matching the module's existing split.
11
12use 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/// Directives collected from one GPO's SYSVOL files.
23#[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
45/// Canonical SYSVOL file paths for one GPO, relative to the share root.
46struct 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
61/// A Policies entry is a GPO when it is a "{GUID}" folder.
62fn is_gpo_guid(name: &str) -> bool {
63    name.len() >= 3 && name.starts_with('{') && name.ends_with('}')
64}
65
66/// Connect to `dc_host` SYSVOL and collect GPO directives.
67///
68/// `domain_fqdn` is the SYSVOL sub-root (e.g. "DOMAIN.LOCAL"), `domain`/`user`
69/// and `auth` are the SMB credentials.
70pub 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        // GptTmpl.inf: Privilege Rights (#47) and Restricted Groups (#56).
99        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) => {} // absent, normal
108            Err(_) => {}   // real error already logged by try_read_file
109        }
110
111        // GPP Groups.xml: local group membership (#56), machine and user scope.
112        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/// GPO folders whose computer configuration is applicable according to LDAP.
339#[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}