调试实战 | 使用 GFlags 与 WinDbg 定位 VS2022 “重复释放” 引发的崩溃

摘要

本文记录了一次独特的调试经历:作为开发利器的 Visual Studio 2022,其在切换调用栈时频繁崩溃。面对这一问题,利用 procdump 自动捕获崩溃转储文件,并通过 WinDbg 初步排查将问题指向堆内存的异常操作,可能是堆损坏或重复释放。为了精准定位,我启用 gflags 工具开启页堆检测,最终成功捕获到首次释放操作的完整调用栈,明确问题根源在于VSDebug!treegrid::CTreeGridItemContainerGenerator::Refresh过程中的内存重复释放。虽然因缺少源码无法直接修复,但通过环境隔离(关闭特定程序)避免了问题复现。此次实战再次证明了 procdumpgflags 等工具在诊断复杂内存问题中的巨大价值,也提醒我们即使面对没有源码的“黑盒”组件,系统化的调试方法依然能指引我们找到问题的本质。

缘起

最近,用 vs2022 在调试的时候,切换调用栈,会有很大概率崩溃。一次两次就忍了,不停的崩溃就有点说不过去了。 话不多说,先放张动图看看 vs2022 是怎么崩溃的。

vs2022崩溃

好在我已经设置了 procdump 为事后调试器,每当有进程崩溃的时候,都会在 d:\dumps\ 目录下保存一份转储文件。下图是最近保存的一些转储文件(已经清理过几次了)。

recent-dump-files

初步分析

老规矩,使用 windbg 打开对应的转储文件,先无脑 !analyze -v 一波,没看到有用的信息。执行 k 命令查看调用栈,如下图:

delete-exception

看到熟悉的 delete 基本就猜到是堆出问题了,或者是堆破坏或者是重复释放。如果能找到 delete 的地址,然后使用地址相关命令(比如, !address 或者 !heap -x addr 命令)应该是可以看到一些信息的。那么该如何找到这个地址呢?

查找关键地址

64 位程序一般是 __stdcall,一般第一个参数是通过 rcx 传递的,但是寄存器的值随着调用其它函数是会改变的,除非保存到栈上过。为了查看传递给 delete 的参数,找到调用它的栈帧。找到栈帧 10VSDebug!operator delete+0x9)对应的返回地址(00007fff98f13082),使用 ub 查看对应的反汇编。查看是否有保存 rcx 的操作,没有的话,继续向调用方向找(当然也可以向被调用方向查找是否有保存 rcx 的操作)。直到找到栈帧 12 的返回值地址,

12 0000002899dad690 00007fff99103111 VSDebug!CClassFactory<CRefCount>::Release+0x27

使用 ub 00007fff99103111 L28(为什么 L28,因为好截图)

find-the-addres-being-deleted

从图中可知,rcx 来自 rbx,而 rbx 的值被保存到当前栈帧(栈帧 13rsp+0x40 的位置上过。

执行 dq 0000002899dad6c0 + 0x40 L2 然后执行 !address 0000014185ce4360,然后执行 !heap -x 0x14185ce4360,可以发现这个地址确实是 free 的。如下图:

verify-the-adress-has-been-deleted

虽然这里的值不是 100% 靠谱,但是也能在一定程度上证实我们的猜测。其实,解决堆相关问题,可以使用神器 gflags,可以在尽可能早的时候把问题暴露出来。

设置 gflags

打开 gflags.exe,切换到 Image File 页,在 Image:(TAB to refresh) 后面的编辑框内输入 vs 的进程名 devenv.exe,然后按 TAB 键。可以无脑勾选跟堆相关的所有选项。每一项的具体意义可以询问 AI,给出的解释比较靠谱。

set-gflags-for-devenv

当然,也可以通过命令行执行,最终都是操作注册表(下表摘自微软官方文档):

设置类型 注册表位置
系统级设置(注册表) HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\GlobalFlag
特定程序的设置(“映像文件”)适用于计算机的所有用户。 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\GlobalFlag
特定程序的无提示退出设置(“无提示进程退出”)适用于计算机的所有用户。 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\ImageFileName
计算机的所有用户的图像文件的页堆选项 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\PageHeapFlags
用户模式堆栈跟踪数据库大小 (tracedb HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\StackTraceDatabaseSizeInMb
为图像文件创建用户模式堆栈跟踪数据库(ust、0x1000) Windows 将映像文件名添加到 USTEnabled 注册表项的值(HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\USTEnabled)。
在可能的情况下大型页加载映像 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\UseLargePages。
特殊池(内核特殊池标记) HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PoolTag
验证开始/验证结束 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PoolTagOverruns。 “ 验证开始 ”选项将值设置为 0。 “ 验证结束 ”选项将值设置为 1。
映像文件的调试器 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\调试器
对象引用跟踪 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Kernel\ObTraceProcessName ObTracePermanent 和 ObTracePoolTags

配置好后,再次重现问题,打开转储文件,再次进行分析。

再次分析

执行 k 命令,可以发现在 GetParent 函数内部触发了异常。如下图:

callstack-after-turn-on-gflags

执行 .ecxr 查看异常发生时的上下文,如下图:

ecxr-after-turn-on-gflags

第一眼就感觉 rax 的值好特殊啊。看看它的来源,很幸运,在崩溃代码附近(00007fff984e309a)执行 ubu 操作可以找到完整信息,如下图:

virtual-function-call-when-crash

红色高亮部分是非常典型的虚函数调用代码。rax 指向虚表,rbx 是对象地址。非常幸运的是在红框上方,把 rbx 保存到了栈 rsp+40h 的地方。其实,没必要,因为当前上下文是异常发生时的上下文(执行了 .ecxr 命令),直接可以获取 rbx 的值。通过查看栈上的值,也佐证了这个观点。

verify-rbx-by-rsp

拿到对象地址后,执行 !address 000001a053fd31f0!heap -x 000001a053fd31f0

view-heap-address-info

从图中没看出特别明显的错误(尤其注意 Flags 的值)。我们已经使用 gflags 开启页堆,并且开启了 ust,可以通过 !heap -p -a 0x1a053fd31f0 命令查看此地址相关的调用栈,如下图:

heap-p-a-address

从上图可知,在 VSDebug!treegrid::CTreeGridItemContainerGenerator::Refresh 的时候已经触发了 delete 操作,至此,已经可以确认这是个重复释放的问题了,而且第一次释放时的调用栈也很清楚。

虽然问题已经很明确了,但是我堆 f0f0f0f0f0f0f0f0 这个填充模式充满了好奇。

填充模式

我在查看 windbg 帮助文档关于 !heap 部分时,意外发现了它的意义。原来是开启轻型页堆时,内存被释放后,会用此模式填充。

fill-pattern-explanation-from-windbg-help-document

而且,我还发现了其它几个有意思的填充值,赶紧实战验证下,通过上面的 !heap -x 000001a053fd31f0 命令已经得到了对应的 Heap Entry 的地址是 000001a053fd31a0。使用 dd 000001a053fd31a0 L80 命令看一下这段内存数据的值。

view-memory-patttern

解决问题

虽然查到问题了,但是没有代码,也没法解决。但是在整个折腾的过程中发现只有开着某个特定程序的时候,vs 才会崩溃,关闭这个特定程序后 vs 就不再崩溃了。应该是那个程序使用了 UIA 相关接口,做了一些事情,导致 vs 崩溃。

就不再继续折腾了,有时候不是所有问题都要有一个明确的答案,这也算是解决问题的一种方式吧。

亲自动手

我已经把对应的转储文件上传到百度云盘了,感兴趣的小伙伴可以下载,亲自实战一番。

链接: https://pan.baidu.com/s/15Xa-pCeezeHNwNVwJfGzxA 提取码: puv8

参考资料

https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/gflags-details

总结

  • procdump 是收集转储文件的神兵利器,一定要放到自己的武器库里
  • gflags 是解决内存破坏问题的神器,一定要放到自己的武器库里
  • gflags 最终结果是设置注册表,必要时可以手动设置
  • x64 位程序默认调用约定是 __stdcall,第一个参数一般会通过 rcx 传递
BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%