接上篇:
分析总结一下用到的方法:
1. 分析栈回溯信息:
通过“!dumpstack”可以打印出当前线程的所有托管和非托管的栈帧信息,输出信息表格里面第一列是ChildEBP,表示子栈帧的EBP地址,也就是子方法的栈底地址;RetAddr表示当前栈帧的的返回地址,最后一列Caller,Callee就是调用者和被调用者,例如这一列:
0115f218 015a0478 (MethodDesc 01554d04 +0x30 TestStackMethod.Program.Main(System.String[])), calling (MethodDesc 01554cf8 +0 TestStackMethod.Program.M1(Int32, Int32, Int32, Int32, Int32, Int32)) |
从这里能看出方法Main调用了M1方法,M1方法的栈底地址为0115f218;M1方法执行完后回到Main方法时的地址为015a0478;调用者是Main方法,被调用者是M1方法,另外还详细写了方法的签名和方法描述符信息。
在这里有几点需要注意:
1.1 命令“!clrstack”输出的表格第一列是Child SP,我个人认为是表示子栈帧的栈顶地址(寄存器ESP或者SP存的就是栈顶地址信息),但是经过我的观察这个栈顶地址不能说是准确的,这个值也不是当前的栈帧或者父栈帧的栈顶地址,所以这里要么是一个SOS的BUG,要么就是这一列表示的其他的意思,所以个人建议不要通过“!clrstack”命令打印出来的Child SP来找堆中的信息。另外在非托管的代码调试中,使用k系列的命令打印堆栈信息也是包含EBP,所以在这里熟悉通过EBP找栈中的信息的话,可以通吃托管和非托管。
1.2 第二列是返回地址,当前在我的机器上调试打印出来的结果是只当前栈帧的信息,但我记得以前也是父栈帧的返回地址(也就是当前方法执行完返回的地址),这个可能是因为版本的原因,读者分析的时候注意留意一下这一列的信息当前栈帧的还是父栈帧的。
2. 分析栈帧
这一节是重点,在这里主要细分成两点:
2.1 确定栈帧的地址范围
首先上一节解释了ChildEBP的信息,但是没有详细说栈的详细内容。当一个线程创建时会默认分配1M的用户地址空间用来存放用户模式下的栈的信息,这个内存地址主要存储参数和局部变量的信息;然后线程开始运行,从调用者(caller)到被调用者(callee)(或者说一个函数调另一个函数,一个方法调另一个方法),在进入调用者后首先是给寄存器EBP和ESP赋值,分别表示当前方法的栈底和栈顶地址,如果当前方法的变量写入超出这个地址范围或者写到了特定的位置,这个就是非常著名的栈溢出。从调用者进入被调用者,EBP和ESP被重新赋值,分别是当前被调用者的栈底和栈顶。调用者和被调用者的栈地址的范围是连着的,而且是从高到低的地址走向,所以大致情况如图:
另外可以通过“!teb”命令找到当前线程的栈地址范围:
0:000> !teb TEB at 00f8d000 ExceptionList: 0115f250 StackBase: 01160000 StackLimit: 0115d000 SubSystemTib: 00000000 FiberData: 00001e00 ArbitraryUserPointer: 00000000 Self: 00f8d000 EnvironmentPointer: 00000000 ClientId: 000012b0 . 00001b5c RpcHandle: 00000000 Tls Storage: 012fc8d0 PEB Address: 00f8a000 LastErrorValue: 1150 LastStatusValue: c0000139 Count Owned Locks: 0 HardErrorMode: 0 |
StackBase的地址信息是当前线程的栈的起始地址,StackLimit是终止地址,当线程分配的到栈里面的信息超过这个终止地址时,就是著名的StackOverflowException。
2.2 为什么从栈的地址范围找返回地址
这个其实是一个很好的技巧,因为每调用一个方法或者函数都会用到汇编指令call,在调用call的时候,其实CPU不仅仅跳转到被调用者的地址,CPU还做了额外的事情: 将从被调用者返回调用者时需要执行指令地址压入栈中,这时候ESP减去4,这也是为什么这时候可以从调用者的栈内存范围中找到这个返回地址。在找到这个返回地址后,就能知道一些参数相关的信息就在返回地址的高地址处了。
这其实是利用了另一知识点:调用约定(call convention)。调用约定是调用方法或者函数进行参数传递的一种方式,调用约定一般有三种:
a: __cdecl (Cdeclaration),参数从右到左入栈,从被调用者返回后通过更改ESP的值达到堆栈的平衡(看不懂后面一句话不重要)
b: PASCAL: 参数从左到右入栈,函数或方法内进行堆栈平衡
c: __stdcall: 参数从右到左入栈,函数或方法内进行堆栈平衡
假设一共有三个参数p1, p2和p3,调用test(p1, p2, p3),在不考虑寄存器的因素,可以简单的归纳在调用者的内部汇编大致为:
__cdecl | Pascal | __stdcall |
push p3 push p2 push p1 call test add esp, 0c | push p1 push p2 push p3 call test | push p3 push p2 push p1 call test |
因为call会push return addr,所以上表可以看成如下:
__cdecl | Pascal | __stdcall |
push p3 push p2 push p1 push retaddr | push p1 push p2 push p3 push retaddr | push p3 push p2 push p1 push retaddr |
所以在栈中找到retaddr,就成功了一小半,因为在retaddr的高地址处可能就含有参数信息。
在这一步,如果找不到当前栈帧的栈内存的地址范围也没关系,前面不是描述了当前线程的栈地址范围的查找方法,利用这个地址范围同样可以找返回地址(有省略):
0:000> !teb TEB at 00f8d000 ExceptionList: 0115f250 StackBase: 01160000 StackLimit: 0115d000 SubSystemTib: 00000000 FiberData: 00001e00 ArbitraryUserPointer: 00000000 Self: 00f8d000 EnvironmentPointer: 00000000 ClientId: 000012b0 . 00001b5c RpcHandle: 00000000 Tls Storage: 012fc8d0 PEB Address: 00f8a000 LastErrorValue: 1150 LastStatusValue: c0000139 Count Owned Locks: 0 HardErrorMode: 0 0:000> dd 0115d000 01160000 … 0115f200 03303174 000002a6 00000003 0115f2dc 0115f210 00000000 0115f250 0115f238 015a0478 0115f220 00000149 00000004 00000003 00000002 … |
3. 反汇编M1的父方法
前面讲操作的时候,列出了Main方法的反汇编的代码,并简化了过程:
push z push o push p push q set ECX =x set EDX=y push 0x015a0478 |
从这里可以看出JIT做了部分优化,先push的z,然后从左到右把参数压栈,其中还利用了寄存器ECX和EDX存储了前两个参数,看到这里或许大家得到一个结论:C#的JIT编译方法的调用协议是Pascal,其实不一定,JIT编译会根据当前的机器信息编译的IL,有可能是Pascal,也有可能是其他的调用协议方式,只不过当前在我的笔记本上用的是Pascal方式,所以这个C#对应的机器码不是固定的调用协议,每次大家都需要看下反汇编。另外就是很多编译器,包括JIT编译器都会把一些参数放在寄存器中传递,对于JIT,32位模式下寄存器ECX和EDX通常存储第一个和第二个参数的值,在64位的模式下,几乎所有的参数都是在寄存器里面了,这加大了分析的难度。
接下来的一个步骤就没什么要补充了,分享过程已经包含在步骤里面。
最后需要注意的是,我所用的例子都是static方法,所以没有隐式参数(implicit parameter),但是在实例方法的调用时,第一个参数不是方法签名里面列出来的第一个参数,而且调用该实例方法的对象,这一点需要注意一下,不然在分析对应汇编代码的时候会有点摸不着头脑。