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.
| Index | Offset | Method Name | Signature |
|---|---|---|---|
| 0 | 0x00 | QueryInterface | (GUID* riid, void** ppv) |
| 1 | 0x08 | AddRef | () |
| 2 | 0x10 | Release | () |
| 3 | 0x18 | InstallFinalize | (iesEnum, IMsiMessage*, uchar) |
| 4 | 0x20 | SetLastUsedSource | (HWND, ushort*, ushort*, uchar, uchar) |
| 5 | 0x28 | Reboot | () |
| 6 | 0x30 | DoInstall | (ireEnum, ushort*, ushort*, ushort*, ushort*, int, uchar, IMsiMessage*, iioEnum, ulong, HWND*, IMsiRecord*) |
| 7 | 0x38 | IsServiceInstalling | () |
| 8 | 0x40 | RegisterUser | (ushort*, ushort*, ushort*, ushort*) |
| 9 | 0x48 | RemoveRunOnceEntry | (ushort*) |
| 10 | 0x50 | CleanupTempPackages | (IMsiMessage*, uchar) |
| 11 | 0x58 | SourceListClearByType | (ushort*, ushort*, isrcEnum) |
| 12 | 0x60 | SourceListAddSource | (ushort*, ushort*, isrcEnum, ushort*) |
| 13 | 0x68 | SourceListClearLastUsed | (ushort*, ushort*) |
| 14 | 0x70 | RegisterCustomActionServer | (icacCustomActionContext*, uchar*, int, IMsiCustomAction*, ulong*, IMsiRemoteAPI**, ulong*) |
| 15 | 0x78 | CreateCustomActionServer | (icacCustomActionContext, ulong, IMsiRemoteAPI*, ushort*, ulong, ulong, uchar*, int*, IMsiCustomAction**, ulong*, uchar, ushort) |
| 16 | 0x80 | SourceListUpdate | (uchar, ushort*, ushort*, ulong, ulong, ulong, ushort*, ushort*, ulong, iSrcOpEnum) |
| 17 | 0x88 | SourceListSetInfo | (ushort*, ushort*, ulong, ulong, ushort*, ushort*) |
| 18 | 0x90 | SetInstallParentWindow | (HWND*) |
| 19 | 0x98 | UninstallApplication | (ushort*, uint) |
| 20 | 0xA0 | MsiBeginTransactionW | (ushort*, ulong, ulong*, ushort*) |
| 21 | 0xA8 | MsiEndTransaction | (ulong, IMsiMessage*) |
| 22 | 0xB0 | MsiJoinTransaction | (ulong, ulong, ulong*, ushort*) |
| 23 | 0xB8 | GetTransactionAttributes | () |
| 24 | 0xC0 | SetChangeOfOwnerEvent | () |
| 25 | 0xC8 | SetEEUIDirectoryAndFilter | (ushort*, ulong) |
| 26 | 0xD0 | GetJoinEEUIInfo | (ushort*, ulong*, icacCustomActionContext*) |
| 27 | 0xD8 | SetEEUIServerDetails | (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:
MsiBeginTransactionW()
establishes an active transaction.SetEEUIDirectoryAndFilter()
schedules a user-accessible folder path in theTempPackages
registry key. The only access check is that the caller hasDELETE
permission on the folder, there is no verification that the folder was created by the MSI service.CleanupTempPackages()
enumeratesTempPackages
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 TempPackage
, CleanupTempPackages()
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::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::~CComPointer(&v21); CComPointer::~CComPointer(&v28); CComPointer::~CComPointer(&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 #2
represents 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 TempPackages
registry 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::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::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::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 #
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::~CComPointer(&v12); } else { CComPointer::~CComPointer(&v12); v3 = 0; } } } CAPITempBuffer::Destroy(&lpPathName); CAPITempBuffer::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()
withDELETE
access. This limits what folder the user can delete. - The assumption is correct since the
MSI Install Server
is mitigated withRedirectionGuard()
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 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 ofC:\
. - At [2], a handle is opened with
GENERIC_READ | WRITE_DAC | READ_CONTROL | DELETE
access, granting DELETE permission so thatSetEEUIDirectoryAndFilter()
‘s check passes. - At [3], the MSI server COM object is instantiated.
- At [4], a transaction is begun via
MsiBeginTransactionW()
(vtable offset0xA0
), creating theCMsiTransaction
object. - At [5],
C:\Config.Msi
is registered inTempPackages
viaSetEEUIDirectoryAndFilter()
, the permission check passes because the folder was opened with DELETE. - At [6],
CleanupTempPackages()
(vtable offset0x50
) is called,CleanupTempPackagesInternal()
enumeratesTempPackages
, findsC:\Config.Msi
with the folder flag, and callsFDeleteFolder()
, 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 DELETE
access 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
- Instantiate the MSI Install Server COM object via
CreateMsiServer()
, which returns aCMsiConfigurationManager
COM object. - Create a
C:\Config.Msi
folder withGENERIC_READ | WRITE_DAC | READ_CONTROL | DELETE
access flags. - Call
MsiBeginTransactionW()
exposed from theCMsiConfigurationManager
interface (vtable offset0xA0
). - Call
SetEEUIDirectoryAndFilter()
exposed from theCMsiConfigurationManager
interface (vtable offset0xC8
) withC:\Config.Msi
as the first argument. If successful, the folderC:\Config.Msi
is added toHKLM\Software\Microsoft\Windows\CurrentVersion\Installer\TempPackages
as aREG_DWORD
. This sets the condition for triggering the delete. - 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.
- Cleanup & Install: Uninstall any previous MSI instance, then install a crafted
poc.msi
to a temp folder. Windows Installer createsC:\Config.Msi
, registers it inHKLM\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. - Open tracking handle: The exploit opens
marker.txt
(the installed file in a temp folder) withFILE_SHARE_DELETE
. This handle will follow the file if it is moved, since Windows handles track the file object, not the path. - 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. - Start uninstall in parallel: The uninstaller moves
marker.txt
intoC:\Config.Msi
as an.rbf
(rollback file), since it needs to preserve the original file in case rollback is needed. - Discover the .rbf name: The exploit polls
GetFinalPathNameByHandle()
on themarker.txt
handle. Once the installer moves the file, the reported path changes toC:\Config.Msi\something.rbf
, revealing the filename. - 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. - Lock the .rbf: The exploit opens a new handle to the
.rbf
withoutFILE_SHARE_DELETE
. This works because the.rbf
retainsmarker.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). - 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, andC:\Config.Msi
cannot be deleted because it is not empty. The uninstaller gives up and completes. - Delete the .rbf: The exploit sets
FileDispositionInfo
(delete-on-close) on its own handle, then closes it. NowC:\Config.Msi
is empty but still registered inHKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders
.
Phase 2: Trigger the C:\Config.Msi Folder Delete as SYSTEM
- Trigger the vulnerability: Calling
CleanupTempPackages()
exposed from theCMsiConfigurationManager
interface (vtable offset0x50
) will enumerate the values atHKLM\Software\Microsoft\Windows\CurrentVersion\Installer\TempPackages
and trigger theC:\Config.Msi
folder delete. The folder will be deleted by themsiexec.exe
service process running as SYSTEM. TheHKLM\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.
- 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 ofC:\
, and theHKLM\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. - Open a WRITE_DAC handle: The exploit opens a handle to
C:\Config.Msi
withGENERIC_READ | READ_CONTROL | WRITE_DAC | FILE_DELETE_CHILD
. TheWRITE_DAC
permission is granted because the folder currently has a NULL DACL. This is critical: this handle retains theWRITE_DAC
permission even after the DACL is later changed, because Windows performs access checks at handle-open time, not at use time. - 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. - Watch for .rbs creation: The exploit calls
ReadDirectoryChangesW()
to monitorC:\Config.Msi
for new files. - 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.rbs
write is complete, then waits for the exploit to allow rollback to proceed. - Windows Installer uses C:\Config.Msi: Because the
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Folders
registry entry still exists, Windows Installer recognizesC:\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 itsWRITE_DAC
handle. - Detect the .rbs filename:
ReadDirectoryChangesW()
reports the creation of the.rbs
file, giving the exploit the exact filename. - Re-apply NULL DACL: The exploit uses
NtSetSecurityObject()
with itsWRITE_DAC
handle 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 grantedWRITE_DAC
at that time and retains it. - 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 executepoc.cmd
.
- Deletes the legitimate
- Trigger rollback: The exploit signals the installer to proceed with rollback. The installer reads the malicious
.rbs
file, and during rollback it executes thepoc.cmd
payload as SYSTEM. - 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.
