Live-loading a kernel security package (KSP) without a reboot

The symptom

A kernel-mode security package (KSP) is loaded with a plain sc start right after a user-mode helper already called AddSecurityPackage() to register it with LSA — no reboot in between. DriverEntry calls KSecRegisterSecurityProvider() as normal, and it fails:

SEC_E_SECPKG_NOT_FOUND (0x80090305)

Same story as the user-mode SSP case, one layer down: the id LSA handed out resolves correctly on the LSA side, but the kernel's own bookkeeping was never told to expect it.

Why: a fixed-size array, sized once

ksecdd.sys keeps its own kernel-side table of registered packages. It's a plain fixed-size array, sized once by InitializePackages() the first time anything needs it, and never resized afterwards. An id assigned by a later, hot AddSecurityPackage() call is simply past the end of an array that will never grow on its own — nothing rewrites its bounds just because LSA now knows about one more package.

Step 1: grow the table before retrying registration

The table lives inside a per-silo state object reachable through PsGetPermanentSiloContext — no hardcoded silo slot, no hardcoded struct offset. Locating it means scanning candidate objects for a (count, table) pair that behaves like the real thing, then growing it by exactly the one entry that's missing and retrying KSecRegisterSecurityProvider once.

"Behaves like the real thing" has to be judged carefully. The first version accepted the first candidate that merely looked plausible, and that wasn't enough on two different occasions: within one candidate object, a stray field can pass a weak check just because the entries that would disprove it never get examined; across objects, an unrelated kernel structure can coincidentally look plausible too. The second case corrupted an unrelated structure and crashed the machine before this was tightened. The fix: rank every candidate by how many of its entries independently resolve to a real SECPKG_KERNEL_FUNCTION_TABLE, and keep the strongest match, not the first one found.

Step 2: find out your own id, don't guess it

Right after registration succeeds, KSecRegisterSecurityProvider has necessarily already written the package's own &KernelFunctionTable pointer into that same table, at whatever slot LSA assigned. Scanning the table once for the slot holding that exact pointer recovers the real numeric id from the same data ksecdd itself populated — not from inference, and not by assuming it's simply "the last one".

Step 3: the crash that made a third step necessary

Registering and knowing your own id isn't quite the end of it. Somewhere in a NegoEx exchange, something in the kernel still tries to resolve a package by that id and, at least once during this investigation, that resolution failed mid-negotiation — and the machine went down for real:

Windows BSOD: SYSTEM_THREAD_EXCEPTION_NOT_HANDLED, What failed: ksecpkg.sys
Live capture on the test VM: SYSTEM_THREAD_EXCEPTION_NOT_HANDLED, ksecpkg.sys — root-caused to ksecpkg!NegoExtsDeleteKernelContext dereferencing a lookup that came back empty.

A failed id resolution turned out to be the precondition for this crash — never seen after a clean single-round lookup, always after this exact kind of failed resolution forced a retry. The fix is a hook on FunctionTable->LocatePackageById, a function pointer inside SECPKG_KERNEL_FUNCTIONS, the table every kernel package receives. Live inspection (reading the pointer from both ends and comparing) confirmed it's the exact same shared ksecdd.sys singleton that ksecpkg!NegoExtsKernelFunctions itself calls through — so overwriting the copy already legitimately held is a plain data write to a struct field, not inline code patching. That distinction matters: patching ksecdd!KsecRegisterExtension itself was considered too, and dropped, because hooking actual code in a system component carries real PatchGuard bugcheck risk that a data write to a struct doesn't.

The hook only ever answers for the package's own id — established in step 2, not guessed — and passes every other lookup through untouched.

Collecting the evidence

Beyond the Microsoft-documented ed nt!Kd_KSECDD_Mask 0xFFFFFFFF / e ksecpkg!KsecInfoLevel 6 trace mask (see Developing Kernel SSP), a few things earned their keep on this investigation:

  • A live kernel debugger session with breakpoints set directly on the functions under suspicion — both the Microsoft side (ksecpkg!NegoExtsCreateKernelModeContext, NegoExtsInitKernelContext, NegoExtsDeleteKernelContext) and the package's own (EIDCreateKernelContext, EIDDeleteKernelContext), each set to log and continue ("kb; g") rather than stop, to watch the whole call sequence go by without babysitting every breakpoint:
    WinDbg kernel debugger session with breakpoints on NegoExts and EIDKernelPackage context functions
  • Comparing a dump taken right before a crash against a fresh session after the reboot that follows it — capturing !analyze -v and a full dump the moment a bugcheck hits, then, once the machine is back up, opening a brand new kernel debug session and walking the same package's initialization from a clean state. The difference between the two is exactly the state that was wrong going in.
  • TTD, used here mainly to answer a narrower question than in the user-mode investigation: whether a given function was called at all during a repro, via dx @$cursession.TTD.Calls("Module!Function") — an empty result is as informative as a populated one when it rules a code path out.

None of this replaces reading the disassembly. It's what makes the disassembly worth reading — pointing at the right function, at the right moment, instead of guessing.