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:
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:
Last updated