Windows 11 句柄审查与泄漏定位教程


Windows 11 句柄审查与泄漏定位教程

纯 PowerShell 通用版

适用系统:Windows 10 / Windows 11
适用环境:Windows PowerShell 5.1、PowerShell 7
建议:使用“管理员身份”打开 PowerShell


一、句柄是什么

Windows 程序访问文件、注册表、进程、线程、设备等系统对象时,通常通过“句柄”完成访问。

常见句柄对象包括:

  • 文件和目录
  • 注册表项
  • 进程和线程
  • 事件对象 Event
  • 互斥体 Mutex
  • 信号量 Semaphore
  • 管道 Pipe
  • 共享内存 Section
  • 访问令牌 Token
  • 网络和设备对象

正常程序会打开对象、使用对象,再关闭句柄。程序持续创建句柄却没有正确关闭,就可能出现句柄泄漏。

典型表现:

10:00 Handles=3000
10:05 Handles=5000
10:10 Handles=8000
10:15 Handles=12000

判断句柄泄漏最重要的不是某一时刻的绝对值,而是:

在系统空闲或相同工作负载下,句柄是否持续单向增长,并且长时间不回落。


二、句柄多少才算异常

Windows 没有适用于所有电脑的固定异常阈值。

情况 参考判断
总句柄几万 轻度使用时常见
总句柄十几万 浏览器、IDE、虚拟机、后台软件较多时可能正常
总句柄二三十万 建议审查,但不代表一定泄漏
总句柄持续上涨 比绝对值更值得怀疑
单个普通程序几千句柄 需要结合程序类型判断
单进程数万句柄且持续上涨 高度可疑
同名进程数量不断增加 可能是进程增殖,不一定是单进程泄漏
重启后恢复低位,随后持续上涨 符合泄漏或后台任务堆积特征

浏览器、数据库、虚拟机、杀毒软件、IDE、WebView2 应用本来就可能持有较多句柄,因此不要仅凭“句柄很多”直接结束进程。


三、快速检查系统总量

$processes = Get-Process -ErrorAction SilentlyContinue

[PSCustomObject]@{
    Time      = Get-Date
    Processes = $processes.Count
    Handles   = ($processes | Measure-Object HandleCount -Sum).Sum
    Threads   = (
        $processes |
        ForEach-Object {
            try { $_.Threads.Count } catch { 0 }
        } |
        Measure-Object -Sum
    ).Sum
    MemoryGB  = [math]::Round(
        ($processes | Measure-Object WorkingSet64 -Sum).Sum / 1GB,
        2
    )
} | Format-List

最简总句柄命令:

(
    Get-Process -ErrorAction SilentlyContinue |
    Measure-Object HandleCount -Sum
).Sum

四、单进程句柄排行榜

Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
    [PSCustomObject]@{
        PID      = $_.Id
        Process  = $_.ProcessName
        Handles  = $_.HandleCount
        Threads  = try { $_.Threads.Count } catch { 0 }
        MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
        CPU      = try { [math]::Round($_.CPU, 1) } catch { 0 }
    }
} |
Sort-Object Handles -Descending |
Select-Object -First 30 |
Format-Table -AutoSize

重点观察:

  • 是否有普通程序达到数万句柄;
  • 是否有进程线程数异常高;
  • 是否有进程内存、线程和句柄同步上涨;
  • 是否存在大量同名进程。

五、按程序名称汇总多实例

浏览器、WebView2、Electron、QQ、IDE、网盘等通常会拆分为大量子进程。单个进程不高,不代表程序族合计不高。

Get-Process -ErrorAction SilentlyContinue |
Group-Object ProcessName |
ForEach-Object {
    $group = $_.Group

    [PSCustomObject]@{
        Process   = $_.Name
        Instances = $group.Count
        Handles   = ($group | Measure-Object HandleCount -Sum).Sum
        Threads   = (
            $group |
            ForEach-Object {
                try { $_.Threads.Count } catch { 0 }
            } |
            Measure-Object -Sum
        ).Sum
        MemoryMB  = [math]::Round(
            ($group | Measure-Object WorkingSet64 -Sum).Sum / 1MB,
            1
        )
    }
} |
Sort-Object Handles -Descending |
Select-Object -First 30 |
Format-Table -AutoSize

六、判断句柄泄漏还是进程增殖

单进程句柄泄漏

特征:

同一个 PID
同一个启动时间
HandleCount 持续增长

进程增殖

特征:

单个进程句柄不高
同名进程数量持续增加
总句柄随之上涨

例如:

10:00 WebView2 实例=20
10:30 WebView2 实例=45
11:00 WebView2 实例=80

第二种情况应查明谁不断创建子进程,而不是只盯住某个 PID。


七、持续监控系统总句柄

Ctrl + C 停止。

while ($true) {
    $processes = Get-Process -ErrorAction SilentlyContinue

    $handles = ($processes | Measure-Object HandleCount -Sum).Sum
    $threads = (
        $processes |
        ForEach-Object {
            try { $_.Threads.Count } catch { 0 }
        } |
        Measure-Object -Sum
    ).Sum

    "{0:yyyy-MM-dd HH:mm:ss}  Processes={1}  Threads={2}  Handles={3}" -f `
        (Get-Date),
        $processes.Count,
        $threads,
        $handles

    Start-Sleep -Seconds 10
}

正常情况通常会小幅上下波动。持续单向上涨且不回落才更可疑。


八、监控指定进程

假设目标 PID 为 12345

$TargetProcessId = 12345

while ($true) {
    $process = Get-Process -Id $TargetProcessId `
        -ErrorAction SilentlyContinue

    if ($null -eq $process) {
        Write-Host "目标进程已经退出。"
        break
    }

    [PSCustomObject]@{
        Time      = Get-Date
        PID       = $process.Id
        Process   = $process.ProcessName
        Handles   = $process.HandleCount
        Threads   = try { $process.Threads.Count } catch { 0 }
        MemoryMB  = [math]::Round($process.WorkingSet64 / 1MB, 1)
        PrivateMB = [math]::Round($process.PrivateMemorySize64 / 1MB, 1)
    } | Format-Table -AutoSize

    Start-Sleep -Seconds 5
}

确认泄漏时要检查:

  • PID 是否没有变化;
  • 进程启动时间是否没有变化;
  • 句柄是否持续增长;
  • 工作完成后句柄是否仍不释放。

九、比较一分钟内谁增长最快

function Get-HandleSnapshot {
    Get-Process -ErrorAction SilentlyContinue |
    ForEach-Object {
        $startTime = try {
            $_.StartTime.ToString("o")
        }
        catch {
            ""
        }

        [PSCustomObject]@{
            Key       = "$($_.Id)|$startTime"
            PID       = $_.Id
            Process   = $_.ProcessName
            StartTime = $startTime
            Handles   = [int64]$_.HandleCount
            Threads   = try { $_.Threads.Count } catch { 0 }
            MemoryMB  = [math]::Round($_.WorkingSet64 / 1MB, 1)
        }
    }
}

Write-Host "正在采集第一次快照……"
$before = Get-HandleSnapshot

Write-Host "等待 60 秒,请保持正常使用电脑……"
Start-Sleep -Seconds 60

Write-Host "正在采集第二次快照……"
$after = Get-HandleSnapshot

$beforeMap = @{}
foreach ($item in $before) {
    $beforeMap[$item.Key] = $item
}

$after |
ForEach-Object {
    if ($beforeMap.ContainsKey($_.Key)) {
        $old = $beforeMap[$_.Key]

        [PSCustomObject]@{
            PID           = $_.PID
            Process       = $_.Process
            HandlesOld    = $old.Handles
            HandlesNow    = $_.Handles
            HandleDelta   = $_.Handles - $old.Handles
            ThreadsDelta  = $_.Threads - $old.Threads
            MemoryDeltaMB = [math]::Round(
                $_.MemoryMB - $old.MemoryMB,
                1
            )
        }
    }
} |
Sort-Object HandleDelta -Descending |
Select-Object -First 30 |
Format-Table -AutoSize

经验判断:

一分钟句柄增量 说明
几个到几十个 常见正常波动
数百个 建议连续观察
数千个 高度可疑
连续多轮增长 很可能存在泄漏
增长后明显回落 可能是正常批量任务

建议连续执行三至五轮。


十、按程序族比较增长

function Get-GroupedHandleSnapshot {
    Get-Process -ErrorAction SilentlyContinue |
    Group-Object ProcessName |
    ForEach-Object {
        [PSCustomObject]@{
            Process   = $_.Name
            Instances = $_.Count
            Handles   = ($_.Group | Measure-Object HandleCount -Sum).Sum
            Threads   = (
                $_.Group |
                ForEach-Object {
                    try { $_.Threads.Count } catch { 0 }
                } |
                Measure-Object -Sum
            ).Sum
        }
    }
}

$before = Get-GroupedHandleSnapshot
Start-Sleep -Seconds 60
$after = Get-GroupedHandleSnapshot

$beforeMap = @{}
foreach ($item in $before) {
    $beforeMap[$item.Process] = $item
}

$after |
ForEach-Object {
    $old = if ($beforeMap.ContainsKey($_.Process)) {
        $beforeMap[$_.Process]
    }
    else {
        $null
    }

    [PSCustomObject]@{
        Process       = $_.Process
        InstancesNow  = $_.Instances
        InstanceDelta = if ($old) {
            $_.Instances - $old.Instances
        }
        else {
            $_.Instances
        }
        HandlesNow    = $_.Handles
        HandleDelta   = if ($old) {
            $_.Handles - $old.Handles
        }
        else {
            $_.Handles
        }
        ThreadDelta   = if ($old) {
            $_.Threads - $old.Threads
        }
        else {
            $_.Threads
        }
    }
} |
Sort-Object HandleDelta -Descending |
Select-Object -First 30 |
Format-Table -AutoSize

判断:

  • HandleDelta 很高、InstanceDelta=0:更像现有进程内部泄漏;
  • HandleDeltaInstanceDelta 都很高:更像不断产生新进程。

十一、查看进程路径、命令行和父进程

$TargetProcessId = 12345

Get-CimInstance Win32_Process `
    -Filter "ProcessId=$TargetProcessId" |
Select-Object `
    ProcessId,
    ParentProcessId,
    Name,
    ExecutablePath,
    CreationDate,
    CommandLine |
Format-List

父进程详情:

$TargetProcessId = 12345

$process = Get-CimInstance Win32_Process `
    -Filter "ProcessId=$TargetProcessId"

if ($process) {
    $parent = Get-CimInstance Win32_Process `
        -Filter "ProcessId=$($process.ParentProcessId)"

    [PSCustomObject]@{
        ChildPID      = $process.ProcessId
        ChildName     = $process.Name
        ParentPID     = $process.ParentProcessId
        ParentName    = $parent.Name
        ParentPath    = $parent.ExecutablePath
        ParentCommand = $parent.CommandLine
    } | Format-List
}

十二、查看完整进程祖先链

function Get-ProcessParentChain {
    param(
        [Parameter(Mandatory)]
        [int]$ProcessId
    )

    $allProcesses = Get-CimInstance Win32_Process
    $processMap = @{}

    foreach ($process in $allProcesses) {
        $processMap[[int]$process.ProcessId] = $process
    }

    $currentId = $ProcessId
    $level = 0

    while ($processMap.ContainsKey($currentId)) {
        $current = $processMap[$currentId]

        [PSCustomObject]@{
            Level          = $level
            PID            = $current.ProcessId
            ParentPID      = $current.ParentProcessId
            Name           = $current.Name
            ExecutablePath = $current.ExecutablePath
            CommandLine    = $current.CommandLine
        }

        if ($current.ParentProcessId -eq 0) { break }
        if ($current.ParentProcessId -eq $current.ProcessId) { break }

        $currentId = [int]$current.ParentProcessId
        $level++
    }
}

Get-ProcessParentChain -ProcessId 12345 |
Format-Table -Wrap -AutoSize

十三、查看子进程

$TargetProcessId = 12345

Get-CimInstance Win32_Process |
Where-Object {
    $_.ParentProcessId -eq $TargetProcessId
} |
Select-Object `
    ProcessId,
    ParentProcessId,
    Name,
    CreationDate,
    ExecutablePath,
    CommandLine |
Format-Table -Wrap -AutoSize

十四、分析 svchost.exe

svchost.exe 是 Windows 服务宿主,不要看到句柄高就直接结束。

查询指定 PID 承载的服务:

$TargetProcessId = 12345

Get-CimInstance Win32_Service |
Where-Object {
    $_.ProcessId -eq $TargetProcessId
} |
Select-Object `
    Name,
    DisplayName,
    State,
    StartMode,
    ProcessId,
    PathName |
Format-Table -Wrap -AutoSize

查看所有运行中的服务和 PID:

Get-CimInstance Win32_Service |
Where-Object {
    $_.State -eq "Running"
} |
Sort-Object ProcessId, Name |
Select-Object `
    ProcessId,
    Name,
    DisplayName,
    StartMode |
Format-Table -AutoSize

需要重启具体服务时:

Restart-Service -Name "服务名" -Force

不要随意停止关键系统服务。


十五、分析 System 进程

System 通常为 PID 4,代表大量内核和驱动活动。

Get-Process -Id 4 |
Select-Object `
    Id,
    ProcessName,
    HandleCount,
    Threads,
    NonpagedSystemMemorySize64,
    PagedSystemMemorySize64 |
Format-List

如果 System 句柄持续增长,常见嫌疑包括:

  • 存储或 USB 驱动;
  • 网卡、蓝牙、显卡驱动;
  • 文件系统过滤驱动;
  • 杀毒软件;
  • VPN、虚拟网卡;
  • 虚拟机;
  • 磁盘或文件系统异常。

查看运行中的驱动:

Get-CimInstance Win32_SystemDriver |
Where-Object {
    $_.State -eq "Running"
} |
Select-Object `
    Name,
    DisplayName,
    StartMode,
    State,
    PathName |
Sort-Object DisplayName |
Format-Table -Wrap -AutoSize

纯 PowerShell 可以判断 PID 4 是否持续增长,但不能可靠显示每个内核句柄究竟属于哪个驱动对象。


十六、检查系统事件日志

查看最近两小时系统警告和错误:

$startTime = (Get-Date).AddHours(-2)

Get-WinEvent -FilterHashtable @{
    LogName   = "System"
    StartTime = $startTime
    Level     = 2, 3
} -ErrorAction SilentlyContinue |
Select-Object `
    TimeCreated,
    ProviderName,
    Id,
    LevelDisplayName,
    Message |
Format-Table -Wrap

筛选常见磁盘、USB 和驱动问题:

Get-WinEvent -FilterHashtable @{
    LogName   = "System"
    StartTime = (Get-Date).AddHours(-6)
} -ErrorAction SilentlyContinue |
Where-Object {
    $_.ProviderName -match `
        "Disk|Ntfs|stor|USB|Kernel-PnP|DriverFrameworks|WHEA|volmgr"
} |
Select-Object `
    TimeCreated,
    ProviderName,
    Id,
    LevelDisplayName,
    Message |
Format-Table -Wrap

十七、检查进程数字签名

$TargetProcessId = 12345

$process = Get-CimInstance Win32_Process `
    -Filter "ProcessId=$TargetProcessId"

$process |
Select-Object `
    ProcessId,
    Name,
    ExecutablePath,
    CommandLine |
Format-List

if ($process.ExecutablePath) {
    Get-AuthenticodeSignature `
        -FilePath $process.ExecutablePath |
    Select-Object `
        Status,
        StatusMessage,
        SignerCertificate,
        Path |
    Format-List
}

未签名不等于恶意,有签名也不等于没有问题。更应关注异常路径、临时目录运行和名称伪装。


十八、检查 GDI 和 USER 对象泄漏

某些 GUI 程序会泄漏窗口、菜单、画刷、字体、设备上下文等资源。

先加载 API:

Add-Type @"
using System;
using System.Runtime.InteropServices;

public static class GuiResources
{
    [DllImport("user32.dll")]
    public static extern int GetGuiResources(
        IntPtr hProcess,
        int uiFlags
    );
}
"@

查看排行榜:

Get-Process -ErrorAction SilentlyContinue |
ForEach-Object {
    try {
        [PSCustomObject]@{
            PID         = $_.Id
            Process     = $_.ProcessName
            Handles     = $_.HandleCount
            GDIObjects  = [GuiResources]::GetGuiResources($_.Handle, 0)
            USERObjects = [GuiResources]::GetGuiResources($_.Handle, 1)
        }
    }
    catch {
    }
} |
Sort-Object GDIObjects -Descending |
Select-Object -First 30 |
Format-Table -AutoSize

GDI 或 USER 对象持续上涨时,可能出现窗口无法打开、菜单变黑、界面无法刷新、按钮和图标消失等现象。


十九、长期记录系统句柄

先创建目录:

$AuditRoot = Join-Path $env:USERPROFILE "Desktop\HandleAudit"
New-Item -ItemType Directory -Path $AuditRoot -Force | Out-Null

每 30 秒记录一次:

$LogPath = Join-Path `
    $env:USERPROFILE `
    "Desktop\HandleAudit\system_handle_log.csv"

while ($true) {
    $processes = Get-Process -ErrorAction SilentlyContinue

    $row = [PSCustomObject]@{
        Time      = Get-Date
        Processes = $processes.Count
        Handles   = ($processes | Measure-Object HandleCount -Sum).Sum
        Threads   = (
            $processes |
            ForEach-Object {
                try { $_.Threads.Count } catch { 0 }
            } |
            Measure-Object -Sum
        ).Sum
        MemoryGB  = [math]::Round(
            ($processes | Measure-Object WorkingSet64 -Sum).Sum / 1GB,
            2
        )
    }

    $row |
    Export-Csv `
        -Path $LogPath `
        -NoTypeInformation `
        -Encoding UTF8 `
        -Append

    $row | Format-Table -AutoSize
    Start-Sleep -Seconds 30
}

查看最近记录:

Import-Csv $LogPath |
Select-Object -Last 20 |
Format-Table -AutoSize

二十、长期记录句柄最高的进程

$LogPath = Join-Path `
    $env:USERPROFILE `
    "Desktop\HandleAudit\top_process_handles.csv"

while ($true) {
    $time = Get-Date

    Get-Process -ErrorAction SilentlyContinue |
    Sort-Object HandleCount -Descending |
    Select-Object -First 30 |
    ForEach-Object {
        [PSCustomObject]@{
            Time     = $time
            PID      = $_.Id
            Process  = $_.ProcessName
            Handles  = $_.HandleCount
            Threads  = try { $_.Threads.Count } catch { 0 }
            MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
        }
    } |
    Export-Csv `
        -Path $LogPath `
        -NoTypeInformation `
        -Encoding UTF8 `
        -Append

    Write-Host "已记录:$time"
    Start-Sleep -Seconds 30
}

二十一、检查开机启动项、服务和计划任务

开机启动项:

Get-CimInstance Win32_StartupCommand |
Select-Object `
    Name,
    Command,
    Location,
    User |
Sort-Object Name |
Format-Table -Wrap

运行中的服务:

Get-Service |
Where-Object {
    $_.Status -eq "Running"
} |
Sort-Object DisplayName |
Format-Table Status, Name, DisplayName -AutoSize

运行中的计划任务:

Get-ScheduledTask |
Where-Object {
    $_.State -eq "Running"
} |
Select-Object TaskPath, TaskName, State |
Format-Table -AutoSize

二十二、安全验证可疑程序

记录关闭前总句柄:

$before = (
    Get-Process -ErrorAction SilentlyContinue |
    Measure-Object HandleCount -Sum
).Sum

优先尝试正常关闭:

$TargetProcessId = 12345

$process = Get-Process -Id $TargetProcessId `
    -ErrorAction SilentlyContinue

if ($process) {
    $null = $process.CloseMainWindow()
}

必要时按 PID 强制结束:

Stop-Process -Id $TargetProcessId -Force

重新统计:

$after = (
    Get-Process -ErrorAction SilentlyContinue |
    Measure-Object HandleCount -Sum
).Sum

[PSCustomObject]@{
    Before  = $before
    After   = $after
    Reduced = $before - $after
} | Format-List

关闭程序后句柄下降很多,说明它贡献较大,但不一定代表它存在泄漏,也可能只是正常占用较高。


二十三、不能随意结束的系统进程

一般不要直接结束:

System
Registry
smss.exe
csrss.exe
wininit.exe
winlogon.exe
services.exe
lsass.exe
dwm.exe

svchost.exe,应先查 PID 对应服务,不要批量结束。

explorer.exe 可以重启:

Stop-Process -Name explorer -Force
Start-Process explorer.exe

重启 Explorer 只能验证资源管理器或 Shell 扩展问题,不能修复内核驱动泄漏。


二十四、常见异常模式

模式一:单个 PID 句柄持续上涨

可能原因:

  • 文件句柄未关闭;
  • 注册表句柄未关闭;
  • 同步对象泄漏;
  • 插件或扩展泄漏;
  • 程序自身缺陷。

模式二:同名进程越来越多

可能原因:

  • 父程序不断拉起子进程;
  • 子进程超时后未退出;
  • 更新器反复启动;
  • IDE、终端或脚本反复创建工具进程;
  • 浏览器或 WebView2 宿主异常。

模式三:System 句柄上涨

重点怀疑:

  • USB、磁盘或存储驱动;
  • VPN、虚拟网卡;
  • 杀毒软件;
  • 虚拟机;
  • 文件系统过滤驱动。

模式四:svchost 某个 PID 上涨

先查询该 PID 承载的具体服务,再针对服务排查。

模式五:Explorer 句柄上涨

重点怀疑:

  • 右键菜单扩展;
  • 网盘 Shell 扩展;
  • 搜索工具;
  • 压缩软件;
  • 缩略图生成;
  • 无响应的外接磁盘或网络路径。

模式六:浏览器或 WebView2 总量高

重点检查:

  • 标签页和扩展;
  • 后台运行设置;
  • 使用 WebView2 的桌面应用;
  • 是否有子进程残留;
  • 同名进程实例是否持续增长。

二十五、一键审查脚本

将以下脚本保存为 Handle-Audit.ps1

param(
    [int]$ObserveSeconds = 60,
    [int]$Top = 30,
    [string]$OutputDirectory = (
        Join-Path $env:USERPROFILE "Desktop\HandleAudit"
    )
)

Set-StrictMode -Version Latest
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null

function Get-FullProcessSnapshot {
    $time = Get-Date
    $cimProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue
    $cimMap = @{}

    foreach ($item in $cimProcesses) {
        $cimMap[[int]$item.ProcessId] = $item
    }

    Get-Process -ErrorAction SilentlyContinue |
    ForEach-Object {
        $cim = $null
        if ($cimMap.ContainsKey([int]$_.Id)) {
            $cim = $cimMap[[int]$_.Id]
        }

        $creationTime = ""
        if ($cim -and $cim.CreationDate) {
            try {
                $creationTime = ([datetime]$cim.CreationDate).ToString("o")
            }
            catch {
                $creationTime = ""
            }
        }

        [PSCustomObject]@{
            SnapshotTime   = $time
            Key            = "$($_.Id)|$creationTime"
            PID            = $_.Id
            ParentPID      = if ($cim) { $cim.ParentProcessId } else { 0 }
            Process        = $_.ProcessName
            CreationTime   = $creationTime
            Handles        = [int64]$_.HandleCount
            Threads        = try { [int64]$_.Threads.Count } catch { 0 }
            WorkingSetMB   = [math]::Round($_.WorkingSet64 / 1MB, 2)
            PrivateMB      = [math]::Round($_.PrivateMemorySize64 / 1MB, 2)
            ExecutablePath = if ($cim) { $cim.ExecutablePath } else { $null }
            CommandLine    = if ($cim) { $cim.CommandLine } else { $null }
        }
    }
}

function Get-GroupedSnapshot {
    param([Parameter(Mandatory)][array]$Snapshot)

    $Snapshot |
    Group-Object Process |
    ForEach-Object {
        [PSCustomObject]@{
            Process      = $_.Name
            Instances    = $_.Count
            Handles      = ($_.Group | Measure-Object Handles -Sum).Sum
            Threads      = ($_.Group | Measure-Object Threads -Sum).Sum
            WorkingSetMB = [math]::Round(
                ($_.Group | Measure-Object WorkingSetMB -Sum).Sum,
                2
            )
            PrivateMB    = [math]::Round(
                ($_.Group | Measure-Object PrivateMB -Sum).Sum,
                2
            )
        }
    }
}

function Get-SystemSummary {
    param([Parameter(Mandatory)][array]$Snapshot)

    [PSCustomObject]@{
        Time         = Get-Date
        Processes    = $Snapshot.Count
        Handles      = ($Snapshot | Measure-Object Handles -Sum).Sum
        Threads      = ($Snapshot | Measure-Object Threads -Sum).Sum
        WorkingSetGB = [math]::Round(
            ($Snapshot | Measure-Object WorkingSetMB -Sum).Sum / 1024,
            2
        )
        PrivateGB    = [math]::Round(
            ($Snapshot | Measure-Object PrivateMB -Sum).Sum / 1024,
            2
        )
    }
}

Write-Host "正在采集基线快照……" -ForegroundColor Cyan
$before = Get-FullProcessSnapshot
$beforeGrouped = Get-GroupedSnapshot -Snapshot $before
$beforeSummary = Get-SystemSummary -Snapshot $before

$before | Export-Csv `
    (Join-Path $OutputDirectory "01_processes_before.csv") `
    -NoTypeInformation -Encoding UTF8

$before |
Sort-Object Handles -Descending |
Select-Object -First $Top |
Export-Csv `
    (Join-Path $OutputDirectory "02_top_processes_before.csv") `
    -NoTypeInformation -Encoding UTF8

$beforeGrouped |
Sort-Object Handles -Descending |
Export-Csv `
    (Join-Path $OutputDirectory "03_grouped_processes_before.csv") `
    -NoTypeInformation -Encoding UTF8

$beforeSummary |
Export-Csv `
    (Join-Path $OutputDirectory "04_system_summary_before.csv") `
    -NoTypeInformation -Encoding UTF8

$beforeSummary | Format-List

Write-Host "观察 $ObserveSeconds 秒……" -ForegroundColor Yellow
Start-Sleep -Seconds $ObserveSeconds

Write-Host "正在采集第二次快照……" -ForegroundColor Cyan
$after = Get-FullProcessSnapshot
$afterGrouped = Get-GroupedSnapshot -Snapshot $after
$afterSummary = Get-SystemSummary -Snapshot $after

$after | Export-Csv `
    (Join-Path $OutputDirectory "05_processes_after.csv") `
    -NoTypeInformation -Encoding UTF8

$afterGrouped |
Sort-Object Handles -Descending |
Export-Csv `
    (Join-Path $OutputDirectory "06_grouped_processes_after.csv") `
    -NoTypeInformation -Encoding UTF8

$afterSummary |
Export-Csv `
    (Join-Path $OutputDirectory "07_system_summary_after.csv") `
    -NoTypeInformation -Encoding UTF8

$beforeMap = @{}
foreach ($item in $before) {
    $beforeMap[$item.Key] = $item
}

$processGrowth = $after |
ForEach-Object {
    if ($beforeMap.ContainsKey($_.Key)) {
        $old = $beforeMap[$_.Key]

        [PSCustomObject]@{
            PID             = $_.PID
            Process         = $_.Process
            ParentPID       = $_.ParentPID
            HandlesBefore   = $old.Handles
            HandlesAfter    = $_.Handles
            HandleDelta     = $_.Handles - $old.Handles
            ThreadsBefore   = $old.Threads
            ThreadsAfter    = $_.Threads
            ThreadDelta     = $_.Threads - $old.Threads
            PrivateBeforeMB = $old.PrivateMB
            PrivateAfterMB  = $_.PrivateMB
            PrivateDeltaMB  = [math]::Round(
                $_.PrivateMB - $old.PrivateMB,
                2
            )
            ExecutablePath  = $_.ExecutablePath
            CommandLine     = $_.CommandLine
        }
    }
} |
Sort-Object HandleDelta -Descending

$processGrowth |
Export-Csv `
    (Join-Path $OutputDirectory "08_process_handle_growth.csv") `
    -NoTypeInformation -Encoding UTF8

$beforeGroupMap = @{}
foreach ($item in $beforeGrouped) {
    $beforeGroupMap[$item.Process] = $item
}

$groupGrowth = $afterGrouped |
ForEach-Object {
    $old = if ($beforeGroupMap.ContainsKey($_.Process)) {
        $beforeGroupMap[$_.Process]
    }
    else {
        $null
    }

    [PSCustomObject]@{
        Process         = $_.Process
        InstancesBefore = if ($old) { $old.Instances } else { 0 }
        InstancesAfter  = $_.Instances
        InstanceDelta   = if ($old) {
            $_.Instances - $old.Instances
        }
        else {
            $_.Instances
        }
        HandlesBefore   = if ($old) { $old.Handles } else { 0 }
        HandlesAfter    = $_.Handles
        HandleDelta     = if ($old) {
            $_.Handles - $old.Handles
        }
        else {
            $_.Handles
        }
        ThreadDelta     = if ($old) {
            $_.Threads - $old.Threads
        }
        else {
            $_.Threads
        }
    }
} |
Sort-Object HandleDelta -Descending

$groupGrowth |
Export-Csv `
    (Join-Path $OutputDirectory "09_group_handle_growth.csv") `
    -NoTypeInformation -Encoding UTF8

Write-Host "系统变化:" -ForegroundColor Green
[PSCustomObject]@{
    ProcessesBefore = $beforeSummary.Processes
    ProcessesAfter  = $afterSummary.Processes
    ProcessDelta    = $afterSummary.Processes - $beforeSummary.Processes
    HandlesBefore   = $beforeSummary.Handles
    HandlesAfter    = $afterSummary.Handles
    HandleDelta     = $afterSummary.Handles - $beforeSummary.Handles
    ThreadsBefore   = $beforeSummary.Threads
    ThreadsAfter    = $afterSummary.Threads
    ThreadDelta     = $afterSummary.Threads - $beforeSummary.Threads
} | Format-List

Write-Host "同一进程句柄增长排行榜:" -ForegroundColor Green
$processGrowth |
Select-Object -First $Top `
    PID,
    Process,
    HandleDelta,
    ThreadDelta,
    PrivateDeltaMB |
Format-Table -AutoSize

Write-Host "程序族句柄增长排行榜:" -ForegroundColor Green
$groupGrowth |
Select-Object -First $Top |
Format-Table -AutoSize

Write-Host "审查完成。报告目录:" -ForegroundColor Cyan
Write-Host $OutputDirectory

运行:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

.\Handle-Audit.ps1 -ObserveSeconds 120 -Top 40

默认报告目录:

桌面\HandleAudit

主要文件:

01_processes_before.csv
02_top_processes_before.csv
03_grouped_processes_before.csv
04_system_summary_before.csv
05_processes_after.csv
06_grouped_processes_after.csv
07_system_summary_after.csv
08_process_handle_growth.csv
09_group_handle_growth.csv

其中最重要的是:

08_process_handle_growth.csv
09_group_handle_growth.csv

二十六、标准排查流程

第一阶段:确认问题

  1. 记录总句柄;
  2. 记录进程数;
  3. 记录线程数;
  4. 观察总句柄是否持续上涨;
  5. 确认是否同时出现卡顿、窗口无法打开等症状。

第二阶段:定位范围

  1. 查看单进程句柄排行;
  2. 查看同名进程汇总;
  3. 比较一分钟句柄增量;
  4. 比较实例数量增量;
  5. 区分句柄泄漏和进程增殖。

第三阶段:追踪来源

  1. 查看命令行;
  2. 查看可执行文件路径;
  3. 查看父进程;
  4. 查看完整祖先链;
  5. 查看子进程;
  6. 对 svchost 查询具体服务;
  7. 对 System 检查驱动和设备。

第四阶段:隔离验证

  1. 正常退出可疑程序;
  2. 记录句柄下降数量;
  3. 暂停插件或扩展;
  4. 暂停后台服务;
  5. 断开非必要设备;
  6. 重复相同操作验证是否复现。

第五阶段:形成证据

建议保留:

  • 系统总量日志;
  • 进程增长 CSV;
  • 命令行;
  • 父进程链;
  • 程序版本;
  • 触发步骤;
  • 开始和结束时间;
  • 关闭程序前后的句柄差值。

二十七、最终判断标准

通常满足以下多个条件,才能较有把握地判断为句柄泄漏:

  1. 同一个 PID 持续存在;
  2. 句柄数量连续多轮增长;
  3. 负载停止后仍然不回落;
  4. 重复执行某个操作时稳定复现;
  5. 关闭目标程序后总句柄明显下降;
  6. 重启目标程序后句柄恢复低位;
  7. 再次操作后重新开始增长;
  8. 进程实例数量没有同步增加;
  9. 不是正常浏览器标签页、虚拟机或批量任务造成;
  10. 问题与特定插件、版本、设备或服务高度相关。

不够可靠的判断:

它现在有 10000 个句柄。

更可靠的证据链:

同一个 PID 在空闲状态下每分钟稳定增加 1500 个句柄,
停止业务后不释放,
关闭该程序后系统总句柄立即下降 80000,
重新启动该程序后句柄恢复低位,
再次执行相同操作后重新持续上涨。

这才是一条完整、可信的句柄泄漏证据链。


文章作者: 0xdadream
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 0xdadream !
评论
  目录