From 5a051d297744a1e188400443b971da521dd313e4 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 10:36:49 -0600 Subject: [PATCH 1/5] refactor: remove localuser_spray automation and update domain test data to contoso.local **Changed:** - Updated test data across modules from "sevenkingdoms.local" and "example.com" to "contoso.local" and similar modern domain names for consistency and clarity - Replaced test user/domain/hostnames in orchestrator, dedup, credential expansion, PTH spray, and domain probe with contoso.local, fabrikam.local, and child.contoso.local - Updated documentation comments and user guidance strings to use contoso.local and related domains instead of placeholder or legacy examples - Improved domain attribution logic in hash extraction to more accurately assign built-in accounts (e.g., Administrator) to domains when dump evidence is present - Adjusted hash parsing logic and tests to ensure Administrator and other built-ins are attributed to the correct domain in both ares-cli and ares-tools **Removed:** - Removed the `localuser_spray` automation module, associated test cases, and all references to its deduplication set and strategy weights - Eliminated `auto_localuser_spray` from automation spawner and public exports - Deleted deduplication constant and set for localuser_spray in state management - Removed localuser_spray weights from all orchestrator strategy profiles - Cleared localuser_spray from dedup set arrays and related orchestrator tests --- ares-cli/src/dedup/domains.rs | 41 ++- ares-cli/src/dedup/tests.rs | 43 +++ .../orchestrator/automation/acl_discovery.rs | 2 +- .../automation/credential_expansion.rs | 8 +- .../automation/cross_forest_enum.rs | 4 +- .../automation/foreign_group_enum.rs | 2 +- .../automation/group_enumeration.rs | 4 +- .../automation/localuser_spray.rs | 294 ------------------ ares-cli/src/orchestrator/automation/mod.rs | 2 - .../src/orchestrator/automation/pth_spray.rs | 12 +- .../src/orchestrator/automation_spawner.rs | 1 - ares-cli/src/orchestrator/mod.rs | 2 +- .../orchestrator/output_extraction/hashes.rs | 43 ++- .../orchestrator/state/domain_probe/worker.rs | 10 +- ares-cli/src/orchestrator/state/inner.rs | 11 +- ares-cli/src/orchestrator/state/mod.rs | 3 - .../src/orchestrator/state/persistence.rs | 6 +- .../state/publishing/credentials.rs | 8 +- .../orchestrator/state/publishing/domains.rs | 12 +- ares-cli/src/orchestrator/strategy.rs | 4 - ares-tools/src/parsers/mod.rs | 2 +- ares-tools/src/parsers/secrets.rs | 28 +- docs/goad-checklist.md | 3 + 23 files changed, 180 insertions(+), 365 deletions(-) delete mode 100644 ares-cli/src/orchestrator/automation/localuser_spray.rs diff --git a/ares-cli/src/dedup/domains.rs b/ares-cli/src/dedup/domains.rs index 8469ba7c..79d492ae 100644 --- a/ares-cli/src/dedup/domains.rs +++ b/ares-cli/src/dedup/domains.rs @@ -180,6 +180,7 @@ pub(crate) fn normalize_state_domains( { let mut valid_domains: HashSet = HashSet::new(); let mut host_fqdns: HashSet = HashSet::new(); + let target_domain_lower = target_domain.map(|d| d.to_lowercase()); if let Some(td) = target_domain { valid_domains.insert(td.to_lowercase()); } @@ -207,8 +208,8 @@ pub(crate) fn normalize_state_domains( } // Also keep child domains whose suffix parent is already valid. - // e.g. north.sevenkingdoms.local survives when sevenkingdoms.local is valid, - // even before any north users/hosts have been enumerated. + // e.g. child.contoso.local survives when contoso.local is valid, + // even before any child users/hosts have been enumerated. let child_domains: Vec = domains .iter() .filter_map(|d| { @@ -225,10 +226,37 @@ pub(crate) fn normalize_state_domains( .collect(); valid_domains.extend(child_domains); + // Symmetric rule for forest roots: if a child domain is already valid + // (from target config, users, or corroborated host evidence), keep its + // suffix-parent too when that parent is present in the raw domain set. + // This avoids dropping roots like `contoso.local` when a DC was + // recorded with hostname exactly `contoso.local`. + let implied_parent_domains: HashSet = domains + .iter() + .filter_map(|d| { + let lower = d.trim().to_lowercase(); + if !valid_domains.contains(&lower) { + return None; + } + let parts: Vec<&str> = lower.split('.').collect(); + if parts.len() > 2 { + let parent = parts[1..].join("."); + if domains + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&parent)) + { + return Some(parent); + } + } + None + }) + .collect(); + valid_domains.extend(implied_parent_domains.iter().cloned()); + // A string that appears as the suffix (parts[1..]) of any host FQDN is a real // domain, even if it also happens to appear as a host's own hostname field - // (e.g. a DC recorded as hostname="north.sevenkingdoms.local" while - // castelblack.north.sevenkingdoms.local is another host in the same op). + // (e.g. a DC recorded as hostname="child.contoso.local" while + // dc01.child.contoso.local is another host in the same op). let confirmed_domains: HashSet = hosts .iter() .filter(|h| !h.hostname.is_empty() && h.hostname.contains('.')) @@ -242,7 +270,10 @@ pub(crate) fn normalize_state_domains( domains.retain(|d| { let lower = d.to_lowercase(); valid_domains.contains(&lower) - && (!host_fqdns.contains(&lower) || confirmed_domains.contains(&lower)) + && (!host_fqdns.contains(&lower) + || confirmed_domains.contains(&lower) + || target_domain_lower.as_deref() == Some(lower.as_str()) + || implied_parent_domains.contains(&lower)) }); } } diff --git a/ares-cli/src/dedup/tests.rs b/ares-cli/src/dedup/tests.rs index d6b238be..0141b6fc 100644 --- a/ares-cli/src/dedup/tests.rs +++ b/ares-cli/src/dedup/tests.rs @@ -712,6 +712,26 @@ fn normalize_state_domains_domain_kept_from_target_domain() { assert_eq!(domains[0], "fabrikam.local"); } +#[test] +fn normalize_state_domains_target_domain_survives_matching_host_fqdn() { + let users: Vec = vec![]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![make_host("192.168.58.220", "contoso.local")]; + + normalize_state_domains( + &users, + &mut creds, + &mut hashes, + &mut domains, + &hosts, + Some("contoso.local"), + ); + + assert_eq!(domains, vec!["contoso.local".to_string()]); +} + #[test] fn normalize_state_domains_child_domain_kept_when_parent_valid() { // A child domain (3+ labels) should survive the filter when its @@ -745,6 +765,29 @@ fn normalize_state_domains_child_domain_kept_when_parent_valid() { assert!(!domains.contains(&"orphan.other".to_string())); } +#[test] +fn normalize_state_domains_parent_domain_kept_when_child_is_valid() { + let users: Vec = vec![]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec![ + "contoso.local".to_string(), + "child.contoso.local".to_string(), + ]; + let hosts = vec![ + make_host("192.168.58.220", "contoso.local"), + make_host("192.168.58.150", "dc01.child.contoso.local"), + ]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert!( + domains.contains(&"contoso.local".to_string()), + "forest root should survive when a valid child domain implies it" + ); + assert!(domains.contains(&"child.contoso.local".to_string())); +} + #[test] fn normalize_state_domains_hash_not_corrected_when_domain_is_known() { // When hash domain IS in known_domains, it should NOT be corrected even if user diff --git a/ares-cli/src/orchestrator/automation/acl_discovery.rs b/ares-cli/src/orchestrator/automation/acl_discovery.rs index ffb2b7a4..e614c9b0 100644 --- a/ares-cli/src/orchestrator/automation/acl_discovery.rs +++ b/ares-cli/src/orchestrator/automation/acl_discovery.rs @@ -260,7 +260,7 @@ pub async fn auto_acl_discovery(dispatcher: Arc, mut shutdown: watch " source_domain: the domain of the source principal\n", "Focus on ACEs where the source is a user we have credentials for.\n\n", "IMPORTANT: Include ALL users discovered in the discovered_users array:\n", - " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + " {\"username\": \"samaccountname\", \"domain\": \"contoso.local\", ", "\"source\": \"acl_discovery\"}" ), }); diff --git a/ares-cli/src/orchestrator/automation/credential_expansion.rs b/ares-cli/src/orchestrator/automation/credential_expansion.rs index f0abb979..cd396377 100644 --- a/ares-cli/src/orchestrator/automation/credential_expansion.rs +++ b/ares-cli/src/orchestrator/automation/credential_expansion.rs @@ -1470,16 +1470,16 @@ mod tests { fn select_hash_work_resolves_netbios_domain_for_dispatch() { let mut s = StateInner::new("op".into()); s.netbios_to_fqdn - .insert("north".into(), "north.sevenkingdoms.local".into()); + .insert("child".into(), "child.contoso.local".into()); s.hosts.push(make_host( - "winterfell.north.sevenkingdoms.local", + "dc01.child.contoso.local", "192.168.58.10", )); - s.hashes.push(make_ntlm_hash("alice", "aaaaaaaa", "NORTH")); + s.hashes.push(make_ntlm_hash("alice", "aaaaaaaa", "CHILD")); let work = select_hash_expansion_work(&s, 10); assert_eq!(work.len(), 1); - assert_eq!(work[0].resolved_domain, "north.sevenkingdoms.local"); + assert_eq!(work[0].resolved_domain, "child.contoso.local"); assert_eq!(work[0].targets, vec!["192.168.58.10"]); } diff --git a/ares-cli/src/orchestrator/automation/cross_forest_enum.rs b/ares-cli/src/orchestrator/automation/cross_forest_enum.rs index 6c0d5907..2c92004f 100644 --- a/ares-cli/src/orchestrator/automation/cross_forest_enum.rs +++ b/ares-cli/src/orchestrator/automation/cross_forest_enum.rs @@ -187,7 +187,7 @@ pub async fn auto_cross_forest_enum( "DoesNotRequirePreAuth, or interesting SPNs.\n\n", "IMPORTANT: For each user found, include them in the discovered_users ", "array with EXACTLY this JSON format:\n", - " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + " {\"username\": \"samaccountname\", \"domain\": \"contoso.local\", ", "\"source\": \"ldap_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}\n", "Also report users with DoesNotRequirePreAuth as vulnerabilities with ", "vuln_type='asrep_roastable', and users with SPNs as vuln_type='kerberoastable'." @@ -250,7 +250,7 @@ pub async fn auto_cross_forest_enum( "and managed-by. This is critical for mapping cross-domain attack paths.\n\n", "IMPORTANT: For each user found in any group, include them in the ", "discovered_users array with EXACTLY this JSON format:\n", - " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + " {\"username\": \"samaccountname\", \"domain\": \"contoso.local\", ", "\"source\": \"ldap_group_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}" ), }); diff --git a/ares-cli/src/orchestrator/automation/foreign_group_enum.rs b/ares-cli/src/orchestrator/automation/foreign_group_enum.rs index b7ea367b..c2de8567 100644 --- a/ares-cli/src/orchestrator/automation/foreign_group_enum.rs +++ b/ares-cli/src/orchestrator/automation/foreign_group_enum.rs @@ -133,7 +133,7 @@ pub async fn auto_foreign_group_enum( "domain=target_domain, source_domain=foreign_domain.\n\n", "IMPORTANT: For each user discovered during FSP enumeration, include them in the ", "discovered_users array with EXACTLY this JSON format:\n", - " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + " {\"username\": \"samaccountname\", \"domain\": \"contoso.local\", ", "\"source\": \"foreign_group_enumeration\", \"memberOf\": [\"Group1\"]}\n", "Include ALL users found — both foreign principals and local group members." ), diff --git a/ares-cli/src/orchestrator/automation/group_enumeration.rs b/ares-cli/src/orchestrator/automation/group_enumeration.rs index 3bad80a0..7375a37e 100644 --- a/ares-cli/src/orchestrator/automation/group_enumeration.rs +++ b/ares-cli/src/orchestrator/automation/group_enumeration.rs @@ -219,7 +219,7 @@ pub async fn auto_group_enumeration( "If a password IS provided, use ldap_search with filter (objectCategory=group) ", "to enumerate groups, members, and Foreign Security Principals.\n\n", "CROSS-DOMAIN AUTH: If the credential domain differs from the target domain ", - "(e.g. credential from child.domain.local querying parent domain.local), ", + "(e.g. credential from child.contoso.local querying parent contoso.local), ", "you MUST pass bind_domain= to ldap_search. ", "Check the 'bind_domain' field in the task payload — if present, always pass it ", "to ldap_search so the LDAP bind uses user@bind_domain while querying the target domain.\n\n", @@ -236,7 +236,7 @@ pub async fn auto_group_enumeration( "and any custom groups with adminCount=1.\n\n", "Report cross-domain memberships as vuln_type='foreign_group_membership'.\n\n", "IMPORTANT: For each user found, include in discovered_users array:\n", - " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + " {\"username\": \"samaccountname\", \"domain\": \"contoso.local\", ", "\"source\": \"ldap_group_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}" ), }); diff --git a/ares-cli/src/orchestrator/automation/localuser_spray.rs b/ares-cli/src/orchestrator/automation/localuser_spray.rs deleted file mode 100644 index 734a6914..00000000 --- a/ares-cli/src/orchestrator/automation/localuser_spray.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! auto_localuser_spray -- test localuser/localuser credentials across domains. -//! -//! GOAD configures a `localuser` account with username=password across all three -//! domains. In one domain this user has Domain Admin privileges. This module -//! specifically tests the localuser:localuser credential combo against each -//! discovered DC, which standard password spraying may miss if it doesn't -//! include "localuser" in its wordlist. - -use std::sync::Arc; -use std::time::Duration; - -use serde_json::json; -use tokio::sync::watch; -use tracing::{debug, info, warn}; - -use crate::orchestrator::dispatcher::Dispatcher; -use crate::orchestrator::state::*; - -/// Collect localuser spray work items from current state. -/// -/// Pure logic extracted from `auto_localuser_spray` so it can be unit-tested -/// without needing a `Dispatcher` or async runtime. -fn collect_localuser_spray_work(state: &StateInner) -> Vec { - let mut items = Vec::new(); - - for (domain, dc_ip) in &state.all_domains_with_dcs() { - let dedup_key = format!("localuser:{}", domain.to_lowercase()); - if state.is_processed(DEDUP_LOCALUSER_SPRAY, &dedup_key) { - continue; - } - - items.push(LocaluserWork { - dedup_key, - domain: domain.clone(), - dc_ip: dc_ip.clone(), - }); - } - - items -} - -/// Tests localuser:localuser credentials against each domain. -/// Interval: 45s. -pub async fn auto_localuser_spray( - dispatcher: Arc, - mut shutdown: watch::Receiver, -) { - let mut interval = tokio::time::interval(Duration::from_secs(45)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - loop { - tokio::select! { - _ = interval.tick() => {}, - _ = shutdown.changed() => break, - } - if *shutdown.borrow() { - break; - } - - if !dispatcher.is_technique_allowed("localuser_spray") { - continue; - } - - let work = { - let state = dispatcher.state.read().await; - collect_localuser_spray_work(&state) - }; - - for item in work { - let payload = json!({ - "technique": "smb_login_check", - "target_ip": item.dc_ip, - "domain": item.domain, - "credential": { - "username": "localuser", - "password": "localuser", - "domain": item.domain, - }, - }); - - let priority = dispatcher.effective_priority("localuser_spray"); - match dispatcher - .throttled_submit("credential_access", "credential_access", payload, priority) - .await - { - Ok(Some(task_id)) => { - info!( - task_id = %task_id, - domain = %item.domain, - dc = %item.dc_ip, - "localuser credential spray dispatched" - ); - - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_LOCALUSER_SPRAY, item.dedup_key.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_LOCALUSER_SPRAY, &item.dedup_key) - .await; - } - Ok(None) => { - debug!(domain = %item.domain, "localuser spray deferred"); - } - Err(e) => { - warn!(err = %e, domain = %item.domain, "Failed to dispatch localuser spray"); - } - } - } - } -} - -struct LocaluserWork { - dedup_key: String, - domain: String, - dc_ip: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- collect_localuser_spray_work tests --- - - #[test] - fn collect_empty_state_returns_no_work() { - let state = StateInner::new("test-op".into()); - let work = collect_localuser_spray_work(&state); - assert!(work.is_empty()); - } - - #[test] - fn collect_single_domain_produces_work() { - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("contoso.local".into(), "192.168.58.10".into()); - let work = collect_localuser_spray_work(&state); - assert_eq!(work.len(), 1); - assert_eq!(work[0].domain, "contoso.local"); - assert_eq!(work[0].dc_ip, "192.168.58.10"); - assert_eq!(work[0].dedup_key, "localuser:contoso.local"); - } - - #[test] - fn collect_multiple_domains() { - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("contoso.local".into(), "192.168.58.10".into()); - state - .domain_controllers - .insert("fabrikam.local".into(), "192.168.58.20".into()); - let work = collect_localuser_spray_work(&state); - assert_eq!(work.len(), 2); - let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); - assert!(domains.contains(&"contoso.local")); - assert!(domains.contains(&"fabrikam.local")); - } - - #[test] - fn collect_dedup_skips_already_processed() { - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("contoso.local".into(), "192.168.58.10".into()); - state.mark_processed(DEDUP_LOCALUSER_SPRAY, "localuser:contoso.local".into()); - let work = collect_localuser_spray_work(&state); - assert!(work.is_empty()); - } - - #[test] - fn collect_dedup_skips_processed_keeps_unprocessed() { - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("contoso.local".into(), "192.168.58.10".into()); - state - .domain_controllers - .insert("fabrikam.local".into(), "192.168.58.20".into()); - state.mark_processed(DEDUP_LOCALUSER_SPRAY, "localuser:contoso.local".into()); - let work = collect_localuser_spray_work(&state); - assert_eq!(work.len(), 1); - assert_eq!(work[0].domain, "fabrikam.local"); - } - - #[test] - fn collect_dedup_key_lowercased() { - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); - let work = collect_localuser_spray_work(&state); - assert_eq!(work.len(), 1); - assert_eq!(work[0].dedup_key, "localuser:contoso.local"); - } - - #[test] - fn collect_no_credentials_needed() { - // localuser_spray does NOT require existing credentials (it uses hardcoded localuser:localuser) - let mut state = StateInner::new("test-op".into()); - state - .domain_controllers - .insert("contoso.local".into(), "192.168.58.10".into()); - assert!(state.credentials.is_empty()); - let work = collect_localuser_spray_work(&state); - assert_eq!(work.len(), 1); - } - - #[test] - fn dedup_key_format() { - let key = format!("localuser:{}", "contoso.local"); - assert_eq!(key, "localuser:contoso.local"); - } - - #[test] - fn dedup_set_name() { - assert_eq!(DEDUP_LOCALUSER_SPRAY, "localuser_spray"); - } - - #[test] - fn payload_structure_has_correct_technique() { - let payload = json!({ - "technique": "smb_login_check", - "target_ip": "192.168.58.10", - "domain": "contoso.local", - "credential": { - "username": "localuser", - "password": "localuser", - "domain": "contoso.local", - }, - }); - assert_eq!(payload["technique"], "smb_login_check"); - assert_eq!(payload["target_ip"], "192.168.58.10"); - assert_eq!(payload["credential"]["username"], "localuser"); - assert_eq!(payload["credential"]["password"], "localuser"); - assert_eq!(payload["credential"]["domain"], "contoso.local"); - } - - #[test] - fn work_struct_construction() { - let work = LocaluserWork { - dedup_key: "localuser:contoso.local".into(), - domain: "contoso.local".into(), - dc_ip: "192.168.58.10".into(), - }; - assert_eq!(work.domain, "contoso.local"); - assert_eq!(work.dc_ip, "192.168.58.10"); - assert_eq!(work.dedup_key, "localuser:contoso.local"); - } - - #[test] - fn no_credentials_needed_in_work_struct() { - // LocaluserWork does not carry a credential -- it uses hardcoded localuser:localuser - let work = LocaluserWork { - dedup_key: "localuser:fabrikam.local".into(), - domain: "fabrikam.local".into(), - dc_ip: "192.168.58.20".into(), - }; - assert_eq!(work.domain, "fabrikam.local"); - } - - #[test] - fn dedup_key_normalizes_domain() { - let key = format!("localuser:{}", "CONTOSO.LOCAL".to_lowercase()); - assert_eq!(key, "localuser:contoso.local"); - } - - #[test] - fn credential_uses_domain_from_target() { - let domain = "contoso.local"; - let payload = json!({ - "credential": { - "username": "localuser", - "password": "localuser", - "domain": domain, - }, - }); - assert_eq!(payload["credential"]["domain"], domain); - } - - #[test] - fn per_domain_dedup() { - let domains = ["contoso.local", "fabrikam.local"]; - let keys: Vec = domains - .iter() - .map(|d| format!("localuser:{}", d.to_lowercase())) - .collect(); - assert_eq!(keys.len(), 2); - assert_ne!(keys[0], keys[1]); - } -} diff --git a/ares-cli/src/orchestrator/automation/mod.rs b/ares-cli/src/orchestrator/automation/mod.rs index 3d8f8c92..5a3e1ce5 100644 --- a/ares-cli/src/orchestrator/automation/mod.rs +++ b/ares-cli/src/orchestrator/automation/mod.rs @@ -40,7 +40,6 @@ mod group_enumeration; mod krbrelayup; mod laps; mod ldap_signing; -mod localuser_spray; mod lsassy_dump; mod machine_account_quota; mod mssql; @@ -106,7 +105,6 @@ pub use group_enumeration::auto_group_enumeration; pub use krbrelayup::auto_krbrelayup; pub use laps::auto_laps_extraction; pub use ldap_signing::auto_ldap_signing; -pub use localuser_spray::auto_localuser_spray; pub use lsassy_dump::auto_lsassy_dump; pub use machine_account_quota::auto_machine_account_quota; pub use mssql::auto_mssql_detection; diff --git a/ares-cli/src/orchestrator/automation/pth_spray.rs b/ares-cli/src/orchestrator/automation/pth_spray.rs index 687a5c5c..9d8cd172 100644 --- a/ares-cli/src/orchestrator/automation/pth_spray.rs +++ b/ares-cli/src/orchestrator/automation/pth_spray.rs @@ -634,11 +634,11 @@ mod tests { state.hashes.push(make_ntlm_hash( "admin", "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret - "sevenkingdoms.local", + "contoso.local", )); state .hosts - .push(make_smb_host("10.1.2.254", "braavos.essos.local", false)); + .push(make_smb_host("192.168.58.254", "dc01.fabrikam.local", false)); let work = collect_pth_work(&state).unwrap(); assert!(work.is_empty()); @@ -648,18 +648,18 @@ mod tests { fn collect_filters_machine_and_krbtgt_hashes() { let mut state = StateInner::new("test".into()); state.hashes.push(make_ntlm_hash( - "WINTERFELL$", + "DC01$", "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret - "north.sevenkingdoms.local", + "child.contoso.local", )); state.hashes.push(make_ntlm_hash( "krbtgt", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", // pragma: allowlist secret - "north.sevenkingdoms.local", + "child.contoso.local", )); state.hosts.push(make_smb_host( "192.168.58.11", - "srv01.north.sevenkingdoms.local", + "srv01.child.contoso.local", false, )); diff --git a/ares-cli/src/orchestrator/automation_spawner.rs b/ares-cli/src/orchestrator/automation_spawner.rs index c1ccda14..2c2beb37 100644 --- a/ares-cli/src/orchestrator/automation_spawner.rs +++ b/ares-cli/src/orchestrator/automation_spawner.rs @@ -78,7 +78,6 @@ pub(crate) fn spawn_automation_tasks( spawn_auto!(auto_petitpotam_unauth); spawn_auto!(auto_winrm_lateral); spawn_auto!(auto_group_enumeration); - spawn_auto!(auto_localuser_spray); spawn_auto!(auto_krbrelayup); spawn_auto!(auto_searchconnector_coercion); spawn_auto!(auto_lsassy_dump); diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index e07ddc4d..3f164d9b 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -532,7 +532,7 @@ async fn run_inner() -> Result<()> { let cost_handle = spawn_cost_summary(queue.clone(), config.clone(), shutdown_rx.clone()); // Candidate-domain probe worker — verifies hostname-inferred domains - // (e.g. `corp.example.com` derived from `host.corp.example.com`) via + // (e.g. `child.contoso.local` derived from `host.child.contoso.local`) via // `_ldap._tcp.dc._msdcs.` SRV lookups before promoting them. let probe_ctx = state::domain_probe::DomainProbeContext { state: shared_state.clone(), diff --git a/ares-cli/src/orchestrator/output_extraction/hashes.rs b/ares-cli/src/orchestrator/output_extraction/hashes.rs index 83a930ff..539086ec 100644 --- a/ares-cli/src/orchestrator/output_extraction/hashes.rs +++ b/ares-cli/src/orchestrator/output_extraction/hashes.rs @@ -31,7 +31,7 @@ static RE_NTLM_CONTINUATION: LazyLock = // AES256 trust/account key from secretsdump: // DOMAIN\\user:aes256-cts-hmac-sha1-96: -// domain.local/user:aes256-cts-hmac-sha1-96: +// contoso.local/user:aes256-cts-hmac-sha1-96: // user:aes256-cts-hmac-sha1-96: static RE_AES256_KEY: LazyLock = LazyLock::new(|| { Regex::new(r"(?:[^\\/\s:]+[\\/])?([^:\s\\/]+):aes256-cts-hmac-sha1-96:([a-fA-F0-9]+)").unwrap() @@ -253,7 +253,13 @@ pub fn extract_hashes(output: &str, default_domain: &str) -> Vec { // 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) { + let has_domain_dump_evidence = inferred_domain.is_some() + || (!detected_ambiguous + && !default_netbios.is_empty() + && detected_netbios + .as_deref() + .is_some_and(|nb| nb == default_netbios)); + let domain = if is_well_known_local_sam(username, rid, has_domain_dump_evidence) { String::new() } else if let Some(ref inferred) = inferred_domain { inferred.clone() @@ -288,18 +294,28 @@ pub fn extract_hashes(output: &str, default_domain: &str) -> Vec { /// Mirror of `parsers::secrets::is_local_sam_account` for the regex fallback. /// We don't track section context here (the fallback runs over arbitrary tool -/// output, not just secretsdump), so attribution is purely name/RID-based. -fn is_well_known_local_sam(username: &str, rid: &str) -> bool { +/// output, not just secretsdump), so we combine name/RID heuristics with +/// same-output evidence that the dump is really NTDS/domain material. +fn is_well_known_local_sam(username: &str, rid: &str, has_domain_dump_evidence: bool) -> bool { + if username.starts_with('$') || username.starts_with("_SC_") || username.starts_with("NL$") { + return true; + } if matches!(rid, "500" | "501" | "503" | "504") { let name = username.to_ascii_lowercase(); if matches!( name.as_str(), "administrator" | "guest" | "defaultaccount" | "wdagutilityaccount" ) { + // If the same output also proves we're parsing an NTDS/domain dump + // (prefixed AD rows or a matching $MACHINE.ACC marker), these + // unprefixed built-ins are domain principals, not local SAM rows. + if has_domain_dump_evidence { + return false; + } return true; } } - username.starts_with('$') || username.starts_with("_SC_") || username.starts_with("NL$") + false } /// Hashcat cracked TGS: $krb5tgs$23$*user$DOMAIN$spn*$hash:plaintext @@ -570,7 +586,14 @@ CHILD\\DC01$:aes256-cts-hmac-sha1-96:5839387800000000000000000000000000000000000 krbtgt:502:aad3b435b51404eeaad3b435b51404ee:8c6d94541dbc90f085e86828428d2cbf:::"; let hashes = extract_hashes(output, "child.contoso.local"); assert!(hashes.iter().any(|h| h.username == "krbtgt")); - assert!(hashes.iter().any(|h| h.username == "Administrator")); + let admin = hashes + .iter() + .find(|h| h.username == "Administrator") + .expect("Administrator should be extracted"); + assert_eq!( + admin.domain, "child.contoso.local", + "Administrator should inherit the target domain when the dump evidence matches it" + ); } #[test] @@ -638,6 +661,10 @@ CHILD.CONTOSO.LOCAL\\bob:1106:aad3b435b51404eeaad3b435b51404ee:d977b98c6c9282c5c .iter() .find(|h| h.username == "krbtgt") .expect("krbtgt should be extracted"); + let admin = hashes + .iter() + .find(|h| h.username == "Administrator") + .expect("Administrator 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" @@ -646,6 +673,10 @@ CHILD.CONTOSO.LOCAL\\bob:1106:aad3b435b51404eeaad3b435b51404ee:d977b98c6c9282c5c krbtgt.domain, "fabrikam.local", "krbtgt must NOT be tagged with the task's intent domain when the dump is from another realm" ); + assert_eq!( + admin.domain, "CHILD.CONTOSO.LOCAL", + "Administrator should inherit the same proven dump realm as krbtgt" + ); } #[test] diff --git a/ares-cli/src/orchestrator/state/domain_probe/worker.rs b/ares-cli/src/orchestrator/state/domain_probe/worker.rs index 6cc76572..e14bf718 100644 --- a/ares-cli/src/orchestrator/state/domain_probe/worker.rs +++ b/ares-cli/src/orchestrator/state/domain_probe/worker.rs @@ -198,13 +198,13 @@ mod tests { state .publish_candidate_domain( &q, - "fake.example.com", + "fake.contoso.local", DomainEvidence::HostnameInference, None, ) .await .unwrap(); - let prober = StubProber::new(vec![("fake.example.com", ProbeOutcome::Rejected("nx"))]); + let prober = StubProber::new(vec![("fake.contoso.local", ProbeOutcome::Rejected("nx"))]); drain_with_mock(&state, &q, &prober).await; let s = state.inner.read().await; assert!(s.domains.is_empty()); @@ -218,7 +218,7 @@ mod tests { state .publish_candidate_domain( &q, - "transient.example.com", + "transient.contoso.local", DomainEvidence::HostnameInference, None, ) @@ -228,7 +228,7 @@ mod tests { drain_with_mock(&state, &q, &prober).await; let s = state.inner.read().await; assert!(s.domains.is_empty()); - let cand = s.candidate_domains.get("transient.example.com").unwrap(); + let cand = s.candidate_domains.get("transient.contoso.local").unwrap(); assert!(cand.probed); } @@ -239,7 +239,7 @@ mod tests { state .publish_candidate_domain( &q, - "transient.example.com", + "transient.contoso.local", DomainEvidence::HostnameInference, None, ) diff --git a/ares-cli/src/orchestrator/state/inner.rs b/ares-cli/src/orchestrator/state/inner.rs index c268de0e..646609d8 100644 --- a/ares-cli/src/orchestrator/state/inner.rs +++ b/ares-cli/src/orchestrator/state/inner.rs @@ -983,7 +983,6 @@ mod tests { DEDUP_PETITPOTAM_UNAUTH, DEDUP_WINRM_LATERAL, DEDUP_GROUP_ENUMERATION, - DEDUP_LOCALUSER_SPRAY, DEDUP_KRBRELAYUP, DEDUP_SEARCHCONNECTOR, DEDUP_LSASSY_DUMP, @@ -1109,14 +1108,14 @@ mod tests { let mut state = StateInner::new("op-1".into()); state .netbios_to_fqdn - .insert("north".into(), "north.sevenkingdoms.local".into()); + .insert("child".into(), "child.contoso.local".into()); state .dominated_domains - .insert("north.sevenkingdoms.local".into()); + .insert("child.contoso.local".into()); - assert!(state.is_domain_dominated("north.sevenkingdoms.local")); - assert!(state.is_domain_dominated("NORTH")); - assert!(!state.is_domain_dominated("sevenkingdoms.local")); + assert!(state.is_domain_dominated("child.contoso.local")); + assert!(state.is_domain_dominated("CHILD")); + assert!(!state.is_domain_dominated("contoso.local")); assert!(!state.is_domain_dominated("")); } diff --git a/ares-cli/src/orchestrator/state/mod.rs b/ares-cli/src/orchestrator/state/mod.rs index ed389d54..143b9fad 100644 --- a/ares-cli/src/orchestrator/state/mod.rs +++ b/ares-cli/src/orchestrator/state/mod.rs @@ -61,7 +61,6 @@ pub const DEDUP_DFS_COERCION: &str = "dfs_coercion"; pub const DEDUP_PETITPOTAM_UNAUTH: &str = "petitpotam_unauth"; pub const DEDUP_WINRM_LATERAL: &str = "winrm_lateral"; pub const DEDUP_GROUP_ENUMERATION: &str = "group_enumeration"; -pub const DEDUP_LOCALUSER_SPRAY: &str = "localuser_spray"; pub const DEDUP_KRBRELAYUP: &str = "krbrelayup"; pub const DEDUP_SEARCHCONNECTOR: &str = "searchconnector"; pub const DEDUP_LSASSY_DUMP: &str = "lsassy_dump"; @@ -156,7 +155,6 @@ const ALL_DEDUP_SETS: &[&str] = &[ DEDUP_PETITPOTAM_UNAUTH, DEDUP_WINRM_LATERAL, DEDUP_GROUP_ENUMERATION, - DEDUP_LOCALUSER_SPRAY, DEDUP_KRBRELAYUP, DEDUP_SEARCHCONNECTOR, DEDUP_LSASSY_DUMP, @@ -211,7 +209,6 @@ mod tests { DEDUP_PETITPOTAM_UNAUTH, DEDUP_WINRM_LATERAL, DEDUP_GROUP_ENUMERATION, - DEDUP_LOCALUSER_SPRAY, DEDUP_KRBRELAYUP, DEDUP_SEARCHCONNECTOR, DEDUP_LSASSY_DUMP, diff --git a/ares-cli/src/orchestrator/state/persistence.rs b/ares-cli/src/orchestrator/state/persistence.rs index 1b085941..c6c26ae6 100644 --- a/ares-cli/src/orchestrator/state/persistence.rs +++ b/ares-cli/src/orchestrator/state/persistence.rs @@ -638,14 +638,14 @@ mod tests { state .publish_candidate_domain( &q, - "transient.example.com", + "transient.contoso.local", ares_core::models::DomainEvidence::HostnameInference, Some("192.168.58.50".to_string()), ) .await .unwrap(); state - .mark_candidate_probed(&q, "transient.example.com") + .mark_candidate_probed(&q, "transient.contoso.local") .await .unwrap(); @@ -653,7 +653,7 @@ mod tests { state2.load_from_redis(&q).await.unwrap(); let s = state2.inner.read().await; - let candidate = s.candidate_domains.get("transient.example.com").unwrap(); + let candidate = s.candidate_domains.get("transient.contoso.local").unwrap(); assert!(candidate.probed); assert_eq!(candidate.source_host_ip.as_deref(), Some("192.168.58.50")); } diff --git a/ares-cli/src/orchestrator/state/publishing/credentials.rs b/ares-cli/src/orchestrator/state/publishing/credentials.rs index 3a8e5a5c..d2651ef0 100644 --- a/ares-cli/src/orchestrator/state/publishing/credentials.rs +++ b/ares-cli/src/orchestrator/state/publishing/credentials.rs @@ -1009,8 +1009,8 @@ mod tests { // 33 chars — relay artifact let bad = make_hash( - "robb.stark", - "north.sevenkingdoms.local", + "jdoe", + "child.contoso.local", "NTLM", "aad3b435b51404eeaad3b435b51404ee0", ); // pragma: allowlist secret @@ -1018,8 +1018,8 @@ mod tests { // 8 chars — truncated capture let short = make_hash( - "robb.stark", - "north.sevenkingdoms.local", + "jdoe", + "child.contoso.local", "NTLM", "aabbccdd", ); diff --git a/ares-cli/src/orchestrator/state/publishing/domains.rs b/ares-cli/src/orchestrator/state/publishing/domains.rs index a1d0f48a..d3914c23 100644 --- a/ares-cli/src/orchestrator/state/publishing/domains.rs +++ b/ares-cli/src/orchestrator/state/publishing/domains.rs @@ -295,7 +295,7 @@ mod tests { let outcome = state .publish_candidate_domain( &q, - "unknown.example.com", + "unknown.contoso.local", DomainEvidence::HostnameInference, Some("192.168.58.5".into()), ) @@ -304,7 +304,7 @@ mod tests { assert_eq!(outcome, DomainPublishOutcome::Held); let s = state.inner.read().await; assert!(s.domains.is_empty()); - assert!(s.candidate_domains.contains_key("unknown.example.com")); + assert!(s.candidate_domains.contains_key("unknown.contoso.local")); } #[tokio::test] @@ -472,14 +472,14 @@ mod tests { state .publish_candidate_domain( &q, - "transient.example.com", + "transient.contoso.local", DomainEvidence::HostnameInference, None, ) .await .unwrap(); state - .mark_candidate_probed(&q, "transient.example.com") + .mark_candidate_probed(&q, "transient.contoso.local") .await .unwrap(); { @@ -490,13 +490,13 @@ mod tests { let mut s = state.inner.write().await; let cand = s .candidate_domains - .get_mut("transient.example.com") + .get_mut("transient.contoso.local") .unwrap(); cand.last_probed_at = Some(Utc::now() - Duration::seconds(CANDIDATE_PROBE_RETRY_SECS + 1)); } let pending = state.pending_candidate_domains().await; assert_eq!(pending.len(), 1); - assert_eq!(pending[0].fqdn, "transient.example.com"); + assert_eq!(pending[0].fqdn, "transient.contoso.local"); } } diff --git a/ares-cli/src/orchestrator/strategy.rs b/ares-cli/src/orchestrator/strategy.rs index 347d795f..a0dc4d8f 100644 --- a/ares-cli/src/orchestrator/strategy.rs +++ b/ares-cli/src/orchestrator/strategy.rs @@ -309,7 +309,6 @@ fn fast_weights() -> HashMap { ("petitpotam_unauth", 4), ("winrm_lateral", 5), ("group_enumeration", 2), - ("localuser_spray", 4), ("krbrelayup", 5), ("searchconnector_coercion", 5), ("lsassy_dump", 3), @@ -389,7 +388,6 @@ fn comprehensive_weights() -> HashMap { ("pth_spray", 2), ("winrm_lateral", 2), ("rdp_lateral", 2), - ("localuser_spray", 2), // --- Tier 3: Recon, enumeration, coercion setup --- ("smb_signing_disabled", 3), ("share_coercion", 3), @@ -468,7 +466,6 @@ fn stealth_weights() -> HashMap { ("petitpotam_unauth", 5), ("winrm_lateral", 4), ("group_enumeration", 2), - ("localuser_spray", 7), ("krbrelayup", 4), ("searchconnector_coercion", 6), ("lsassy_dump", 5), @@ -769,7 +766,6 @@ mod tests { "petitpotam_unauth", "winrm_lateral", "group_enumeration", - "localuser_spray", "krbrelayup", "searchconnector_coercion", "lsassy_dump", diff --git a/ares-tools/src/parsers/mod.rs b/ares-tools/src/parsers/mod.rs index 95e53ffa..f1445202 100644 --- a/ares-tools/src/parsers/mod.rs +++ b/ares-tools/src/parsers/mod.rs @@ -105,7 +105,7 @@ pub fn parse_tool_output(tool_name: &str, output: &str, params: &Value) -> Value } "raise_child" => { // raiseChild.py performs the parent-domain NTDS dump in standard - // secretsdump format (lines like "domain.local/user:RID:LM:NT:::" + // secretsdump format (lines like "contoso.local/user:RID:LM:NT:::" // or "DOMAIN\\user:RID:..."). Derive parent FQDN from child_domain // and pass as target_domain so bare-username lines and NetBIOS // prefixes get attributed to the parent forest root. diff --git a/ares-tools/src/parsers/secrets.rs b/ares-tools/src/parsers/secrets.rs index f57c8360..82ca3714 100644 --- a/ares-tools/src/parsers/secrets.rs +++ b/ares-tools/src/parsers/secrets.rs @@ -58,7 +58,7 @@ pub fn parse_secretsdump(output: &str, params: &Value) -> (Vec, Vec" or - // "domain.local/user:aes256-cts-hmac-sha1-96:" + // "contoso.local/user:aes256-cts-hmac-sha1-96:" let mut aes_keys: std::collections::HashMap = std::collections::HashMap::new(); for raw_line in output.lines() { let line = strip_nxc_framing(raw_line).trim(); @@ -207,6 +207,19 @@ fn is_local_sam_account(raw_user: &str, rid: &str, section: DumpSection) -> bool return true; } let name = raw_user.to_ascii_lowercase(); + // LSA pseudo-rows from `[*] Dumping LSA Secrets` are always machine-local, + // even if a prior NTDS marker left us in `DumpSection::Domain`. + if raw_user.starts_with('$') || raw_user.starts_with("_SC_") || raw_user.starts_with("NL$") { + return true; + } + // In an explicit NTDS/domain section, unprefixed rows are AD accounts. + // This is the load-bearing distinction for `Administrator:500` from + // `-just-dc-ntlm` / `nxc smb --ntds` output: treating RID 500 as "always + // local SAM" drops the realm and breaks child->parent trust escalation, + // which requires a same-domain Administrator hash. + if section == DumpSection::Domain { + return false; + } // RID-based: 500/501/503/504 are well-known built-ins. Don't include 502 // (krbtgt) — it's a domain account that happens to share a fixed RID. if matches!(rid, "500" | "501" | "503" | "504") @@ -217,10 +230,6 @@ fn is_local_sam_account(raw_user: &str, rid: &str, section: DumpSection) -> bool { return true; } - // LSA pseudo-rows from `[*] Dumping LSA Secrets` — `$MACHINE.ACC`, etc. - if raw_user.starts_with('$') || raw_user.starts_with("_SC_") || raw_user.starts_with("NL$") { - return true; - } // Safe default for unmarked dumps: treat as local SAM. krbtgt and machine // accounts (`ENDS_WITH$`) are never local — let those fall through to the // target_domain branch. @@ -418,16 +427,19 @@ svc_sql:1001:aad3b435b51404eeaad3b435b51404ee:abcdef1234567890abcdef1234567890:: [*] Searching for pekList, be patient [*] PEK # 0 found and decrypted: abcdef [*] Reading and decrypting hashes from /tmp/ntds.dit +Administrator:500:aad3b435b51404eeaad3b435b51404ee:22222222222222222222222222222222::: alice:1103:aad3b435b51404eeaad3b435b51404ee:abcdef1234567890abcdef1234567890::: WIN-XYZ$:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef::: [*] Kerberos keys grabbed"; let params = json!({"target_domain": "contoso.local"}); let (hashes, _) = parse_secretsdump(output, ¶ms); - assert_eq!(hashes.len(), 2); - assert_eq!(hashes[0]["username"], "alice"); + assert_eq!(hashes.len(), 3); + assert_eq!(hashes[0]["username"], "Administrator"); assert_eq!(hashes[0]["domain"], "contoso.local"); - assert_eq!(hashes[1]["username"], "WIN-XYZ$"); + assert_eq!(hashes[1]["username"], "alice"); assert_eq!(hashes[1]["domain"], "contoso.local"); + assert_eq!(hashes[2]["username"], "WIN-XYZ$"); + assert_eq!(hashes[2]["domain"], "contoso.local"); } #[test] diff --git a/docs/goad-checklist.md b/docs/goad-checklist.md index 11f1ed7e..09b726ab 100644 --- a/docs/goad-checklist.md +++ b/docs/goad-checklist.md @@ -270,6 +270,9 @@ Comprehensive checklist for GOAD lab provisioning, user/group creation, vulnerab - [ ] ESC6 - EDITF_ATTRIBUTESUBJECTALTNAME2 flag on ESSOS-CA - [ ] ESC7 - ManageCA abuse via viserys.targaryen - [ ] ESC8 - NTLM relay to Web Enrollment (HTTP `/certsrv` on braavos + kingslanding) +- [ ] ESC8 (essos, primary) - relay to `http://braavos.essos.local/certsrv/certfnsh.asp`; coerce `meereen.essos.local` (PetitPotam / Coercer) into `ntlmrelayx --adcs --template DomainController`; obtain `MEEREEN$` PFX -> `certipy auth` -> DCSync `essos.local` +- [ ] ESC8 (sevenkingdoms, optional) - relay to `http://kingslanding/certsrv/certfnsh.asp`; coerce `winterfell` / `castelblack` -> `KINGSLANDING$` cert -> DA `sevenkingdoms.local` +- [ ] ESC8 caveats - Web Enrollment is enabled on both CAs; `DomainController` is published (no ESC4 prep needed); relay target is HTTP so SMB signing is irrelevant; the path is pure HTTP and does not depend on forest trust abuse - [ ] ESC9 - UPN spoofing (missandei via GenericAll on khal.drogo) - [ ] ESC10 - Weak certificate mapping, Case 1 (StrongCertificateBindingEnforcement=0) - [ ] ESC10 - Weak certificate mapping, Case 2 (CertificateMappingMethods=0x04) From f20e771d7a32be384c818670dee58afd29711f0c Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 10:41:29 -0600 Subject: [PATCH 2/5] docs: document ec2 clean test cycle and remove redundant test script **Added:** - Added detailed instructions and example commands for running a full clean test cycle on EC2, including environment setup, deployment, and Redis wipe to README.md and AGENTS.md - Provided warnings about `ulimit` and zig linker limitations in documentation **Removed:** - Removed `test.sh` script as its functionality is now fully documented in markdown guides and is redundant --- AGENTS.md | 12 +++++++++++- README.md | 29 +++++++++++++++++++++++++++++ test.sh | 33 --------------------------------- 3 files changed, 40 insertions(+), 34 deletions(-) delete mode 100755 test.sh diff --git a/AGENTS.md b/AGENTS.md index c2dc0abd..79983ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,19 @@ task remote:rust:deploy:quick task remote:check task remote:rust:deploy:config -# Deploy to EC2 +# Deploy to EC2 (requires S3_BUCKET env var for binary staging) task ec2:deploy task ec2:deploy:config + +# EC2 full clean test cycle (mirrors K8s `red:multi:sync:align && red:multi`): +# ulimit -n 65536 # zig linker chokes on huge fd limits +# export S3_BUCKET=your-deploy-bucket +task ec2:stop EC2_NAME=kali-ares +task ec2:stop-op EC2_NAME=kali-ares LATEST=true +task -y ec2:deploy EC2_NAME=kali-ares +task ec2:exec EC2_NAME=kali-ares CMD="redis-cli FLUSHALL" +task ec2:start EC2_NAME=kali-ares +task -y red:ec2:multi TARGET=dreadgoad EC2_NAME=kali-ares BLUE_ENABLED=1 ``` After code changes, always deploy before testing remote behavior. Use `task remote:check` to verify sync. diff --git a/README.md b/README.md index 3e56b326..a37e5fc5 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,35 @@ task remote:check task remote:status ``` +#### EC2 Clean Test Cycle + +Full reset on an EC2 instance: stop workers and any running op, deploy +fresh binaries, wipe Redis, restart workers, then launch a new operation. +EC2 equivalent of the K8s `task -y red:multi:sync:align && task -y red:multi` +shortcut. + +`ec2:deploy` requires `S3_BUCKET` (binary staging bucket) — export it or +pass on each invocation. + +```bash +export S3_BUCKET=your-deploy-bucket + +EC2_NAME=kali-ares +TARGET=dreadgoad +BLUE_ENABLED=1 + +task ec2:stop EC2_NAME=$EC2_NAME # stop workers +task ec2:stop-op EC2_NAME=$EC2_NAME LATEST=true # stop running op +task -y ec2:deploy EC2_NAME=$EC2_NAME # cross-compile + ship binary +task ec2:exec EC2_NAME=$EC2_NAME CMD="redis-cli FLUSHALL" # wipe Redis +task ec2:start EC2_NAME=$EC2_NAME # start workers +task -y red:ec2:multi TARGET=$TARGET EC2_NAME=$EC2_NAME BLUE_ENABLED=$BLUE_ENABLED +``` + +If the host shell raises `nofile` above ~65k (some tuned shells go to +1048576), the zig 0.16 linker invoked by cross-compilation will fail. +Clamp before running `ec2:deploy`: `ulimit -n 65536`. + ## Configuration ### Config File diff --git a/test.sh b/test.sh deleted file mode 100755 index 1fe791ac..00000000 --- a/test.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# zig 0.16's linker chokes on absurdly high FD limits (e.g. 1048576 from a -# tuned shell) when the kernel's per-process cap is much lower. Clamp to a -# concrete value that zig is happy with — task's internal shell (mvdan/sh) -# doesn't implement `ulimit` so we have to set this here, in real bash. -ulimit -n 65536 || ulimit -n 10240 || true - -EC2_NAME="${EC2_NAME:-kali-ares}" -TARGET="${TARGET:-dreadgoad}" -BLUE_ENABLED="${BLUE_ENABLED:-1}" -export S3_BUCKET=dread-infra-alpha-operator-range-staging-us-west-1 - -echo "=== Stopping workers + any running operation ===" -task ec2:stop EC2_NAME="${EC2_NAME}" 2>/dev/null || true -task ec2:stop-op EC2_NAME="${EC2_NAME}" LATEST=true 2>/dev/null || true - -echo "" -echo "=== Deploying binaries to ${EC2_NAME} ===" -task -y ec2:deploy EC2_NAME="${EC2_NAME}" - -echo "" -echo "=== Wiping Redis ===" -task ec2:exec EC2_NAME="${EC2_NAME}" CMD="redis-cli FLUSHALL" - -echo "" -echo "=== Starting workers on fresh Redis with new binary ===" -task ec2:start EC2_NAME="${EC2_NAME}" - -echo "" -echo "=== Launching operation against ${TARGET} (blue=${BLUE_ENABLED}) ===" -task -y red:ec2:multi TARGET="${TARGET}" EC2_NAME="${EC2_NAME}" BLUE_ENABLED="${BLUE_ENABLED}" From e345526034a6d105055a9c917f563f43f62fdf62 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 10:44:54 -0600 Subject: [PATCH 3/5] docs: update ec2 clean test cycle example for clarity and variable usage **Changed:** - Expanded EC2 test cycle example to set and reuse shell variables for EC2_NAME, TARGET, and BLUE_ENABLED to improve clarity and reduce repetition - Moved shell setup commands (ulimit, export S3_BUCKET) out of comments and into executable lines for better usability - Updated all task command examples to reference shell variables instead of hardcoded values, making instructions more adaptable --- AGENTS.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79983ff2..b1a3ad47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,14 +58,19 @@ task ec2:deploy task ec2:deploy:config # EC2 full clean test cycle (mirrors K8s `red:multi:sync:align && red:multi`): -# ulimit -n 65536 # zig linker chokes on huge fd limits -# export S3_BUCKET=your-deploy-bucket -task ec2:stop EC2_NAME=kali-ares -task ec2:stop-op EC2_NAME=kali-ares LATEST=true -task -y ec2:deploy EC2_NAME=kali-ares -task ec2:exec EC2_NAME=kali-ares CMD="redis-cli FLUSHALL" -task ec2:start EC2_NAME=kali-ares -task -y red:ec2:multi TARGET=dreadgoad EC2_NAME=kali-ares BLUE_ENABLED=1 +ulimit -n 65536 # zig linker chokes on huge fd limits +export S3_BUCKET=your-deploy-bucket + +EC2_NAME=kali-ares +TARGET=dreadgoad +BLUE_ENABLED=1 + +task ec2:stop EC2_NAME=$EC2_NAME +task ec2:stop-op EC2_NAME=$EC2_NAME LATEST=true +task -y ec2:deploy EC2_NAME=$EC2_NAME +task ec2:exec EC2_NAME=$EC2_NAME CMD="redis-cli FLUSHALL" +task ec2:start EC2_NAME=$EC2_NAME +task -y red:ec2:multi TARGET=$TARGET EC2_NAME=$EC2_NAME BLUE_ENABLED=$BLUE_ENABLED ``` After code changes, always deploy before testing remote behavior. Use `task remote:check` to verify sync. From f5c9c960f2349b2fa8baafec00d31c4493536c3d Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 11:03:06 -0600 Subject: [PATCH 4/5] fix: detect raise_child failures on zero exit status and clear dedup for retry **Added:** - Added detection of common failure markers in raise_child output even when exit status is zero; raise_child now marks result as failed if such markers are found - Added unit tests to verify detection of SessionError and KDC_ERR_ in raise_child output, as well as correct success handling without such markers **Changed:** - Updated auto_trust_follow to clear deduplication state and allow retry when raise_child reports error or fails to dispatch, improving reliability of trust-follow logic --- ares-cli/src/orchestrator/automation/trust.rs | 18 +++++- ares-tools/src/privesc/delegation.rs | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index d9b6e8e3..16b0dce8 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -1130,12 +1130,24 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: let parent_domain_bg = parent_domain.clone(); let child_domain_bg = child_domain.clone(); let vuln_id_bg = vuln_id.clone(); + let key_bg = key.clone(); tokio::spawn(async move { let result = dispatcher_bg .llm_runner .tool_dispatcher() .dispatch_tool("privesc", &task_id, &call) .await; + let clear_dedup = || async { + dispatcher_bg + .state + .write() + .await + .unmark_processed(DEDUP_TRUST_FOLLOW, &key_bg); + let _ = dispatcher_bg + .state + .unpersist_dedup(&dispatcher_bg.queue, DEDUP_TRUST_FOLLOW, &key_bg) + .await; + }; match result { Ok(exec_result) => { if let Some(err) = exec_result.error.as_ref() { @@ -1153,8 +1165,9 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: child_domain = %child_domain_bg, parent_domain = %parent_domain_bg, output_tail = %tail, - "raise_child returned error" + "raise_child returned error — clearing dedup for retry" ); + clear_dedup().await; return; } // Verify parent compromise — only mark exploited @@ -1245,8 +1258,9 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: err = %e, child_domain = %child_domain_bg, parent_domain = %parent_domain_bg, - "raise_child dispatch errored" + "raise_child dispatch errored — clearing dedup for retry" ); + clear_dedup().await; } } }); diff --git a/ares-tools/src/privesc/delegation.rs b/ares-tools/src/privesc/delegation.rs index 48597d8d..313d8711 100644 --- a/ares-tools/src/privesc/delegation.rs +++ b/ares-tools/src/privesc/delegation.rs @@ -241,7 +241,28 @@ pub async fn raise_child(args: &Value) -> Result { } // raiseChild performs multiple secretsdumps internally — needs extra time - cmd.timeout_secs(300).execute().await + let mut output = cmd.timeout_secs(300).execute().await?; + if output.success { + if let Some(failure_line) = detect_raise_child_failure(&output.combined_raw()) { + output.success = false; + if !output.stderr.is_empty() && !output.stderr.ends_with('\n') { + output.stderr.push('\n'); + } + output.stderr.push_str(&format!( + "raiseChild reported failure despite zero exit status: {failure_line}" + )); + } + } + Ok(output) +} + +fn detect_raise_child_failure(output: &str) -> Option<&str> { + output.lines().find(|line| { + let trimmed = line.trim(); + trimmed.contains("SessionError:") + || trimmed.contains("KDC_ERR_") + || trimmed.contains("Traceback (most recent call last):") + }) } #[cfg(test)] @@ -874,4 +895,42 @@ mod tests { }); assert!(raise_child(&args).await.is_ok()); } + + #[tokio::test] + async fn raise_child_detects_sessionerror_on_zero_exit() { + mock::push(crate::ToolOutput { + stdout: "Impacket v0.13.0\n[-] Kerberos SessionError: KDC_ERR_TGT_REVOKED(TGT has been revoked)\n" + .to_string(), + stderr: String::new(), + exit_code: Some(0), + success: true, + }); + let args = json!({ + "child_domain": "child.contoso.local", + "username": "Administrator", + "hash": "31d6cfe0d16ae931b73c59d7e0c089c0" + }); + let output = raise_child(&args).await.unwrap(); + assert!(!output.success); + assert_eq!(output.exit_code, Some(0)); + assert!(output.stderr.contains("KDC_ERR_TGT_REVOKED")); + } + + #[tokio::test] + async fn raise_child_keeps_success_without_failure_markers() { + mock::push(crate::ToolOutput { + stdout: "Impacket v0.13.0\n[*] Success path\n".to_string(), + stderr: String::new(), + exit_code: Some(0), + success: true, + }); + let args = json!({ + "child_domain": "child.contoso.local", + "username": "Administrator", + "hash": "31d6cfe0d16ae931b73c59d7e0c089c0" + }); + let output = raise_child(&args).await.unwrap(); + assert!(output.success); + assert!(output.stderr.is_empty()); + } } From f56b7b9e6958d6e7d27f17729c8b021d3277050e Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Sat, 16 May 2026 12:53:24 -0600 Subject: [PATCH 5/5] style: reformat test code for improved readability and consistency **Changed:** - Reformatted test code to reduce unnecessary line breaks and improve code readability in test modules for credential_expansion.rs, pth_spray.rs, inner.rs, and publishing/credentials.rs - Updated multi-line function calls and chained method calls to use more concise, consistent formatting in test setups --- .../src/orchestrator/automation/credential_expansion.rs | 6 ++---- ares-cli/src/orchestrator/automation/pth_spray.rs | 8 +++++--- ares-cli/src/orchestrator/state/inner.rs | 4 +--- ares-cli/src/orchestrator/state/publishing/credentials.rs | 7 +------ 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/credential_expansion.rs b/ares-cli/src/orchestrator/automation/credential_expansion.rs index cd396377..54ce1405 100644 --- a/ares-cli/src/orchestrator/automation/credential_expansion.rs +++ b/ares-cli/src/orchestrator/automation/credential_expansion.rs @@ -1471,10 +1471,8 @@ mod tests { let mut s = StateInner::new("op".into()); s.netbios_to_fqdn .insert("child".into(), "child.contoso.local".into()); - s.hosts.push(make_host( - "dc01.child.contoso.local", - "192.168.58.10", - )); + s.hosts + .push(make_host("dc01.child.contoso.local", "192.168.58.10")); s.hashes.push(make_ntlm_hash("alice", "aaaaaaaa", "CHILD")); let work = select_hash_expansion_work(&s, 10); diff --git a/ares-cli/src/orchestrator/automation/pth_spray.rs b/ares-cli/src/orchestrator/automation/pth_spray.rs index 9d8cd172..9d6c858a 100644 --- a/ares-cli/src/orchestrator/automation/pth_spray.rs +++ b/ares-cli/src/orchestrator/automation/pth_spray.rs @@ -636,9 +636,11 @@ mod tests { "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret "contoso.local", )); - state - .hosts - .push(make_smb_host("192.168.58.254", "dc01.fabrikam.local", false)); + state.hosts.push(make_smb_host( + "192.168.58.254", + "dc01.fabrikam.local", + false, + )); let work = collect_pth_work(&state).unwrap(); assert!(work.is_empty()); diff --git a/ares-cli/src/orchestrator/state/inner.rs b/ares-cli/src/orchestrator/state/inner.rs index 646609d8..845ffef8 100644 --- a/ares-cli/src/orchestrator/state/inner.rs +++ b/ares-cli/src/orchestrator/state/inner.rs @@ -1109,9 +1109,7 @@ mod tests { state .netbios_to_fqdn .insert("child".into(), "child.contoso.local".into()); - state - .dominated_domains - .insert("child.contoso.local".into()); + state.dominated_domains.insert("child.contoso.local".into()); assert!(state.is_domain_dominated("child.contoso.local")); assert!(state.is_domain_dominated("CHILD")); diff --git a/ares-cli/src/orchestrator/state/publishing/credentials.rs b/ares-cli/src/orchestrator/state/publishing/credentials.rs index d2651ef0..d1c69c31 100644 --- a/ares-cli/src/orchestrator/state/publishing/credentials.rs +++ b/ares-cli/src/orchestrator/state/publishing/credentials.rs @@ -1017,12 +1017,7 @@ mod tests { assert!(!state.publish_hash(&q, bad).await.unwrap()); // 8 chars — truncated capture - let short = make_hash( - "jdoe", - "child.contoso.local", - "NTLM", - "aabbccdd", - ); + let short = make_hash("jdoe", "child.contoso.local", "NTLM", "aabbccdd"); assert!(!state.publish_hash(&q, short).await.unwrap()); let s = state.inner.read().await;