首页
社区
课程
招聘
[原创]一个关于ntoskrnl内核内存泄漏问题复现与分析
发表于: 2025-9-4 20:37 573

[原创]一个关于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 usage
void 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.
//
NTSTATUS
NTAPI
NtCreateUserProcess(
    _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
//
 
BOOL
WINAPI
CreateProcessInternalW(
    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逆向分析
感谢你们的分享

欢迎提出意见


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回