The MDSec red team are regularly performing research to identify privilege escalation vectors in Windows and macOS for use during red team engagements. Where the indicators in exploiting the EoP are more risky, or we’ve had sufficient use from the vulnerability, we typically report it to the vendor to be fixed. In this post, we’ll document a Windows 11 elevation of privilege vulnerability that we exploited during a number of our red team ops in 2025.
During a review of the scheduled tasks in Windows 11, we noticed a task related to the Microsoft Recall feature and even with that feature disabled, proved particularly interesting.
The scheduled task is configured to run in context of NT AUTHORITY\SYSTEM:

To check if we can start this scheduled task as a low privilege user we can check the DACL’s on the XML file containing task definition:
PS C:\WINDOWS\system32> icacls.exe 'C:\Windows\system32\Tasks\Microsoft\Windows\WindowsAI\Recall\PolicyConfiguration'
C:\Windows\system32\Tasks\Microsoft\Windows\WindowsAI\Recall\PolicyConfiguration NT AUTHORITY\SYSTEM:(F)
NT AUTHORITY\LOCAL SERVICE:(RX) BUILTIN\Administrators:(RX)
NT AUTHORITY\SYSTEM:(R)
Successfully processed 1 files; Failed processing 0 files
Unfortunately low privilege users are not allowed to manually start this scheduled task, but scheduled tasks can also have triggers defined that will start the task automatically when a particular event occurs.
The PolicyConfiguration task for example has multiple triggers defined as seen in the image below:

During the discovery of this vulnerability, the On Workstation unlock of any user trigger was not present on the latest Windows Insider, so instead we focused on the other triggers.
To view custom triggers and the actions defined for this scheduled task we can export the task definition to XML:
<Task version="1.6" xmlns="<http://schemas.microsoft.com/windows/2004/02/mit/task>">
<RegistrationInfo>
<Version>1.0</Version>
<URI>\Microsoft\Windows\WindowsAI\Recall\PolicyConfiguration</URI>
<SecurityDescriptor>D:P(A;;FA;;;SY)(A;;FRFX;;;LS)(A;;FRFX;;;BA)</SecurityDescriptor>
</RegistrationInfo>
<Triggers>
<WnfStateChangeTrigger id="RecallPolicyCheckUpdateTrigger">
<Enabled>true</Enabled>
<StateName>7508BCA32C079E41</StateName>
</WnfStateChangeTrigger>
<WnfStateChangeTrigger id="AADStatusChangeTrigger">
<Enabled>true</Enabled>
<StateName>7508BCA32C0F8241</StateName>
</WnfStateChangeTrigger>
<WnfStateChangeTrigger id="DisableAIDataAnalysisTrigger">
<Enabled>true</Enabled>
<StateName>7528BCA32C079E41</StateName>
</WnfStateChangeTrigger>
<WnfStateChangeTrigger id="UserLoginTrigger">
<Enabled>true</Enabled>
<StateName>7510BCA338038113</StateName>
</WnfStateChangeTrigger>
<SessionStateChangeTrigger id="SessionUnlockTrigger">
<Enabled>true</Enabled>
<StateChange>SessionUnlock</StateChange>
</SessionStateChangeTrigger>
</Triggers>
<Principals>
<Principal id="LocalSystem">
<UserId>S-1-5-18</UserId>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>true</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>false</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="LocalSystem">
<ComHandler>
<ClassId>{0BE6820D-B667-4CB6-931B-C153A77DA895}</ClassId>
</ComHandler>
</Actions>
</Task>
From the task definition we can learn a few things:
Before we can actually look for any vulnerabilities, we need a reliable way of starting this scheduled task.
The Windows Notification Facility is a notification mechanism that allows inter-process communication, cross process communication, communication between userland processes and kernel drivers. We are not going to delve into the internals of WNF today and you can read more about it here and here from people that know a great deal about the topic.
What is important to us is that as many things in the Windows eco system, WNF state names are securable objects and they will have security descriptors that define who can use that WNF state name.
In order to test if we can write to a WNF state name we can use ZwUpdateWnfStateData NTAPI with dummy data. We will first try 7508BCA32C079E41 which maps to RecallPolicyCheckUpdateTrigger
#include <windows.h>
#include <winternl.h>
#pragma comment(lib, "ntdll.lib")
typedef NTSTATUS(WINAPI* ZwUpdateWnfStateDataType)(PVOID, PVOID, ULONG, PVOID, PVOID, PVOID, ULONG);
ZwUpdateWnfStateDataType ZwUpdateWnfStateData;
int main()
{
CHAR stateName[] = { 0x75, 0x08, 0xBC, 0xA3, 0x2C, 0x07, 0x9e, 0x41 };
HMODULE ntdll = LoadLibraryW(L"ntdll.dll");
ZwUpdateWnfStateData = (ZwUpdateWnfStateDataType)GetProcAddress(ntdll, "ZwUpdateWnfStateData");
ZwUpdateWnfStateData(stateName, 0, 0, 0, 0, 0, 0);
return 0;
}
Running this code as a low privileged user successfully starts the scheduled task:

When the task is started it will try to open the C:\Users\%username%\AppData\Local\CoreAIPlatform.00\UKP directory which does not exist by default:

Looking at the stack in procmon, this file operation comes from TryDeleteRecallData function in ShellTaskConfig.dll.
First this function will get the user profile directory using the function GetUserWorkingDirectories(_QWORD *a1)
This function uses WTSEnumerateSessionsW, WTSQueryUserToken and GetUserProfileDirectoryW API’s to obtain all valid desktop sessions, then for each session queries the user’s access token and obtain the user’s profile directory:
if ( WTSEnumerateSessionsW(0, 0, 1u, &ppSessionInfo, &v15) ) // Enumerate all sessions
{
for ( i = 0; i < v15; ++i )
{
hToken = 0;
phToken[0] = &hToken;
phToken[1] = 0;
LOBYTE(v19) = 1;
v7 = WTSQueryUserToken(ppSessionInfo[i].SessionId, &phToken[1]); /// Query user token for each session
wil::details::out_param_t<wil::unique_any_t<wil::details::unique_storage<wil::details::handle_null_resource_policy<int (*)(void *),&int CloseHandle(void *)>>>>::~out_param_t<wil::unique_any_t<wil::details::unique_storage<wil::details::handle_null_resource_policy<int (*)(void *),&int CloseHandle(void *)>>>>(phToken);
if ( v7 )
{
cchSize = 260;
if ( GetUserProfileDirectoryW(hToken, ProfileDir, &cchSize) ) // Get profile directory for each user
{
v8 = -1;
<SNIP>
Then the string AppData\Local\CoreAIPlatform.00\UKP is concatenated to every profile directory retuned by GetUserWorkingDirectories:
std::wstring::_Construct<1,unsigned short const *>(&v30, L"AppData\Local\CoreAIPlatform.00\UKP");
Finally if the directory exists, the TryDeleteUniqueDirectory function is called:
if ( std::filesystem::is_directory((std::filesystem *)Src, v12) ) // Check if its directory
{
v14 = TryDeleteUniqueDirectory((const struct std::filesystem::path *)Src, v13);
*(_DWORD *)(*(_QWORD *)tip2::tip_test<tip2::details::merged_data<_tip_RecallDataDeletedTest,_tip_RecallDataDeletedTest>>::operator->(
&v23,
v24)
+ 264LL) = v14;
if ( v24[0] )
The TryDeleteUniqueDirectory function will call the FindExistingUniqueDirectory, this function uses FindFileFirst/FindFileNext API’s to find all directories that match following format: {????????-????-????-????-????????????}
<SNIP>
std::wstring::_Construct<1,unsigned short const *>(&v12, L"{????????-????-????-????-????????????}");
std::filesystem::operator/(lpFileName);
std::wstring::~wstring(&v12);
memset_0(&FindFileData, 0, sizeof(FindFileData));
v7 = (const WCHAR *)lpFileName;
if ( lpFileName[3] > (LPCWSTR)7 )
v7 = lpFileName[0];
FirstFileW = (char *)FindFirstFileW(v7, &FindFileData);
if ( (unsigned __int64)(FirstFileW - 1) > 0xFFFFFFFFFFFFFFFDuLL )
{
if ( GetLastError() != 2 )
wil::details::in1diag3::Throw_GetLastError(retaddr, v9, v10, v11);
*(_OWORD *)(a1 + 16) = 0;
*(_OWORD *)a1 = 0;
*(_QWORD *)(a1 + 16) = 0;
*(_QWORD *)(a1 + 24) = 7;
*(_WORD *)a1 = 0;
if ( (unsigned __int64)(FirstFileW - 1) > 0xFFFFFFFFFFFFFFFDuLL )
goto LABEL_16;
}
else
{
while ( (FindFileData.dwFileAttributes & 0x10) == 0 && FindNextFileW(FirstFileW, &FindFileData) )
;
do
++v5;
while ( FindFileData.cFileName[v5] );
v12 = 0;
v13 = 0;
v14 = 0;
std::wstring::_Construct<1,unsigned short const *>(&v12, FindFileData.cFileName);
std::filesystem::path::path((std::filesystem::path *)a1, a2);
std::filesystem::path::operator/=((void *)a1, (std::filesystem *)&v12);
std::wstring::~wstring(&v12);
}
FindClose(FirstFileW);
<SNIP>
For every directory returned by FindExistingUniqueDirectory a IShellItem object is created:
FindExistingUniqueDirectory(pszPath, a1);
while ( pszPath[2] )
{
ppv = 0;
v3 = (const WCHAR *)pszPath;
if ( pszPath[3] > (PCWSTR)7 )
v3 = pszPath[0];
v4 = SHCreateItemFromParsingName(v3, 0, &GUID_43826d1e_e718_42ee_bc55_a1e261c37bfe, &ppv);
if ( v4 < 0 )
{
wil::details::in1diag3::_Log_Hr(
retaddr,
(void *)0x4B,
(unsigned int)"pcshell\shell\aix\shellconfigtask\lib\..\inc\ShellConfigTaskHelpers.h",
(const char *)(unsigned int)v4,
dwAuthnLevel);
goto LABEL_25;
}
Then a instance of IFileOperation is created:
Instance = CoCreateInstance(&CLSID_FileOperation, 0, 3u, &GUID_947aab5f_0a5c_4c13_b4d6_4bf7836fc9f8, (LPVOID *)v13);
The SetOperationFlags sets FOF_NO_UI:
Instance = (*(__int64 (__fastcall **)(_QWORD, __int64))(**(_QWORD **)v13 + 40LL))(*(_QWORD *)v13, 1556);
QueryInterface is then called on the original IFileOperation object to obtain a proxy pointer (pProxy) and then CoSetProxyBlanket is used to ensure that all operations are done in the NT Authority\System context:
Instance = (***(__int64 (__fastcall ****)(_QWORD, GUID *, IUnknown **))v13)(
*(_QWORD *)v13,
&GUID_947aab5f_0a5c_4c13_b4d6_4bf7836fc9f8,
&pProxy);
<SNIP>
v9 = CoSetProxyBlanket(pProxy, 0xFFFFFFFF, 0xFFFFFFFF, 0, 0, 3u, 0, 0x40u);
Finally the DeleteItem is called which will delete the directory and all its content:
v10 = ((__int64 (__fastcall *)(IUnknown *, void *, _QWORD))pProxy->lpVtbl[6].QueryInterface)(pProxy, ppv, 0);
v6 = retaddr;
if ( v10 >= 0 )
{
v10 = ((__int64 (__fastcall *)(IUnknown *))pProxy->lpVtbl[7].QueryInterface)(pProxy);
v6 = retaddr;
if ( v10 >= 0 )
goto LABEL_23;
v8 = 90;
As the DeleteItem is using DeleteFile/ RemoveDirectory API’s under the hood, this leads to arbitrary file/folder delete in the context of the NT Authority\SYSTEM user as no checks for junctions/symbolic links are performed.
To gain code execution a public technique which utilises MSI rollback is used.
The proof of concept provided to Microsoft would perform the following actions:
C:\Users\%username%\AppData\Local\CoreAIPlatform.00\UKP\{99c69926-fd4c-4f33-9123-4815b659cda6} directory.{99c69926-fd4c-4f33-9123-4815b659cda6} inside \RPC Control directory that points to c:\config.msi directory{99c69926-fd4c-4f33-9123-4815b659cda6} directory{99c69926-fd4c-4f33-9123-4815b659cda6} directory is moved c:\windows\tempUKP directory is turned into a junction which points to NT Object manager directory \RPC ControlThis resulted in gaining execution in context of SYSTEM account as seen below:
This vulnerability was reported on July 30th to MSRC. On November 7th they notified us that the fix is ready and would be published in the November Patch Tuesday.
Hi Filip Dragović,
Thank you for your report to the Microsoft Security Response Center (MSRC) and providing your acknowledgement details. We are planning to release the fix for MSRC case 100127 in our November 2025 Security Update Release on November 11th, 2025. You will be acknowledged for this fix in CVE-2025-60710 . If this schedule changes, we will let you know.
After the CVE was released on November 11th, we published the Proof of Concept exploit without doing any patch diffing as any responsible security researcher would do. 🤡
Two days later MSRC informed me that they made a mistake and that the patch was scheduled for December instead and we pulled the PoC from GitHub.

When the actual patch arrived we decided to actually do patch diffing before making PoC public again.
Looking into the patch a new function ForEachUniqueDirectoryHandle was called before trying to cleanup the directory.
This function would get a handle on the %USERPROFILE%\AppData\Local\CoreAIPlatform.00\UKP directory
DirHandle = TryGetDirHandle(a1); // %USERPROFILE%\AppData\Local\CoreAIPlatform.00\UKP
v7 = DirHandle;
The inside TryGetDirHandle , function DoesExpectedPathMatchFinalPath is called to check if the UKP directory is a junction and if it is the process will terminate without performing cleanup.
If UKP directory is not a junction then it will search for any directory that matches the GUID format:
std::wstring::_Construct<1,unsigned short const *>(&v35, L"{????????-????-????-????-????????????}");
std::filesystem::operator/(lpFileName);
std::wstring::~wstring(&v35);
memset_0(&FindFileData, 0, sizeof(FindFileData));
v10 = (const WCHAR *)lpFileName;
if ( lpFileName[3] > (LPCWSTR)7 )
v10 = lpFileName[0];
hFindFile = FindFirstFileW(v10, &FindFileData);
If that directory is found then a call to NtCreateFile is made which will create secure_file.lock inside that directory:
*(_WORD *)v13 = 0;
v14 = std::wstring::append(&v38, L"secure_file.lock");
*(_OWORD *)SourceString = 0;
v33 = 0u;
*(_OWORD *)SourceString = *(_OWORD *)v14;
v33 = *(_OWORD *)(v14 + 16);
*(_QWORD *)(v14 + 16) = 0;
*(_QWORD *)(v14 + 24) = 7;
*(_WORD *)v14 = 0;
std::wstring::~wstring(&v38);
std::wstring::~wstring(&Src);
DestinationString = 0;
v15 = (const WCHAR *)SourceString;
if ( *((_QWORD *)&v33 + 1) > 7u )
v15 = SourceString[0];
RtlInitUnicodeString(&DestinationString, v15); // create UNICODE string <GUID>\secure_file.lock
memset(&ObjectAttributes, 0, sizeof(ObjectAttributes)); // initialize OBJECT_ATTRIBUTES
ObjectAttributes.Length = 48;
ObjectAttributes.RootDirectory = 0;
ObjectAttributes.Attributes = 4160;
ObjectAttributes.ObjectName = &DestinationString;
*(_OWORD *)&ObjectAttributes.SecurityDescriptor = 0;
<SNIP>
// NtCreateFile
if ( NtCreateFile(
FileHandle[0],
0x13019Fu,
&ObjectAttributes,
&IoStatusBlock,
0,
0x80u,
3u,
3u,
0x1060u,
0,
0) >= 0
&& FileHandle[0]
&& (unsigned __int64)*FileHandle[0] - 1 <= 0xFFFFFFFFFFFFFFFDuLL )
In the snippet above we can see that IDA shows that the RootDirectory is set to NULL, if this was true this would be exploitable as we could delete arbitrary files as the file was opened with the FILE_DELETE_ON_CLOSE flag. But if we look closer in a debugger we can see that the RootDirectory is set with a handle to the %USERPROFILE%\AppData\Local\CoreAIPlatform.00\UKP directory and ObjectName is relative to that directory:
0:004> dq @r8 L6
00000080`0057f378 00000000`00000030 00000000`000002dc
00000080`0057f388 00000080`0057f368 00000000`00001040
00000080`0057f398 00000000`00000000 00000000`00000000
0:004> du poi(poi(@r8+10h)+8)
00000080`005b22e0 "{69014b10-d2de-4a90-9782-1fed4bd"
00000080`005b2320 "f5847}\secure_file.lock"
Once the secure_file.lock file is created a function will return and it will continue to clean up the directory using the old TryDeleteUniqueDirectory function. This is where an issue arises with the patch, while they managed to protect the “unique” directory, any sub-directory is unprotected. The IFileOperation::DeleteItem used by TryDeleteUniqueDirectory will perform recursive deletion by default, this allows us to bypass the patch by just creating additional sub-directories inside the %USERPROFILE%\AppData\Local\CoreAIPlatform.00\UKP\<GUID> directory.

The original PoC was slightly changed to bypass the patch and gain execution as NT Authority\SYSTEM

MSRC fixed this patch bypass in January, this time they ditched the IFileOperation::DeleteItem to clean up directories and instead used combination of NtCreateFile / GetFinalPathByHandle / SetFileDispostion API calls to verify files/directories and delete them.
This post was written by Filip Dragovic.