From 987d96859be5ffa5b57f67dca9c57a03a337c350 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 14:56:51 -0600 Subject: [PATCH] feat: improve credential domain reconciliation for low-trust sources **Added:** - Introduced `reconcile_low_trust_credential_domain` to correct credential domains when the source is low-trust and a more accurate domain is found in user state - Added helper function `is_low_trust_realm_inferred_credential_source` to identify sources with unreliable domain information - Added unit tests for `reconcile_low_trust_credential_domain` covering correct, unchanged, and ambiguous cases **Changed:** - Updated credential processing in discovery polling to attempt domain reconciliation for low-trust sources, logging when a correction is made - Enhanced credential parsing to reconcile low-trust domains using both state and newly parsed users, with warning log on correction --- .../result_processing/discovery_polling.rs | 17 ++++- .../src/orchestrator/result_processing/mod.rs | 37 +++++++++++ .../orchestrator/result_processing/tests.rs | 65 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/ares-cli/src/orchestrator/result_processing/discovery_polling.rs b/ares-cli/src/orchestrator/result_processing/discovery_polling.rs index ace11db0..91b524e5 100644 --- a/ares-cli/src/orchestrator/result_processing/discovery_polling.rs +++ b/ares-cli/src/orchestrator/result_processing/discovery_polling.rs @@ -12,6 +12,7 @@ use tracing::{debug, info, warn}; use ares_core::models::{Credential, Hash, Host, Share, TrustInfo, User, VulnerabilityInfo}; use super::parsing::resolve_parent_id; +use super::reconcile_low_trust_credential_domain; use super::LOCKOUT_PATTERNS; use crate::orchestrator::dispatcher::Dispatcher; @@ -65,8 +66,20 @@ async fn poll_discoveries(dispatcher: &Dispatcher) -> Result<()> { match disc_type { "credential" => match serde_json::from_value::(data.clone()) { Ok(mut cred) => { + let state = dispatcher.state.read().await; + let extracted_domain = cred.domain.clone(); + if let Some(corrected) = + reconcile_low_trust_credential_domain(&mut cred, &state.users) + { + warn!( + username = %cred.username, + extracted_domain = %extracted_domain, + corrected_domain = %corrected, + source = %cred.source, + "Reassigning real-time credential discovery to directory-attested domain from state.users", + ); + } if cred.parent_id.is_none() { - let state = dispatcher.state.read().await; let (pid, step) = resolve_parent_id( &state.credentials, &state.hashes, @@ -78,8 +91,8 @@ async fn poll_discoveries(dispatcher: &Dispatcher) -> Result<()> { ); cred.parent_id = pid; cred.attack_step = step; - drop(state); } + drop(state); let user_domain = format!("{}@{}", cred.username, cred.domain); match dispatcher .state diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 02dc2871..c50cb259 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -1101,6 +1101,30 @@ pub(crate) fn reconcile_extracted_credential_domain( Some(only) } +fn is_low_trust_realm_inferred_credential_source(source: &str) -> bool { + matches!( + source, + "description_field" + | "autologon_registry" + | "sysvol_script" + | "user_description_leak" + | "netexec_password" + | "ldap_description" + ) +} + +pub(crate) fn reconcile_low_trust_credential_domain( + cred: &mut ares_core::models::Credential, + users: &[ares_core::models::User], +) -> Option { + if !is_low_trust_realm_inferred_credential_source(&cred.source) { + return None; + } + let corrected = reconcile_extracted_credential_domain(users, &cred.username, &cred.domain)?; + cred.domain = corrected.clone(); + Some(corrected) +} + /// `kerberoast_{username}` or `asrep_roast_{domain}` token when the /// captured hash carries the canonical impacket / hashcat prefix /// (`$krb5tgs$`, `$krb5asrep$`). Returns `None` for other hash types so @@ -1481,7 +1505,20 @@ async fn extract_discoveries( // Read lock is released before any publish calls (which take write locks). { let state = dispatcher.state.read().await; + let mut user_hints = state.users.clone(); + user_hints.extend(parsed.users.iter().cloned()); + for cred in &mut parsed.credentials { + let extracted_domain = cred.domain.clone(); + if let Some(corrected) = reconcile_low_trust_credential_domain(cred, &user_hints) { + warn!( + username = %cred.username, + extracted_domain = %extracted_domain, + corrected_domain = %corrected, + source = %cred.source, + "Reassigning parser-extracted credential to directory-attested domain from state.users", + ); + } if cred.parent_id.is_none() { let (pid, step) = resolve_parent_id( &state.credentials, diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index 1f733e9d..12b25398 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -2075,3 +2075,68 @@ mod reconcile_extracted_credential_domain { assert_eq!(got, Some("child.contoso.local".to_string())); } } + +mod reconcile_low_trust_credential_domain { + use super::super::reconcile_low_trust_credential_domain; + use ares_core::models::{Credential, User}; + + fn user(username: &str, domain: &str) -> User { + User { + username: username.to_string(), + domain: domain.to_string(), + description: String::new(), + is_admin: false, + source: "kerberos_enum".to_string(), + } + } + + fn cred(username: &str, domain: &str, source: &str) -> Credential { + Credential { + id: "c1".to_string(), + username: username.to_string(), + password: "_L0ngCl@w_".to_string(), + domain: domain.to_string(), + source: source.to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn corrects_low_trust_sysvol_realm() { + let users = vec![user("alice", "child.contoso.local")]; + let mut cred = cred("alice", "contoso.local", "sysvol_script"); + + let got = reconcile_low_trust_credential_domain(&mut cred, &users); + + assert_eq!(got, Some("child.contoso.local".to_string())); + assert_eq!(cred.domain, "child.contoso.local"); + } + + #[test] + fn leaves_high_trust_source_unchanged() { + let users = vec![user("alice", "child.contoso.local")]; + let mut cred = cred("alice", "contoso.local", "secretsdump"); + + let got = reconcile_low_trust_credential_domain(&mut cred, &users); + + assert_eq!(got, None); + assert_eq!(cred.domain, "contoso.local"); + } + + #[test] + fn leaves_ambiguous_low_trust_realm_unchanged() { + let users = vec![ + user("administrator", "child.contoso.local"), + user("administrator", "contoso.local"), + ]; + let mut cred = cred("administrator", "contoso.local", "sysvol_script"); + + let got = reconcile_low_trust_credential_domain(&mut cred, &users); + + assert_eq!(got, None); + assert_eq!(cred.domain, "contoso.local"); + } +}