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 OffsetSizeFieldDescription0x0016 BMAGICFixed GUID, serialised in WinRT WriteGuid byte order0x1016 BHEADER_CONSTFixed 16-byte constant, written verbatim0x204 BFILETIME.dwLowDateTimeLower 32 bits of GetSystemTimeAsFileTime()0x244 BFILETIME.dwHighDateTimeUpper 32 bits of GetSystemTimeAsFileTime()0x284 Btotal_payload_lengthciphertext_len + 0x2000x2CN Bpre-paddingRandom bytes, N = pad_mt() & 0x1FF (0..511)0x2C+NM BciphertextM = total_payload_length − 0x2000x2C+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: NameValueRolePROV_KEY_DW00x3B21D91EXOR'd into the key/IV MT seedPROV_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: The first output ANDed with 0x1FF gives pre_pad_len (0..511). 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