Windows Installer, Exploiting Custom Actions

Over a year ago, I published my research around the Windows Installer Service. The article explained in detail how the MSI repair process executes in an elevated context, but the lack of impersonation could lead to Arbitrary File Delete and similar issues. The issue was acknowledged by Microsoft (as CVE-2023-21800), but it was never directly fixed. Instead, the introduction of a Redirection Guard mitigated all symlink attacks in the context of the msiexec process. Back then, I wasn’t particularly happy with the solution, but I couldn’t find any bypass.

The Redirection Guard turned out to work exactly as intended, so I spent some time attacking the Windows Installer Service from other angles. Some bugs were found (CVE-2023-32016), but I always felt that the way Microsoft handled the impersonation issue wasn’t exactly right. That unfixed behavior became very useful during another round of research.

This article describes the unpatched vulnerability affecting the latest Windows 11 versions. It illustrates how the issue can be leveraged to elevate a local user’s privileges. The bug submission was closed after half-a-year of processing, as non-reproducible. I will demonstrate how the issue can be reproduced by anyone else.

Custom Actions

Custom Actions in the Windows Installer world are user-defined actions that extend the functionality of the installation process. Custom Actions are necessary in scenarios where the built-in capabilities of Windows Installer are insufficient. For example, if an application requires specific registry keys to be set dynamically based on the user’s environment, a Custom Action can be used to achieve this. Another common use case is when an installer needs to perform complex tasks like custom validations or interactions with other software components that cannot be handled by standard MSI actions alone.

Overall, Custom Actions can be implemented in different ways, such as:

  • Compiled to custom DLLs using the exposed C/C++ API
  • Inline VBScript or JScript snippets within the WSX file
  • Explicitly calling system commands within the WSX file

All of the above methods are affected, but for simplicity, we will focus on the last type.

Let’s take a look at an example WSX file (poc.wsx) containing some Custom Actions:

<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
    <Product Id="{12345678-9259-4E29-91EA-8F8646930000}" Language="1033" Manufacturer="YourCompany" Name="HelloInstaller" UpgradeCode="{12345678-9259-4E29-91EA-8F8646930001}" Version="1.0.0.0">
        <Package Comments="This installer database contains the logic and data required to install HelloInstaller." Compressed="yes" Description="HelloInstaller" InstallerVersion="200" Languages="1033" Manufacturer="YourCompany" Platform="x86" ReadOnly="no" />

        <CustomAction Id="SetRunCommand" Property="RunCommand" Value="&quot;[%USERPROFILE]\test.exe&quot;" Execute="immediate" />
        <CustomAction Id="RunCommand" BinaryKey="WixCA" DllEntry="WixQuietExec64" Execute="commit" Return="ignore" Impersonate="no" />
        <Directory Id="TARGETDIR" Name="SourceDir">
            <Directory Id="ProgramFilesFolder">
                <Directory Id="INSTALLFOLDER" Name="HelloInstaller" ShortName="krp6fjyg">
                    <Component Id="ApplicationShortcut" Guid="{12345678-9259-4E29-91EA-8F8646930002}" KeyPath="yes">
                        <CreateFolder Directory="INSTALLFOLDER" />
                    </Component>
                </Directory>
            </Directory>
        </Directory>
        <Property Id="ALLUSERS" Value="1" />
        <Feature Id="ProductFeature" Level="1" Title="Main Feature">
            <ComponentRef Id="ApplicationShortcut" />
        </Feature>
        <MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." Schedule="afterInstallValidate" />

        <InstallExecuteSequence>
            <Custom Action="SetRunCommand" After="InstallInitialize">1</Custom>
            <Custom Action="RunCommand" After="SetRunCommand">1</Custom>
        </InstallExecuteSequence>
    </Product>
</Wix>

This looks like a perfectly fine WSX file. It defines the InstallExecuteSequence, which consists of two custom actions. The SetRunCommand is queued to run right after the InstallInitialize event. Then, the RunCommand should start right after SetRunCommand finishes.

The SetRunCommand action simply sets the value of the RunCommand property. The [%USERPROFILE] string will be expanded to the path of the current user’s profile directory. This is achieved by the installer using the value of the USERPROFILE environment variable. The expansion process involves retrieving the environment variable’s value at runtime and substituting [%USERPROFILE] with this value.

The second action, also called RunCommand, uses the RunCommand property and executes it by calling the WixQuietExec64 method, which is a great way to execute the command quietly and securely (without spawning any visible windows). The Impersonate="no" option enables the command to execute with LocalSystem’s full permissions.

On a healthy system, the administrator’s USERPROFILE directory cannot be accessed by any less privileged users. Whatever file is executed by the RunCommand shouldn’t be directly controllable by unprivileged users.

We covered a rather simple example. Implementing the intended Custom Action is actually quite complicated. There are many mistakes that can be made. The actions may rely on untrusted resources, they can spawn hijackable console instances, or run with more privileges than necessary. These dangerous mistakes may be covered in future blogposts.

Testing the Installer

Having the WiX Toolset at hand, we can turn our XML into an MSI file. Note that we need to enable the additional WixUtilExtension to use the WixCA:

candle .\poc.wxs 
light .\poc.wixobj -ext WixUtilExtension.dll

The poc.msi file should be created in the current directory.

According to our WSX file above, once the installation is initialized, our Custom Action should run the "[%USERPROFILE]\test.exe" file. We can set up a ProcMon filter to look for that event. Remember to also enable the “Integrity” column.

Procmon filters settings

We can install the application using any Admin account (the Almighty user here)

msiexec /i C:\path\to\poc.msi

ProcMon should record the CreateFile event. The file was not there, so additional file extensions were tried.

NAME NOT FOUND event for the almighty user

The same sequence of actions can be reproduced by running an installation repair process. The command can point at the specific C:/Windows/Installer/*.msi file or use a GUID that we defined in a WSX file:

msiexec /fa {12345678-9259-4E29-91EA-8F8646930000}

The result should be exactly the same if the Almighty user triggered the repair process.

On the other hand, note what happens if the installation repair was started by another unprivileged user: lowpriv.

NAME NOT FOUND event for the lowpriv user

It is the user’s environment that sets the executable path, but the command still executes with System level integrity, without any user impersonation! This leads to a straightforward privilege escalation.

As a final confirmation, the lowpriv user would plant an add-me-as-admin type of payload under the C:/Users/lowpriv/test.exe path. The installation process will not finish until the test.exe is running, handling that behavior is rather trivial, though.

Event details

Optionally, add /L*V log.txt to the repair command for a detailed log. The poisoned properties should be evident:

MSI (s) (98:B4) [02:01:33:733]: Machine policy value 'AlwaysInstallElevated' is 0
MSI (s) (98:B4) [02:01:33:733]: User policy value 'AlwaysInstallElevated' is 0
...
Action start 2:01:33: InstallInitialize.
MSI (s) (98:B4) [02:01:33:739]: Doing action: SetRunCommand
MSI (s) (98:B4) [02:01:33:739]: Note: 1: 2205 2:  3: ActionText
Action ended 2:01:33: InstallInitialize. Return value 1.
MSI (s) (98:B4) [02:01:33:740]: PROPERTY CHANGE: Adding RunCommand property. Its value is '"C:\Users\lowpriv\test.exe"'.
Action start 2:01:33: SetRunCommand.
MSI (s) (98:B4) [02:01:33:740]: Doing action: RunCommand
MSI (s) (98:B4) [02:01:33:740]: Note: 1: 2205 2:  3: ActionText
Action ended 2:01:33: SetRunCommand. Return value 1.

The Poisoned Variables

The repair operation in msiexec.exe can be initiated by a standard user, while automatically elevating its privileges to execute certain actions, including various custom actions defined in the MSI file. Notably, not all custom actions execute with elevated privileges. Specifically, an action must be explicitly marked as Impersonate="no", be scheduled between the InstallExecuteSequence and InstallFinalize events, and use either commit, rollback or deferred as the execution type to run elevated.

In the future, we may publish additional materials, including a toolset to hunt for affected installers that satisfy the above criteria.

Elevated custom actions may use environment variables as well as Windows Installer properties (see the full list of properties). I’ve observed the following properties can be “poisoned” by a standard user that invokes the repair process:

  • “AdminToolsFolder”
  • “AppDataFolder”
  • “DesktopFolder”
  • “FavoritesFolder”
  • “LocalAppDataFolder”
  • “MyPicturesFolder”
  • “NetHoodFolder”
  • “PersonalFolder”
  • “PrintHoodFolder”
  • “ProgramMenuFolder”
  • “RecentFolder”
  • “SendToFolder”
  • “StartMenuFolder”
  • “StartupFolder”
  • “TempFolder”
  • “TemplateFolder”

Additionally, the following environment variables are often used by software installers (this list is not exhaustive):

  • “APPDATA”
  • “HomePath”
  • “LOCALAPPDATA”
  • “TEMP”
  • “TMP”
  • “USERPROFILE”

These values are typically utilized to construct custom paths or as system command parameters. Poisoned values can alter the command’s intent, potentially leading to a command injection vulnerability.

Note that the described issue is not exploitable on its own. The MSI file utilizing a vulnerable Custom Action must be already installed on the machine. However, the issue could be handy to pentesters performing Local Privilege Elevation or as a persistence mechanism.

Disclosure Timeline

The details of this issue were reported to the Microsoft Security Response Center on December 1, 2023. The bug was confirmed on the latest Windows Insider Preview build at the time of the reporting: 26002.1000.rs_prerelease.231118-1559.

Disclosure Timeline Status
12/01/2023 The vulnerability reported to Microsoft
02/09/2024 Additional details requested
02/09/2024 Additional details provided
05/09/2024 Issue closed as non-reproducible: “We completed the assessment and because we weren’t able to reproduce the issue with the repro steps provide _[sic]_. We don’t expect any further action on the case and we will proceed with closing out the case.”

We asked Microsoft to reopen the ticket and the blogpost draft was shared with Microsoft prior to the publication.

As of now, the issue is still not fixed. We confirmed that it is affecting the current latest Windows Insider Preview build 10.0.25120.751.