调试实战 | 记一次有教益的递归栈查看(续)

缘起

在上篇文章中介绍了在 windbg 中如何查看非常深的调用栈 —— 使用 kN 命令指定栈帧数。kN 虽好,但最多只能查看 0xffff 个栈帧。如果栈帧数量比 0xffff 还多,该如何查看呢?本文将介绍几种查看方法。

在介绍查看方法前,需要对线程栈的特点有个基本的认识。

线程栈的关键特性

  • 线程栈是从高向低扩展的,当向栈上 push 一个值的时候,栈底指针 esp 的值会减小。

  • 函数返回地址会保存到栈上:

    函数 A 调用函数 B 的时候,会把 B 需要的参数根据调用约定放到对应的位置,有可能是通过寄存器传递,也有可能通过栈传递。处理完参数后会执行 call B,而 call 指令可以简单理解为以下两个操作:

    1. 把返回地址(调用函数 B 的下一条指令地址)入栈
    2. 跳转到函数 B 继续执行

    如果函数 B 会调用另外一个函数 C,那么会遵循相同的规律:会把返回地址( call C 后面的地址)入栈,然后跳转到 C 继续执行。当 C 执行结束的时候,CPU 会从栈上把保存的返回地址弹出到 rip 中,这样就可以继续从函数 B 中调用函数 C 的下一条指令继续执行了。

根据以上几点可以得到一个非常重要的结论:如果 A 调用了 BB 又调用了 CC 又调用了 D。那么 B 返回到 A 的地址在线程栈的高处,C 返回到 B 的地址在线程栈的低处,D 返回到 C 的地址在线程栈的最低处。

有了以上的基本认识,就可以使用以下几种方法查看调用栈了。

查看方法

方法1:使用 .kframes 设置默认显示的栈帧数量

​ 增大默认显示数量,这样就可以一次性显示更多的调用栈

方法2:使用 dps,自己识别调用栈

​ 可以灵活高效的从指定的位置开始查找

方法3:使用 k 命令的时候指定 StackPtr

​ 可以从指定位置开始显示调用栈,不必从头开始显示

为了方便验证每种方法的可行性,我写了一个非常简单的递归调用测试程序,为了让调用栈可以更深一些,我修改了工程设置中的堆栈保留大小0x70800000(大概 1.75 GB )。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void Recursive(int depth)
{
if (depth > 0)
{
Recursive(--depth);
}
}

void CallRecursive()
{
Recursive(0x7fffffff);
}

int main()
{
CallRecursive();
}

测试工程可以到这里下载。

接下来依次介绍每种查看方法。

方法1:使用 .kframes 设置默认显示的栈帧数量

windbg 的帮助文档中发现可以通过 .kframes 命令来设置 k 命令默认显示的栈帧数量。但是也不是可以显示无限多个栈帧。

那么通过 .kframes 可以设置的最大栈帧数是多少呢?通过几次尝试,我发现 .kframes 可以接受的最大值是 32 位的带符号整数的最大值,也就是 0x7fffffff(对应的十进制是 2147483647)。

try-to-find-max-kframes-number

但是,如果通过 .kframes 命令把栈帧数设置为 0x7fffffff 后,再执行 k 命令,发现 windbg 会直接提示内存分配失败。

windbg-k-failed-after-kframes-set-to-0x7fffffff

尝试把栈帧数设置为 0x1000000,再执行 k 命令,发现 windbg 的内存占用非常高,高峰期大概消耗了 20GB 的物理内存(下图中的 Working Set 列),经过将近两分钟的努力,最终还是以内存分配失败告终~

windbg-memory-usage-kframe-0x10000000

又试了几个更小的值,发现在我的机器上(24GB 物理内存)设置为 0x1000000 时可以输出结果,但是因为数据量太大了,等了半个多小时也没执行完~

虽然这次 .kframes 没能成功,但这绝对是一个非常值得了解的命令,可以处理绝大多数情况下的线程栈查看问题。

优点:

  1. 操作非常简单
  2. 可以处理绝大多数情况

缺点:

  1. 会影响后续 k 命令默认显示效果(仅限当前调试会话,windbg 重启后会自动失效)
  2. 当调用栈过深的时候,k 命令可能会非常非常非常慢(对于示例程序,半个小时还没执行完)
  3. 内存占用可能会非常高(需要分配内存来显示对应的信息)
  4. 不能解决调用栈过深的问题(受到物理内存的限制)
  5. 很难找到一个合适的值(设置的太大,可能消耗过多的资源,运行慢;设置的太小,调用栈可能显示不全)

方法2:使用 dps,自己识别调用栈

windbg 中可以通过 dps 以指针长度为单位打印出指定内存范围的值,同时会输出匹配的符号。

操作步骤:

  1. 通过 !teb 指令找到栈顶(StackBase)的位置,然后减去一定的值(比如 64kb)得到一个较低的地址 A
  2. 执行 dps A StackBase。如果输出结果中没有包含感兴趣的函数,可以减去一个更大的值,再次执行 dps 并查看输出结果,直到输出结果中包含感兴趣的函数为止。
  3. 根据 dps 的输出内容手动识别调用栈。

实战:

通过 !teb 命令获取栈顶位置(0000002a33800000)然后减去 64KB0x10000,也可以换成其它值,一般情况下 64KB 足够了)得到地址 0000002a337f0000,然后执行 dps 0000002a337f0000 0000002a33800000。或者可以直接直接输入 dps 0000002a33800000-0x10000 0000002a33800000

windbg-dps

根据 dps 的结果可知,已经包含了关键的递归函数 —— TestDeepRecursive!Recursive,可以根据此次 dps 的输出结果手动识别调用栈。拉到输出结果的最下方,可以看到输出结果如下图:

windbg-dps-manual-reconstruct-callstack

从上图可以看到 main 函数,CallRecursive 函数,Recursive 函数。而且与 vs 中的调用栈完美匹配。

view-callstack-in-vs

说明: 输出结果中极有可能包含很多无关的信息(比如上图中的黄色高亮部分),需要仔细甄别。

优点:

  1. 输出结果速度非常快
  2. 非常灵活,强大

缺点:

  1. 需要对线程栈有一定的认识
  2. 需要人肉识别调用栈,有一定难度
  3. 比较依赖调试符号,如果没有调试符号,只根据地址信息,很难找出关联关系
  4. 容易出错,因为栈上的内容比较杂,可能包含很多无关的信息

方法3:使用 k 命令的时候指定 StackPtr

前两种方法都有各自的优缺点,可以在前两种方法的基础上使用本方法——使用 k 命令的时候指定正确的 StackPtrwindbg 会自动帮我们识别调用栈。

使用本方法时需要传递一个正确StackPtr(调试 x64 程序时需要传递 rsp,调试 x86 程序时需要传递 ebp,也叫 BasePtr ),也可以同时指定要显示的栈帧数量。

关于 k 命令的帮助文档可以参考下图(截取自 windbg 帮助文档):

windbg-k-command-help

如果传递的 StackPtr 不对,那么输出结果很可能是错误的。比如,我使用一个错误的值执行 k=0x0000002a337ff938 输出结果如下:

output-of-wrong-stackptr-k-command

所以,传递一个正确的 StackPtr 是必须的。那么,该如何获取一个正确的 StackPtr 呢?有两个方法:

  1. 执行 k 命令的时候,最左侧那一列就是 rspx86 程序对应着 ebp)。可以这样处理:先通过 .kframes 设置一个相对合理的值,然后执行 k 命令,等命令执行完,取最后一条输出结果的 rsp 的值,假设是 00000029c3004040,然后执行 k=00000029c3004040 3,就可以继续显示后续的三条调用栈了。重复此过程即可。
    windbg-k-start-at-specific-address
    实际使用的时候,可以尽量每次多显示一些栈帧,如果调用栈非常深,需要重复的次数会很多,但总比不能查看强!

  2. dps 的输出结果中 “猜” 一个 ebp 或者 rsp 的值。说是猜,其实是有规律的。

    2.1 对于 x86 的程序,ebp 保存了调用者的 ebpebp+4 的位置保存了返回地址。
    view-ebp-by-dps

    根据上图可以猜测,一个合法的 ebp 的值是 0x009ef908

    windbg 中输入 k=0x09ef908 0x100,可以得到下图完美的调用栈:k-0x009ef908-0x100

    2.2 对于 x64 的程序,rsp-8 的位置保存了返回地址。可以根据有意义的符号名称对应的最左侧地址值 +8 得到 rsp 的值。

    view-rsp-by-dps

    根据上图可知,一个合法的 rsp 的值是 0x0000002a337ffbd0。在 windbg 中输入 k=0x0000002a337ffbd0 0x100,可以得到下图完美的调用栈:

    k-0000002a337ffbd0-0x100

优点:

  1. 输出效率高,只需要显示关心的栈帧即可
  2. 不用自己识别调用栈,可以像普通的 k 命令一样输出调用栈

缺点:

  1. 需要指定一个合法的 StackPtr,不能随便指定
  2. 需要非常了解 x86/x64 程序的调用栈,这样才能比较快速准确的找到合法的 StackPtr

所以,dps + k=StackPtr [FrameCount] 是最高效,最优雅的解决方案。

说明: 如果知道了一个合法的 StackPtr,也可以先通过 r rsp = StackPtr 修改 rsp 寄存器的值,然后再执行 k 命令显示调用栈。但是这个方法有一个特别不好的地方,rsp 会被修改,后续用到 rsp 寄存器的命令都会受影响。因此,不推荐使用。

总结

  • 使用 .kframes 可以设置默认显示的栈帧数,可以突破默认最多显示 0xffff 个栈帧的限制,但是注意如果设置的值太大,会非常消耗内存
  • dps 可以按指针打印一系列的值,并且会显示匹配的符号。务必记住此命令,非常有用
  • 使用 k 命令时,可以指定 StackPtr 来从指定位置开始显示调用栈
  • 对于 x86 的程序,ebp 保存了调用者的 ebpebp+4 的位置保存了返回地址。非常重要!!!
  • 对于 x64 的程序,rsp-8 的位置保存了返回地址。非常重要!!!

参考资料

https://devblogs.microsoft.com/oldnewthing/20130906-00/?p=3303

https://blog.aaronballman.com/2011/07/reconstructing-a-corrupted-stack-crawl/

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%