EXODUS BLOG


Microsoft Windows Installer Folder Delete Privilege Escalation

By Endri Domi

Overview

This blog post details a logic vulnerability discovered internally in the COM interface exposed from the msi.dll binary. The vulnerability allows a low-privileged user to cause the MSI service to delete an arbitrary folder that the user has access to, by scheduling its path in the TempPackages registry key. The service does not validate that the folder being deleted was actually created by the installer, it blindly trusts whatever paths are stored in TempPackages. The vulnerability was patched in April 2025 and assigned CVE-2025-27727.

The Microsoft Windows Installer service (msiexec.exe) runs as SYSTEM and handles the installation, modification, and removal of Windows applications packaged as .msi files. It exposes its functionality to client processes through a COM interface, a CMsiConfigurationManager COM object registered with CLSID {000C101C-0000-0000-C000-000000000046} that implements the IMsiServer interface (IID {000C101C-0000-0000-C000-000000000046}).

The exploit technique described in this post (Phases 0–3) leverages this folder-deletion primitive to achieve SYSTEM code execution by planting malicious rollback scripts in a newly recreated C:\Config.Msi folder.

Disclaimer: All function names, struct layouts, and vtable assignments presented in this post were derived through reverse engineering of msi.dll and may not reflect Microsoft’s internal naming conventions.

Background

In this section we give an overviewof the Windows installer COM architecture and the CMsiConfigurationManager Vtable relevant to this vulnerability.

Windows Installer COM Architecture

The Windows Installer service (msiexec.exe) runs as SYSTEM and exposes its functionality through a COM interface. Client processes, regardless of their privilege level, communicate with the service via COM method calls that are marshalled across process boundaries by the RPC runtime.

The server-side entry point is CreateMsiServer(), which instantiates a CMsiConfigurationManager COM object. This object implements the IMsiServer interface (which inherits from IUnknown) and serves as the central dispatcher for all installer operations, including transaction management, folder registration, rollback script handling, and temp package cleanup.

CMsiConfigurationManager Vtable

The public COM interface exposed to callers consists of 28 methods: 3 from IUnknown(offsets 0x00–0x10) and 25 custom methods (offsets 0x18–0xD8). Entries beyond offset 0xD8 are internal-only, they are not reachable through the COM interface and are used for intra-process dispatch within the MSI service.

IndexOffsetMethod NameSignature
00x00QueryInterface(GUID* riid, void** ppv)
10x08AddRef()
20x10Release()
30x18InstallFinalize(iesEnum, IMsiMessage*, uchar)
40x20SetLastUsedSource(HWND, ushort*, ushort*, uchar, uchar)
50x28Reboot()
60x30DoInstall(ireEnum, ushort*, ushort*, ushort*, ushort*, int, uchar, IMsiMessage*, iioEnum, ulong, HWND*, IMsiRecord*)
70x38IsServiceInstalling()
80x40RegisterUser(ushort*, ushort*, ushort*, ushort*)
90x48RemoveRunOnceEntry(ushort*)
100x50CleanupTempPackages(IMsiMessage*, uchar)
110x58SourceListClearByType(ushort*, ushort*, isrcEnum)
120x60SourceListAddSource(ushort*, ushort*, isrcEnum, ushort*)
130x68SourceListClearLastUsed(ushort*, ushort*)
140x70RegisterCustomActionServer(icacCustomActionContext*, uchar*, int, IMsiCustomAction*, ulong*, IMsiRemoteAPI**, ulong*)
150x78CreateCustomActionServer(icacCustomActionContext, ulong, IMsiRemoteAPI*, ushort*, ulong, ulong, uchar*, int*, IMsiCustomAction**, ulong*, uchar, ushort)
160x80SourceListUpdate(uchar, ushort*, ushort*, ulong, ulong, ulong, ushort*, ushort*, ulong, iSrcOpEnum)
170x88SourceListSetInfo(ushort*, ushort*, ulong, ulong, ushort*, ushort*)
180x90SetInstallParentWindow(HWND*)
190x98UninstallApplication(ushort*, uint)
200xA0MsiBeginTransactionW(ushort*, ulong, ulong*, ushort*)
210xA8MsiEndTransaction(ulong, IMsiMessage*)
220xB0MsiJoinTransaction(ulong, ulong, ulong*, ushort*)
230xB8GetTransactionAttributes()
240xC0SetChangeOfOwnerEvent()
250xC8SetEEUIDirectoryAndFilter(ushort*, ulong)
260xD0GetJoinEEUIInfo(ushort*, ulong*, icacCustomActionContext*)
270xD8SetEEUIServerDetails(IMsiCustomAction*, uchar*, ulong, icacCustomActionContext)

The three vtable entries that are relevant for the vulnerability’s path:

  • Offset 0xA0 (MsiBeginTransactionW)
  • Offset 0xC8 (SetEEUIDirectoryAndFilter)
  • Offset 0x50 (CleanupTempPackages)

Vulnerability

The CMsiConfigurationManager COM interface exposes three methods that, when called in sequence, allow a low-privileged user to cause the MSI service (running as SYSTEM) to delete an arbitrary folder:

  1. MsiBeginTransactionW() establishes an active transaction.
  2. SetEEUIDirectoryAndFilter() schedules a user-accessible folder path in the TempPackages registry key. The only access check is that the caller has DELETEpermission on the folder, there is no verification that the folder was created by the MSI service.
  3. CleanupTempPackages() enumerates TempPackages and deletes the referenced folders as SYSTEM. There is no validation that the paths being deleted were actually created by the installer, the service blindly trusts the TempPackages registry contents.

The logic flaw is straightforward: the MSI service trusts the TempPackages registry as an authoritative record of folders it should clean up, but a low-privileged user can write arbitrary paths to this registry key via SetEEUIDirectoryAndFilter(). The only gatekeeping is a DELETE permission check on the folder, which is trivially satisfied if the user created the folder themselves with permissive ACLs. Once a path is in TempPackageCleanupTempPackages() will delete it as SYSTEM, including LockdownPath fallback to override restrictive ACLs. The service assumes that TempPackages entries are created only by the installer itself, but the COM interface allows low-privileged users to inject arbitrary paths.

CMsiConfigurationManager::MsiBeginTransactionW()

The CMsiConfigurationManager::MsiBeginTransactionW() method is the COM interface method at vtable offset 0xA0:

__int64 __fastcall CMsiConfigurationManager::MsiBeginTransactionW(
        CMsiConfigurationManager *this,
        const unsigned __int16 *a2,
        int a3,
        unsigned int *a4,
        unsigned __int16 *a5)
{
  EnterCriticalSection(&g_csServerInterfaceLock);

[1]

  LODWORD(a4) = CMsiTransaction::CreateTransaction(a2, a3 | 0x80000000, 0, a4, a5);
  LeaveCriticalSection(&g_csServerInterfaceLock);
  return (unsigned int)a4;
}

At [1], it calls CMsiTransaction::CreateTransaction() and a global transaction object will be created and returned to the caller. This newly created transaction is then used in the CMsiConfigurationManager::SetEEUIDirectoryAndFilter() method.

CMsiConfigurationManager::SetEEUIDirectoryAndFilter()

The entry point is CMsiConfigurationManager::SetEEUIDirectoryAndFilter() at vtable offset 0xC8:

__int64 __fastcall CMsiConfigurationManager::SetEEUIDirectoryAndFilter(
        CMsiConfigurationManager *this,
        const unsigned __int16 *a2,
        unsigned int a3)
{
  unsigned int v3; // edi
  CMsiTransaction *v6; // rsi
  HANDLE FileW; // rbx
  bool v8; // cl
  MsiString *v9; // rax
  unsigned __int16 *v10; // rax
  __int64 v12; // [rsp+60h] [rbp-28h] BYREF
  HANDLE v13; // [rsp+A8h] [rbp+20h] BYREF

  v3 = 0;
  LODWORD(v13) = 0;

[2]

  EnterCriticalSection(&g_csServerInterfaceLock);
  v6 = (CMsiTransaction *)CMsiTransaction::m_pMsiTransaction;

[3]

  if ( !IsAdmin() )
  {

[4]

    StartImpersonating();
    FileW = CreateFileW(a2, 0x10000u, 1u, 0i64, 3u, 0x2200000u, 0i64);
    v13 = FileW;
    StopImpersonating(v8);
    if ( FileW == (HANDLE)-1i64 )
    {

[Truncated]

    }
    CHandle::~CHandle((CHandle *)&v13);
  }

[5]

  if ( v6 )
    v3 = CMsiTransaction::SetEEUIDirectoryAndFilter(v6, a2, a3);
  LABEL_11:
  LeaveCriticalSection(&g_csServerInterfaceLock);
  return v3;
}

At [2], the critical section g_csServerInterfaceLock is entered to serialize all server interface calls. The global transaction pointer m_pMsiTransaction is read, this is the transaction object created by MsiBeginTransactionW.

At [3], the function checks if the caller of this function is an Administrator account, and when this is not the case the StartImpersonating() function is called, at [4]. This function impersonates the calling user.

Under impersonation, the function CreateFileW() is called. The first argument passed to the function is supplied as the file to open the handle for. Due to the second argument (0x10000), the file requests the DELETE access flag. The 0x2200000 flags are FILE_FLAG_BACKUP_SEMANTICS (0x2000000) needed to open directories, ORed with FILE_FLAG_OPEN_REPARSE_POINT (0x200000) which opens the reparse point itself rather than following the target, a security measure against junction attacks. If CreateFileW returns INVALID_HANDLE_VALUE, the caller does not have delete permissions on the directory and ERROR_ACCESS_DENIED is returned.

When a valid handle returns, the code reaches [5]. Here the CMsiTransaction::SetEEUIDirectoryAndFilter() method is called using the transaction created earlier.

On the server side, CMsiTransaction::SetEEUIDirectoryAndFilter() stores the path and flags, then schedules the folder for deletion:

__int64 __fastcall CMsiTransaction::SetEEUIDirectoryAndFilter(
        CMsiTransaction *this,
        const unsigned __int16 *a2,
        int a3)
{
  if ( !CMsiTransaction::IsValidCaller(this) )
    return 5i64;

[6]

  StringCchCopyW((unsigned __int16 *)this + 82, 0x104ui64, a2);

[7]

  *((_DWORD *)this + 40) = a3;

[8]

  return CMsiTransaction::ScheduleFileOrFolderDelete(this, (const unsigned __int16 *)this + 82, 1);
}

At [6], IsValidCaller() validates that the calling thread is the same thread that called MsiBeginTransactionW(), the transaction owner. The argument for this method is copied to (unsigned __int16 *)this + 82, the WCHAR[260] EEUI directory buffer. This buffer is then used as the argument for the CMsiTransaction::ScheduleFileOrFolderDelete() method when it is called at [7]. At [8], the filter flags are stored at (_DWORD *)this + 40, byte offset 40 * 4 = 0xA0, indicating what types of files to show in the EEUI.

Within the CMsiTransaction::ScheduleFileOrFolderDelete() method, the user-supplied path is written to the TempPackage registry:

__int64 __fastcall CMsiTransaction::ScheduleFileOrFolderDelete(
        CMsiTransaction *this,
        const unsigned __int16 *a2,
        char a3)
{

[Truncated]

[9]

  MsiString::MsiString((MsiString *)&v24, a2);
  v11 = &MsiString::s_NullString;

[10]

  if ( a3 )
  {
    v12 = MsiString::MsiString((MsiString *)&v26, 2);
    v13 = MsiString::MsiString((MsiString *)&v25, L"#");
    v14 = (*(__int64 (__fastcall **)(_QWORD, _QWORD))(**(_QWORD **)v13 + 168i64))(*(_QWORD *)v13, *(_QWORD *)v12);
    (*(void (__fastcall **)(void *))(MsiString::s_NullString + 16i64))(&MsiString::s_NullString);
    v11 = (void *)v14;
    (*(void (__fastcall **)(__int64))(*(_QWORD *)v25 + 16i64))(v25);
    (*(void (__fastcall **)(__int64))(*(_QWORD *)v26 + 16i64))(v26);
  }
  v15 = v28;

[11]

  v16 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v24 + 80i64))(v24);

[12]

  v17 = (*(__int64 (__fastcall **)(__int64, __int64, void *))(*(_QWORD *)v15 + 48i64))(v15, v16, v11);
  v18 = *CComPointer<IMsiDatabase>::operator=(&v21, v17) == 0;
  v19 = *(void (__fastcall **)(void *))(*(_QWORD *)v11 + 16i64);
  if ( !v18 )
  {
    v19(v11);
    (*(void (__fastcall **)(__int64))(*(_QWORD *)v24 + 16i64))(v24);
    goto LABEL_10;
  }
  v19(v11);
  (*(void (__fastcall **)(__int64))(*(_QWORD *)v24 + 16i64))(v24);
  CComPointer<IMsiSelectionManager>::~CComPointer<IMsiSelectionManager>(&v21);
  CComPointer<IMsiSelectionManager>::~CComPointer<IMsiSelectionManager>(&v28);
  CComPointer<IMsiSelectionManager>::~CComPointer<IMsiSelectionManager>(&v22);
  CElevate::~CElevate((CElevate *)v23);
  return 0i64;
}

At [9], a new MsiString object is made with the user supplied path as the argument. At [10], since a3 is 1 (folder), the # prefix is concatenated with the integer value 2 to form the registry value data, in MSI’s internal string notation, # prefixes a numeric value, so #2represents the integer 2 (the folder flag, bit 1 set). This new MsiString (v24) is then used at [11] to call the function  CMsiString::GetString(). The user-supplied value is stored in v16. Which is then used at [12] as the second argument when calling v15+48, which is the CMsiRegKey::SetValue() method. This call writes a registry value where the value name is the arbitrary directory path (e.g. C:\Config.Msi) and the value data encodes the folder flag in the actual Windows registry, this appears as a named value of type REG_DWORD with data 0x00000002.

When C:\Config.Msi is provided as the directory path, the created registry structure looks as follows.

HKLM\Software\Microsoft\Windows\CurrentVersion\Installer\TempPackages
└── "C:\Config.Msi"

CMsiConfigurationManager::CleanupTempPackages()

When a transaction completes or the installer service performs periodic cleanup, the CleanupTempPackages() method at vtable offset 0x50 is invoked. This triggers a multi-stage pipeline that enumerates the TempPackage registry and deletes the referenced files and folders.

This is where the logic vulnerability manifests: the cleanup pipeline trusts the TempPackagesregistry entries without validating that the paths being deleted were actually created by the MSI service. Any path written to TempPackages, whether by the installer itself or by an attacker who called SetEEUIDirectoryAndFilter() will be deleted as SYSTEM.

char __fastcall CMsiConfigurationManager::CleanupTempPackages(
        struct IMsiServices **this,
        struct IMsiMessage *a2,
        char a3)
{
  char v3;

  v3 = 0;
  if ( !a3 )

[13]

    return CleanupTempPackagesInternal(this[2], a2);

[14]

  if ( MsiUIMessageContext::SetServiceInstalling((MsiUIMessageContext *)&g_MessageContext, 1u) )
  {
    if ( g_dmDiagnosticMode )
      DebugString(
        9,
        0,
        0,
        L"Server locked. Will skip uninstalled package cleanup, and allow locking install to perform cleanup.",
        L"(NULL)",
        L"(NULL)",
        L"(NULL)",
        L"(NULL)",
        L"(NULL)",
        L"(NULL)",
        0,
        0i64);
  }
  else
  {

[15]

    v3 = CleanupTempPackagesInternal(this[2], a2);
    MsiUIMessageContext::SetServiceInstalling((MsiUIMessageContext *)&g_MessageContext, 0);
  }
  return v3;
}

At [13], when a3 (the fRunningInstall flag) is false, cleanup is performed immediately via a direct call to CleanupTempPackagesInternal() without acquiring the server lock. At [14], when fRunningInstall is true, SetServiceInstalling(TRUE) attempts to set a global flag indicating the MSI service is performing an installation, this prevents two installations from cleaning up simultaneously. At [15], if the lock acquisition succeeds, cleanup proceeds. But if it fails (another install holds the lock), cleanup is silently skipped entirely, the assumption is that the other install will perform cleanup when it finishes.

The core cleanup engine enumerates the TempPackage registry and deletes each referenced file or folder. No validation is performed to confirm that the paths in TempPackages were created by the MSI service, the function blindly trusts the registry contents:

char __fastcall CleanupTempPackagesInternal(struct IMsiServices *a1, struct IMsiMessage *a2)
{
  struct IMsiServices *v2; // r12
  char v3; // r14
  int v4; // r13d

[Truncated]

  __int64 v53; // [rsp+108h] [rbp+7Fh] BYREF

  v2 = a1;
  v3 = 0;
  v52 = 0;
  v4 = 0;

[16]

  CElevate::CElevate((CElevate *)v49, 1);
  if ( v2 )
  {

[Truncated]

    if ( *(_QWORD *)CComPointer<IMsiDatabase>::operator=(&v46, v6) )
    {

[17]

      v7 = (*(__int64 (__fastcall **)(__int64, const wchar_t *, _QWORD, __int64))(*(_QWORD *)v46 + 112i64))(
             v46,
             L"Software\\Microsoft\\Windows\\CurrentVersion\\Installer\\TempPackages",
             0i64,
             0xFFFFFFFFi64);
      if ( *(_QWORD *)CComPointer<IMsiDatabase>::operator=(&v44, v7) )
      {

[Truncated]

[18]

        while ( 1 )
        {

[Truncated]

[19]

          if ( (*(int (__fastcall **)(void *))(*(_QWORD *)v43 + 56i64))(v43) > 1 )
          {
            MsiString::Remove(&v43, 3i64, 35i64);

[20]

            v20 = (*(__int64 (__fastcall **)(void *))(*(_QWORD *)v43 + 32i64))(v43);
            v19 = v20;
            if ( v20 == 0x80000000 )
              v19 = 0;
          }

[21]

          if ( (v19 & 2) != 0 )
          {

[22]

            v21 = (const unsigned __int16 *)(*(__int64 (__fastcall **)(void *))(*(_QWORD *)v42 + 80i64))(v42);

[23]

            if ( FDeleteFolder(v21, v22, v23) )
            {
              v24 = (__int64)v44;
              v25 = (*(__int64 (__fastcall **)(void *))(*(_QWORD *)v42 + 80i64))(v42);
              v26 = (*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v24 + 24i64))(v24, v25, 0i64);
              CComPointer<IMsiDatabase>::operator=(&v53, v26);
            }

[Truncated]

}

At [16], CElevate(true) elevates to SYSTEM privileges, which is required for deleting files in Config.Msi (which has restrictive ACLs), accessing the HKLM registry, and removing locked files. At [17], the TempPackages registry key is opened via OpenRegistryKey() (vtable 0x70). At [18], the main enumeration loop begins. Note that no validation is performed that the paths were created by the MSI service.

At [19], GetIntegerValue() (vtable 0x38) checks whether the entry’s value data is numeric (> 1) or a string, this distinguishes folder entries (numeric flag) from file entries (string path). At [20], for numeric entries, MsiString::Remove(str, 3, 0x23) strips the # numeric prefix delimiter from the value data, MSI stores numeric values internally as #<integer> strings, so #2 becomes 2 after the prefix is removed; then GetInteger() (vtable 0x20) parses the remaining value data as an integer flag.

If the parsed integer is 0x80000000 it is treated as no flags (set to 0). At [21], the FOLDER flag (bit 1 = 0x2) is checked, if set, the folder deletion path is taken. At [22], GetString() (vtable 0x50) returns the folder path from the registry value name. At [23], FDeleteFolder()recursively deletes the folder and all its contents as SYSTEM, then the registry value is removed via DeleteValue() (vtable 0x18).

When a TempPackages entry has the folder flag set, FDeleteFolder() is called to recursively delete the folder and all its contents:

char __fastcall FDeleteFolder(const unsigned __int16 *folder, __int64 a2, __int64 a3)
{
  char v3;

[Truncated]

[24]

    if ( !RemoveDirectoryW(folder) )
    {
      v12 = LockdownPath(folder, 0);
      SetFileAttributesW(folder, 0);
      if ( RemoveDirectoryW(folder) )
      {
        CComPointer<IMsiSelectionManager>::~CComPointer<IMsiSelectionManager>(&v12);
      }
      else
      {
        CComPointer<IMsiSelectionManager>::~CComPointer<IMsiSelectionManager>(&v12);
        v3 = 0;
      }
    }
  }
  CAPITempBuffer<unsigned short,1>::Destroy(&lpPathName);
  CAPITempBuffer<unsigned short,1>::Destroy(&lpFileName);
  return v3;
}

At [24], a call to the RemoveDirectoryW() function is made to delete the passed in folder path.

Key observation:

C:\Config.Msi is a temporary folder created by the Windows Installer (MSI) during software installation. It stores:

  • Rollback scripts (to undo a failed install).
  • Patch files (.msp) being applied.
  • Temporary install data.

The logic flaw:

  • Non-admin callers must have DELETE permission on the directory, verified via CreateFileW() with DELETE access. This limits what folder the user can delete.
  • The assumption is correct since the MSI Install Server is mitigated with RedirectionGuard() that blocks traversal of NTFS junctions and symbolic links created by unprivileged users.
  • The assumption is broken when C:\Config.Msi folder is chosen, since the folder does not normally exist on the drive allowing it to be re-created it with user permissions before marking the folder for deletion.

By installing and importing NtObjectManager one can verify the mitigation on the MSI Server process:

PS C:\tmp> Get-NtProcessMitigations -ProcessId 18392

<PROCESS 18392 - msiexec.exe>
  ImagePath: \Device\HarddiskVolume3\Windows\System32\msiexec.exe
  Win32ImagePath: C:\Windows\System32\msiexec.exe
  CommandLine: C:\WINDOWS\system32\msiexec.exe /V
  IsRestricted: False
  IsAppContainer: False
  IsLowPrivilegeAppContainer: False
  IntegrityLevel: System

[TRUNCATED]

[REDIRECTION TRUST]
  EnforceRedirectionTrust: True
  AuditRedirectionTrust: False

Proof of Concept

The following sequence of COM calls triggers the vulnerability:

[1]
CreateDirectoryW(L"C:\\Config.Msi", NULL);

[2]
HANDLE hDir = CreateFileW(L"C:\\Config.Msi",
    GENERIC_READ | WRITE_DAC | READ_CONTROL | DELETE,
    FILE_SHARE_READ, NULL, OPEN_EXISTING,
    FILE_FLAG_BACKUP_SEMANTICS, NULL);

[3]
CMsiConfigurationManager *pServer = CreateMsiServer();

[4]
pServer->MsiBeginTransactionW(L"PoC", 0, hWnd, &hTransaction);

[5]
pServer->SetEEUIDirectoryAndFilter(L"C:\\Config.Msi", 0);

[6]
pServer->CleanupTempPackages(NULL, FALSE);

The proof-of-concept performs the following steps:

  • At [1], C:\Config.Msi is created with permissive ACLs, any user can create folders at the root of C:\.
  • At [2], a handle is opened with GENERIC_READ | WRITE_DAC | READ_CONTROL | DELETEaccess, granting DELETE permission so that SetEEUIDirectoryAndFilter()‘s check passes.
  • At [3], the MSI server COM object is instantiated.
  • At [4], a transaction is begun via MsiBeginTransactionW() (vtable offset 0xA0), creating the CMsiTransaction object.
  • At [5], C:\Config.Msi is registered in TempPackages via SetEEUIDirectoryAndFilter(), the permission check passes because the folder was opened with DELETE.
  • At [6], CleanupTempPackages() (vtable offset 0x50) is called, CleanupTempPackagesInternal() enumerates TempPackages, finds C:\Config.Msi with the folder flag, and calls FDeleteFolder(), which runs as SYSTEM. No validation is performed that the path was created by the installer.

Exploitation

The vulnerability provides a folder-deletion primitive: any folder the attacker has DELETEaccess to can be deleted as SYSTEM. The following phases describe the exploit technique that leverages this primitive to achieve SYSTEM code execution by planting malicious rollback scripts in a newly recreated C:\Config.Msi folder.

The process is split into 4 phases as explained below:

Phase 0: Prepare the Folder Delete Vulnerability Conditions

  1. Instantiate the MSI Install Server COM object via CreateMsiServer(), which returns a CMsiConfigurationManager COM object.
  2. Create a C:\Config.Msi folder with GENERIC_READ | WRITE_DAC | READ_CONTROL | DELETE access flags.
  3. Call MsiBeginTransactionW() exposed from the CMsiConfigurationManager interface (vtable offset 0xA0).
  4. Call SetEEUIDirectoryAndFilter() exposed from the CMsiConfigurationManagerinterface (vtable offset 0xC8) with C:\Config.Msi as the first argument. If successful, the folder C:\Config.Msi is added to HKLM\Software\Microsoft\Windows\CurrentVersion\Installer\TempPackages as a REG_DWORD. This sets the condition for triggering the delete.
  5. Delete the user-created C:\Config.Msi folder.

Phase 1: Prepare C:\Config.Msi for Deletion

Goal: Leave C:\Config.Msi as an empty, registered folder that can be deleted by the vulnerability.

  1. Cleanup & Install: Uninstall any previous MSI instance, then install a crafted poc.msi to a temp folder. Windows Installer creates C:\Config.Msi, registers it in HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders, and populates it with rollback data. When the install completes, the installer cleans up and deletes the folder, but the registry entry persists.
  2. Open tracking handle: The exploit opens marker.txt (the installed file in a temp folder) with FILE_SHARE_DELETE. This handle will follow the file if it is moved, since Windows handles track the file object, not the path.
  3. Event creation: Create 2 named events for synchronization: one that the MSI custom action signals when the .rbf write is complete, and one that the exploit signals to allow the uninstaller to resume.
  4. Start uninstall in parallel: The uninstaller moves marker.txt into C:\Config.Msi as an .rbf (rollback file), since it needs to preserve the original file in case rollback is needed.
  5. Discover the .rbf name: The exploit polls GetFinalPathNameByHandle() on the marker.txt handle. Once the installer moves the file, the reported path changes to C:\Config.Msi\something.rbf, revealing the filename.
  6. Synchronize with the uninstaller: The MSI custom action signals when the .rbf write is complete, then waits for the exploit to allow the uninstaller to resume. This gives the exploit a safe window to act.
  7. Lock the .rbf: The exploit opens a new handle to the .rbf without FILE_SHARE_DELETE. This works because the .rbf retains marker.txt’s permissive ACL (same-volume move preserves the security descriptor, see Key Tricks below). This handle prevents anyone from deleting the .rbf (sharing violation).
  8. Let the uninstaller finish: The exploit signals the uninstaller to continue. The uninstaller tries to delete the .rbf and the folder but fails, the .rbf cannot be deleted due to the sharing violation, and C:\Config.Msi cannot be deleted because it is not empty. The uninstaller gives up and completes.
  9. Delete the .rbf: The exploit sets FileDispositionInfo (delete-on-close) on its own handle, then closes it. Now C:\Config.Msi is empty but still registered in HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders.

Phase 2: Trigger the C:\Config.Msi Folder Delete as SYSTEM

  1. Trigger the vulnerability: Calling CleanupTempPackages() exposed from the CMsiConfigurationManager interface (vtable offset 0x50) will enumerate the values at HKLM\Software\Microsoft\Windows\CurrentVersion\Installer\TempPackages and trigger the C:\Config.Msi folder delete. The folder will be deleted by the msiexec.exe service process running as SYSTEM. The HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders registry entry remains, this is part of the exploit technique for Phase 3.

Phase 3: Plant Malicious Rollback Scripts

Goal: Create C:\Config.Msi with a weak DACL, let Windows Installer write rollback files there, swap them with malicious versions, and let the installer execute them as SYSTEM.

This phase leverages the HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders registry entry that persists after the vulnerability-triggered deletion.

  1. Re-Create C:\Config.Msi with NULL DACL: The exploit creates C:\Config.Msi with a security descriptor that has a NULL DACL. A NULL DACL grants everyone full access. This works because any user can create folders at the root of C:\, and the HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders registry entry from Phase 1 still exists, so Windows Installer will recognize this folder as a valid rollback location.
  2. Open a WRITE_DAC handle: The exploit opens a handle to C:\Config.Msi with GENERIC_READ | READ_CONTROL | WRITE_DAC | FILE_DELETE_CHILD. The WRITE_DACpermission is granted because the folder currently has a NULL DACL. This is critical: this handle retains the WRITE_DAC permission even after the DACL is later changed, because Windows performs access checks at handle-open time, not at use time.
  3. Event creation: Create 2 named events for synchronization: one that the MSI custom action signals when the .rbs write is complete, and one that the exploit signals to allow rollback to proceed.
  4. Watch for .rbs creation: The exploit calls ReadDirectoryChangesW() to monitor C:\Config.Msi for new files.
  5. Start a failing install: The exploit starts installing the MSI with ERROROUT=1, which causes the install to error out and trigger rollback. The MSI custom action signals when the .rbswrite is complete, then waits for the exploit to allow rollback to proceed.
  6. Windows Installer uses C:\Config.Msi: Because the HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders registry entry still exists, Windows Installer recognizes C:\Config.Msi as the rollback location. It creates an .rbs file there and begins writing rollback instructions. The installer also sets its own restrictive DACL on the folder at this point, but the exploit already has its WRITE_DAChandle.
  7. Detect the .rbs filename: ReadDirectoryChangesW() reports the creation of the .rbsfile, giving the exploit the exact filename.
  8. Re-apply NULL DACL: The exploit uses NtSetSecurityObject() with its WRITE_DAChandle to replace the installer’s restrictive DACL with a NULL DACL again. This is the key trick: even though the folder now has a restrictive DACL, the exploit’s handle was opened when the DACL was permissive, so it was granted WRITE_DAC at that time and retains it.
  9. Swap the rollback files: With the NULL DACL in place, the exploit:
    • Deletes the legitimate .rbs.
    • Writes a malicious .rbs that instructs the installer to execute poc.cmd.
  10. Trigger rollback: The exploit signals the installer to proceed with rollback. The installer reads the malicious .rbs file, and during rollback it executes the poc.cmd payload as SYSTEM.
  11. SYSTEM shell: The attacker will have elevated code execution with SYSTEM privileges.

Demo

Patch

Microsoft patched this vulnerability by adding a feature flag check in CMsiTransaction::SetEEUIDirectoryAndFilter() that prevents the call to ScheduleFileOrFolderDelete():

__int64 __fastcall CMsiTransaction::SetEEUIDirectoryAndFilter(
        CMsiTransaction *this,
        const unsigned __int16 *a2,
        int a3)
{
  if ( !CMsiTransaction::IsValidCaller(this) )
    return 5i64;
  StringCchCopyW((unsigned __int16 *)this + 82, 0x104ui64, a2);
  *((_DWORD *)this + 40) = a3;

[1]

  if ( wil::details::FeatureImpl<__WilFeatureTraits_Feature_2399682873>::__private_IsEnabled(
         (__int64)&`wil::Feature<__WilFeatureTraits_Feature_2399682873>::GetImpl'::`2'::impl) )
    return 0i64;
  else
    return CMsiTransaction::ScheduleFileOrFolderDelete(this, (const unsigned __int16 *)this + 82, 1);
}

At [1], a new feature flag has been added in the code and is checked before calling CMsiTransaction::ScheduleFileOrFolderDelete(). Since the flag is set to enabled, the call to the vulnerable function is not made, the method simply returns success (0) without writing the folder path to the TempPackages registry. This effectively neutralizes the vulnerability by breaking the chain: SetEEUIDirectoryAndFilter() no longer schedules the folder for deletion, so CleanupTempPackages() will never find an attacker-controlled path in TempPackages.

About Exodus Intelligence

Our world-class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild. Our researchers create and use in-house agentic AI tooling to supplement parts of their vulnerability research and exploit development workflow. In addition to efficiency gains, we’re able to ensure AI-enabled research output maintains the same standards of quality as traditional research.

For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact info@exodusintel.com for further discussion.