diff --git a/ansible/roles/vulns_adcs_esc10_case1/README.md b/ansible/roles/vulns_adcs_esc10_case1/README.md index d0476c15..cc8cef60 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) - Conditional ## 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/ansible/roles/vulns_adcs_esc13/files/esc13.ps1 b/ansible/roles/vulns_adcs_esc13/files/esc13.ps1 index 3d7de687..2cec9601 100644 --- a/ansible/roles/vulns_adcs_esc13/files/esc13.ps1 +++ b/ansible/roles/vulns_adcs_esc13/files/esc13.ps1 @@ -59,57 +59,55 @@ $ConfigNC = $ADRootDSE.configurationNamingContext $IssuanceName = "IssuancePolicyESC13" $ESC13Template = "CN=$esc13templateName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC" -# Generate a new unique OID -$OID = New-TemplateOID -ConfigNC $ConfigNC - # Define the path to the OID $TemplateOIDPath = "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC" -# Create a new AD object with the generated OID -$oa = @{ - 'DisplayName' = $IssuanceName - 'Name' = $IssuanceName - 'flags' = [System.Int32]'2' - 'msPKI-Cert-Template-OID' = $OID.TemplateOID - } -$theresults = New-ADObject -Path $TemplateOIDPath -OtherAttributes $oa -Name $OID.TemplateName -Type 'msPKI-Enterprise-Oid' - -# Get the new OID object -$OIDContainer = "CN=OID,CN=Public Key Services,CN=Services,"+$ConfigNC -$OIDs = Get-ADObject -Filter * -SearchBase $OIDContainer -Properties DisplayName,Name,msPKI-Cert-Template-OID,msDS-OIDToGroupLink -$newOIDObj = ($OIDS | where {$_.DisplayName -eq $IssuanceName }) -$newOIDValue = $newOIDObj | select -ExpandProperty msPKI-Cert-Template-OID - -# Get the ESC13 template object for updating -$adObject = Get-ADObject $ESC13Template -Properties msPKI-Certificate-Policy - -# Get the current policies -$policies = $adObject.'msPKI-Certificate-Policy' +# Reuse the issuance policy OID if this already ran. Creating one unconditionally +# leaves a second, unlinked OID object behind on every re-run (lab reset), points +# the template at both, and links only one of them. +# @() keeps a single match from unrolling to a bare string, whose [0] would be the +# character "C" rather than a distinguished name. +$existing = @(Get-ADObject -SearchBase $TemplateOIDPath -Filter { DisplayName -eq $IssuanceName } -Properties DisplayName, 'msPKI-Cert-Template-OID') + +if ($existing.Count -gt 1) { + # Converge the duplicate state an earlier run may have left behind. + Write-Output "Removing $($existing.Count - 1) duplicate $IssuanceName OID object(s)" + $existing | Select-Object -Skip 1 | ForEach-Object { + Remove-ADObject -Identity $_.DistinguishedName -Confirm:$false + } + $existing = @($existing | Select-Object -First 1) +} -# Add new OID to the policies -$newPolicy = $newOIDValue # replace with your new OID -$policies = $newPolicy +if ($existing.Count -eq 0) { + $OID = New-TemplateOID -ConfigNC $ConfigNC + $oa = @{ + 'DisplayName' = $IssuanceName + 'Name' = $IssuanceName + 'flags' = [System.Int32]'2' + 'msPKI-Cert-Template-OID' = $OID.TemplateOID + } + New-ADObject -Path $TemplateOIDPath -OtherAttributes $oa -Name $OID.TemplateName -Type 'msPKI-Enterprise-Oid' + $existing = @(Get-ADObject -SearchBase $TemplateOIDPath -Filter { DisplayName -eq $IssuanceName } -Properties DisplayName, 'msPKI-Cert-Template-OID') +} -# Convert policies to an array of strings -$policies = $policies | ForEach-Object { $_.ToString() } +$newOIDObj = $existing | Select-Object -First 1 +$newOIDValue = $newOIDObj.'msPKI-Cert-Template-OID' +$esc13OID_dn = $newOIDObj.DistinguishedName +if (-not $esc13OID_dn) { + throw "Could not resolve the $IssuanceName OID object under $TemplateOIDPath" +} +$esc13OID_dn -# Update the ESC13 template AD object -Set-ADObject -Identity $adObject.DistinguishedName -Replace @{ 'msPKI-Certificate-Policy' = $policies } +# Point the ESC13 template at exactly this issuance policy +$adObject = Get-ADObject $ESC13Template -Properties msPKI-Certificate-Policy +Set-ADObject -Identity $adObject.DistinguishedName -Replace @{ 'msPKI-Certificate-Policy' = $newOIDValue.ToString() } # Get DN of the ESC13 Group $ludus_esc13_group_dn = (Get-ADGroup $esc13group).DistinguishedName $ludus_esc13_group_dn -# Get Distinguished Name of the ESC13 OID Issuance Policy we created -# Thanks to Jonas (https://twitter.com/Jonas_B_K) for helping with this! -$ADRootDSE = Get-ADRootDSE -$ConfigurationNC = $ADRootDSE.configurationNamingContext -$OIDContainer = "CN=OID,CN=Public Key Services,CN=Services,"+$ConfigurationNC -$OIDs = Get-ADObject -Filter * -SearchBase $OIDContainer -Properties DisplayName,Name,msPKI-Cert-Template-OID,msDS-OIDToGroupLink -$esc13OID_dn = ($OIDS | where {$_.DisplayName -eq $IssuanceName }).DistinguishedName[0] -$esc13OID_dn - # Create a DirectoryEntry object for the Issuance Policy OID +# Thanks to Jonas (https://twitter.com/Jonas_B_K) for helping with this! $object = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$esc13OID_dn") # Set the msDS-OIDToGroupLink property to the DN of the ESC13 group @@ -118,3 +116,12 @@ $object.Properties["msDS-OIDToGroupLink"].Value = $Toset $object.CommitChanges() $object.RefreshCache() $object | select msDS-OIDToGroupLink + +# The group link is what makes ESC13 exploitable, and a silent no-op here looks +# identical to success, so confirm it landed in the directory. +$link = Get-ADObject -Identity $esc13OID_dn -Properties 'msDS-OIDToGroupLink' | + Select-Object -ExpandProperty 'msDS-OIDToGroupLink' +if ($link -ne $ludus_esc13_group_dn) { + throw "msDS-OIDToGroupLink on $esc13OID_dn is '$link', expected '$ludus_esc13_group_dn'" +} +Write-Output "ESC13 linked: $esc13OID_dn -> $link" diff --git a/ansible/roles/vulns_adcs_esc7/tasks/main.yml b/ansible/roles/vulns_adcs_esc7/tasks/main.yml index 2b113298..060696ea 100644 --- a/ansible/roles/vulns_adcs_esc7/tasks/main.yml +++ b/ansible/roles/vulns_adcs_esc7/tasks/main.yml @@ -32,9 +32,17 @@ ansible.windows.win_powershell: script: | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module PSPKI -Force -ErrorAction Stop - if (-not (Get-Module -ListAvailable -Name PSPKI)) { - throw "PSPKI module installation failed" + # Guard the install the same way the NuGet task above does: -Force means a + # re-install would not error, but it still reaches out to PSGallery, so on + # a lab that already has PSPKI any egress hiccup becomes a fatal. + if (Get-Module -ListAvailable -Name PSPKI) { + $Ansible.Changed = $false + } else { + Install-Module PSPKI -Force -ErrorAction Stop + if (-not (Get-Module -ListAvailable -Name PSPKI)) { + throw "PSPKI module installation failed" + } + $Ansible.Changed = $true } error_action: stop diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index 2901ca3e..ff902c6c 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -6,8 +6,10 @@ package validate import ( "context" _ "embed" + "errors" "fmt" "io" + "strconv" "strings" "github.com/dreadnode/dreadgoad/internal/labmap" @@ -747,8 +749,7 @@ func (v *Validator) checkADCSESC6(ctx context.Context, w io.Writer) { v.addResult(w, "WARN", "ADCS-ESC6", fmt.Sprintf("EditFlags query error on %s: %s", hostLabel, r.Error), "") case r.Present: - v.addResult(w, "PASS", "ADCS-ESC6", - fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 set on %s (ESC6 exploitable)", hostLabel), "") + v.checkESC6KDCBinding(ctx, w, role, hostLabel) default: v.addResult(w, "FAIL", "ADCS-ESC6", fmt.Sprintf("EDITF_ATTRIBUTESUBJECTALTNAME2 NOT set on %s", hostLabel), "") @@ -756,6 +757,97 @@ func (v *Validator) checkADCSESC6(ctx context.Context, w io.Writer) { } } +// 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. +// +// 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 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(adcsRole); domain != "" { + dcRole = v.lab.DCForDomain(domain) + } + } + if dcRole == "" { + return kdcBinding{}, errors.New("no DC resolved for its domain") + } + dcHost := strings.ToUpper(dcRole) + if !v.hasHost(dcHost) { + return kdcBinding{DCLabel: dcHost}, fmt.Errorf("validating DC %s is unreachable", dcHost) + } + + 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`, + "Name": "StrongCertificateBindingEnforcement", + }) + switch { + case err != nil: + 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("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, 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, b.Value, b.DCLabel), "") + } +} + func (v *Validator) checkADCSESC10(ctx context.Context, w io.Writer) { printHeader(w, "ADCS ESC10 - Weak Certificate Mapping") @@ -2620,10 +2712,93 @@ func (v *Validator) checkADCSESC9(ctx context.Context, w io.Writer) { if len(asrepDCs) > 0 && !found { 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 +// object it creates under the forest OID container. +const esc13IssuanceName = "IssuancePolicyESC13" + // checkADCSESC13 verifies the ESC13 template's msPKI-Certificate-Policy is -// populated (the esc13.ps1 script writes the issuance policy OID into it). +// populated (the esc13.ps1 script writes the issuance policy OID into it) and +// that the issuance policy OID carries an msDS-OIDToGroupLink. The policy +// attribute alone is not enough: a template can reference an OID that links to +// no group, which leaves ESC13 unexploitable while still looking configured. func (v *Validator) checkADCSESC13(ctx context.Context, w io.Writer) { printHeader(w, "ADCS ESC13 - Issuance Policy Link") @@ -2656,7 +2831,68 @@ func (v *Validator) checkADCSESC13(ctx context.Context, w io.Writer) { v.addResult(w, "PASS", "ADCS-ESC13", fmt.Sprintf("ESC13 issuance policy set on %s: %s", queryHost, strings.TrimSpace(output)), "") } + v.checkESC13GroupLink(ctx, w, queryHost) + } +} + +// checkESC13GroupLink verifies the issuance policy OID object is linked to a +// group via msDS-OIDToGroupLink, and that exactly one such OID object exists. +func (v *Validator) checkESC13GroupLink(ctx context.Context, w io.Writer, dc string) { + output, err := runScriptTextErr(ctx, v, dc, + `$base = "CN=OID,CN=Public Key Services,CN=Services," + (Get-ADRootDSE).configurationNamingContext; `+ + `$oids = @(Get-ADObject -SearchBase $base -Filter {DisplayName -eq {{psq .Name}}} `+ + `-Properties DisplayName,'msDS-OIDToGroupLink' -ErrorAction SilentlyContinue); `+ + `if ($oids.Count -eq 0) { Write-Output 'NO_OID'; exit }; `+ + `$linked = @($oids | Where-Object { $_.'msDS-OIDToGroupLink' }); `+ + `Write-Output ("COUNT=" + $oids.Count + " LINKED=" + $linked.Count + " GROUP=" + $linked[0].'msDS-OIDToGroupLink')`, + map[string]any{"Name": esc13IssuanceName}) + count, linked, parsed := parseESC13GroupLink(output) + switch { + case err != nil: + v.addResult(w, "WARN", "ADCS-ESC13", + fmt.Sprintf("Could not query %s OID on %s: %v", esc13IssuanceName, dc, err), "") + case strings.Contains(output, "NO_OID"): + v.addResult(w, "FAIL", "ADCS-ESC13", + fmt.Sprintf("No %s OID object on %s (esc13.ps1 has not run)", esc13IssuanceName, dc), "") + case !parsed: + v.addResult(w, "WARN", "ADCS-ESC13", + fmt.Sprintf("Unreadable %s OID probe output on %s: %q", esc13IssuanceName, dc, strings.TrimSpace(output)), "") + case linked == 0: + v.addResult(w, "FAIL", "ADCS-ESC13", + fmt.Sprintf("%s OID on %s has no msDS-OIDToGroupLink (ESC13 is not exploitable)", esc13IssuanceName, dc), "") + case count != 1: + v.addResult(w, "WARN", "ADCS-ESC13", + fmt.Sprintf("Duplicate %s OID objects on %s: %s", esc13IssuanceName, dc, strings.TrimSpace(output)), "") + default: + v.addResult(w, "PASS", "ADCS-ESC13", + fmt.Sprintf("ESC13 OID linked on %s: %s", dc, strings.TrimSpace(output)), "") + } +} + +// parseESC13GroupLink pulls COUNT and LINKED out of the probe's key=value line. +// It splits fields rather than substring-matching because "COUNT=10" contains +// "COUNT=1": a lab that accumulated ten stale OID objects would otherwise read +// as the healthy single-object case. Trailing GROUP= is a DN that may itself +// contain spaces and "=", so only the two integer keys are trusted. +func parseESC13GroupLink(output string) (count, linked int, ok bool) { + var haveCount, haveLinked bool + for _, field := range strings.Fields(output) { + key, val, found := strings.Cut(field, "=") + if !found { + continue + } + n, err := strconv.Atoi(val) + if err != nil { + continue + } + switch key { + case "COUNT": + count, haveCount = n, true + case "LINKED": + linked, haveLinked = n, true + } } + return count, linked, haveCount && haveLinked } // ---- Section 16: DNS / Audit ---- diff --git a/cli/internal/validate/esc13_link_test.go b/cli/internal/validate/esc13_link_test.go new file mode 100644 index 00000000..41ec6534 --- /dev/null +++ b/cli/internal/validate/esc13_link_test.go @@ -0,0 +1,148 @@ +package validate + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/provider" +) + +// esc13.ps1 sets the template's msPKI-Certificate-Policy before it links the +// issuance policy OID to a group, so a run that dies in between leaves the lab +// looking configured while ESC13 is not actually exploitable. The group-link +// probe is what distinguishes those states. +func TestCheckESC13GroupLink(t *testing.T) { + tests := []struct { + name string + stdout string + wantStatus string + wantDetail string + }{ + { + name: "linked single OID passes", + stdout: "COUNT=1 LINKED=1 GROUP=CN=greatmaster,OU=Groups,DC=essos,DC=local\n", + wantStatus: "PASS", + }, + { + name: "OID present but unlinked fails", + stdout: "COUNT=1 LINKED=0 GROUP=\n", + wantStatus: "FAIL", + wantDetail: "no msDS-OIDToGroupLink", + }, + { + name: "no OID object at all fails", + stdout: "NO_OID\n", + wantStatus: "FAIL", + wantDetail: "has not run", + }, + { + name: "duplicate OID objects warn", + stdout: "COUNT=2 LINKED=1 GROUP=CN=greatmaster,OU=Groups,DC=essos,DC=local\n", + wantStatus: "WARN", + wantDetail: "Duplicate", + }, + { + // One stale OID accrues per re-provision, so a long-lived lab reaches + // double digits. "COUNT=10" contains "COUNT=1", which a substring test + // would read as the healthy single-object case. + name: "ten duplicate OID objects still warn", + stdout: "COUNT=10 LINKED=1 GROUP=CN=greatmaster,OU=Groups,DC=essos,DC=local\n", + wantStatus: "WARN", + wantDetail: "Duplicate", + }, + { + name: "unparsable probe output warns rather than passing", + stdout: "Get-ADObject : A referral was returned from the server\n", + wantStatus: "WARN", + wantDetail: "Unreadable", + }, + } + + 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: tt.stdout}, nil + }) + v.silent = true + v.hosts["DC03"] = "i-dc03" + + v.checkESC13GroupLink(context.Background(), io.Discard, "DC03") + + 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 (detail: %q)", got.Status, tt.wantStatus, got.Detail) + } + if tt.wantDetail != "" && !strings.Contains(got.Name, tt.wantDetail) { + t.Errorf("message %q does not mention %q", got.Name, tt.wantDetail) + } + }) + } +} + +// A transport failure must not be reported as a missing link: an unreachable DC +// is unknown, not broken. The context is canceled up front so the shared +// transport retry (a hardcoded 2s backoff) short-circuits instead of sleeping. +func TestCheckESC13GroupLink_TransportErrorWarns(t *testing.T) { + v, _ := newStubValidator(t, func(_ int, _ string) (*provider.CommandResult, error) { + return nil, context.DeadlineExceeded + }) + v.silent = true + v.hosts["DC03"] = "i-dc03" + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + v.checkESC13GroupLink(ctx, io.Discard, "DC03") + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + if got := v.report.Results[0].Status; got != "WARN" { + t.Errorf("status = %q, want WARN", got) + } +} + +// The GROUP= field is a DN carrying both spaces and "=", so field-splitting the +// probe line must not let DN components masquerade as the integer keys. +func TestParseESC13GroupLink(t *testing.T) { + tests := []struct { + name string + output string + wantCount int + wantLinked int + wantOK bool + }{ + {"single linked", "COUNT=1 LINKED=1 GROUP=CN=greatmaster,DC=essos,DC=local", 1, 1, true}, + {"double digit count", "COUNT=10 LINKED=1 GROUP=CN=greatmaster,DC=essos,DC=local", 10, 1, true}, + {"unlinked empty group", "COUNT=1 LINKED=0 GROUP=", 1, 0, true}, + {"DN with spaces does not shadow keys", "COUNT=2 LINKED=2 GROUP=CN=Domain Admins,CN=Users,DC=essos,DC=local", 2, 2, true}, + {"missing keys", "NO_OID", 0, 0, false}, + {"non-numeric values", "COUNT=many LINKED=some", 0, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + count, linked, ok := parseESC13GroupLink(tt.output) + if ok != tt.wantOK || count != tt.wantCount || linked != tt.wantLinked { + t.Errorf("parseESC13GroupLink(%q) = (%d, %d, %v), want (%d, %d, %v)", + tt.output, count, linked, ok, tt.wantCount, tt.wantLinked, tt.wantOK) + } + }) + } +} + +// The probe must render to valid PowerShell with the OID DisplayName quoted. +func TestESC13GroupLinkProbe_Renders(t *testing.T) { + got, err := renderScript(`-Filter {DisplayName -eq {{psq .Name}}}`, map[string]any{"Name": esc13IssuanceName}) + if err != nil { + t.Fatalf("renderScript: %v", err) + } + want := `-Filter {DisplayName -eq 'IssuancePolicyESC13'}` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/cli/internal/validate/esc6_kdc_test.go b/cli/internal/validate/esc6_kdc_test.go new file mode 100644 index 00000000..515f45f7 --- /dev/null +++ b/cli/internal/validate/esc6_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" +) + +// kdcEnvelope wraps a payload the way registry_dword.ps1 does; runScriptJSON +// discards anything without the markers. +func kdcEnvelope(payload string) string { + return "===BEGIN_JSON===\n" + payload + "\n===END_JSON===\n" +} + +// 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 kdcBindingLab() *labmap.LabMap { + return &labmap.LabMap{ + Hosts: map[string]labmap.HostInfo{ + "srv03": {NewHostname: "braavos", NewDomain: "essos.local"}, + "dc03": {NewHostname: "meereen", NewDomain: "essos.local"}, + }, + HostConfigs: map[string]labmap.HostConfig{ + "srv03": {Hostname: "braavos", Type: "server", Domain: "essos.local"}, + "dc03": {Hostname: "meereen", Type: "dc", Domain: "essos.local"}, + }, + DomainConfigs: map[string]labmap.DomainConfig{ + "essos.local": {DC: "dc03", CAServer: "braavos"}, + }, + } +} + +// The CA-side EDITF_ATTRIBUTESUBJECTALTNAME2 bit does not establish that ESC6 +// can win. ESC6 issues off the stock User template, so the SAN it injects rides +// alongside a security extension carrying the *requester's* SID. Only a KDC at +// StrongCertificateBindingEnforcement=0 ignores that extension; 1 +// (Compatibility) still validates a present extension strictly and rejects the +// mismatch. Passing on anything but 0 reports an exploit that cannot land. +func TestCheckESC6KDCBinding(t *testing.T) { + tests := []struct { + name string + stdout string + wantStatus string + wantDetail string + }{ + { + name: "SCBE=0 is the only exploitable case", + stdout: `{"present":true,"value":0,"error":""}`, + wantStatus: "PASS", + wantDetail: "ESC6 exploitable", + }, + { + // 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", + wantDetail: "NOT exploitable", + }, + { + name: "SCBE=2 full enforcement rejects it too", + stdout: `{"present":true,"value":2,"error":""}`, + wantStatus: "FAIL", + wantDetail: "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: "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, func(_ int, _ string) (*provider.CommandResult, error) { + return &provider.CommandResult{Status: "Success", Stdout: kdcEnvelope(tt.stdout)}, nil + }) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") + + 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 tt.wantDetail != "" && !strings.Contains(got.Name, tt.wantDetail) { + t.Errorf("message %q does not mention %q", got.Name, tt.wantDetail) + } + }) + } +} + +// The verdict must name the DC it read, not the CA. Attributing an enforcement +// value to the CA host is how the cross-forest split stayed invisible: every +// 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: kdcEnvelope(`{"present":true,"value":1,"error":""}`)}, nil + }) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") + + msg := v.report.Results[0].Name + if !strings.Contains(msg, "MEEREEN") { + t.Errorf("message must name the validating DC MEEREEN, got %q", msg) + } + if !strings.Contains(msg, "BRAAVOS") { + t.Errorf("message must still name the CA BRAAVOS, got %q", msg) + } +} + +// The registry read must target the KDC service key. A typo here silently turns +// every lab into the "absent, so unknown" branch, which looks like a cautious +// WARN rather than a broken probe. +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: kdcEnvelope(`{"present":true,"value":0,"error":""}`)}, nil + }) + v.silent = true + v.lab = kdcBindingLab() + v.hosts = map[string]string{"SRV03": "i-srv03", "DC03": "i-dc03"} + + v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") + + if got := v.report.Results[0].Status; got != "PASS" { + t.Fatalf("setup: expected PASS, got %q", got) + } + if !strings.Contains(gotScript, `Services\Kdc`) { + t.Errorf("probe must read the KDC service key, got script: %q", gotScript) + } + if !strings.Contains(gotScript, "StrongCertificateBindingEnforcement") { + t.Errorf("probe must read StrongCertificateBindingEnforcement, got script: %q", gotScript) + } +} + +// 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: kdcEnvelope(`{"present":true,"value":0,"error":""}`)}, nil + }) + v.silent = true + v.lab = &labmap.LabMap{ + Hosts: map[string]labmap.HostInfo{"srv03": {NewHostname: "braavos"}}, + HostConfigs: map[string]labmap.HostConfig{"srv03": {Hostname: "braavos"}}, + DomainConfigs: map[string]labmap.DomainConfig{}, + } + v.hosts = map[string]string{"SRV03": "i-srv03"} + + v.checkESC6KDCBinding(context.Background(), io.Discard, "srv03", "BRAAVOS") + + if len(v.report.Results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(v.report.Results)) + } + if got := v.report.Results[0].Status; got != "WARN" { + t.Errorf("status = %q, want WARN", got) + } +} 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/cli/internal/validate/validator_test.go b/cli/internal/validate/validator_test.go index 7abc784a..e712abb3 100644 --- a/cli/internal/validate/validator_test.go +++ b/cli/internal/validate/validator_test.go @@ -213,10 +213,9 @@ func (c *stdoutCapture) restore() string { // ---- Additional tests for validator.go and checks.go ---- func TestNewValidator_Defaults(t *testing.T) { + // NewValidator always returns a non-nil *Validator, so there is nothing to + // guard against here; assert the defaults it fills in instead. v := NewValidator(nil, "testenv", false, nil, nil) - if v == nil { - t.Fatal("NewValidator returned nil") - } if v.env != "testenv" { t.Errorf("env = %q, want %q", v.env, "testenv") } 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/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 |