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
4 changes: 2 additions & 2 deletions cli/cmd/scoreboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}

Expand Down
166 changes: 110 additions & 56 deletions cli/internal/scoreboard/transport_ares.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:<id>:exploited` Redis set so
// technique objectives can be credited directly. Both payloads are
Expand All @@ -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)
Expand Down Expand Up @@ -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()
}
2 changes: 1 addition & 1 deletion cli/internal/scoreboard/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions cli/internal/scoreboard/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading