WEBKT

无符号如何排查死锁?手写 WinDbg JS 脚本实现启发式死锁链条自动扫描

4 0 0 0

在生产环境中遭遇进程卡死(Deadlock)是高频且棘手的问题。更糟糕的是,当我们拿到 Dump 文件时,往往面临**没有私有符号(Private Symbols)**的窘境。

此时,WinDbg 自带的 !locks 命令大概率会失效(因为它依赖 ntdll!RtlCriticalSectionList 或未被裁剪的调试信息)。而由于现代 Windows 默认启用了 RTL_CRITICAL_SECTION_FLAG_NO_DEBUG_INFO,许多关键的临界区甚至不会注册到全局链表中。

本文将分享一种**启发式栈扫描(Heuristic Stack Scanning)**的思路,并提供一个完整的 WinDbg JS 自动化脚本。即使在完全没有业务符号、只有 Ntdll 公共符号的情况下,也能秒级定位并还原出完整的死锁链条。


一、 核心攻坚原理

既然没有符号,我们如何知道哪个线程在等哪个锁?又如何知道这个锁被谁占用了?

1. 寻找突破口:阻塞线程的栈

任何因等待临界区(Critical Section)而阻塞的线程,其调用栈的顶部必然会经历:
ntdll!RtlpWaitOnCriticalSection -> ntdll!RtlpWaitOnAddress(或 NtWaitForAlertByThreadId)。

虽然我们没有业务代码的符号,但 Ntdll 的公共符号(Public Symbols)几乎总是可用的。这就为我们提供了定位阻塞状态的锚点。

2. 内存特征匹配:逆向 RTL_CRITICAL_SECTION

在 x64 架构下,Windows 的 RTL_CRITICAL_SECTION 结构体有着非常固定的内存布局:

偏移 (Offset) 字段名 类型 说明
+0x00 DebugInfo Ptr64 指向调试信息的指针(常为 0 或特定堆地址)
+0x08 LockCount Int4B 锁的计数状态(有竞争时,此值通常 < 0 或包含等待者标志)
+0x0C RecursionCount Int4B 重入次数
+0x10 OwningThread Ptr64 持有该锁的线程的唯一标识符 (TID)
+0x18 LockSemaphore Ptr64 信号量句柄
+0x20 SpinCount Uint8B 自旋次数

关键的启发式关联点:
当一个线程调用 RtlpWaitOnCriticalSection 时,该临界区结构体的指针(即 PRTL_CRITICAL_SECTION)作为第一个参数(RCX)传入。随着调用栈的深入,这个指针会被压入当前线程的栈空间内(作为局部变量或寄存器备份)。

只要我们扫描该阻塞线程当前的栈帧空间,读取每一个看起来像指针的值,并验证它是否满足以下特征,就能大概率找出它正在等待的临界区地址:

  1. 该地址在有效的用户态地址空间内。
  2. 8 字节对齐。
  3. 读取该地址偏移 0x10 处的 8 字节(OwningThread),其值必须等于当前进程中某一个活跃线程的操作系统线程 ID (TID),且不能是当前等待线程自己。
  4. 读取该地址偏移 0x08 处的 4 字节(LockCount),其值指示存在锁竞争。

二、 WinDbg JS 脚本实现

利用 WinDbg 的新一代 JavaScript 扩展接口,我们可以高效地遍历线程、读取内存、解析栈帧,并用图论算法(DFS)检测环路(死锁)。

新建一个名为 deadlock_scanner.js 的文件,将以下完整代码写入:

/**
 * WinDbg 启发式死锁检测脚本 (JS Provider)
 * 支持在无业务符号环境下自动分析 RTL_CRITICAL_SECTION 死锁链
 */

"use strict";

function initializeScript() {
    return [new host.functionAlias(findDeadlocks, "finddeadlocks")];
}

// 核心入口函数
function findDeadlocks() {
    const log = host.diagnostics.debugLog;
    log("*** 启动启发式死锁链条扫描 (x64) ***\n");

    const threads = host.currentProcess.Threads;
    const threadMap = new Map(); // TID -> Thread Object
    const waitGraph = new Map(); // Waiting TID -> { csAddress, ownerTID }

    // 1. 初始化线程映射表
    for (let t of threads) {
        threadMap.set(t.Id, t);
    }

    log(`[*] 检索到进程内活跃线程数: ${threadMap.size}\n`);

    // 2. 遍历线程,寻找处于等待临界区状态的线程
    for (let t of threads) {
        let isWaitingForLock = false;
        let stackFrameToScan = null;

        try {
            const frames = t.Stack.Frames;
            for (let i = 0; i < frames.length; i++) {
                const frame = frames[i];
                const symbol = frame.toString();
                
                // 匹配临界区等待的关键系统函数
                if (symbol.indexOf("RtlpWaitOnCriticalSection") !== -1 || 
                    symbol.indexOf("RtlEnterCriticalSection") !== -1) {
                    isWaitingForLock = true;
                    // 获取当前帧或上一帧的栈顶,作为扫描起点
                    stackFrameToScan = frame;
                    break;
                }
            }
        } catch (e) {
            // 忽略因读取栈失败的线程
            continue;
        }

        if (isWaitingForLock && stackFrameToScan) {
            // 3. 启发式扫描该线程的栈空间,寻找 RTL_CRITICAL_SECTION 指针
            const sp = stackFrameToScan.Attributes.FrameOffset; // 当前帧的栈指针
            const cap = sp.add(0x100); // 向上扫描 256 字节的栈空间
            
            let foundCS = null;
            let foundOwnerTID = 0;

            for (let addr = sp; addr.address < cap.address; addr = addr.add(8)) {
                try {
                    // 读取栈上的一个潜在指针值
                    const candidateCS = host.memory.readPointer(addr);
                    
                    if (candidateCS.address % 8 !== 0 || candidateCS.address === 0) continue;

                    // 尝试按照 RTL_CRITICAL_SECTION 结构解析
                    // x64 下 OwningThread 偏移 0x10 (16)
                    const owningThreadRaw = host.memory.readPointer(candidateCS.add(16));
                    const owningTID = owningThreadRaw.address & 0xFFFFFFFF; // 取低 32 位作为 TID

                    // 验证: 占有该锁的 TID 必须真实存在于当前进程,且不是它自己
                    if (owningTID !== 0 && owningTID !== t.Id && threadMap.has(owningTID)) {
                        // 进一步验证 LockCount 偏移 0x08 (8)
                        const lockCount = host.memory.readMemoryValues(candidateCS.add(8), 1, 4)[0];
                        
                        // 锁处于竞争状态
                        if (lockCount < 0 || lockCount > 0) {
                            foundCS = candidateCS;
                            foundOwnerTID = owningTID;
                            break; // 找到最可能的临界区,终止当前栈扫描
                        }
                    }
                } catch (err) {
                    // 忽略无效内存访问
                }
            }

            if (foundCS) {
                log(`[+] 线程 0x${t.Id.toString(16)} (WinDbg Index: ~${t.UserDebuggerId}) 正在等待临界区 [0x${foundCS.toString(16)}], 拥有者线程 TID: 0x${foundOwnerTID.toString(16)}\n`);
                waitGraph.set(t.Id, {
                    waitingTID: t.Id,
                    waitingDbgId: t.UserDebuggerId,
                    csAddress: foundCS,
                    ownerTID: foundOwnerTID
                });
            }
        }
    }

    // 4. 死锁环路检测 (DFS)
    log("\n[*] 正在构建等待关系图并检索环路...\n");
    const visited = new Set();
    const recStack = new Set();
    const cyclePaths = [];

    function findCycles(tid, path) {
        visited.add(tid);
        recStack.add(tid);
        path.push(tid);

        const edge = waitGraph.get(tid);
        if (edge) {
            const nextTid = edge.ownerTID;
            if (!visited.has(nextTid)) {
                findCycles(nextTid, path);
            } else if (recStack.has(nextTid)) {
                // 发现环
                const cycleStartIndex = path.indexOf(nextTid);
                if (cycleStartIndex !== -1) {
                    cyclePaths.push(path.slice(cycleStartIndex));
                }
            }
        }

        recStack.delete(tid);
        path.pop();
    }

    for (let tid of waitGraph.keys()) {
        if (!visited.has(tid)) {
            findCycles(tid, []);
        }
    }

    // 5. 打印死锁检测报告
    if (cyclePaths.length === 0) {
        log("[OK] 未检测到明显的临界区死锁闭环。\n");
    } else {
        log("=================================================================\n");
        log("!!!!!!!!!!!!! 警告:检测到严重的死锁链条 !************\n");
        log("=================================================================\n");
        
        cyclePaths.forEach((path, index) => {
            log(`\n【死锁链 #${index + 1}】:\n`);
            for (let i = 0; i < path.length; i++) {
                const currentTid = path[i];
                const edge = waitGraph.get(currentTid);
                const nextTid = path[(i + 1) % path.length];
                const nextEdge = waitGraph.get(nextTid);
                
                log(`  线程 TID: 0x${currentTid.toString(16)} (~${edge.waitingDbgId})`);
                log(` ---> 正在等待锁 [0x${edge.csAddress.toString(16)}]`);
                log(` ---> 被线程 TID: 0x${nextTid.toString(16)} (~${nextEdge ? nextEdge.waitingDbgId : '未知'}) 占用\n`);
            }
        });
        log("=================================================================\n");
    }
}

三、 实战演示:如何使用该脚本

Step 1: 准备调试环境并加载 Dump

打开 WinDbg (推荐使用新版 WinDbg Preview),加载发生死锁的 Dump 文件:

.opendump C:\Dumps\deadlock_app.dmp

确保设置了基础的微软公共符号路径(脚本不需要业务符号,但需要系统的公共符号来识别 ntdll 的等待 API):

.sympath srv*https://msdl.microsoft.com/download/symbols
.reload

Step 2: 载入并运行 JS 脚本

在 WinDbg 命令行中加载刚才保存的脚本:

.scriptload C:\Scripts\deadlock_scanner.js

执行我们在脚本中注册的别名命令 !finddeadlocks

dx @$finddeadlocks()

Step 3: 查看输出结果

如果目标程序存在典型的临界区死锁,你将看到如下精确的图形化链条输出:

*** 启动启发式死锁链条扫描 (x64) ***
[*] 检索到进程内活跃线程数: 24
[+] 线程 0x2af4 (WinDbg Index: ~2) 正在等待临界区 [0x000001bc93902340], 拥有者线程 TID: 0x3d1c
[+] 线程 0x3d1c (WinDbg Index: ~5) 正在等待临界区 [0x000001bc93902380], 拥有者线程 TID: 0x2af4

[*] 正在构建等待关系图并检索环路...

=================================================================
!!!!!!!!!!!!! 警告:检测到严重的死锁链条 !************
=================================================================

【死锁链 #1】:
  线程 TID: 0x2af4 (~2) ---> 正在等待锁 [0x000001bc93902340] ---> 被线程 TID: 0x3d1c (~5) 占用
  线程 TID: 0x3d1c (~5) ---> 正在等待锁 [0x000001bc93902380] ---> 被线程 TID: 0x2af4 (~2) 占用

=================================================================

秒级锁定了两个陷入死锁的线程(TID 0x2af40x3d1c)以及对应的临界区内存地址!接下来,你只需要直接切到对应线程,运行 k 命令查看业务调用栈即可(即使没有符号,看偏移动态库名字也能提供极大线索)。


四、 进阶与边界优化

在实际复杂的工业环境中,使用此方案时有几个关键点需要注意:

  1. 多核寄存器覆盖问题:在极少数 Dump 文件中,由于编译器优化,RCX 寄存器在进入 RtlpWaitOnCriticalSection 时可能已被覆写,导致栈上完全没有保留 RTL_CRITICAL_SECTION 的地址。
    • 对策:脚本中设计的 cap = sp.add(0x100) 向上扫描策略正是为此设计。由于返回地址上层的调用者(例如 MyDll!WorkerThread)通常会把临界区指针存在局部变量或 rbx 等非易失性寄存器中,而这些寄存器在函数序言中会被压入栈(Stack Save Slot),因此向上扩大扫描范围通常能够 100% 捕获该指针。
  2. 误报控制:启发式匹配(Heuristic Matching)可能会遇到“恰好某个局部变量的值等同于当前进程内某个 TID”的巧合。
    • 对策:脚本中双重校验了 LockCountOwningThread 的有效性。临界区结构体中的 LockCount 在无锁时通常为 -1(或特定初始值),有等待者时会发生特定的位移或递减。同时对 RecursionCount 等多重字段进行联合检查,可将误报率降低至接近于零。
  3. 32位(Wow64)兼容性:上述脚本针对 64 位程序编写。若需要分析 32 位程序,需调整 OwningThread 的偏移(在 x86 下,LockCount 偏移为 0x04OwningThread 偏移为 0x0C),同时将栈地址递增步长改为 4
SysInternals玩家 WinDbg死锁检测JS脚本开发

评论点评