29 April 2024
Claudio Contin
Introduction
On the 19th of April (2024), floesen_ tweeted https://twitter.com/floesen_/status/1781262186062422019: "LSASS has the ability to execute arbitrary kernel-mode addresses? I wrote a small proof of concept that allows administrators to execute unsigned code in the kernel if LSA Protection is disabled."
According to their proof of concept GitHub repository, the IOCTL_KSEC_IPC_SET_FUNCTION_RETURN
operation of the Kernel Security Support Provider Interface (KSecDD.sys
) allows the Local Security Authority Server Service (LSASS
) to execute arbitrary kernel-mode addresses. The researcher also mentions that as soon as LSASS starts, it invokes lsass.exe!LsapOpenKsec
where it connects itself to the interface using the IOCTL_KSEC_CONNECT_LSA
operation. From this point on, no further process can connect to the interface and therefore the logic can only be triggered by LSASS.
The exploit
The POC contains the loader.c
which simply enables debug privileges (SeDebugPrivilege
), opens an handle to lsass and injects the dllmain
(compiled as exploit.dll) into it. The DLL abuses this to disable the Driver Signature Enforcement by overwriting the ci.dll!g_CiOptions
. Local administrator privileges are required in order to inject the DLL into the lsass process.
Unsigned kernel drivers can bypass the standard security checks imposed by Windows. Kernel drivers run with high privileges on the system, allowing them to interact directly with hardware and critical system components. Attackers may patch kernel event callbacks to blind EDRs, causing them to miss critical events or providing false information, thus evading detection. Attackers could also leverage this blind spot to carry out various malicious activities, including credential extraction.
By default, Windows only allows drivers signed with a Microsoft certificate to be loaded on a system. By disabling the Driver Signature Enforcement, a local administrator is able to load custom and unsigned drivers and obtain kernel access to the system.
Inspecting the dllmain.c
source, we see these two lines (line 7 and 11):
Unless you are using the same version of Windows used by the author of the POC, it is likely that, if you run the exploit as it is, this will result in a blue screen of death (BSoD). This is due to the above offsets being different depending on the Windows version/build number.
The exploit (DLL) obtains the absolute kernel address of a mov qword [rcx], rdx
instruction in the ntoskrnl.exe
(Windows kernel) and the absolute address of the ci!g_CiOption
. Then, it searches the handle to the KsecDD driver, setups the IoctlBuffer
with the required parameters, and it invokes the KsecDD IOCTL_KSEC_IPC_SET_FUNCTION_RETURN
. At line 160, we can see the value we want to write in memory to disable the signature check: 0
means disabled and 6
(default value) means enabled.
Note that this will not work on a host with Process Protection Light (PPL) or HIVC (hypervisor-protected code integrity - https://learn.microsoft.com/en-us/windows/security/hardware-security/enable-virtualization-based-protection-of-code-integrity) enabled, as explained by Adam Chester's (XPN) in the g_CiOptions in a Virtualized World post.
Analysis
In order to properly follow the exploit using a debugger, we first have to setup a host (VM will do) for remote kernel debugging. To start, we need to install the requirements on the test host: https://learn.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk.
After the requirements are installed, we need to enable remote debugging (https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/setting-up-network-debugging-of-a-virtual-machine-host), using kdnet.exe
:
192.168.0.30 is the IP of the host which will run Windows Debugger (WinDbg) to connect to the test host, and 50000 is the port, which can be anything between 50000-50039.
The kdnet command will return an output similar to the following:
On the WinDbg host, go to File, Attach to kernel, leave the Port number to the default 50000 value, and copy and paste the key returned from the kdnet command ran on the test host, then click OK. Finally, reboot the test host.
After the test host is booting, in WinDbg we see the successful connection. The test host I used is a Windows 10 (Build 19045.4191) professional version.
Find offsets
To find the CiOptions offset (relative to the CI module base), we break in WinDbg and run the dq ci!g_CiOptions
command.
As we can see, the current CiOptions is set to 6 (enabled). To calculate the offset, we find the base address of the CI module by running lmDvaCI
.
Based on the above analysis we determined that the offset of the CiOptions from the CI module base address is: 0x74f5a400 - 0x74f20000 = 0x3A400
.
The next step is to identify the address of a MOV qword ptr [RCX],RDX
instruction as part of the Windows kernel module nt
(ntkrnlmp.exe).
The above assembly operation is equivalent to 48 89 11
in HEX. To find the relative address of the first instruction in the kernel module, I opened the C:\Windows\System32\ntoskrnl.exe
file in Ghidra and searched for the 48 89 11
instruction pattern.
The base address of the module in Ghidra is 0x140000000, therefore the relative address of the MOV instruction is 0x201852
.
In WinDbg, lets find the nt
module base address by running lmDvantkrnlmp
.
Then, we analyse the instructions at nt+0x201852
and we determine that the MOV instruction we are after is part of the FsRtlInitializeFileLock
function (at 0x2 offset from the start of the function).
Module kernel addresses can be obtain from user-land processes. The KexecDD finds them using the FindKernelAddresses function at line 47. The code below can be used to enumerate the kernel address of all the loaded kernel modules.
For this reason, with the offsets previously calculated, we can determine the absolute kernel address of the CiOptions and the MOV operation.
Now we could simply hardcode them in the dllmain.c exploit and run it on the target system, but we can try to obtain these addresses programmatically.
Find offsets programmatically
I stumbled across a great blog post written by vikingfr that describes how to abuse a vulnerable signed kernel driver to patch the CiOptions. The GitHub repository of the post can be found here. The repository is a fork of the https://github.com/fengjixuchui/gdrv-loader which is the result of a vulnerability of the gigabyte kernel driver identified by SecureAuth which can be found here. The exploit programmatically identifies the CiOptions memory address and patches it using the vulnerable driver. Lets explore how this exploit finds the required offset for the CiOptions from the module base address.
The part that finds the offset can be found in the swind2.cpp file, specifically in the AnalyzeCi
and QueryCiOptions
functions.
At a high-level, the program uses the MapFileSectionView
which allows to get the pointer to the MappedBase pointing at the C:\Windows\System32\CI.dll
base address. The function is defined in the pe.cpp file.
The QueryCiOptions
gets the pointer to the CiInitialize function using the GetProcedureAddress
function, also defined in the pe.cpp file, which parses the PE and finds the exported function (CiInitialize in this case).
The code then proceeds iterating the assembly instructions to identify CALL instructions (E8
in HEX). Based on the CiIinitialize screenshot stub of the post we can see that the the call to CipInitialize
(which is not exported, therefore cannot be identified using the GetProcedureAddress function) is the second CALL instruction.
If we compare the CiIinitialize stub in our current WinDbg session we notice it is different, probably due to the fact that the post is from 2021 and the functions have changed since then, therefore we will need to take this into account when identifying the right CALL instruction (call CI!CipIinitialize). The image below shows the stub for the Windows 10 host used for this post.
Once the relative address of CipIinitialize is found, the code then searches for the reference to the CiOptions, by matching the assembly instruction starting with 89 0d
(MOV dword ptr [CiOptions], ecx) and obtaining the address of CiOptions. If we inspect the current Windows 10 CipInitialize stub, we notice that this method is still applicable, as the function has not changed, at least for the section we are interested (as shown in the image below).
We can apply the same concept to identify the relative address of the mov qword ptr [rcx],rdx
in the nt kernel module, which is in the exported FsRtlInitializeFileLock function we identified earlier. We obtain the pointer to the function, we parse the assembly instructions until we identify the 0x118948
instruction (48 89 11
= MOV qword ptr [RCX],RDX).
You can find the final implementation on a forked repository I created for this: https://github.com/clod81/KExecDD-gdrv-loader.
These calculations could be embedded within the DLL injected into lsass, but for the POC I decided to leave the DLL untouched and simply hardcode the values once identified with the method described above.
Final POC
We are now ready to set these offsets of the target system in the dllmain.c source and compile it. I also created a second DLL that will restore the original value of 6 to re-enable the driver signature check. After we load the unsigned driver we have to restore the setting to avoid PatchGuard (Kernel Patch Protection - KPP) BSoD'ing our system.
Lets compile the loader.c and the DLLs using the x64 Native Tools Command Prompt:
For the test I used and compiled an open source driver: Driver-kaldereta. Lets register the unsigned driver, and try to load it:
As expected, Windows will not load it, as the driver is not signed.
Lets setup WinDbg once again against the target host, and set a breakpoint on the mov qword ptr [rcx],rdx
instruction of the nt!FsRtlInitializeFileLock function, then launch the exploit.
As we can see, we hit the breakpoint, indicating the offsets we calculated are correct. We also see that the RCX register contains the memory address of CiOptions, and RDX contains 0x0, which is what we want to patch the CiOptions. CiOptions has the value of 6. Lets execute the MOV instruction by stepping in WinDbg. After the instruction ran, CiOptions now is set to 0, indicating we should now be able to load the unsigned driver.
The output indicated that our unsigned driver has been loaded successfully.
We can now run the exploit passing the argument 6, which will execute the same, but re-setting the original CiOptions value.
Shout out to floesen_ for the interesting finding and the POC. I expect that Microsoft will fix or mitigate this in future releases.