Skip to content

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.

Watch the version number. The structure named 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.
The goal

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.

Primary credential vs supplemental (secondary) credentials

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 a Password and Flags. 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. Our MSV1_0_SUPPLEMENTAL_CREDENTIAL_V3 travels 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.

What to return for a passwordless logon

The fix is to carry the real OWFs in the supplemental V3 blob and let it do the work:

  • The primary credential's Password is 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 reads Password or Flags (see the section below), so it neutralises the primary credential's password whatever its content. Setting it to a zero-length UNICODE_STRING is 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 structure

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

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 the ShaPassword copied verbatim (raw SHA1(UTF16LE(password)), no salt, no derivation). Without it, local DPAPI stays locked.
  • Do not set MSV1_0_CRED_CREDKEY_PRESENT unless you are actually supplying a pre-computed CredentialKey; leave that field zero otherwise.
  • MSV1_0_CRED_LM_PRESENT is only ever read on the legacy version-0 path — irrelevant here.
The credential-key type

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) — requires MSV1_0_CRED_SHA_PRESENT; the key is the ShaPassword, copied verbatim. This cannot be derived from the NT hash, so the SHA OWF is mandatory for local accounts.
  • DomainUserCredKey (2) — if a CredentialKey is 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.

Why the primary credential must stay empty

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:

  1. The V3 path short-circuits everything else. In msv1_0!SspAcceptCredentials the 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 == V3 test fires before Flags or 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.

  2. 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: SspAcceptCredentials never tests that flag, and its single call to MsvpPutClearOwfsInPrimaryCredential passes IsOwf = FALSE hardcoded — 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.

Returning the blob to LSA

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;
Another problem? Contact us to help us improve this article.