Skip to content
106 changes: 100 additions & 6 deletions ares-cli/src/ops/loot/format/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,32 @@ pub(super) fn print_loot_human(

if state.has_domain_admin || state.has_golden_ticket {
let mut lines = Vec::new();
let total_domains = domains.len();
if state.has_domain_admin {
lines.push("\u{2605} DOMAIN ADMIN ACHIEVED".to_string());
let da_count = achievements.values().filter(|a| a.has_da).count();
if total_domains > 0 {
lines.push(format!(
"\u{2605} DOMAIN ADMIN ACHIEVED ({da_count}/{total_domains} domains)"
));
} else {
lines.push("\u{2605} DOMAIN ADMIN ACHIEVED".to_string());
}
if let Some(path) = &state.domain_admin_path {
lines.push(format!(" path: {path}"));
}
}
if state.has_golden_ticket {
lines.push("\u{2605} GOLDEN TICKET OBTAINED".to_string());
let gt_count = achievements
.values()
.filter(|a| a.has_golden_ticket)
.count();
if total_domains > 0 {
lines.push(format!(
"\u{2605} GOLDEN TICKET OBTAINED ({gt_count}/{total_domains} domains)"
));
} else {
lines.push("\u{2605} GOLDEN TICKET OBTAINED".to_string());
}
}
let inner_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) + 2;
println!("\u{250c}{}\u{2510}", "\u{2500}".repeat(inner_width));
Expand Down Expand Up @@ -531,6 +549,35 @@ fn resolve_domain_fqdn(domain: &str, netbios_to_fqdn: &HashMap<String, String>)
lower
}

/// Defensive filter for domains that originated from a Windows workgroup or
/// auto-generated computer name rather than a real Kerberos realm.
///
/// Upstream parsers (`smb.rs`, `output_extraction::users`) drop these at
/// ingest, but old loot already in state may still carry them. Without this
/// filter, a stray `krbtgt@win-xxx.wgrp.local` row would flip the pseudo-domain
/// to "compromised" in the achievements rollup.
///
/// Heuristic operates on a single domain string (no `(name:...)` context here):
/// matches literal `WORKGROUP`/`MSHOME`, and the Windows default computer-name
/// prefix `WIN-` followed by 11 alphanumerics as the first label.
fn looks_like_workgroup_pseudo_domain(domain: &str) -> bool {
let domain = domain.trim().trim_end_matches('.');
if domain.is_empty() {
return false;
}
if domain.eq_ignore_ascii_case("WORKGROUP") || domain.eq_ignore_ascii_case("MSHOME") {
return true;
}
let first_label = domain.split('.').next().unwrap_or("");
if first_label.len() == 15 && first_label[..4].eq_ignore_ascii_case("WIN-") {
let suffix = &first_label[4..];
if suffix.bytes().all(|b| b.is_ascii_alphanumeric()) {
return true;
}
}
false
}

/// Per-domain achievement status.
#[derive(Default)]
pub(super) struct DomainAchievement {
Expand All @@ -552,7 +599,7 @@ pub(super) fn build_domain_achievements(
for h in hashes {
if h.username.eq_ignore_ascii_case("krbtgt") {
let domain = resolve_domain_fqdn(&h.domain, &state.netbios_to_fqdn);
if domain.is_empty() {
if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) {
continue;
}
let entry = achievements.entry(domain).or_default();
Expand All @@ -569,7 +616,7 @@ pub(super) fn build_domain_achievements(
if let Some(domain_val) = vuln.details.get("domain") {
let raw = domain_val.as_str().unwrap_or("");
let domain = resolve_domain_fqdn(raw, &state.netbios_to_fqdn);
if !domain.is_empty() {
if !domain.is_empty() && !looks_like_workgroup_pseudo_domain(&domain) {
achievements.entry(domain).or_default().has_golden_ticket = true;
}
}
Expand All @@ -580,7 +627,7 @@ pub(super) fn build_domain_achievements(
for c in credentials {
if c.is_admin {
let domain = resolve_domain_fqdn(&c.domain, &state.netbios_to_fqdn);
if domain.is_empty() {
if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) {
continue;
}
let entry = achievements.entry(domain).or_default();
Expand All @@ -595,7 +642,7 @@ pub(super) fn build_domain_achievements(
for h in hashes {
if h.username.eq_ignore_ascii_case("administrator") {
let domain = resolve_domain_fqdn(&h.domain, &state.netbios_to_fqdn);
if domain.is_empty() {
if domain.is_empty() || looks_like_workgroup_pseudo_domain(&domain) {
continue;
}
let entry = achievements.entry(domain).or_default();
Expand Down Expand Up @@ -1086,6 +1133,53 @@ mod tests {
assert!(achievements.is_empty());
}

#[test]
fn build_domain_achievements_skips_workgroup_pseudo_domain() {
// Old loot row from before the upstream parsers learned to drop
// workgroup pseudo-domains: an attacker-box krbtgt entry tagged with
// the auto-generated WIN-XXX...wgrp.local string. The achievements
// rollup must NOT promote it to a "compromised domain" (DA).
let state = empty_state();
let hashes = vec![
make_hash("krbtgt", "win-abcdefghijk.wgrp.local", "ntlm"),
make_hash("Administrator", "WORKGROUP", "ntlm"),
// Real domain alongside the polluted ones must still come through.
make_hash("krbtgt", "contoso.local", "ntlm"),
];
let credentials = vec![make_credential("admin", "win-abcdefghijk.local", true)];

let achievements = build_domain_achievements(&state, &hashes, &credentials);
assert!(!achievements.contains_key("win-abcdefghijk.wgrp.local"));
assert!(!achievements.contains_key("workgroup"));
assert!(!achievements.contains_key("win-abcdefghijk.local"));
assert!(achievements.get("contoso.local").unwrap().has_da);
}

#[test]
fn looks_like_workgroup_pseudo_domain_detects_win_prefix() {
assert!(looks_like_workgroup_pseudo_domain(
"win-abcdefghijk.wgrp.local"
));
assert!(looks_like_workgroup_pseudo_domain("WIN-ABCDEFGHIJK.local"));
assert!(looks_like_workgroup_pseudo_domain("WORKGROUP"));
assert!(looks_like_workgroup_pseudo_domain("mshome"));
}

#[test]
fn looks_like_workgroup_pseudo_domain_passes_real_domain() {
assert!(!looks_like_workgroup_pseudo_domain("contoso.local"));
assert!(!looks_like_workgroup_pseudo_domain("child.contoso.local"));
assert!(!looks_like_workgroup_pseudo_domain("fabrikam.local"));
assert!(!looks_like_workgroup_pseudo_domain(""));
// Wrong length / suffix shape — don't over-trigger
assert!(!looks_like_workgroup_pseudo_domain("win-short.local"));
assert!(!looks_like_workgroup_pseudo_domain(
"win-toolongsuffix9.local"
));
// First label has WIN- prefix but has non-alphanumeric in the suffix
assert!(!looks_like_workgroup_pseudo_domain("win-abc!defghij.local"));
}

// Domain/forest structure computation (inline in print_loot_human)

/// Extract the domain/forest structure logic into a helper we can test.
Expand Down
22 changes: 19 additions & 3 deletions ares-cli/src/ops/loot/format/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use ares_core::models::SharedRedTeamState;

use super::display::build_domain_achievements;
use super::hosts::dedup_hosts;
use super::report_filter::{is_reportable_credential, is_reportable_hash};
use crate::dedup::{dedup_credentials, dedup_hashes, dedup_users};

pub(super) fn print_loot_json(
Expand All @@ -13,6 +14,7 @@ pub(super) fn print_loot_json(
domains: &[String],
) {
let unique_users = dedup_users(&state.all_users, &state.netbios_to_fqdn);
// dedup first (achievements need the full set), then filter for reporting.
let unique_creds = dedup_credentials(credentials);
let unique_hashes = dedup_hashes(hashes);
let merged_hosts = dedup_hosts(
Expand All @@ -21,9 +23,23 @@ pub(super) fn print_loot_json(
&state.domain_controllers,
);

// Build per-domain compromise status
// Build per-domain compromise status from the full deduped set — krbtgt
// hashes and admin entries credit DA/Golden-Ticket achievements even
// though they're filtered from the report's credentials/hashes lists.
let achievements = build_domain_achievements(state, &unique_hashes, &unique_creds);

// Drop noise (machine accounts, krbtgt, local-SAM built-ins,
// already-cracked hash blobs) before serializing the cred/hash lists
// consumed by external scoreboards.
let report_creds: Vec<&ares_core::models::Credential> = unique_creds
.iter()
.filter(|c| is_reportable_credential(c))
.collect();
let report_hashes: Vec<&ares_core::models::Hash> = unique_hashes
.iter()
.filter(|h| is_reportable_hash(h))
.collect();

// Build forest structure
let mut all_domains: Vec<String> = domains
.iter()
Expand Down Expand Up @@ -135,13 +151,13 @@ pub(super) fn print_loot_json(
"is_admin": u.is_admin,
"source": u.source,
})).collect::<Vec<_>>(),
"credentials": unique_creds.iter().map(|c| serde_json::json!({
"credentials": report_creds.iter().map(|c| serde_json::json!({
"username": c.username,
"password": c.password,
"domain": c.domain,
"is_admin": c.is_admin,
})).collect::<Vec<_>>(),
"hashes": unique_hashes.iter().map(|h| serde_json::json!({
"hashes": report_hashes.iter().map(|h| serde_json::json!({
"username": h.username,
"domain": h.domain,
"hash_type": h.hash_type,
Expand Down
1 change: 1 addition & 0 deletions ares-cli/src/ops/loot/format/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod display;
mod hosts;
mod json;
mod report_filter;

use ares_core::models::SharedRedTeamState;

Expand Down
Loading
Loading