From c1ef9cdbdd800e6dfe5a9d9cc35b0eaed030ef7c Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Fri, 15 May 2026 16:00:56 -0600 Subject: [PATCH] feat: infer domain ownership from has_domain_admin in ares domain_compromise **Added:** - Infer domain and DC host ownership directly from has_domain_admin signals in ares domain_compromise entries, even without krbtgt evidence - Include admin_users field in aresDomainCompromise struct and synthesize evidence in findings for improved attribution - New function domainsFromDomainAdminFindings to extract owned domains from domain_admin signals in findings - New function inferDCHostsFromDomainAdmin to infer DC host ownership via domain_admin signals - New function domainAdminEvidence to format admin user evidence for findings - Tests to verify domain and host inference from has_domain_admin with and without golden_ticket **Changed:** - Update JSON query and aresDomainCompromise struct to include admin_users - Synthesize new findings for domain_admin state in writeDomainCompromiseEntries, producing explicit domain_admin: targets - Update domain and host inference logic in verify.go to account for new domain_admin findings, including markHostInferred and markDomainInferred to use new evidence and reason fields - Adjust test coverage to validate new domain ownership inference paths and ensure uncompromised domains do not produce false positives **Removed:** - Legacy comments and test references to krbtgt-only inference paths, clarifying that domain_admin state is now authoritative for domain ownership signals --- cli/internal/scoreboard/transport_ares.go | 47 +++++++++--- cli/internal/scoreboard/verify.go | 66 +++++++++++++++-- cli/internal/scoreboard/verify_test.go | 89 +++++++++++++++++++---- 3 files changed, 168 insertions(+), 34 deletions(-) diff --git a/cli/internal/scoreboard/transport_ares.go b/cli/internal/scoreboard/transport_ares.go index 4e46d5c5..627d5df3 100644 --- a/cli/internal/scoreboard/transport_ares.go +++ b/cli/internal/scoreboard/transport_ares.go @@ -73,13 +73,15 @@ type aresHashEntry struct { // aresDomainCompromise mirrors entries in the ares loot JSON's // `domain_compromise[]` array. Ares filters krbtgt rows out of `hashes[]` by // design (see ares-cli `report_filter.rs`: krbtgt is "consumed internally by -// Golden Ticket detection rather than tracked as a cred objective"), so this -// metadata field is the only signal that survives the report boundary when a -// domain was compromised via krbtgt extraction without cracking a DA cleartext. +// Golden Ticket detection rather than tracked as a cred objective"), and some +// DA paths use built-in users that are not answer-key credential objectives. +// This metadata is therefore the authoritative report-boundary signal for +// domain ownership. type aresDomainCompromise struct { Domain string `json:"domain"` HasDomainAdmin bool `json:"has_domain_admin"` HasGoldenTicket bool `json:"has_golden_ticket"` + AdminUsers []string `json:"admin_users"` KrbtgtHashTypes []string `json:"krbtgt_hash_types"` } @@ -92,7 +94,7 @@ func (t *AresTransport) FetchReport(ctx context.Context) (string, error) { const jqFilter = `{operation_id, started_at,` + ` credentials: [.credentials[] | {username, password, domain, is_admin}],` + ` hashes: [.hashes[] | {username, domain, hash_value, hash_type, source}],` + - ` domain_compromise: [.domain_compromise[] | {domain, has_domain_admin, has_golden_ticket, krbtgt_hash_types}]}` + ` domain_compromise: [.domain_compromise[] | {domain, has_domain_admin, has_golden_ticket, admin_users, krbtgt_hash_types}]}` cmd := fmt.Sprintf("%s ops loot --latest --json | jq -c %s | gzip -c | base64 -w0", shellQuote(t.BinaryPath), shellQuote(jqFilter)) out, status, stderr, err := runSSMShell(ctx, t.Client, t.InstanceID, cmd) @@ -308,13 +310,11 @@ func writeExploitedEntries(b *strings.Builder, exploited []string, emitted map[s } // writeDomainCompromiseEntries synthesizes findings from domain_compromise[] -// metadata. Ares filters krbtgt rows out of hashes[] (see aresDomainCompromise -// doc), so without this step DreadGOAD's domainsFromKrbtgt never sees the -// krbtgt extraction and the "DOMAINS OWNED" panel stays empty for domains -// compromised solely via krbtgt (no cracked DA cleartext). The placeholder -// evidence is a 32-zero hex string so extractNTHash accepts it as an -// NT-hash-shaped signal; the actual hash value is intentionally not exposed -// in the loot JSON, so the evidence is symbolic. +// metadata. The explicit domain_admin signal credits real DA-level compromise +// even when the DA account is built-in (for example ESSOS\administrator) and +// therefore absent from the answer-key credential objectives. The krbtgt +// compatibility signal remains for older inference paths that key off an +// NT-hash-shaped krbtgt finding. func writeDomainCompromiseEntries(b *strings.Builder, entries []aresDomainCompromise, emitted map[string]bool) { const krbtgtSyntheticEvidence = "00000000000000000000000000000000" for _, dc := range entries { @@ -322,6 +322,17 @@ func writeDomainCompromiseEntries(b *strings.Builder, entries []aresDomainCompro if domain == "" { continue } + if dc.HasDomainAdmin { + signalID := "domain_admin:" + domain + if !emitted[signalID] { + emitted[signalID] = true + writeJSONLEntry(b, map[string]string{ + "target": signalID, + "evidence": domainAdminEvidence(dc), + "description": "ares: domain_compromise has_domain_admin", + }) + } + } if dc.HasDomainAdmin && len(dc.KrbtgtHashTypes) > 0 { writeJSONLEntry(b, map[string]string{ "target": "krbtgt@" + domain, @@ -342,3 +353,17 @@ func writeDomainCompromiseEntries(b *strings.Builder, entries []aresDomainCompro } } } + +func domainAdminEvidence(dc aresDomainCompromise) string { + var admins []string + for _, admin := range dc.AdminUsers { + admin = strings.TrimSpace(admin) + if admin != "" { + admins = append(admins, admin) + } + } + if len(admins) == 0 { + return "ares: domain_compromise has_domain_admin" + } + return "ares: domain_compromise has_domain_admin via " + strings.Join(admins, ",") +} diff --git a/cli/internal/scoreboard/verify.go b/cli/internal/scoreboard/verify.go index 0bc42941..1c50a0b7 100644 --- a/cli/internal/scoreboard/verify.go +++ b/cli/internal/scoreboard/verify.go @@ -28,6 +28,8 @@ var serviceToTechnique = map[string]string{ "ADCS": "", } +const domainAdminSignalPrefix = "domain_admin:" + // VerifyReport runs all findings in a report against an answer key and // returns the resulting status (matched objectives + group stats). func VerifyReport(report *Report, ak *AnswerKey) *StatusReport { @@ -61,6 +63,9 @@ func matchCredentials(report *Report, ak *AnswerKey, status *StatusReport, match matchedAny = true } if !matchedAny { + if isSyntheticFinding(finding.Target) { + continue + } status.UnmatchedFindings = append(status.UnmatchedFindings, *finding) } } @@ -103,9 +108,14 @@ func inferRemaining(report *Report, ak *AnswerKey, status *StatusReport, matched } inferredHostIDs := inferHosts(matchedObjs, hostObjs) inferredDomains := inferDomains(matchedObjs) + domainAdminSignals := domainsFromDomainAdminFindings(report.Findings) + for d := range domainAdminSignals { + inferredDomains[d] = true + } for d := range domainsFromKrbtgt(report.Findings) { inferredDomains[d] = true } + inferDCHostsFromDomainAdmin(hostObjs, domainAdminSignals, inferredHostIDs) hostInferenceInputs := append([]*Objective{}, matchedObjs...) for _, o := range hostObjs { @@ -125,16 +135,16 @@ func inferRemaining(report *Report, ak *AnswerKey, status *StatusReport, matched } switch obj.Group { case "hosts": - markHostInferred(obj, status, matched, matchedObjs, inferredHostIDs) + markHostInferred(obj, status, matched, matchedObjs, inferredHostIDs, domainAdminSignals) case "domains": - markDomainInferred(obj, status, matched, matchedObjs, inferredDomains) + markDomainInferred(obj, status, matched, matchedObjs, inferredDomains, domainAdminSignals) case "techniques": markTechniqueInferred(obj, status, matched, inferredTech) } } } -func markHostInferred(obj *Objective, status *StatusReport, matched map[string]bool, matchedObjs []*Objective, inferredHostIDs map[string]bool) { +func markHostInferred(obj *Objective, status *StatusReport, matched map[string]bool, matchedObjs []*Objective, inferredHostIDs map[string]bool, domainAdminSignals map[string]Finding) { if !inferredHostIDs[obj.ID] { return } @@ -150,10 +160,14 @@ func markHostInferred(obj *Objective, status *StatusReport, matched map[string]b break } } - ev, tech := "(inferred)", "" + ev, tech, reason := "(inferred)", "", "Inferred from admin credential" if via != "" { ev = fmt.Sprintf("admin credential: %s", via) tech = fmt.Sprintf("via %s", via) + } else if sig, ok := domainAdminSignals[strings.ToLower(obj.Domain)]; ok && strings.EqualFold(obj.HostType, "dc") { + ev = sig.Evidence + tech = "Ares domain_compromise" + reason = "Inferred from domain admin state" } status.Verified = append(status.Verified, VerifiedObjective{ ObjectiveID: obj.ID, @@ -162,14 +176,14 @@ func markHostInferred(obj *Objective, status *StatusReport, matched map[string]b Verified: true, AgentEvidence: ev, Technique: tech, - Reason: "Inferred from admin credential", + Reason: reason, }) if g := status.Groups["hosts"]; g != nil { g.Achieved++ } } -func markDomainInferred(obj *Objective, status *StatusReport, matched map[string]bool, matchedObjs []*Objective, inferredDomains map[string]bool) { +func markDomainInferred(obj *Objective, status *StatusReport, matched map[string]bool, matchedObjs []*Objective, inferredDomains map[string]bool, domainAdminSignals map[string]Finding) { if !inferredDomains[obj.Domain] { return } @@ -181,10 +195,14 @@ func markDomainInferred(obj *Objective, status *StatusReport, matched map[string break } } - ev, tech := "(inferred)", "" + ev, tech, reason := "(inferred)", "", "Inferred from DA credential" if daCred != "" { ev = fmt.Sprintf("DA credential: %s", daCred) tech = fmt.Sprintf("via %s", daCred) + } else if sig, ok := domainAdminSignals[strings.ToLower(obj.Domain)]; ok { + ev = sig.Evidence + tech = "Ares domain_compromise" + reason = "Inferred from domain admin state" } status.Verified = append(status.Verified, VerifiedObjective{ ObjectiveID: obj.ID, @@ -193,7 +211,7 @@ func markDomainInferred(obj *Objective, status *StatusReport, matched map[string Verified: true, AgentEvidence: ev, Technique: tech, - Reason: "Inferred from DA credential", + Reason: reason, }) if g := status.Groups["domains"]; g != nil { g.Achieved++ @@ -373,6 +391,38 @@ func domainsFromKrbtgt(findings []Finding) map[string]bool { return owned } +func domainsFromDomainAdminFindings(findings []Finding) map[string]Finding { + owned := map[string]Finding{} + for _, f := range findings { + target := strings.ToLower(strings.TrimSpace(f.Target)) + if !strings.HasPrefix(target, domainAdminSignalPrefix) { + continue + } + domain := strings.TrimSpace(strings.TrimPrefix(target, domainAdminSignalPrefix)) + if domain != "" { + owned[domain] = f + } + } + return owned +} + +func inferDCHostsFromDomainAdmin(hostObjs []*Objective, domainAdminSignals map[string]Finding, owned map[string]bool) { + for _, h := range hostObjs { + if !strings.EqualFold(h.HostType, "dc") { + continue + } + if _, ok := domainAdminSignals[strings.ToLower(h.Domain)]; ok { + owned[h.ID] = true + } + } +} + +func isSyntheticFinding(target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + return strings.HasPrefix(target, "tech:") || + strings.HasPrefix(target, domainAdminSignalPrefix) +} + func inferHosts(matched []*Objective, hostObjs []*Objective) map[string]bool { users := map[string]struct{}{} for _, o := range matched { diff --git a/cli/internal/scoreboard/verify_test.go b/cli/internal/scoreboard/verify_test.go index 1e06edfe..dc4d2ab2 100644 --- a/cli/internal/scoreboard/verify_test.go +++ b/cli/internal/scoreboard/verify_test.go @@ -178,11 +178,9 @@ func TestAnswerKeyAsrepCredentialsHaveHint(t *testing.T) { } } -// TestSynthesizeJSONLDomainCompromise covers the case where ares reports a -// domain as compromised via krbtgt extraction (essos.local on a real op) but -// the krbtgt row is filtered out of hashes[] by ares's report_filter. Without -// the domain_compromise[] synthesis path, the scoreboard's DOMAINS OWNED panel -// stays empty for these domains. +// TestSynthesizeJSONLDomainCompromise covers the report-boundary signals Ares +// emits when a domain is compromised. has_domain_admin owns the domain even +// without krbtgt, while has_golden_ticket is still credited separately. func TestSynthesizeJSONLDomainCompromise(t *testing.T) { loot := &aresLoot{ OperationID: "op-test", @@ -192,20 +190,20 @@ func TestSynthesizeJSONLDomainCompromise(t *testing.T) { Domain: "essos.local", HasDomainAdmin: true, HasGoldenTicket: true, + AdminUsers: []string{"administrator"}, KrbtgtHashTypes: []string{"ntlm"}, }, { - // Uncompromised domain: must NOT produce a krbtgt finding. + // Uncompromised domain: must NOT produce ownership or GT signals. Domain: "uncompromised.local", HasDomainAdmin: false, }, { - // DA without krbtgt (e.g. DA via cleartext only): no synthetic - // krbtgt finding either, since domainsFromKrbtgt is the wrong - // path for that. The matched-credential path covers it. - Domain: "creds-only.local", - HasDomainAdmin: true, - KrbtgtHashTypes: nil, + // DA without krbtgt still owns the domain; this is the ESC1/admin + // path where the old krbtgt-only inference missed ESSOS. + Domain: "admin-only.local", + HasDomainAdmin: true, + AdminUsers: []string{"administrator"}, }, }, } @@ -214,21 +212,82 @@ func TestSynthesizeJSONLDomainCompromise(t *testing.T) { owned := domainsFromKrbtgt(report.Findings) if !owned["essos.local"] { - t.Errorf("essos.local should be inferred as owned from domain_compromise, got %v", owned) + t.Errorf("essos.local should still produce the krbtgt compatibility signal, got %v", owned) } - if owned["uncompromised.local"] || owned["creds-only.local"] { + if owned["uncompromised.local"] || owned["admin-only.local"] { t.Errorf("only essos.local should be in krbtgt-inferred set, got %v", owned) } + ownedFromDA := domainsFromDomainAdminFindings(report.Findings) + if _, ok := ownedFromDA["essos.local"]; !ok { + t.Errorf("essos.local should be inferred from has_domain_admin, got %v", ownedFromDA) + } + if _, ok := ownedFromDA["admin-only.local"]; !ok { + t.Errorf("admin-only.local should be inferred from has_domain_admin without krbtgt, got %v", ownedFromDA) + } + if _, ok := ownedFromDA["uncompromised.local"]; ok { + t.Errorf("uncompromised.local should not be inferred from has_domain_admin, got %v", ownedFromDA) + } + tech := techniquesFromFindings(report.Findings) if !tech["golden_ticket-essos.local"] { t.Errorf("golden_ticket-essos.local technique should be synthesized, got %v", tech) } - if tech["golden_ticket-uncompromised.local"] || tech["golden_ticket-creds-only.local"] { + if tech["golden_ticket-uncompromised.local"] || tech["golden_ticket-admin-only.local"] { t.Errorf("only essos.local should produce a golden_ticket technique, got %v", tech) } } +func TestVerifyDomainCompromiseWithoutGoldenTicket(t *testing.T) { + ak := loadGOADAnswerKey(t) + loot := &aresLoot{ + OperationID: "op-20260515-145348", + StartedAt: "2026-05-15T14:53:48Z", + Credentials: []aresCredEntry{ + {Username: "missandei", Password: "fr3edom", Domain: "essos.local"}, + }, + Hashes: []aresHashEntry{ + { + Username: "administrator", + Domain: "essos.local", + HashValue: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + HashType: "ntlm", + Source: "certipy_esc1_full_chain", + }, + }, + DomainCompromise: []aresDomainCompromise{ + { + Domain: "essos.local", + HasDomainAdmin: true, + HasGoldenTicket: false, + AdminUsers: []string{"administrator"}, + }, + }, + } + report := ParseReport(synthesizeJSONL(loot, []string{"adcs_esc1_10.1.2.254"})) + status := VerifyReport(report, ak) + verified := verifiedObjectiveIDs(status) + + for _, id := range []string{"cred-essos.local-missandei", "domain-essos.local", "host-meereen", "tech-adcs_esc1"} { + if !verified[id] { + t.Errorf("%s should be verified from ESC1/domain_compromise path; verified=%v", id, verified) + } + } + if verified["tech-golden_ticket-essos.local"] { + t.Errorf("golden ticket must not be verified when has_golden_ticket is false; verified=%v", verified) + } +} + +func verifiedObjectiveIDs(status *StatusReport) map[string]bool { + out := map[string]bool{} + for _, vo := range status.Verified { + if vo.Verified { + out[vo.ObjectiveID] = true + } + } + return out +} + func TestExtractUsernameFormats(t *testing.T) { cases := map[string]string{ "alice@example.com": "alice",