如果到目前为止您还没有机会调试优化的x64代码,请不要再等待太久,也不要落后于时代!由于类似x64 fastcall的调用约定加上大量的通用寄存器,在调用堆栈中的任意点查找变量值确实非常困难。
在本文中,我想详细介绍一些我最喜欢的调试优化x64代码的技术。但是在深入研究这些技术之前,让我们先对x64调用约定有一个快速的概述。
x64调用约定
熟悉x86平台上fastcall调用约定的人将认识到与x64调用约定的相似之处。通常,您必须了解x86平台上的多个调用约定,而在x64平台上,目前只有一个。在这种情况下,通过__declspec(naked)调用(当然不包括直接调用)可以实现编码
我不会详细介绍x64呼叫约定的所有细微差别,因此我建议您查看以下链接(http://msdn.microsoft.com/en-us/library/ms794533.aspx). 但是通常,函数的前四个参数是通过寄存器rcx、rdx、r8和r9传递的。如果函数接受四个以上的参数,则这些参数将传递到堆栈上。(熟悉x86 fastcall调用约定的人,其中前两个参数是在ecx和edx中传递的,熟悉这种约定的人会认识到它们的相似之处)。
为了帮助说明x64调用约定是如何工作的,我创建了一些简单的示例代码。虽然代码是人为设计的,与真实世界中的代码相去甚远,但它演示了在实际世界中可能遇到的一些场景。代码如下所示。
#include <stdlib.h> #include <stdio.h> #include <windows.h> __declspec(noinline) void FunctionWith4Params( int param1, int param2, int param3, int param4 ) { size_t lotsOfLocalVariables1 = rand(); size_t lotsOfLocalVariables2 = rand(); size_t lotsOfLocalVariables3 = rand(); size_t lotsOfLocalVariables4 = rand(); size_t lotsOfLocalVariables5 = rand(); size_t lotsOfLocalVariables6 = rand(); DebugBreak(); printf( "Entering FunctionWith4Params( %X, %X, %X, %X ) ", param1, param2, param3, param4 ); printf( "Local variables: %X, %X, %X, %X, %X, %X ", lotsOfLocalVariables1, lotsOfLocalVariables2, lotsOfLocalVariables3, lotsOfLocalVariables4, lotsOfLocalVariables5, lotsOfLocalVariables6 ); } __declspec(noinline) void FunctionWith5Params( int param1, int param2, int param3, int param4, int param5 ) { FunctionWith4Params( param5, param4, param3, param2 ); FunctionWith4Params( rand(), rand(), rand(), rand() ); } __declspec(noinline) void FunctionWith6Params( int param1, int param2, int param3, int param4, int param5, int param6 ) { size_t someLocalVariable1 = rand(); size_t someLocalVariable2 = rand(); printf( "Entering %s( %X, %X, %X, %X, %X, %X ) ", "FunctionWith6Params", param1, param2, param3, param4, param5, param6 ); FunctionWith5Params( rand(), rand(), rand(), param1, rand() ); printf( "someLocalVariable1 = %X, someLocalVariable2 = %X ", someLocalVariable1, someLocalVariable2 ); } int main( int /*argc*/, TCHAR** /*argv*/ ) { // I use the rand() function throughout this code to keep // the compiler from optimizing too much. If I had used // constant values, the compiler would have optimized all // of these away. int params[] = { rand(), rand(), rand(), rand(), rand(), rand() }; FunctionWith6Params( params[0], params[1], params[2], params[3], params[4], params[5] ); return 0; }
将此代码剪切并粘贴到cpp文件中(例如示例.cpp). 我使用Windows SDK(具体是Windows SDK CMD shell)使用以下命令行将此代码编译为C++代码:
cl /EHa /Zi /Od /favor:INTEL64 example.cpp /link /debug
注意/Od开关。这将禁用所有优化。稍后,我将启用最大化优化,这就是乐趣开始的时候!
一旦构建了可执行模块(我的示例.exe),然后可以在调试器中按如下方式启动它:
windbg -Q -c "bu example!main;g;" example.exe
上面的命令将在windbg中启动应用程序,在main()例程上设置一个断点,然后转到该断点。
现在,让我们看一下调用FunctionWith6Params()时堆栈的样子。下图说明了指令指针位于FunctionWith6Params()的代码开头但在prolog代码执行之前的堆栈:
请注意,在本例中,调用者(在本例中为main())在堆栈上为所有六个参数分配了足够的空间,以便使用FunctionWith6Params()函数,即使前四个参数是通过寄存器传入的。堆栈上的额外空间通常被称为寄存器参数的“主空间”。在前面的图中,我已经显示了那些填充了xxxxxxxx的插槽,以表明其中的值在此时实际上是随机的。这是因为调用程序main()没有初始化这些插槽。被调用函数可自行决定将前四个参数存储在该空间中以便于安全保存。这正是在未优化的构建中发生的情况,并且是一个巨大的调试方便,因为如果需要,可以很容易地找到堆栈上前四个参数的内容。另外,windbg堆栈命令,如kb和kv,显示了前几个参数,将报告真实的结果。
综上所述,下面是FunctionWith6Params()中的prolog代码执行后堆栈的样子:
FunctionWith6Params()的prolog程序集代码如下所示:
0:000> uf . example!FunctionWith6Params [c: emplog_entrysample_codeexample.cpp @ 28]: 41 00000001`40015900 mov dword ptr [rsp+20h],r9d 41 00000001`40015905 mov dword ptr [rsp+18h],r8d 41 00000001`4001590a mov dword ptr [rsp+10h],edx 41 00000001`4001590e mov dword ptr [rsp+8],ecx 41 00000001`40015912 push rbx 41 00000001`40015913 push rsi 41 00000001`40015914 push rdi 41 00000001`40015915 sub rsp,50h
您可以看到,前四条指令将堆栈上的前四个参数保存在main()分配的主空间中。然后,prolog代码保存6params()计划在执行期间使用的所有非易失性寄存器。保存的寄存器的状态在返回到调用方之前在函数epilog代码中恢复。最后,prolog代码在堆栈上保留一些空间,在本例中,为0x50字节。
栈顶的这个空间是用来做什么的?首先,为任何局部变量创建空间。在本例中,FunctionWith6Params()有两个。但是,这两个局部变量只占0x10字节。在堆栈顶部创建的其余空间是怎么处理的?
在x64平台上,当代码为调用另一个函数准备堆栈时,它不会像x86代码那样使用push指令将参数放在堆栈上。相反,对于特定函数,堆栈指针通常保持固定不变。编译器检查当前函数中代码调用的所有函数,找到参数数最多的函数,然后在堆栈上创建足够的空间来容纳这些参数。在本例中,FunctionWith6Params()调用printf(),传递8个参数。因为这是参数数最多的被调用函数,编译器在堆栈上创建8个插槽。堆栈上的前四个插槽将是任何函数FunctionWith6Params()调用所使用的主空间。
x64调用约定的一个方便的副作用是,一旦您位于函数的prolog和epilog的括号内,当指令指针位于该函数中时,堆栈指针不会改变。这样就不需要基本指针了,这在x86调用约定中很常见。当FunctionWith6Params()中的代码准备调用子函数时,它只需将前四个参数放入所需的寄存器中,如果参数超过4个,则使用mov指令将其余参数放入分配的堆栈空间,但要确保跳过堆栈上的前四个参数槽。
调试优化的x64代码(噩梦开始)
为什么调试x64优化代码如此棘手?好吧,还记得调用者在堆栈上为被调用者创建的主空间来保存前四个参数吗?原来,调用约定不要求被调用方使用该空间!而且,您可以肯定地打赌,优化后的x64代码将不会使用该空间,除非它对于优化目的是必要的和方便的。此外,当优化的代码确实使用主空间时,它可以使用它来存储非易失性寄存器,而不是函数的前四个参数。
继续使用以下命令行重新编译示例代码:
cl /EHa /Zi /Ox /favor:INTEL64 example.cpp /link /debug
注意/Ox开关的用法。这将启用最大优化。调试符号仍处于打开状态,因此我们可以轻松调试优化的代码。始终在启用调试信息的情况下生成发布产品,以便可以调试发布版本!
让我们看看FunctionWith6Params()的prolog程序集代码是如何变化的:
41 00000001`400158e0 mov qword ptr [rsp+8],rbx 41 00000001`400158e5 mov qword ptr [rsp+10h],rbp 41 00000001`400158ea mov qword ptr [rsp+18h],rsi 41 00000001`400158ef push rdi 41 00000001`400158f0 push r12 41 00000001`400158f2 push r13 41 00000001`400158f4 sub rsp,40h 41 00000001`400158f8 mov ebx,r9d 41 00000001`400158fb mov edi,r8d 41 00000001`400158fe mov esi,edx 41 00000001`40015900 mov r12d,ecx
优化后的代码明显不同!让我们逐项列出以下更改:
·函数使用堆栈上的home空间,但是,它不在那里存储前四个参数。相反,它使用空间来存储一些非易失性寄存器,它必须稍后在epilog代码中恢复。这个经过优化的代码将使用更多的处理器寄存器,因此它必须保存更多的非易失性寄存器。
·它仍然将三个非易失性寄存器与存储在主空间中的其他三个寄存器一起推送到堆栈中以安全保存。
·然后在堆栈上创建空间。但是,它的空间比未优化代码中的空间小,并且只有0x40字节。这是因为优化的代码使用寄存器来表示局部变量someLocalVariable1和someLocalVariable2。因此,它只需要为调用函数所需的8个插槽创建空间,printf()是最大数量的参数。
·然后,它将前四个参数存储到非易失性寄存器中,而不是存储在主空间中。(不要指望这种行为。优化后的函数不能复制rcx、rdx、r8和r9的内容。这完全取决于代码的结构)
现在,在第一次printf()调用之后,单步执行FunctionWith6Params()到源行。我的机器上printf()调用生成的输出如下:
输入6个参数的函数(29、4823、18BE、6784、4AE1、3D6C)
windbg中stack命令的一个常见版本是kb,它还显示帧中每个函数的前几个参数。实际上,它显示堆栈的前几个位置。命令的输出如下所示:
0:000> kb RetAddr : Args to Child : Call Site 00000001`4001593b : 00000000`00004ae1 00000000`00004823 00000000`000018be 00000000`007e3570 : example!FunctionWith6Params+0x6a [c: emplog_entrysample_codeexample.cpp @ 37] 00000001`40001667 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000001 : example!main+0x5b [c: emplog_entrysample_codeexample.cpp @ 57] 00000000`76d7495d : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : example!__tmainCRTStartup+0x15b 00000000`76f78791 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0xd 00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x1d
请注意,FunctionWith6Params()的前四个参数并非都与kb命令所显示的完全匹配!当然,这是优化的副作用。在优化的代码中,您根本不能相信kb和kv显示的输出。这就是为什么优化的x64代码如此难以调试的最大原因。请相信我,上面kb输出中的第二个和第三个插槽与FunctionWith6Params()的实际参数值匹配,这纯属运气。这是因为FunctionWith6Params()将非易失性寄存器存储在这些插槽中,而main()恰好在调用FunctionWith6Params()之前将这些值放入这些非易失性寄存器中。
参数侦查——技术1(Down the Call Graph)
现在,让我们来看看在运行x64代码时,在调用堆栈中为函数查找难以捉摸的函数参数的一些技术。我在Functionwith4params()中放置了一个DebugBreak()调用来说明。继续,让代码在windbg中运行,直到它到达这个断点。现在,假设您看到的不是一个实时调试场景,而是一个来自您的客户的转储文件,而这正是您的应用程序崩溃的地方。所以,你看一下堆栈,它看起来像下面这样:
0:000> kL Child-SP RetAddr Call Site 00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint 00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66 00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20 00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97 00000000`0012fee0 00000001`4000168b example!main+0x5b 00000000`0012ff20 00000000`7733495d example!__tmainCRTStartup+0x15b 00000000`0012ff60 00000000`77538791 kernel32!BaseThreadInitThunk+0xd 00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
现在,让我们假设为了找出问题所在,您需要知道FunctionWith6Params()的第一个参数。假设您在控制台输出中没有看到第一个参数。不公平的欺骗!
我要演示的第一种技术是向下挖掘调用图,以找出在输入FunctionWith6Params()后rcx(第一个参数)的内容发生了什么。在本例中,由于参数是32位整数,我们将尝试遵循ecx的内容,这是rcx的下半部分。
让我们先看一下FunctionWith6Params()中的汇编代码,从开始到调用FunctionWith5Params():
0:000> u example!FunctionWith6Params example!FunctionWith6Params+0x97 example!FunctionWith6Params [c: emplog_entrysample_codeexample.cpp @ 41]: 00000001`400158e0 mov qword ptr [rsp+8],rbx 00000001`400158e5 mov qword ptr [rsp+10h],rbp 00000001`400158ea mov qword ptr [rsp+18h],rsi 00000001`400158ef push rdi 00000001`400158f0 push r12 00000001`400158f2 push r13 00000001`400158f4 sub rsp,40h 00000001`400158f8 mov ebx,r9d 00000001`400158fb mov edi,r8d 00000001`400158fe mov esi,edx 00000001`40015900 mov r12d,ecx 00000001`40015903 call example!rand (00000001`4000148c) 00000001`40015908 movsxd r13,eax 00000001`4001590b call example!rand (00000001`4000148c) 00000001`40015910 lea rdx,[example!`string'+0x68 (00000001`40020d40)] 00000001`40015917 movsxd rbp,eax 00000001`4001591a mov eax,dword ptr [rsp+88h] 00000001`40015921 lea rcx,[example!`string'+0x80 (00000001`40020d58)] 00000001`40015928 mov dword ptr [rsp+38h],eax 00000001`4001592c mov eax,dword ptr [rsp+80h] 00000001`40015933 mov r9d,esi 00000001`40015936 mov dword ptr [rsp+30h],eax 00000001`4001593a mov r8d,r12d 00000001`4001593d mov dword ptr [rsp+28h],ebx 00000001`40015941 mov dword ptr [rsp+20h],edi 00000001`40015945 call example!printf (00000001`400012bc) 00000001`4001594a call example!rand (00000001`4000148c) 00000001`4001594f mov edi,eax 00000001`40015951 call example!rand (00000001`4000148c) 00000001`40015956 mov esi,eax 00000001`40015958 call example!rand (00000001`4000148c) 00000001`4001595d mov ebx,eax 00000001`4001595f call example!rand (00000001`4000148c) 00000001`40015964 mov r9d,r12d 00000001`40015967 mov r8d,esi 00000001`4001596a mov edx,ebx 00000001`4001596c mov ecx,eax 00000001`4001596e mov dword ptr [rsp+20h],edi 00000001`40015972 call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ) (00000001`4000100a)
FunctionWith6Params()将ecx复制到r12d中以备以后使用,因为内容必须传递给FunctionWith6Params()主体中的多个函数。请注意,在调用FunctionWith5Params()时,ecx的内容已经复制到r12d和r9d中,但是r9d是易失性的,因此我们必须小心,因为当FunctionWith5Params()调用FunctionWith4Params()时,它可能会在下一次函数调用之前被覆盖。有了这些信息,让我们深入研究迄今为止已执行的FunctionWith5Params()的汇编代码:
0:000> u example!FunctionWith5Params example!FunctionWith5Params+0x20 example!FunctionWith5Params [c: emplog_entrysample_codeexample.cpp @ 32]: 00000001`40015880 mov qword ptr [rsp+8],rbx 00000001`40015885 mov qword ptr [rsp+10h],rsi 00000001`4001588a push rdi 00000001`4001588b sub rsp,20h 00000001`4001588f mov ecx,dword ptr [rsp+50h] 00000001`40015893 mov eax,r9d 00000001`40015896 mov r9d,edx 00000001`40015899 mov edx,eax 00000001`4001589b call example!ILT+10(?FunctionWith4ParamsYAXHHHHZ) (00000001`4000100f)
在调用FunctionWith4Params()时,我们所追求的值现在在eax、edx和r12d中。同样,请注意eax和edx,因为它们是易失性的。但是,由于FunctionWith5Params()没有接触到r12d,所以我们所追求的参数的内容仍然在r12d中
现在,让我们看看函数with4params()中迄今为止执行的代码:
0:000> u example!FunctionWith4Params example!FunctionWith4Params+0x66 example!FunctionWith4Params [c: emplog_entrysample_codeexample.cpp @ 9]: 00000001`400157b0 48895c2408 mov qword ptr [rsp+8],rbx 00000001`400157b5 48896c2410 mov qword ptr [rsp+10h],rbp 00000001`400157ba 4889742418 mov qword ptr [rsp+18h],rsi 00000001`400157bf 57 push rdi 00000001`400157c0 4154 push r12 00000001`400157c2 4155 push r13 00000001`400157c4 4156 push r14 00000001`400157c6 4157 push r15 00000001`400157c8 4883ec50 sub rsp,50h 00000001`400157cc 458be1 mov r12d,r9d 00000001`400157cf 458be8 mov r13d,r8d 00000001`400157d2 448bf2 mov r14d,edx 00000001`400157d5 448bf9 mov r15d,ecx 00000001`400157d8 e8afbcfeff call example!rand (00000001`4000148c) 00000001`400157dd 4898 cdqe 00000001`400157df 4889442448 mov qword ptr [rsp+48h],rax 00000001`400157e4 e8a3bcfeff call example!rand (00000001`4000148c) 00000001`400157e9 4898 cdqe 00000001`400157eb 4889442440 mov qword ptr [rsp+40h],rax 00000001`400157f0 e897bcfeff call example!rand (00000001`4000148c) 00000001`400157f5 4863e8 movsxd rbp,eax 00000001`400157f8 e88fbcfeff call example!rand (00000001`4000148c) 00000001`400157fd 4863f0 movsxd rsi,eax 00000001`40015800 e887bcfeff call example!rand (00000001`4000148c) 00000001`40015805 4863f8 movsxd rdi,eax 00000001`40015808 e87fbcfeff call example!rand (00000001`4000148c) 00000001`4001580d 4863d8 movsxd rbx,eax 00000001`40015810 ff15a24b0100 call qword ptr [example!_imp_DebugBreak (00000001`4002a3b8)]
我们刚找到我们要找的东西!红色突出显示的行显示r12保存在堆栈上,因为FunctionWith4Params()希望重用r12。由于r12是一个非易失性寄存器,它必须将内容保存在某个地方,以便在函数退出之前恢复内容。我们要做的就是找到堆栈上的那个插槽,假设堆栈没有被破坏,我们就可以得到我们的奖品了。
查找插槽的一种技术是从前面所示的堆栈转储中与FunctionWith4Params()框架相关联的子SP值开始,在我的构建中是00000000`0012fd0。使用该值,让我们使用dps命令转储堆栈内容:
0:000> dps 00000000`0012fdd0 L10 00000000`0012fdd0 00000001`00000001 00000000`0012fdd8 00000001`40024040 example!_iob+0x30 00000000`0012fde0 00000000`00000000 00000000`0012fde8 00000001`40002f9e example!_getptd_noexit+0x76 00000000`0012fdf0 00000000`00261310 00000000`0012fdf8 00000001`40001a92 example!_unlock_file2+0x16 00000000`0012fe00 00000000`00000001 00000000`0012fe08 00000000`00004823 00000000`0012fe10 00000000`000041bb 00000000`0012fe18 00000000`00005af1 00000000`0012fe20 00000000`00000000 00000000`0012fe28 00000000`00000000 00000000`0012fe30 00000000`00002cd6 00000000`0012fe38 00000000`00000029 00000000`0012fe40 00000000`00006952 00000000`0012fe48 00000001`400158a0 example!FunctionWith5Params+0x20 [c: emplog_entrysample_codeexample.cpp @ 34]
当我们用红色输入FunctionWith4Params()时,我已经强调了rsp指向的位置。根据上面为FunctionWith4Params()显示的prolog代码,我们可以找到存放奖品的插槽。我在上面用绿色突出显示了它,您可以看到我机器上的值是0x29,它与发送到控制台的printf()值相匹配。此外,我在FunctionWith4Params()的汇编代码中用绿色突出显示了r14d,以指示edx(第二个参数)的内容被复制到了哪里。由于FunctionWith4Params()实际上是堆栈中的顶级函数(由于DebugBreak()不带参数),因此r14d还应该包含我们所追求的值。倾倒r14的内容物证明如下:
0:000> r r14 r14=0000000000000029
总而言之,当您通过调用图向下跟踪寄存器传递的参数值时,请查找将值复制到的位置。具体地说,如果将值复制到非易失性寄存器中,这可能是件好事。如果一个非易失性的函数需要先保存它的内容,那么它必须首先在非易失性堆栈上执行。如果你没有那么幸运,你也许可以跟踪一个寄存器,它被复制到一个在断点处没有被改变。上述两种情况均已显示。
参数侦查——技术2(Up the Call Graph)
我要演示的第二种技术与第一种技术非常相似,只是我们像以前一样以相反的方向遍历堆栈/调用图,也就是说,向上遍历调用图。不幸的是,这些技术没有一个是可靠的,并保证会取得成果。所以,有多种技术可以使用是很好的,即使所有的技术都有可能被淘汰。
我们知道,当函数With6Params()被调用时,ecx包含我们所追求的值。因此,如果我们查看main()的代码,也许可以找到在函数调用之前填充ecx寄存器的源代码。让我们看看main()中的汇编代码:
0:000> u example!main example!main+0x5b example!main [c: emplog_entrysample_codeexample.cpp @ 58]: 00000001`400159b0 48895c2408 mov qword ptr [rsp+8],rbx 00000001`400159b5 48896c2410 mov qword ptr [rsp+10h],rbp 00000001`400159ba 4889742418 mov qword ptr [rsp+18h],rsi 00000001`400159bf 48897c2420 mov qword ptr [rsp+20h],rdi 00000001`400159c4 4154 push r12 00000001`400159c6 4883ec30 sub rsp,30h 00000001`400159ca e8bdbafeff call example!rand (00000001`4000148c) 00000001`400159cf 448be0 mov r12d,eax 00000001`400159d2 e8b5bafeff call example!rand (00000001`4000148c) 00000001`400159d7 8be8 mov ebp,eax 00000001`400159d9 e8aebafeff call example!rand (00000001`4000148c) 00000001`400159de 8bf0 mov esi,eax 00000001`400159e0 e8a7bafeff call example!rand (00000001`4000148c) 00000001`400159e5 8bf8 mov edi,eax 00000001`400159e7 e8a0bafeff call example!rand (00000001`4000148c) 00000001`400159ec 8bd8 mov ebx,eax 00000001`400159ee e899bafeff call example!rand (00000001`4000148c) 00000001`400159f3 448bcf mov r9d,edi 00000001`400159f6 89442428 mov dword ptr [rsp+28h],eax 00000001`400159fa 448bc6 mov r8d,esi 00000001`400159fd 8bd5 mov edx,ebp 00000001`400159ff 418bcc mov ecx,r12d 00000001`40015a02 895c2420 mov dword ptr [rsp+20h],ebx 00000001`40015a06 e8fab5feff call example!ILT+0(?FunctionWith6ParamsYAXHHHHHHZ) (00000001`40001005)
我们看到ecx是从r12d的内容中复制的,这很有帮助,因为r12d是一个非易失性寄存器,如果它被调用堆栈下一级的函数重用,那么它必须被保留,而保留通常意味着在堆栈上放一个副本。如果用堆栈中的值填充ecx会很好,此时我们实际上已经完成了。但在这种情况下,我们只需要重新开始向下的旅程。
我们不用看太远。让我们再来看看函数with6params()的prolog代码:
example!FunctionWith6Params [c: emplog_entrysample_codeexample.cpp @ 41]: 41 00000001`400158e0 mov qword ptr [rsp+8],rbx 41 00000001`400158e5 mov qword ptr [rsp+10h],rbp 41 00000001`400158ea mov qword ptr [rsp+18h],rsi 41 00000001`400158ef push rdi 41 00000001`400158f0 push r12 41 00000001`400158f2 push r13 41 00000001`400158f4 sub rsp,40h 41 00000001`400158f8 mov ebx,r9d 41 00000001`400158fb mov edi,r8d 41 00000001`400158fe mov esi,edx 41 00000001`40015900 mov r12d,ecx
r12在函数with6params()中被重用,这意味着我们的奖品将在堆栈上。首先,让我们使用dps命令查看位于00000000`0012fe80的此帧的子SP:
0:000> dps 00000000`0012fe80 L10 00000000`0012fe80 00000000`00001649 00000000`0012fe88 00000000`00005f90 00000000`0012fe90 00000000`00000029 00000000`0012fe98 00000000`00004823 00000000`0012fea0 00000000`00006952 00000000`0012fea8 00000001`00006784 00000000`0012feb0 00000000`00004ae1 00000000`0012feb8 00000001`00003d6c 00000000`0012fec0 00000000`00000000 00000000`0012fec8 00000000`00000029 00000000`0012fed0 00000000`00006784 00000000`0012fed8 00000001`4000128b example!main+0x5b [c: emplog_entrysample_codeexample.cpp @ 72]
当我们输入FunctionWith6Params()时,我用红色突出显示了rsp指向的插槽。此时,遍历汇编代码并找到存储值的插槽是一件简单的事情。我在上面用绿色突出显示了它。
参数侦察——技术3(Inspecting Dead Space)
我要演示的最后一个技巧涉及到更多的技巧,包括查看堆栈上“死”的或以前使用过的、当前函数调用未使用的插槽。为了演示,假设在DebugBreak()被命中后,我们需要知道传递给FunctionWith6Params()的param4的内容。让我们再看一下为函数with6params()执行的程序集,这次,让我们遵循r9d,第四个参数:
0:000> u example!FunctionWith6Params example!FunctionWith6Params+0x97 example!FunctionWith6Params [c: emplog_entrysample_codeexample.cpp @ 41]: 00000001`400158e0 mov qword ptr [rsp+8],rbx 00000001`400158e5 mov qword ptr [rsp+10h],rbp 00000001`400158ea mov qword ptr [rsp+18h],rsi 00000001`400158ef push rdi 00000001`400158f0 push r12 00000001`400158f2 push r13 00000001`400158f4 sub rsp,40h 00000001`400158f8 mov ebx,r9d 00000001`400158fb mov edi,r8d 00000001`400158fe mov esi,edx 00000001`40015900 mov r12d,ecx 00000001`40015903 call example!rand (00000001`4000148c) 00000001`40015908 movsxd r13,eax 00000001`4001590b call example!rand (00000001`4000148c) 00000001`40015910 lea rdx,[example!`string'+0x68 (00000001`40020d40)] 00000001`40015917 movsxd rbp,eax 00000001`4001591a mov eax,dword ptr [rsp+88h] 00000001`40015921 lea rcx,[example!`string'+0x80 (00000001`40020d58)] 00000001`40015928 mov dword ptr [rsp+38h],eax 00000001`4001592c mov eax,dword ptr [rsp+80h] 00000001`40015933 mov r9d,esi 00000001`40015936 mov dword ptr [rsp+30h],eax 00000001`4001593a mov r8d,r12d 00000001`4001593d mov dword ptr [rsp+28h] ,ebx 00000001`40015941 mov dword ptr [rsp+20h],edi 00000001`40015945 call example!printf (00000001`400012bc) 00000001`4001594a call example!rand (00000001`4000148c) 00000001`4001594f mov edi,eax 00000001`40015951 call example!rand (00000001`4000148c) 00000001`40015956 mov esi,eax 00000001`40015958 call example!rand (00000001`4000148c) 00000001`4001595d mov ebx,eax 00000001`4001595f call example!rand (00000001`4000148c) 00000001`40015964 mov r9d,r12d 00000001`40015967 mov r8d,esi 00000001`4001596a mov edx,ebx 00000001`4001596c mov ecx,eax 00000001`4001596e mov dword ptr [rsp+20h],edi 00000001`40015972 call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ) (00000001`4000100a)
注意,r9d首先被移到ebx中。但是,请注意,它将内容复制到堆栈上rsp+0x28的插槽中。这个插槽是什么?它是以下printf()调用的第六个参数。请记住,编译器会查看代码调用的所有函数,并找到具有最大参数数的函数,然后为该函数分配足够的空间。当代码准备调用printf()时,它会将我们所追求的值移动到保留堆栈空间中的第六个参数槽中。但是这些信息有什么用呢?
如果检查FunctionWith6params(),就会发现printf()之后调用的每个函数都不到六个参数。具体地说,对FunctionWith5Params()的调用只使用其中的5个插槽,剩下的3个插槽中只剩下垃圾。这些垃圾其实是我们的宝贝!通过检查代码,可以保证没有人重写rsp+28表示的插槽。
要找到这个插槽,让我们再次从获取我们正在讨论的帧的子SP值开始,如下所示:
0:000> kL Child-SP RetAddr Call Site 00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint 00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66 00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20 00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97 00000000`0012fee0 00000001`4000168b example!main+0x5b 00000000`0012ff20 00000000`7733495d example!__tmainCRTStartup+0x15b 00000000`0012ff60 00000000`77538791 kernel32!BaseThreadInitThunk+0xd 00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
然后我们可以取上面突出显示的值,并在代码中使用相同的偏移量来找到我们的值:
0:000> dd 000000000012fe80+28 L1 00000000`0012fea8 00006784
正如预期的那样,堆栈上的“dead”槽包含我们要查找的值。您可以将该值与控制台上显示的输出进行比较以进行验证。
非易失性寄存器快捷方式
现在我已经向你们展示了在寄存器中找到这些难以捉摸的值背后的理论,让我给你们展示一条捷径,让生活变得轻松一点。快捷方式依赖于.frame命令的/r选项。使用.frame/r时,调试器具有跟踪非易失性寄存器的智能。但与任何技术一样,口袋里总是有多个工具,以防需要使用所有工具来验证结果。
为了演示,让我们考虑前面描述的技术2,在这里我们查找调用图,我们想知道r12在main()调用Functionwith6params()之前是什么。继续,在windbg中重新启动应用程序,并让它运行,直到达到DebugBreak()。现在,让我们看看包含帧编号的堆栈:
0:000> knL # Child-SP RetAddr Call Site 00 00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint 01 00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66 02 00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20 03 00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97 04 00000000`0012fee0 00000001`4000168b example!main+0x5b 05 00000000`0012ff20 00000000`7748495d example!__tmainCRTStartup+0x15b 06 00000000`0012ff60 00000000`775b8791 kernel32!BaseThreadInitThunk+0xd 07 00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
根据前面对main()中的程序集的分析,我们知道FunctionWith6Params()的第一个参数在调用FunctionWith6Params()之前也存储在main()中的非易失性寄存器r12中。现在,看看我们使用.frame/r命令将当前帧设置为4时得到的结果。
0:000> .frame /r 4 04 00000000`0012fee0 00000001`4000168b example!main+0x5b [c: emplog_entrysample_codeexample.cpp @ 70] rax=0000000000002ea6 rbx=0000000000004ae1 rcx=0000000000002ea6 rdx=0000000000145460 rsi=00000000000018be rdi=0000000000006784 rip=0000000140015a0b rsp=000000000012fee0 rbp=0000000000004823 r8=000007fffffdc000 r9=0000000000001649 r10=0000000000000000 r11=0000000000000246 r12=0000000000000029 r13=0000000000000000 r14=0000000000000000 r15=0000000000000000 iopl=0 nv up ei pl nz na pe nc cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202 example!main+0x5b: 00000001`40015a0b 488b5c2440 mov rbx,qword ptr [rsp+40h] ss:00000000`0012ff20=0000000000000000
如您所见,.frame/r显示了在调用FunctionWith6Params()之前在main()中的寄存器内容。当心!使用此命令时,只能信任非易失性寄存器!请务必查看以下链接,以查看哪些寄存器被认为是不稳定的:x64 64位的寄存器使用情况。
.frame/r可以节省您在堆栈上手动查找保存的易失性寄存器的时间。在我的实验中,.frame/r甚至可以在没有符号信息的情况下工作。但是,如果遇到.frame/r崩溃的情况,知道如何手动执行也不会有坏处。
结论
x64调用约定和处理器中丰富的通用寄存器带来了许多优化的机会。然而,当所有这些优化都发挥作用时,它们肯定会使调试变得困难。在简要概述了x64调用约定之后,我演示了三种可以用来查找调用堆栈中各种函数的参数值的技术。我还向您展示了一个快捷方式,可以用来查看调用堆栈中特定帧的非易失性寄存器。我希望您在调试过程中发现这些技术很有用。此外,我敦促您更加熟悉x64呼叫约定的所有细微差别。