ms_lsad/lib.rs
1//! **MS-LSAD** — Local Security Authority (Domain Policy) Remote Protocol.
2//!
3//! Companion crate to [`ms-lsat`](https://crates.io/crates/ms-lsat) (LSA Translation).
4//! Both share the interface UUID `12345778-1234-abcd-ef00-0123456789ab` (v0.0) on
5//! `\PIPE\lsarpc`. LSAT covers SID↔name lookups; this crate covers **domain policy
6//! read/write** and **trusted-domain object management**.
7//!
8//! v0.1 opnums:
9//!
10//! | Opnum | Name | Purpose |
11//! |---|---|---|
12//! | 44 | `LsarOpenPolicy2` | (reused from `ms-lsat`) get policy handle |
13//! | 13 | `LsarEnumerateTrustedDomains` | enumerate configured trusts (v1 API) |
14//! | 0 | `LsarClose` | (reused) close policy handle |
15//!
16//! # Dual use
17//!
18//! - **Audit / DFIR** — enumerate every configured trust and its direction / attributes
19//! for defensive posture. Currently no pure-Rust way to do this over the wire without
20//! linking a full Windows SDK.
21//! - **Offensive** — enumerate trusts as a scouting step before cross-forest attack
22//! chains (the trust-key extraction path lands with `LsarQueryTrustedDomainInfoByName`
23//! / opnum 48 or via DRS bulk `GetNCChanges` — target for v0.2).
24//!
25//! # Composes with
26//!
27//! - [`dcerpc`](https://crates.io/crates/dcerpc) — sealed LSARPC transport.
28//! - [`ms-lsat`](https://crates.io/crates/ms-lsat) — shares the policy handle with
29//! SID/name translation calls.
30//! - [`ms-drsr`](https://crates.io/crates/ms-drsr) — alternative trust-key path via
31//! DRS replication.
32//!
33//! # Spec
34//!
35//! [MS-LSAD]: <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lsad/>
36
37#![deny(unsafe_code)]
38
39use dcerpc::ndr::{NdrDecoder, NdrEncoder};
40use dcerpc::transport::SmbPipe;
41use dcerpc::{Result, Syntax};
42use ms_lsat::{access, opnum as lsat_opnum, PolicyHandle};
43use smb2_client::SmbClient;
44use windows_sddl::sid::Sid;
45
46/// The shared LSA interface (v0.0) — same UUID as LSAT.
47pub fn lsad_syntax() -> Syntax {
48 Syntax::new("12345778-1234-abcd-ef00-0123456789ab", 0, 0)
49}
50
51/// MS-LSAD opnum table ([MS-LSAD] §3.1.4). LSA interface shares the numbering with LSAT
52/// but the two carve up different opnum ranges. Values here are LSAD-only.
53pub mod opnum {
54 /// `LsarClose` — reused from LSAT.
55 pub const CLOSE: u16 = 0;
56 /// `LsarEnumerateTrustedDomains` (v1) — [MS-LSAD] §3.1.4.7.7.
57 pub const ENUMERATE_TRUSTED_DOMAINS: u16 = 13;
58 /// `LsarOpenPolicy2` — reused from LSAT.
59 pub const OPEN_POLICY2: u16 = 44;
60 /// `LsarQueryTrustedDomainInfoByName` — [MS-LSAD] §3.1.4.7.4. Target for v0.2 (trust-key read).
61 pub const QUERY_TRUSTED_DOMAIN_INFO_BY_NAME: u16 = 48;
62 /// `LsarEnumerateTrustedDomainsEx` (v2) — [MS-LSAD] §3.1.4.7.8. Target for v0.2 (richer TDO info).
63 pub const ENUMERATE_TRUSTED_DOMAINS_EX: u16 = 50;
64}
65
66// ---- RPC_SID marshaling (mirrors ms-lsat's private helper) ----------------
67
68fn decode_sid(d: &mut NdrDecoder) -> Result<Sid> {
69 let _max = d.u32()?;
70 let revision = d.u8()?;
71 let count = d.u8()? as usize;
72 let auth = d.read_bytes(6)?;
73 let identifier_authority = auth.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64);
74 // Bounded-alloc preflight per the icedracon wire rule: each sub-authority is 4 bytes,
75 // so count × 4 must fit in the remaining stub.
76 if count
77 .checked_mul(4)
78 .map_or(true, |need| need > d.remaining())
79 {
80 return Err(dcerpc::RpcError::Protocol(format!(
81 "RPC_SID: SubAuthorityCount={count} exceeds remaining stub"
82 )));
83 }
84 let mut sub_authorities = Vec::with_capacity(count);
85 for _ in 0..count {
86 sub_authorities.push(d.u32()?);
87 }
88 Ok(Sid {
89 revision,
90 identifier_authority,
91 sub_authorities,
92 })
93}
94
95// ---- LsarEnumerateTrustedDomains ------------------------------------------
96
97/// One row of a `LsarEnumerateTrustedDomains` response.
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct TrustedDomainInfo {
101 /// NetBIOS name of the trusted domain (from `LSAPR_TRUST_INFORMATION.Name`).
102 pub name: String,
103 /// SID of the trusted domain.
104 pub sid: Sid,
105}
106
107/// `LsarEnumerateTrustedDomains(PolicyHandle, EnumerationContext, EnumerationBuffer, PreferedMaximumLength)`.
108///
109/// Request stub layout ([MS-LSAD] §3.1.4.7.7):
110///
111/// - `PolicyHandle`: 20 bytes (attrs + GUID)
112/// - `EnumerationContext`: u32 — 0 for first call; server returns updated value if more entries remain
113/// - `PreferedMaximumLength`: u32 — server hint, we pass a large default (e.g. 8192)
114///
115/// Total 28 bytes.
116pub fn encode_enumerate_trusted_domains(
117 handle: &PolicyHandle,
118 enumeration_context: u32,
119 prefered_maximum_length: u32,
120) -> Vec<u8> {
121 let mut e = NdrEncoder::new();
122 handle.encode(&mut e);
123 e.u32(enumeration_context);
124 e.u32(prefered_maximum_length);
125 e.into_bytes()
126}
127
128/// A batched enumeration response: the trust rows + the updated context.
129///
130/// Client code either calls once with a large `prefered_maximum_length` (rarely > a handful of
131/// trusts in real domains, ~256 entries max for practical purposes), or loops feeding
132/// `next_context` back until it comes back == 0 or the returned rows are empty.
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134#[non_exhaustive]
135pub struct EnumerateTrustsPage {
136 pub trusts: Vec<TrustedDomainInfo>,
137 /// If non-zero, the client should call again with this value to fetch the next page.
138 pub next_context: u32,
139}
140
141/// Decode a `LsarEnumerateTrustedDomains` response.
142///
143/// **Live-validated** against Windows Server 2025 DC (`testlab.local`, stand-alone —
144/// 0 configured trusts). The end-to-end path is:
145/// `SmbClient::connect → login → tree_connect(IPC$) → open_pipe(lsarpc) → bind(LSA UUID)
146/// → LsarOpenPolicy2 (opnum 44) → LsarEnumerateTrustedDomains (opnum 13)` — completes with
147/// no RPC fault, decoder returns the empty page cleanly. Real trust decoding is exercised
148/// by the [`enumerate_multiple_trusts_deferred_order`](tests::enumerate_multiple_trusts_deferred_order)
149/// synthetic test; a live positive-case requires adding a trust to the lab domain.
150///
151/// Wire shape (in order of appearance in the response stub):
152///
153/// 1. `EnumerationContext`: u32
154/// 2. `LSAPR_TRUSTED_ENUM_BUFFER`:
155/// - `EntriesRead`: u32
156/// - `Information`: pointer to a conformant array of `LSAPR_TRUST_INFORMATION`
157/// - hoisted `max_count`: u32
158/// - per-entry fixed part (12 bytes): `Name.Length` u16 + `Name.MaximumLength` u16 +
159/// `Name.Buffer` ptr u32 + `Sid` ptr u32
160/// - deferred: each `Name.Buffer` (conformant-varying WSTR) and each `RPC_SID`, in entry order
161/// 3. NTSTATUS trailer
162///
163/// Bounded-alloc: the `EntriesRead × 12` preflight before allocating the header vector
164/// matches the pattern locked in across the wire stack in dcerpc 0.2.6.
165pub fn decode_enumerate_trusted_domains(stub: &[u8]) -> Result<EnumerateTrustsPage> {
166 let mut d = NdrDecoder::new(stub);
167
168 let next_context = d.u32()?;
169 let entries = d.u32()? as usize;
170 let info_ptr = d.u32()?;
171
172 if info_ptr == 0 {
173 return Ok(EnumerateTrustsPage {
174 trusts: Vec::new(),
175 next_context,
176 });
177 }
178
179 let _max_count = d.u32()?;
180
181 // Bounded-alloc: each fixed-part header is 12 bytes.
182 if entries
183 .checked_mul(12)
184 .map_or(true, |need| need > d.remaining())
185 {
186 return Err(dcerpc::RpcError::Protocol(format!(
187 "LsarEnumerateTrustedDomains: EntriesRead={entries} exceeds remaining stub"
188 )));
189 }
190
191 let mut headers: Vec<(u16, u32, u32)> = Vec::with_capacity(entries);
192 for _ in 0..entries {
193 let name_len = d.u16()?;
194 let _name_max = d.u16()?;
195 let name_ptr = d.u32()?;
196 let sid_ptr = d.u32()?;
197 headers.push((name_len, name_ptr, sid_ptr));
198 }
199
200 let mut trusts = Vec::with_capacity(entries);
201 for (_len, name_ptr, sid_ptr) in headers {
202 let name = if name_ptr != 0 {
203 d.conformant_varying_wstr()?
204 } else {
205 String::new()
206 };
207 let sid = if sid_ptr != 0 {
208 decode_sid(&mut d)?
209 } else {
210 Sid {
211 revision: 1,
212 identifier_authority: 0,
213 sub_authorities: vec![],
214 }
215 };
216 trusts.push(TrustedDomainInfo { name, sid });
217 }
218
219 Ok(EnumerateTrustsPage {
220 trusts,
221 next_context,
222 })
223}
224
225// ---- High-level client ----------------------------------------------------
226
227/// Ergonomic LSAD client. Binds the LSA interface over an already-open `\lsarpc` pipe, opens
228/// the policy handle lazily on the first call.
229pub struct LsadClient<'a> {
230 pipe: SmbPipe<'a>,
231 handle: Option<PolicyHandle>,
232}
233
234impl<'a> LsadClient<'a> {
235 /// Bind the LSA interface over a fresh `\lsarpc` pipe.
236 pub async fn bind(client: &'a mut SmbClient, file_id: [u8; 16]) -> Result<Self> {
237 let mut pipe = SmbPipe::new(client, file_id);
238 pipe.bind(lsad_syntax()).await?;
239 Ok(LsadClient { pipe, handle: None })
240 }
241
242 async fn ensure_handle(&mut self, system_name: &str) -> Result<PolicyHandle> {
243 if let Some(h) = self.handle {
244 return Ok(h);
245 }
246 let stub = ms_lsat::encode_open_policy2(system_name, access::MAXIMUM_ALLOWED);
247 let resp = self.pipe.call(lsat_opnum::OPEN_POLICY2, &stub).await?;
248 let mut d = NdrDecoder::new(&resp);
249 let h = PolicyHandle::decode(&mut d)?;
250 self.handle = Some(h);
251 Ok(h)
252 }
253
254 /// Enumerate every trust in one page (loops the paging cursor internally until the server
255 /// returns `next_context == 0`). Real-world domains have < 256 trusts; a single page is
256 /// almost always sufficient.
257 pub async fn enumerate_trusts(&mut self, system_name: &str) -> Result<Vec<TrustedDomainInfo>> {
258 let h = self.ensure_handle(system_name).await?;
259 let mut out: Vec<TrustedDomainInfo> = Vec::new();
260 let mut ctx: u32 = 0;
261 loop {
262 let stub = encode_enumerate_trusted_domains(&h, ctx, 8192);
263 let resp = self
264 .pipe
265 .call(opnum::ENUMERATE_TRUSTED_DOMAINS, &stub)
266 .await?;
267 let page = decode_enumerate_trusted_domains(&resp)?;
268 let got = page.trusts.len();
269 out.extend(page.trusts);
270 if page.next_context == 0 || got == 0 {
271 break;
272 }
273 ctx = page.next_context;
274 }
275 Ok(out)
276 }
277}
278
279// ---- Tests ----------------------------------------------------------------
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use dcerpc::ndr::NdrEncoder;
285
286 fn encode_sid(e: &mut NdrEncoder, sid: &Sid) {
287 e.u32(sid.sub_authorities.len() as u32);
288 e.u8(sid.revision);
289 e.u8(sid.sub_authorities.len() as u8);
290 let a = sid.identifier_authority;
291 e.bytes(&[
292 (a >> 40) as u8,
293 (a >> 32) as u8,
294 (a >> 24) as u8,
295 (a >> 16) as u8,
296 (a >> 8) as u8,
297 a as u8,
298 ]);
299 for s in &sid.sub_authorities {
300 e.u32(*s);
301 }
302 }
303
304 #[test]
305 fn enumerate_request_stub_is_fixed_28_bytes() {
306 let stub = encode_enumerate_trusted_domains(&PolicyHandle([0u8; 20]), 0, 8192);
307 // handle(20) + context(4) + max_len(4) = 28 exactly, no NDR padding.
308 assert_eq!(stub.len(), 28);
309 assert_eq!(u32::from_le_bytes(stub[20..24].try_into().unwrap()), 0);
310 assert_eq!(u32::from_le_bytes(stub[24..28].try_into().unwrap()), 8192);
311 }
312
313 #[test]
314 fn enumerate_context_is_returned_intact_on_empty_page() {
315 // Build a synthetic response with EntriesRead=0 and next_context=42.
316 let mut e = NdrEncoder::new();
317 e.u32(42); // next_context
318 e.u32(0); // EntriesRead
319 e.u32(0); // Information ptr = NULL
320 let stub = e.into_bytes();
321 let page = decode_enumerate_trusted_domains(&stub).unwrap();
322 assert_eq!(page.next_context, 42);
323 assert!(page.trusts.is_empty());
324 }
325
326 /// Encode a synthetic response with one trust (name + domain SID), then decode and
327 /// verify. Locks in the per-entry deferred-data ordering.
328 #[test]
329 fn enumerate_roundtrip_single_trust() {
330 let dom_sid = Sid::parse("S-1-5-21-1111-2222-3333").unwrap();
331 let dn: Vec<u16> = "TRUSTED-CORP".encode_utf16().collect();
332 let dl = (dn.len() * 2) as u16;
333
334 let mut e = NdrEncoder::new();
335 e.u32(0); // next_context — no more pages
336 e.u32(1); // EntriesRead
337 e.referent(); // Information array ptr
338 e.u32(1); // conformant max_count
339 // fixed header: Length + Max + BufferPtr + SidPtr = 12 bytes
340 e.u16(dl);
341 e.u16(dl);
342 e.referent(); // Name.Buffer ptr
343 e.referent(); // Sid ptr
344 // deferred: name buffer WSTR
345 e.u32(dn.len() as u32);
346 e.u32(0);
347 e.u32(dn.len() as u32);
348 for u in &dn {
349 e.u16(*u);
350 }
351 // deferred: RPC_SID
352 encode_sid(&mut e, &dom_sid);
353 // NTSTATUS trailer — not read by decoder
354 e.u32(0);
355
356 let stub = e.into_bytes();
357 let page = decode_enumerate_trusted_domains(&stub).unwrap();
358 assert_eq!(page.next_context, 0);
359 assert_eq!(page.trusts.len(), 1);
360 assert_eq!(page.trusts[0].name, "TRUSTED-CORP");
361 assert_eq!(page.trusts[0].sid.to_string(), "S-1-5-21-1111-2222-3333");
362 }
363
364 #[test]
365 fn enumerate_multiple_trusts_deferred_order() {
366 // Two trusts. Encoder must emit BOTH fixed headers THEN both deferred payloads in
367 // header order — the classic NDR pointer-shape gotcha.
368 let sids = [
369 Sid::parse("S-1-5-21-10-20-30").unwrap(),
370 Sid::parse("S-1-5-21-40-50-60").unwrap(),
371 ];
372 let names: Vec<Vec<u16>> = ["FOREST-A", "FOREST-B"]
373 .iter()
374 .map(|s| s.encode_utf16().collect())
375 .collect();
376
377 let mut e = NdrEncoder::new();
378 e.u32(0); // next_context
379 e.u32(2); // EntriesRead
380 e.referent(); // Information array ptr
381 e.u32(2); // conformant max_count
382
383 // Both fixed headers first
384 for name in &names {
385 let l = (name.len() * 2) as u16;
386 e.u16(l);
387 e.u16(l);
388 e.referent(); // Name.Buffer ptr
389 e.referent(); // Sid ptr
390 }
391 // Then all deferred, in entry order: name0, sid0, name1, sid1
392 for (name, sid) in names.iter().zip(sids.iter()) {
393 e.u32(name.len() as u32);
394 e.u32(0);
395 e.u32(name.len() as u32);
396 for u in name {
397 e.u16(*u);
398 }
399 encode_sid(&mut e, sid);
400 }
401 e.u32(0); // NTSTATUS
402
403 let stub = e.into_bytes();
404 let page = decode_enumerate_trusted_domains(&stub).unwrap();
405 assert_eq!(page.trusts.len(), 2);
406 assert_eq!(page.trusts[0].name, "FOREST-A");
407 assert_eq!(page.trusts[0].sid.to_string(), "S-1-5-21-10-20-30");
408 assert_eq!(page.trusts[1].name, "FOREST-B");
409 assert_eq!(page.trusts[1].sid.to_string(), "S-1-5-21-40-50-60");
410 }
411
412 #[test]
413 fn hostile_entries_read_is_bounded_against_stub() {
414 // Server-returned EntriesRead = u32::MAX must not force a giant Vec::with_capacity
415 // before the header preflight rejects the reply.
416 let mut e = NdrEncoder::new();
417 e.u32(0); // next_context
418 e.u32(u32::MAX); // EntriesRead — hostile
419 e.referent(); // Information array ptr
420 e.u32(u32::MAX); // conformant max_count
421 // No headers follow — the preflight must reject.
422 let stub = e.into_bytes();
423 let err = decode_enumerate_trusted_domains(&stub).unwrap_err();
424 // Just check that we didn't panic / OOM. Any error is fine here.
425 let _ = err;
426 }
427}