← articles

DLL pinning on Windows: when FreeLibrary does nothing

Why a DLL can refuse to unload on Windows, how to verify that it was pinned, and why OpenSSL can accidentally pin your own module.


An easily overlooked aspect of the Windows loader is that a DLL may remain loaded even when FreeLibrary() is called correctly.

In the case examined here, the module was a DLL injected into a target process. Under normal circumstances, unloading such a module is straightforward: create a thread in the remote process and invoke FreeLibrary() on it. In this instance, that had no effect. System Informer was likewise unable to unload the module. Although the initial assumption was that the issue stemmed from a reference-count leak in the surrounding code, the actual cause was different: the DLL had been pinned.

What is DLL pinning?

Windows provides a documented mechanism for marking a loaded module as effectively non-unloadable for the remainder of the process lifetime. This is exposed through GetModuleHandleEx() with the GET_MODULE_HANDLE_EX_FLAG_PIN flag.1 Although the function name suggests a simple handle lookup, this code path also mutates the loader state associated with the DLL. In practical terms, a pinned module has the following properties.

When a module is pinned:

  • FreeLibrary() no longer causes the module to unload.
  • Tools that rely on normal loader semantics also cannot unload it.
  • The DLL stays mapped until the process terminates.

This behavior is easy to miss because it is not a loader feature commonly encountered in typical application code.

The relevant pattern looks like this:

HMODULE handle = nullptr;
GetModuleHandleExA(
    GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
    reinterpret_cast<LPCSTR>(&some_symbol_inside_the_module),
    &handle);

Once this occurs, the module no longer behaves like an ordinary reference-counted DLL and is instead kept resident until process exit.

What happens when a module is pinned?

At a high level, pinning changes the loader state so that the module is no longer treated as an ordinary reference-counted DLL. The behavior becomes clearer by following the path from GetModuleHandleExA() into the loader internals and then examining what happens later when unload is attempted.

GetModuleHandleExA path

The public API entry point is GetModuleHandleExA(), but the relevant behavior occurs inside ntdll. If the caller does not request GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, execution eventually follows the path that mutates the module state:

//
// Somewhere inside GetModuleHandleExA:
//
if ((flags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT) == 0)
{
    Status = LdrAddRefDll((dwPublicFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) 
                        ? LDR_ADDREF_DLL_PIN 
                        : 0, 
                        module);

    ...
}

The call into LdrAddRefDll is the important step. Internally it is defined roughly as NTSTATUS LdrAddRefDll(ULONG Flags, PVOID BaseAddress), and from there the loader follows one of two paths: it either increments the ordinary load count or, if the pin flag is present, calls LdrpPinModule.

The piece of code looks roughly like this:

NTSTATUS
NTAPI
LdrAddRefDll(ULONG Flags, PVOID BaseAddress)
{
  ...

  //
  // Want to pin this DLL or just inc. the refcount?
  //
  // LDR_ADDREF_DLL_PIN is defined as 0x00000001
  //
  if (Flags & LDR_ADDREF_DLL_PIN)
  {
    Status = LdrpPinModule(Entry);
  }
  else
  {
    Status = LdrpIncrementModuleLoadCount(Entry);
  }

  ...
}

This is the point at which the loader distinguishes between an ordinary reference-counted module and a pinned one.

NTLdr internals

Once LdrpPinModule runs, Windows is no longer operating at the level of the public HMODULE abstraction. Instead, it works with the loader's internal bookkeeping. That state resides in the process loader data reachable from PEB_LDR_DATA, where each loaded image has its own loader entry and also participates in the dependency graph through a DDAG node.2

One important detail is that this changed in NT 6.2, that is, Windows 8 and later. Older loader implementations used the module entry's LoadCount directly as the effective reference count. In the newer DDAG-based loader, the meaningful count moved to the DDAG node, while the old module field remained as ObsoleteLoadCount.3

LdrpPinModule first checks whether the module is already pinned. If it is, the function returns success immediately. Otherwise, it verifies that the module's DDAG node still has a live load count and then calls LdrpPinNodeRecurse on that node:

NTSTATUS
NTAPI
LdrpPinModule(PLDR_DATA_TABLE_ENTRY Module)
{
  NTSTATUS Status = STATUS_SUCCESS;

  //
  // Already pinned? Fail fast.
  //
  if (Node->LoadCount == -1 || Module->ProcessStaticImport)
  {
    return STATUS_SUCCESS;
  }

  ...

  //
  // LoadCount musn't be 0 for us to pin it
  //
  if (Module->DdagNode->LoadCount != 0)
  {
    LdrpPinNodeRecurse(Module->DdagNode);
  }
  else
  {
    Status = STATUS_UNSUCCESSFUL;
  }

  ...

   return Status;
}

That recursive step is where the actual pinning occurs. LdrpPinNodeRecurse marks the current node as pinned by setting the DDAG node load count to -1 and the module's ObsoleteLoadCount to -1 for backward compatibility:

VOID
NTAPI
LdrpPinNodeRecurse(PLDR_DDAG_NODE Node)
{
  PLDR_DATA_TABLE_ENTRY Module = ...

  //
  // Fast path; if already pinned or static import, don't continue
  //
  if (Node->LoadCount == -1 || Module->ProcessStaticImport)
  {
    return;
  }

  Node->LoadCount = -1;
  Module->ObsoleteLoadCount = -1;

  //
  // Now the function also loops over all DLL dependencies of the current DLL
  // and calls LdrpPinNodeRecurse() recursively on each of them.
  //
}

Afterward, it walks the dependency list and applies the same logic to dependent nodes as well.

The effect is therefore not a simple additional reference increment. On Windows 8 and later, the loader rewrites both the DDAG node state and the module's obsolete per-entry count into sentinel values. At that point, the module, and effectively the dependency subgraph attached to it, is treated as resident until process termination.

Why unload stops working

The other relevant aspect is the unload path. When the loader later attempts to dereference the module during unload, it checks the same pinned state and returns without performing the normal decrement-and-unload work:

VOID
NTAPI
LdrpDereferenceModule(IN PLDR_DATA_TABLE_ENTRY Module)
{
  //
  // If this is a pinned module, don't unload
  //
  if (Node->LoadCount == -1 || Module->ProcessStaticImport)
    return;

  ...
}

This is why calling FreeLibrary() appears to do nothing. The function is not failing in an unusual way; rather, the loader state has been changed into a form that no longer behaves like an ordinary decrementable reference count.

This also explains why external tools are of limited use in this situation. Attempts to unload the DLL through utilities such as System Informer or Process Hacker fail for the same reason. If the loader's internal state indicates that the module is pinned, the normal unload path is no longer available.

Ho to unpin the DLL?

AFAIK, there's no legit way of doing this. The only real way would be changing the LoadCount value to anything other than the sentinel value -1. That should cause FreeLibrary() to behave normally again, because the loader will once again treat the module as decrementable rather than permanently pinned.

Checking LoadCount in WinDbg

If this behavior needs to be verified in WinDbg, the quickest method is:

  1. Get the base address of the DLL.
  2. Run !dlls -c <base>.

For example:

0:056> !dlls -c 55790000

0x2ca7eec8: <path/to/dll>
      Base   0x55790000  EntryPoint  0x5626472c  Size        0x01c61000    DdagNode     0x2ca91588
      Flags  0x800822cc  TlsIndex    0x00000000  LoadCount   0xffffffff    NodeRefCount 0x00000000
             <unknown>
             LDRP_LOAD_NOTIFICATIONS_SENT
             LDRP_IMAGE_DLL
             LDRP_PROCESS_ATTACH_CALLED

The relevant field here is LoadCount. If it appears with all bits set, the module is no longer in an ordinary reference-counted state. Instead, it reflects the sentinel state produced by the pinning path inside ntdll. On Windows 8 and later, the effective loader count resides in the DDAG structures, while the older per-module field remains only as ObsoleteLoadCount.4

Strictly speaking, the same state can be observed directly through the internal loader data in _PEB_LDR_DATA, but for day-to-day reverse-engineering work !dlls -c is the more practical option because it exposes the relevant state without requiring manual traversal of the loader lists.

What is the ProcessStaticImport flag on a DLL?

Another detail visible in the loader checks is the ProcessStaticImport flag.

In this context, ProcessStaticImport does not mean that the DLL was compiled into another DLL as object code from a static library. It means the module was loaded by the Windows loader as part of resolving the import table of another image. In other words, the DLL is a normal load-time dependency, not a module that was brought in later through an explicit LoadLibrary() call.

Put differently, this is the ordinary PE import mechanism. One module names another DLL in its import table, and the loader brings that dependency in automatically when the parent image is loaded.

That distinction matters because the loader treats such hard dependencies differently from dynamically loaded modules. The check against ProcessStaticImport in the unload path is therefore about whether the module entered the process as a load-time dependency, not about whether its code was embedded directly into some other DLL.

How did this happen in my code?

In this case, the cause was not the unload code at all. It was an OpenSSL dependency.

The important detail is that OpenSSL does not only pin itself in the obvious shared-library case. Depending on how it is used, it can also affect the loader state of the module that brings it into the process.

That is what occurred here. The initial assumption was that the injected module itself, or its cleanup path, had leaked a reference. In reality however, a third-party dependency had already instructed Windows to keep the entire module resident for the remainder of the process lifetime.

OpenSSL issue #20977 documents this exact behavior. The key detail is that no-shared does not imply no-pinshared.

As a result, a static OpenSSL build inside a DLL can still inherit DLL pinning unless that behavior is disabled explicitly.

Why would anyone do this?

OpenSSL documents the motivation clearly: it wants its cleanup handlers to remain valid when its shutdown logic runs, specifically around its atexit()-based cleanup path.

The concern is understandable. If the library is unloaded too early and cleanup still runs later, that cleanup code may execute after the code or state it depends on has already disappeared. This is precisely the kind of shutdown bug that leads to crashes.

From OpenSSL's perspective, pinning is a defensive choice. It is preferable to keep the library mapped until process exit than to risk late cleanup running against an image that has already been unloaded.

Conclusion

If FreeLibrary() appears to do nothing, the first step should not be to assume that the problem lies in the unload code. Instead, verify whether the module was pinned. If there is uncertainty about whether one of the dependencies is responsible, inspect the LoadCount field in WinDbg.

That single detail can completely change the direction of the debugging process. Instead of chasing nonexistent reference-count leaks in the injector or unload path, the investigation shifts toward loader state and third-party dependencies. In this instance, that distinction was the difference between continuing down the wrong path and identifying the actual cause.

Special thanks

The following projects were particularly useful as references and documentation sources during the investigation:


References

  1. Microsoft documentation for GetModuleHandleEx() and GET_MODULE_HANDLE_EX_FLAG_PIN: nf-libloaderapi-getmodulehandleexa
  2. In the Windows loader context, DDAG means dependency directed acyclic graph. It is the loader's internal graph of module dependencies, represented by structures such as LDR_DDAG_NODE. A node tracks modules, their dependencies, incoming dependencies, reference counts, and loader state.
  3. Geoff Chappell's LDR_DATA_TABLE_ENTRY reference, including the historical change from LoadCount to ObsoleteLoadCount: https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntldr/ldr_data_table_entry.htm
  4. Background discussion on loader load-count sentinels: https://stackoverflow.com/questions/22855932/is-0x0000ffff-the-default-load-count-of-a-dll-in-windows