Skip to main content

rusthound_ce/modules/localgroup/
samr.rs

1//! SAMR alias branch: the two opnums the `dcerpc` crate does not ship.
2//!
3//!   SamrOpenAlias          opnum 27  (MS-SAMR 3.1.5.1.5)
4//!   SamrGetMembersInAlias  opnum 33  (MS-SAMR 3.1.5.5.5)
5//!
6//! Built on dcerpc's public surface (SmbPipe, SamrHandle, the encode_*/decode_*
7//! helpers and the NDR layer), so no fork is needed. Only encode_sid/decode_sid
8//! are private upstream and are reimplemented here.
9//!
10//! Read-only: the alias handle is opened with ALIAS_LIST_MEMBERS only.
11//!
12//! Ported from <https://github.com/g0h4n/LocalGroups-rs> for issue #69.
13
14use anyhow::{Result, anyhow, bail};
15use dcerpc::ndr::{NdrDecoder, NdrEncoder};
16use dcerpc::samr::{
17    SamrHandle, access, decode_enum_domains, decode_lookup_domain, encode_connect2,
18    encode_enum_domains, encode_lookup_domain, encode_open_domain, samr_syntax,
19};
20use dcerpc::transport::SmbPipe;
21use dcerpc::RpcError;
22use smb2_client::SmbClient;
23use windows_sddl::sid::Sid;
24
25// Opnums (MS-SAMR §3.1.4)
26
27pub mod opnum {
28    /// SamrOpenAlias(IN domain, IN access, IN rid) -> alias handle.
29    pub const OPEN_ALIAS: u16 = 27;
30    /// SamrGetMembersInAlias(IN alias) -> SAMPR_PSID_ARRAY_OUT.
31    pub const GET_MEMBERS_IN_ALIAS: u16 = 33;
32    /// SamrCloseHandle(IN/OUT handle).
33    pub const CLOSE_HANDLE: u16 = 1;
34}
35
36/// Alias-specific access masks (MS-SAMR §2.2.1.6). Read-only by design.
37pub mod alias_access {
38    /// ALIAS_LIST_MEMBERS, the only right SamrGetMembersInAlias needs.
39    pub const LIST_MEMBERS: u32 = 0x0000_0004;
40}
41
42/// The well-known BUILTIN domain SID, `S-1-5-32`.
43pub fn builtin_sid() -> Sid {
44    Sid { revision: 1, identifier_authority: 5, sub_authorities: vec![32] }
45}
46
47// RPC_SID marshaling (private upstream, reimplemented)
48
49/// Encode an RPC_SID. Same bytes as dcerpc's private encode_sid.
50/// Only the tests use it today; kept as the counterpart of decode_sid.
51#[allow(dead_code)]
52fn encode_sid(e: &mut NdrEncoder, sid: &Sid) {
53    e.u32(sid.sub_authorities.len() as u32); // max_count
54    e.u8(sid.revision);
55    e.u8(sid.sub_authorities.len() as u8);
56    let a = sid.identifier_authority;
57    e.bytes(&[
58        (a >> 40) as u8,
59        (a >> 32) as u8,
60        (a >> 24) as u8,
61        (a >> 16) as u8,
62        (a >> 8) as u8,
63        a as u8,
64    ]);
65    for s in &sid.sub_authorities {
66        e.u32(*s);
67    }
68}
69
70/// Decode one RPC_SID. max_count is read but never used to allocate: the real
71/// count is the 1-byte SubAuthorityCount, capped at 15.
72fn decode_sid(d: &mut NdrDecoder) -> Result<Sid> {
73    let _max = d.u32()?;
74    let revision = d.u8()?;
75    let count = d.u8()? as usize;
76    if count > 15 {
77        bail!("RPC_SID SubAuthorityCount={count} exceeds the 15 allowed by MS-DTYP");
78    }
79    let auth = d.read_bytes(6)?;
80    let identifier_authority = auth.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64);
81    let mut sub_authorities = Vec::with_capacity(count);
82    for _ in 0..count {
83        sub_authorities.push(d.u32()?);
84    }
85    Ok(Sid { revision, identifier_authority, sub_authorities })
86}
87
88// Response tail helper
89
90/// Read the 4-byte NTSTATUS every SAMR reply ends with, before parsing the
91/// body, so failures report their real status.
92fn tail_status(stub: &[u8], what: &str) -> Result<u32> {
93    if stub.len() < 4 {
94        bail!("{what}: reply too short ({} bytes, need at least the NTSTATUS)", stub.len());
95    }
96    let tail = &stub[stub.len() - 4..];
97    Ok(u32::from_le_bytes(tail.try_into().unwrap()))
98}
99
100/// Explain a DCE/RPC fault. Different code space from NTSTATUS: a fault means
101/// the call was refused before SAMR ran, so there is no stub to parse.
102pub fn explain_fault(status: u32) -> String {
103    match status {
104        0x0000_0005 => "nca_s_fault_access_denied: the RPC call was refused before SAMR ran.                         On Win10 1607 / Server 2016 and later the default descriptor grants                         remote SAM to local Administrators only, so a plain domain user is                         denied on a member host. Use an account that is local admin on the                         target, or collect this host's local group membership from GPO instead"
105            .to_string(),
106        0x0000_0001 => "nca_s_fault_other: the server rejected the request".to_string(),
107        _ => format!("RPC fault {status:#010x}, see MS-RPCE appendix for nca_s_* codes"),
108    }
109}
110
111/// Map the handful of NTSTATUS values worth explaining to the operator.
112pub fn explain_status(status: u32) -> &'static str {
113    match status {
114        0xC000_0022 => "STATUS_ACCESS_DENIED: the account lacks the right to read this alias \
115                        (local admin is normally required on a member host)",
116        0xC000_0034 => "STATUS_OBJECT_NAME_NOT_FOUND: this BUILTIN alias does not exist on \
117                        the target (normal for RID 580 on older/Core builds)",
118        0xC000_0060 => "STATUS_SPECIAL_ACCOUNT: the alias is protected on this system",
119        0xC000_0008 => "STATUS_INVALID_HANDLE: the domain handle was closed or never opened",
120        _           => "see MS-ERREF for this NTSTATUS",
121    }
122}
123
124// Encoders / decoders for the two missing opnums
125
126/// SamrOpenAlias. Wire layout: handle (20) + access (4) + rid (4).
127pub fn encode_open_alias(domain: &SamrHandle, desired_access: u32, rid: u32) -> Vec<u8> {
128    let mut e = NdrEncoder::new();
129    domain.encode(&mut e);
130    e.u32(desired_access);
131    e.u32(rid);
132    e.into_bytes()
133}
134
135/// SamrGetMembersInAlias. Wire layout: handle (20), the rest is [out].
136pub fn encode_get_members_in_alias(alias: &SamrHandle) -> Vec<u8> {
137    let mut e = NdrEncoder::new();
138    alias.encode(&mut e);
139    e.into_bytes()
140}
141
142/// Decode a `SAMPR_PSID_ARRAY_OUT` reply into the member SIDs.
143///
144/// ```text
145/// typedef struct _SAMPR_PSID_ARRAY_OUT {
146///   unsigned long Count;
147///   [size_is(Count)] PSAMPR_SID_INFORMATION Sids;   // SAMPR_SID_INFORMATION { PRPC_SID }
148/// } SAMPR_PSID_ARRAY_OUT;
149/// ```
150///
151/// Wire order: Count, array pointer, max_count, one pointer per entry, then
152/// the SIDs, then the NTSTATUS.
153///
154/// Count is bounded against the remaining stub before allocating: a SID is at
155/// least 12 bytes, so it can never exceed remaining / 12.
156pub fn decode_get_members_in_alias(stub: &[u8]) -> Result<Vec<Sid>> {
157    let status = tail_status(stub, "SamrGetMembersInAlias")?;
158    if status != 0 {
159        bail!(
160            "SamrGetMembersInAlias failed (NTSTATUS 0x{status:08x}), {}",
161            explain_status(status)
162        );
163    }
164
165    let mut d = NdrDecoder::new(stub);
166    let count = d.u32()? as usize;
167    let array_ref = d.u32()?;
168
169    if count == 0 || array_ref == 0 {
170        // An empty alias is a perfectly valid, meaningful result.
171        return Ok(Vec::new());
172    }
173
174    // Bound Count against what is actually left in the stub.
175    let min_sid_bytes = 12usize; // max_count(4) + rev(1) + subcount(1) + authority(6)
176    let budget = d.remaining() / min_sid_bytes;
177    if count > budget {
178        bail!(
179            "SamrGetMembersInAlias: Count={count} exceeds remaining stub \
180             ({} bytes, at most {budget} SIDs), truncated or hostile reply",
181            d.remaining()
182        );
183    }
184
185    let max_count = d.u32()? as usize;
186    if max_count < count {
187        bail!("SamrGetMembersInAlias: conformant max_count={max_count} < Count={count}");
188    }
189
190    // One referent id per SAMPR_SID_INFORMATION, all in a row.
191    let mut present = Vec::with_capacity(count);
192    for _ in 0..count {
193        present.push(d.u32()? != 0);
194    }
195
196    // Then the deferred RPC_SIDs, in the same order, skipping null pointers.
197    let mut sids = Vec::with_capacity(count);
198    for is_present in present {
199        if is_present {
200            sids.push(decode_sid(&mut d)?);
201        }
202    }
203    Ok(sids)
204}
205
206// Client
207
208/// SAMR bound over an open \samr pipe, with both the upstream calls and the
209/// alias ones.
210pub struct SamrAliasClient<'a> {
211    pipe: SmbPipe<'a>,
212}
213
214impl<'a> SamrAliasClient<'a> {
215    /// Bind SAMR. Like dcerpc's SamrClient::bind, but keeps the pipe reachable
216    /// so we can call arbitrary opnums.
217    pub async fn bind(client: &'a mut SmbClient, file_id: [u8; 16]) -> Result<Self> {
218        let mut pipe = SmbPipe::new(client, file_id);
219        pipe.bind(samr_syntax()).await.map_err(|e| anyhow!("SAMR bind: {e}"))?;
220        Ok(Self { pipe })
221    }
222
223    async fn call(&mut self, opnum: u16, stub: &[u8]) -> Result<Vec<u8>> {
224        self.pipe.call(opnum, stub).await.map_err(|e| match e {
225            // A fault carries its own code space; translate it rather than
226            // surfacing a bare hex value the operator has to look up.
227            RpcError::Fault(status) => anyhow!("opnum {opnum}: {}", explain_fault(status)),
228            other => anyhow!("opnum {opnum}: {other}"),
229        })
230    }
231
232    /// SamrConnect2 -> server handle.
233    pub async fn connect(&mut self, server: &str) -> Result<SamrHandle> {
234        let stub = encode_connect2(server, access::MAXIMUM_ALLOWED);
235        let resp = self.call(57, &stub).await?;
236        let status = tail_status(&resp, "SamrConnect2")?;
237        if status != 0 {
238            bail!("SamrConnect2 failed (NTSTATUS 0x{status:08x}), {}", explain_status(status));
239        }
240        let mut d = NdrDecoder::new(&resp);
241        SamrHandle::decode(&mut d).map_err(|e| anyhow!("SamrConnect2 handle: {e}"))
242    }
243
244    /// SamrOpenDomain on an arbitrary domain SID -> domain handle.
245    pub async fn open_domain(&mut self, server: &SamrHandle, sid: &Sid) -> Result<SamrHandle> {
246        let stub = encode_open_domain(server, access::MAXIMUM_ALLOWED, sid);
247        let resp = self.call(7, &stub).await?;
248        let status = tail_status(&resp, "SamrOpenDomain")?;
249        if status != 0 {
250            bail!("SamrOpenDomain failed (NTSTATUS 0x{status:08x}), {}", explain_status(status));
251        }
252        let mut d = NdrDecoder::new(&resp);
253        SamrHandle::decode(&mut d).map_err(|e| anyhow!("SamrOpenDomain handle: {e}"))
254    }
255
256    /// SamrOpenDomain on `S-1-5-32`, the BUILTIN domain that holds the aliases.
257    pub async fn open_builtin(&mut self, server: &SamrHandle) -> Result<SamrHandle> {
258        self.open_domain(server, &builtin_sid()).await
259    }
260
261    /// The host's own domain SID, used as the local-member filter prefix.
262    ///
263    /// SAMR exposes two domains: Builtin, plus one named after the machine
264    /// (member host) or the domain (DC). We look up the second. Mirrors
265    /// SharpHound's server.GetMachineSid(), and avoids needing LSA or LDAP.
266    ///
267    /// On a DC this is the domain SID, see [`is_domain_controller`].
268    pub async fn machine_sid(&mut self, server: &SamrHandle) -> Result<Option<(String, Sid)>> {
269        let mut resume = 0u32;
270        let mut names: Vec<String> = Vec::new();
271        loop {
272            let stub = encode_enum_domains(server, resume, 0x1000);
273            let resp = self.call(6, &stub).await?;
274            let status = tail_status(&resp, "SamrEnumerateDomainsInSamServer")?;
275            // STATUS_MORE_ENTRIES (0x105) is a success code here.
276            if status != 0 && status != 0x0000_0105 {
277                bail!(
278                    "SamrEnumerateDomainsInSamServer failed (NTSTATUS 0x{status:08x}), {}",
279                    explain_status(status)
280                );
281            }
282            let (next, batch) = decode_enum_domains(&resp)
283                .map_err(|e| anyhow!("SamrEnumerateDomainsInSamServer decode: {e}"))?;
284            names.extend(batch.into_iter().map(|(_rid, n)| n));
285            if status != 0x0000_0105 || next == resume {
286                break;
287            }
288            resume = next;
289        }
290
291        let Some(local) = names.iter().find(|n| !n.eq_ignore_ascii_case("Builtin")) else {
292            return Ok(None);
293        };
294
295        let resp = self.call(5, &encode_lookup_domain(server, local)).await?;
296        let sid = decode_lookup_domain(&resp)
297            .map_err(|e| anyhow!("SamrLookupDomainInSamServer({local}): {e}"))?;
298        Ok(Some((local.clone(), sid)))
299    }
300
301    /// SamrOpenAlias(rid) with `ALIAS_LIST_MEMBERS` only -> alias handle.
302    pub async fn open_alias(&mut self, domain: &SamrHandle, rid: u32) -> Result<SamrHandle> {
303        let stub = encode_open_alias(domain, alias_access::LIST_MEMBERS, rid);
304        let resp = self.call(opnum::OPEN_ALIAS, &stub).await?;
305        let status = tail_status(&resp, "SamrOpenAlias")?;
306        if status != 0 {
307            bail!(
308                "SamrOpenAlias(RID {rid}) failed (NTSTATUS 0x{status:08x}), {}",
309                explain_status(status)
310            );
311        }
312        let mut d = NdrDecoder::new(&resp);
313        SamrHandle::decode(&mut d).map_err(|e| anyhow!("SamrOpenAlias handle: {e}"))
314    }
315
316    /// SamrGetMembersInAlias -> the member SIDs.
317    pub async fn get_members_in_alias(&mut self, alias: &SamrHandle) -> Result<Vec<Sid>> {
318        let stub = encode_get_members_in_alias(alias);
319        let resp = self.call(opnum::GET_MEMBERS_IN_ALIAS, &stub).await?;
320        decode_get_members_in_alias(&resp)
321    }
322
323    /// SamrCloseHandle, best effort: a leaked handle dies with the pipe.
324    pub async fn close_handle(&mut self, handle: &SamrHandle) -> Result<()> {
325        let mut e = NdrEncoder::new();
326        handle.encode(&mut e);
327        let resp = self.call(opnum::CLOSE_HANDLE, &e.into_bytes()).await?;
328        let status = tail_status(&resp, "SamrCloseHandle")?;
329        if status != 0 {
330            bail!("SamrCloseHandle failed (NTSTATUS 0x{status:08x})");
331        }
332        Ok(())
333    }
334}
335
336// SID helpers
337
338/// Render a SID in the canonical `S-R-IA-SA1-SA2-...` string form.
339pub fn sid_to_string(sid: &Sid) -> String {
340    let mut s = format!("S-{}-{}", sid.revision, sid.identifier_authority);
341    for sa in &sid.sub_authorities {
342        s.push('-');
343        s.push_str(&sa.to_string());
344    }
345    s
346}
347
348/// True when `member` belongs to `prefix`'s domain. Compares sub-authorities,
349/// not strings, so S-1-5-21-1-2-3 never matches S-1-5-21-1-2-30.
350pub fn is_under(member: &Sid, prefix: &Sid) -> bool {
351    member.identifier_authority == prefix.identifier_authority
352        && member.sub_authorities.len() > prefix.sub_authorities.len()
353        && member.sub_authorities[..prefix.sub_authorities.len()] == prefix.sub_authorities[..]
354}
355
356/// True when the target looks like a DC, judged from the name SAMR gave its
357/// non-BUILTIN domain: the domain's NetBIOS name on a DC, the machine's own
358/// name otherwise. Compared against `-d`, reduced to its first label.
359///
360/// It matters because on a DC that domain's SID is the domain SID, and
361/// filtering members under it would empty out every group.
362///
363/// Heuristic: it breaks when the NetBIOS name differs from the first DNS
364/// label. Asking SAMR whether krbtgt exists would be exact.
365pub fn is_domain_controller(local_domain_name: &str, cli_domain: &str) -> bool {
366    let netbios = cli_domain.split('.').next().unwrap_or(cli_domain);
367    local_domain_name.eq_ignore_ascii_case(netbios)
368        || local_domain_name.eq_ignore_ascii_case(cli_domain)
369}
370
371/// Guess an ObjectType from the SID alone. Well-known SIDs are labelled;
372/// domain SIDs come back as "Base" because telling User from Group from
373/// Computer needs LDAP. RustHound-CE resolves it at merge time.
374pub fn guess_object_type(sid: &Sid) -> &'static str {
375    let s = sid_to_string(sid);
376    match s.as_str() {
377        // Well-known groups that appear constantly in local admin lists.
378        "S-1-5-32-544" | "S-1-5-32-545" | "S-1-5-32-555" | "S-1-5-32-562"
379        | "S-1-5-32-580" | "S-1-5-11" | "S-1-5-4" | "S-1-1-0" => "Group",
380        // Local SYSTEM / LOCAL SERVICE / NETWORK SERVICE.
381        "S-1-5-18" | "S-1-5-19" | "S-1-5-20" => "User",
382        _ => "Base",
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn sid(s: &str) -> Sid {
391        let mut parts = s.split('-').skip(1);
392        let revision: u8 = parts.next().unwrap().parse().unwrap();
393        let identifier_authority: u64 = parts.next().unwrap().parse().unwrap();
394        let sub_authorities = parts.map(|p| p.parse().unwrap()).collect();
395        Sid { revision, identifier_authority, sub_authorities }
396    }
397
398    #[test]
399    fn open_alias_layout() {
400        let stub = encode_open_alias(&SamrHandle([0; 20]), alias_access::LIST_MEMBERS, 544);
401        assert_eq!(stub.len(), 20 + 4 + 4);
402        assert_eq!(&stub[20..24], &alias_access::LIST_MEMBERS.to_le_bytes());
403        assert_eq!(&stub[24..28], &544u32.to_le_bytes());
404    }
405
406    #[test]
407    fn get_members_request_is_handle_only() {
408        assert_eq!(encode_get_members_in_alias(&SamrHandle([7; 20])).len(), 20);
409    }
410
411    /// Build a synthetic SAMPR_PSID_ARRAY_OUT and round-trip it, proves the
412    /// nested-pointer / conformant-array decode without a live DC.
413    fn encode_members_response(sids: &[Sid]) -> Vec<u8> {
414        let mut e = NdrEncoder::new();
415        e.u32(sids.len() as u32); // Count
416        e.referent(); // Sids array pointer
417        e.u32(sids.len() as u32); // conformant max_count
418        for _ in sids {
419            e.referent(); // SAMPR_SID_INFORMATION.SidPointer
420        }
421        for s in sids {
422            encode_sid(&mut e, s);
423        }
424        e.u32(0); // NTSTATUS
425        e.into_bytes()
426    }
427
428    #[test]
429    fn members_decode_roundtrip() {
430        let members = vec![
431            sid("S-1-5-21-1111111111-2222222222-3333333333-512"),
432            sid("S-1-5-21-1111111111-2222222222-3333333333-1103"),
433            sid("S-1-5-21-4000000000-3900000000-3800000000-500"), // local account
434        ];
435        let stub = encode_members_response(&members);
436        let out = decode_get_members_in_alias(&stub).unwrap();
437        let rendered: Vec<String> = out.iter().map(sid_to_string).collect();
438        assert_eq!(rendered[0], "S-1-5-21-1111111111-2222222222-3333333333-512");
439        assert_eq!(rendered.len(), 3);
440    }
441
442    #[test]
443    fn empty_alias_is_not_an_error() {
444        let stub = encode_members_response(&[]);
445        assert!(decode_get_members_in_alias(&stub).unwrap().is_empty());
446    }
447
448    #[test]
449    fn access_denied_is_reported_as_such() {
450        let mut e = NdrEncoder::new();
451        e.u32(0);
452        e.u32(0);
453        e.u32(0xC000_0022); // STATUS_ACCESS_DENIED
454        let err = decode_get_members_in_alias(&e.into_bytes()).unwrap_err();
455        assert!(err.to_string().contains("ACCESS_DENIED"), "got {err}");
456    }
457
458    /// Regression guard: hostile server claims Count = u32::MAX with a
459    /// truncated tail. Pre-bound this drove Vec::with_capacity(u32::MAX).
460    #[test]
461    fn count_is_bounded_against_stub() {
462        let mut e = NdrEncoder::new();
463        e.u32(u32::MAX); // Count
464        e.referent(); // non-null array pointer
465        e.u32(0); // NTSTATUS
466        let err = decode_get_members_in_alias(&e.into_bytes()).unwrap_err();
467        assert!(err.to_string().contains("exceeds remaining stub"), "got {err}");
468    }
469
470    #[test]
471    fn local_filter_matches_on_subauthorities_not_prefix_string() {
472        let machine = sid("S-1-5-21-1-2-3");
473        assert!(is_under(&sid("S-1-5-21-1-2-3-500"), &machine));
474        // The classic string-prefix bug: -30 must NOT match -3.
475        assert!(!is_under(&sid("S-1-5-21-1-2-30-500"), &machine));
476        // A domain principal is not under the machine SID.
477        assert!(!is_under(&sid("S-1-5-21-7-8-9-1103"), &machine));
478    }
479
480    #[test]
481    fn builtin_sid_is_s_1_5_32() {
482        assert_eq!(sid_to_string(&builtin_sid()), "S-1-5-32");
483    }
484
485    /// The group ObjectIdentifier form differs between a member host and a DC.
486    /// Confirmed against a live DC (ESSOS.LOCAL lab): emitting
487    /// "<domain SID>-544" there fabricates a SID that exists nowhere in AD.
488    #[test]
489    fn dc_builtin_group_id_is_the_domain_scoped_well_known_principal() {
490        let rid = 544u32;
491        let machine = "S-1-5-21-1111111111-2222222222-3333333333";
492        // Member host: <machine SID>-<rid>
493        assert_eq!(format!("{machine}-{rid}"),
494                   "S-1-5-21-1111111111-2222222222-3333333333-544");
495        // DC: <DOMAIN>-S-1-5-32-<rid>, uppercased
496        assert_eq!(format!("{}-S-1-5-32-{rid}", "essos.local".to_uppercase()),
497                   "ESSOS.LOCAL-S-1-5-32-544");
498    }
499
500    #[test]
501    fn dc_is_detected_so_the_local_filter_is_disabled() {
502        // On a DC, SAMR names its non-BUILTIN domain after the domain itself.
503        assert!(is_domain_controller("CORP", "CORP"));
504        assert!(is_domain_controller("CORP", "corp.local"));
505        // Live-confirmed case: SAMR reports "ESSOS" for domain ESSOS.LOCAL.
506        assert!(is_domain_controller("ESSOS", "ESSOS.LOCAL"));
507        // On a member host it is named after the machine.
508        assert!(!is_domain_controller("FS01", "CORP"));
509        assert!(!is_domain_controller("WKS-042", "corp.local"));
510    }
511}