Skip to content
Draft
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
8 changes: 4 additions & 4 deletions ad/GOAD-variant-1/data/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@
"susan.white": {
"firstname": "susan",
"surname": "white",
"password": "l246ggx",
"password": "shadow",
"city": "-",
"description": "Susan White",
"groups": [],
Expand Down Expand Up @@ -558,7 +558,7 @@
"ryan.myers": {
"firstname": "ryan",
"surname": "myers",
"password": "si4q5iagz",
"password": "sunshine",
"city": "Dallas",
"description": "Ryan Myers",
"groups": [
Expand All @@ -572,7 +572,7 @@
"alexander.peterson": {
"firstname": "alexander",
"surname": "peterson",
"password": "wlrucscdadzooz",
"password": "football",
"city": "Dallas",
"description": "Alexander Peterson",
"groups": [
Expand Down Expand Up @@ -605,7 +605,7 @@
"christine.martin": {
"firstname": "christine",
"surname": "martin",
"password": "ddlfwkwdemov",
"password": "baseball",
"city": "Denver",
"description": "Christine Martin",
"groups": [
Expand Down
8 changes: 4 additions & 4 deletions ad/GOAD-variant-1/mapping.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,24 @@
"passwords": {
"iamthekingoftheworld": "mcnkpmyufebebibtdmcc",
"Sup1_sa_P@ssw0rd!": "6q_E@9Bk]^|LolaX9",
"fr3edom": "l246ggx",
"fr3edom": "shadow",
"BurnThemAll!": "Av^MO$q>t)<i",
"Dracarys": "SEZrjMFR",
"FightP3aceAndHonor!": "<Bs!d4UGlv[ll>*x-Iz",
"robbsansabradonaryarickon": "uejpqnidxtnoehjdwbtsqaztl",
"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",
Expand Down
85 changes: 83 additions & 2 deletions cli/internal/variant/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
}
Comment thread
mkultraWasHere marked this conversation as resolved.
}

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
}
}
Comment thread
mkultraWasHere marked this conversation as resolved.
return users
}

func (g *Generator) buildUserPasswordMap(domains map[string]*DomainConfig) {
for _, domain := range domains {
for username, user := range domain.Users {
Expand Down
55 changes: 55 additions & 0 deletions cli/internal/variant/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 37 additions & 13 deletions cli/internal/variant/namegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
},
Comment thread
mkultraWasHere marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -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)
}
Comment thread
mkultraWasHere marked this conversation as resolved.

// GenerateCityName returns a unique city name.
func (ng *NameGenerator) GenerateCityName() string {
return ng.ensureUnique(secureChoice(ng.cityNames))
Expand Down
31 changes: 31 additions & 0 deletions cli/internal/variant/namegen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading