Nvwgf2umx.dll leaks 184 bytes per thread in its DLL_THREAD_ATTACH handler (595.95, 580.88)

nvwgf2umx.dll allocates 184 bytes in its DllMain DLL_THREAD_ATTACH handler and does not free them in DLL_THREAD_DETACH. Any process with the driver resident leaks 184 bytes per thread created, for as long as the driver stays loaded, regardless of whether the process issues a single Direct3D call.

Minimal reproduction, full logs, and dump analysis:

Environment

OS       Windows 10 Pro 22H2 (19045.6466)
GPU      GeForce RTX 3070 — single NVIDIA adapter, no second GPU
Driver   595.95 (32.0.15.9595); also reproduced on 580.88 (32.0.15.8088)
Build    MSVC (VS2019), x64, /O2 /MD

Reproduction

A single 60-line .cpp with no third-party dependencies. It LoadLibraryExes the DLL named on the command line, then creates and join()s an empty worker thread N times, printing private bytes as it goes. No D3D device, no rendering, no window — the only driver code that runs is its DllMain.

test1_loadlibrary.exe "<driverstore>\nvwgf2umx.dll" 500000   # leaking run
test1_loadlibrary.exe "C:\Windows\System32\version.dll" 500000   # control

The worker returns normally — no TerminateThread — so DLL_THREAD_DETACH does fire. Threads are created and joined one at a time, so at most one extra thread exists at any moment and thread stacks cannot account for the growth.

Result

Leaking run, private bytes over 500,000 threads:

after-load=31899648
iter 100000  private=55721984   delta=+23822336
iter 300000  private=101928960  delta=+70029312
iter 500000  private=148312064  delta=+116412416

Linear across the entire run, no plateau. (116,412,416 − 23,822,336) ÷ 400,000 = 231.48 bytes per thread.

Control run — same binary, same machine, same loop, same instrumentation, only the loaded module differs:

after-load=1081344
iter   3000  private=1155072  delta=+73728
iter 500000  private=1155072  delta=+73728

72 KB of one-time warm-up, then byte-for-byte identical at iteration 3,000 and 500,000 — 497,000 iterations apart. This run drives the identical loader thread-attach/detach path 500,000 times with several DLLs resident. The notifications themselves accumulate nothing.

Side by side after the same 500,000 threads: control holds 1.1 MiB and stopped changing at iteration 3,000; the process with nvwgf2umx.dll loaded holds 141 MiB and is still climbing linearly when the test stops.

Heap analysis

Full dump at end of run, +ust enabled. !ext.heap -stat -h on the process heap:

    size     #blocks     total     ( %)
    b8 7a125 - 57bd298  (99.20)

0xb8 = 184 bytes. 0x7a125 = 500,005 blocks against 500,000 threads created. 92,000,920 ÷ 500,005 = exactly 184. 99.20% of all busy bytes in the heap. The same LFH bucket holds 17 blocks in the control.

Allocation stack for one of those blocks (!heap -p -a, read bottom-up):

    7ffe66e3b49d ntdll!RtlpAllocateHeapInternal+0xa7d
    7ffe4556c548 nvwgf2umx!NVDEV_Thunk+0x587228
    7ffe45539eef nvwgf2umx!NVDEV_Thunk+0x554bcf
    7ffe4553a189 nvwgf2umx!NVDEV_Thunk+0x554e69
    7ffe4553a5b2 nvwgf2umx!NVDEV_Thunk+0x555292
    7ffe447a457a nvwgf2umx+0xc457a
    7ffe66e29a1d ntdll!LdrpCallInitRoutine+0x61
    7ffe66e2789f ntdll!LdrpInitializeThread+0x167
    7ffe66e86094 ntdll!LdrpInitialize+0x408
    7ffe66e85c1e ntdll!LdrInitializeThunk+0xe

A thread starts → the loader initializes it → it calls each module’s entry point → the nvwgf2umx.dll entry point runs → four frames inside the driver → 184-byte allocation, never released on detach. (NVDEV_Thunk+<large offset> is just the nearest exported symbol; the module attribution is unambiguous.)

Not a regression, and not fixed

Identical per-thread growth on both drivers, eight months apart:

580.88 (Jul 2025) 595.95 (Mar 2026)
+ust on 231.48 B/thread 231.48 B/thread
+ust off 198.72 B/thread 198.72 B/thread

The leak is not an artifact of the forced load

DLL_THREAD_ATTACH is delivered by the loader to every mapped module on every thread creation, regardless of how that module was loaded, so the reproduction above exercises the driver’s entry point the same way an ordinary load does.

It also reproduces with no forced load at all. This was originally found in a process that had the driver resident without any LoadLibrary call from the application — an OpenGL renderer containing no Direct3D code, where nvwgf2umx.dll was loaded by nvldumdx on behalf of a D3D11 device created by something other than application code. Of 60 sampled live 184-byte blocks in that process, 53 were allocated by nvwgf2umx.dll, every one on the same LdrpInitializeThreadDllMain path. All 53 stacks are in the repo.

Whatever the handler is meant to do on attach, the block is not released on detach.

For cross-reference: this is also filed as NVIDIA bug report 6672628.