调试64bit程序所面临的挑战
如果到目前为止,你还没有调试过优化后的64bit代码,为了不落后于时代,那就赶紧去尝试吧。由于64bit下fastcall调用方式和大量通用寄存器的存在,找出任意栈帧中局部变量的值将会变得非常棘手。
本文,我将详述一些我非常喜欢的调试64bit代码的技巧。但在此之前,我们先对64bit下函数的调用方式做一个了解。
X64调用约定
如果你熟悉32位平台下的fastcall调用约定,那么对64位平台的fastcall你也会觉得很熟悉。在32位平台下,你需要掌握多种函数调用约定,而在64位平台下当前仅有fastcall一种调用约定(当然,我排除了通过__declspec(naked)实现的自定义调用方式)。
我将不会深入解释所有x64调用约定的方方面面,但是通常,x64fastcall调用约定下,函数的前4个参数通过寄存器rcx、rdx、r8和r9传递,超过4个的参数将通过栈进行传递(x86 fastcall调用约定下,函数的前两个参数通过寄存器ecx和edx传递,其余通过栈传递)
为了帮助演示x64调用约定如何工作,我编写了如下示例代码。尽管示例代码和工程应用中的场景有差别,但也能演示一些真实场景下的问题。代码如下:
#include <stdlib.h>
#include <stdio.h>
#include <windows.h>
__declspec(noinline)
void
FunctionWith4Params( int param1, intparam2, 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 )\n",
param1, param2, param3, param4 );
printf( "Local variables: %X, %X, %X, %X, %X, %X \n",
lotsOfLocalVariables1, lotsOfLocalVariables2,
lotsOfLocalVariables3, lotsOfLocalVariables4,
lotsOfLocalVariables5, lotsOfLocalVariables6 );
}
__declspec(noinline)
void
FunctionWith5Params( int param1, intparam2, int param3,
int param4, int param5 )
{
FunctionWith4Params( param5, param4, param3, param2 );
FunctionWith4Params( rand(), rand(), rand(), rand() );
}
__declspec(noinline)
void
FunctionWith6Params( int param1, intparam2, 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 )\n",
"FunctionWith6Params",
param1, param2, param3, param4, param5, param6 );
FunctionWith5Params( rand(), rand(), rand(),
param1, rand() );
printf( "someLocalVariable1 = %X, someLocalVariable2 = %X\n",
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文件中,按如下方式构建:
cl /EHa /Zi /Od /favor:INTEL64 example.cpp/link /debug
注意上面的/Od选项用于禁用所有优化。后面我将打开优化(魔法之门)。
生成exe后,按如下方式调试:
Windbg –Q –c “bu example!main;g;”example.exe
上面的命令将在windbg中运行程序,并在main函数处设置断点,然后一直运行到断点。
现在,让我们看一下当函数FunctionWith6Params被调用时栈的内容。
<<<<<<<<见后面链接中原文的图>>>>>>>>>>>>>
注意,尽管函数的前四个参数不需要通过栈传递,但是调用者,此处是main函数,为所有6个参数都分配了栈上空间。为通过寄存器传递的参数分配的栈空间通常叫做对应寄存器参数的“home space”。在上图中,xxxxxxxx所表示的位置内容是随机的,main函数并没有初始化这些地址。被调用函数可以自行决定是否将前4个寄存器参数写入这些位置。事实上,这正是未优化版本的行为,这样调试起来将非常方便,因为你可以从栈上看到函数参数。此外,windbg的栈相关参数如kb、kv将能正确的显示函数的前面几个参数。
下面就是函数FunctionWith6Params的前导代码执行后的栈示意图:
<<<<<<<<见后面链接中原文的图>>>>>>>>>>>>>
FunctionWith6Params的前导代码如下:
41 00000001`40015900 mov dword ptr [rsp+20h],r9d
4100000001`40015905 mov dword ptr[rsp+18h],r8d
4100000001`4001590a mov dword ptr[rsp+10h],edx
4100000001`4001590e mov dword ptr[rsp+8],ecx
4100000001`40015912 push rbx
4100000001`40015913 push rsi
4100000001`40015914 push rdi
4100000001`40015915 sub rsp,50h
可以看到,前4条指令用于将通过寄存器传过来的参数写入由main函数分配的对应的”home space”当中了,然后前导代码将所有自身打算使用的非易失性寄存器保存到栈上(在函数返回之前会从栈上恢复这些寄存器)。最后,前导代码通过操作rsp寄存器预留栈空间,这里是0x50字节。
那么这些预留的栈空间有何用了?首先,用于保存所有局部变量,FunctionWith6Params中有2个局部变量。但是这2个局部变量总大小为0x10字节,那剩下的栈空间用来干嘛了?在X64平台下,当为调用函数准备栈空间的时候,不会像X86平台那样使用push指令来将参数压入栈中。而是,对于一个函数执行的时候,它的栈指针一般不会改变。编译器在编译函数的时候会搜索该函数调用的参数最多的函数具有几个参数,据此来分配函数的栈空间。在这个例子中,FunctionWith6Params中调用printf使用了8个参数,因此编译器为该函数在栈上分配了8个位置。最上面的4个位置将作为所有FunctionWith6Params中调用的函数的home
space。
X64调用约定带来的一个有趣的地方就是一旦你执行完一个函数的前导代码,栈指针在执行结尾代码之前都将保持不变。这样就舍弃了X86平台下需要的基址寄存器。当在函数FunctionWith6Params中进行函数调用时,前4个参数通过寄存器传递,然后使用mov指令将剩下的参数放入已经分配好的栈空间上。
调试优化过的X64代码(噩梦的开始)
为何调试优化过的X64代码如此棘手?还记得调用函数为通过寄存器传递的函数分配的home space吗?在未优化的时候,被调用函数将会把通过寄存器传递的参数写入home space当中,但是,这一步在优化过后就没有了。
继续采用如下的参数编译之前的代码:
cl /EHa /Zi /Ox /favor:INTEL64 example.cpp/link /debug
注意这里使用的是/Ox选项开启了最大优化。仍然打开了调试符号生成来方便我们调试代码。始终开启调试信息来构建你的外发产品,这样你才能调试你的外发产品。
让我们看看FunctionWith6Params的前导代码:
4100000001`400158e0 mov qword ptr[rsp+8],rbx
4100000001`400158e5 mov qword ptr[rsp+10h],rbp
4100000001`400158ea mov qword ptr[rsp+18h],rsi
4100000001`400158ef push rdi
4100000001`400158f0 push r12
4100000001`400158f2 push r13
4100000001`400158f4 sub rsp,40h
4100000001`400158f8 mov ebx,r9d
4100000001`400158fb mov edi,r8d
4100000001`400158fe mov esi,edx
4100000001`40015900 mov r12d,ecx
优化后的代码跟之前的完全不一样了。总结下来变化有:
l 函数使用了栈上的homespace。但是它不是用来存储前4个参数,而是用来保存非易失性寄存器的值,由于优化后的代码将使用更多的寄存器,导致更多的非易失性寄存器被保存。
l 三个寄存器值被保存在栈上,还有3个被保存在home space处。
l 然后分配了栈空间。但是相比非优化代码,仅用了栈上0x40字节。这是因为优化后的代码使用寄存器来表示局部变量someLocalVariable1和someLocalVariable2。因此函数仅需要为调用函数printf函数分配8个空间就可以了。
l 然后代码将前4个参数保存到了非易失寄存器当中,而不是栈上的home space。(不要依赖这个机制,一个被优化过的函数可能不会对rcx、rdx、r8、r9做拷贝,具体依赖于代码结构)
现在单步调试到第一个printf调用之后。我机器上printf的输出是:
Entering FunctionWith6Params( 29, 4823,18BE, 6784, 4AE1, 3D6C )
Windbg的常用栈显示命令是kb,这将显示出函数的前面几个参数。事实上,它显示的是栈的前面小部分的内容。Kb命令的输出是:
0:000> kb
RetAddr : Args to Child : Call Site
00000001`4001593b : 00000000`00004ae100000000`00004823 00000000`000018be 00000000`007e3570 :example!FunctionWith6Params+0x6a [c:\temp\blog_entry\sample_code\example.cpp @37]
00000001`40001667 : 00000000`0000000000000000`00000000 00000000`00000000 00000000`00000001 : example!main+0x5b[c:\temp\blog_entry\sample_code\example.cpp @ 57]
00000000`76d7495d : 00000000`0000000000000000`00000000 00000000`00000000 00000000`00000000 :example!__tmainCRTStartup+0x15b
00000000`76f78791 : 00000000`0000000000000000`00000000 00000000`00000000 00000000`00000000 :kernel32!BaseThreadInitThunk+0xd
00000000`00000000 : 00000000`0000000000000000`00000000 00000000`00000000 00000000`00000000 :ntdll!RtlUserThreadStart+0x1d
注意,FunctionWith6Params的前四个参数和kb显示的参数不完全相同,这就是优化带来的副作用。对于优化过的代码,你不能在相信kv、kb给出的结果了。这就是为什么64bit代码如此难以调试的原因。相信我,上面第3个和第4个参数和实际相符仅仅是因为巧合。
参数推演—技巧1(向下推演)
现在,让我们看一些从64位函数调用栈中找出函数参数的方法。为了演示用,我在函数FunctionWith4Params中加入了一个DebugBreak调用。在windbg中执行程序直到命中断点。现在,假定你所看到的是一个来自用户的dump的场景,你的程序就crash在这个点。你用KL命令看到的栈如下:
0:000> kL
Child-SP RetAddr Call Site
00000000`0012fdc8 00000001`40015816ntdll!DbgBreakPoint
00000000`0012fdd0 00000001`400158a0example!FunctionWith4Params+0x66
00000000`0012fe50 00000001`40015977example!FunctionWith5Params+0x20
00000000`0012fe80 00000001`40015a0bexample!FunctionWith6Params+0x97
00000000`0012fee0 00000001`4000168bexample!main+0x5b
00000000`0012ff20 00000000`7733495dexample!__tmainCRTStartup+0x15b
00000000`0012ff60 00000000`77538791kernel32!BaseThreadInitThunk+0xd
00000000`0012ff90 00000000`00000000ntdll!RtlUserThreadStart+0x1d
现在,我们认为你必须找到FunctionWith6Params的调用参数才能找出问题。(假设你没有看到控制台输出的参数,不许作弊)
我所要演示给你的第一个技巧是观察进入函数FunctionWith6Params后,代码对rcx做了哪些操作。这里,由于参数是32位的,我们将跟踪ecx的值。
让我们从FunctionWith6Params中开始准备调用函数FunctionWith5Params的代码看起:
0:000> u example!FunctionWith6Paramsexample!FunctionWith6Params+0x97
example!FunctionWith6Params[c:\temp\blog_entry\sample_code\example.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
参数推演—技巧2(向上推演)
参数推演—技巧3(死区探查)
参数推演—技巧4(非易失性寄存器)
非易失性寄存器和易失性寄存器列表:
https://msdn.microsoft.com/en-us/library/9z1stfyw.aspx
原文: