Unlock DPAPI without a password using MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3
When a user logs on with a smart card there is no password to unlock DPAPI, so the master keys protected by the password stay locked and the roaming/credential store is unavailable. Windows solves this internally with a supplemental credential: a small structure an authentication package returns to LSA that carries the OWF (one-way function) hashes MSV1_0 needs to build the DPAPI credential key — without any cleartext password. This article documents the version 4 structure (MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3), how MSV1_0 consumes it, and the exact fields, flags and credential-key type to set.
MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3 carries Version == 4 (MSV1_0_CRED_VERSION_V3), not 3 — yes, "V3" really does map to 4. Likewise the struct named V2 carries Version == 2. If you set Version = 3, MSV1_0 rejects the blob and DPAPI stays locked.
DPAPI derives its master-key protector from a per-user credential key. On a password logon MSV1_0 computes that key from the password. On a smart-card logon there is no password, so the package must hand MSV1_0 the two hashes it would otherwise derive itself: the NT OWF (MD4(UTF16LE(password))) and the SHA OWF (SHA1(UTF16LE(password))). The vehicle for that is a supplemental credential blob returned alongside the logon.
When an authentication package answers LsaApLogonUserEx2, it returns two distinct credential objects to LSA. Understanding the split is what makes the passwordless approach work:
- Primary credential (
SECPKG_PRIMARY_CRED) — the identity of the logon session:DownlevelName,DomainName,LogonServer,UserSid,LogonId, plus optionally aPasswordandFlags. LSA caches this and re-presents it to the other packages for single sign-on. - Supplemental / secondary credentials (
SECPKG_SUPPLEMENTAL_CRED_ARRAY) — an array of named blobs LSA routes to specific packages. OurMSV1_0_SUPPLEMENTAL_CREDENTIAL_V3travels here, addressed to"NTLM".
On a normal logon Windows fills the primary credential's Password with the secret in clear and sets PRIMARY_CRED_CLEAR_PASSWORD; MSV1_0 then derives the OWFs from it. Observed flag combinations (from the Kerberos and MSV1_0 SSP output):
password logon : Password = clear password
Flags = 0x10000009 (PRIMARY_CRED_CLEAR_PASSWORD | PRIMARY_CRED_CACHED_LOGON)
smart card logon : Password = the PIN in clear
Flags = 0x10000048 (PRIMARY_CRED_INTERACTIVE_SMARTCARD_LOGON | PRIMARY_CRED_CACHED_LOGON)
Notice that on a stock smart-card logon Windows puts the PIN in clear into the primary credential. But a PIN is not the account password, so MSV1_0 cannot derive the DPAPI-usable OWFs from it — which is the whole reason DPAPI is often broken after a smart-card logon.
The fix is to carry the real OWFs in the supplemental V3 blob and let it do the work:
- The primary credential's
Passwordis effectively a don't-care — you can put whatever you want there (or leave it empty). A valid V3 blob is dispatched before MSV1_0 ever readsPasswordorFlags(see the section below), so it neutralises the primary credential's password whatever its content. Setting it to a zero-lengthUNICODE_STRINGis simply the tidiest choice — there is no need to smuggle the PIN or a fake password through it. - Keep the identifying fields (
DownlevelName,DomainName,LogonServer,UserSid,LogonId) and the smart-card flag:Flags = PRIMARY_CRED_INTERACTIVE_SMARTCARD_LOGON | PRIMARY_CRED_EX | PRIMARY_CRED_PACKED_CREDS. - Return the
MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3(NT + SHA OWF, no cleartext) as the secondary credential addressed to"NTLM".
The OWF pair itself is obtained by decrypting the PIN, reading the certificate from the card, and using the private key to recover the stored NT+SHA OWF (in this package, ContainerGetCredentialFromCertificate returns the 36-byte NtOwf[16] || ShaOwf[20] pair). So the PIN is used only to unlock the card — never as a password surrogate — and DPAPI is seeded from the genuine OWFs.
The SDK defines three password-carrying variants. Beware the off-by-one: the struct named V3 carries Version == 4 (MSV1_0_CRED_VERSION_V3); the one named V2 carries Version == 2.
#define MSV1_0_OWF_PASSWORD_LENGTH 16
#define MSV1_0_SHA_PASSWORD_LENGTH 20
#define MSV1_0_CREDENTIAL_KEY_LENGTH 20
#define MSV1_0_CRED_VERSION_V2 0x00000002
#define MSV1_0_CRED_VERSION_V3 0x00000004 // <- the "V3" struct
typedef enum _MSV1_0_CREDENTIAL_KEY_TYPE {
InvalidCredKey, // 0
DeprecatedIUMCredKey, // 1
DomainUserCredKey, // 2
LocalUserCredKey, // 3
ExternallySuppliedCredKey // 4
} MSV1_0_CREDENTIAL_KEY_TYPE;
typedef struct _MSV1_0_CREDENTIAL_KEY {
UCHAR Data[MSV1_0_CREDENTIAL_KEY_LENGTH]; // 20 bytes
} MSV1_0_CREDENTIAL_KEY;
typedef struct _MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3 {
ULONG Version; // +0x00 = 4
ULONG Flags; // +0x04
MSV1_0_CREDENTIAL_KEY_TYPE CredentialKeyType;// +0x08
UCHAR NtPassword[16]; // +0x0C NT OWF = MD4(UTF16LE(pwd))
MSV1_0_CREDENTIAL_KEY CredentialKey; // +0x1C (20 bytes, may be zero)
UCHAR ShaPassword[20]; // +0x30 SHA OWF = SHA1(UTF16LE(pwd))
} MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3; // total = 0x44 (68) bytes
MSV1_0 validates the blob with MsvpValidateSupplementalCredsBuffer, which returns the Version field and enforces a minimum size — for version 4 the buffer must be at least 0x44 bytes. A shorter buffer is rejected.
The Flags field is a bitmask telling MSV1_0 which of the hash fields are populated:
#define MSV1_0_CRED_LM_PRESENT 0x0001 // LM hash present (v0 path only) #define MSV1_0_CRED_NT_PRESENT 0x0002 // NtPassword is valid #define MSV1_0_CRED_REMOVED 0x0004 #define MSV1_0_CRED_CREDKEY_PRESENT 0x0008 // CredentialKey is valid #define MSV1_0_CRED_SHA_PRESENT 0x0010 // ShaPassword is valid
To carry both OWFs with no cleartext anywhere, set:
v3.Flags = MSV1_0_CRED_NT_PRESENT | MSV1_0_CRED_SHA_PRESENT; // 0x12
MSV1_0_CRED_NT_PRESENT— needed so the NT OWF is consumed (used to derive the domain credential key).MSV1_0_CRED_SHA_PRESENT— the important one for local accounts: the local credential key is theShaPasswordcopied verbatim (rawSHA1(UTF16LE(password)), no salt, no derivation). Without it, local DPAPI stays locked.- Do not set
MSV1_0_CRED_CREDKEY_PRESENTunless you are actually supplying a pre-computedCredentialKey; leave that field zero otherwise. MSV1_0_CRED_LM_PRESENTis only ever read on the legacy version-0 path — irrelevant here.
Set CredentialKeyType to match the account. MSV1_0 decides which key DPAPI receives in MspDetermineUserCredentialKeyType (via LsaILookupUserAccountType): a domain account (type 2/3) gets DomainUserCredKey (2), everything else gets LocalUserCredKey (3).
LocalUserCredKey(3) — requiresMSV1_0_CRED_SHA_PRESENT; the key is theShaPassword, copied verbatim. This cannot be derived from the NT hash, so the SHA OWF is mandatory for local accounts.DomainUserCredKey(2) — if aCredentialKeyis supplied and its type matches (or is 4 =ExternallySuppliedCredKey), it is used directly; otherwise MSV1_0 derives it from the NT OWF:MsvpDeriveSecureCredKey:salt = RtlConvertSidToUnicodeString(userSid) k1 = PBKDF2-HMAC-SHA256(NtOwf[16], salt, 10000) -> 32 bytes key = PBKDF2-HMAC-SHA256(k1, salt, 1) -> 16 bytes (written into a 20-byte MSV1_0_CREDENTIAL_KEY, last 4 bytes zero)
Practical rule: for a local account set CredentialKeyType = LocalUserCredKey and provide the SHA OWF; for a domain account the NT OWF alone is sufficient (the key is derived), so DomainUserCredKey with NtPassword set is enough.
The V3 blob is meant to be the only source of the OWFs — do not also try to smuggle a password (or a bare OWF) through the primary credential's password field. Two reasons:
- The V3 path short-circuits everything else. In
msv1_0!SspAcceptCredentialsthe dispatch is, in order:v = MsvpValidateSupplementalCredsBuffer(cb, buf); // returns Version if (v == MSV1_0_CRED_VERSION_V3) -> NlpMakePrimaryCredentialFromStrongSupplementalCredential if (v == MSV1_0_CRED_VERSION_REMOTE) -> ...FromRemoteCred if ((Flags & (CLEAR_PASSWORD|ENCRYPTED_CREDGUARD_PASSWORD)) == 0) -> ...FromStrongSupplemental if (v == MSV1_0_CRED_VERSION_V2) -> ...FromStrongSupplemental else -> password path
The
v == V3test fires beforeFlagsor the password buffer are ever read. A valid V3 blob neutralises the primary credential on its own — the cleartext/OWF password path is never reached. - The OWF-password shortcut does not work anyway. Setting
PRIMARY_CRED_OWF_PASSWORD (0x2)with a 16-byte hash in the password field is a trap:SspAcceptCredentialsnever tests that flag, and its single call toMsvpPutClearOwfsInPrimaryCredentialpassesIsOwf = FALSEhardcoded — so your hash gets hashed again (a hash of the hash), and no SHA OWF is ever produced. That path cannot unlock local DPAPI. The V3 structure is the supported way to inject pre-computed OWFs.
So: build the V3 blob, leave the password/primary-credential empty, and let the version-4 dispatch do the rest.
The V3 structure is delivered inside a SECPKG_SUPPLEMENTAL_CRED_ARRAY. LSA (lsasrv) routes each entry to a package by matching PackageName (case-insensitive) against the name the package registered in SpGetInfo. For MSV1_0 that name is "NTLM" — not "MSV1_0" and not the lookup alias MICROSOFT_AUTHENTICATION_PACKAGE_V1_0. A wrong name leaves the array unrouted: lsasrv frees it without it ever reaching MSV1_0, and DPAPI stays locked.
// one self-contained allocation: [ CRED_ARRAY ][ V3 blob ][ L"NTLM\0" ] PMSV1_0_SUPPLEMENTAL_CREDENTIAL_V3 v3 = ...; v3->Version = MSV1_0_CRED_VERSION_V3; // 4 v3->Flags = MSV1_0_CRED_NT_PRESENT | MSV1_0_CRED_SHA_PRESENT; v3->CredentialKeyType = LocalUserCredKey; // or DomainUserCredKey memcpy(v3->NtPassword, ntOwf, 16); memcpy(v3->ShaPassword, shaOwf, 20); array->CredentialCount = 1; array->Credentials[0].PackageName = UNICODE_STRING(L"NTLM"); array->Credentials[0].CredentialSize = sizeof(MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3); array->Credentials[0].Credentials = (PUCHAR) v3;