Skip to content

Load a kernel-mode security package without a reboot

The symptom

A user-mode helper registers a kernel-mode security package with LSA through AddSecurityPackage(), then starts its driver with sc start, with no reboot in between. DriverEntry calls KSecRegisterSecurityProvider(), which fails:

SEC_E_SECPKG_NOT_FOUND (0x80090305)

This is the user-mode SSP case one layer down. The id that LSA assigned resolves on the LSA side, but the kernel's own package table doesn't know it.

Root cause: a fixed-size array, sized once

ksecdd.sys keeps its own table of registered packages. It is a fixed-size array that InitializePackages() sizes on first use and never resizes. An id assigned later by a hot AddSecurityPackage() call falls past the end of the array, and nothing in ksecdd grows it when LSA adds a package.

Step 1: grow the table before retrying registration

The table lives in a per-silo state object that PsGetPermanentSiloContext returns, so no silo slot or structure offset needs to be hardcoded. The code scans the candidate objects for a (count, table) pair that matches the real table, grows it by the one missing entry, and calls KSecRegisterSecurityProvider again.

The match test has to be strict. A weak check fails in two ways. Inside one object, a stray field can pass because the entries that would rule it out are never checked. Across objects, an unrelated kernel structure can look plausible too, and growing it corrupts memory and crashes the machine. The reliable test ranks every candidate by how many of its entries resolve to a real SECPKG_KERNEL_FUNCTION_TABLE, and keeps the best match, not the first one.

Step 2: read the package id, don't guess it

Once registration succeeds, KSecRegisterSecurityProvider has written the package's &KernelFunctionTable pointer into the table, at the slot LSA assigned. A scan for the slot that holds that pointer gives the real id, from the data ksecdd filled in itself. Assuming the package is the last entry is not safe.

Step 3: hook LocatePackageById

Registration and the id are not enough. During a NegoEx exchange, the kernel resolves the package by its id. When that lookup fails mid-negotiation, the machine crashes:

Windows BSOD: SYSTEM_THREAD_EXCEPTION_NOT_HANDLED, What failed: ksecpkg.sys
SYSTEM_THREAD_EXCEPTION_NOT_HANDLED in ksecpkg.sys: ksecpkg!NegoExtsDeleteKernelContext dereferences the result of a lookup that returned nothing.

The crash only happens after a failed id lookup forces a retry, never after a clean single-round lookup. The fix hooks FunctionTable->LocatePackageById, a function pointer in SECPKG_KERNEL_FUNCTIONS, the table every kernel package receives. It is the same shared ksecdd.sys table that ksecpkg!NegoExtsKernelFunctions calls through (reading the pointer from both sides confirms it). Replacing the pointer is a data write to a structure field, not a code patch. The other option, patching the code of ksecdd!KsecRegisterExtension, risks a PatchGuard bugcheck. A data write doesn't.

The hook answers only for the package's own id, read in step 2, and passes every other lookup through.

Debugging tools

Besides the ksecdd trace mask, ed nt!Kd_KSECDD_Mask 0xFFFFFFFF / e ksecpkg!KsecInfoLevel 6 (see Developing a kernel-mode security package), these tools help:

  • A live kernel debugger with breakpoints on the context functions, on the Microsoft side (ksecpkg!NegoExtsCreateKernelModeContext, NegoExtsInitKernelContext, NegoExtsDeleteKernelContext) and in the package (EIDCreateKernelContext, EIDDeleteKernelContext). Each breakpoint logs and continues ("kb; g"), so the whole call sequence shows up in one run:
    WinDbg kernel debugger session with breakpoints on NegoExts and EIDKernelPackage context functions
  • A crash dump compared with a clean session. Save !analyze -v and a full dump at the bugcheck. After the reboot, open a new kernel debug session and step through the same package initialization. The difference between the two shows the bad state.
  • TTD, to check whether a function runs at all during a repro: dx @$cursession.TTD.Calls("Module!Function"). An empty result rules out a code path.

These tools don't replace the disassembly. They show which function to read, and when.