diff --git a/cli/cmd/scoreboard.go b/cli/cmd/scoreboard.go index 7162978a..188dac7a 100644 --- a/cli/cmd/scoreboard.go +++ b/cli/cmd/scoreboard.go @@ -16,7 +16,7 @@ import ( var scoreboardCmd = &cobra.Command{ Use: "scoreboard", - Short: "Live status board for GOAD engagements", + Short: "Live scoreboard for GOAD engagements", Long: `Tracks an agent's progress against a GOAD lab: parses the lab config into a checklist of objectives ("answer key"), polls a JSONL report file locally or from an EC2 instance via SSM, and verifies findings against the @@ -40,7 +40,7 @@ TUI. Use --transport=local to read a local file, or --transport=ssm with var scoreboardDemoCmd = &cobra.Command{ Use: "demo", - Short: "Render a sample status board with mock findings", + Short: "Render a sample scoreboard with mock findings", RunE: runScoreboardDemo, } diff --git a/cli/internal/scoreboard/transport_ares.go b/cli/internal/scoreboard/transport_ares.go index 5d653e02..4e46d5c5 100644 --- a/cli/internal/scoreboard/transport_ares.go +++ b/cli/internal/scoreboard/transport_ares.go @@ -48,10 +48,11 @@ func NewAresTransport(ctx context.Context, instanceID, binaryPath, region string } type aresLoot struct { - OperationID string `json:"operation_id"` - StartedAt string `json:"started_at"` - Credentials []aresCredEntry `json:"credentials"` - Hashes []aresHashEntry `json:"hashes"` + OperationID string `json:"operation_id"` + StartedAt string `json:"started_at"` + Credentials []aresCredEntry `json:"credentials"` + Hashes []aresHashEntry `json:"hashes"` + DomainCompromise []aresDomainCompromise `json:"domain_compromise"` } type aresCredEntry struct { @@ -69,6 +70,19 @@ type aresHashEntry struct { Source string `json:"source"` } +// 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. +type aresDomainCompromise struct { + Domain string `json:"domain"` + HasDomainAdmin bool `json:"has_domain_admin"` + HasGoldenTicket bool `json:"has_golden_ticket"` + KrbtgtHashTypes []string `json:"krbtgt_hash_types"` +} + // FetchReport runs `ares ops loot --latest --json` on the remote instance and, // if successful, also fetches the `ares:op::exploited` Redis set so // technique objectives can be credited directly. Both payloads are @@ -77,7 +91,8 @@ type aresHashEntry struct { 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}]}` + ` hashes: [.hashes[] | {username, domain, hash_value, hash_type, source}],` + + ` domain_compromise: [.domain_compromise[] | {domain, has_domain_admin, has_golden_ticket, 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) @@ -216,75 +231,114 @@ func aresExploitedToTechniqueIDs(entry string) []string { func synthesizeJSONL(l *aresLoot, exploited []string) string { var b strings.Builder - startTime := l.StartedAt - header := map[string]string{ + writeJSONLEntry(&b, map[string]string{ "agent_id": "ares:" + l.OperationID, - "start_time": startTime, + "start_time": l.StartedAt, + }) + for _, c := range l.Credentials { + writeCredentialEntry(&b, c) + } + for _, h := range l.Hashes { + writeHashEntry(&b, h) } - hb, _ := json.Marshal(header) - b.Write(hb) + emitted := map[string]bool{} + writeExploitedEntries(&b, exploited, emitted) + writeDomainCompromiseEntries(&b, l.DomainCompromise, emitted) + return b.String() +} + +func writeJSONLEntry(b *strings.Builder, entry map[string]string) { + eb, _ := json.Marshal(entry) + b.Write(eb) b.WriteByte('\n') +} - for _, c := range l.Credentials { - if c.Username == "" || c.Password == "" { - continue - } - target := c.Username - if c.Domain != "" { - target = c.Username + "@" + c.Domain - } - desc := "ares loot" - if c.IsAdmin { - desc = "ares loot (admin)" - } - entry := map[string]string{ - "target": target, - "evidence": c.Password, - "description": desc, - } - eb, _ := json.Marshal(entry) - b.Write(eb) - b.WriteByte('\n') +func writeCredentialEntry(b *strings.Builder, c aresCredEntry) { + if c.Username == "" || c.Password == "" { + return + } + target := c.Username + if c.Domain != "" { + target = c.Username + "@" + c.Domain } + desc := "ares loot" + if c.IsAdmin { + desc = "ares loot (admin)" + } + writeJSONLEntry(b, map[string]string{ + "target": target, + "evidence": c.Password, + "description": desc, + }) +} - for _, h := range l.Hashes { - if h.Username == "" || h.HashValue == "" { - continue - } - target := h.Username - if h.Domain != "" { - target = h.Username + "@" + strings.ToLower(h.Domain) - } - htype := h.HashType - if htype == "" { - htype = "hash" - } - entry := map[string]string{ - "target": target, - "evidence": h.HashValue, - "description": "ares: " + strings.ToLower(htype) + " (" + h.Source + ")", - } - eb, _ := json.Marshal(entry) - b.Write(eb) - b.WriteByte('\n') +func writeHashEntry(b *strings.Builder, h aresHashEntry) { + if h.Username == "" || h.HashValue == "" { + return + } + target := h.Username + if h.Domain != "" { + target = h.Username + "@" + strings.ToLower(h.Domain) } + htype := h.HashType + if htype == "" { + htype = "hash" + } + writeJSONLEntry(b, map[string]string{ + "target": target, + "evidence": h.HashValue, + "description": "ares: " + strings.ToLower(htype) + " (" + h.Source + ")", + }) +} - emitted := map[string]bool{} +func writeExploitedEntries(b *strings.Builder, exploited []string, emitted map[string]bool) { for _, ex := range exploited { for _, techID := range aresExploitedToTechniqueIDs(ex) { if emitted[techID] { continue } emitted[techID] = true - entry := map[string]string{ + writeJSONLEntry(b, map[string]string{ "target": "tech:" + techID, "evidence": "ares: " + ex, "description": "exploited", + }) + } + } +} + +// 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. +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 && len(dc.KrbtgtHashTypes) > 0 { + writeJSONLEntry(b, map[string]string{ + "target": "krbtgt@" + domain, + "evidence": krbtgtSyntheticEvidence, + "description": "ares: synthetic krbtgt from domain_compromise (" + strings.Join(dc.KrbtgtHashTypes, ",") + ")", + }) + } + if dc.HasGoldenTicket { + techID := "golden_ticket-" + domain + if !emitted[techID] { + emitted[techID] = true + writeJSONLEntry(b, map[string]string{ + "target": "tech:" + techID, + "evidence": "ares: domain_compromise has_golden_ticket", + "description": "exploited", + }) } - eb, _ := json.Marshal(entry) - b.Write(eb) - b.WriteByte('\n') } } - return b.String() } diff --git a/cli/internal/scoreboard/tui.go b/cli/internal/scoreboard/tui.go index 0d30d9af..b96049e6 100644 --- a/cli/internal/scoreboard/tui.go +++ b/cli/internal/scoreboard/tui.go @@ -370,7 +370,7 @@ func renderBoard(status *StatusReport, ak *AnswerKey, agentID string, startTime parts = append(parts, styleFaint.Render(" q quit · r reload · j/k scroll · g/G top/bottom")) } - return panelWithTitle("DreadGOAD STATUS BOARD", strings.Join(parts, "\n"), width) + return panelWithTitle("DreadGOAD SCOREBOARD", strings.Join(parts, "\n"), width) } // panelWithTitle frames `body` in a rounded border with `title` embedded in diff --git a/cli/internal/scoreboard/verify_test.go b/cli/internal/scoreboard/verify_test.go index 48bdb771..1e06edfe 100644 --- a/cli/internal/scoreboard/verify_test.go +++ b/cli/internal/scoreboard/verify_test.go @@ -178,6 +178,57 @@ 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. +func TestSynthesizeJSONLDomainCompromise(t *testing.T) { + loot := &aresLoot{ + OperationID: "op-test", + StartedAt: "2026-05-14T18:24:06Z", + DomainCompromise: []aresDomainCompromise{ + { + Domain: "essos.local", + HasDomainAdmin: true, + HasGoldenTicket: true, + KrbtgtHashTypes: []string{"ntlm"}, + }, + { + // Uncompromised domain: must NOT produce a krbtgt finding. + 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, + }, + }, + } + jsonl := synthesizeJSONL(loot, nil) + report := ParseReport(jsonl) + + owned := domainsFromKrbtgt(report.Findings) + if !owned["essos.local"] { + t.Errorf("essos.local should be inferred as owned from domain_compromise, got %v", owned) + } + if owned["uncompromised.local"] || owned["creds-only.local"] { + t.Errorf("only essos.local should be in krbtgt-inferred set, got %v", owned) + } + + 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"] { + t.Errorf("only essos.local should produce a golden_ticket technique, got %v", tech) + } +} + func TestExtractUsernameFormats(t *testing.T) { cases := map[string]string{ "alice@example.com": "alice",