Skip to content

Commit 82f0fec

Browse files
authored
feat: capture trust partner SID from trust enum to unblock child→parent forge (#311)
**Key Changes:** - Added support for extracting and propagating the securityIdentifier (domain SID) during domain trust enumeration and parsing - Updated trust parsing logic to handle both canonical and base64-encoded securityIdentifier formats - Modified orchestrator state publishing to upsert domain SIDs from trust data, ensuring correct automation on hardened DCs - Expanded and improved test coverage to validate new SID extraction and handling logic **Added:** - security_identifier field to TrustInfo struct, with appropriate serde handling for optionality and defaulting - `ares-core/src/models/core.rs` - Logic in trust parser to extract securityIdentifier from both canonical text and base64-encoded LDAP outputs, including a decoder for binary SIDs - `ares-tools/src/parsers/trust.rs` - Tests for parsing, decoding, and correct state population of securityIdentifier, including multiple edge cases and block boundaries - `ares-tools/src/parsers/trust.rs`, `ares-cli/src/orchestrator/state/publishing/entities.rs` - Inline extraction and emission of canonical securityIdentifier in impacket LDAP enumeration for pass-the-hash authentication - `ares-tools/src/recon.rs` **Changed:** - Trust enumeration and parsing code paths to support and carry securityIdentifier where present, ensuring downstream state and automation logic can use the SID directly - Orchestrator state publishing logic to upsert domain_sids from trust-enum data, mirroring the post-SAMR lookup persistence path and supporting automation on hardened 2019+ DCs - `ares-cli/src/orchestrator/state/publishing/entities.rs` - Test helpers and fixtures across several test modules to include security_identifier in constructed TrustInfo instances where relevant **Removed:** - Redundant or now-unnecessary fallback logic and comments related to SID acquisition via legacy mechanisms in favor of direct propagation from trust enumeration
1 parent 0537e40 commit 82f0fec

10 files changed

Lines changed: 299 additions & 10 deletions

File tree

‎ares-cli/src/ops/inject.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ pub(crate) async fn ops_inject_trust(
327327
direction,
328328
trust_type: trust_type.clone(),
329329
sid_filtering,
330+
security_identifier: None,
330331
};
331332

332333
let added = reader.add_trusted_domain(&mut conn, &trust).await?;

‎ares-cli/src/orchestrator/automation/trust.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2676,6 +2676,7 @@ mod tests {
26762676
direction: "bidirectional".into(),
26772677
trust_type: "forest".into(),
26782678
sid_filtering: true,
2679+
security_identifier: None,
26792680
};
26802681
let s = state_with_trust("fabrikam.local", trust);
26812682
assert!(is_filtered_inter_forest_trust(
@@ -2693,6 +2694,7 @@ mod tests {
26932694
direction: "bidirectional".into(),
26942695
trust_type: "forest".into(),
26952696
sid_filtering: false,
2697+
security_identifier: None,
26962698
};
26972699
let s = state_with_trust("fabrikam.local", trust);
26982700
assert!(!is_filtered_inter_forest_trust(
@@ -2730,6 +2732,7 @@ mod tests {
27302732
direction: "bidirectional".into(),
27312733
trust_type: "parent_child".into(),
27322734
sid_filtering: false,
2735+
security_identifier: None,
27332736
};
27342737
let s = state_with_trust("contoso.local", parent_trust);
27352738
// Target fabrikam.local has no metadata — try the forge.
@@ -2750,6 +2753,7 @@ mod tests {
27502753
direction: "bidirectional".into(),
27512754
trust_type: "forest".into(),
27522755
sid_filtering: true,
2756+
security_identifier: None,
27532757
};
27542758
let s = state_with_trust("fabrikam.local", target_trust);
27552759
assert!(is_filtered_inter_forest_trust(

‎ares-cli/src/orchestrator/completion.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ mod tests {
667667
direction: "bidirectional".to_string(),
668668
trust_type: trust_type.to_string(),
669669
sid_filtering: false,
670+
security_identifier: None,
670671
}
671672
}
672673

‎ares-cli/src/orchestrator/result_processing/admin_checks.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ mod tests {
582582
direction: "bidirectional".to_string(),
583583
trust_type: "forest".to_string(),
584584
sid_filtering: true,
585+
security_identifier: None,
585586
}
586587
}
587588

‎ares-cli/src/orchestrator/state/publishing/entities.rs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,25 @@ impl SharedState {
378378
let added = reader.add_trusted_domain(&mut conn, &trust).await?;
379379
if added {
380380
let domain_key = trust.domain.to_lowercase();
381+
// Capture the SID *before* moving `trust` into the map. Upserting
382+
// domain_sids from trust-enum data is the load-bearing step that
383+
// lets `auto_trust_follow` pass its parent-SID gate on hardened
384+
// 2019+ parent DCs where the post-hoc SAMR / null-session lsaquery
385+
// fallbacks (in `golden_ticket::resolve_domain_sid`) are blocked.
386+
let trust_sid = trust.security_identifier.clone();
381387
{
382388
let mut state = self.inner.write().await;
383389
state.trusted_domains.insert(domain_key.clone(), trust);
390+
if let Some(ref sid) = trust_sid {
391+
state.domain_sids.insert(domain_key.clone(), sid.clone());
392+
}
393+
}
394+
if let Some(sid) = trust_sid {
395+
// Persist to redis so a replayed/reloaded operation inherits
396+
// the SID — mirrors the persistence path used after a SAMR
397+
// lookup succeeds in resolve_domain_sid.
398+
let mut conn2 = queue.connection();
399+
let _ = reader.set_domain_sid(&mut conn2, &domain_key, &sid).await;
384400
}
385401
// Also promote the foreign domain into state.domains so the
386402
// per-domain automations pick it up.
@@ -542,6 +558,7 @@ mod tests {
542558
direction: "bidirectional".to_string(),
543559
trust_type: "forest".to_string(),
544560
sid_filtering: false,
561+
security_identifier: None,
545562
}
546563
}
547564

@@ -818,6 +835,48 @@ mod tests {
818835
assert_eq!(t.trust_type, "forest");
819836
}
820837

838+
#[tokio::test]
839+
async fn publish_trust_info_upserts_domain_sid_when_carried() {
840+
// When the trust enum captured securityIdentifier, publish_trust_info
841+
// must mirror it into state.domain_sids so `auto_trust_follow` passes
842+
// its parent-SID gate without needing the SAMR/lsaquery fallbacks.
843+
// This is the load-bearing wiring for the child→parent forge path.
844+
let state = SharedState::new("op-sid".to_string());
845+
let q = mock_queue();
846+
847+
let mut trust = make_trust("contoso.local");
848+
trust.security_identifier = Some("S-1-5-21-1111111111-2222222222-3333333333".into());
849+
let added = state.publish_trust_info(&q, trust).await.unwrap();
850+
assert!(added);
851+
852+
let s = state.inner.read().await;
853+
assert_eq!(
854+
s.domain_sids.get("contoso.local").map(String::as_str),
855+
Some("S-1-5-21-1111111111-2222222222-3333333333"),
856+
"domain_sids must be populated from the trust's security_identifier"
857+
);
858+
}
859+
860+
#[tokio::test]
861+
async fn publish_trust_info_no_sid_leaves_domain_sids_empty() {
862+
// Legacy trust enum runs (no securityIdentifier) must not corrupt
863+
// domain_sids — we leave the slot for `golden_ticket::resolve_domain_sid`
864+
// to fill via SAMR/lsaquery.
865+
let state = SharedState::new("op-nosid".to_string());
866+
let q = mock_queue();
867+
868+
let trust = make_trust("fabrikam.local");
869+
assert!(trust.security_identifier.is_none());
870+
let added = state.publish_trust_info(&q, trust).await.unwrap();
871+
assert!(added);
872+
873+
let s = state.inner.read().await;
874+
assert!(
875+
!s.domain_sids.contains_key("fabrikam.local"),
876+
"missing SID must NOT insert a domain_sids entry"
877+
);
878+
}
879+
821880
#[test]
822881
fn same_domain_is_same_forest() {
823882
assert!(are_in_same_forest("contoso.local", "contoso.local"));

‎ares-core/src/models/core.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ mod tests {
255255
direction: "bidirectional".to_string(),
256256
trust_type: "parent_child".to_string(),
257257
sid_filtering: false,
258+
security_identifier: None,
258259
};
259260
assert!(t.is_parent_child());
260261
assert!(!t.is_cross_forest());
@@ -268,6 +269,7 @@ mod tests {
268269
direction: "outbound".to_string(),
269270
trust_type: "forest".to_string(),
270271
sid_filtering: true,
272+
security_identifier: None,
271273
};
272274
assert!(t.is_cross_forest());
273275
assert!(!t.is_parent_child());
@@ -281,6 +283,7 @@ mod tests {
281283
direction: "inbound".to_string(),
282284
trust_type: "external".to_string(),
283285
sid_filtering: false,
286+
security_identifier: None,
284287
};
285288
assert!(t.is_cross_forest());
286289
}
@@ -293,6 +296,7 @@ mod tests {
293296
direction: String::new(),
294297
trust_type: "unknown".to_string(),
295298
sid_filtering: false,
299+
security_identifier: None,
296300
};
297301
assert!(!t.is_cross_forest());
298302
assert!(!t.is_parent_child());
@@ -467,6 +471,7 @@ mod tests {
467471
direction: "bidirectional".to_string(),
468472
trust_type: "parent_child".to_string(),
469473
sid_filtering: true,
474+
security_identifier: None,
470475
};
471476
let json = serde_json::to_string(&trust).unwrap();
472477
let deser: TrustInfo = serde_json::from_str(&json).unwrap();
@@ -532,6 +537,16 @@ pub struct TrustInfo {
532537
/// Whether SID filtering is active (blocks RID < 1000 across forest trusts).
533538
#[serde(default)]
534539
pub sid_filtering: bool,
540+
/// Domain SID of the trusted partner, in canonical S-1-5-21-X-Y-Z form
541+
/// when the LDAP `securityIdentifier` attribute was captured by
542+
/// `enumerate_domain_trusts`. Carrying this on the trust object lets the
543+
/// orchestrator pre-populate `state.domain_sids` for the partner without
544+
/// a separate authenticated SAMR lookup against the foreign DC — that
545+
/// lookup is the gate that previously blocked child→parent forge dispatch
546+
/// on hardened (2019+) parent DCs where cross-realm NTLM is rejected and
547+
/// null-session lsaquery is disabled.
548+
#[serde(default, skip_serializing_if = "Option::is_none")]
549+
pub security_identifier: Option<String>,
535550
}
536551

537552
impl TrustInfo {

‎ares-core/src/state/reader.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,7 @@ mod tests {
767767
direction: "bidirectional".to_string(),
768768
trust_type: trust_type.to_string(),
769769
sid_filtering: false,
770+
security_identifier: None,
770771
}
771772
}
772773

‎ares-llm/src/routing/credentials.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ mod tests {
218218
direction: "bidirectional".to_string(),
219219
trust_type: "forest".to_string(),
220220
sid_filtering: true,
221+
security_identifier: None,
221222
},
222223
);
223224
assert!(is_valid_credential_for_domain(

0 commit comments

Comments
 (0)