nvwgf2umx.dll 메모리 누수 발견
회사 프로그램을 디버깅하다가 NVIDIA Direct3D 유저 모드 드라이버 nvwgf2umx.dll의
메모리 누수를 찾았다. 재현 코드와 증거는 전부
nvwgf2umx-thread-leak 저장소에
올려 두었고, 이 글은 그 리포트의 요약이다.
결론
nvwgf2umx.dll은 DllMain의 DLL_THREAD_ATTACH 핸들러에서 184바이트를 할당하고
DLL_THREAD_DETACH에서 해제하지 않는다.
- 드라이버가 프로세스에 올라가 있기만 하면, 그 프로세스가 만드는 모든 스레드마다 184바이트가 샌다. Direct3D를 한 번도 호출하지 않아도 마찬가지다.
- 스레드는 정상 종료한다.
TerminateThread를 쓰지 않으므로DLL_THREAD_DETACH는 분명히 호출된다. 드라이버가 attach에서 잡은 걸 그냥 안 놓는 것이다. - 스레드 50만 개를 만들고 나면 힙에 184바이트 블록이 500,005개 남는다. 프로세스 힙에서 사용 중인 전체 바이트의 **99.20%**다.
- 드라이버 595.95와 580.88에서 똑같이 재현된다. 8개월 차이가 나는 두 버전의 스레드당 증가량이 소수점 둘째 자리까지 같다. 특정 릴리스의 회귀도 아니고, 최신 버전에서 고쳐지지도 않았다.
스레드를 계속 만들고 버리는 프로그램이라면 — 스레드 풀이 늘었다 줄었다 하거나, 작업이나 프레임마다 워커를 띄우거나, 플러그인을 각자 스레드에서 돌리거나 — 프로세스 메모리가 끝없이 자란다.
DLL_THREAD_ATTACH가 뭔지부터
프로세스가 스레드를 하나 만들면, Windows 로더는 그 프로세스에 매핑된 모든 모듈의
진입점(DllMain)을 DLL_THREAD_ATTACH로 한 번씩 호출한다. 스레드가 끝나면 같은
목록을 다시 돌면서 DLL_THREAD_DETACH로 호출한다. 모듈이 스레드마다 뭔가 준비해야
할 때 쓰라고 있는 자리다.
nvwgf2umx.dll은 attach에서 184바이트짜리 구조체(아마 스레드별 장부)를 힙에 할당한다.
그리고 detach에서 그걸 해제하지 않는다. 그게 전부다.
여기서 중요한 건 이 경로가 Direct3D가 아니라 로더의 것이라는 점이다. 스레드 생성 만으로 발동하므로 프로세스가 D3D를 쓰든 말든 상관이 없다.
최소 재현: 변수를 하나만 남기기
누수를 드라이버 탓으로 돌리려면 다른 후보를 전부 지워야 한다. 그래서 재현 코드에는 Direct3D 호출도, 디바이스도, 렌더링도, 창도 없다. 어댑터도 하나뿐인 머신에서 돌렸다. 하는 일은 딱 두 가지다.
- 명령줄로 받은 DLL을
LoadLibrary로 올린다. - 빈 워커 스레드를 만들고
join()하기를 N번 반복하면서 private byte를 찍는다.
// 아무것도 안 하고 정상 반환한다. 로더가 모든 모듈에 DLL_THREAD_DETACH를
// 전달하게 하려면 이래야 한다. TerminateThread를 쓰면 detach가 아예 안 오고,
// 테스트는 의미를 잃는다.
static void worker() { }
// ...
HMODULE h = LoadLibraryExA(dllPath, NULL, LOAD_WITH_ALTERED_SEARCH_PATH);
for (unsigned i = 1; i <= iters; ++i) {
std::thread t(worker);
t.join();
if (i % 1000 == 0) { /* private bytes 출력 */ }
}
스레드는 하나씩 만들고 바로 join하므로 어느 순간에도 여분의 스레드는 최대 하나다. 스레드 스택이 증가분의 원인일 수 없다는 뜻이다.
LoadLibrary만 하고 아무것도 안 하면, 관측된 누수를 D3D 객체 수명이나 프레젠테이션,
어댑터 열거, 애플리케이션 자체 할당 탓으로 돌릴 수 없다. 실행되는 드라이버 코드는
DllMain뿐이기 때문이다.
남은 후보는 하나다. std::thread를 50만 번 돌린 것 자체가 원인일 가능성. 그건
대조군으로 지운다. 같은 바이너리, 같은 머신, 같은 반복 횟수로 관계없는 시스템
DLL(version.dll)을 올린다.
실행 결과
누수 실행 (전체 로그에서 10만 회 간격으로 발췌):
loaded ...\nvwgf2umx.dll at 00007FFE446E0000
private bytes: before-load=966656 after-load=31899648
iter 1000 private=32493568 delta=+593920
iter 100000 private=55721984 delta=+23822336
iter 200000 private=78823424 delta=+46923776
iter 300000 private=101928960 delta=+70029312
iter 400000 private=125030400 delta=+93130752
iter 500000 private=148312064 delta=+116412416
after settle: private=148312064 growth=+116412416
전 구간에서 선형이다. 어디서도 평평해지지 않는다.
대조군:
loaded C:\Windows\System32\version.dll at 00007FFE5DB90000
private bytes: before-load=974848 after-load=1081344
iter 1000 private=1122304 delta=+40960
iter 3000 private=1155072 delta=+73728
iter 100000 private=1155072 delta=+73728
iter 300000 private=1155072 delta=+73728
iter 500000 private=1155072 delta=+73728
after settle: private=1155072 growth=+73728
72KB 정도 준비 운동을 하고 나면 값이 다시는 변하지 않는다. 3,000회와 500,000회의 private byte가 바이트 단위로 같다. 49만 7천 회 차이가 나는데도.
이 실행도 똑같은 로더 attach/detach 경로를 50만 번 태운다. C 런타임 DLL을 포함해 여러
모듈이 프로세스에 올라가 있는 상태로. 알림 자체는 아무것도 쌓지 않는다. 메모리가
자라는 건 올린 모듈이 nvwgf2umx.dll일 때뿐이다.
같은 스레드 50만 개를 지나온 뒤, 대조군 프로세스는 1.1MiB를 들고 3,000회에서 멈춰 있고, 드라이버를 올린 프로세스는 141MiB를 들고 여전히 선형으로 오르는 중이다.
WinDbg: 누가 할당했는지 이름을 대게 만들기
여기까지는 “드라이버를 올리면 는다”까지다. 무엇이 쌓이는지, 그걸 누가 할당했는지는
힙을 직접 봐야 안다. 두 실행 끝에서 .dump /ma로 전체 덤프를 떴다.
준비: +ust
할당 콜스택을 남기려면 프로세스가 실행되기 전에 유저 모드 스택 추적을 켜 둬야 한다. 관리자 명령 프롬프트에서:
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\test1_loadlibrary.exe" /v GlobalFlag /t REG_SZ /d 0x00001000 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\test1_loadlibrary.exe" /v StackTraceDatabaseSizeInMb /t REG_DWORD /d 64 /f
GlobalFlag 0x00001000이 FLG_USER_STACK_TRACE_DB, gflags -i test1_loadlibrary.exe +ust와
같다. 이걸 안 켜면 !heap -p -a가 블록은 찾아 주지만 콜스택은 못 보여 준다. 다 쓰면
reg delete로 지운다.
힙 총량부터: !heap -s
| 누수 실행 | 대조군 | |
|---|---|---|
| Committed | 114,456KB (~112MiB) | 136KB |
| Reserved | 130,460KB | 1,800KB |
| Free | 1,713KB, 56 blocks | 8KB, 21 blocks |
누수 쪽은 112MiB를 커밋해 두고 free는 1.7MB뿐이다. 프리 리스트에 앉아서 OS로 돌아갈 차례를 기다리는 게 아니라 진짜로 아직 할당된 상태라는 뜻이다. 스레드를 정확히 같은 수만큼 만들고 없앤 대조군은 136KB다.
힙의 거의 전부가 한 가지 크기: !heap -stat -h
요청 크기별로 사용 중인 바이트를 묶어 준다.
heap @ 0000013cac3a0000
group-by: TOTSIZE max-display: 20
size #blocks total ( %) (percent of total busy bytes)
b8 7a125 - 57bd298 (99.20)
58 944 - 32f60 (0.23)
68 359 - 15c28 (0.10)
...
0xb8은 184바이트. 0x7a125는 500,005개다. 만든 스레드가 50만 개니까 딱 다섯 개
많다. 0x57bd298은 92,000,920바이트이고, 92,000,920 ÷ 500,005 = 정확히 184. 저 블록이
전부 같은 할당이라는 확인이다. 그리고 이것들이 **힙에서 사용 중인 전체 바이트의
99.20%**를 차지한다.
대조군에서 같은 버킷을 보면:
Size Blocks Seg Empty Aff
------------------------------------------------
176 22 1 0 0
224 17 1 1 0 <--
------------------------------------------------
Total front-end busy blocks: 171
이 할당을 담당하는 버킷이 대조군에는 17개, 누수 실행에는 500,005개 있다. 대조군의 프론트엔드 힙 전체가 171 블록이다. 184바이트짜리를 50만 개 만들어 내는 게 테스트 프로그램도, C 런타임도, 로더도 아니라는 뜻이다. 셋 다 두 실행에서 동일하다.
블록의 정체: !heap -flt s b8
0000013cae214000 000e 000e [00] 0000013cae214030 000b8 - (busy)
0000013cae2140e0 000e 000e [00] 0000013cae214110 000b8 - (busy)
...
UserSize 0xb8이 요청한 184바이트, Size 0x0e가 할당 단위 14개 — 실제로 소비된
224바이트다.
그러면 산수가 맞아떨어진다. 500,005 × 224 = 112,001,120바이트(106.8MiB). 측정된 private byte 증가량은 116,412,416바이트(111.0MiB). 차이 3.8%는 저단편화 힙(LFH)의 서브세그먼트 메타데이터다.
스레드당 증가량도 같은 이야기를 한다.
(116,412,416 − 23,822,336) ÷ 400,000 스레드 = 231.48 바이트/스레드
이 숫자는 계측 상태에 따라 달라진다. +ust를 끄면 198.72바이트/스레드다. 차이 33바이트는
스택 추적 장부이지 추가 누수가 아니다. 요청 크기 184바이트는 어느 설정에서도 변하지
않으므로, 리포트에서 계속 인용하는 숫자는 그쪽이다.
범인: !heap -p -a
블록 하나를 찍어 할당 콜스택을 본다.
0:000> !heap -p -a 0000013cae214810
address 0000013cae214810 found in
_HEAP @ 13cac3a0000
0000013cae2147e0 000e 0000 [00] 0000013cae214810 000b8 - (busy)
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
7ffe66e85c73 ntdll!LdrpInitialize+0x3b
7ffe66e85c1e ntdll!LdrInitializeThunk+0xe
아래에서 위로 읽는다. 새 스레드가 시작하고(LdrInitializeThunk) → 로더가 초기화하고
(LdrpInitializeThread) → 올라온 모듈들의 진입점을 부르고(LdrpCallInitRoutine) →
nvwgf2umx.dll의 진입점이 돌고 → 드라이버 내부 네 프레임을 지나 →
RtlpAllocateHeapInternal. DLL_THREAD_ATTACH에서 할당되고 detach에서 해제되지 않는
184바이트가 여기서 나온다.
NVDEV_Thunk+<큰 오프셋>으로 보이는 건 그게 가장 가까운 export 심볼이기 때문이다.
nvwgf2umx.dll은 공개 심볼을 자세히 내놓지 않는다. 어느 모듈인지는 그래도 명확하다.
”LoadLibrary로 유저 모드 드라이버를 직접 올린 게 반칙 아닌가?”
이 재현에 나올 법한 가장 유력한 반론이라 미리 답해 둔다.
- 새는 코드 경로는 Direct3D의 것이 아니라 로더의 것이다.
DLL_THREAD_ATTACH는 스레드가 생길 때마다 프로세스에 매핑된 모든 모듈에 전달된다. 그 모듈이 어떻게 올라왔는지와 무관하게.DllMain의 thread-attach 핸들러는 D3D 런타임이 자기를 먼저 초기화해 줬을 거라고 가정할 수 없다. - D3D 런타임이 초기화하기 전까지는 아무 일도 안 하도록 만든 핸들러라면, 초기화되지 않은 프로세스에서 할당을 한다는 것 자체가 결함이다. 그리고 어느 쪽이든 detach에서 해제하지 않는 건 그대로 남는다.
- 강제 로드 없이도 같은 누수가 난다. 애초에 이 문제를 발견한 애플리케이션에서
nvwgf2umx.dll은LoadLibrary호출 없이 정상 경로로 올라와 있었다.
실제로는 이렇게 만났다
순서는 사실 반대였다. 위 재현은 드라이버를 일부러 올리지만, 이 문제를 처음 본 건 부르지도 않은 드라이버가 올라와 있던 프로세스에서였다.
그 애플리케이션은 OpenGL 렌더러다. 소스에 Direct3D 호출이 없고, D3D 디바이스를 만들지
않고, D3D 라이브러리를 링크하지도 않는다. 그런데도 nvwgf2umx.dll이 프로세스 안에
있었다. 누수된 블록 하나의 스택을 뜨니 이렇게 들어와 있었다.
7ffaffa38bb4 nvwgf2umx+0xc8bb4 <- DllMain이 돌면서 할당
7ffc236bf98a ntdll!LdrpCallInitRoutineInternal+0x22
...
7ffc235b9370 ntdll!LdrLoadDll+0x170
7ffc20d037bf KERNELBASE!LoadLibraryExW+0xff <- 여기서 nvwgf2umx.dll이 로드된다
7ffbd6b0096c nvldumdx!OpenAdapter12+0x5e4c
...
7ffc1a67655e d3d11!NDXGI::CUMDAdapter::OpenAdapter10_2+0xa2
7ffc1a6a6091 d3d11!CCreateDeviceCache::CUMDAdapterCache::Load+0x3b9
7ffc1a678c86 d3d11!CCreateDeviceCache::CAdapterCache::ResolveUMDAndVersion+0x236
7ffc1a676e06 d3d11!D3D11CoreCreateDevice+0x676
7ffc1a6793c3 d3d11!D3D11CreateDeviceAndSwapChainImpl+0x2a3
프로세스 안의 무언가가 D3D11 디바이스를 만들었고, D3D11 런타임이 어댑터의 유저 모드
드라이버를 찾았고, NVIDIA의 로더 심 nvldumdx가 nvwgf2umx.dll을 LoadLibraryExW로
올렸다. 그 순간부터 이 프로세스가 만드는 모든 스레드가 위의 비용을 낸다.
이 스택은 DLL_PROCESS_ATTACH 경로다. 누수 자체가 아니라 드라이버가 어떻게
들어왔는지를 보여 주려고 붙였다. 그리고 저 D3D11CreateDeviceAndSwapChain을 누가
불렀는지는 아직 모른다. 스택 추적 데이터베이스가 그 깊이에서 잘린다. 애플리케이션
코드가 아니라는 것만 말할 수 있다. 거기엔 D3D 코드가 없으니까.
누수도 그 프로세스에서 같은 모양으로 일어난다. 전체 덤프에서 살아 있는 184바이트 블록
60개를 뽑아 할당 스택을 봤더니, 60개 중 53개가 nvwgf2umx.dll이 할당한 것이었고,
그 53개는 전부 로더의 thread-attach 경로였다.
7ffc23571d1c ntdll!RtlAllocateHeap+0xabc
7ffb007e7298 nvwgf2umx!NVDEV_Thunk+0x530bd8
...
7ffaffa38b9a nvwgf2umx+0xc8b9a
7ffc236bf98a ntdll!LdrpCallInitRoutineInternal+0x22
7ffc23572d23 ntdll!LdrpCallInitRoutine+0x93
7ffc23572aa7 ntdll!LdrpInitializeThread+0x297
7ffc235c69ab ntdll!LdrpInitialize+0xa7
7ffc235f73ce ntdll!LdrInitializeThunk+0xe
최소 재현과 같은 모양이다. 드라이버 빌드와 Windows 버전이 달라서 오프셋만 다르고, 경로는 같다. 강제 로드 재현은 평범한 로드 경로에서도 일어나는 누수를 격리한 것이지, 없는 누수를 만들어 낸 게 아니다.
그래서 우회하기가 어렵다
- 그 애플리케이션은 Direct3D를 쓴 적이 없다. 개발자가 해제할 D3D 객체도, 지울 D3D 호출도 없다. 애초에 없었으니까. 누수는 프로세스가 자기 의사와 무관하게 떠안은 모듈 안에 통째로 들어 있다.
- 드라이버가 올라가 있는 동안 스레드를 재활용하는 장수 프로세스라면 전부 영향을 받는다. 프레임마다 스레드를 만드는 패턴이면 금방 눈에 띄지만, 누수는 그 패턴이 아니라 스레드 생성에 묶여 있다.
- 스레드는 정상 종료하므로
DLL_THREAD_DETACH는 온다. 애플리케이션 쪽에 고칠 실수가 없다.
두 드라이버 버전
전체 절차를 580.88(32.0.15.8088, 2025년 7월)에도 돌렸다.
595.95(32.0.15.9595, 2026년 3월)보다 8개월 오래된 버전이다.
| 580.88 | 595.95 | |
|---|---|---|
+ust 켬 | 231.48 B/스레드 | 231.48 B/스레드 |
+ust 끔 | 198.72 B/스레드 | 198.72 B/스레드 |
소수점 둘째 자리까지 같다. 한 릴리스에서 생긴 회귀가 아니고, 최신 릴리스에서 고쳐지지도 않았다는 뜻이다.
현재 상태
- 2026-08-27 — NVIDIA 개발자 포럼에 제보: nvwgf2umx.dll leaks 184 bytes per thread in its DLL_THREAD_ATTACH handler (595.95, 580.88)
- 2026-08-27 — 개발자 버그 폼으로 접수, 버그 #6672628 (트래커는 비공개)
- 2026-08-30 — NVIDIA가 재현 확인. 현재 DirectX 엔지니어링 팀으로 넘어가 있다.
재현 코드, 전체 로그, WinDbg 출력 전문은 저장소에 있다 — ArtistDeveloper/nvwgf2umx-thread-leak.