๐Ÿ“œ A script to set Windows file associations on a per-user basis, bypassing the built-in protection.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
set-fileassoc/Set-FileAssoc.ps1

545 lines
19 KiB

<#
.SYNOPSIS
Sets Windows file associations on a per-user basis, bypassing the built-in protection.
.DESCRIPTION
This script allows a user or an IT administrator to change user file associations in Windows 10.
User file associations in newer versions of Windows are normally protected from an unauthorized change,
and therefore can only be set interactively through Settings app, or using a XML file pushed through GPO.
The XML method has several drawbacks:
- IT administrator has to keep track of any new associations when a Windows Feature Update gets released;
- if the computer is not in a domain, associations can only be set in a reference image, and as a result:
- apps also have to be pre-built in your image;
- once an user changes one of their file associations, it cannot be set back using the XML method.
A SetUserFTA tool has been made in 2017 to combat this limitation:
https://kolbi.cz/blog/2017/10/25/setuserfta-userchoice-hash-defeated-set-file-type-associations-per-user/
However, this tool is also not a perfect solution:
- it only changes associations for the user that launched the tool;
- this is problematic if computers are managed by means of a remote configuration administration tool,
like Ansible;
- workarounds to run SetUserFTA in different user contexts exist, but they are also not ideal;
- closed-source model.
For my personal use case (domainless network of Ansible-managed Windows 10 nodes with a "bleeding-edge" update policy),
a different approach was needed, therefore, this script was made.
.PARAMETER Extension
Specifies a file extension that an association will be set for.
Example: .pdf
.PARAMETER ProgID
Specifies the ProgID - an application/extension identifier for a file extension.
ProgIDs can be found:
- in HKCU:\Software\Classes for software that was installed in an user context;
- in HKLM:\Software\Classes for system-wide software;
- in HKCR: for a combined list of software (only works for your own user context).
Examples:
SumatraPDF
VLC.mp4
.PARAMETER SkipProgIDValidation
Do not check if a ProgID actually exists for each user.
If this parameter is specified, ProgID will always be set, even if it does not correspond to anything.
.PARAMETER CurrentUser
Specify this to explicitly use the script in current user context (default behaviour).
.PARAMETER AllUsers
Specify this to use the script for all valid local users.
This will also try to change associations for service accounts and such, but these accounts normally
do not have any association preferences, if they never were logged in to interactively.
This parameter requires administrative privileges.
.PARAMETER Users
An array (comma-delimited list) of usernames.
If this is set, this script will change settings only if an user's name exists in the array.
Examples:
user
admin, paul, mike, lina
.INPUTS
None.
.OUTPUTS
System.Int32.
-1: if nothing was changed, or a fatal error occurred.
0: if associations were changed for at least one user.
.EXAMPLE
C:\> .\Set-FileAssoc.ps1 -Extension .pdf -ProgID SumatraPDF -AllUsers
.EXAMPLE
C:\> .\Set-FileAssoc.ps1 -Extension .html -ProgID ChromeHTML -Users user1, user2
.NOTES
This script was tested on Windows 10 Home 1909 and 2004.
This script was originally made for personal use (and still is), and, therefore:
- its author provides no guarantee that the product works, and/or will work on future versions of Windows;
- does not have a SLA or even a guarantee that the product will be maintained within its lifecycle;
- no obligations are made regarding user support and troubleshooting.
This script is a product of reverse-engineering Windows binaries.
Therefore, if your organization has to strictly adhere to Microsoft EULA,
it may be problematic, legal-wise, to use this script, because:
- it circumvents the measures set in place by Microsoft to prevent tampering with
file associations and user experience;
- it uses features that were implemented by reverse-engineering binaries that
are "legally protected" from being reverse-engineered.
Hash algorithm re-implementation is written in C# to avoid pitfalls with PowerShell arithmetic and integer overflows.
.LINK
https://git.pootis.network/dave/set-fileassoc
#>
#Requires -Version 5
[CmdletBinding(DefaultParameterSetName="CurrentUser", SupportsShouldProcess)]
Param (
[Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="CurrentUser")]
[Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="AllUsers")]
[Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="SpecificUsers")]
[ValidatePattern("\..+")]
[String]$Extension,
[Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="CurrentUser")]
[Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="AllUsers")]
[Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="SpecificUsers")]
[ValidateNotNullOrEmpty()]
[String]$ProgID,
[Parameter(ParameterSetName="CurrentUser", HelpMessage="Do not check if ProgID exists in per-user HKCR")]
[Parameter(ParameterSetName="AllUsers", HelpMessage="Do not check if ProgID exists in per-user HKCR")]
[Parameter(ParameterSetName="SpecificUsers", HelpMessage="Do not check if ProgID exists in per-user HKCR")]
[Switch]$SkipProgIDValidation,
[Parameter(ParameterSetName="CurrentUser", HelpMessage="Perform operations only on current user")]
[Switch]$CurrentUser,
[Parameter(ParameterSetName="AllUsers", HelpMessage="Perform operations on all users")]
[Switch]$AllUsers,
[Parameter(ParameterSetName="SpecificUsers", HelpMessage="Perform operations on specific users")]
[ValidateNotNullOrEmpty()]
[String[]]$Users
)
Set-StrictMode -Version 3
$RegQueryInfoKeySig = @"
[DllImport("advapi32.dll", CharSet = CharSet.Auto)]
extern public static Int32 RegQueryInfoKey(
Microsoft.Win32.SafeHandles.SafeRegistryHandle hKey,
StringBuilder lpClass,
[In, Out] ref UInt32 lpcbClass,
UInt32 lpReserved,
out UInt32 lpcSubKeys,
out UInt32 lpcbMaxSubKeyLen,
out UInt32 lpcbMaxClassLen,
out UInt32 lpcValues,
out UInt32 lpcbMaxValueNameLen,
out UInt32 lpcbMaxValueLen,
out UInt32 lpcbSecurityDescriptor,
out System.Runtime.InteropServices.ComTypes.FILETIME lpftLastWriteTime
);
"@
Add-Type -Language CSharp @"
using System;
namespace SetFileAssoc.PatentHash
{
public static class HashFuncs
{
public static uint[] WordSwap(byte[] a, int sz, byte[] md5)
{
if (sz < 2 || (sz & 1) == 1) {
throw new ArgumentException(String.Format("Invalid input size: {0}", sz), "sz");
}
unchecked {
uint o1 = 0;
uint o2 = 0;
int ta = 0;
int ts = sz;
int ti = ((sz - 2) >> 1) + 1;
uint c0 = (BitConverter.ToUInt32(md5, 0) | 1) + 0x69FB0000;
uint c1 = (BitConverter.ToUInt32(md5, 4) | 1) + 0x13DB0000;
for (uint i = (uint)ti; i > 0; i--) {
uint n = BitConverter.ToUInt32(a, ta) + o1;
ta += 8;
ts -= 2;
uint v1 = 0x79F8A395 * (n * c0 - 0x10FA9605 * (n >> 16)) + 0x689B6B9F * ((n * c0 - 0x10FA9605 * (n >> 16)) >> 16);
uint v2 = 0xEA970001 * v1 - 0x3C101569 * (v1 >> 16);
uint v3 = BitConverter.ToUInt32(a, ta - 4) + v2;
uint v4 = v3 * c1 - 0x3CE8EC25 * (v3 >> 16);
uint v5 = 0x59C3AF2D * v4 - 0x2232E0F1 * (v4 >> 16);
o1 = 0x1EC90001 * v5 + 0x35BD1EC9 * (v5 >> 16);
o2 += o1 + v2;
}
if (ts == 1) {
uint n = BitConverter.ToUInt32(a, ta) + o1;
uint v1 = n * c0 - 0x10FA9605 * (n >> 16);
uint v2 = 0xEA970001 * (0x79F8A395 * v1 + 0x689B6B9F * (v1 >> 16)) -
0x3C101569 * ((0x79F8A395 * v1 + 0x689B6B9F * (v1 >> 16)) >> 16);
uint v3 = v2 * c1 - 0x3CE8EC25 * (v2 >> 16);
o1 = 0x1EC90001 * (0x59C3AF2D * v3 - 0x2232E0F1 * (v3 >> 16)) +
0x35BD1EC9 * ((0x59C3AF2D * v3 - 0x2232E0F1 * (v3 >> 16)) >> 16);
o2 += o1 + v2;
}
uint[] ret = new uint[2];
ret[0] = o1;
ret[1] = o2;
return ret;
}
}
public static uint[] Reversible(byte[] a, int sz, byte[] md5)
{
if (sz < 2 || (sz & 1) == 1) {
throw new ArgumentException(String.Format("Invalid input size: {0}", sz), "sz");
}
unchecked {
uint o1 = 0;
uint o2 = 0;
int ta = 0;
int ts = sz;
int ti = ((sz - 2) >> 1) + 1;
uint c0 = BitConverter.ToUInt32(md5, 0) | 1;
uint c1 = BitConverter.ToUInt32(md5, 4) | 1;
for (uint i = (uint)ti; i > 0; i--) {
uint n = (BitConverter.ToUInt32(a, ta) + o1) * c0;
n = 0xB1110000 * n - 0x30674EEF * (n >> 16);
ta += 8;
ts -= 2;
uint v1 = 0x5B9F0000 * n - 0x78F7A461 * (n >> 16);
uint v2 = 0x1D830000 * (0x12CEB96D * (v1 >> 16) - 0x46930000 * v1) +
0x257E1D83 * ((0x12CEB96D * (v1 >> 16) - 0x46930000 * v1) >> 16);
uint v3 = BitConverter.ToUInt32(a, ta - 4) + v2;
uint v4 = 0x16F50000 * c1 * v3 - 0x5D8BE90B * (c1 * v3 >> 16);
uint v5 = 0x2B890000 * (0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16)) +
0x7C932B89 * ((0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16)) >> 16);
o1 = 0x9F690000 * v5 - 0x405B6097 * (v5 >> 16);
o2 += o1 + v2;
}
if (ts == 1) {
uint n = BitConverter.ToUInt32(a, ta) + o1;
uint v1 = 0xB1110000 * c0 * n - 0x30674EEF * ((c0 * n) >> 16);
uint v2 = 0x5B9F0000 * v1 - 0x78F7A461 * (v1 >> 16);
uint v3 = 0x1D830000 * (0x12CEB96D * (v2 >> 16) - 0x46930000 * v2) +
0x257E1D83 * ((0x12CEB96D * (v2 >> 16) - 0x46930000 * v2) >> 16);
uint v4 = 0x16F50000 * c1 * v3 - 0x5D8BE90B * ((c1 * v3) >> 16);
uint v5 = 0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16);
o1 = 0x9F690000 * (0x2B890000 * v5 + 0x7C932B89 * (v5 >> 16)) -
0x405B6097 * ((0x2B890000 * v5 + 0x7C932B89 * (v5 >> 16)) >> 16);
o2 += o1 + v2;
}
uint[] ret = new uint[2];
ret[0] = o1;
ret[1] = o2;
return ret;
}
}
public static long MakeLong(uint left, uint right) {
return (long)left << 32 | (long)right;
}
}
}
"@
$RegQueryInfoKey = Add-Type -MemberDefinition $RegQueryInfoKeySig -Name ImportedFuncs -Namespace RegQueryInfoKey -Using System.Text -PassThru
function Get-ObjectCount($Objects) {
if (!$Objects) {
return 0
} else {
return ($Objects | Measure).Count
}
}
function Get-HKURootForUser($User) {
try {
if ($User.CU) {
return "HKCU:"
} else {
$Root = "registry::HKEY_USERS\$($User.SID)"
if (!(Test-Path $Root)) {
throw "Root HKU subkey does not exist or is unavaliable"
} else {
return $Root
}
}
} catch {
Write-Warning "Failed to retrieve HKU subkey for user `"$($User.Name)`": $_"
}
return $null
}
function Get-HKUKeyForUser($User) {
try {
$Key = "$($User.Root)\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\$Extension\UserChoice"
if (!(Test-Path $Key)) {
throw "UserChoice subkey does not exist or is unavaliable"
} else {
return $Key
}
} catch {
Write-Warning "Failed to retrieve UserChoice subkey for user `"$($User.Name)`": $_"
}
return $null
}
function Get-KeyWriteTimeForUser($User) {
try {
if ($User.Key -is [Microsoft.Win32.RegistryKey]) {
$Key = $User.Key
} else {
$Key = Get-Item -Path $User.Key -ErrorAction Stop
if (!$Key -or ($Key -isnot [Microsoft.Win32.RegistryKey])) {
throw "Expected RegistryKey, got $($Key.GetType())"
}
}
if (!$Key.Handle) {
throw "Key handle is missing or set to 0"
}
$SBLen = 16384
$SB = New-Object System.Text.StringBuilder -ArgumentList $SBLen
$LastWriteTime = New-Object System.Runtime.InteropServices.ComTypes.FILETIME
switch ($RegQueryInfoKey::RegQueryInfoKey($Key.Handle, $SB, [ref]$SBLen, $null, [ref]$null, [ref]$null, [ref]$null,
[ref]$null, [ref]$null, [ref]$null, [ref]$null, [ref]$LastWriteTime)) {
0 {
$FTHigh = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($LastWriteTime.dwHighDateTime), 0)
$FTLow = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($LastWriteTime.dwLowDateTime), 0)
$FT = [datetime]::FromFileTime(([Int64]$FTHigh -shl 32) -bor $FTLow)
$FTTrunc = (New-Object DateTime $FT.Year, $FT.Month, $FT.Day, $FT.Hour, $FT.Minute, 0, $FT.Kind).ToFileTime()
return [string]::Format("{0:x8}{1:x8}", $FTTrunc -shr 32, $FTTrunc -band [uint32]::MaxValue)
}
default {
throw "RegQueryInfoKey returned error code $_"
}
}
} catch {
Write-Warning "Failed to retrieve write time for UserChoice subkey for user `"$($User.Name)`": $_"
}
return $null
}
function Get-InputStringForUser($User) {
return ("{0}{1}{2}{3}{4}" -f $Extension, $User.SID, $ProgID, $User.WriteTime,
"User Choice set via Windows User Experience {D18B6DD5-6124-4341-9318-804003BAFA0B}").ToLowerInvariant()
}
function Get-IsUserSelected($User) {
if ($CurrentUser) {
return $User.CU
} elseif ($Users) {
return ($User.Name -in $Users)
} else {
return $true
}
}
function Enumerate-Users {
try {
$SIDs = Get-CimInstance -Filter "LocalAccount=TRUE" -Class "Win32_UserAccount" | ? {$_.SID -notmatch "^S-1-5-21-(\d{10}-){3}5[0-9]{2}$"}
if ($SIDs -and (Get-ObjectCount $SIDs) -ge 1) {
$NewSIDs = $SIDs | select Name, SID, @{n="CU"; e={$_.SID -eq (([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value)}}
$NewSIDs = $NewSIDs | ? {(Get-IsUserSelected $_) -eq $true}
return $NewSIDs
}
} catch {
Write-Warning "Failed to enumerate user list through CIM"
}
return @()
}
function Set-UserKeyInfo($User) {
return $User |
select *, @{n="Root"; e={Get-HKURootForUser $_}} | ? Root -ne $null |
select *, @{n="Key"; e={Get-HKUKeyForUser $_}} | ? Key -ne $null
}
function Set-UserExtraInfo($User) {
return $User |
select *, @{n="WriteTime"; e={Get-KeyWriteTimeForUser $_}} | ? WriteTime -ne $null |
select *, @{n="InputString"; e={Get-InputStringForUser $_}} | ? InputString -ne $null
}
function Get-IsProgIDAvaliable($User) {
if ($SkipProgIDValidation) {
return $true
}
return (Test-Path "$($User.Root)\Software\Classes\$ProgID" -ErrorAction SilentlyContinue) -or
(Test-Path "HKLM:\Software\Classes\$ProgID" -ErrorAction SilentlyContinue)
}
function Clear-UserChoice($User) {
if (!$User.Key) {
throw "No registry key provided for Clear-UserChoice"
}
if ($PSCmdlet.ShouldProcess($User.Key, "Clear-ItemProperty")) {
Clear-ItemProperty -Path $User.Key -Name "Hash"
}
}
function Set-UserChoice($User, $Hash) {
if (!$Hash) {
throw "No hash provided for Set-UserChoice"
}
if ($PSCmdlet.ShouldProcess($User.Key, "Set-ItemProperty")) {
Set-ItemProperty -Path $User.Key -Name "ProgId" -Value $ProgID -Type String
Set-ItemProperty -Path $User.Key -Name "Hash" -Value $Hash -Type String
}
}
function Convert-StringToUTF16LEArray($Str) {
return [System.Collections.ArrayList]([System.Text.Encoding]::Unicode.GetBytes($Str))
}
function Get-ArrayMD5Hash($A) {
return [System.Security.Cryptography.HashAlgorithm]::Create("MD5").ComputeHash($A)
}
function Get-PatentHash([byte[]]$A, [byte[]]$MD5) {
$Size = $A.Count
$ShiftedSize = ($Size -shr 2) - ($Size -shr 2 -band 1) * 1
[uint32[]]$A1 = [SetFileAssoc.PatentHash.HashFuncs]::WordSwap($A, [int]$ShiftedSize, $MD5);
[uint32[]]$A2 = [SetFileAssoc.PatentHash.HashFuncs]::Reversible($A, [int]$ShiftedSize, $MD5);
$Ret = [SetFileAssoc.PatentHash.HashFuncs]::MakeLong($A1[1] -bxor $A2[1], $A1[0] -bxor $A2[0]);
return $Ret
}
{
[System.Int32]$ReturnCode = -1
if (!$CurrentUser -and !$AllUsers -and !$Users) {
$CurrentUser = $true
}
$EnumeratedUsers = Enumerate-Users
foreach ($User in $EnumeratedUsers) {
$User = Set-UserKeyInfo $User
if (!$User) {
continue
}
try {
Clear-UserChoice $User
} catch {
Write-Warning "Clear-UserChoice failed, skipping user `"$($User.Name)`""
continue
}
$User = Set-UserExtraInfo $User
if (!$User) {
continue
}
try {
$A = Convert-StringToUTF16LEArray $User.InputString
$A += (0,0)
$MD5 = Get-ArrayMD5Hash $A
$PatentHash = Get-PatentHash $A $MD5
$Hash = [System.Convert]::ToBase64String([System.BitConverter]::GetBytes([Int64]$PatentHash))
Write-Verbose "Hash for user `"$($User.Name)`": $Hash"
} catch {
Write-Warning "Failed to calculate hash for `"$($User.Name)`", skipping this user"
continue
}
try {
Set-UserChoice -User $User -Hash $Hash
} catch {
Write-Warning "Set-UserChoice failed for user `"$($User.Name)`""
}
Write-Host "File association set for user `"$($User.Name)`": $Extension => $ProgID (hash `"$Hash`")"
$ReturnCode = 0 # return 0 if at least one assoc was set correctly
}
exit $ReturnCode
}.Invoke()