Skip to main content

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}