winternl

cybersecurity & programming

Unpacking the AAD Broker LocalState Cache

tl;dr: Source: https://github.com/jackullrich/AADBrokerDecrypt

Intro

The Azure AD Broker (AAD Broker) is a component of Entra ID that orchestrates Azure AD sign-in, device-bound primary refresh token (PRT) handling, and application token issuance exposed by Windows Runtime (WinRT) APIs. In this post, we’ll map the broker’s on-disk cache and show how to unpack its file formats. Additionally, we offer a brief discussion of the security considerations of the cache contents. Let’s begin by taking a look at the filesystem structure of the LocalState Cache.

Note: I conducted all research on Windows 11 24H2 (26100.4946).

Filesystem Structure

The LocalState cache is located at:

<LocalAppData>\Packages\Microsoft.AAD.BrokerPlugin_<PublisherId>\LocalState

The <PublisherId> value is a Base32 encoding of the first eight bytes of a SHA256 hash of the publisher string. Microsoft published AAD Broker and the publisher string used to calculate the package id is:

CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US

This procedure is outlined in the Windows 8.1 Enterprise Device Management Protocol specification.

Implementation Source Code (C#)
public static class Windows8AppPublisherHash
{
    static uint PUBLISHER_HASHED_BYTES = 8;
    
    public static string GetPublisherId(string publisher)
    {
        UnicodeEncoding unicode = new UnicodeEncoding();
        string publisherId;
        using (SHA256CryptoServiceProvider sha256 = new SHA256CryptoServiceProvider())
        {
            byte[] hash = sha256.ComputeHash(unicode.GetBytes(publisher));
            char[] chars = Base32Encode(hash, PUBLISHER_HASHED_BYTES);
            publisherId = new string(chars);
        }
        return publisherId;
    }
    
    public static char ValueToDigit(byte value)
    {
        char[] base32DigitList = "0123456789abcdefghjkmnpqrstvwxyz".ToCharArray();
        return base32DigitList[value];
    }
   
    public static char[] Base32Encode(byte[] bytes, uint byteCount)
    {
        uint wcharCount = 0;
        uint wcharsIdx = 0;
        uint numBits = byteCount * 8;
        wcharCount = numBits / 5;
        if (numBits % 5 != 0)
        {
            ++wcharCount;
        }
        char[] wchars = new char[wcharCount];
        
        for (uint byteIdx = 0; byteIdx < byteCount; byteIdx += 5)
        {
            byte firstByte = bytes[byteIdx];
            byte secondByte = (byteIdx + 1) < byteCount ? bytes[byteIdx + 1] : (byte)0;
            wchars[wcharsIdx++] = ValueToDigit((byte)((firstByte & 0xF8) >> 3));
            wchars[wcharsIdx++] = ValueToDigit((byte)(
             ((firstByte & 0x07) << 2) | ((secondByte & 0xC0) >> 6)));
            if (byteIdx + 1 < byteCount)
            {
                byte thirdByte = (byteIdx + 2) < byteCount ? bytes[byteIdx + 2] : (byte)0;
                wchars[wcharsIdx++] = ValueToDigit((byte)((secondByte & 0x3E) >> 1));
                wchars[wcharsIdx++] = ValueToDigit((byte)
                 (((secondByte & 0x01) << 4) | ((thirdByte & 0xF0) >> 4)));
                if (byteIdx + 2 < byteCount)
                {
                    byte fourthByte = (byteIdx + 3) < byteCount ? bytes[byteIdx + 3] : (byte)0;
                    wchars[wcharsIdx++] = ValueToDigit((byte)
                     (((thirdByte & 0x0F) << 1) | ((fourthByte & 0x80) >> 7)));
                    if (byteIdx + 3 < byteCount)
                    {
                        byte fifthByte = (byteIdx + 4) < byteCount ? bytes[byteIdx + 4] : (byte)0;
                        wchars[wcharsIdx++] = ValueToDigit((byte)((fourthByte & 0x7C) >> 2));
                        wchars[wcharsIdx++] = ValueToDigit((byte)
                         (((fourthByte & 0x03) << 3) | ((fifthByte & 0xE0) >> 5)));
                        if (byteIdx + 4 < byteCount)
                        {
                            wchars[wcharsIdx++] = ValueToDigit((byte)(fifthByte & 0x1F));
                        }
                    }
                }
            }
        }
        return wchars;
    }
}

A directory listing of the LocalState cache might appear as something like this:

Naming Convention

The naming convention for these files is a prefix followed by a 24-character token derived from identity material for users, PRT authorities, applications, and application authorities.

  • u_ = User principal name (UPN)
  • p_ = PRT authority
  • c_ = Client (Application Id)
  • a_ = Authority

The string derivation functionality was reversed from ClientCache::GetPRTFileName and ClientCache::GetClientFileName in AAD.Core.dll. Each label is a 24-character lowercase token produced by StringUtility::FileSysName. This function takes the input string (UPN for u_, client/app ID for c_, authority URL for a_, and authority host for p_), normalizes it, calculates the SHA-1 hash, then encodes the first 15 bytes with the broker’s custom Base32-hex alphabet. The broker writes paths like u_<UPNID>\c_<CLIENTID>\a_<AUTHID>.<ext> and u_<UPNID>\p_<HOSTID>, where <ext> reflects the logon type (def/pwd/ngc/scd/fido). A full reimplementation is available in the accompanying GitHub repository.

Unpacking Procedure

All AAD Broker cache files go through a similar packing procedure. The packing procedure is spread across a few classes within AAD.Core.dll. Let’s reverse how it works.

Opening up any of the packed files in a hex editor we are met with an identifiable sequence. A UTF-8 byte order mark (BOM) followed by an ASCII string.

  • First 3 bytes: UTF-8 BOM
    • Marker to indicate the file is encoded in UTF-8 (EF BB BF)
  • Header: 3-1 or 3-0
    • 3-1 – The file contents are packed and encrypted
    • 3-0 – The file contents contain a hash value

The 3-0 header was not observed on the analyzed machine, but is present in the AAD.Core.dll packing code.

//
// AAD.Core.dll
//

void __cdecl Packer::V3::Pack(
    longlong* param_1,
    longlong* SerializedBuffer_Input,
    char      EncryptOrHash
)
{
    // ...

    // Select version tag based on EncryptOrHash flag
    pwVar6 = L"3-0";
    if (EncryptOrHash != '\0') {
        pwVar6 = L"3-1";
    }

    // ...

    // Branch on protection mode: hash vs. protect
    if (EncryptOrHash == '\0') {
        HashProtect(
            (SecureBuffer*)local_50,
            (SecureBuffer*)SerializedBuffer_Input
        );
    }
    else {
        ProtectData::Protect(
            (uchar*)local_50._0_8_,
            local_50._8_4_ - local_50._0_4_,
            p_Var8,
            (vector<>*)SerializedBuffer_Input,
            in_stack_ffffffffffffff88
        );
    }
}

Strip the UTF-8 BOM and version tag, and what remains is a Base64-encoded string. Decoding that string yields an ASN.1 blob, which is the standard format for CNG/DPAPI-protected data.

In order to decrypt the blob we will need the following fields of the ASN1 blob:

  • Key encrypting key (KEK – 262 bytes) – DPAPI Protected
  • Content encrypting key (CEK – 40 bytes) – RFC 3394 wrapped key with KEK
  • Initialization Vector (IV – 12 bytes) – AES-GCM can use a 12 byte IV
  • Encrypted Content (variable byte length) – AES-GCM encrypted content

Start by unprotecting the KEK in the user’s security context, as specified within the CNG blob. Use the recovered KEK to unwrap the CEK. Since AES-GCM requires an authentication tag (MAC), which is appended to the ciphertext, you can then decrypt the encrypted content with the unwrapped CEK, the IV, and the tag.

After decryption, we can see the following contents:

At first, the data doesn’t reveal much, but further reversing of AAD.Core.dll!Packer::V3::Pack shows a call to a compression routine, giving us a clue.

//
// AAD.Core.dll
//

void __cdecl Packer::V3::Pack(
    longlong* param_1,
    longlong* SerializedBuffer_Input, // input buffer (serialized data)
    char      EncryptOrHash           // flag: encrypt or hash
)
{
    // ...

    // Compress the serialized input
    Compress(
        (SecureBuffer*)SerializedBuffer_Input,
        (SecureBuffer*)local_50
    );

    // ...
}

Skipping the first four bytes as some unknown header value, we can see a zlib header.

CMFFLG
0x78 0x01Fastest compression
0x78 0x9CDefault compression (common)
0x78 0xDABest compression

After inflating the remainder of the file (excluding the header), the output begins to take on a more recognizable structure. In a hex editor, strings start to appear, though additional processing is still required.

At this point, it becomes necessary to understand where the packed (or rather, serialized) data comes from. The data is a serialized class, AuthenticationContext, from AAD.Core.dll. In the pseudocode below, we first inspect the serialization call site and then reconstruct the deserialization control flow.

//
// AAD.Core.dll
// AuthenticationContext serialization call site.
//

void __thiscall AuthenticationContext::SerializeImpl(
    void*       this,
    ulonglong   param_2,
    undefined   Encrypt_OR_Hash,
    SerializeFlags flags
)
{
    
    // ...

    // Serialize AuthenticationContext into Serialized_Buffer
    CTX_SERIALIZE_JSON::Serialize(
        (AuthenticationContext*)this,
        flags,
        (SecureBuffer*)Serialized_Buffer
    );

    // Pack serialized payload
    Packer::V3::Pack(
        param_2,
        Serialized_Buffer,
        Encrypt_OR_Hash
    );
}
//
// AAD.Core.dll
// Reconstructed deserialization path
//

AuthenticationContext* resultContext = { ... };

// Select deserialization path based on unpacked header value
switch (unpackedHeader)
{
    case 5: case 6: case 7: case 8: case 9:
    case 10: case 11: case 12: case 13: case 14:
    case 15: case 16: case 17: case 18:
    {
        // Deserialize using CTX_SERIALIZE logic
        // Appears to be a custom binary serialization
        resultContext = CTX_SERIALIZE::DeserializeImpl(unpackedHeader, unpackedData);
        break;
    }
    case 19: 
    {
        // 0x13 = JSON-based serialization
        resultContext = CTX_SERIALIZE_JSON::Deserialize(unpackedHeader, unpackedData);
        break;
    }
    default: // ...
}

There are two different serialization (and thus deserialization procedures). By far the more commonly observed procedure was the JSON serialization. The JSON blob has a simple header:

struct JsonHeader
{
    std::uint32_t Magic; // Expected constant: 0x13 (19)
    std::uint32_t Size;  // Size of the following JSON payload in bytes
    // std::uint8_t Json[Size]; JSON contents
};

The other magic values (5-18) correspond to a custom binary serialization format that primarily relies on length-prefixed strings. Unlike the JSON case, there isn’t a size field that describes the full payload. Instead, the first field after the magic value is itself a length-prefixed string. Rather than fully reversing this serialization scheme, a more practical approach is to simply extract the strings directly from the binary blob. A naive extraction algorithm is implemented in the repository, but string extraction utilities such as strings from Sysinternals may also be used.

Unpacked Data

We’ll begin by examining the unpacked PRT file (p_) contents.

Unpacked PRT JSON
{
  "wia": "",
  "upn_id": "...",
  "upn": "user@example.com",
  "up": "",
  "uid": "u:00000000-0000-0000-0000-000000000000.00000000-0000-0000-0000-000000000000",
  "tid": "00000000-0000-0000-0000-000000000000",
  "sub": "...",
  "sct": "",
  "redir": "ms-appx-web://Microsoft.AAD.BrokerPlugin/00000000-0000-0000-0000-000000000000",
  "prt": {
    "val": "REDACTED",
    "sk_val": "REDACTED",
    "sk_kt": "ngc",
    "sk_alg": "",
    "is_prt": true,
    "is_brd": true
  },
  "pic_rfsh": 0,
  "pic_etag": "",
  "opt": 3,
  "oid": "00000000-0000-0000-0000-000000000000",
  "mex": "",
  "mdm": "00000000-0000-0000-0000-000000000000",
  "lst_rfsh": 0,
  "log_hnt": "",
  "lgn_t": 0,
  "is_v2": false,
  "is_ui": 1,
  "htid": "00000000-0000-0000-0000-000000000000",
  "gn": "GivenName",
  "fn": "FamilyName",
  "ex_qs_t": "",
  "ex_qs_a": "",
  "ex_hdr_t": "",
  "ex_hdr_a": "",
  "eml": "",
  "dn": "GivenName FamilyName",
  "crts": [],
  "crt_usg": [],
  "auth_url": "",
  "auth_tkn": "",
  "auth": "https://login.microsoftonline.com/common",
  "appid": "00000000-0000-0000-0000-000000000000",
  "altid": "",
  "acc_t": 0
}

There is a lot of identity metadata material here. Of note are the fields pertaining to the PRT. The PRT value (prt.val above) is opaque and cannot be decrypted on the machine; however, the PRT session key (prt.sk_val) is a base64 encoded blob. Decoded from base64, the session key has an unknown 8 byte header followed by a legacy DPAPI blob protected with a machine key. Once unprotected, the blob reveals a structured payload containing the material necessary to derive the PRT session key.

Which can be described by the following structure.

struct SkBlobHeader
{
    std::uint32_t Version;         // == 0x00000001
    std::uint32_t SkGuidLength;    // byte count of the UTF-16LE string *including* the trailing L'\0'
    std::uint32_t OpaqueKeyLength; // byte count of opaque key material that follows

    // Immediately followed in memory by:
    // wchar_t      SkGuid[SkGuidLength/2]; // UTF-16LE text like L"SK-31da...4f17", including NULL
    // std::uint8_t OpaqueKey[OpaqueKeyLength];
    // std::uint8_t Padding[2];              // usually { 0x02, 0x02 } ?
};

The session key GUID value was also observed in the registry:

HKLM\SYSTEM\CurrentControlSet\Control\Cryptography\Ngc\KeyTransportKey\...\TpmKeyTransportKeyName
  • How do I derive a key?
    • mimikatz.exe dpapi::cloudapkd /keyvalue:<OpaqueKey> /keyname:<SK-GUID>
  • How does that derivation process work?
Session Key Derivation
unsigned char data[178] = {
    // Opaque Key ...
};

unsigned char derived_key[32]{ 0 };

NCRYPT_PROV_HANDLE provHandle{ 0 };
NCRYPT_KEY_HANDLE  keyHandle{ 0 };
NCRYPT_KEY_HANDLE  importedKeyHandle{ 0 };

char kdf_label[] = "AzureAD-SecureConversation";
char kdf_ctx[24]{ 0 };

const wchar_t* algo = NCRYPT_SHA256_ALGORITHM;

// KDF parameters: LABEL, CONTEXT, HASH ALGORITHM
NCryptBuffer buffer[] = {
    { sizeof(kdf_label),           KDF_LABEL,         kdf_label },
    { sizeof(kdf_ctx),             KDF_CONTEXT,       kdf_ctx   },
    { sizeof(NCRYPT_SHA256_ALGORITHM), KDF_HASH_ALGORITHM, (PVOID)algo },
};

NCryptBufferDesc bufferDesc = {
    NCRYPTBUFFER_VERSION,
    ARRAYSIZE(buffer),
    buffer
};

// Open platform crypto provider
auto status = NCryptOpenStorageProvider(
    &provHandle,
    MS_PLATFORM_CRYPTO_PROVIDER,
    0
);

// Temporarily elevate
GetSystemPrivs();

// Open existing persisted key by name
status = NCryptOpenKey(
    provHandle,
    &keyHandle,
    L"SK-31da1755-bc5e-c041-bbe5-7c9161114f17",
    0 /* AT_SIGNATURE */,
    0
);

// Drop elevation
RevertToSelf();

// Import opaque transport blob bound to the provider/key
status = NCryptImportKey(
    provHandle,
    keyHandle,
    NCRYPT_OPAQUETRANSPORT_BLOB,
    nullptr,
    &importedKeyHandle,
    data,
    sizeof(data),
    0
);

DWORD cbResult{ 0 };

// Derive key with the given KDF params
status = NCryptKeyDerivation(
    importedKeyHandle,
    &bufferDesc,
    derived_key,
    sizeof(derived_key),
    &cbResult,
    0
);

// Base64-encode the derived key (WinRT helper)
auto str = CryptographicBuffer::EncodeToBase64String(
    CryptographicBuffer::CreateFromByteArray(derived_key)
);

Application and Authority Files

Within the AAD Broker cache, the structure follows a consistent naming scheme:

  • Application folders are prefixed with c_.
  • Authority files inside those folders are prefixed with a_.

Each c_ folder corresponds to a single application (client), and within it, each a_ file corresponds to one authority used by that application. If an application trusts multiple authorities, the folder will contain multiple a_ files.

The authority (a_) files are per-authority token caches for a given application. If an attacker can read an a_ file they can immediately reuse any stored access tokens and can often extend access by using refresh tokens found in the same file.

The same reversing process is used for authority files as is used for the PRT file, provided that the 3-1 header is present. Upon reversal of the packing algorithm you will discover different identity material than was present in the PRT JSON.

View Unpacked JSON
{
  "wia": "",
  "upn_id": "...",
  "upn": "user@example.com",
  "up": "",
  "uid": "u:00000000-0000-0000-0000-000000000000.00000000-0000-0000-0000-000000000000",
  "tid": "00000000-0000-0000-0000-000000000000",
  "sub": "...",
  "sct": "",
  "rt_cache": [],
  "rt": {
    "val": "",
    "sk_val": "",
    "sk_kt": "",
    "sk_alg": "",
    "is_prt": false,
    "is_brd": false
  },
  "redir": "ms-appx-web://Microsoft.AAD.BrokerPlugin/00000000-0000-0000-0000-000000000000",
  "pic_rfsh": 0,
  "pic_etag": "",
  "opt": 3,
  "oid": "00000000-0000-0000-0000-000000000000",
  "mex": "",
  "mdm": "00000000-0000-0000-0000-000000000000",
  "lst_rfsh": 0,
  "log_hnt": "",
  "lgn_t": 0,
  "is_v2": false,
  "is_ui": 1,
  "htid": "00000000-0000-0000-0000-000000000000",
  "gn": "GivenName",
  "fn": "FamilyName",
  "ex_qs_t": "",
  "ex_qs_a": "",
  "ex_hdr_t": "",
  "ex_hdr_a": "",
  "eml": "",
  "dn": "GivenName FamilyName",
  "crts": [],
  "crt_usg": [],
  "cache": [
    {
      "tkn_t": "",
      "tkn": "REDACTED",
      "scp": "...",
      "rdr": "ms-appx-web://Microsoft.AAD.BrokerPlugin/00000000-0000-0000-0000-000000000000",
      "rc": "...",
      "id_tkn": "REDACTED",
      "exp": 0,
      "encl": "",
      "e_hsh": "REDACTED",
      "e_exp": 0,
      "cat": 0,
      "bk": ""
    }
  ],
  "auth_url": "",
  "auth_tkn": "",
  "auth": "https://login.microsoftonline.com/common",
  "appid": "00000000-0000-0000-0000-000000000000",
  "altid": "",
  "acc_t": 0
}

Examining the tkn and id_tkn fields reveal themselves to be JSON web tokens (JWTs), which might look similar to this:

View Decoded JWT
Header: {
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "REDACTED",
  "kid": "REDACTED"
}
Payload: {
  "aud": "https://onestore.microsoft.com",
  "iss": "https://sts.windows.net/00000000-0000-0000-0000-000000000000/",
  "iat": 0,
  "nbf": 0,
  "exp": 0,
  "acr": "1",
  "aio": "REDACTED",
  "amr": [
    "rsa",
    "wia"
  ],
  "appid": "00000000-0000-0000-0000-000000000000",
  "appidacr": "0",
  "cnf": {
    "tbh": "REDACTED"
  },
  "deviceid": "00000000-0000-0000-0000-000000000000",
  "family_name": "FamilyName",
  "given_name": "GivenName",
  "idtyp": "user",
  "ipaddr": "0.0.0.0",
  "name": "GivenName FamilyName",
  "oid": "00000000-0000-0000-0000-000000000000",
  "onprem_sid": "S-1-5-00-0000000000-0000000000-0000000000-0000",
  "puid": "REDACTED",
  "rh": "REDACTED",
  "scp": "user_impersonation",
  "sid": "00000000-0000-0000-0000-000000000000",
  "sub": "REDACTED",
  "tid": "00000000-0000-0000-0000-000000000000",
  "unique_name": "user@example.com",
  "upn": "user@example.com",
  "uti": "REDACTED",
  "ver": "1.0",
  "wids": [
    "00000000-0000-0000-0000-000000000000"
  ],
  "xms_ftd": "REDACTED",
  "xms_idrel": "0 0"
}

Authority files and the nested JWT content will vary slightly between files.

Authority files also make use of magic values other than 13, indicating that their contents are serialized in a custom binary format. The serialized data is the same underlying class, just not in JSON format. Originally, I was thinking it could be BSON, but it does not appear to be. The values are stored as length-prefixed strings. Instead of reversing the binary serialization completely, it’s easier to simply extract the strings directly from the binary blob. The code for this naive string extraction is in the GitHub repository.

Security Considerations

Persistent Identity Material

  • AAD Broker’s LocalState cache contains PRTs, application tokens, and refresh tokens.
  • PRT values are opaque; however their session keys (sk_val) are present in the cache. Session keys are bound to the TPM, preventing replay value elsewhere. However, an attacker with local SYSTEM context may derive usable session keys.
  • Authority files store access/refresh tokens directly, tied to specific client applications.

Potential for Silent, Long-Lived Access

  • If an attacker derives a valid session key, they can impersonate the broker to renew tokens without user interaction.
  • This effectively bypasses MFA after initial authentication, enabling long-lived persistence in cloud resources.
  • Authority files with cached refresh tokens similarly allow token minting for specific apps.

Reconnaissance

The LocalCache contains a significant amount of structured metadata.

  • User identifiers: UPNs, OIDs, tenant IDs and on-prem SIDs that map identities to tenants and hosts.
  • Application identifiers: client/app IDs (GUIDs) and sometimes the app_displayname are embedded in JWTs or cached tokens.
  • Service scope information: scp/wids claims and tkn contents that list permissions granted to apps (e.g., Files.Read, Sites.Read.All, User.Read).
  • Device linkage: device IDs and cnf.tbh (token binding) markers indicating whether tokens are bound to a device key.
  • Token lifetimes and audiences: iat, exp, aud, and appid are exposed in cached JWTs.

Conclusion

Overall, the LocalState cache is poorly documented. It stores structured identity and application metadata that may be leveraged for reconnaissance. Refresh tokens or derived session keys should enable long-lived access, though the ability to fully derive or replay session keys depends on local protections such as TPM-bound keys and having sufficient privileges. Further research into what is and what is not possible with the metadata is needed.

References