C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931 在日常分析C++软件异常的日常工作中,大多数情况下我们都是使用Windbg去静态分析dump文件去排查软件异常的,今天我们就来详细地讲一下如何使用Windbg去静态分析dump文件,以供参考。
1、概述
基本大部分软件都内置了异常捕获模块,在软件发生闪退崩溃时,都会弹出相关的提示框,比如PC版的微信在崩溃时,其内置的异常捕获模块会捕获到并生成日志及dump文件,同时会弹出如下的发送错误报告的提示框:
提示框的下方会自动带上崩溃相关的文件,其中最后一个文件就是我们要讲的dump文件,点击确定则会将这些文件发送到腾讯远端的服务器上。腾讯后台的运维人员就会收到通知,然后到服务器上将dump等文件下载下去去分析。
有些软件可能没有上传崩溃日志到服务器的功能,捕捉到异常时会自动将dump文件保存到指定的路径中,事后可以到该路径中取到对应的dump文件。如果客户机器上遇到崩溃,可以和客户联系,让客户帮忙从对应的路径中取来dump文件。
下面就来详细讲一下拿到dump文件之后如何使用Windbg去静态分析dump文件。
2、静态分析dump文件的一般步骤
用Windbg打开dump文件,先使用.ecxr命令切换到异常的上下文,然后根据切换成功后显示的异常类型(ExceptionCode值对应的含义,比如0xC0000005对应的就是AccessViolation内存访问违例)、发生崩溃的那条汇编指令及相关寄存器的值。通过查看汇编指令及各寄存器的值,可以初步判断当前发生的是什么异常,比如C++类空指针、内存访问违例。
接着,使用kn/kv/kp查看函数调用堆栈,看看是调用了什么函数触发的异常。调用堆栈中会显示函数所在模块信息,一般我们需要拿到这些模块的pdb文件,加载pdb文件后函数堆栈中才会显示具体的函数名和行号的。我们需要使用lm命令查看这些模块的时间戳,然后找来这些模块对应的pdb文件,然后将pdb文件的路径设置到windbg中。
然后,根据函数调用堆栈中显示的函数名及行号,对照着源代码去分析发生异常的原因。有时候为了搞清楚发生异常的本质,我们还需要使用IDA查看相关二进制文件的汇编代码,查看一下发生异常的那条汇编指令的上下文,对照着C++源码,看看那条汇编指令为啥会出现异常。
3、分析实例说明
我们为了方便展开讲解,我们特意使用VisualStudio创建了一个基于MFC对话框的exe程序工程,对话框中有个名为button1的按钮,如下所示:
我们在此按钮的响应中添加了一段引发崩溃的测试代码,故意让程序产生崩溃,测试然后拿到崩溃时的dump文件。
具体的测试代码如下所示:
// 添加的一段测试代码
SHELLEXECUTEINFO *pShExeInfo = NULL;
int nVal = pShExeInfo->cbSize; // 通过空指针访问结构体成员,导致崩溃
CString strTip;
strTip.Format( _T("nVal=%d."), nVal );
AfxMessageBox( strTip );
代码中使用到的结构体SHELLEXECUTEINFO 定义如下:
typedef struct _SHELLEXECUTEINFOW
{
DWORD cbSize; // in, required, sizeof of this structure
ULONG fMask; // in, SEE_MASK_XXX values
HWND hwnd; // in, optional
LPCWSTR lpVerb; // in, optional when unspecified the default verb is choosen
LPCWSTR lpFile; // in, either this value or lpIDList must be specified
LPCWSTR lpParameters; // in, optional
LPCWSTR lpDirectory; // in, optional
int nShow; // in, required
HINSTANCE hInstApp; // out when SEE_MASK_NOCLOSEPROCESS is specified
void *lpIDList; // in, valid when SEE_MASK_IDLIST is specified, PCIDLIST_ABSOLUTE, for use with SEE_MASK_IDLIST & SEE_MASK_INVOKEIDLIST
LPCWSTR lpClass; // in, valid when SEE_MASK_CLASSNAME is specified
HKEY hkeyClass; // in, valid when SEE_MASK_CLASSKEY is specified
DWORD dwHotKey; // in, valid when SEE_MASK_HOTKEY is specified
union
{
HANDLE hIcon; // not used
#if (NTDDI_VERSION >= NTDDI_WIN2K)
HANDLE hMonitor; // in, valid when SEE_MASK_HMONITOR specified
#endif // (NTDDI_VERSION >= NTDDI_WIN2K)
} DUMMYUNIONNAME;
HANDLE hProcess; // out, valid when SEE_MASK_NOCLOSEPROCESS specified
} SHELLEXECUTEINFOW, *LPSHELLEXECUTEINFOW;
#ifdef UNICODE
typedef SHELLEXECUTEINFOW SHELLEXECUTEINFO;
typedef LPSHELLEXECUTEINFOW LPSHELLEXECUTEINFO;
#else
typedef SHELLEXECUTEINFOA SHELLEXECUTEINFO;
typedef LPSHELLEXECUTEINFOA LPSHELLEXECUTEINFO;
#endif // UNICODE
在测试代码中定义了SHELLEXECUTEINFO结构体指针pShExeInfo,并初始化为NULL,然后并没有给该指针赋一个有效的结构体对象地址,然后使用pShExeInfo访问结构体的cbSize成员的内存,因为pShExeInfo中的值为NULL,所以结构体cbSize成员的内存地址是结构体对象起始地址的偏移,因为结构体对象地址为NULL,cbSize成员位于结构体的首位,所以cbSize成员就是结构体对象的首地址,就是NULL,所以就访问64KB小地址内存块的异常,引发内存访问违例,导致程序发生崩溃闪退。
至于如何在程序中设置异常捕获模块去捕获异常、自动生成dump文件,可以尝试使用google开源的CrashRpt库,我们产品很早就有了,具体怎么获取目前还没有研究过,大家需要的话可以到网上搜一下。
4、用Windbg打开dump文件,初步分析
使用Windbg打开dump文件(直接将dump文件拖入到Windbg),然后输入.ecxr命令,切换到异常的上下文,就可以发生的异常类型、发生了异常崩溃的那条汇编及崩溃时的各个寄存器的值。
首先,我们查看一下发生的异常类型,比如AccessViolation内存访问违例、StackOverflow线程栈溢出的异常,这样对异常有个初步的认知,如下:
接着就是查看发生异常的那条指令,查看指令的构成以及崩溃时的各个寄存器的值,可能能初步估计出发生异常的原因。如果指令中访问了一个很小的地址或者访问了一个很大的地址,都会触发内存访问违例。
1)访问64KB小地址内存区,引发访问违例
在Windows系统中,64KB以内的内存地址是禁止访问的,如果程序访问这个范围内的内存,则会触发内存访问违例,系统会强行将进程终止掉。
2)访问了内核态的大地址内存区,引发访问违例
对于32位程序,系统会给进程分配4GB的虚拟内存,一般情况下用户态和内核态会各占一半,即各占2GB,我们编写的代码基本都是运行在用户态的,用户态的代码时不能访问内核态内存地址的(内核态地址是供系统内核模块使用的),如果崩溃指令中访问了一个很大的内存地址,超过用户态的地址范围0-2GB,内存地址大于0x8000000,则会触发内存违例,因为用户态的代码是禁止访问内核态内存地址的。
如果汇编指令中使用到了ecx寄存器进行地址计算,去访问计算出来的内存地址,如果访问了一个小内存地址并且ecx寄存器为0,那么这个崩溃可能是空指针引发的。在C++中,在调用C++类的成员函数时是通过ecx寄存器传递C++对象地址的,所以在通过C++类对象去调用虚函数(调用虚函数时的二次寻址)及通过C++对象去访问对象中的数据成员时,都会用到ecx寄存器的。
上面故意添加的会引发崩溃的测试代码,运行后产生dump文件,我们来分析一下这个dump文件。用Windbg打开dump文件后,输入.excr命令,接着输入kn命令,查看到如下的结果:
首先,这是Access Violation内存访问违例的异常。其次,从崩溃的这条汇编指令来看,是访问了小地址0x00000000地址,这是访问了小于64KB小地址内存区,这个范围的地址是禁止访问的,所以引发了内存访问违例。从汇编指令及寄存器的值来看,看不出来什么明显的线索。
所以接着输入了kn命令,将函数调用堆栈打印出来。函数调用堆栈是从下往上看的,最上面一行就是最后调用的一个函数,也是崩溃的那条汇编指令所在的函数。从函数调用堆栈的最后一帧调用的函数来看,程序的崩溃是发生在TestDlg.exe文件模块中,不是其他的dll模块。显示的函数地址是相对TestDlg.exe文件模块起始地址的偏移,为啥看不到模块中具体函数名称呢?那是因为Windbg找不到TestDlg.exe对应的pdb文件,pdb文件中包含对应的二进制文件中的函数名称及变量等信息,Windbg加载到pdb文件才能显示完整的函数名。
查看函数调用堆栈的命令,除了kn,还有kv和kp命令,其中kv还可以看到函数调用堆栈中调用函数时传递的参数,如下所示:
我们需要取来pdb符号库文件,去查看具体的函数名及行号的,这样才好找到直接的线索的。下面就来看看如何获取到TestDlg.exe模块的pdb文件。
5、找到pdb文件,设置到windbg中,查看完整的函数调用堆栈
如何才能找到TestDlg.exe文件对应的dpb文件?我们可以通过查看TestDlg.exe文件的时间戳找到文件的编译时间,通过编译时间找到文件对应的pdb文件。在Windbg中输入lm vm TestDlg*命令,可以查看到TestDlg.exe文件的详细信息,其中就包含文件的时间戳:(当前的lm命令中使用m通配符参数,所以在TestDlg后面加上了*号)
可以看到文件是2022年6月25日8点26分23秒生成的,就可以找到对应时间点的pdb文件了。
一般在公司正式的项目中,通过自动化软件编译系统,每天都会自动编译软件版本,并将软件的安装包及相关模块的pdb文件保存到文件服务器中,如下所示:
这样我们就可以根据模块的编译时间找到对应版本的pdb文件了。
我们找到了TestDlg.exe对应的pdb文件TestDlg.pdb,将其所在的路径设置到Windbg中。点击Windbg菜单栏中的File->Symbol File Path...,打开设置pdb文件路径的窗口,将pdb文件的路径设置进去,如下所示:
点击OK按钮之前,最好勾选上Reload选项,这样Windbg就会去自动加载pdb文件了。但有时勾选了该选项,好像不会自动去加载,我们就需要使用.reload /f TestDlg.exe命令去让Windbg强制去加载pdb文件(命令中必须是包含文件后缀的文件全名)。
设置完成后,我们可以再次运行lm vm TestDlg*命令去看看pdb文件有没有加载进来:
如果已经加载进来,则会在上图中的位置显示出已经加载进来的pdb文件的完整路径。,如上所示。
加载到TestDlg.exe文件对应的pdb文件之后,我们再次执行kn命令就可以包含具体的函数名及及代码的行号信息了,如下:
我们看到了具体的函数名CTestDlgDlg::OnBnClickedButton1,还看到了对应的代码行号312。通过这些信息,我们就能到源代码中找到对应的位置了,如下所示:
是访问了空指针产生的异常。当然上面的代码是我们故意这样写的,目的是为了构造一个异常来详细讲解如何使用Windbg进行动态调试跟踪的。
6、将C++源代码路径设置到Windbg中,Windbg会自动跳转到源代码行号上
为了方便查看,我们可以直接在Windbg中设置C++源码路径,这样Windbg会自动跳转到源码对应的位置。点击Windbg菜单栏的File->Source File Path...,将源码路径设置进去:
然后Windbg会自动跳转到对应的函数及行号上:
然后点击函数调用堆栈中每行最前面的数字超链接,就可以自动切换到对应的函数中。上图中的函数调用堆栈中很多模块是系统库中的,比如mfc100u、User32等,这些库是系统库,是没有源码的。我们可以点击最下面的第23个链接,其位于我们应用程序的模块中,会自动跳转到对应的代码中,如下:
7、有时需要查看函数调用堆栈中函数的局部变量或C++类对象中变量的值
有时我们通过查看变量的值,找到排查问题的线索,比如变量中值为0或者很大的异常值。这点我们在多次问题排查中使用到,确实能找到一些线索。
可以查看函数中局部变量的值,也可以查看函数所在类对象的this指针指向的类对象中变量的值。我们要查看哪个函数,就点击函数调用堆栈中每一行前面的数字超链接,如下所示:
我们看到了局部变量pShExeInfo 的值:
struct _SHELLEXECUTEINFOW * pShExeInfo = 0x00000000
我们可以点击this对象的超链接:
就能查看当前函数对应的C++类对象中成员变量的值,如下:
但有时不一定能查看变量的值,因为当前通过异常捕获模块自动生成的dump文件一般是minidump文件,文件也就几MB左右,不可能包含所有变量的值。所以要在minidump文件中查看变量的值,要看运气的,有时能查看到,有时是看不到的。这里要讲一下dump文件的分类,主要分为minidump文件和全dump文件。
我们将windbg附加到进程上使用.dump命令导出的dump文件,是全dump文件,全dump文件中包含了所有的信息,可以查看到所有变量的信息。另外通过任务管理器导出的dump文件:
也是全dump文件。全dump文件因为包含了所有的信息,所以会比较大,会达到数百MB,甚至上GB的大小。但如果通过安装在程序的异常捕获模块CrashReport导出的dump文件就是非全dump文件,是mini dump文件,大概只有几MB左右,因为异常捕获模块捕获到异常后,会自动导出dump文件,保存到磁盘上,如果都导出体量很大的全dump文件,很大量消耗用户的磁盘空间,所以我们会设置生成mini dump文件。
在异常捕获模块中我们是通过调用系统API函数MiniDumpWriteDump导出dump文件的,我们通过设置不同的函数调用参数去控制生成mini dump文件的。
8、有时我们需要用IDA打开二进制文件去查看汇编代码上下文
有时通过函数调用堆栈中函数及行号,我们很难搞清楚到底为什么会发生崩溃,这时候我们就需要回归本源了,就需要去查看发生异常崩溃的那条汇编指令的上下文了。
汇编指令最能直观地反映出问题的本质,通过阅读汇编代码,就能搞清楚为啥会触发崩溃了。一般我们在使用.ecxr命令切换到发生异常的汇编指令时,我们可以直接在该条汇编指令的上下文了,点击菜单栏的View->Disassembly,即可打开显示汇编代码的页面,如下所示:
但直接在Windbg中查看汇编代码,对于我们这些不精通汇编代码的人来说,是有很大困难的,我们是大概率看不懂的。
一般需要借助IDA工具打开二进制文件去查看汇编代码的,IDA在解析出汇编时会在汇编代码中加上一些注释,特别是在有pdb符号库文件时,会添加更多的注释,通过这些注释,我们就能对照了C++源代码,就能大概读懂汇编上下文了。如果能找到pdb符号库文件,则需要将pdb文件放在目标二进制文件的同级目录中,IDA打开二进制文件时会去自动加载pdb文件。
8.1、IDA工具介绍
IDA是比利时Hex-Rays公司出品的一款交互式静态反汇编工具。它可以直接反汇编出二进制文件的汇编代码,是目前软件逆向与安全分析领域最好用、最强大的一个静态反汇编软件,已成为众多软件安全分析人员不可缺少的利器!它支持Windows、Linux等多个平台,支持Intel X84、X64、ARM、MIPS等数十种CPU指令集。
在实际工作中,我们一般使用反汇编工具IDA去打开二进制文件,查看二进制文件中的汇编代码。IDA既支持打开Windows下的.exe、.dll等二进制文件,也可以打开Linux下的.bin、.so等二进制文件。
8.2、使用IDA打开二进制文件
IDA安装完成后,双击启动程序,会弹出如下的提示框:
点击“New”即新建一个对象。紧接着弹出让选择要打开的文件:
可以找到目标文件的路径,打开目标文件即可。也可以点击取消,然后直接将文件拖到IDA中。打开文件时会让选择加载文件的方式:
对于Windows库,选择PE方式即可。接下来,会弹出是否要加载pdb文件的提示框:
选择Yes即可。前面我们说过,需要将pdb文件放置到目标二进制文件的同一级目录中,这样IDA在打开二进制文件时就会搜索到对应的pdb文件,回去自动加载pdb文件。
打开后的效果如下:
默认是先是Grapg View图状关系视图,需要右键点击视图区域,在弹出的右键菜单中点击Text View菜单项,切换到Text View文字视图,这样就能看到具体的汇编代码了:
8.3、在有pdb文件时到IDA中定位到发生异常的汇编指令的位置
Windbg中发生异常的那条汇编指令,我们需要到IDA中找到对应的位置,然后查看目标位置的汇编指令的上下文。在目标二进制文件有pdb的情况下,要在IDA中定位到发生异常的汇编指令的位置,会比较简单。
首先在Windbg中显示的函数调用堆栈中找到发生崩溃的那条汇编指令所在的函数名CTestDlgDlg::OnBnClickedButton1,如下:
然后到IDA中,点击菜单栏的Jump->Jumptofunction...,在打开的函数列表窗口中,点击下方的Search按钮,在搜索框中输入函数名后搜索:
在列表中找到函数,双击之久切换到该函数的汇编代码处:
这样就看到该函数的代码段地址,然后加上Windbg中显示的偏移值:
即:
CTestDlgDlg::OnBnClickedButton1+0x32 = 0x00401A30 + 0x32 = 0x00401A62
就得到发生崩溃的那条汇编指令在当前二进制中的地址,因为汇编指令就为在当前这个函数中,鼠标向下拉动找到地址即可,这样就能找到发生异常的那条汇编指令了。
其实还有个快捷操作,在计算出发生异常的那条汇编指令的地址后,按下快捷键G,在弹出窗口中输入刚才的地址0x00401A62,就可以直接go到发生异常的那条汇编指令的位置,如下:
8.4、在没有pdb文件时到IDA中定位到发生异常的汇编指令的位置
在没有目标二进制文件的pdb文件的情况下,要在IDA中定位到发生异常的汇编指令的位置,会相对麻烦一点。
**发生异常的汇编指令的地址,是程序实际运行时的代码段地址,需要在Windbg中计算出该条汇编指令的地址相对于所在模块起始地址的偏移值,然后加上IDA中该模块的的默认加载地址,就能得到当前汇编指令在IDA中的静态地址,然后直接go过去,就能看到产生异常的汇编指令的上下文了**。在本例中,发生异常的汇编指令的地址就是0x00401a62,如下:(发生异常的这行汇编指令最前面的那个地址,就是当前汇编指令的地址)
从函数调用堆栈上看,当前这条发生异常的汇编指令所在的函数为:
TestDlg!CTestDlgDlg::OnBnClickedButton1+0x32
所以这条指令所属模块为TestDlg.exe,所以使用lm命令查看TestDlg.exe模块的起始地址:
TestDlg.exe模块其实地址为0x00400000(这是系统将TestDlg.exe模块加载到进程空间中的起始地址,代码段地址 ),所以发生异常的这条汇编指令相对于所在模块TestDlg.exe的偏移地址为:
0x00401a62 - 0x00400000 = 0x00001a62
然后再到打开TestDlg.exe二进制文件的IDA中,查看该TestDlg.exe模块默认的加载地址:(将滚轮滚动到最上面即可看到,注意此处是默认预加载地址,并不是加载起来后的真正的地址)
这样根据之前计算出来的偏移,加上TestDlg.exe模块的默认加载地址,就得到发生异常的那条汇编指令在IDA打开的静态文件中的位置:
0x00001a62 + 0x00400000 = 0x00401a62
然后到IDA中按下G快捷键,GO到0x00401a62地址处,即找到发生异常的那条汇编指令:
在IDA中找到发生异常的汇编指令的位置,就可以去查看其附近的汇编代码上下文了。
8.5、阅读汇编代码上下文
我们在阅读汇编代码的上下文时,一般是对照着C++源码进行的,然后依托汇编代码上下文中的注释,找到汇编代码与C++源码的对应关系。在阅读汇编代码时,要了解一些常用的汇编指令及常用寄存器的使用(比如EAX用来存放函数的返回值,ECX用来传递C++对象地址的),熟悉函数调用时的参数入栈、栈分布及参数寻址,了解内存拷贝的汇编代码实现、了解虚函数调用的二次寻址的过程,去啃发生异常的那条汇编指令的上下文中的汇编代码。
汇编指令比较多,我们只需要了解一些常用的汇编指令即可,如果遇到不熟悉的汇编指令去搜索一下就可以了。**关于分析C++软件异常需要掌握哪些汇编知识**,可以参见之前写的文章:
分析C++软件异常需要掌握的汇编知识汇总https://blog.csdn.net/chenlycly/article/details/124758670 此外,有一点需要注意的是,在Release下编译器会对C++源码会做优化,部分C++源码可能会被优化掉,C++源码有时不能完全和汇编代码对应起来的,但这基本不影响汇编代码上下文的阅读。
**关于如何使用IDA工具的详细说明**,可以查看之前写的文章:
IDA反汇编工具使用详解https://blog.csdn.net/chenlycly/article/details/120635120
9、最后
本文通过一个实例详细介绍了使用Windbg去静态分析dump文件的完整过程,以供大家参考。
Windbg和IDA等工具的下载链接如下:
链接:https://pan.baidu.com/s/1ID6_0RSYKbiy_tzfYDX3Ew
提取码:tn6i
版权归原作者 dvlinker 所有, 如有侵权,请联系我们删除。