Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,24 @@ 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

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.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions ares-cli/src/dedup/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ pub(crate) fn normalize_state_domains(
{
let mut valid_domains: HashSet<String> = HashSet::new();
let mut host_fqdns: HashSet<String> = 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());
}
Expand Down Expand Up @@ -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<String> = domains
.iter()
.filter_map(|d| {
Expand All @@ -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<String> = 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<String> = hosts
.iter()
.filter(|h| !h.hostname.is_empty() && h.hostname.contains('.'))
Expand All @@ -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))
});
}
}
43 changes: 43 additions & 0 deletions ares-cli/src/dedup/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> = 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
Expand Down Expand Up @@ -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<User> = 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
Expand Down
2 changes: 1 addition & 1 deletion ares-cli/src/orchestrator/automation/acl_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ pub async fn auto_acl_discovery(dispatcher: Arc<Dispatcher>, 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\"}"
),
});
Expand Down
12 changes: 5 additions & 7 deletions ares-cli/src/orchestrator/automation/credential_expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1470,16 +1470,14 @@ 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());
s.hosts.push(make_host(
"winterfell.north.sevenkingdoms.local",
"192.168.58.10",
));
s.hashes.push(make_ntlm_hash("alice", "aaaaaaaa", "NORTH"));
.insert("child".into(), "child.contoso.local".into());
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);
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"]);
}

Expand Down
4 changes: 2 additions & 2 deletions ares-cli/src/orchestrator/automation/cross_forest_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
Expand Down Expand Up @@ -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\"]}"
),
});
Expand Down
2 changes: 1 addition & 1 deletion ares-cli/src/orchestrator/automation/foreign_group_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
Expand Down
4 changes: 2 additions & 2 deletions ares-cli/src/orchestrator/automation/group_enumeration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<credential_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",
Expand All @@ -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\"]}"
),
});
Expand Down
Loading
Loading