Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion ares-cli/src/orchestrator/result_processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,52 @@ async fn resolve_domain_from_ip(dispatcher: &Arc<Dispatcher>, target_ip: Option<
state.domains.first().cloned().unwrap_or_default()
}

/// Prefer the directory-attested domain for a text-extracted credential.
///
/// `extract_plaintext_passwords` (and the cracked-password / hash extractors)
/// stamp every credential with `default_domain` — the *task target's* domain,
/// resolved from the target IP via the `domain_controllers` map — whenever the
/// captured line doesn't carry an explicit `DOMAIN\user` or `user@domain`
/// prefix. That's wrong for foreign-realm principals that surface in the
/// stdout of a tool run against a different DC: e.g. an LDAP search hitting
/// the parent DC returns child-domain users in description/sysvol blobs and
/// they get stored under the parent realm, after which every downstream auth
/// attempt fails with `STATUS_LOGON_FAILURE` against any DC.
///
/// `state.users` is populated by trusted enumeration parsers
/// (`kerberos_enum`, `ldap_group_enumeration`, `ldap_enumeration`, …) where
/// the realm is whatever the directory itself returned — directory-attested
/// rather than IP-inferred. When the extracted username matches exactly one
/// such entry with a non-empty domain that differs from the IP-resolved
/// fallback, prefer the state.users domain.
///
/// Returns `None` when:
/// - no matching user exists in state (nothing to correct against);
/// - the username is associated with multiple domains in state (can't
/// disambiguate — keep the extractor's guess);
/// - the only known domain already matches the extracted one (no-op).
pub(crate) fn reconcile_extracted_credential_domain(
users: &[ares_core::models::User],
username: &str,
extracted_domain: &str,
) -> Option<String> {
let user_lc = username.to_lowercase();
let mut domains: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for u in users {
if u.username.to_lowercase() == user_lc && !u.domain.is_empty() {
domains.insert(u.domain.to_lowercase());
}
}
if domains.len() != 1 {
return None;
}
let only = domains.into_iter().next().unwrap();
if only.eq_ignore_ascii_case(extracted_domain) {
return None;
}
Some(only)
}

/// `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
Expand Down Expand Up @@ -1266,7 +1312,21 @@ async fn extract_from_raw_text(

let mut new_count = 0usize;

for cred in extracted.credentials {
for mut cred in extracted.credentials {
let corrected = {
let state = dispatcher.state.read().await;
reconcile_extracted_credential_domain(&state.users, &cred.username, &cred.domain)
};
if let Some(corrected) = corrected {
warn!(
username = %cred.username,
extracted_domain = %cred.domain,
corrected_domain = %corrected,
source = %cred.source,
"Reassigning text-extracted credential to directory-attested domain from state.users",
);
cred.domain = corrected;
}
let is_cracked = cred.source.starts_with("cracked:");
let source = cred.source.clone();
let username = cred.username.clone();
Expand Down
76 changes: 76 additions & 0 deletions ares-cli/src/orchestrator/result_processing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1941,3 +1941,79 @@ fn locked_usernames_no_lockout_lines_empty() {
let p = json!({"summary": "[+] CONTOSO\\alice:Pw Pwn3d!"});
assert!(extract_locked_usernames_from_result(&Some(p)).is_empty());
}

mod reconcile_extracted_credential_domain {
use super::super::reconcile_extracted_credential_domain;
use ares_core::models::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(),
}
}

#[test]
fn corrects_when_username_unique_in_other_domain() {
let users = vec![user("alice", "child.contoso.local")];
let got = reconcile_extracted_credential_domain(&users, "alice", "contoso.local");
assert_eq!(got, Some("child.contoso.local".to_string()));
}

#[test]
fn case_insensitive_username_match() {
let users = vec![user("Alice", "child.contoso.local")];
let got = reconcile_extracted_credential_domain(&users, "ALICE", "contoso.local");
assert_eq!(got, Some("child.contoso.local".to_string()));
}

#[test]
fn no_correction_when_extracted_matches_known_domain() {
let users = vec![user("alice", "child.contoso.local")];
let got = reconcile_extracted_credential_domain(&users, "alice", "CHILD.contoso.local");
assert_eq!(got, None);
}

#[test]
fn no_correction_when_user_unknown() {
let users = vec![user("bob", "contoso.local")];
let got = reconcile_extracted_credential_domain(&users, "alice", "contoso.local");
assert_eq!(got, None);
}

#[test]
fn no_correction_when_user_ambiguous_across_domains() {
// Same username in two domains (e.g. Administrator in parent + child) —
// can't disambiguate, so the extractor's guess stands.
let users = vec![
user("administrator", "contoso.local"),
user("administrator", "child.contoso.local"),
];
let got = reconcile_extracted_credential_domain(&users, "administrator", "contoso.local");
assert_eq!(got, None);
}

#[test]
fn ignores_state_users_with_empty_domain() {
// An anomalous user row with no domain is not a usable signal.
let users = vec![user("alice", "")];
let got = reconcile_extracted_credential_domain(&users, "alice", "contoso.local");
assert_eq!(got, None);
}

#[test]
fn duplicate_domains_collapse_to_one_match() {
// Two state.users rows for the same principal (e.g. discovered via two
// different enumeration tools) should still be treated as a unique
// domain assignment.
let users = vec![
user("alice", "child.contoso.local"),
user("alice", "CHILD.contoso.local"),
];
let got = reconcile_extracted_credential_domain(&users, "alice", "contoso.local");
assert_eq!(got, Some("child.contoso.local".to_string()));
}
}
Loading