Streaming vulnerabilities from Windows Kernel - Proxying to Kernel - Part II
This is a series of research related to Kernel Streaming attack surface. It is recommended to read the following articles first.
In the previous research on Proxying to Kernel, we discovered multiple vulnerabilities in Kernel Stearming as well as an overlooked bug Class. We successfully exploited vulnerabilities CVE-2024-35250 and CVE-2024-30084 to compromise Windows 11 at Pwn2Own Vancouver 2024.
In this article, we will continue to explore this attack surface and bug Class, revealing another vulnerability and exploitation technique, which was also presented at HEXACON 2024.
After Pwn2Own Vancouver 2024, we continued to investigate the ks!KsSynchronousIoControlDevice bug pattern to see if there were any other security issues. However, after some time, we did not find any other exploitable points in the property operations of KS object. Therefore, we shifted our focus to another feature, KS Event.
KS EventSimilar to the KS Property mentioned in the previous article, the KS object not only has its own property set but also provides the functionality to set KS Event. For instance, you can set an event to trigger when the device status changes or at regular intervals, which is convenient for developers of playback software to define subsequent behaviors. Each KS Event, like a property, requires the KS object to support it to be used. We can register or disable these Events through IOCTL_KS_ENABLE_EVENT and IOCTL_KS_DISABLE_EVENT.
KSEVENTDATAWhen registering a KS Event, you can register the desired event by providing KSEVENTDATA. You can include handles such as EVENT_HANDLE and SEMAPHORE_HANDLE in the registration. When KS triggers this event, it will notify you using the provided handle.
The work flow of IOCTL_KS_ENABLE_EVENTThe entire work flow is similar to IOCTL_KS_PROPERTY. When calling DeviceIoControl, as shown in the figure below, the user’s requests are sequentially passed to the corresponding driver for processing.
Similarly, in step 3, 32-bit requests will be converted into 64-bit requests. By step 6, ks.sys will determine which driver and addhandler to handle your request based on the event of your requests.
Finally, forward it to the corresponding driver. As shown in the figure above, it is finally forwarded to KsiDefaultClockAddMarkEvent in ks to set the timer.
After grasping the KS Event functionality and process, we swiftly identified another exploitable vulnerability, CVE-2024-30090, based on the previous bug pattern.
Proxying to kernel again !This time, the issue occurs when ksthunk converts a 32-bit request into a 64-bit one.
As shown in the figure below, when ksthunk receives an IOCTL_KS_ENABLE_EVENT request and the request is from a WoW64 Process, it will perform the conversion from a 32-bit structure to a 64-bit structure.
The conversion would call ksthunk!CKSAutomationThunk::ThunkEnableEventIrp to handle it.
__int64 __fastcall CKSAutomationThunk::ThunkEnableEventIrp(__int64 ioctlcode_d, PIRP irp, __int64 a3, int *a4) { ... if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLE || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ONESHOT || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLEBUFFERED ) { // Convert 32-bit requests and pass down directly } else if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_QUERYBUFFER ) { ... newinputbuf = (KSEVENT *)ExAllocatePoolWithTag((POOL_TYPE)0x600, (unsigned int)(inputbuflen + 8), 'bqSK'); ... memcpy(newinputbuf,Type3InputBuffer,0x28); //------------------------[1] ... v18 = KsSynchronousIoControlDevice( v25->FileObject, 0, IOCTL_KS_ENABLE_EVENT, newinputbuf, inputbuflen + 8, OutBuffer, outbuflen, &BytesReturned); //-----------------[2] ... } ... }In CKSAutomationThunk::ThunkEnableEventIrp, a similar bug pattern is clearly visible. You can see that during the processing, the original request is first copied into a newly allocated buffer at [1]. Subsequently, this buffer is used to call the new IOCTL using KsSynchronousIoControlDevice at [2]. Both newinputbuf and OutBuffer are controlled by the user.
The flow when calling CKSAutomationThunk::ThunkEnableEventIrp is illustrated as follows:
When calling IOCTL in a WoW64 process, you can see in step 2 of the diagram that the I/O Manager sets Irp->RequestorMode to UserMode(1). In step 3, ksthunk converts the user’s request from 32-bit to 64-bit, handled by CKSAutomationThunk::ThunkEnableEventIrp.
Afterward, in step 5, KsSynchronousIoControlDevice will be called to issue the IOCTL, and at this point, the new Irp->RequestorMode has become KernelMode(0). The subsequent processing is the same as a typical IOCTL_KS_ENABLE_EVENT, so it won’t be detailed further. In summary, we now have a primitive that allows us to perform arbitrary IOCTL_KS_ENABLE_EVENT with KernelMode. Next, we need to look for places where we can achieve EoP.
The ExploitationFollowing the previous approach, we first analyzed the entry point ksthunk. However, after searching for a while, we found no potential privilege escalation points. In ksthunk, most instances where Irp->RequestMode is KernelMode(0) are directly passed down without additional processing. Therefore, we shifted our eyes to the next layer, ks, to see if there are any opportunities for privilege escalation during the event handling process.
Quickly, we found a place that caught our attention.
In the KspEnableEvent handler, a code snippet first checks the NotificationType in the KSEVENTDATA you passed in to determine how to register and handle your event. In general, it usually provides an EVENT_HANDLE or a SEMAPHORE_HANDLE. However, in ks, if called from KernelMode, we can provide an Event Object or even a DPC Object to register your event, making the overall handling more efficient.
This means we can use this DeviceIoControl with KernelMode primitive to provide a kernel object for subsequent processing. If constructed well, it might achieve EoP, but it depends on how this Object is used later.
However, after trying for a while, we discovered that …
__int64 __fastcall CKSAutomationThunk::ThunkEnableEventIrp(__int64 ioctlcode_d, PIRP irp, __int64 a3, int *a4) { ... if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLE || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ONESHOT || (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_ENABLEBUFFERED ) //-------[3] { // Convert 32-bit requests and pass down directly } else if ( (v25->Parameters.DeviceIoControl.Type3InputBuffer->Flags & 0xEFFFFFFF) == KSEVENT_TYPE_QUERYBUFFER ) //-------[4] { ... newinputbuf = (KSEVENT *)ExAllocatePoolWithTag((POOL_TYPE)0x600, (unsigned int)(inputbuflen + 8), 'bqSK'); ... memcpy(newinputbuf,Type3InputBuffer,0x28); //------[5] ... v18 = KsSynchronousIoControlDevice( v25->FileObject, 0, IOCTL_KS_ENABLE_EVENT, newinputbuf, inputbuflen + 8, OutBuffer, outbuflen, &BytesReturned); ... } ... }If you want to provide a kernel object to register an event, then the flag given in the IOCTL for KSEVENT must be KSEVENT_TYPE_ENABLE at [3]. However, at [4], where the vulnerability is triggered, it must be KSEVENT_TYPE_QUERYBUFFER, and it is impossible to directly provide a kernel object as we might have expected.
Fortunately, IOCTL_KS_ENABLE_EVENT also uses Neither I/O to transmit data. It also presents a Double Fetch issue again.
As shown in the figure above, we can set the flag to KSEVENT_TYPE_QUERYBUFFER before calling IOCTL. When checking, it will handle it with KSEVENT_TYPE_QUERYBUFFER. Before the second KsSynchronousIoControlDevice call, we can change the flag to KSEVENT_TYPE_ENABLE.
This way, we can successfully trigger the vulnerability and construct a specific kernel object to register the event.
Trigger the eventWhen would it use the kernel object that you constructed? When an event is triggered, ks will call ks!ksGenerateEvent through DPC. At this point, it will determine how to handle your event based on the NotificationType you specified.
Let’s take a look at KsGenerateEvent
NTSTATUS __stdcall KsGenerateEvent(PKSEVENT_ENTRY EventEntry) { switch ( EventEntry->NotificationType ) { case KSEVENTF_DPC: ... if ( !KeInsertQueueDpc(EventEntry->EventData->Dpc.Dpc, EventEntry->EventData, 0LL) ) _InterlockedAdd(&EventEntry->EventData->EventObject.Increment, 0xFFFFFFFF); //--------[6] ... case KSEVENTF_KSWORKITEM: ... KsIncrementCountedWorker(eventdata->KsWorkItem.KsWorkerObject); //-----------[7] } }At this point, there are multiple ways to exploit this. The most straightforward method is to directly construct a DPC structure and queue a DPC to achieve arbitrary kernel code execution, which corresponds to the code snippet at [6]. However, the IRQL when calling KsGenerateEvent is DISPATCH_LEVEL, making it very difficult to construct a DPC object in User space, and the exploitation process will encounter many issues.
Therefore, we opt for an alternative route using KSEVENTF_KSWORKITEM at [7]. This method involves passing in a kernel address and manipulating it to be recognized as a pointer to KSWORKITEM.
It can achieve an arbitrary kernel address increment by one. The entire process is illustrated in the diagram below.
When calling IOCTL_KS_ENABLE_EVENT, after constructing KSEVENTDATA to point to a kernel memory address, ks will handle it as a kernel object and register the specified event.
When triggered, ks will increment the content at our provided memory address. Therefore, we have a kernel arbitrary increment primitive here.
Arbitrary increment primitive to EoPFrom arbitrary increment primitive to EoP, there are many methods that can be exploited, among which the most well-known are abuse token privilege and IoRing. Initially, it seemed like this would be the end of it.
However, both of these methods have certain limitations in this situation:
Abuse token PrivilegeIf we use the method of abusing token privilege for EoP, the key lies of the technique in overwriting Privileges.Enable and Privileges.Present. Since our vulnerability can only be incremented by one at a time, both fields need to be written to obtain SeDebugPrivilege. The default values for these two fields are 0x602880000 and 0x800000, which need to be changed to 0x602980000 and 0x900000. This means each field needs to be written 0x10 times, totaling 0x20 writes. Each write requires a race condition, which takes times and significantly reduces stability.
IoRingUsing IoRing to achieve arbitrary writing might be a simpler method. To achieve arbitrary write, you just need to overwrite IoRing->RegBuffersCount and IoRing->RegBuffers. However, a problem arises.
When triggering the arbitrary increment, if the original value is 0, it will call KsQueueWorkItem, where some corresponding complex processing will occur, leading to BSoD. The exploitation method of IoRing happens to encounter this situation…
Is it really impossible to exploit it stably?
Let’s find a new way !When traditional exploitation methods hit a roadblock, it might be worthwhile to dive deeper into the core mechanics of the technique. You may unexpectedly discover new approaches along the way.
After several days of contemplation, we decided to seek a new approach. However, starting from scratch might take considerable time and may not yield results. Therefore, we chose to derive new inspiration from two existing methods. First, let’s look at abusing token privilege. The key aspect here is exploiting a vulnerability to obtain SeDebugPrivilege, allowing us to open high-privilege processes such as winlogon.
The question arises: why does having SeDebugPrivilege allow you to open high-privilege processes?
We need to take a look at nt!PsOpenProcess first.
From this code snippet, we can see that when we open the process, the kernel will use SeSinglePrivilegeCheck to check if you have SeDebugPrivilege. If you have it, you will be granted PROCESS_ALL_ACCESS permission, allowing you to perform any action on any process except PPL. As the name implies, it is intended for debugging purposes. However, it is worth noting that nt!SeDebugPrivilege is a global variable in ntoskrnl.exe.
It’s a LUID structure that was initialized at system startup. The actual value is 0x14, indicating which bit in the Privileges.Enable and Privileges.Present fields represent SeDebugPrivilege. Therefore, when we use NtOpenProcess, the system reads the value in this global variable
Once the value of nt!SeDebugPrivilege is obtained, it will be used to inspect the Privileges field in the Token to see if the Enable and Present fields are set. For SeDebugPrivilege, it will check the 0x14 bit.
However, there is an interesting thing…
The global variable nt!SeDebugPrivilege is located in a writable section!
A new idea was born.
Make abusing token privilege great again !By default, a normal user will have only a limited number of Privileges, as shown in this diagram.
We can notice that in most cases, SeChangeNotifyPrivilege is enabled. At this point, we can look at the initialization part and find that SeChangeNotifyPrivilege represents the value 0x17.
What would happen if we use the vulnerability to change nt!SeDebugPrivilege from 0x14 to 0x17?
As shown in the figure, in the NtOpenProcess flow, it will first get the value of nt!SeDebugPrivilege, and at this time the obtained value is 0x17 (SeChangeNotifyPrivilege)
The next check will verify the current process token using 0x17 to see if it has this Privilege. However, normal users generally have SeChangeNotifyPrivilege, so even if you don’t have SeDebugPrivilege, you will still pass the check and obtain PROCESS_ALL_ACCESS. In other words, anyone with SeChangeNotifyPrivilege can open a high-privilege process except PPL.
Furthermore, by using the vulnerability mentioned above, we can change nt!SeDebugPrivilege from 0x14 to 0x17. Since the original value is not 0, it will not be affected by KsQueueWorkItem, making it highly suitable for our purposes.
Once we can open a high-privilege process, the privilege escalation method is the same as the abuse token privilege approach so that we won’t elaborate on that here. Ultimately, we successfully achieved EoP on Windows 11 23H2 by again utilizing Proxying to kernel.
RemarkActually, this technique also applies to other Privilege.
- SeTcbPrivilege = 0x7
- SeTakeOwnershipPrivilege = 0x9
- SeLoadDriverPrivilege = 0xa
- …
The focus of these two articles is primarily on how we analyze past vulnerabilities to discover new ones, how we gain new ideas from previous research, find new exploitation methods, new vulnerabilities, and new attack surfaces.
There may still be many security issues of this bug class, and they might not be limited to Kernel Streaming and IoBuildDeviceIoControlRequest. I believe this is a design flaw in Windows, and if we search carefully, we might find more vulnerabilities.
For this type of vulnerability, you need to pay attention to the timing of setting Irp->RequestorMode. If it is set to KernelMode and then user input is used, issues may arise. Moreover, this type of vulnerability is often very exploitable.
In Kernel Streaming, I believe there are quite a few potential security vulnerabilities. There are also many components like Hdaudio.sys or Usbvideo.sys that might be worth examining and are suitable places for fuzzing. If you are a kernel driver developer, it is best not to only check Irp->RequestorMode . There might still be issues within the Windows architecture. Finally, I strongly recommend everyone to update Windows to the latest version as soon as possible.
Is that the end of it ?Apart from proxy-based vulnerabilities, we have also identified many other bug classes, allowing us to discover over 20 vulnerabilities in Kernel Streaming. Some of these vulnerabilities are quite unique, so stay tuned for Part III.
Reference