{"uuid": "3311f49c-9bd2-4312-8211-981993f5f1e9", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-54121", "type": "seen", "source": "https://gist.github.com/Amr-Hamza/2c62db7914457ef7bfe2649f021b4473", "content": "# Certighost (CVE-2026-54121)\n\n**Authors:** [@h0j3n](https://x.com/h0j3n), [@aniqfakhrul](https://x.com/aniqfakhrul)  \n**Date:** July 24, 2026\n\n---\n\nCertighost is an Active Directory Certificate Services (AD CS) vulnerability that allowed a low-privileged domain user to impersonate a Domain Controller and achieve domain compromise in the tested AD CS configuration. The issue was addressed in the July 2026 security updates.\n\nThe vulnerable path is an AD CS enrollment fallback known as a *chase* during directory-object resolution. By supplying request attributes such as `cdc`, an attacker could cause the Certification Authority (CA) to ask an attacker-controlled host for identity data belonging to a Domain Controller. The CA then used that data while issuing a certificate.\n\n\n  \n\n\nThis post explains how the chase path works, why it could be abused, and what changed in the update.\n\n## Introduction to Active Directory Certificate Services\n\nIf you already work with AD CS, feel free to skip ahead. For the rest of the post, it is enough to think of a certificate as a signed proof of identity. Like a Kerberos ticket, it can be presented during authentication to show who a user or computer is supposed to be.\n\nAD CS is Microsoft's public key infrastructure implementation. It integrates with Active Directory and issues X.509 certificates for purposes such as encryption, signing, secure communications, and authentication.\n\nA certificate binds a subject to a public key. The private key stays with the requester, while the certificate contains the public key and identity information signed by a Certification Authority. A service that trusts the CA can use that signature to trust the identity in the certificate.\n\nCertificate templates control the enrollment process. A template decides who may request a certificate, what it can be used for, what identity information must be present, and whether the CA may accept information supplied in the request.\n\nThe relevant piece here is certificate-based client authentication. A client requests a certificate from an Enterprise CA and later presents it to the Key Distribution Center (KDC) to obtain Kerberos credentials.\n\n### PKINIT and certificate mapping\n\nTo make this concrete, consider a small lab domain named `ABC.LOCAL` with AD CS installed. A low-privileged account can request a certificate from a template it is permitted to enroll in, then use that certificate to authenticate to the KDC.\n\n![Requesting and authenticating with a certificate using Certipy](https://gist.github.com/user-attachments/assets/c7d6f12d-d1bc-451c-b861-eb2863060173)\n\n\nRequesting and authenticating with a certificate using Certipy\n\nDuring certificate authentication, the KDC maps the identity in the certificate to an Active Directory account and issues a Kerberos TGT when the mapping succeeds.\n\n## Background: the chase fallback\n\nBefore getting to the bug, we need to look at one enrollment behavior. In some cross-domain-controller enrollment scenarios, the CA may perform a second directory lookup, referred to here as a *chase*.\n\nTwo request attributes influence that lookup:\n\n- `cdc` (Client DC), which identifies the host the CA should contact\n- `rmd` (Remote Domain), which identifies the principal the CA should look up\n\nWhen both attributes are present, the CA contacts the host named by `cdc` over SMB and LDAP, then searches for the principal named by `rmd`. The request-processing path reads these values and passes them into the remote directory lookup.\n\n\n\n  \n\n\n\nWireshark capture showing the CA following the requester-controlled cdc target to the attacker host over SMB/LSA and LDAP.\n\n## The vulnerability: Certighost\n\nThe CA accepted the requester-supplied chase target without first proving that it was the Domain Controller it claimed to be. That made it possible for an attacker to run LDAP and LSA services on a host they controlled, direct the CA to that host, and return directory data for a chosen target principal.\n\nIn the vulnerable flow, the attacker supplies `cdc` and `rmd` in a certificate request. The CA opens SMB and LDAP connections to the address in `cdc`, performs the chase lookup for the principal in `rmd`, and uses the returned identity data while building the certificate.\n\nThe issue is not just the outbound connection. The CA used the response from that host as directory data for the request. In the tested chain, the attacker could return a Domain Controller's `objectSid` and `dNSHostName`. Those values influenced the certificate identity material issued by the CA, including the strong-mapping SID and DNS identity fields used during authentication.\n\nA machine account created through the default `ms-DS-MachineAccountQuota` setting is a valid domain principal. This allowed the attacker-controlled chase endpoint to satisfy the authentication checks needed for the CA to continue, even though it was not the Domain Controller being impersonated.\n\n\n\n  \n\n\n\nVulnerable chase flow: the CA follows requester-controlled cdc, accepts attacker-supplied directory data, and issues certificate identity material for the target DC.\n\nThe diagram follows the three parties involved in the vulnerable chase: the attacker prepares a valid machine account and rogue services, the CA follows the requester-controlled `cdc` target and trusts the returned identity data, and the Domain Controller is then impersonated through certificate-based authentication and replication access.\n\n## Proof of concept walkthrough\n\n### Lab environment\n\nOur test environment contained:\n\n- An AD forest at Windows Server 2016 or later functional level\n- Domain Controller: `WDC01.abc.local` (`192.168.8.128`)\n- Enterprise CA: `abc-WCA01-CA` on `WCA01.abc.local` (`192.168.8.129`)\n- The default `ms-DS-MachineAccountQuota` value of `10`\n- The default `Machine` certificate template with its default access control list\n- An attacker host at `192.168.8.134`\n- One low-privileged Domain Users account: `normaluser`\n\n### Step 1: Install dependencies\n\nThe proof of concept is a self-contained Python script. It requires impacket, cryptography, pyasn1, asn1crypto, and dnspython.\n\n```bash\nsudo pip install --break-system-packages git+https://github.com/fortra/impacket.git cryptography pyasn1 asn1crypto pycryptodome dnspython\n```\n\n### Step 2: Run the proof of concept and observe the result\n\n```bash\nsudo python3 certighost.py \\\n  -d abc.local \\\n  -u normaluser -p '[PASSWORD]' \\\n  --dc-ip 192.168.8.128\n```\n\nThe script discovers the CA and a target Domain Controller, creates a machine account, starts the local LDAP and LSA services, submits the crafted enrollment request, and produces the terminal output shown below.\n\n![Terminal output of a successful Certighost run](https://gist.github.com/user-attachments/assets/7ad0a7a7-edf7-4b50-8c69-7e0c91ed2ae4)\n\n\nTerminal output of a successful Certighost run\n\nAt this point, the attacker has a CA-signed certificate that authenticates as the target Domain Controller in the tested configuration and a Kerberos credential cache for that account.\n\n### Step 3: DCSync\n\nA Domain Controller account has directory replication rights. With the resulting Kerberos credential, the attacker can request account secrets, including the `krbtgt` secret.\n\n![Successful DCSync output showing the `krbtgt` account](https://gist.github.com/user-attachments/assets/64e7a484-87d4-46fc-a3d7-c2b600ffe9cd)\n\n\nSuccessful DCSync output showing the `krbtgt` account\n\n### What the proof of concept does\n\nThe script follows the full chain in one process:\n\n1. It connects to LDAP with the supplied low-privileged account and discovers the CA, Domain Controller, domain SID, and domain GUID.\n2. It creates a machine account through `ms-DS-MachineAccountQuota` and registers the required service principal names.\n3. It starts local LSA and LDAP services. The services relay the CA's authentication challenge to the real Domain Controller so the chase endpoint is accepted as a valid domain principal.\n4. It submits a certificate request containing the `cdc` and `rmd` attributes.\n5. It receives a certificate carrying identity material for the target Domain Controller, including SID and DNS-name data used by the authentication flow.\n6. It uses the certificate to authenticate as the target Domain Controller and writes the resulting credential cache.\n\n## Post-update behavior\n\nThe July 2026 update changes the CA-side request path. Before continuing a chase, the CA validates the supplied `cdc` target against Active Directory.\n\nThe update adds a validation step to the principal-loading path. When the validation gate is active, the CA checks that the hostname supplied in `cdc` resolves to a legitimate Domain Controller computer object in the real directory before it continues the chase. A validation failure takes the request down an error path rather than the chase continuation.\n\n| Component | Before the update | After the update |\n|---|---|---|\n| Chase target | The CA followed the requester-supplied target | The CA validates the supplied `cdc` hostname against the real directory |\n| Principal lookup | The remote response was treated as authoritative | The target must resolve to a legitimate Domain Controller computer object |\n| Certificate identity data | Returned SID and hostname could be attacker-controlled | Resolution proceeds only after target validation and a follow-on SID comparison |\n| Certificate request result | A forged chase response could lead to issuance | A failed validation or SID comparison takes an error path |\n\nThe fix addresses the trust boundary that made Certighost possible: a chase endpoint is no longer accepted merely because it responds as a domain principal. The July binary contains a gate around the new validation, so the protection applies when that gate and its validation path are active.\n\nThe diagram below summarizes the July update path. It shows which checks must pass before the CA continues the chase, and which checks stop the request before that point.\n\n\n\n\nJuly update flow: the CA validates the chase target against AD before allowing the referral chase to continue.\n\nRead the diagram from top to bottom: the request enters the validation path, each check answers one yes/no question, and the request must pass every check before the chase continues. A failed check stops the request before that point. The specific conditions behind each check - length and character checks, the Domain Controller computer-object requirement, and the resolved-SID comparison - are described in the paragraph below.\n\nThe validation function rejects overlong and IP-literal targets, blocks the filtered-character set before constructing the LDAP query, and requires a unique computer object whose `userAccountControl` includes `SERVER_TRUST_ACCOUNT` (`8192`). If the first directory search has no match, the code retries with an extracted base DN. The loader only reaches the referral continuation after a successful validation return; it also contains a later resolved-SID comparison. These control-flow observations were checked against the hash-matched July DLL.\n\n\nDetailed patched flow:\n\n```text\n1. User submits cert enrollment request with cdc=\"attacker.example.com\"\n2. _LoadPrincipalObject() calls _GetDSObject() -&gt; fails (referral needed)\n3. polGetRequestAttribute(\"cdc\", &amp;bstrString) -&gt; reads attacker's hostname\n4. NEW: Feature_3185813818 check (servicing flag)\n   - DISABLED -&gt; old behavior (backward compat)\n   - ENABLED -&gt; _ValidateChaseTargetIsDC(bstrString)\n      - Reject if empty\n      - Reject if &gt; 260 chars\n      - Reject if IPv4 address (RtlIpv4StringToAddressW)\n      - Reject if IPv6 address (RtlIpv6StringToAddressW)\n      - Reject if contains LDAP injection chars: ( ) * [ \\ ]\n      - LDAP search: (&amp;(objectCategory=computer)(dNSHostName=)\n                       (userAccountControl:1.2.840.113556.1.4.803:=8192))\n         - 8192 = SERVER_TRUST_ACCOUNT = must be a real DC\n         - If 0 results -&gt; try with extracted base DN (_ExtractBaseDNForDCSearch)\n         - If still 0 -&gt; REJECT\n5. Only if validation passes -&gt; _GetDSObject(a2, 1, bstrString) -&gt; connect to verified DC\n6. NEW: _GetObjectSID() -&gt; verify resolved object's SID matches requester's SID\n   - If SID mismatch -&gt; REJECT (prevents object substitution)\n```\n\n## Code analysis\n\nThe proof-of-concept shows the impact, but the code path explains the root cause. The relevant logic sits in `certpdef.dll`, the AD CS Certificate Policy Enterprise module loaded by `certsrv.exe`. The vulnerable behavior is in the request-principal loading path, where request-supplied attributes are used while resolving the requester identity.\n\nWe compare the June and July builds because June is the vulnerable baseline and July is the Microsoft patched build. That makes the July-only functions useful markers for the fix.\n\nThe functions that matter for the request path are:\n\n| Build | Function | Why it matters |\n|---|---|---|\n| June / vulnerable | `CRequestInstance::_LoadPrincipalObject` | Vulnerable integration point; reads the requester-controlled chase target. |\n| June / vulnerable | `CRequestInstance::_GetDSObject` | Directory lookup/chase sink reached with the untrusted `cdc` host. |\n| June / vulnerable | `CRequestInstance::_GetObjectSID` | Original SID retrieval path before the July identity-verification changes. |\n| July / patched | `CRequestInstance::_ExtractBaseDNForDCSearch` | Helper used by the DC verification LDAP search. |\n| July / patched | `CRequestInstance::_ValidateChaseTargetIsDC` | Validates that `cdc` resolves to a real Domain Controller. |\n| July / patched | `CRequestInstance::_LoadPrincipalObject` | Patched integration point; calls validation before the chase. |\n| July / patched | `CRequestInstance::_GetDSObject` | Same logical sink, now reached only after validation when the protected path is active. |\n| July / patched | `CRequestInstance::_GetObjectSID` | Updated identity/SID verification path after object resolution. |\n| July / patched | `Feature_3185813818` | Servicing feature gate around the updated behavior. |\n\nThe July-only pieces to look for are `CRequestInstance::_ValidateChaseTargetIsDC`, `CRequestInstance::_ExtractBaseDNForDCSearch`, and the `Feature_3185813818` gate.\n\n### June vulnerable build flow\n\nIn the June vulnerable build, `CRequestInstance::_LoadPrincipalObject` reads the `cdc` request attribute and passes it directly into `_GetDSObject` with chase enabled.\n\n```c\n// decompiled shape from CRequestInstance::_LoadPrincipalObject\npolGetRequestAttribute(policy, L\"cdc\", &amp;bstrString);\n\nCRequestInstance::_GetDSObject(this, policy, 1, bstrString);\n//                                           ^ chase enabled\n//                                              ^ requester-controlled host\n```\n\nThe request-to-directory path is:\n\n```text\ncertificate request attribute -&gt; cdc BSTR -&gt; _GetDSObject(..., chase = 1, cdc)\n```\n\nIn other words, requester-controlled routing data reached a directory-authority decision before the CA had checked that the target was a Domain Controller.\n\n### July patched build flow\n\nThe July patched build wraps the same chase with a feature-gated validation call and then performs an additional SID verification path.\n\n```c\n// decompiled shape from CRequestInstance::_LoadPrincipalObject\npolGetRequestAttribute(policy, L\"cdc\", &amp;bstrString);\n\nif (Feature_3185813818::IsEnabled()) {\n    CRequestInstance::_ValidateChaseTargetIsDC(this, bstrString);\n\n    CRequestInstance::_GetDSObject(this, policy, 1, bstrString);\n\n    CRequestInstance::_GetObjectSID(this, &amp;resolved_sid, mode);\n}\n```\n\nThere is still an legacy behavior branch visible in the patched control flow:\n\n```c\nCRequestInstance::_GetDSObject(this, policy, 1, bstrString);\n```\n\nThat branch is reached when the servicing feature gate is not enabled. For lab validation, the feature state matters because the protected path is conditional.\n\n## Code analysis: `_ValidateChaseTargetIsDC`\n\n`CRequestInstance::_ValidateChaseTargetIsDC` is the main new guard. It takes the requester-supplied `cdc` value and checks that it is a Domain Controller hostname before `_GetDSObject` follows the chase.\n\nThe validation has two layers.\n\nFirst, it rejects values that are not safe DC hostnames:\n\n```c\n// 1. Strip leading backslashes, then reject empty input\nwhile (*target == L'\\\\')\n    target++;\nif (*target == L'\\0')\n    reject;\n\n// 2. Reject oversized hostnames\nif (wcslen(target) &gt; 0x104)\n    reject;\n\n// 3. Reject IPv4 and IPv6 literals\nif (RtlIpv4StringToAddressW(target, TRUE, &amp;end, &amp;ipv4) == STATUS_SUCCESS &amp;&amp; *end == 0)\n    reject;\n\nif (RtlIpv6StringToAddressW(target, &amp;end, &amp;ipv6) == STATUS_SUCCESS &amp;&amp; *end == 0)\n    reject;\n\n// 4. Reject LDAP filter metacharacters\nfor (p = target; *p; p++) {\n    if ((*p - 0x28) &lt;= 2 || (*p - 0x5b) &lt;= 2)\n        reject;\n}\n```\n\nThe IP checks block raw-address chase targets such as `192.0.2.10` or `::1`. The metacharacter check keeps the hostname inside the LDAP filter slot.\n\nAfter the string checks, the function asks Active Directory whether the target is a DC:\n\n```text\n(&amp;(objectCategory=computer)(dNSHostName=%s)(userAccountControl:1.2.840.113556.1.4.803:=8192))\n```\n\n`8192` is `SERVER_TRUST_ACCOUNT`, the `userAccountControl` bit used by Domain Controller computer accounts. The updated code searches for exactly one computer object with that bit set and a `dNSHostName` matching the supplied `cdc` value.\n\nThe search path is roughly:\n\n```c\nldap_search_ext_sW(ldap, NULL, LDAP_SCOPE_SUBTREE,\n                   filter, NULL, TRUE, NULL, NULL,\n                   &amp;timeout, 1, &amp;result);\n\ncount = ldap_count_entries(ldap, result);\n\nif (count == 0) {\n    base_dn = CRequestInstance::_ExtractBaseDNForDCSearch(this);\n    ldap_search_ext_sW(ldap, base_dn, LDAP_SCOPE_SUBTREE,\n                       filter, NULL, TRUE, NULL, NULL,\n                       &amp;timeout, 1, &amp;result);\n    count = ldap_count_entries(ldap, result);\n}\n\nif (count != 1)\n    reject;\n```\n\nThe missing check was simple but critical: the chase target must be an AD-registered Domain Controller, not just a host string from the certificate request.\n\n## Why the update fixes the bug\n\nBefore the update, the vulnerable chain was:\n\n```text\nattacker request -&gt; cdc=\"attacker host\" -&gt; CA LDAP chase -&gt; attacker response -&gt; issued identity\n```\n\nAfter the update, the intended chain is:\n\n```text\nattacker request -&gt; cdc=\"some host\"\n        |\n        v\nvalidate hostname shape\n        |\n        v\nquery AD for dNSHostName= AND SERVER_TRUST_ACCOUNT\n        |\n        v\nrequire exactly one real DC object\n        |\n        v\nonly then call _GetDSObject(..., chase = 1, cdc)\n        |\n        v\nverify resolved SID / identity\n```\n\nThe update changes two things:\n\n1. **Target authenticity:** the chase target must be an AD-registered Domain Controller, not an arbitrary requester-controlled host.\n2. **Object authenticity:** the object resolved through the chase must match the expected identity, preventing substitution even if the lookup path is unusual.\n\n## Functions of interest\n\nFor security researchers tracing the request path, start with these functions:\n\n```text\nJune vulnerable build\n  CRequestInstance::_LoadPrincipalObject\n  CRequestInstance::_GetDSObject\n  CRequestInstance::_GetObjectSID\n\nJuly patched build\n  Feature_3185813818\n  CRequestInstance::_ExtractBaseDNForDCSearch\n  CRequestInstance::_ValidateChaseTargetIsDC\n  CRequestInstance::_LoadPrincipalObject\n  CRequestInstance::_GetDSObject\n  CRequestInstance::_GetObjectSID\n```\n\nGood code references for the writeup are:\n\n1. June `_LoadPrincipalObject` showing `cdc` flowing into `_GetDSObject`.\n2. July `_LoadPrincipalObject` showing the call to `_ValidateChaseTargetIsDC` before `_GetDSObject`.\n3. July `_ValidateChaseTargetIsDC` showing the IP rejection and LDAP filter construction.\n4. July `_ValidateChaseTargetIsDC` showing the `userAccountControl:1.2.840.113556.1.4.803:=8192` DC query.\n5. July `_GetObjectSID` or its caller site showing the added SID verification path.\n\n## Hot fix without the update: disable the chase fallback\n\nIf the July update cannot be installed immediately, the vulnerable code path can be switched off completely with a policy flag, because the `cdc` chase is an optional fallback feature, not code that runs on every request:\n\n```powershell\ncertutil -setreg policy\\EditFlags -EDITF_ENABLECHASECLIENTDC\nRestart-Service CertSvc -Force\n```\n\nThis is a mitigation, not a patch. If `EDITF_ENABLECHASECLIENTDC` is re-enabled on an unpatched CA by an administrator, image, or group policy, the vulnerability is exploitable again.\n\nNote: *This hot fix was validated only in a controlled lab environment, not on a production CA. Before applying it in production, test it on a staging CA that mirrors your environment and confirm that no legitimate enrollment relies on the chase fallback those requests will fail once the flag is cleared. The July update remains the recommended remediation.*\n\n## Timeline\n\n- **May 14, 2026:** Vulnerability reported to the Microsoft Security Response Center\n- **May 14, 2026:** Case assigned\n- **May 22, 2026:** Case investigated and confirmed\n- **July 14, 2026:** Patch released by Microsoft as CVE-2026-54121\n- **July 24, 2026:** Public disclosure\n\n## References\n\n- [certighost.py PoC script](https://github.com/aniqfakhrul/CVE-2026-54121)\n- [Certipy AD CS toolkit](https://github.com/ly4k/Certipy)\n- [Microsoft Security Update Guide: CVE-2026-54121](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-54121)\n- [MS-WCCE: Windows Client Certificate Enrollment Protocol](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/)\n\n## Acknowledgments\n\nThe authors would like to thank the Microsoft Security Response Center for the acknowledgement.\n", "creation_timestamp": "2026-07-24T11:25:08.332340Z"}