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};
18use crate::args::Options;
19
20use super::types::{GppLocalGroup, PrivilegeAssignment, RestrictedGroupDirective};
21use super::{parse_gpttmpl_bytes, parse_groups_xml};
22
23/// Directives collected from one GPO's SYSVOL files.
24#[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
46/// Canonical SYSVOL file paths for one GPO, relative to the share root.
47struct 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
62/// A Policies entry is a GPO when it is a "{GUID}" folder.
63fn is_gpo_guid(name: &str) -> bool {
64    name.len() >= 3 && name.starts_with('{') && name.ends_with('}')
65}
66
67/// Build the SMB target and credentials, then collect GPO directives off SYSVOL.
68pub 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    // SMB target (the DC): ldapfqdn, else IP, else domain.
76    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    // Kerberos: build a cifs/<dc> ticket from KRB5CCNAME when --kerberos is set.
89    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    // Password / pass the hash.
100    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
107/// Connect to `dc_host` SYSVOL and collect GPO directives.
108///
109/// `domain_fqdn` is the SYSVOL sub-root (e.g. "DOMAIN.LOCAL"), `domain`/`user`
110/// and `auth` are the SMB credentials.
111async 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        // GptTmpl.inf: Privilege Rights (#47) and Restricted Groups (#56).
140        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) => {} // absent, normal
149            Err(_) => {}   // real error already logged by try_read_file
150        }
151
152        // GPP Groups.xml: local group membership (#56), machine and user scope.
153        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/// GPO folders whose computer configuration is applicable according to LDAP.
380#[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}