LSASS rings KsecDD ext. 0

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):

// mov qword [rcx], rdx
#define NTOSKRNL_WRITE_GADGET 0x53A4B0

// ci!g_CiOptions
#define CI_OPTIONS 0x4D004

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:

cd "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64"
kdnet 192.168.0.30 50000
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:

Enabling network debugging on Microsoft Hypervisor Virtual Machine.
Key=3u8smyv477z20.2owh9gl90gbxx.3sfsihzgq7di4.nh8ugnmzb4l7

To debug this vm, run the following command on your debugger host machine.
windbg -k net:port=50000,key=3u8smyv477z20.2owh9gl90gbxx.3sfsihzgq7di4.nh8ugnmzb4l7

Then restart this VM by running shutdown -r -t 0 from this command prompt.

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.

windbg

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.

success windbg connection

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.

kd> dq ci!g_CiOptions
fffff803`74f5a400  00000000`00000006 00000000`00000000
fffff803`74f5a410  ffffcf8a`6ec14d70 ffffcf8a`6ec14410
....

windbg CiOptions

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.

Browse full module list
start             end                 module name
fffff803`74f20000 fffff803`7500c000   CI
....

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.

ghidra kernel search

kernel mov instruction

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.

Browse full module list
start             end                 module name
fffff803`71200000 fffff803`72246000   nt
....

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).

kd> u fffff803`71200000 + 0x201852 L10
nt!FsRtlInitializeFileLock+0x2:
fffff803`71401852 488911          mov     qword ptr [rcx],rdx
fffff803`71401855 48894118        mov     qword ptr [rcx+18h],rax
fffff803`71401859 884110          mov     byte ptr [rcx+10h],al
fffff803`7140185c 4c894108        mov     qword ptr [rcx+8],r8
fffff803`71401860 894158          mov     dword ptr [rcx+58h],ea
....

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.

CiIinitialize stub

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).

CiIinitialize stub

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.

offsets

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:

cl.exe /nologo /MT /Ox /W0 /GS- /DNDEBUG loader.c /link Advapi32.lib /OUT:exploit.exe /SUBSYSTEM:CONSOLE /MACHINE:x64
cl.exe /LD /nologo /MT /Ox /W0 /GS- /DNDEBUG dllmain.c /link /OUT:exploit.dll /SUBSYSTEM:CONSOLE /MACHINE:x64
cl.exe /LD /nologo /MT /Ox /W0 /GS- /DNDEBUG dllmain_restore.c /link /OUT:restore.dll /SUBSYSTEM:CONSOLE /MACHINE:x64

For the test I used and compiled an open source driver: Driver-kaldereta. Lets register the unsigned driver, and try to load it:

sc create unsigned type= kernel binPath= C:\path_to\unsigned.sys
sc start unsigned

driver load failure

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.

breakpoint 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.

driver loaded

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.

driver loaded

Shout out to floesen_ for the interesting finding and the POC. I expect that Microsoft will fix or mitigate this in future releases.

References

  • https://github.com/floesen/KExecDD
  • https://twitter.com/floesen_/status/1781262186062422019
  • https://v1k1ngfr.github.io/loading-windows-unsigned-driver/
  • https://github.com/fengjixuchui/gdrv-loader
  • https://github.com/v1k1ngfr/gdrv-loader
  • https://www.secureauth.com/labs/advisories/gigabyte-drivers-elevation-of-privilege-vulnerabilities/
  • https://blog.xpnsec.com/gcioptions-in-a-virtualized-world
  • https://learn.microsoft.com/en-us/windows/security/hardware-security/enable-virtualization-based-protection-of-code-integrity
  • https://learn.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk
  • https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/setting-up-network-debugging-of-a-virtual-machine-host
  • https://github.com/gmh5225/Driver-kaldereta
  • https://github.com/clod81/KExecDD-gdrv-loader

Author

Claudio Claudio Contin - Principal Consultant

Contact

Get in touch