-
-
[原创]一个关于ntoskrnl内核内存泄漏问题复现与分析
-
发表于: 2025-9-4 20:37 573
-
一个关于ntoskrnl内核内存泄漏问题复现与分析
概述
当通过PsSetCreateProcessNotifyRoutineEx回调函数在多线程环境下拒绝进程创建时,会发生内核内存泄漏。这一问题将导致内存池耗尽并引发系统崩溃。其根本原因是NtCreateUserProcess中的进程令牌问题。
在该文发表时,问题已被MSRC确认,详见后文
环境搭建
系统环境
Windows 10 22H2 19045.5737
Windows 10 Kernel Version 19041 MP (4 procs) Free x64
Product: WinNt, suite: TerminalServer SingleUserTS
Edition build lab: 19041.1.amd64fre.vb_release.191206-1406
禁用了内核隔离
编译器
MSVC 19.44.35213
WDK 10.1.26100.4202
WinSDK10.1.26100.4188
复现
首先应该写一个使用了PsSetCreateProcessNotifyRoutineEx回调函数的驱动,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | #include"Driver.h"VOID DriverUnload( _In_ PDRIVER_OBJECT DriverObject){ if (DriverObject) { PsSetCreateProcessNotifyRoutineEx( (PCREATE_PROCESS_NOTIFY_ROUTINE_EX)PsNotifyRoutine, TRUE ); } return;}NTSTATUS DriverEntry( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegPath){ if (!DriverObject || !RegPath) { return STATUS_UNSUCCESSFUL; } DriverObject->DriverUnload = DriverUnload; NTSTATUS status = PsSetCreateProcessNotifyRoutineEx( (PCREATE_PROCESS_NOTIFY_ROUTINE_EX)PsNotifyRoutine, FALSE ); if (!NT_SUCCESS(status)) { DbgPrint("Failed in create notify routine\n"); } return status;}VOID PsNotifyRoutine( _Inout_ PEPROCESS PEprocess, _In_ HANDLE ProcessId, _Inout_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo){ UNREFERENCED_PARAMETER(ProcessId); if (!CreateInfo) { return; } PCSTR processName = (PCSTR)PsGetProcessImageFileName(PEprocess); if (!processName) { return; } if (_stricmp(processName, "notepad.exe") == 0) { CreateInfo->CreationStatus = STATUS_ACCESS_DENIED; } return;} |
此时,我们拒绝了notepad.exe的进程创建
接下来,写一个多线程测试器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | #include <windows.h>#include <iostream>#include <thread>#include <vector>#include <atomic>#include <chrono>#include <string>std::atomic<int> activeThreads(0);std::atomic<bool> stopFlag(false);void CreateTargetProcess(const std::string& processName, int threadId) { while (!stopFlag) { STARTUPINFOA si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; std::string commandLine = processName; if (CreateProcessA( NULL, const_cast<char*>(commandLine.c_str()), NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) { //The driver will prevent the process from starting // If the driver failed, we terminate the process std::cout << "Thread " << threadId << ": Process created (PID: " << pi.dwProcessId << ")\n"; TerminateProcess(pi.hProcess, 0); //close the handle CloseHandle(pi.hProcess); CloseHandle(pi.hThread); } else { DWORD err = GetLastError(); if (err == ERROR_ACCESS_DENIED) { // This is expected because the driver set the status to ACCESS_DEIED // std::cout << "Thread " << threadId << ": Access denied (expected)\n"; } else { std::cerr << "Thread " << threadId << ": CreateProcess failed. Error: " << err << "\n"; } } } --activeThreads;}// This routine monitors memory usagevoid MonitorResources() { while (!stopFlag || activeThreads > 0) { SYSTEM_INFO sysInfo; GetSystemInfo(&sysInfo); MEMORYSTATUSEX memStatus; memStatus.dwLength = sizeof(memStatus); GlobalMemoryStatusEx(&memStatus); //System token count DWORD handleCount; GetProcessHandleCount(GetCurrentProcess(), &handleCount); std::cout << "\n--- Resource Monitor ---\n" << "Threads: " << activeThreads << "\n" << "Memory Load: " << memStatus.dwMemoryLoad << "%\n" << "Avail Phys: " << (memStatus.ullAvailPhys >> 20) << " MB\n" << "Total Handles: " << handleCount << "\n" << "-----------------------\n"; std::this_thread::sleep_for(std::chrono::seconds(2)); }}int main() { const int NUM_THREADS = 1000; const std::string TARGET_PROCESS = "notepad.exe"; std::thread monitor(MonitorResources); // start the test thread std::vector<std::thread> threads; for (int i = 0; i < NUM_THREADS; ++i) { ++activeThreads; threads.emplace_back(CreateTargetProcess, TARGET_PROCESS, i); } // test for 1800 seconds std::cout << "Starting stress test for 1800 seconds...\n"; std::this_thread::sleep_for(std::chrono::seconds(1800)); stopFlag = true; std::cout << "Stopping test...\n"; // wait for all threads to stop for (auto& t : threads) { t.join(); } monitor.join(); std::cout << "Stress test completed.\n"; system("pause"); return 0;} |
接下来,可以观察到以下典型表现
内存消耗增长:
15分钟内内存使用量增长42.8%
基线值:3.5 GB 峰值:5.0 GB
平均分配速率:约25 MB/分钟

这是终止测试时的截图
多线程测试应用程序终止后,系统总体内存使用率有所下降。但根据PoolMon指标监测,以下标签对应的内核池分配仍持续保持高位:
Icp 完成端口上的I/O完成数据包队列
ObRt 对象引用堆栈跟踪
这些特定标签的持续分配水平表明,内核模式资源在进程解除过程中存在未正确释放的潜在泄漏。
Token标签池分配(代表令牌对象)呈现非线性增长模式。并非每次测试迭代都持续增长。这种间歇性特征表明引用计数错误发生于特定内核执行路径,且可能与异步清理操作有关。
分析
通过启用!obtrace对象引用跟踪功能来监控已创建进程的主令牌,可发现大量令牌对象在其所属进程终止后仍保留引用计数,这将阻止对象的正常释放。
通过WinDbg跟踪,我发现了进程终止路径上的关键差异:
被阻断的进程终止(通过PsSetCreateProcessNotifyRoutineEx)
当通过PsSetCreateProcessNotifyRoutineEx注册的回调在NtCreateUserProcess执行期间返回STATUS_ACCESS_DENIED时,系统会通过PsTerminateProcess触发紧急终止。该操作发生在进程创建的最后阶段。
若在WinDbg中手动将状态覆盖为STATUS_SUCCESS,进程将完成完整初始化流程。这意味着此时所有内核关联对象(EPROCESS、令牌等)均已建立。在NtCreateUserProcess执行完毕后,控制权将返回kernelbase!CreateProcessInternalW,该模块将完成用户模式初始化并激活挂起的线程。
根据Windows子系统架构定义,NtCreateUserProcess会向调用方返回进程和线程句柄。当进程意外终止时,部分关联对象会通过ObfDereferenceObjectsWithTag执行解除引用操作,但令牌对象却未能被正确解除引用。
当令牌对象已完成分配但其所属进程异常终止且未正确释放令牌时,引用计数将无法归零。这将导致ObRt标签内核池分配持续累积。
以下是该问题的示意图。(伪代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | //// This a section of NtCreateUserProcess. // It shows the final phase of creating a process in the kernel.//NTSTATUSNTAPINtCreateUserProcess( _Out_ PHANDLE ProcessHandle, _Out_ PHANDLE ThreadHandle //Skip others...){ // 令牌由 PspReferenceTokenForNewProcess 分配 status = NotifyCallbackArray(Process,...); //在PspInsertThread中被调用,这里简化了示例 if (!NT_SUCCESS(status)) { PsTerminateProcess(Process, status); // 紧急终止 // PsTerminateProcess 不会清理令牌对象 HalPutDmaAdapter((PADAPTER_OBJECT)unk1); ObfDereferenceObjectsWithTag(Process,"PsCr") if(info->unknownsection){ SeDeleteCodeIntegrityOriginClaimForFileObject(); } if(psignal) { ObfDereferenceObjectsWithTag(psignal,"PsJb"); } PspDeleteCreateProcessContext(unk3); if ( DriverContext.ExtraCreateParameter ){ FsRtlFreeExtraCreateParameterList(DriverContext.ExtraCreateParameter); } if ( signal2 ){ HalPutDmaAdapter((PADAPTER_OBJECT)unk2); } return (unsigned int)status; // 令牌对象已完成分配,但未得到正确释放 // 这可能是导致问题的根源 } // 正常操作(例如将句柄返回给CreateProcessInternalW) // 其他操作略}//// 这是 CreateProcessInternalW 函数的部分代码段// 该函数调用 NtCreateUserProcess//BOOLWINAPICreateProcessInternalW( HANDLE hToken, LPCWSTR lpApplicationName, // 其他参数略... PHANDLE hNewToken){ // 其他操作略... status = NtCreateUserProcess(&hProcess, &hThread, ...); if(!NT_SUCCESS(status)){ // 令牌对象已完成分配,引用计数 > 0 // 内核未清理这些对象,导致 ObRt 内存池持续增长 return FALSE;} ResumeThread(hThread); // 正常操作...} |
相较而言,在NtTerminateProcess中
1 2 3 4 5 6 7 8 9 10 11 12 | NTSYSAPI NTSTATUS NtTerminateProcess( HANDLE ProcessHandle, NTSTATUS ExitStatus){ PspTerminateProcess(Process,...); // 后续调用其他函数来正确清理进程令牌 // 通过这种方式,内核能够减少引用计数 // 此流程运作正常} |
结论
这种执行路径的差异可能同时解释了间歇性泄漏模式和ObRt/Icp分配持续存在的现象——紧急终止流程绕过了对象管理器中对引用计数递减至关重要的清理例程。
后记
MSRC的说法
MSRC成功复现了问题,并承认其存在。
但他们认为加载驱动需要管理员权限,所以这个不是重要漏洞,没有修复,但是这个确实是ntoskrnl的问题。
我对他们的评估持保留意见。
为什么此文翻译味重
当时给MSRC交的报告就是英文的,主要是我实在是有点懒,不想完全重写,就直接用翻译器了,请见谅。
致谢
最初发现问题的帖子:[求助]通过PsSetCreateProcessNotifyRoutineEx回调拦截进程导致内核句柄泄漏
关于进程创建的分析:NtCreateProcess逆向分析
感谢你们的分享
欢迎提出意见