Skip to content

AddSecurityPackage without a reboot

The symptom

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:

Remote Desktop Connection dialog: An authentication error has occurred. A specified authentication package is unknown.
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:

Remote Desktop Connection dialog: An authentication error has occurred (Code: 0x80080341).
Same client, different error: 0x80080341, with no other detail.
Two lists, not one

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.

The 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. EnumerateSecurityPackagesW only lists the node when bit 0x20 is set
  • +0x18: package id
  • +0x24: second flags field. Bit 0x04 means "not snapped yet" (load pending); the value becomes 0x11 once snapped
  • +0x38: pointer to a small object that owns the loaded DLL, with a refcount at its +0x20 (not a bare HMODULE)
  • +0x50: bit 0x100000 marks the NegoEx "extender" package
  • +0x68: package name, UNICODE_STRING
  • +0xB8: SECPKG_USER_FUNCTION_TABLE pointer, NULL until 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).

What doesn't work
  • Calling AddSecurityPackageW again. SecpAddPackage only reads Flags and never touches SecPackageControlList.
  • 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 references SecPackageControlList or 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.exe memory from outside, which is fragile and blocked when lsass runs as a protected process.
What works: patch the list from inside lsass

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 QuerySecurityPackageInfoW on 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.

A second problem: the DLL path is a cache key

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 reboot still fixes both

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.

The kernel-mode side of the same problem

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.