AddSecurityPackage without a reboot
On a running machine, AddSecurityPackageW() returns SEC_E_OK and the package works right away for plain authentication. CredSSP, and so RDP, still fails partway through the handshake. The client shows:
mstsc: "A specified authentication package is unknown", STATUS_NO_SUCH_PACKAGE / 0xC00000FE. Nothing says that a reboot fixes it.After a reboot, the same package works. It is registered correctly both times. The difference is whether SspiCli.dll's in-process package table has an entry for it, and that table is separate from the LSA registration. Kernel-mode packages have the same kind of problem (see Developing a kernel-mode security package and the note at the end).
An unrelated error earlier in the CredSSP handshake shows up in the same dialog with only a hex code:
0x80080341, with no other detail.AddSecurityPackageW() only reaches lsasrv.dll. SecpAddPackage makes an RPC call (NdrClientCall3, interface sspirpc, opnum 3) to lsasrv!WLsaAddPackage, which updates _LSAP_SECURITY_PACKAGE. Of the SECURITY_PACKAGE_OPTIONS structure, only Flags is read (for SECPKG_OPTIONS_PERMANENT). Type is ignored.
SspiCli.dll keeps a second list, SecPackageControlList, for anything that needs a package's user-mode function table: encrypt, decrypt, sign. NegoEx needs it to run CredSSP. The list is per process and lives in the process that loaded SspiCli (here, lsass.exe). It is built once at LSA startup and never rebuilt. AddSecurityPackageW has no path to it.
Server-side trace of the failure during an 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
After a reboot, the same lookup with the same id succeeds, because SecPackageControlList has the entry.
SecPackageControlList layout
It is a doubly linked list: a LIST_ENTRY head and one node per package. Neither the list nor SecLocatePackageById, the function that walks it, is exported, and no header describes them. The layout below comes from a live node in memory:
+0x00/+0x08:LIST_ENTRY(Flink / Blink)+0x10: flags byte.EnumerateSecurityPackagesWonly lists the node when bit0x20is set+0x18: package id+0x24: second flags field. Bit0x04means "not snapped yet" (load pending); the value becomes0x11once snapped+0x38: pointer to a small object that owns the loaded DLL, with a refcount at its+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 term for loading a package's DLL and filling its function table on first use. It happens when something first calls into the package, not at registration. These offsets change between Windows builds, so code that touches this list has to find them at runtime (see below).
- Calling
AddSecurityPackageWagain.SecpAddPackageonly readsFlagsand never touchesSecPackageControlList. - The exported lookup APIs:
EnumerateSecurityPackagesW,QuerySecurityPackageInfoW,SecInitUserModeContext(ordinal 2). They all check the same "already loaded" flag first and return early when it is set. SecCacheSspiPackages: exported, but it only writes a registry cache, not the live list.DeleteSecurityPackage: not implemented (the A and W entry points share the same RVA), so remove and re-add is not an option.- Another RPC call into
lsasrv.dll. lsasrv works only on its own list,_LSAP_SECURITY_PACKAGE, and no symbol in it referencesSecPackageControlListor its node type. The disassembly confirms that no lsasrv code path reaches this list. - Patching the list from a separate process. That means writing into
lsass.exememory from outside, which is fragile and blocked when lsass runs as a protected process.
Nothing outside lsass can reach SecPackageControlList safely. But once AddSecurityPackageW has loaded the package, its DLL is mapped inside lsass.exe. So the package itself does the insertion, on request through a plain LsaCallAuthenticationPackage call right after registration. The package then:
- Forces NTLM, Negotiate and Kerberos to snap, with
QuerySecurityPackageInfoWon each name. Snapping is lazy, so right after boot or during an unattended install none of them may be snapped yet. The next step needs at least two snapped nodes as references. - Finds each field (id, name, function table pointer) from properties that must hold across the reference nodes: uniqueness, value range, shape. It never uses a fixed offset, since the layout is undocumented and changes between builds. A test list with extra padding checks that nothing is hardcoded.
- Clones a full node from one of the reference packages, with the allocator SspiCli uses (no heap mismatch), and overwrites only the id, name and function table. Every other field, including the ones whose meaning is unknown, comes from a valid node.
- Checks the result with
EnumerateSecurityPackagesW, so SspiCli's own code reads the new node, and reports success only if it shows up.
When the layout can't be identified with confidence, for example on an unknown Windows build, the package writes nothing and tells the caller to reboot.
This one is easy to confuse with the first. lsass.exe maps a package's DLL once, from the path given to AddSecurityPackageW, and runs that image for the life of the process. It never checks the file on disk again. A second AddSecurityPackageW call with a path that was already loaded does nothing, even if the file has changed. This applies to any second registration with the same path, not only to file upgrades.
The path works as a cache key. A path lsass has never seen forces a real LoadLibrary. Before each AddSecurityPackageW call, copy the DLL under a new unique name (temp directory, GUID in the file name) and mark the copy for deletion at reboot. It can't be deleted right away, because lsass may keep it mapped for the rest of the session.
This only forces AddSecurityPackageW to load the file from disk. It is not a general way to replace a DLL that lsass has loaded.
A plain reboot fixes both problems: lsass restarts, rebuilds SecPackageControlList, and maps every package from disk. No undocumented layout, no GUID-named copies. The two workarounds here are for when a reboot is not possible right away. The reboot stays the default, and it is the only path Microsoft documents.
Developing a kernel-mode security package describes the kernel-mode symptom. A package registered with KSecRegisterSecurityProvider() after lsass.exe starts works, but NegoEx fails because LocatePackageById returns NULL: LSA maps names to ids once, at startup.
Same root cause, different layer: a lookup table built once at startup and never refreshed. That table can also be reached from inside the running kernel. Load a kernel-mode security package without a reboot covers the fix: grow ksecdd's package table and hook LocatePackageById from the driver, the kernel-mode counterpart of the SecPackageControlList patch.