From c81d42e1bc6b8d20210c8385f60b6821259037a4 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 30 Jul 2026 12:02:53 -0600 Subject: [PATCH 1/2] fix: gate ADCS ESC6 and ESC9 on same-domain KDC certificate binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - Added `weakCertBindingDomains` and `kdcBoundADCSTechniques` in scoreboard generation to withhold credit for ESC6/ESC9 unless the certificate's own domain pins `StrongCertificateBindingEnforcement=0` via `adcs_esc10_case1` - cli/internal/scoreboard/generate.go - Added `checkESC9Enforcement` and `checkESC9KDCBinding` validation, gating ESC9 on both the template's `CT_FLAG_NO_SECURITY_EXTENSION` flag and a KDC binding of ≤1, with the template checked first so labs shipping no ESC9 template report INFO - cli/internal/validate/checks.go - Added `esc9_kdc_test.go` covering ESC9 KDC thresholds, template gating, missing flag, and same-domain DC resolution - cli/internal/validate/esc9_kdc_test.go - Added scoreboard topology tests asserting ESC6/ESC9 are credited only when the pin lives in the CA's own domain - cli/internal/scoreboard/topology_gating_test.go - Added a KDC restart to the esc10_case1 role so the new binding mode takes effect, since the running KDC otherwise stays on its old mode - ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml and README.md - Added `adcs_esc10_case1` to the essos DC vuln lists so both labs pin the weak binding in the CA domain - ad/GOAD/data/config.json and ad/GOAD-variant-1/data/config.json - Documented KDC binding as a hard gate, including the KB5014754 default shift, the ESC6-vs-ESC9 threshold table, and event 39 level semantics - docs/GOAD-vulnerabilities-comprehensive.md, docs/domain-compromise-paths.md, docs/domains-and-users.md **Changed:** - Refactored ESC6's inline KDC read into a shared `readKDCBinding` helper returning a `kdcBinding` struct, and reused it for ESC9 - cli/internal/validate/checks.go - Changed the absent-value verdict from WARN to FAIL for both ESC6 and ESC9, since the shipped default is now Full Enforcement rather than unknown; the message advises pinning with `adcs_esc10_case1` - cli/internal/validate/checks.go - Renamed the ESC6 test helpers (`esc6Envelope`/`esc6Lab` → `kdcEnvelope`/`kdcBindingLab`) to reflect their shared use and updated the absent-value expectations to FAIL - cli/internal/validate/esc6_kdc_test.go --- ad/GOAD-variant-1/data/config.json | 1 + ad/GOAD/data/config.json | 2 +- .../roles/vulns_adcs_esc10_case1/README.md | 1 + .../vulns_adcs_esc10_case1/tasks/main.yml | 22 +++ cli/internal/scoreboard/generate.go | 53 ++++- .../scoreboard/topology_gating_test.go | 61 ++++++ cli/internal/validate/checks.go | 178 +++++++++++++---- cli/internal/validate/esc6_kdc_test.go | 44 +++-- cli/internal/validate/esc9_kdc_test.go | 187 ++++++++++++++++++ docs/GOAD-vulnerabilities-comprehensive.md | 44 ++++- docs/domain-compromise-paths.md | 16 +- docs/domains-and-users.md | 2 +- 12 files changed, 544 insertions(+), 67 deletions(-) create mode 100644 cli/internal/validate/esc9_kdc_test.go diff --git a/ad/GOAD-variant-1/data/config.json b/ad/GOAD-variant-1/data/config.json index c750a2d6..4ebc26ad 100644 --- a/ad/GOAD-variant-1/data/config.json +++ b/ad/GOAD-variant-1/data/config.json @@ -130,6 +130,7 @@ "vulns": [ "ntlmdowngrade", "disable_firewall", + "adcs_esc10_case1", "adcs_esc7", "adcs_esc13", "adcs_esc15" diff --git a/ad/GOAD/data/config.json b/ad/GOAD/data/config.json index c11d9a52..b439393e 100644 --- a/ad/GOAD/data/config.json +++ b/ad/GOAD/data/config.json @@ -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": { diff --git a/ansible/roles/vulns_adcs_esc10_case1/README.md b/ansible/roles/vulns_adcs_esc10_case1/README.md index d0476c15..ae714b0b 100644 --- a/ansible/roles/vulns_adcs_esc10_case1/README.md +++ b/ansible/roles/vulns_adcs_esc10_case1/README.md @@ -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) ## Example Playbook diff --git a/ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml b/ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml index f9b44016..38219766 100644 --- a/ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml +++ b/ansible/roles/vulns_adcs_esc10_case1/tasks/main.yml @@ -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 diff --git a/cli/internal/scoreboard/generate.go b/cli/internal/scoreboard/generate.go index ca1f6b3a..b9010cec 100644 --- a/cli/internal/scoreboard/generate.go +++ b/cli/internal/scoreboard/generate.go @@ -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) @@ -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 @@ -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) } } } diff --git a/cli/internal/scoreboard/topology_gating_test.go b/cli/internal/scoreboard/topology_gating_test.go index 619d4233..e3d9252c 100644 --- a/cli/internal/scoreboard/topology_gating_test.go +++ b/cli/internal/scoreboard/topology_gating_test.go @@ -86,4 +86,65 @@ func TestTopologyGatedTechniques(t *testing.T) { } } }) + + // ESC6 and ESC9 need a KDC that will accept a weak certificate mapping, and + // 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. + for _, lab := range []string{"GOAD", "GOAD-variant-1"} { + t.Run("kdc_bound_adcs/"+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) + } + } } diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index 49da4c16..b2c88561 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -6,6 +6,7 @@ package validate import ( "context" _ "embed" + "errors" "fmt" "io" "strconv" @@ -733,40 +734,52 @@ func (v *Validator) checkADCSESC6(ctx context.Context, w io.Writer) { } } -// checkESC6KDCBinding decides whether a CA with EDITF_ATTRIBUTESUBJECTALTNAME2 -// set can actually win, which the CA-side flag alone does not establish. +// kdcBinding is one KDC's StrongCertificateBindingEnforcement state, read from +// the DC that will validate certificates issued in a given domain. +type kdcBinding struct { + // DCLabel is the hostname of the DC the value was read from. + DCLabel string + Value int + // Present is false when the value is absent, which means the KDC falls + // back to its shipped default rather than to anything the lab chose. + Present bool +} + +// unpinnedKDCNote explains what an absent StrongCertificateBindingEnforcement +// value means now that Microsoft has moved the default under us. // -// ESC6 issues off the stock User template, and EDITF_ATTRIBUTESUBJECTALTNAME2 -// only injects a SAN; it cannot touch the szOID_NTDS_CA_SECURITY_EXT security -// extension, so the issued cert carries the *requester's* SID rather than the -// impersonated target's. Only a KDC at StrongCertificateBindingEnforcement=0 -// (Disabled) ignores that extension. At 1 (Compatibility) a *present* extension -// is still validated strictly and the mismatch is rejected, and 2 (Full -// Enforcement) rejects it as well. So the pass condition is ==0, not !=2. +// The Feb 2025 hardening rollout (KB5014754) made Full Enforcement the built-in +// default, so an unset value is not "unknown, possibly permissive": it is the +// strict mode. That was confirmed behaviourally on this lab's essos KDC, which +// has no value set and refused an ESC9 certificate with event 39 logged at +// *Error* level. Event 39's level is the discriminator, not its presence: +// Compatibility permits the logon and logs 39 as a Warning. +const unpinnedKDCNote = "has no StrongCertificateBindingEnforcement value, so its KDC takes the shipped default of Full Enforcement (KB5014754, Feb 2025)" + +// readKDCBinding resolves the DC whose KDC validates certificates for adcsRole's +// domain and reads its StrongCertificateBindingEnforcement value. // -// The KDC that matters is the DC of the CA's own domain, not the CA host, which -// is why a CA and the one permissive KDC can sit in different forests and leave -// ESC6 dead while every CA-side probe reports green. -func (v *Validator) checkESC6KDCBinding(ctx context.Context, w io.Writer, role, hostLabel string) { - dcRole := v.lab.ADCSDCRole(role) +// The KDC that matters is the DC of the certificate's own domain, not the CA +// host and not whichever DC in the lab happens to be permissive. GOAD pins +// StrongCertificateBindingEnforcement=0 on kingslanding while the CA and every +// vulnerable template live in essos, so a check that reads any DC but this one +// credits an exploit that cannot land. +func (v *Validator) readKDCBinding(ctx context.Context, adcsRole string) (kdcBinding, error) { + dcRole := v.lab.ADCSDCRole(adcsRole) if dcRole == "" { - if domain := v.lab.DomainForHost(role); domain != "" { + if domain := v.lab.DomainForHost(adcsRole); domain != "" { dcRole = v.lab.DCForDomain(domain) } } if dcRole == "" { - v.addResult(w, "WARN", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but no DC resolved for its domain; cannot confirm ESC6 is exploitable", hostLabel), "") - return + return kdcBinding{}, errors.New("no DC resolved for its domain") } dcHost := strings.ToUpper(dcRole) if !v.hasHost(dcHost) { - v.addResult(w, "WARN", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but validating DC %s is unreachable; cannot confirm ESC6 is exploitable", hostLabel, dcHost), "") - return + return kdcBinding{DCLabel: dcHost}, fmt.Errorf("validating DC %s is unreachable", dcHost) } - dcLabel := strings.ToUpper(v.lab.Hostname(dcRole)) + b := kdcBinding{DCLabel: strings.ToUpper(v.lab.Hostname(dcRole))} r, err := runScriptJSON[registryDWORDResult](ctx, v, dcHost, scriptRegistryDWORD, map[string]any{ "Path": `HKLM:\SYSTEM\CurrentControlSet\Services\Kdc`, @@ -774,26 +787,41 @@ func (v *Validator) checkESC6KDCBinding(ctx context.Context, w io.Writer, role, }) switch { case err != nil: - v.addResult(w, "WARN", "ADCS-ESC6", - fmt.Sprintf("Could not query StrongCertificateBindingEnforcement on %s: %v", dcLabel, err), "") + return b, fmt.Errorf("could not query StrongCertificateBindingEnforcement on %s: %w", b.DCLabel, err) case r.Error != "": + return b, fmt.Errorf("StrongCertificateBindingEnforcement query error on %s: %s", b.DCLabel, r.Error) + } + b.Value, b.Present = r.Value, r.Present + return b, nil +} + +// checkESC6KDCBinding decides whether a CA with EDITF_ATTRIBUTESUBJECTALTNAME2 +// set can actually win, which the CA-side flag alone does not establish. +// +// ESC6 issues off the stock User template, and EDITF_ATTRIBUTESUBJECTALTNAME2 +// only injects a SAN; it cannot touch the szOID_NTDS_CA_SECURITY_EXT security +// extension, so the issued cert carries the *requester's* SID rather than the +// impersonated target's. Only a KDC at StrongCertificateBindingEnforcement=0 +// (Disabled) ignores that extension. At 1 (Compatibility) a *present* extension +// is still validated strictly and the mismatch is rejected, and 2 (Full +// Enforcement) rejects it as well. So the pass condition is ==0, not !=2. +func (v *Validator) checkESC6KDCBinding(ctx context.Context, w io.Writer, role, hostLabel string) { + b, err := v.readKDCBinding(ctx, role) + if err != nil { v.addResult(w, "WARN", "ADCS-ESC6", - fmt.Sprintf("StrongCertificateBindingEnforcement query error on %s: %s", dcLabel, r.Error), "") - case !r.Present: - // An absent value means the KDC built-in default applies, and the - // registry cannot tell you which default that is. Read the KDC - // operational log instead and compare event 39 (weak mapping - // accepted, so Compatibility) against event 40 (weak mapping denied, - // so Full Enforcement). Reporting PASS here would assert an - // exploitability we have not observed. - v.addResult(w, "WARN", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but %s has no StrongCertificateBindingEnforcement value, so its KDC default is unknown; check Microsoft-Windows-Kerberos-Key-Distribution-Center events 39 vs 40 on %s", hostLabel, dcLabel, dcLabel), "") - case r.Value == 0: + fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but %s; cannot confirm ESC6 is exploitable", hostLabel, err), "") + return + } + switch { + case !b.Present: + v.addResult(w, "FAIL", "ADCS-ESC6", + fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but %s %s, which rejects the SID mismatch (ESC6 NOT exploitable); pin it with the adcs_esc10_case1 vuln", hostLabel, b.DCLabel, unpinnedKDCNote), "") + case b.Value == 0: v.addResult(w, "PASS", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s and StrongCertificateBindingEnforcement=0 on %s (ESC6 exploitable)", hostLabel, dcLabel), "") + fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s and StrongCertificateBindingEnforcement=0 on %s (ESC6 exploitable)", hostLabel, b.DCLabel), "") default: v.addResult(w, "FAIL", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but StrongCertificateBindingEnforcement=%d on %s, which validates the security extension and rejects the SID mismatch (ESC6 NOT exploitable)", hostLabel, r.Value, dcLabel), "") + fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s but StrongCertificateBindingEnforcement=%d on %s, which validates the security extension and rejects the SID mismatch (ESC6 NOT exploitable)", hostLabel, b.Value, b.DCLabel), "") } } @@ -2577,6 +2605,82 @@ func (v *Validator) checkADCSESC9(ctx context.Context, w io.Writer) { if len(asrepDCs) > 0 && !any { v.addResult(w, "FAIL", "ADCS-ESC9", "No ESC9 pivot users found in any AS-REP-configured domain", "") } + + v.checkESC9Enforcement(ctx, w) +} + +// ctFlagNoSecurityExtension is CT_FLAG_NO_SECURITY_EXTENSION in +// msPKI-Enrollment-Flag: the bit that makes a template an ESC9 template by +// omitting szOID_NTDS_CA_SECURITY_EXT from every certificate it issues. +const ctFlagNoSecurityExtension = 0x00080000 + +// checkESC9Enforcement verifies the two conditions the pivot user does not +// establish: that the ESC9 template really drops the SID security extension, +// and that the KDC which will see the resulting certificate still accepts a +// weak mapping. +// +// Both are checked per template DC, and the template is checked first so labs +// that publish no ESC9 template (GOAD-Light, GOAD-Mini, NHA) report INFO rather +// than a KDC verdict about a route they never shipped. +func (v *Validator) checkESC9Enforcement(ctx context.Context, w io.Writer) { + for _, dcRole := range v.adcsTemplateDCs() { + dc := strings.ToUpper(dcRole) + if !v.hasHost(dc) { + continue + } + output := v.adcsTemplateAttr(ctx, dc, "ESC9", "msPKI-Enrollment-Flag") + val := strings.TrimSpace(output) + flag, parseErr := strconv.ParseInt(val, 10, 64) + switch { + case strings.Contains(output, "TEMPLATE_NOT_FOUND"): + v.addResult(w, "INFO", "ADCS-ESC9", + fmt.Sprintf("ESC9 template not present on %s", dc), "") + case val == "" || parseErr != nil: + v.addResult(w, "WARN", "ADCS-ESC9", + fmt.Sprintf("Could not read ESC9 template msPKI-Enrollment-Flag on %s", dc), "") + case uint32(flag)&ctFlagNoSecurityExtension == 0: + v.addResult(w, "FAIL", "ADCS-ESC9", + fmt.Sprintf("ESC9 template on %s lacks CT_FLAG_NO_SECURITY_EXTENSION (msPKI-Enrollment-Flag=%s)", dc, val), "") + default: + v.checkESC9KDCBinding(ctx, w, dcRole) + } + } +} + +// checkESC9KDCBinding decides whether an issued ESC9 certificate can convert to +// a TGT, which the template flag does not establish. +// +// CT_FLAG_NO_SECURITY_EXTENSION works by *removing* szOID_NTDS_CA_SECURITY_EXT +// from the issued certificate, leaving the KDC no SID to bind and forcing the +// weak UPN mapping the attack spoofs. StrongCertificateBindingEnforcement 0 +// (Disabled) and 1 (Compatibility) both allow that fallback; 2 (Full +// Enforcement) refuses any certificate it cannot map strongly, so stripping the +// extension is itself disqualifying. The pass condition is !=2, unlike ESC6's +// ==0: ESC6 also loses at 1, because its certificate *has* a security extension +// and a present extension is validated strictly even in Compatibility mode. +// +// This gate is independent of the ACL chain, and deliberately so. Enrolling the +// ESC9 template on staging as an ordinary domain user, with no UPN spoof and no +// -sid, yielded a certificate with no object SID whose AS-REQ the KDC dropped +// with event 39 at Error level. Nothing about the UPN-write primitive was in +// play, so an ESC9 verdict that waits on the ACL chain waits on the wrong +// blocker. +func (v *Validator) checkESC9KDCBinding(ctx context.Context, w io.Writer, dcRole string) { + b, err := v.readKDCBinding(ctx, dcRole) + switch { + case err != nil: + v.addResult(w, "WARN", "ADCS-ESC9", + fmt.Sprintf("Cannot confirm ESC9 is exploitable: %s", err), "") + case !b.Present: + v.addResult(w, "FAIL", "ADCS-ESC9", + fmt.Sprintf("%s %s, which refuses a certificate carrying no SID (ESC9 NOT exploitable); pin it with the adcs_esc10_case1 vuln", b.DCLabel, unpinnedKDCNote), "") + case b.Value >= 2: + v.addResult(w, "FAIL", "ADCS-ESC9", + fmt.Sprintf("StrongCertificateBindingEnforcement=%d on %s refuses a certificate with no SID security extension, which is exactly what CT_FLAG_NO_SECURITY_EXTENSION produces (ESC9 NOT exploitable)", b.Value, b.DCLabel), "") + default: + v.addResult(w, "PASS", "ADCS-ESC9", + fmt.Sprintf("StrongCertificateBindingEnforcement=%d on %s allows the weak UPN mapping an ESC9 certificate needs (ESC9 exploitable)", b.Value, b.DCLabel), "") + } } // esc13IssuanceName is the DisplayName esc13.ps1 gives the issuance policy OID diff --git a/cli/internal/validate/esc6_kdc_test.go b/cli/internal/validate/esc6_kdc_test.go index 9b208987..515f45f7 100644 --- a/cli/internal/validate/esc6_kdc_test.go +++ b/cli/internal/validate/esc6_kdc_test.go @@ -10,16 +10,16 @@ import ( "github.com/dreadnode/dreadgoad/internal/provider" ) -// esc6Envelope wraps a payload the way registry_dword.ps1 does; runScriptJSON +// kdcEnvelope wraps a payload the way registry_dword.ps1 does; runScriptJSON // discards anything without the markers. -func esc6Envelope(payload string) string { +func kdcEnvelope(payload string) string { return "===BEGIN_JSON===\n" + payload + "\n===END_JSON===\n" } -// esc6Lab models the GOAD shape that makes this check necessary: the CA +// kdcBindingLab models the GOAD shape that makes this check necessary: the CA // (braavos) is a member server, so the KDC that validates its certificates is // the DC of the CA's own domain (meereen), not the CA host. -func esc6Lab() *labmap.LabMap { +func kdcBindingLab() *labmap.LabMap { return &labmap.LabMap{ Hosts: map[string]labmap.HostInfo{ "srv03": {NewHostname: "braavos", NewDomain: "essos.local"}, @@ -55,8 +55,10 @@ func TestCheckESC6KDCBinding(t *testing.T) { wantDetail: "ESC6 exploitable", }, { - // The measured dc03 state. Compatibility mode still validates a - // present security extension, so the SID mismatch is rejected. + // Compatibility mode still validates a present security + // extension, so the SID mismatch is rejected. This is the case + // that separates ESC6 from ESC9: an ESC9 certificate has no + // extension to validate and survives here. name: "SCBE=1 compatibility rejects the SID mismatch", stdout: `{"present":true,"value":1,"error":""}`, wantStatus: "FAIL", @@ -69,14 +71,16 @@ func TestCheckESC6KDCBinding(t *testing.T) { wantDetail: "NOT exploitable", }, { - // The trap that produced a wrong conclusion once already: an - // absent value means the KDC built-in default applies, and the - // registry cannot say which default that is. Anything but WARN - // here asserts a state nobody observed. - name: "absent value is unknown, not exploitable", + // An absent value is not "unknown, possibly permissive". The + // built-in default has been Full Enforcement since KB5014754 + // (Feb 2025), and the essos KDC, which sets no value, was + // measured refusing a certificate outright: event 39 at Error + // level, no TGT. Reporting WARN here would leave a dead route + // looking merely unverified. + name: "absent value means Full Enforcement, not exploitable", stdout: `{"present":false,"value":0,"error":""}`, - wantStatus: "WARN", - wantDetail: "KDC default is unknown", + wantStatus: "FAIL", + wantDetail: "shipped default of Full Enforcement", }, { name: "script error warns", @@ -89,10 +93,10 @@ func TestCheckESC6KDCBinding(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, _ := newStubValidator(t, func(_ int, _ string) (*provider.CommandResult, error) { - return &provider.CommandResult{Status: "Success", Stdout: esc6Envelope(tt.stdout)}, nil + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(tt.stdout)}, nil }) v.silent = true - v.lab = esc6Lab() + v.lab = kdcBindingLab() v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") @@ -116,10 +120,10 @@ func TestCheckESC6KDCBinding(t *testing.T) { // CA-side probe was green while the deciding KDC was never consulted. func TestCheckESC6KDCBinding_ReportsDCNotCA(t *testing.T) { v, _ := newStubValidator(t, func(_ int, _ string) (*provider.CommandResult, error) { - return &provider.CommandResult{Status: "Success", Stdout: esc6Envelope(`{"present":true,"value":1,"error":""}`)}, nil + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(`{"present":true,"value":1,"error":""}`)}, nil }) v.silent = true - v.lab = esc6Lab() + v.lab = kdcBindingLab() v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") @@ -140,10 +144,10 @@ func TestCheckESC6KDCBinding_ReadsKDCKeyOnDC(t *testing.T) { var gotScript string v, _ := newStubValidator(t, func(_ int, command string) (*provider.CommandResult, error) { gotScript = command - return &provider.CommandResult{Status: "Success", Stdout: esc6Envelope(`{"present":true,"value":0,"error":""}`)}, nil + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(`{"present":true,"value":0,"error":""}`)}, nil }) v.silent = true - v.lab = esc6Lab() + v.lab = kdcBindingLab() v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") @@ -162,7 +166,7 @@ func TestCheckESC6KDCBinding_ReadsKDCKeyOnDC(t *testing.T) { // An unresolvable DC is unknown, not exploitable. func TestCheckESC6KDCBinding_NoDCResolvedWarns(t *testing.T) { v, _ := newStubValidator(t, func(_ int, _ string) (*provider.CommandResult, error) { - return &provider.CommandResult{Status: "Success", Stdout: esc6Envelope(`{"present":true,"value":0,"error":""}`)}, nil + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(`{"present":true,"value":0,"error":""}`)}, nil }) v.silent = true v.lab = &labmap.LabMap{ diff --git a/cli/internal/validate/esc9_kdc_test.go b/cli/internal/validate/esc9_kdc_test.go new file mode 100644 index 00000000..fb25053b --- /dev/null +++ b/cli/internal/validate/esc9_kdc_test.go @@ -0,0 +1,187 @@ +package validate + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/labmap" + "github.com/dreadnode/dreadgoad/internal/provider" +) + +// esc9TemplateFlag is the msPKI-Enrollment-Flag the shipped ESC9.json carries: +// 0x80029, so CT_FLAG_NO_SECURITY_EXTENSION (0x80000) plus the publish and +// auto-enrollment bits. +const esc9TemplateFlag = "524329" + +// esc9Stub answers both probes the check makes from one stub: the template +// attribute query (a Get-ADObject over pKICertificateTemplate) and the KDC +// registry read. +func esc9Stub(templateOut, registryJSON string) func(int, string) (*provider.CommandResult, error) { + return func(_ int, command string) (*provider.CommandResult, error) { + if strings.Contains(command, "pKICertificateTemplate") { + return &provider.CommandResult{Status: "Success", Stdout: templateOut + "\n"}, nil + } + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(registryJSON)}, nil + } +} + +// ESC6 and ESC9 read the same registry value and fail at different thresholds, +// which is exactly the kind of pair a shared helper invites collapsing onto one +// condition. CT_FLAG_NO_SECURITY_EXTENSION strips the SID extension from the +// issued certificate, so Compatibility mode has nothing to validate strictly and +// falls back to the weak UPN mapping the attack spoofs: ESC9 survives at 1 where +// ESC6 dies. Only Full Enforcement, which refuses any certificate it cannot map +// strongly, closes it. +func TestCheckESC9Enforcement_KDCBinding(t *testing.T) { + tests := []struct { + name string + stdout string + wantStatus string + wantDetail string + }{ + { + name: "SCBE=0 disabled ignores the missing extension", + stdout: `{"present":true,"value":0,"error":""}`, + wantStatus: "PASS", + wantDetail: "ESC9 exploitable", + }, + { + // The case that separates ESC9 from ESC6. Passing only on 0 here + // would report a live route as dead. + name: "SCBE=1 compatibility still permits the weak mapping", + stdout: `{"present":true,"value":1,"error":""}`, + wantStatus: "PASS", + wantDetail: "ESC9 exploitable", + }, + { + // Measured on staging: enrolled as an ordinary domain user with + // no UPN spoof and no -sid, certificate issued with no object + // SID, AS-REQ reached the KDC and no TGT came back. + name: "SCBE=2 full enforcement refuses a certificate with no SID", + stdout: `{"present":true,"value":2,"error":""}`, + wantStatus: "FAIL", + wantDetail: "NOT exploitable", + }, + { + name: "absent value means Full Enforcement, not exploitable", + stdout: `{"present":false,"value":0,"error":""}`, + wantStatus: "FAIL", + wantDetail: "shipped default of Full Enforcement", + }, + { + name: "script error warns", + stdout: `{"present":false,"value":0,"error":"Access denied"}`, + wantStatus: "WARN", + wantDetail: "query error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, _ := newStubValidator(t, esc9Stub(esc9TemplateFlag, tt.stdout)) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC9Enforcement(context.Background(), io.Discard) + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + got := v.report.Results[0] + if got.Status != tt.wantStatus { + t.Errorf("status = %q, want %q (message: %q)", got.Status, tt.wantStatus, got.Name) + } + if !strings.Contains(got.Name, tt.wantDetail) { + t.Errorf("message %q does not mention %q", got.Name, tt.wantDetail) + } + }) + } +} + +// GOAD-Light, GOAD-Mini and NHA install a CA but publish no ESC9 template. A +// KDC verdict there would fail a lab for a route it never shipped, so the +// template gates the enforcement read. +func TestCheckESC9Enforcement_NoTemplateSkipsKDCVerdict(t *testing.T) { + v, _ := newStubValidator(t, esc9Stub("TEMPLATE_NOT_FOUND", `{"present":false,"value":0,"error":""}`)) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC9Enforcement(context.Background(), io.Discard) + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + got := v.report.Results[0] + if got.Status != "INFO" { + t.Errorf("status = %q, want INFO (message: %q)", got.Status, got.Name) + } + if strings.Contains(got.Name, "exploitable") { + t.Errorf("a lab with no ESC9 template must not get an exploitability verdict, got %q", got.Name) + } +} + +// The template being present is not the same as the template being an ESC9 +// template. Without CT_FLAG_NO_SECURITY_EXTENSION the certificate carries a SID +// like any other and the KDC binding is beside the point. +func TestCheckESC9Enforcement_TemplateMissingFlag(t *testing.T) { + v, _ := newStubValidator(t, esc9Stub("41", `{"present":true,"value":0,"error":""}`)) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC9Enforcement(context.Background(), io.Discard) + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + got := v.report.Results[0] + if got.Status != "FAIL" { + t.Errorf("status = %q, want FAIL (message: %q)", got.Status, got.Name) + } + if !strings.Contains(got.Name, "CT_FLAG_NO_SECURITY_EXTENSION") { + t.Errorf("message %q does not name the missing flag", got.Name) + } +} + +// The DC that decides ESC9 is the one for the domain the certificate is issued +// in. GOAD pins StrongCertificateBindingEnforcement=0 on kingslanding, in a +// forest with no CA and no vulnerable templates, so a check that finds any +// permissive KDC in the lab reports a route that cannot be walked. +func TestCheckESC9Enforcement_ReadsCertificateDomainDC(t *testing.T) { + v, _ := newStubValidator(t, esc9Stub(esc9TemplateFlag, `{"present":true,"value":2,"error":""}`)) + v.silent = true + v.lab = &labmap.LabMap{ + Hosts: map[string]labmap.HostInfo{ + "dc01": {NewHostname: "kingslanding", NewDomain: "sevenkingdoms.local"}, + "dc03": {NewHostname: "meereen", NewDomain: "essos.local"}, + "srv03": {NewHostname: "braavos", NewDomain: "essos.local"}, + }, + HostConfigs: map[string]labmap.HostConfig{ + "dc01": {Hostname: "kingslanding", Type: "dc", Domain: "sevenkingdoms.local", Vulns: []string{"adcs_esc10_case1"}}, + "dc03": {Hostname: "meereen", Type: "dc", Domain: "essos.local"}, + "srv03": {Hostname: "braavos", Type: "server", Domain: "essos.local"}, + }, + DomainConfigs: map[string]labmap.DomainConfig{ + "essos.local": {DC: "dc03", CAServer: "braavos"}, + "sevenkingdoms.local": {DC: "dc01"}, + }, + } + v.hosts = map[string]string{"DC01": "i-dc01", "DC03": "i-dc03", "SRV03": "i-srv03"} + + v.checkESC9Enforcement(context.Background(), io.Discard) + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + msg := v.report.Results[0].Name + if !strings.Contains(msg, "MEEREEN") { + t.Errorf("verdict must name the essos KDC MEEREEN, got %q", msg) + } + if strings.Contains(msg, "KINGSLANDING") { + t.Errorf("verdict must not be drawn from the permissive KDC in another forest, got %q", msg) + } +} diff --git a/docs/GOAD-vulnerabilities-comprehensive.md b/docs/GOAD-vulnerabilities-comprehensive.md index 5ccac372..bdf03d60 100644 --- a/docs/GOAD-vulnerabilities-comprehensive.md +++ b/docs/GOAD-vulnerabilities-comprehensive.md @@ -318,6 +318,39 @@ These scheduled tasks and configurations are provisioned by Ansible roles to ena ## ADCS Vulnerabilities +### KDC binding is a hard gate + +Every route that authenticates with a *weakly mapped* certificate is decided by +one registry value on one host: `StrongCertificateBindingEnforcement` under +`HKLM:\SYSTEM\CurrentControlSet\Services\Kdc`, on the DC of the domain the +certificate is issued in. Not the CA host, and not whichever DC in the lab +happens to be permissive. + +| Value | Mode | Certificate with a *mismatched* SID (ESC6) | Certificate with *no* SID (ESC9) | +| --- | --- | --- | --- | +| 0 | Disabled | accepted | accepted | +| 1 | Compatibility | rejected | accepted | +| 2 | Full Enforcement | rejected | rejected | + +Two things make this easy to get wrong: + +- **The default moved.** The February 2025 hardening rollout (KB5014754) made + Full Enforcement the built-in default, so an unset value is the strict mode, + not a permissive one. A lab that never pinned the value had ESC2, ESC6 and + ESC9 quietly close while every CA-side and template-side probe stayed green. +- **Event 39's level is the discriminator, not its presence.** Compatibility + permits the logon and logs 39 as a *Warning*; Full Enforcement refuses and + logs it as an *Error*. Reading only "an event 39 appeared" gets the mode + backwards. + +ESC1 is unaffected because `ENROLLEE_SUPPLIES_SUBJECT` plus a matching `-sid` +produces a *strong* mapping, not a bypassed one, which is why it converts +against the very same KDC that refuses ESC9. + +GOAD pins the value with the `adcs_esc10_case1` vuln on the DC of the domain +that owns the CA and the vulnerable templates. `dreadgoad validate` reads it +there and fails ESC6 and ESC9 if it is missing or enforcing. + ### ESC1 - Enrollee Supplies Subject **Vulnerability:** Certificate templates allow requesters to specify Subject Alternative Name @@ -407,6 +440,11 @@ These scheduled tasks and configurations are provisioned by Ansible roles to ena - **Impact:** Any template can be used to request certificates with arbitrary SANs - **Detection:** `certipy find -vulnerable` - **Exploitation:** Request certificate with `-upn` flag for any template +- **Also requires `StrongCertificateBindingEnforcement=0`** on the KDC of the + domain the certificate is issued in. The SAN rides alongside a security + extension holding the *requester's* SID, so Compatibility mode (1) validates + that extension and rejects the mismatch just as Full Enforcement (2) does. + See [KDC binding is a hard gate](#kdc-binding-is-a-hard-gate). ### ESC7 - ManageCA/ManageCertificate Abuse @@ -478,7 +516,11 @@ These scheduled tasks and configurations are provisioned by Ansible roles to ena - **Prerequisites:** - GenericWrite on target account - `msPKI-EnrollmentFlag` contains `CT_FLAG_NO_SECURITY_EXTENSION` - - `StrongCertificateBindingEnforcement=1` or `CertificateMappingMethods=0x04` + - `StrongCertificateBindingEnforcement` of 0 or 1 on the KDC of the domain the + certificate is issued in, or `CertificateMappingMethods=0x04`. Stripping the + security extension is what makes the weak UPN mapping reachable, so Full + Enforcement (2) turns the template's defining feature into a disqualifier. + See [KDC binding is a hard gate](#kdc-binding-is-a-hard-gate). - **Attack Chain:** 1. Add shadow credentials to target to obtain their hash: diff --git a/docs/domain-compromise-paths.md b/docs/domain-compromise-paths.md index cf2a706a..141dad9c 100644 --- a/docs/domain-compromise-paths.md +++ b/docs/domain-compromise-paths.md @@ -109,14 +109,24 @@ Essos DA via the gMSA→drogon nested-group route and via ADCS. Nesting that mak | E3 | ESC2 | ADCS ESC2 (Any-Purpose EKU) | any essos Domain User | cert as DA | `:189` | | E4 | ESC3 / ESC3-CRA | ADCS ESC3 (enrollment agent) | any essos Domain User | enroll-on-behalf-of DA | `:189` | | E5 | ESC4 | ADCS ESC4 (template DACL) → ESC1 | khal.drogo (`horse`) | reconfigure template → cert as DA | `:318` GenericAll on ESC4 template | -| E6 | ESC6 | ADCS ESC6 (CA SAN flag) | any essos Domain User | SAN-spoof DA | `vulns_adcs_esc6` sets `EDITF_ATTRIBUTESUBJECTALTNAME2` (`:221`) | +| E6 | ESC6 | ADCS ESC6 (CA SAN flag) | any essos Domain User | SAN-spoof DA | `vulns_adcs_esc6` sets `EDITF_ATTRIBUTESUBJECTALTNAME2` (`:221`); needs meereen `StrongCertificateBindingEnforcement=0` (`:188`) | | E7 | ESC7 | ADCS ESC7 (ManageCA) | viserys.targaryen (`GoldCrown`) | approve/enable → cert as DA | `:191-194` | -| E8 | ESC9 | ADCS ESC9 (no-security-extension + UPN write) | missandei/khal | UPN-swap → cert as DA | `:189`; write via `:321`/`:323`/`:316` | +| E8 | ESC9 | ADCS ESC9 (no-security-extension + UPN write) | missandei/khal | UPN-swap → cert as DA | `:189`; write via `:321`/`:323`/`:316`; needs meereen `StrongCertificateBindingEnforcement` ≤ 1 (`:188`) | | E9 | ESC11 | ADCS ESC11 (ICertPassage RPC relay) | coercion + relay | DC cert → DCSync | `vulns_adcs_esc11` clears `IF_ENFORCEENCRYPTICERTREQUEST` (`:221`) | | E10 | ESC13 | ADCS ESC13 (issuance-policy → group) | any essos Domain User | cert grants `greatmaster` = local admin DC03 → DA | `:196-201`; greatmaster ∈ DC03 Administrators (`:181`) | | E11 | ESC15 (EKUwu) | ADCS ESC15 (v1 enrollee-supplies-subject) | any essos Domain User | add client-auth app-policy → cert as DA | `vulns_adcs_esc15` grants Domain Users Enroll on "Web Server" | | E12 | ESC8 on ESSOS-CA | ADCS ESC8 (web-enroll relay) | any domain creds + coerce meereen | DC cert → DA | CA on srv03 (`inventory:88-90`); web enroll default true | +**E6 and E8 depend on the essos KDC, not on the CA.** Both authenticate with a +weakly mapped certificate, so meereen's `StrongCertificateBindingEnforcement` +decides them: 0 for E6 (its certificate carries the requester's SID, which +Compatibility mode still validates), 0 or 1 for E8 (its certificate carries no +SID at all). The value is unset by default and, since KB5014754 in February +2025, an unset value means Full Enforcement, which closes both. GOAD therefore +pins it on meereen via `adcs_esc10_case1` (`:188`). The pin on kingslanding +(`:22`) does not help here: it is a different forest, with no CA and no +vulnerable templates to enrol against. + **E1 routes to braavos local admin** (→ SYSTEM → read gmsaDragon$ → control drogon): khal.drogo direct local admin (`:213`) and MSSQL sysadmin (`:226-227`); jorah.mormont LAPS reader (`:254-257`); `DragonsFriends → GenericWrite braavos$`→ RBCD (`:320`). ### Cross-domain / trust hops — 4 additional paths @@ -183,7 +193,7 @@ Counting rule: one row per genuinely distinct provisioned artifact **or** distin **F — Delegation (3):** sansa unconstrained (`unconstrained_delegation_user.ps1`); jon.snow constrained use-any-protocol (`constrained_delegation_use_any.ps1`); jon.snow constrained kerberos-only (`constrained_delegation_kerb_only.ps1`). -**G — ADCS (59):** **49** = 7 any-user templates (ESC1, ESC2, ESC3, ESC3-CRA, ESC6, ESC13, ESC15) × 7 named essos users (daenerys, viserys, khal.drogo, jorah.mormont, missandei, drogon, sql_svc) (`:188-189`, `:221`, `:325-389`); + ESC4 (khal only, `:318`); + ESC7 (viserys only, `:191-194`); + ESC9 (4 write-holders: khal, missandei, Spys, viserys; `:316-323`); + ESC8 braavos relay (1); + ESC11 braavos relay (1); + ESC10 dc01 case1+case2 (2, `:22`). +**G — ADCS (59):** **49** = 7 any-user templates (ESC1, ESC2, ESC3, ESC3-CRA, ESC6, ESC13, ESC15) × 7 named essos users (daenerys, viserys, khal.drogo, jorah.mormont, missandei, drogon, sql_svc) (`:188-189`, `:221`, `:325-389`); + ESC4 (khal only, `:318`); + ESC7 (viserys only, `:191-194`); + ESC9 (4 write-holders: khal, missandei, Spys, viserys; `:316-323`); + ESC8 braavos relay (1); + ESC11 braavos relay (1); + ESC10 dc01 case1+case2 (2, `:22`). ESC10 case 1 is also pinned on meereen (`:188`), which is what keeps E6 and E8 alive at all; its essos abuse paths reuse the same four UPN-write holders already counted under ESC9, so the total is unchanged. **H — Trusts (7):** child→parent golden+ExtraSID 519, child→parent inter-realm TGT, raiseChild.py one-shot (`:392-540`, trust `:396`); tyron→DragonsFriends→essos (`:298-302`); daenerys→AcrossTheNarrowSea→kingslanding$ (`:587-591`, `:601`); Small Council→Spys→jorah (`:303-305`, `:317`); daenerys→DragonsFriends→braavos$ (`:299-302`, `:320`). diff --git a/docs/domains-and-users.md b/docs/domains-and-users.md index e26169df..08d41f14 100644 --- a/docs/domains-and-users.md +++ b/docs/domains-and-users.md @@ -33,7 +33,7 @@ Trust: sevenkingdoms.local <──bidirectional──> essos.local | ------ | ---------- | | DC01 (kingslanding) | ADCS, Defender ON | | DC02 (winterfell) | LLMNR, NBT-NS, SMB shares, Defender ON | -| DC03 (meereen) | ADCS custom templates (ESC1, ESC2, ESC3, ESC3-CRA, ESC4, ESC9, ESC13), LAPS DC, NTLM downgrade, Defender ON | +| DC03 (meereen) | ADCS custom templates (ESC1, ESC2, ESC3, ESC3-CRA, ESC4, ESC9, ESC13), weak KDC certificate binding (ESC10 case 1, required by ESC6/ESC9), LAPS DC, NTLM downgrade, Defender ON | | SRV02 (castelblack) | IIS, MSSQL (+ SSMS), WebDAV, SMB shares, Defender OFF | | SRV03 (braavos) | MSSQL, WebDAV, LAPS, SMB shares, RunAsPPL, Defender ON | From edcab5e9d087aa4214f28bf326abe1bbcacee304 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 30 Jul 2026 12:17:45 -0600 Subject: [PATCH 2/2] refactor: split topology gating test into focused functions **Changed:** - Topology gating tests - Split the monolithic `TestTopologyGatedTechniques` into five focused functions (NoADCS, NoChildDomain, NHAWebEnrollment, HasADCS, VariantMatchesGOAD, KDCBound) to keep each under the repo's gocyclo threshold, updating subtest names to drop redundant prefixes - `cli/internal/scoreboard/topology_gating_test.go` - Documented that the KDC restart step is now conditional - `ansible/roles/vulns_adcs_esc10_case1/README.md` --- .../roles/vulns_adcs_esc10_case1/README.md | 2 +- .../scoreboard/topology_gating_test.go | 96 +++++++++++-------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/ansible/roles/vulns_adcs_esc10_case1/README.md b/ansible/roles/vulns_adcs_esc10_case1/README.md index ae714b0b..cc8cef60 100644 --- a/ansible/roles/vulns_adcs_esc10_case1/README.md +++ b/ansible/roles/vulns_adcs_esc10_case1/README.md @@ -16,7 +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) +- **Restart the KDC so the new binding mode takes effect** (ansible.windows.win_service) - Conditional ## Example Playbook diff --git a/cli/internal/scoreboard/topology_gating_test.go b/cli/internal/scoreboard/topology_gating_test.go index e3d9252c..42f7bb96 100644 --- a/cli/internal/scoreboard/topology_gating_test.go +++ b/cli/internal/scoreboard/topology_gating_test.go @@ -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] { @@ -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] { @@ -74,25 +86,29 @@ 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) } - }) + } +} - // ESC6 and ESC9 need a KDC that will accept a weak certificate mapping, and - // 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. +// 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("kdc_bound_adcs/"+lab, func(t *testing.T) { + t.Run(lab, func(t *testing.T) { techs := techniqueSet(t, lab) for _, id := range []string{"adcs_esc6", "adcs_esc9"} { if !techs[id] {