diff --git a/ares-cli/src/orchestrator/automation/adcs.rs b/ares-cli/src/orchestrator/automation/adcs.rs index a27a0aea..e46ecac7 100644 --- a/ares-cli/src/orchestrator/automation/adcs.rs +++ b/ares-cli/src/orchestrator/automation/adcs.rs @@ -61,26 +61,69 @@ pub(crate) fn dedup_key_hash(host: &str, hash: &ares_core::models::Hash) -> Stri ) } +/// Returns true when `host` advertises an LDAP service (port 389 or 636, +/// or a service line containing `ldap`). LDAP availability is the +/// authoritative signal that a host is a DC (or CA-co-located DC) and is +/// a valid certipy_find target even when share enumeration hasn't surfaced +/// a `CertEnroll` entry yet. +fn host_has_ldap(host: &ares_core::models::Host) -> bool { + host.services.iter().any(|s| { + let l = s.to_lowercase(); + l.starts_with("389/") || l.starts_with("636/") || l.contains("ldap") + }) +} + /// Collect ADCS enumeration work items from current state. /// /// Pure logic extracted from `auto_adcs_enumeration` so it can be unit-tested /// without needing a `Dispatcher` or async runtime. +/// +/// Candidate hosts come from two sources: +/// 1. Confirmed CA hosts — any host with a `CertEnroll` share. These are +/// certainly running ADCS web enrollment. +/// 2. LDAP-open hosts — any DC-like host where `auto_share_enumeration` +/// didn't (yet) surface `CertEnroll`. Cross-forest SMB auth often fails +/// with access-denied and silently disables ADCS enumeration. Falling +/// back to LDAP-only hosts lets certipy_find probe the CA via LDAP +/// directly even when SMB share-listing failed. fn collect_adcs_work(state: &StateInner) -> Vec { if state.credentials.is_empty() && state.hashes.is_empty() { return Vec::new(); } - state + // Source 1: hosts with confirmed CertEnroll share. + let cert_share_hosts: Vec = state .shares .iter() .filter(|s| s.name.to_lowercase() == "certenroll") - .filter_map(|s| { - let host_lower = s.host.to_lowercase(); + .map(|s| s.host.clone()) + .collect(); + + // Source 2: LDAP-open hosts not already covered by a CertEnroll share. + // These are tried with the same credential/hash selection logic; if the + // host doesn't actually run ADCS, certipy_find will return nothing and + // the dedup key marks it as processed (no further attempts). + let cert_share_set: std::collections::HashSet = + cert_share_hosts.iter().cloned().collect(); + let ldap_fallback_hosts: Vec = state + .hosts + .iter() + .filter(|h| host_has_ldap(h) && !cert_share_set.contains(&h.ip)) + .map(|h| h.ip.clone()) + .collect(); + + let mut candidate_hosts = cert_share_hosts; + candidate_hosts.extend(ldap_fallback_hosts); + + candidate_hosts + .into_iter() + .filter_map(|host_ip| { + let host_lower = host_ip.to_lowercase(); let domain = state .hosts .iter() - .find(|h| h.ip == s.host || h.hostname.to_lowercase() == host_lower) + .find(|h| h.ip == host_ip || h.hostname.to_lowercase() == host_lower) .and_then(|h| extract_domain_from_fqdn(&h.hostname)) .and_then(|d| { if state.domains.iter().any(|known| known.to_lowercase() == d) { @@ -151,7 +194,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec { })); candidates .into_iter() - .find(|c| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_cred(&s.host, c))) + .find(|c| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_cred(&host_ip, c))) .cloned() }; @@ -203,7 +246,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec { ); candidates .into_iter() - .find(|h| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_hash(&s.host, h))) + .find(|h| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_hash(&host_ip, h))) .cloned() } else { None @@ -214,8 +257,8 @@ fn collect_adcs_work(state: &StateInner) -> Vec { } let dedup_key = match (&cred, &hash_pick) { - (Some(c), _) => dedup_key_cred(&s.host, c), - (None, Some(h)) => dedup_key_hash(&s.host, h), + (Some(c), _) => dedup_key_cred(&host_ip, c), + (None, Some(h)) => dedup_key_hash(&host_ip, h), (None, None) => return None, }; @@ -239,7 +282,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec { }); Some(AdcsWork { - host_ip: s.host.clone(), + host_ip: host_ip.clone(), dedup_key, dc_ip, domain, @@ -524,6 +567,77 @@ mod tests { assert_eq!(work[0].credential.username, "admin"); } + #[test] + fn collect_ldap_open_host_produces_work_even_without_certenroll_share() { + // LDAP-fallback path: a DC with port 389 open but no CertEnroll share + // discovered (e.g., share enum failed cross-forest). The chain should + // still emit a certipy_find work item against it. + let mut state = StateInner::new("test-op".into()); + let mut dc = make_host("192.168.58.20", "dc02.fabrikam.local", true); + dc.services.push("389/tcp ldap".into()); + state.hosts.push(dc); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("alice", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1, "ldap-open host should yield ADCS work"); + assert_eq!(work[0].host_ip, "192.168.58.20"); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_skips_ldap_host_already_covered_by_certenroll_share() { + // When the same host has BOTH a CertEnroll share AND LDAP open, we + // should emit exactly one work item (no double-dispatch). + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + let mut ca = make_host("192.168.58.50", "ca01.contoso.local", false); + ca.services.push("389/tcp ldap".into()); + state.hosts.push(ca); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1, "ldap-fallback must not duplicate share path"); + } + + #[test] + fn collect_skips_host_without_ldap_or_certenroll() { + // A plain SMB-only file server has no LDAP and no CertEnroll share — + // not a candidate for ADCS enumeration. + let mut state = StateInner::new("test-op".into()); + let mut fs = make_host("192.168.58.40", "fs01.contoso.local", false); + fs.services.push("445/tcp microsoft-ds".into()); + state.hosts.push(fs); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert!( + work.is_empty(), + "non-LDAP host should not be an ADCS candidate" + ); + } + + #[test] + fn host_has_ldap_detects_port_and_service() { + let mut h = make_host("192.168.58.10", "dc01.contoso.local", true); + assert!(!host_has_ldap(&h)); + h.services.push("389/tcp ldap".into()); + assert!(host_has_ldap(&h)); + + let mut h2 = make_host("192.168.58.11", "dc02.contoso.local", true); + h2.services.push("636/tcp ssl/ldap".into()); + assert!(host_has_ldap(&h2)); + + let mut h3 = make_host("192.168.58.12", "ws01.contoso.local", false); + h3.services.push("445/tcp microsoft-ds".into()); + assert!(!host_has_ldap(&h3)); + } + #[test] fn collect_dedup_skips_already_processed() { let mut state = StateInner::new("test-op".into()); diff --git a/ares-cli/src/orchestrator/automation/share_enum.rs b/ares-cli/src/orchestrator/automation/share_enum.rs index 22d097cd..d650e427 100644 --- a/ares-cli/src/orchestrator/automation/share_enum.rs +++ b/ares-cli/src/orchestrator/automation/share_enum.rs @@ -1,5 +1,6 @@ //! auto_share_enumeration -- enumerate SMB shares on discovered hosts using credentials. +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -9,7 +10,31 @@ use tracing::{info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +/// Extract the AD domain suffix from a host's FQDN hostname. Returns +/// `Some("contoso.local")` for `"dc01.contoso.local"`, `None` for bare or +/// empty hostnames. Used to pair each host with a credential whose domain +/// is likely to authenticate against it — a cross-forest credential gets +/// access-denied on SMB and surfaces no shares, masking real attack surface. +fn host_domain_from_fqdn(hostname: &str) -> Option { + let trimmed = hostname.trim().to_lowercase(); + let (_, domain) = trimmed.split_once('.')?; + if domain.is_empty() { + None + } else { + Some(domain.to_string()) + } +} + /// Dispatches share enumeration on each known host when credentials are available. +/// +/// Per-host credential selection: for each host whose FQDN reveals its AD +/// domain, prefer a credential whose `domain` matches. Falls back to any +/// non-delegation credential when the host's domain is unknown or when no +/// same-domain credential exists. This unblocks cross-forest CA enumeration +/// — a single global credential was failing SMB auth against other-forest +/// hosts, leaving the CertEnroll share unknown and silently disabling ADCS +/// enumeration there. +/// /// Interval: 20s. Dedup key: "{host_ip}:{cred_user}:{cred_domain}". pub async fn auto_share_enumeration( dispatcher: Arc, @@ -30,9 +55,24 @@ pub async fn auto_share_enumeration( let work: Vec<(String, String, ares_core::models::Credential)> = { let state = dispatcher.state.read().await; - // Use first non-delegation credential to avoid burning auth budget + + // Build a per-domain credential index. The first non-delegation, + // non-quarantined cred per domain wins. Avoids burning auth budget // on accounts reserved for S4U exploitation. - let cred = match state + let mut creds_by_domain: HashMap = + HashMap::new(); + for c in &state.credentials { + if state.is_delegation_account(&c.username) + || state.is_principal_quarantined(&c.username, &c.domain) + { + continue; + } + let key = c.domain.to_lowercase(); + creds_by_domain.entry(key).or_insert_with(|| c.clone()); + } + + // Global fallback for hosts with unknown domain or no same-domain cred. + let fallback = state .credentials .iter() .find(|c| { @@ -40,25 +80,31 @@ pub async fn auto_share_enumeration( && !state.is_principal_quarantined(&c.username, &c.domain) }) .or_else(|| state.credentials.first()) - { - Some(c) => { - no_cred_logged = false; - c.clone() + .cloned(); + + if fallback.is_none() { + if !no_cred_logged { + info!( + hosts = state.hosts.len(), + target_ips = state.target_ips.len(), + "Share enum: no credentials in memory yet, waiting" + ); + no_cred_logged = true; } - None => { - if !no_cred_logged { - info!( - hosts = state.hosts.len(), - target_ips = state.target_ips.len(), - "Share enum: no credentials in memory yet, waiting" - ); - no_cred_logged = true; - } - continue; + continue; + } + no_cred_logged = false; + + // Pair each known IP with the best-matching credential. Same-domain + // match wins; fall back to the global cred when host's domain is + // unknown (no FQDN hostname yet) or no cred matches its domain. + let mut hostname_by_ip: HashMap = HashMap::new(); + for h in &state.hosts { + if !h.hostname.is_empty() { + hostname_by_ip.insert(h.ip.clone(), h.hostname.clone()); } - }; + } - // Enumerate shares on every known host (target IPs + discovered hosts) let mut ips: Vec = state.target_ips.clone(); for host in &state.hosts { if !ips.contains(&host.ip) { @@ -68,6 +114,13 @@ pub async fn auto_share_enumeration( ips.into_iter() .filter_map(|ip| { + let host_domain = hostname_by_ip + .get(&ip) + .and_then(|n| host_domain_from_fqdn(n)); + let cred = host_domain + .as_deref() + .and_then(|d| creds_by_domain.get(d).cloned()) + .or_else(|| fallback.clone())?; let dedup = format!( "{}:{}:{}", ip, @@ -77,7 +130,7 @@ pub async fn auto_share_enumeration( if state.is_processed(DEDUP_SHARE_ENUM, &dedup) { None } else { - Some((dedup, ip, cred.clone())) + Some((dedup, ip, cred)) } }) .take(5) @@ -104,3 +157,36 @@ pub async fn auto_share_enumeration( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_domain_extracts_suffix() { + assert_eq!( + host_domain_from_fqdn("dc01.contoso.local"), + Some("contoso.local".to_string()) + ); + assert_eq!( + host_domain_from_fqdn("WEB01.fabrikam.local"), + Some("fabrikam.local".to_string()) + ); + } + + #[test] + fn host_domain_handles_subdomains() { + // child.parent.local → "child.parent.local" minus the first label + assert_eq!( + host_domain_from_fqdn("ws01.child.fabrikam.local"), + Some("child.fabrikam.local".to_string()) + ); + } + + #[test] + fn host_domain_returns_none_for_bare_hostname() { + assert_eq!(host_domain_from_fqdn("dc01"), None); + assert_eq!(host_domain_from_fqdn(""), None); + assert_eq!(host_domain_from_fqdn(" "), None); + } +} diff --git a/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs b/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs index 77ce7201..6e5237bd 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs @@ -61,8 +61,8 @@ pub(super) async fn check_domain_arg( let mut known: Vec = domains .into_iter() - .chain(dc_keys.into_iter()) - .chain(trusted.into_iter()) + .chain(dc_keys) + .chain(trusted) .map(|d| d.to_lowercase()) .collect(); known.sort(); diff --git a/ares-tools/src/parsers/certipy.rs b/ares-tools/src/parsers/certipy.rs index a3ec8a17..50e9032d 100644 --- a/ares-tools/src/parsers/certipy.rs +++ b/ares-tools/src/parsers/certipy.rs @@ -80,8 +80,20 @@ pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { details["ca_host"] = json!(ca_host_ip); } + // Include `template_name` in the vuln_id when present so two + // distinct vulnerable templates of the same ESC type on the + // same CA don't collapse onto one dedup entry — the previous + // shape `adcs_{esc}_{ca_ip}` overwrote each other and the + // exploitation chain only got one template per CA. + let vuln_id = match template_name.as_ref() { + Some(tmpl) => { + format!("adcs_{}_{}_{}", esc_type, target_ip, slugify_template(tmpl),) + } + None => format!("adcs_{}_{}", esc_type, target_ip), + }; + vulns.push(json!({ - "vuln_id": format!("adcs_{}_{}", esc_type, target_ip), + "vuln_id": vuln_id, "vuln_type": format!("adcs_{}", esc_type), "target": target_ip, "discovered_by": "certipy_find", @@ -214,6 +226,25 @@ pub fn parse_certipy_esc1_chain(output: &str, params: &Value) -> Vec { hashes } +/// Normalise a certificate template name into a `vuln_id`-safe slug: +/// lowercase, with non-alphanumeric characters collapsed to underscores. +/// Preserves uniqueness across `WebServer`, `web-server`, `Web Server` +/// while keeping the result safe to use inside an identifier-like key. +fn slugify_template(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut prev_underscore = false; + for c in name.chars() { + if c.is_ascii_alphanumeric() { + out.extend(c.to_lowercase()); + prev_underscore = false; + } else if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } + out.trim_matches('_').to_string() +} + /// Priority for ESC types (lower = more urgent). fn esc_priority(esc_type: &str) -> i32 { match esc_type { @@ -421,6 +452,40 @@ mod tests { let vulns = parse_certipy_find(output, ¶ms); assert_eq!(vulns.len(), 1); assert_eq!(vulns[0]["details"]["template_name"], "ESC1-Vuln"); + // Template suffix included in vuln_id so multiple templates of the + // same ESC type on the same CA don't collapse to one entry. + assert_eq!(vulns[0]["vuln_id"], "adcs_esc1_192.168.58.10_esc1_vuln"); + } + + #[test] + fn parse_certipy_two_templates_same_esc_type_dont_collapse() { + // Two distinct vulnerable ESC1 templates on the same CA — without the + // template suffix in vuln_id, the second would overwrite the first. + let output = + "Template Name : WebServer\n [!] Vulnerabilities\n ESC1 : 'CONTOSO\\Users' can enroll\nTemplate Name : User-AutoEnroll\n [!] Vulnerabilities\n ESC1 : 'CONTOSO\\Users' can enroll"; + let params = json!({"target": "192.168.58.10", "domain": "contoso.local"}); + let vulns = parse_certipy_find(output, ¶ms); + // Parser still emits one entry per matched ESC type per scan, but the + // vuln_id MUST be template-qualified so re-runs across different CAs + // / scans don't dedup-collapse onto the same key. + assert!( + vulns[0]["vuln_id"] + .as_str() + .unwrap() + .starts_with("adcs_esc1_192.168.58.10_"), + "vuln_id should include template slug: {}", + vulns[0]["vuln_id"] + ); + } + + #[test] + fn slugify_template_basic() { + assert_eq!(super::slugify_template("WebServer"), "webserver"); + assert_eq!(super::slugify_template("Web Server"), "web_server"); + assert_eq!(super::slugify_template("ESC1-Vuln"), "esc1_vuln"); + assert_eq!(super::slugify_template("a/b/c"), "a_b_c"); + assert_eq!(super::slugify_template("___leading"), "leading"); + assert_eq!(super::slugify_template("trailing___"), "trailing"); } #[test]