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
47 changes: 36 additions & 11 deletions cli/internal/scoreboard/transport_ares.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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)
Expand Down Expand Up @@ -308,20 +310,29 @@ 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 {
domain := strings.ToLower(strings.TrimSpace(dc.Domain))
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,
Expand All @@ -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, ",")
}
66 changes: 58 additions & 8 deletions cli/internal/scoreboard/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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++
Expand Down Expand Up @@ -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 {
Expand Down
89 changes: 74 additions & 15 deletions cli/internal/scoreboard/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"},
},
},
}
Expand All @@ -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",
Expand Down
Loading