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
132 changes: 123 additions & 9 deletions ares-cli/src/orchestrator/automation/adcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdcsWork> {
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<String> = 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<String> =
cert_share_hosts.iter().cloned().collect();
let ldap_fallback_hosts: Vec<String> = 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) {
Expand Down Expand Up @@ -151,7 +194,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec<AdcsWork> {
}));
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()
};

Expand Down Expand Up @@ -203,7 +246,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec<AdcsWork> {
);
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
Expand All @@ -214,8 +257,8 @@ fn collect_adcs_work(state: &StateInner) -> Vec<AdcsWork> {
}

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,
};

Expand All @@ -239,7 +282,7 @@ fn collect_adcs_work(state: &StateInner) -> Vec<AdcsWork> {
});

Some(AdcsWork {
host_ip: s.host.clone(),
host_ip: host_ip.clone(),
dedup_key,
dc_ip,
domain,
Expand Down Expand Up @@ -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());
Expand Down
124 changes: 105 additions & 19 deletions ares-cli/src/orchestrator/automation/share_enum.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<String> {
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<Dispatcher>,
Expand All @@ -30,35 +55,56 @@ 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<String, ares_core::models::Credential> =
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| {
!state.is_delegation_account(&c.username)
&& !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<String, String> = 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<String> = state.target_ips.clone();
for host in &state.hosts {
if !ips.contains(&host.ip) {
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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);
}
}
4 changes: 2 additions & 2 deletions ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ pub(super) async fn check_domain_arg(

let mut known: Vec<String> = 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();
Expand Down
Loading