Windows 11 Start Menu start2.bin File Format and Read Procedure

Discussion in 'Windows 11' started by jeff789741, Apr 28, 2026.

  1. jeff789741

    jeff789741 MDL Novice

    Sep 11, 2018
    5
    35
    0
    1. Background

    The Windows 11 Start Menu host process (StartMenuExperienceHost.exe) loads StartMenu.dll, which serialises pinned tiles, recently launched applications into a single binary file named start2.bin.

    2. Locations on Disk

    Code:
    %LocalAppData%\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin
    The file are written atomically by the Start Menu host whenever the user pins, unpins, or rearranges tiles.

    3. File Layout

    OffsetSizeFieldDescription
    0x0016 BMAGICFixed GUID, serialised in WinRT WriteGuid byte order
    0x1016 BHEADER_CONSTFixed 16-byte constant, written verbatim
    0x204 BFILETIME.dwLowDateTimeLower 32 bits of GetSystemTimeAsFileTime()
    0x244 BFILETIME.dwHighDateTimeUpper 32 bits of GetSystemTimeAsFileTime()
    0x284 Btotal_payload_lengthciphertext_len + 0x200
    0x2CN Bpre-paddingRandom bytes, N = pad_mt() & 0x1FF (0..511)
    0x2C+NM BciphertextM = total_payload_length − 0x200
    0x2C+N+M0x200−N Bpost-paddingRandom bytes, total padding always = 512
    3.1 MAGIC byte representation

    In the DLL's image the GUID is stored as a standard GUID struct ({E27AE14B-01FC-4D1B-8551-6EDE0B81009C}), i.e. in memory:

    Code:
    4B E1 7A E2  FC 01  1B 4D  85 51  6E DE 0B 81 00 9C
    └── Data1 ──┘ Data2 Data3 └─────── Data4 ────────┘
    The runtime writes it via IDataWriter::WriteGuid, which serialises each Data1/Data2/Data3 component in big-endian order:

    Code:
    on disk:  E2 7A E1 4B  01 FC  4D 1B  85 51 6E DE 0B 81 00 9C
                                         └────── Data4 raw ──────┘
    3.2 HEADER_CONST

    A second fixed constant follows the magic. It is initialised on the stack as four little-endian DWORDs and written by IDataWriter::WriteBytes, so the on-disk bytes match the in-memory bytes exactly:

    Code:
    DWORD[0] = 0x475F5A4E   →  4E 5A 5F 47
    DWORD[1] = 0x49B15B00   →  00 5B B1 49
    DWORD[2] = 0xAF925C8A   →  8A 5C 92 AF
    DWORD[3] = 0x5EF98490   →  90 84 F9 5E
    
    on disk:  4E 5A 5F 47 00 5B B1 49 8A 5C 92 AF 90 84 F9 5E
    3.3 Padding accounting

    Total padding (pre + post) is always 0x200 (512) bytes — the two regions share a fixed budget; they are not each independently 512. The split point N = pad_mt() & 0x1FF gives pre-padding in [0, 511] bytes, and the remainder 0x200 − N (1..512 bytes) becomes post-padding. Only the split varies per file. This means the file size is always:

    Code:
    file_size = 0x2C + 0x200 + ciphertext_len
              = total_payload_length + 0x2C
    4. Cryptographic Constants

    Two 32-bit constants are embedded in the encryption provider:

    NameValueRole
    PROV_KEY_DW00x3B21D91EXOR'd into the key/IV MT seed
    PROV_KEY_DW10x4D9700AFSource for the padding-MT seed constant
    A derived constant is computed once per encrypt/decrypt:

    Code:
    PAD_SEED_CONST = ((PROV_KEY_DW1 & 0xFFFF) << 16)
                   |  (PROV_KEY_DW1 >> 16)
                   =  0x00AF4D97
    Equivalently: swap the high and low 16-bit halves of PROV_KEY_DW1.

    5. Mersenne Twister (MT19937)

    just Mersenne_Twister

    6. Key/IV Generation

    6.1 Two independent MT instances

    Each file uses two MT19937 streams, each seeded from the file's own FILETIME:

    Code:
    pad_seed = PAD_SEED_CONST ^ FILETIME.dwLowDateTime
    
    sym_seed = FILETIME.dwHighDateTime ^ FILETIME.dwLowDateTime ^ PROV_KEY_DW0
    pad_mt = MT19937(pad_seed) is used for:

    1. The first output ANDed with 0x1FF gives pre_pad_len (0..511).
    2. Subsequent outputs (truncated to a byte each) fill pre/post padding bytes.

    sym_mt = MT19937(sym_seed) is consumed by GetSymmetricKeys (below).

    6.2 GetSymmetricKeys(sym_seed)

    Constants in this routine are: MIN = 0x40 (64), MAX = 0x80 (128), IV_LEN = 0x10 (16).

    Code:
    mt        = MT19937(sym_seed)
    key_seed  = mt()                                  // 1st output
    key_len   = MIN + uniform_uint(mt, MAX - MIN)      // 2nd output (rejection)
    iv_seed   = mt()                                  // 3rd output
    
    key_str   = AlphaNumericKeyGenerator(key_seed, key_len)   // 64..128 chars
    iv_str    = AlphaNumericKeyGenerator(iv_seed,  IV_LEN)    // 16 chars
    6.3 uniform_uint(mt, range_size) — rejection sampling

    Code:
    if (range_size == 0)            return 0;
    if (range_size == 0xFFFFFFFF)   return mt();
    
    bound = range_size + 1;
    while (true) {
        v = mt();
        // accept v if it does NOT fall in the truncated tail
        if (!((0xFFFFFFFF / bound) <= (v / bound)
           && (0xFFFFFFFF % bound) != range_size))
            return v % bound;
    }
    6.4 AlphaNumericKeyGenerator(seed, length)

    A second MT is constructed from seed, advanced by a randomised offset, then used to produce printable ASCII characters:

    Code:
    mt = MT19937(seed)
    
    // Phase 1: warm-up — discard a variable number of outputs
    do {
        v = mt();
    } while ((v / 0x3E9) > 0x417873);     // 0x3E9 = 1001
    mt.discard(v % 0x3E9);                  // discard 0..1000 outputs
    
    // Phase 2: produce `length` printable characters in [0x20, 0x7F]
    for (i = 0; i < length; i++) {
        do {
            v = mt();
        } while ((v / 0x60) > 0x2AAAAA9);  // 0x60 = 96
        out[i] = (uint16_t)((v % 0x60) + 0x20);
    }
    The buffer out is a wchar_t (UTF-16) string — but every character is in [0x20, 0x7F] (printable ASCII, including 0x7F/DEL).

    6.5 String → byte buffer

    The wchar_t key/IV strings are passed to Windows.Security.Cryptography.CryptographicBuffer.ConvertStringToBinary with BinaryStringEncoding = 0 (Utf8).

    Because every character is plain ASCII, UTF-8 encoding produces one byte per character with values identical to the character codes themselves. Net effect:

    • key_buf is key_len bytes long, each in [0x20, 0x7F]. Length is between 64 and 128 bytes.
    • iv_buf is exactly 16 bytes long, each in [0x20, 0x7F].

    7. AES Encryption

    • Algorithm string passed to SymmetricKeyAlgorithmProvider::OpenAlgorithm: "AES_CBC_PKCS7".
    • CreateSymmetricKey is invoked with the full key_buf (64..128 bytes). Windows CNG (BCrypt under the hood) selects the largest AES key size that fits — i.e. AES-256 — and silently uses only the first 32 bytes of the buffer. The remaining bytes are ignored.
    • The IV is the full 16-byte iv_buf.
    • PKCS#7 padding is applied automatically.

    In other words the scheme is exactly:

    Code:
    ciphertext = AES_256_CBC_PKCS7_Encrypt(plaintext, key_buf[:32], iv_buf)
    8. Read Procedure

    Given a start2.bin:

    Code:
     1. Read first 16 bytes; verify they equal MAGIC (on-disk byte form, §3.1).
     2. Read bytes [0x10:0x20]; verify they equal HEADER_CONST.
     3. Read ft_low = u32_le @ 0x20 ;  ft_high = u32_le @ 0x24
     4. Read total_len = u32_le @ 0x28
     5. ciphertext_len = total_len − 0x200          ; must be ≥ 0 and a multiple of 16
    
     6. pad_seed   = PAD_SEED_CONST ^ ft_low
        pad_mt     = MT19937(pad_seed)
        pre_pad    = pad_mt() & 0x1FF              ; first output only
        cipher_off = 0x2C + pre_pad
        ciphertext = data[cipher_off : cipher_off + ciphertext_len]
    
     7. sym_seed   = ft_high ^ ft_low ^ PROV_KEY_DW0
        (key_buf, iv_buf) = GetSymmetricKeys(sym_seed)
    
     8. plaintext = AES_256_CBC_PKCS7_Decrypt(ciphertext, key_buf[:32], iv_buf)
    The plaintext is a UTF-8 JSON document ({ ... }) describing the user's pinned-tile state.

    9. The Script

    Code:
    #  Constants
     
    $Script:DefaultStart2Path = Join-Path $env:LOCALAPPDATA `
        'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin'
     
    $Script:MAGIC_GUID = [byte[]](
        0xE2, 0x7A, 0xE1, 0x4B, 0x01, 0xFC, 0x4D, 0x1B,
        0x9C, 0x00, 0x81, 0x0B, 0xDE, 0x6E, 0x51, 0x85)
     
    $Script:HEADER_CONST = [byte[]](
        0x4E, 0x5A, 0x5F, 0x47, 0x00, 0x5B, 0xB1, 0x49,
        0x8A, 0x5C, 0x92, 0xAF, 0x90, 0x84, 0xF9, 0x5E)
     
    $Script:PROV_KEY_DW0 = 0x3B21D91EL
    $Script:PAD_SEED_CONST = 0x00AF4D97L
    $Script:MIN_KEY_LEN = 0x40
    $Script:MAX_KEY_LEN = 0x80
    $Script:IV_LEN = 0x10
    $Script:PAD_TOTAL = 0x200
     
    #  MT19937
    # MSVC std::mt19937 — exact match (matches decrypt_start2.py).
     
    class MT19937 {
        [uint32[]]$State
        [int]$Index
     
        MT19937([uint32]$seed) {
            $this.State = New-Object 'uint32[]' 624
            $this.State[0] = $seed
            for ($i = 1; $i -lt 624; $i++) {
                [long]$prev = $this.State[$i - 1]
                [long]$x = $prev -bxor ($prev -shr 30)
                $x = ($x * 0x6C078965L + $i) -band 0xFFFFFFFFL
                $this.State[$i] = [uint32]$x
            }
            $this.Index = 624
        }
     
        [void]Refresh() {
            for ($i = 0; $i -lt 624; $i++) {
                [long]$y = ([long]$this.State[$i] -band 0x80000000L) -bor `
                ([long]$this.State[($i + 1) % 624] -band 0x7FFFFFFFL)
                [long]$val = [long]$this.State[($i + 397) % 624] -bxor ($y -shr 1)
                if ($y -band 1L) { $val = $val -bxor 0x9908B0DFL }
                $this.State[$i] = [uint32]($val -band 0xFFFFFFFFL)
            }
            $this.Index = 0
        }
     
        [uint32]Next() {
            if ($this.Index -ge 624) { $this.Refresh() }
            [long]$y = [long]$this.State[$this.Index]
            $this.Index++
            $y = ($y -bxor ($y -shr 11)) -band 0xFFFFFFFFL
            $y = ($y -bxor (($y -shl 7)  -band 0x9D2C5680L)) -band 0xFFFFFFFFL
            $y = ($y -bxor (($y -shl 15) -band 0xEFC60000L)) -band 0xFFFFFFFFL
            $y = ($y -bxor ($y -shr 18)) -band 0xFFFFFFFFL
            return [uint32]$y
        }
     
        [void]Discard([int]$n) {
            for ($i = 0; $i -lt $n; $i++) { [void]$this.Next() }
        }
    }
     
    #  Distribution / KDF
     
    function Script:Get-UniformUint32 {
        param(
            [Parameter(Mandatory)] [MT19937]$Mt,
            [Parameter(Mandatory)] [uint32]$Range   # range_size = max - min
        )
        if ($Range -eq 0) { return [uint32]0 }
        if ($Range -eq 0xFFFFFFFFL) { return $Mt.Next() }
        [long]$bound = [long]$Range + 1L
        while ($t()rue) {
            [long]$v = [long]$Mt.Next()
            [long]$rem = $v % $bound
            $condA = ([long]0xFFFFFFFFL / $bound) -le ($v / $bound)
            $condB = ([long]0xFFFFFFFFL % $bound) -ne $Range
            if (-not ($condA -and $condB)) { return [uint32]$rem }
        }
        return [uint32]0   # unreachable; placates parser
    }
     
    function Script:Get-AlphaNumericKey {
        param(
            [Parameter(Mandatory)] [uint32]$Seed,
            [Parameter(Mandatory)] [int]$Length
        )
        $mt = [MT19937]::new($Seed)
     
        # Phase 1: discard pass
        while ($true) {
            [long]$v = [long]$mt.Next()
            if ([long]([math]::Floor($v / 0x3E9L)) -le 0x417873L) { break }
        }
        $mt.Discard([int]($v % 0x3E9L))
     
        # Phase 2: emit `Length` bytes in [0x20, 0x80)
        $out = New-Object 'byte[]' $Length
        for ($i = 0; $i -lt $Length; $i++) {
            while ($true) {
                [long]$w = [long]$mt.Next()
                if ([long]([math]::Floor($w / 0x60L)) -le 0x2AAAAA9L) { break }
            }
            $out[$i] = [byte](($w % 0x60L) + 0x20L)
        }
        return , $out
    }
     
    function Script:Get-SymmetricKey {
        param([Parameter(Mandatory)] [uint32]$SymSeed)
     
        $mt = [MT19937]::new($SymSeed)
        $keySeed = $mt.Next()
        $randOff = Get-UniformUint32 -Mt $mt -Range ([uint32]($Script:MAX_KEY_LEN - $Script:MIN_KEY_LEN))
        $ivSeed = $mt.Next()
     
        $keyLen = [int]$randOff + $Script:MIN_KEY_LEN
        $keyBytes = Get-AlphaNumericKey -Seed $keySeed -Length $keyLen
        $ivBytes = Get-AlphaNumericKey -Seed $ivSeed  -Length $Script:IV_LEN
     
        # WinRT picks the largest AES variant supported, so the first 32 bytes are
        # used as an AES-256 key. (key_str is 64-128 bytes, IV is always 16.)
        $keyTrunc = New-Object 'byte[]' 32
        [Array]::Copy($keyBytes, 0, $keyTrunc, 0, 32)
     
        return [pscustomobject]@{
            Key     = $keyTrunc
            FullKey = $keyBytes
            Iv      = $ivBytes
            KeyLen  = $keyLen
        }
    }
     
    #  AES-CBC-PKCS7
     
    function Script:Invoke-AesCbcPkcs7 {
        param(
            [Parameter(Mandatory)] [byte[]]$Data,
            [Parameter(Mandatory)] [byte[]]$Key,
            [Parameter(Mandatory)] [byte[]]$InitVector,
            [Parameter(Mandatory)] [ValidateSet('Encrypt', 'Decrypt')] [string]$Mode
        )
        $aes = [System.Security.Cryptography.Aes]::Create()
        try {
            $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
            $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
            $aes.Key = $Key
            $aes.IV = $InitVector
            if ($Mode -eq 'Encrypt') {
                $xform = $aes.CreateEncryptor()
            }
            else {
                $xform = $aes.CreateDecryptor()
            }
            try {
                return , $xform.TransformFinalBlock($Data, 0, $Data.Length)
            }
            finally { $xform.Dispose() }
        }
        finally { $aes.Dispose() }
    }
     
    #  Frame parser / builder
     
    function Script:ConvertFrom-Start2Frame {
        param([Parameter(Mandatory)] [byte[]]$Bytes)
     
        if ($Bytes.Length -lt 0x2C) { throw "file too short ($($Bytes.Length) bytes)" }
     
        for ($i = 0; $i -lt 16; $i++) {
            if ($Bytes[$i] -ne $Script:MAGIC_GUID[$i]) {
                $hex = ($Bytes[0..15] | ForEach-Object { '{0:x2}' -f $_ }) -join ''
                throw "bad magic GUID: $hex"
            }
        }
        for ($i = 0; $i -lt 16; $i++) {
            if ($Bytes[16 + $i] -ne $Script:HEADER_CONST[$i]) {
                Write-Warning ("header constant differs at offset 0x{0:x2}" -f (16 + $i))
                break
            }
        }
     
        $ftLow = [BitConverter]::ToUInt32($Bytes, 0x20)
        $ftHigh = [BitConverter]::ToUInt32($Bytes, 0x24)
        $totLen = [BitConverter]::ToUInt32($Bytes, 0x28)
        if ($totLen -lt $Script:PAD_TOTAL) { throw "bad total_len $totLen" }
        $cipherLen = [int]($totLen - $Script:PAD_TOTAL)
     
        $padSeed = [uint32]((($Script:PAD_SEED_CONST) -bxor [long]$ftLow) -band 0xFFFFFFFFL)
        $padMt = [MT19937]::new($padSeed)
        $prePad = [int]($padMt.Next() -band 0x1FF)
        $postPad = $Script:PAD_TOTAL - $prePad
     
        $cipherOff = 0x2C + $prePad
        if ($Bytes.Length -lt ($cipherOff + $cipherLen + $postPad)) { throw "file truncated" }
     
        $ct = New-Object 'byte[]' $cipherLen
        [Array]::Copy($Bytes, $cipherOff, $ct, 0, $cipherLen)
     
        return [pscustomobject]@{
            FileTimeLow  = $ftLow
            FileTimeHigh = $ftHigh
            PrePadLen    = $prePad
            PostPadLen   = $postPad
            Ciphertext   = $ct
        }
    }
     
    #  Registry helper
     
    function Script:Get-RoamedTilePropertiesMapKey {
        if (-not (Test-Path -LiteralPath $Script:CloudStoreCacheRoot)) { return $null }
        Get-ChildItem -LiteralPath $Script:CloudStoreCacheRoot -ErrorAction SilentlyContinue |
        Where-Object {
            $_.PSChildName -like '$*windows.data.unifiedtile.roamedtilepropertiesmap'
        } | Select-Object -First 1
    }
     
    function Script:Write-RoamedTilePropertiesMap {
        param([Parameter(Mandatory)] [byte[]]$Bytes)
        $key = Get-RoamedTilePropertiesMapKey
        if (-not $key) {
            Write-Warning "RoamedTilePropertiesMap cache key not found; skipping registry backup."
            return
        }
        $current = "$($key.PSPath)\Current"
        if (-not (Test-Path -LiteralPath $current)) {
            Write-Warning "Registry key '$current' missing; skipping registry backup."
            return
        }
        Set-ItemProperty -LiteralPath $current -Name Data -Type Binary -Value $Bytes
    }
     
    #  Public API
     
    function Unprotect-StartMenuBin {
        <#
            .SYNOPSIS
            Decrypt a Windows 11 start2.bin (or .bak) file and return the plaintext.
     
            .PARAMETER Path
            Path to the start2.bin file. Defaults to
            %LOCALAPPDATA%\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin
     
            .PARAMETER AsBytes
            Return raw byte[] instead of a UTF-8 decoded string.
            #>
        [CmdletBinding()]
        param(
            [Parameter(Position = 0)] [string]$Path = $Script:DefaultStart2Path,
            [switch]$AsBytes
        )
        if (-not (Test-Path -LiteralPath $Path)) { throw "file not found: $Path" }
        $blob = [System.IO.File]::ReadAllBytes($Path)
        $info = ConvertFrom-Start2Frame -Bytes $blob
     
        $symSeed = [uint32]((([long]$info.FileTimeHigh) -bxor `
                ([long]$info.FileTimeLow)  -bxor `
                    $Script:PROV_KEY_DW0) -band 0xFFFFFFFFL)
        $sym = Get-SymmetricKey -SymSeed $symSeed
        $plain = Invoke-AesCbcPkcs7 -Data $info.Ciphertext -Key $sym.Key -InitVector $sym.Iv -Mode Decrypt
     
        if ($AsBytes) { return , $plain }
        return [System.Text.Encoding]::UTF8.GetString($plain)
    }
     
    #  Calling it
     
    Unprotect-StartMenuBin