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
1 change: 1 addition & 0 deletions ad/GOAD-variant-1/data/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
"vulns": [
"ntlmdowngrade",
"disable_firewall",
"adcs_esc10_case1",
"adcs_esc7",
"adcs_esc13",
"adcs_esc15"
Expand Down
2 changes: 1 addition & 1 deletion ad/GOAD/data/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
]
},
"scripts" : ["asrep_roasting2.ps1"],
"vulns" : ["ntlmdowngrade", "disable_firewall", "adcs_esc7", "adcs_esc13", "adcs_esc15"],
"vulns" : ["ntlmdowngrade", "disable_firewall", "adcs_esc10_case1", "adcs_esc7", "adcs_esc13", "adcs_esc15"],
"vulns_adcs_templates": ["ESC1", "ESC2", "ESC3", "ESC3-CRA", "ESC4", "ESC9"],
"vulns_vars" : {
"adcs_esc7": {
Expand Down
1 change: 1 addition & 0 deletions ansible/roles/vulns_adcs_esc10_case1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ADCS ESC10 Case 1 - Disable strong certificate binding enforcement
### main.yml

- **Set StrongCertificateBindingEnforcement to 0** (ansible.windows.win_regedit)
- **Restart the KDC so the new binding mode takes effect** (ansible.windows.win_service) - Conditional

## Example Playbook

Expand Down
22 changes: 22 additions & 0 deletions ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
# Pin the KDC binding mode explicitly rather than inheriting the Windows
# default. The default moved to Full Enforcement in the February 2025 hardening
# rollout (KB5014754), which silently closes every certificate route that relies
# on a weak (UPN/SAN) mapping: ESC6, ESC9 and ESC10 case 1 all die at once, on a
# lab whose CA-side probes still read green.
- name: Set StrongCertificateBindingEnforcement to 0
ansible.windows.win_regedit:
path: HKLM:\SYSTEM\CurrentControlSet\Services\Kdc
name: StrongCertificateBindingEnforcement
data: 0x0
type: dword
register: _scbe_pin
vars:
ansible_become: true
ansible_become_method: runas
domain_name: "{{ domain }}"
ansible_become_user: "{{ domain_username }}"
ansible_become_password: "{{ domain_password }}"

# The KDC reads this value when the service starts, so the write alone leaves
# the running KDC on its old mode. Nothing later in the vulns run reliably
# reboots this host, and that gap is invisible to any registry-reading check:
# validate would report the weak binding while authentication still refused it.
- name: Restart the KDC so the new binding mode takes effect
ansible.windows.win_service:
name: kdc
state: restarted
when: _scbe_pin is changed
vars:
ansible_become: true
ansible_become_method: runas
Expand Down
53 changes: 49 additions & 4 deletions cli/internal/scoreboard/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,11 @@ func addKerberosTechniques(domains map[string]any, asrep map[string][]string, ad
}

func addHostTechniques(hosts map[string]any, add techniqueAdd) {
weakKDC := weakCertBindingDomains(hosts)
for _, hRaw := range hosts {
h, _ := hRaw.(map[string]any)
addNetworkTechniques(h, add)
addAdcsTechniques(h, add)
addAdcsTechniques(h, weakKDC, add)
addMssqlTechniques(h, add)
addDelegationTechniques(h, add)
addPrivescTechniques(h, add)
Expand All @@ -435,10 +436,54 @@ func addNetworkTechniques(h map[string]any, add techniqueAdd) {
}
}

func addAdcsTechniques(h map[string]any, add techniqueAdd) {
// kdcBoundADCSTechniques are the ADCS techniques whose exploitability rests on
// the KDC accepting a weak certificate mapping, not on the CA-side or
// template-side flag the config records.
//
// ESC6 injects a SAN into a certificate that still carries the *requester's*
// SID, and ESC9 strips the SID extension outright. Neither survives a KDC that
// insists on a strong mapping, and since KB5014754 (Feb 2025) the built-in
// default is Full Enforcement. So the flag alone stopped implying an achievable
// objective: the lab now has to pin the KDC as well.
var kdcBoundADCSTechniques = map[string]bool{
"adcs_esc6": true,
"adcs_esc9": true,
}

// weakCertBindingDomains returns the domains whose KDC the lab explicitly pins
// to StrongCertificateBindingEnforcement=0, which is what the adcs_esc10_case1
// role does.
//
// The pin has to be in the certificate's own domain, so this is deliberately not
// a lab-wide "is any KDC permissive" test. GOAD shipped the pin on kingslanding,
// in a forest holding no CA and no vulnerable templates, while every SAN-spoof
// route lived in essos behind an enforcing KDC.
func weakCertBindingDomains(hosts map[string]any) map[string]bool {
out := map[string]bool{}
for _, hRaw := range hosts {
h, _ := hRaw.(map[string]any)
if !containsString(stringSlice(h["vulns"]), "adcs_esc10_case1") {
continue
}
if domain := strings.ToLower(getStr(h, "domain")); domain != "" {
out[domain] = true
}
}
return out
}

func addAdcsTechniques(h map[string]any, weakKDC map[string]bool, add techniqueAdd) {
domain := strings.ToLower(getStr(h, "domain"))
credit := func(id, label string) {
if kdcBoundADCSTechniques[id] && !weakKDC[domain] {
return
}
add(id, label, "adcs")
}

for _, vuln := range stringSlice(h["vulns"]) {
if label, ok := adcsLabels[vuln]; ok {
add(vuln, label, "adcs")
credit(vuln, label)
}
}
// Hosts in the ansible adcs_customtemplates group publish certificate
Expand All @@ -450,7 +495,7 @@ func addAdcsTechniques(h map[string]any, add techniqueAdd) {
continue
}
if label, ok := adcsLabels[techID]; ok {
add(techID, label, "adcs")
credit(techID, label)
}
}
}
Expand Down
145 changes: 111 additions & 34 deletions cli/internal/scoreboard/topology_gating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,20 @@ func techniqueSet(t *testing.T, lab string) map[string]bool {
return out
}

// TestTopologyGatedTechniques pins the techniques that must not be credited to
// labs that cannot host them. These were previously added unconditionally, which
// put uncompletable objectives on the answer key: ADCS techniques on labs with no
// The tests below pin the techniques that must not be credited to labs that
// cannot host them. These were previously added unconditionally, which put
// uncompletable objectives on the answer key: ADCS techniques on labs with no
// CA, and child-to-parent escalation on labs with no child domain.
func TestTopologyGatedTechniques(t *testing.T) {
// Labs with no ADCS provisioning at all. MINILAB, SCCM, and DRACARYS have an
// empty `adcs` inventory group; TEMPLATE is a scaffold that plants no ADCS.
//
// They are split by concern rather than nested under one function so each stays
// under the repo's gocyclo threshold.

// TestTopologyGatedTechniquesNoADCS covers labs with no ADCS provisioning at
// all. MINILAB, SCCM, and DRACARYS have an empty `adcs` inventory group;
// TEMPLATE is a scaffold that plants no ADCS.
func TestTopologyGatedTechniquesNoADCS(t *testing.T) {
for _, lab := range []string{"MINILAB", "SCCM", "DRACARYS", "TEMPLATE"} {
t.Run("no_adcs/"+lab, func(t *testing.T) {
t.Run(lab, func(t *testing.T) {
techs := techniqueSet(t, lab)
for _, id := range []string{"certifried", "adcs_esc8"} {
if techs[id] {
Expand All @@ -38,34 +43,41 @@ func TestTopologyGatedTechniques(t *testing.T) {
}
})
}
}

// Single-domain labs, and NHA whose two domains are separate forest roots
// (ninja.hack and academy.ninja.lan), so neither is a child of the other.
// TestTopologyGatedTechniquesNoChildDomain covers single-domain labs, and NHA
// whose two domains are separate forest roots (ninja.hack and
// academy.ninja.lan), so neither is a child of the other.
func TestTopologyGatedTechniquesNoChildDomain(t *testing.T) {
for _, lab := range []string{"GOAD-Mini", "MINILAB", "SCCM", "DRACARYS", "TEMPLATE", "NHA"} {
t.Run("no_child_domain/"+lab, func(t *testing.T) {
t.Run(lab, func(t *testing.T) {
if techniqueSet(t, lab)["child_to_parent"] {
t.Errorf("%s has no parent/child domain pair but was credited child_to_parent", lab)
}
})
}
}

// NHA installs a CA, so Certifried stands, but its CA-bearing domain sets
// ca_web_enrollment=false, so ESC8 must not be credited.
t.Run("nha_web_enrollment_disabled", func(t *testing.T) {
techs := techniqueSet(t, "NHA")
if !techs["certifried"] {
t.Error("NHA installs a CA and should still be credited certifried")
}
if techs["adcs_esc8"] {
t.Error("NHA disables ca_web_enrollment and must not be credited adcs_esc8")
}
})
// TestTopologyGatedTechniquesNHAWebEnrollment pins NHA specifically: it installs
// a CA, so Certifried stands, but its CA-bearing domain sets
// ca_web_enrollment=false, so ESC8 must not be credited.
func TestTopologyGatedTechniquesNHAWebEnrollment(t *testing.T) {
techs := techniqueSet(t, "NHA")
if !techs["certifried"] {
t.Error("NHA installs a CA and should still be credited certifried")
}
if techs["adcs_esc8"] {
t.Error("NHA disables ca_web_enrollment and must not be credited adcs_esc8")
}
}

// Labs that do provision a CA keep their ADCS techniques. GOAD-Light and
// GOAD-Mini install one via the `adcs` inventory group without setting the
// domain-level ca_server key, so gating on ca_server alone would regress them.
// TestTopologyGatedTechniquesHasADCS pins that labs which do provision a CA keep
// their ADCS techniques. GOAD-Light and GOAD-Mini install one via the `adcs`
// inventory group without setting the domain-level ca_server key, so gating on
// ca_server alone would regress them.
func TestTopologyGatedTechniquesHasADCS(t *testing.T) {
for _, lab := range []string{"GOAD", "GOAD-Light", "GOAD-Mini", "GOAD-variant-1"} {
t.Run("has_adcs/"+lab, func(t *testing.T) {
t.Run(lab, func(t *testing.T) {
techs := techniqueSet(t, lab)
for _, id := range []string{"certifried", "adcs_esc8"} {
if !techs[id] {
Expand All @@ -74,16 +86,81 @@ func TestTopologyGatedTechniques(t *testing.T) {
}
})
}
}

// GOAD-variant-1 is generated from GOAD and publishes the same certificate
// templates, so it must credit the same per-template ESC techniques.
t.Run("variant_matches_goad_templates", func(t *testing.T) {
goad := techniqueSet(t, "GOAD")
variant := techniqueSet(t, "GOAD-variant-1")
for _, id := range []string{"adcs_esc1", "adcs_esc2", "adcs_esc3", "adcs_esc4", "adcs_esc9"} {
if goad[id] && !variant[id] {
t.Errorf("GOAD credits %q but GOAD-variant-1 does not", id)
// TestTopologyGatedTechniquesVariantMatchesGOAD pins that GOAD-variant-1, which
// is generated from GOAD and publishes the same certificate templates, credits
// the same per-template ESC techniques.
func TestTopologyGatedTechniquesVariantMatchesGOAD(t *testing.T) {
goad := techniqueSet(t, "GOAD")
variant := techniqueSet(t, "GOAD-variant-1")
for _, id := range []string{"adcs_esc1", "adcs_esc2", "adcs_esc3", "adcs_esc4", "adcs_esc9"} {
if goad[id] && !variant[id] {
t.Errorf("GOAD credits %q but GOAD-variant-1 does not", id)
}
}
}

// TestTopologyGatedTechniquesKDCBound pins ESC6 and ESC9, which need a KDC that
// will accept a weak certificate mapping. Both labs now pin
// StrongCertificateBindingEnforcement=0 in the domain that owns the CA and the
// templates. Before that pin the routes were dead on a patched lab while the
// answer key still demanded them.
func TestTopologyGatedTechniquesKDCBound(t *testing.T) {
for _, lab := range []string{"GOAD", "GOAD-variant-1"} {
t.Run(lab, func(t *testing.T) {
techs := techniqueSet(t, lab)
for _, id := range []string{"adcs_esc6", "adcs_esc9"} {
if !techs[id] {
t.Errorf("%s pins a weak KDC binding in its CA domain but lost %q", lab, id)
}
}
})
}
}

// A weak binding pinned in some other domain does not make ESC6 or ESC9
// reachable. This is the exact GOAD shape that hid the problem: the permissive
// KDC sat in a forest with nothing to enrol against, so any lab-wide check for
// one read as green.
func TestADCSTechniquesGatedOnSameDomainKDCPin(t *testing.T) {
hosts := map[string]any{
"dc01": map[string]any{
"domain": "sevenkingdoms.local",
"vulns": []any{"adcs_esc10_case1"},
},
"dc03": map[string]any{
"domain": "essos.local",
"vulns_adcs_templates": []any{"ESC1", "ESC9"},
},
"srv03": map[string]any{
"domain": "essos.local",
"vulns": []any{"adcs_esc6"},
},
}

collect := func(hosts map[string]any) map[string]bool {
got := map[string]bool{}
addHostTechniques(hosts, func(id, _, _ string) { got[id] = true })
return got
}

techs := collect(hosts)
if !techs["adcs_esc1"] {
t.Error("adcs_esc1 does not depend on the KDC binding and must still be credited")
}
for _, id := range []string{"adcs_esc6", "adcs_esc9"} {
if techs[id] {
t.Errorf("%s credited with the only KDC pin in another domain", id)
}
})
}

// Move the pin into the domain that holds the CA and both come back.
hosts["dc03"].(map[string]any)["vulns"] = []any{"adcs_esc10_case1"}
techs = collect(hosts)
for _, id := range []string{"adcs_esc6", "adcs_esc9"} {
if !techs[id] {
t.Errorf("%s not credited despite a weak KDC binding in its own domain", id)
}
}
}
Loading
Loading