rusthound_ce/modules/localgroup/
samr.rs1use 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
25pub mod opnum {
28 pub const OPEN_ALIAS: u16 = 27;
30 pub const GET_MEMBERS_IN_ALIAS: u16 = 33;
32 pub const CLOSE_HANDLE: u16 = 1;
34}
35
36pub mod alias_access {
38 pub const LIST_MEMBERS: u32 = 0x0000_0004;
40}
41
42pub fn builtin_sid() -> Sid {
44 Sid { revision: 1, identifier_authority: 5, sub_authorities: vec![32] }
45}
46
47#[allow(dead_code)]
52fn encode_sid(e: &mut NdrEncoder, sid: &Sid) {
53 e.u32(sid.sub_authorities.len() as u32); 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
70fn 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
88fn 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
100pub 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
111pub 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
124pub 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
135pub 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
142pub 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 return Ok(Vec::new());
172 }
173
174 let min_sid_bytes = 12usize; 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 let mut present = Vec::with_capacity(count);
192 for _ in 0..count {
193 present.push(d.u32()? != 0);
194 }
195
196 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
206pub struct SamrAliasClient<'a> {
211 pipe: SmbPipe<'a>,
212}
213
214impl<'a> SamrAliasClient<'a> {
215 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 RpcError::Fault(status) => anyhow!("opnum {opnum}: {}", explain_fault(status)),
228 other => anyhow!("opnum {opnum}: {other}"),
229 })
230 }
231
232 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 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 pub async fn open_builtin(&mut self, server: &SamrHandle) -> Result<SamrHandle> {
258 self.open_domain(server, &builtin_sid()).await
259 }
260
261 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 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 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 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 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
336pub 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
348pub 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
356pub 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
371pub fn guess_object_type(sid: &Sid) -> &'static str {
375 let s = sid_to_string(sid);
376 match s.as_str() {
377 "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 "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 fn encode_members_response(sids: &[Sid]) -> Vec<u8> {
414 let mut e = NdrEncoder::new();
415 e.u32(sids.len() as u32); e.referent(); e.u32(sids.len() as u32); for _ in sids {
419 e.referent(); }
421 for s in sids {
422 encode_sid(&mut e, s);
423 }
424 e.u32(0); 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"), ];
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); 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 #[test]
461 fn count_is_bounded_against_stub() {
462 let mut e = NdrEncoder::new();
463 e.u32(u32::MAX); e.referent(); e.u32(0); 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 assert!(!is_under(&sid("S-1-5-21-1-2-30-500"), &machine));
476 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 #[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 assert_eq!(format!("{machine}-{rid}"),
494 "S-1-5-21-1111111111-2222222222-3333333333-544");
495 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 assert!(is_domain_controller("CORP", "CORP"));
504 assert!(is_domain_controller("CORP", "corp.local"));
505 assert!(is_domain_controller("ESSOS", "ESSOS.LOCAL"));
507 assert!(!is_domain_controller("FS01", "CORP"));
509 assert!(!is_domain_controller("WKS-042", "corp.local"));
510 }
511}