本帖最后由 tangptr@126.com 于 2025-1-14 10:42 编辑
前言
栈回溯称得上是调试时的神器。调试器若是没有栈回溯的能力,那么接到异常时就很难得知这个调用源是从哪里来的。
x86的原始栈回溯方案
在32位x86上,MSVC编译出来的程序通常会用ebp 寄存器来保存栈帧,从而以链表的形式实现栈回溯。于是乎,就有了最经典的55 8B EC 三字节序列(即push ebp -mov ebp,esp 两条指令)。
当调试器接到异常(如断点)时,会读取线程ebp 的值。这个值就是该栈帧的位置。其中:
- 读取
[ebp+4] 可以获得调用者call 指令的下一条指令的地址。
- 读取
[ebp] 可以获得上一个栈帧的地址。
以此循环,就可以获得整个调用链。如果每个指令地址都能查询到符号,那就能很直观的看明白调用链了。
x64的栈回溯
而到了64位时代,MSVC的ABI选择弃用rbp 寄存器进行栈帧的保存,而是在PE映像中存放栈回溯的信息。这个操作叫做FPO(Frame Pointer Omission)。其实在32位时代也有这个操作,给编译器加上/Oy 即可,但是64位上这个参数就没了。
这么做虽然会增加程序的大小,而且还会使得shellcode中的函数调用无法回溯,但是可以同时去掉保存与删除栈帧所带来的时间与栈空间的开销,为了性能其实还是值得的。
.pdata 节
在.pdata 节中保存着一个RUNTIME_FUNCTION 结构体数组。如果链接时把.pdata 节合并到其他节里,则可以通过PE可选头中的IMAGE_DIRECTORY_ENTRY_EXCEPTION 数据目录定位。这个结构体的定义是:
typedef struct _FPO_DATA
{
ULONG StartAddress;
ULONG EndAddress;
ULONG UnwindInfo;
}RUNTIME_FUNCTION;
以上值皆为相对于映像基址的32位RVA。其中StartAddress 表示函数的起始地址,EndAddress 表示函数的终止地址,UnwindInfo 表示回溯信息。该数组按函数地址排序,因此可以直接用二分查找的方式快速定位回溯信息。
UnwindInfo 指向的是UNWIND_INFO 结构体,限于篇幅不做介绍,详情见MSDN。
不做介绍的更根本的原因是:dbghelp.dll 导出的StackWalk 函数可以帮你实现栈回溯,详见MSDN。
回溯纯汇编写的函数
在OS开发时,往往会用纯汇编实现一些特殊操作(如切换栈)。但是,汇编器可没有高级语言编译器那么聪明,并不能自动推断汇编所写的函数如何回溯。
因此,MASM汇编器提供了一系列的特殊语句。这些语句不会生成可执行的机器指令,但是可以帮助汇编器构造回溯信息。
.allocstack 语句用于标记分配了多少空间的栈。这个语句通常搭配sub rsp,xxx 指令使用。
.pushreg 语句用于标记保存了哪些寄存器。这个语句通常搭配push reg 指令使用。
.pushframe 语句用于标记这个函数被中断/异常调用,栈上有栈帧。这个语句用于标记这个函数栈上有中断/异常栈帧。
.setframe 语句用于标记这个函数有保存栈帧的寄存器。
.endprolog 语句用于标记函数的prologue 行为(即所有的分配栈空间的行为)结束。
因此当使用纯汇编的时候,强烈建议正确结合上述语句构造栈回溯信息从而方便调试。
以上除了.pushframe 以外,使用方式应该都是比较直观的。
回溯被切换的栈
总结
本文简短的讲了一下32位时代的栈回溯方案,然后对比一下64位的栈回溯特点,最后重点讲解写汇编时如何生成栈回溯的信息,并以切换栈进行举例。
|