返回

WinDbg实战:精确定位默认进程堆创建瞬间

windows

WinDbg 调试:捕获默认进程堆的创建时刻

咱们在捣鼓 Windows 底层机制或者看《Windows Internals》这类书的时候,经常会想亲眼看看某些关键事件是怎么发生的。比如,一个很简单的问题:进程启动时,那个“默认进程堆”(Default Process Heap)到底是在哪个瞬间、被哪个函数调用创建出来的?能不能设个断点,抓个现行,看看当时的调用栈?

听起来不难,对吧?但实际操作起来,可能会遇到点小麻烦。

遇到的问题

假设我们有个最简单的 C++ 程序:

#include <iostream>

int main()
{
    std::cout << "Hello World!\n";
}

现在,我们想用 WinDbg 来捕捉它默认堆的创建过程。常规思路是尽可能早地中断到进程,然后对已知的堆创建 API 下断点。

我的尝试步骤大概是这样的:

  1. 尽早启动调试会话

    • 启动 WinDbg。
    • 用 WinDbg 加载并启动 HelloWorld.exe
    • 在初始断点(Initial Breakpoint)处,设置好异常和模块加载通知,以便更早地控制执行流:
      sxe cpr  // 创建进程事件
      sxe ld * // 任何模块加载事件
      
    • 为了确保我们在最早期的阶段介入,执行 .restart 命令。这通常会中断在进程刚刚创建,连 ntdll 都还没完全初始化好的时候。
      .restart
      
  2. 确认我们确实处在早期阶段
    此时,用 lm 命令看看加载了哪些模块,应该只有我们自己的可执行文件。

    0:000> lm
    start    end        module name
    00100000 00122000   HelloWorld   (deferred)
    

    很好,ntdll.dll 还没影儿呢。

  3. 设置堆创建断点
    我们知道 HeapCreate 这个 API 是用来创建堆的,它通常在 kernelbase.dllkernel32.dll 里。既然 kernelbase 还没加载,我们就下一个未解析断点(Unresolved Breakpoint):

    bu kernelbase!heapcreate
    
  4. 确认当前没有堆

    • 一开始直接用 !heap 命令可能会失败,因为相关的调试扩展可能还没准备好。
      0:000> !heap
      Invalid type information
      
    • 没关系,我们继续执行 (g),直到 ntdll.dll 加载进来。因为设置了 sxe ld *,加载时会自动断下。
      0:000> g
      ModLoad: 77940000 77ae4000   ntdll.dll
      [...] Breakpoint 0 hit
      
    • 现在 ntdll 加载了,再试试 !heap,看看有没有堆存在。
      0:000> !heap
              Heap Address      NT/Segment Heap
      
      太棒了!确实还没有堆。理论上,接下来的某个时刻,kernelbase!HeapCreate 应该会被调用来创建默认堆,我们的断点就该命中了。
  5. 继续执行,意外发生
    我们再次 g,期待 kernelbase!HeapCreate 断点命中。然而,怪事发生了:

    0:000> g
    ModLoad: 76540000 76630000   C:\Windows\SysWOW64\KERNEL32.DLL
    [...] Breakpoint 1 hit // 又是一个模块加载断点
    

    这时,我们还没看到 kernelbase!HeapCreate 的断点被触发,kernelbase.dll 可能也还没加载进来(取决于系统版本和具体加载顺序),但我们再用 !heap 检查一下:

    0:000> !heap
            Heap Address      NT/Segment Heap
    
                012d0000              NT Heap
    

    嘿!一个堆(地址 012d0000)已经悄无声息地出现了!我们的 bu kernelbase!HeapCreate 断点像是睡着了一样,完全没动静。

问题来了:到底该怎样才能准确地在默认进程堆被创建的那一刻中断下来呢?

问题分析:为什么 kernelbase!HeapCreate 不行?

咱们之前的尝试基于一个假设:默认进程堆是通过调用用户模式下熟知的 HeapCreate API 来创建的。这个假设在高层应用开发时通常没问题,但在探究进程启动的底层细节时,可能就不完全准确了。

关键点在于:

  1. ntdll.dll 的核心地位ntdll.dll 是 Windows 用户模式代码与内核模式的中介,包含了大量底层的系统服务例程(以 NtRtl 开头的函数)。它是进程第一个加载且完全初始化的用户模式模块。进程许多基础结构的建立,包括早期的内存管理,都发生在 ntdll 内部,发生在 kernel32.dllkernelbase.dll 这些更高层模块完全准备好之前。
  2. 默认堆的创建时机 :默认进程堆是许多后续初始化操作(比如加载其他 DLL、CRT 初始化等)的基础,它必须在非常非常早的阶段就被创建出来。这个阶段很可能就在 ntdll.dll 自己的初始化流程中。
  3. HeapCreate vs RtlCreateHeapHeapCreate API(位于 kernelbasekernel32)实际上是对 ntdll.dll 中更底层的 RtlCreateHeap 函数的封装。当进程刚启动,ntdll 进行内部初始化时,它很可能会直接调用 RtlCreateHeap 来创建默认堆,而不是绕一圈去调用还没完全准备好的 kernelbase!HeapCreate

因此,我们的 bu kernelbase!HeapCreate 断点之所以没用,是因为默认堆很可能是在 ntdll.dll 初始化期间,通过直接调用 ntdll!RtlCreateHeap 创建的,那个时候 kernelbase.dll 可能还没加载,或者即便加载了,初始化流程也还没走到需要调用它的地步。等 kernelbase!HeapCreate 准备好被“正常”调用时,默认堆早就已经存在了。

解决方案

知道了问题所在,解决思路就清晰了:我们需要把断点设置在更底层的、真正执行创建操作的函数上,并且确保断点在它执行前就绪。

方案一:直接断点在 ntdll!RtlCreateHeap

这是最直接有效的方法。既然我们推断 ntdll 使用 RtlCreateHeap 来创建默认堆,那就直接在这里设断点。

原理:

RtlCreateHeapntdll.dll 导出的核心堆管理函数之一,负责实际的堆创建逻辑,包括内存分配和堆数据结构的初始化。在进程早期初始化阶段,它是创建默认堆的最可能执行者。

操作步骤:

  1. 启动调试并尽早中断 :和之前的尝试一样,使用 WinDbg 启动目标程序,并通过 .restart 命令到达非常早期的断点(最好是在 ntdll.dll 加载之前或刚加载时)。使用 sxe ld:ntdll 可以在 ntdll 加载后自动中断。

    // 在 WinDbg 初始断点处
    sxe ld:ntdll  // 当 ntdll 加载时中断
    g
    
  2. ntdll 加载后立即设断点 :当 ntdll.dll 加载中断后,ntdll 的地址空间和导出函数就已知了。此时,立刻设置断点在 ntdll!RtlCreateHeap。注意,这次用 bp (Software Breakpoint) 或者 bu (Unresolved Breakpoint, 但 ntdll 已加载, 效果类似 bp) 都可以,因为 ntdll 已经加载。通常用 bp 就好。

    // ntdll 加载后,调试器中断
    0:000> lm m ntdll  // 确认 ntdll 已加载
    start    end        module name
    77940000 77ae4000   ntdll      (pdb symbols)
    
    0:000> bp ntdll!RtlCreateHeap
    

    小提示: 最好配置好符号路径(.sympath)并加载 ntdll 的符号(.reload /f ntdll.dll),这样 WinDbg 才能解析 RtlCreateHeap 的地址。如果符号不可用,你可能需要硬编码地址,但不推荐。

  3. 继续执行并等待断点命中 :设置好断点后,输入 g 继续执行。

    0:000> g
    
  4. 断点命中,检查调用栈 :如果一切顺利,调试器应该会在不久后中断在 ntdll!RtlCreateHeap。这时,使用 k 命令查看调用栈:

    Breakpoint 0 hit
    ntdll!RtlCreateHeap:
    77a01234 8bff            mov     edi,edi
    0:000> k
     # ChildEBP RetAddr  
    00 001afabc 77a05678 ntdll!RtlCreateHeap // 我们在这里!
    01 001afae0 77a059cd ntdll!RtlpInitializeHeapSegment+0xXXX
    02 001afb20 779a12ef ntdll!RtlpAllocateHeapInternal+0xXXX 
    03 001afb78 779e05f8 ntdll!LdrpInitializeProcess+0xYYY  <-- 看这里!很可能与进程初始化有关
    04 001afbd0 779e04d8 ntdll!_LdrpInitialize+0xZZZ
    05 001afbe8 779a10ec ntdll!LdrInitializeThunk+0xAAA
    // ... 可能还有更早的启动相关的栈帧 ...
    

    观察调用栈,如果看到诸如 ntdll!LdrpInitializeProcessntdll!_LdrpInitialize 这样的函数,它们是 ntdll 负责进程初始化(包括加载器设置)的关键函数。这就非常有力地证明了,这次 RtlCreateHeap 调用是为了创建进程早期的某个重要堆,极大概率就是默认进程堆。

进阶使用技巧:

  • 确认是“默认”堆RtlCreateHeap 会被用来创建 所有 通过 HeapCreate 或类似机制产生的堆,不仅仅是默认堆。在一个简单进程启动初期,第一次或非常靠前的 RtlCreateHeap 调用极有可能就是创建默认堆的。可以通过检查 RtlCreateHeap 的参数来进一步确认。根据 Windows 版本和架构(x86/x64),参数传递方式不同(栈或寄存器)。
    • 在 x86 上,参数在栈上。第一个参数 Flags(通常包含 HEAP_GROWABLE 等),第二个参数 HeapBase (通常为 NULL,让系统选择地址),第三个 ReserveSize,第四个 CommitSize,第五个 Lock (通常为 NULL),第六个 Parameters (通常为 NULL)。你可以用 dd esp+4 L6 查看栈上的 6 个参数。
    • 在 x64 上,前四个参数在 RCX, RDX, R8, R9 寄存器,后续参数在栈上。用 r rcx, rdx, r8, r9 查看前四个参数。
    • 对于默认堆,Flags 参数通常比较“基础”,比如包含 HEAP_GROWABLE (0x2)。
  • 多次命中 :如果断点被多次命中,可以通过检查调用栈或者观察传递给 RtlCreateHeap 的参数来区分是否是创建默认堆,还是后续CRT或其他库创建它们自己的堆。

方案二:探索 ntdll 初始化过程

如果你想更深入地理解默认堆是在 ntdll 初始化的哪个具体步骤被创建的,可以采用更细致的探索方法。

原理:

默认进程堆的创建是进程初始化大流程中的一个子任务。通过在主要的初始化函数(如 LdrpInitializeProcess)入口处设置断点,然后单步跟踪或有策略地执行,可以观察到整个初始化过程,并在其中找到创建堆的精确位置。

操作步骤:

  1. 准备工作 :同样需要尽早中断进程,确保 ntdll 符号已加载。

  2. 断点在初始化入口 :找到 ntdll 中负责进程初始化的关键函数。LdrpInitializeProcess 是一个非常重要的函数,很多核心初始化工作在这里完成。

    0:000> bp ntdll!LdrpInitializeProcess
    0:000> g
    
  3. 跟踪执行 :当断点命中 ntdll!LdrpInitializeProcess 后,你有几种选择:

    • 单步跟踪 (Stepping) :使用 p (Step Over) 或 t (Step Into) 命令小心地执行。这种方法非常慢,但能看到最详细的执行路径。你需要对汇编代码和 Windows 初始化流程有一定了解才能有效进行。
    • 结合 RtlCreateHeap 断点 :在 LdrpInitializeProcess 命中后,再设置 bp ntdll!RtlCreateHeap。然后继续执行 (g)。这样,当 RtlCreateHeap 被调用时就会停下来,并且调用栈顶层就是 LdrpInitializeProcess 内部的某个位置。
    • 使用 wt (Watch Trace) :如果你对 LdrpInitializeProcess 内部结构有一定了解,或者想观察函数调用流程,可以使用 wt 命令。它可以跟踪执行并显示函数调用和返回,帮助你理解流程,尤其是在寻找可能调用 RtlCreateHeap 的地方。例如,可以在 LdrpInitializeProcess 入口执行 wt 命令,观察后续的函数调用链。
      // 在 ntdll!LdrpInitializeProcess 断点命中后
      0:000> wt
      // ... wt 会输出大量的跟踪信息 ...
      // 观察输出,寻找与 Heap 或 Memory Allocation 相关的调用
      

进阶使用技巧:

  • 反汇编和分析 :结合反汇编 (uf ntdll!LdrpInitializeProcess) 和单步调试,理解 LdrpInitializeProcess 的具体逻辑。哪些检查先做?何时会准备内存?何时会调用 Rtl*Nt* 函数?这需要耐心和一定的逆向工程基础。
  • 条件断点 :如果知道 LdrpInitializeProcess 内部可能调用 RtlCreateHeap 的大概位置,可以在那个调用指令前设置断点,或者设置基于特定寄存器或内存值的条件断点,来缩小跟踪范围。

通过以上方法,特别是方案一,你应该就能成功地在 WinDbg 中捕获到默认进程堆创建的瞬间,并检查当时的调用栈,满足你对 Windows 内部机制的好奇心。记住,探索底层细节往往需要对目标平台有基本的了解,并耐心尝试不同的调试策略。