diff --git a/ad/GOAD-variant-1/data/config.json b/ad/GOAD-variant-1/data/config.json index 45fe6813..5902961f 100644 --- a/ad/GOAD-variant-1/data/config.json +++ b/ad/GOAD-variant-1/data/config.json @@ -433,7 +433,7 @@ "susan.white": { "firstname": "susan", "surname": "white", - "password": "l246ggx", + "password": "shadow", "city": "-", "description": "Susan White", "groups": [], @@ -558,7 +558,7 @@ "ryan.myers": { "firstname": "ryan", "surname": "myers", - "password": "si4q5iagz", + "password": "sunshine", "city": "Dallas", "description": "Ryan Myers", "groups": [ @@ -572,7 +572,7 @@ "alexander.peterson": { "firstname": "alexander", "surname": "peterson", - "password": "wlrucscdadzooz", + "password": "football", "city": "Dallas", "description": "Alexander Peterson", "groups": [ @@ -605,7 +605,7 @@ "christine.martin": { "firstname": "christine", "surname": "martin", - "password": "ddlfwkwdemov", + "password": "baseball", "city": "Denver", "description": "Christine Martin", "groups": [ diff --git a/ad/GOAD-variant-1/mapping.json b/ad/GOAD-variant-1/mapping.json index 5a4efe39..62d36634 100644 --- a/ad/GOAD-variant-1/mapping.json +++ b/ad/GOAD-variant-1/mapping.json @@ -91,7 +91,7 @@ "passwords": { "iamthekingoftheworld": "mcnkpmyufebebibtdmcc", "Sup1_sa_P@ssw0rd!": "6q_E@9Bk]^|LolaX9", - "fr3edom": "l246ggx", + "fr3edom": "shadow", "BurnThemAll!": "Av^MO$q>t)*x-Iz", @@ -99,16 +99,16 @@ "Winter2022": "MTmya1uW0b", "MaesterOfMaesters": "WFqrVsLcNEFirMwxV", "Needle": "heNvyj", - "iknownothing": "ddlfwkwdemov", + "iknownothing": "baseball", "VExkHyfsKTW_HMNA7fQy": "xYK7tDxi:+PSp(AS;>=%", "1killerlion": "zzseh2865o2", "cersei": "bpyhct", - "345ertdfg": "si4q5iagz", + "345ertdfg": "sunshine", "il0vejaime": "3jivwfkcxr", "YouWillNotKerboroast1ngMeeeeee": "g0JGPuQBYkLNtB60YJwNoclpn8FCyI", "Alc00L&S3x": "@U#7L^SKww", "@littlefinger@": "<+p*d<,vg<*-hx", - "iseedeadpeople": "wlrucscdadzooz", + "iseedeadpeople": "football", "_W1sper_$": "6&BeB8*+M", "lorastyrell": "yuddrrlgxpv", "hodor": "jqfay", diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 629ccd5d..a3274f97 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -447,8 +447,15 @@ func (g *Generator) mapPasswords(config *LabConfig) { collectDomainPasswords(config.Lab.Domains, passwords) collectHostPasswords(config.Lab.Hosts, passwords) + crackable := g.findCrackablePasswords(config) + for pw := range passwords { - newPW := g.nameGen.GeneratePassword(pw) + var newPW string + if crackable[pw] { + newPW = g.nameGen.GenerateCrackablePassword() + } else { + newPW = g.nameGen.GeneratePassword(pw) + } g.mappings.Passwords[pw] = newPW truncOld := pw truncNew := newPW @@ -458,7 +465,11 @@ func (g *Generator) mapPasswords(config *LabConfig) { if len(truncNew) > 20 { truncNew = truncNew[:20] } - fmt.Printf(" %s... -> %s...\n", truncOld, truncNew) + tag := "" + if crackable[pw] { + tag = " (crackable)" + } + fmt.Printf(" %s... -> %s...%s\n", truncOld, truncNew, tag) } g.buildUserPasswordMap(config.Lab.Domains) @@ -535,6 +546,76 @@ func collectVulnPasswords(vulnsVars map[string]any, passwords map[string]bool) { } } +// findCrackablePasswords identifies original passwords that belong to +// Kerberoastable (SPN) or AS-REP roastable users. These passwords must be +// replaced with dictionary words instead of random strings so they remain +// crackable during the engagement. +func (g *Generator) findCrackablePasswords(config *LabConfig) map[string]bool { + crackable := make(map[string]bool) + + // Users with SPNs are Kerberoastable — their password must be crackable. + for _, domain := range config.Lab.Domains { + for username, user := range domain.Users { + if g.preservedUsers[username] { + continue // sql_svc has SPNs but intentionally uncrackable password + } + if user == nil { + continue + } + if len(user.SPNs) > 0 { + if user.Password != "" { + crackable[user.Password] = true + } + } + } + } + + // Users targeted by AS-REP roasting scripts. + asrepUsers := g.parseASREPScripts() + for _, domain := range config.Lab.Domains { + for username, user := range domain.Users { + if g.preservedUsers[username] { + continue + } + if !asrepUsers[strings.ToLower(username)] { + continue + } + if user != nil && user.Password != "" { + crackable[user.Password] = true + } + } + } + + return crackable +} + +// parseASREPScripts reads asrep*.ps1 scripts from the source lab and extracts +// usernames configured for AS-REP roasting (DoesNotRequirePreAuth). +func (g *Generator) parseASREPScripts() map[string]bool { + users := make(map[string]bool) + pattern := filepath.Join(g.SourcePath, "scripts", "asrep*.ps1") + files, err := filepath.Glob(pattern) + if err != nil { + return users + } + if len(files) == 0 { + fmt.Printf("Warning: no asrep*.ps1 scripts found in %s/scripts — AS-REP roastable users will not get crackable passwords\n", g.SourcePath) + return users + } + re := regexp.MustCompile(`(?i)-Identity\s+"([^"]+)"`) + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + fmt.Printf("Warning: could not read %s: %v\n", f, err) + continue + } + for _, match := range re.FindAllStringSubmatch(string(data), -1) { + users[strings.ToLower(match[1])] = true + } + } + return users +} + func (g *Generator) buildUserPasswordMap(domains map[string]*DomainConfig) { for _, domain := range domains { for username, user := range domain.Users { diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go index 44a99b1f..b9965b3d 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -190,6 +190,61 @@ func TestPasswordInDescriptionPreserved(t *testing.T) { t.Errorf("transformed user %s not found in any domain", newUsername) } +func TestFindCrackablePasswords(t *testing.T) { + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + + // Create scripts dir with an AS-REP roasting script targeting arya.stark + scriptsDir := filepath.Join(sourceDir, "scripts") + if err := os.MkdirAll(scriptsDir, 0o755); err != nil { + t.Fatal(err) + } + // Target arya.stark AND sql_svc — sql_svc should still be skipped (preserved) + if err := os.WriteFile( + filepath.Join(scriptsDir, "asrep_roasting.ps1"), + []byte("Get-ADUser -Identity \"arya.stark\" | Set-ADAccountControl -DoesNotRequirePreAuth:$true\nGet-ADUser -Identity \"sql_svc\" | Set-ADAccountControl -DoesNotRequirePreAuth:$true"), + 0o644, + ); err != nil { + t.Fatal(err) + } + + config := testConfig() + + // Give samwell.tarly SPNs — Kerberoastable + config.Lab.Domains["sevenkingdoms.local"].Users["samwell.tarly"].SPNs = []string{ + "HTTP/eyrie.sevenkingdoms.local", + } + + // Give sql_svc SPNs — should NOT be crackable (preserved) + config.Lab.Domains["sevenkingdoms.local"].Users["sql_svc"].SPNs = []string{ + "MSSQLSvc/kingslanding.sevenkingdoms.local:1433", + } + + gen := NewGenerator(sourceDir, "", "test-crackable") + + crackable := gen.findCrackablePasswords(config) + + // (1) samwell.tarly has SPNs → password must be crackable + if !crackable["Heartsbane"] { + t.Error("expected Heartsbane (samwell.tarly SPN user) to be crackable") + } + + // (2) arya.stark is in asrep script → password must be crackable + if !crackable["NeedleIsMySword!"] { + t.Error("expected NeedleIsMySword! (arya.stark AS-REP user) to be crackable") + } + + // (3) sql_svc is preserved → password must NOT be crackable (even via SPN or AS-REP) + if crackable["SqlSvcPass1!"] { + t.Error("sql_svc password should not be crackable (preserved user)") + } + + // Domain password should not be crackable + if crackable["DomainPass1!"] { + t.Error("domain password should not be crackable") + } +} + func TestApplyReplacements(t *testing.T) { gen := NewGenerator("", "", "test") gen.mappings.Misc["robert"] = "james" diff --git a/cli/internal/variant/namegen.go b/cli/internal/variant/namegen.go index e7794d73..3b06f141 100644 --- a/cli/internal/variant/namegen.go +++ b/cli/internal/variant/namegen.go @@ -12,19 +12,20 @@ import ( type NameGenerator struct { usedNames map[string]bool - domainPrefixes []string - domainSuffixes []string - firstNames []string - lastNames []string - hostnamePrefixes []string - hostnameSuffixes []string - groupThemes []string - groupSuffixes []string - ouRegions []string - ouDivisions []string - animals []string - subdomainWords []string - cityNames []string + domainPrefixes []string + domainSuffixes []string + firstNames []string + lastNames []string + hostnamePrefixes []string + hostnameSuffixes []string + groupThemes []string + groupSuffixes []string + ouRegions []string + ouDivisions []string + animals []string + subdomainWords []string + cityNames []string + crackablePasswords []string // rockyou-style passwords for Kerberoastable/AS-REP accounts } // NewNameGenerator creates a new NameGenerator with default word lists. @@ -117,6 +118,23 @@ func NewNameGenerator() *NameGenerator { "Phoenix", "Seattle", "Portland", "Austin", "Atlanta", "Miami", "Philadelphia", "San Diego", "San Francisco", "New York", }, + // Passwords from rockyou top entries — all appear verbatim in the + // wordlist so they crack with a straight dictionary attack (no rules). + // Used for Kerberoastable and AS-REP roastable accounts. + crackablePasswords: []string{ + "sunshine", "trustno1", "iloveyou", "baseball", "football", + "princess", "starwars", "whatever", "corvette", "midnight", + "computer", "mustang", "shadow", "master", "welcome", + "letmein", "monkey", "blaster", "yankees", "lakers", + "password1", "superman", "qwerty", "tigger", "batman", + "arsenal", "access14", "buster", "soccer", "pepper", + "ginger", "thunder", "summer", "butterfly", "chelsea", + "chocolate", "pumpkin", "sparky", "hammer", "broncos", + "rangers", "fishing", "marlin", "bigdog", "cowboy", + "steelers", "dolphins", "redsox", "camaro", "creative", + "platinum", "passw0rd", "trustme", "zeppelin", "warrior", + "phoenix", + }, } } @@ -304,6 +322,12 @@ func (ng *NameGenerator) GeneratePassword(original string) string { return string(password) } +// GenerateCrackablePassword returns a dictionary password that can be cracked +// via hashcat/john with standard wordlists (rockyou, etc.). +func (ng *NameGenerator) GenerateCrackablePassword() string { + return secureChoice(ng.crackablePasswords) +} + // GenerateCityName returns a unique city name. func (ng *NameGenerator) GenerateCityName() string { return ng.ensureUnique(secureChoice(ng.cityNames)) diff --git a/cli/internal/variant/namegen_test.go b/cli/internal/variant/namegen_test.go index d72b690d..a15e4403 100644 --- a/cli/internal/variant/namegen_test.go +++ b/cli/internal/variant/namegen_test.go @@ -93,6 +93,37 @@ func TestGeneratePassword(t *testing.T) { } } +func TestGenerateCrackablePassword(t *testing.T) { + ng := NewNameGenerator() + + // Should return a non-empty password from the wordlist + pw := ng.GenerateCrackablePassword() + if pw == "" { + t.Error("crackable password should not be empty") + } + + // Should be in the wordlist + found := false + for _, w := range ng.crackablePasswords { + if w == pw { + found = true + break + } + } + if !found { + t.Errorf("crackable password %q not found in wordlist", pw) + } + + // Multiple calls should eventually produce different passwords + seen := make(map[string]bool) + for i := 0; i < 20; i++ { + seen[ng.GenerateCrackablePassword()] = true + } + if len(seen) < 2 { + t.Error("expected some variety in crackable passwords") + } +} + func TestGenerateGroupName(t *testing.T) { ng := NewNameGenerator() name := ng.GenerateGroupName()