AddSecurityPackage without a reboot
AddSecurityPackageW() against a running machine returns SEC_E_OK, and the package resolves fine for plain authentication straight away. CredSSP — and therefore RDP — still fails partway through the handshake, and whoever is sitting at the client sees this:
mstsc, live capture: "A specified authentication package is unknown" — STATUS_NO_SUCH_PACKAGE / 0xC00000FE, surfaced with no indication that the fix is a reboot.Reboot, and the exact same package works. The package is registered correctly on both occasions — what changes is whether SspiCli.dll's own in-process package table has an entry for it, which is a separate thing from LSA registration and is the subject of this page. It's the same family of problem as what Developing Kernel SSP describes for kernel-mode packages (note at the end).
A day earlier in the same investigation, a different bug further back in the same CredSSP handshake — long since fixed, and not this page's subject — surfaced through the same dialog with even less to go on: a bare hex code and nothing else.
0x80080341, no further explanation offered.AddSecurityPackageW() only ever reaches lsasrv.dll's side. SecpAddPackage goes out over real RPC (NdrClientCall3, interface sspirpc, opnum 3) to lsasrv!WLsaAddPackage, which updates _LSAP_SECURITY_PACKAGE. Of the whole SECURITY_PACKAGE_OPTIONS structure passed in, only Flags is ever read (for SECPKG_OPTIONS_PERMANENT) — Type is dead weight.
SspiCli.dll keeps a second, unrelated list — SecPackageControlList — used for anything that needs a package's user-mode function table: encrypt, decrypt, sign. That's exactly what NegoEx needs to run CredSSP. This list is per-process, lives inside whichever process loaded SspiCli (here, lsass.exe), gets built once at LSA init, and nothing rebuilds it afterwards. AddSecurityPackageW has no path to it at all.
Trace of the failure, server side, real RDP logon:
negoexts!WSTCreateUserModeContext -> SspiCli!LsaLocatePackageById(13) ; the new package's id -> walks SecPackageControlList, no match -> returns NULL -> WSTCreateUserModeContext hard-codes 0xC00000FE on NULL -> propagates up through SpInitUserModeContext, mstscax, mstsc
Same lookup, same package id, post-reboot trace: it resolves, because SecPackageControlList now has the entry.
SecPackageControlList actually looks like
It's a plain doubly-linked list — a LIST_ENTRY head, one node per package. Neither the list nor SecLocatePackageById, the function that walks it, is exported, so there's no header to check against; the layout below comes from reading a real, working node in memory:
+0x00/+0x08—LIST_ENTRY(Flink / Blink)+0x10— a flags byte; bit0x20has to be set forEnumerateSecurityPackagesWto list the node at all+0x18— the package id+0x24— another flags field; bit0x04means "not snapped yet" (load still pending), settling at0x11once it is+0x38— a pointer to a small object owning the loaded DLL, refcounted at its own+0x20— not a bareHMODULE+0x50— bit0x100000marks the NegoEx "extender" package+0x68— package name,UNICODE_STRING+0xB8—SECPKG_USER_FUNCTION_TABLEpointer,NULLuntil the node is snapped
"Snapped" is Microsoft's own term for lazily loading a package's DLL and populating its function table on first real use — it doesn't happen at registration time, only when something actually calls into the package. None of these offsets can be trusted to hold across Windows builds, which is why anything touching this list needs to rediscover them at runtime rather than hard-code them (more on that below).
- Calling
AddSecurityPackageWagain.SecpAddPackageonly ever readsFlags— there's no side effect that touchesSecPackageControlList. - Any of the exported lookup APIs —
EnumerateSecurityPackagesW,QuerySecurityPackageInfoW,SecInitUserModeContext(ordinal 2). All of them check the same "already loaded" guard before doing anything and bail out if it's set. SecCacheSspiPackages— exported, but it only writes a registry cache. It doesn't touch the live in-memory list.DeleteSecurityPackage— not implemented. The A and W entry points share the same RVA; remove-then-re-add isn't on the table.- Chasing the RPC call further.
AddSecurityPackageWdoes go over genuine RPC intolsasrv.dll, which looked like it might expose some other opnum worth trying. It doesn't:lsasrv.dllworks entirely on its own list,_LSAP_SECURITY_PACKAGE, and has no symbol referencingSecPackageControlListor its node type anywhere. It's not that the right call hasn't been found —lsasrv.dllis structurally incapable of reaching this list, confirmed by disassembly. - Patching the list from a separate helper process. Possible in principle, but it means writing into
lsass.exe's memory from outside — fragile, and blocked outright wherever process protection is enforced. Ruled out in favor of running the patch from inside lsass (next section).
Since nothing external can reach SecPackageControlList safely, and the package's own DLL is already mapped inside lsass.exe the moment AddSecurityPackageW has loaded it, the insertion has to run as code executing inside lsass — i.e. the package itself, asked to do it via an ordinary LsaCallAuthenticationPackage round trip right after registration. From there:
- Force NTLM, Negotiate and Kerberos to snap first, via
QuerySecurityPackageInfoWon each name. Snapping is lazy, so right after boot or in an unattended install none of them may be snapped yet — and at least two of the three snapped nodes are needed as reference points for the next step. - Identify each field (id, name, function-table pointer) from invariants that must hold across those reference nodes — uniqueness, value range, shape — never from a fixed offset, since the layout isn't documented and does shift between builds. Tested against a synthetic list with extra padding inserted to make sure nothing is hard-coded.
- Clone a full node from one of the snapped reference packages — same allocator SspiCli itself uses, so no heap mismatch — and overwrite only id, name and function-table. Everything else is inherited from a real, valid node, including fields whose exact meaning isn't known.
- Confirm the result with
EnumerateSecurityPackagesW— real SspiCli parsing, not the same code reading back its own write — and only report success once that agrees.
If the layout can't be identified with confidence — an unfamiliar Windows build, say — nothing gets written, and the caller is told to fall back to a reboot instead.
Separate issue, easy to conflate with the first: lsass.exe maps a package's DLL once, from whatever path was given to AddSecurityPackageW, and keeps running those exact bytes for as long as the process lives — it never re-checks the file on disk. Calling AddSecurityPackageW again with that same path is a no-op if that path was ever loaded before, whether or not the file on disk has changed since. This has nothing to do with replacing or upgrading a file — it's true the very first time a second registration attempt uses the same path, for any reason.
The path is effectively a cache key. So: give it a path it has never used, and it has to LoadLibrary for real. In practice, a copy of the DLL under a fresh, unique name (temp directory, GUID in the filename) each time AddSecurityPackageW is about to be called, marked for delete-on-reboot rather than deleted immediately — the file can't be touched while lsass may still have it mapped for the rest of the session.
It's a workaround for lsass's own caching behavior, not a general-purpose DLL replacement mechanism — it only exists to force one specific API call to actually reload from disk.
Both problems above go away on a plain reboot: lsass restarts, rebuilds SecPackageControlList from nothing, and maps every package fresh from disk — no undocumented layout, no GUID-named copies. The two techniques on this page exist only for when a reboot isn't acceptable at that moment. They are narrow workarounds for two specific pieces of undocumented behavior, not a replacement for the reboot path, which stays the default and the only one Microsoft actually documents.
Developing Kernel SSP already covered the kernel-mode symptom: a package registered via KSecRegisterSecurityProvider() after lsass.exe has started can be used, but NegoEx fails because LocatePackageById returns NULL — the name-to-id translation happens once, at LSA init, and is never redone.
Same root cause, different layer: a lookup table built once at initialization and never refreshed. Unlike the driver's own load order — which genuinely can't be worked around, SERVICE_BOOT_START before lsass starts is not optional — this particular table turned out to be reachable from inside the running kernel too. Live-loading a kernel security package (KSP) without a reboot covers the fix: growing ksecdd's own package table and hooking LocatePackageById from inside the driver, the kernel-mode counterpart to the SecPackageControlList patch above.