转载自:https://www.anquanke.com/post/id/95427
《Dive into Windbg》是一系列关于如何理解和使用Windbg的文章,主要涵盖三个方面:
1、Windbg实战运用,排查资源占用、死锁、崩溃、蓝屏等,以解决各种实际问题为导向。
2、Windbg原理剖析,插件、脚本开发,剖析调试原理,便于较更好理解Windbg的工作机制。
3、Windbg后续思考,站在开发和逆向角度,谈谈软件开发,分享作者使用Windbg的一些经历。
涉及知识点:死锁与假死、SEH异常、崩溃分析、开源软件、PDB、Wireshark源码、Https、Qt信号槽、事件机制等。
说起网络协议分析,首先想到的便是老牌的Wireshark,特点是跨平台、界面简洁易操作,协议齐全又开源。因此Wireshark久居网络安全工具排行榜前列,也当之无愧。然而笔者最近在使用Wireshark时,遇到了怪象,先是随机卡死,后又遭遇崩溃,本文主要讲述如何使用Windbg来分析这类问题。
有一段时间没用Wireshark了,最近发现抓包一段时间后,Wireshark随机出现卡死,界面无响应。习惯性地升级版本,然而安装了新版2.4.3问题依旧,于是打算一探究竟。
首先界面无响应,多半是UI主线程卡死了,卡死主要有两种表现:
LdrpLoaderLock:临界区CRITICAL_SECTION,占用该锁地方很多。导致死锁的原因也很多,比如DllMain里创建线程,并等待线程退出。又比如调用GetModuleHandle等函数,操作PEB.LoaderList时,线程刚好被TerminateThread了,那么这个锁就永远占住了。排查这类问题常用命令!locks或者!cs(InitializeCriticalSection初始化时会用DebugInfo->ProcessLocksList把整个进程的临界区连起来,链表头是全局变量ntdll!RtlCriticalSectionList),临界区结构里包含了许多有用信息,比如OwerThread、DebugInfo等等,输入dt -r _RTL_CRITICAL_SECTION查看。 FileObjectLock:事件KEVENT,用同步操作I/O的要注意了。比如经常容易采坑的ZwQueryObject,当去查询一个同步的NamedPipe对象名称时,若NamedPipe处于ConnectNamedPipe状态(且没有Client连接进来),NtFsControlFile调用FSCTL_PIPE_LISTEN例程,而且NtFsControlFile也不会走FastIo流程,此时的Irp会阻塞,并且之前已经IopAcquireFastLock占用了FileObjectLock。所以ZwQueryObject查询这个FileObject便会导致死锁。 AddressCreationLock:守护型互斥体KGUARDED_MUTEX。主要用于进程Vad树的同步操作。常见的死锁场景是当MmMapViewOfSection调用ImageLoad回调时,已经调用KeAcquireGuardedMutex占用了此锁,自然不能在回调里操作Vad,比如ZwAllocateVirtualMemory。
在开始分析前,需要先准备环境,因为笔者装的64位Wireshark,所以必须用Windbg X64版本调试,若分析dump则无此限制。设置微软官方符号后,快捷键F6附加到Wireshark.exe进程(若只需观察内存可以选择非侵入式Noninvasive)。Wireshark2.0开始UI从GTK换成Qt了,附加完成中断到DbgBreadkPoint后,输入~0k,查看Qt主线程栈回溯。
栈回溯犹为关键,通过观察,可得知几个信息:
Qt和libwireshark虽然没有PDB,但调用的函数大多都是导出函数,所以Windbg识别出了符号,注意观察符号后面的偏移地址,如果偏移太大,符号应该不正确,因为一个优雅的函数应该尽可能简洁。比如libwireshark!find_sid_name+0x6215f符号就是错误的。
栈的最顶层并没有等待内核对象,整个调用都在用户态,说明用户态代码可能遇到了上文说的假死情况。
卡死的可能是在调用dissector函数,即协议解码器。
当然,这只是一次线程栈的快照,并不能充分说明问题,为此,这里有个技巧就是构造多次线程快照,观察统计。对于这种必然卡死的问题可以F5(g)运行,再中断Ctrl+Break下来,重复几次,查看栈回溯。虽然每次栈最顶层都有变化,但是一直都离不开libwireshark!ssl_starttls_post_ack+0x6215这个Frame,说明八九不离十就是这个函数。
难道是解码TLS的数据卡住了?求解未知问题,方法很多,Windbg、IDA反汇编跟踪固然可以,但我们知道Wireshark是开源软件,因此对照源码分析更高效。而且对于一个正常项目来说,构建时必然会归档符号文件。于是笔者在https://www.wireshark.org/download/win64/all-versions/ 找到了2.4.3的PDB文件,同时下载了一份对应版本的源码。
说到PDB,经常有人会疑惑Windbg怎么老是加载不上符号,其实只需使用!sym noisy命令打印出Windbg的符号搜索过程,将对应的PDB复制到对搜索的路径,这里笔者直接将PDB释放到Wireshark目录,重新.reload一次,再次查看栈回溯。
感谢符号,有符号和没符号区别就是整容后和整容前的区别。由此看来,确实是TLS解码卡在了ssl_load_keyfile函数,因为这份PDB里包含了行号信息,直接找到对应的代码。
//packet-ssl-utils.c。
for (;;) {
char buf[512], *line;
gsize bytes_read;
GMatchInfo *mi;
line = fgets(buf, sizeof(buf), *keylog_file);
if (!line)
break;
bytes_read = strlen(line);
/* fgets includes the n at the end of the line. */
if (bytes_read > 0 && line[bytes_read - 1] == 'n') {
line[bytes_read - 1] = 0;
bytes_read--;
}
if (bytes_read > 0 && line[bytes_read - 1] == 'r') {
line[bytes_read - 1] = 0;
bytes_read--;
}
//....
from_hex(pre_ms_or_ms, hex_pre_ms_or_ms, strlen(hex_pre_ms_or_ms));
//....
不出所料,果然有一个大循环,每次循环调用fgets(buf, sizeof(buf), *keylog_file);读取512字节,即分块按行读取,猜测应该是keylog_file文件内容过多导致的了,到底是个什么文件?查看ssl_load_keyfile原型如下:
void
ssl_load_keyfile(const gchar *ssl_keylog_filename, FILE **keylog_file,
const ssl_master_key_map_t *mk_map)
现在问题转化为寻找key文件路径,也是寻找参数的过程,根据x64调用约定,前四个参数通过寄存器传递,后续参数通过栈传递。然而查看x64的前4个参数(rcx、rdx、r8、r9)并不容易(其余的的参数可通过栈帧找到),一般来说,前4个参数是通过反汇编分析上层或者下层函数,或者函数内部会暂存,总之需要分析反汇编代码间接找到。
第一个参数:文件路径。这个参数通过.frame、dv查看变量,但ssl_keylog_filename 显示 value unavailable,说明没推导出来。反汇编到上层调用函数,发现mov rcx,qword ptr [libwireshark!ssl_options+0x8],参数rcx是一个全局变量,db poi(libwireshark!ssl_options+0x8)查看参数1的值,对应源码:
static ssl_common_options_t ssl_options = { NULL, NULL};
ssl_load_keyfile(ssl_options.keylog_filename, &ssl_keylog_file, &ssl_master_key_map);
第二个参数:FILE**指针,crt的FILE可转换得到文件句柄,再通过句柄可找到对应的路径。_get_osfhandle函数手动转换方法:.frame 01切换到对应的frame上,再输入dv查看变量,这次推导出了keylog_file的值(这里的rdx一直暂存在r12中),根据导入表确定crt库:dqs libwireshark!_imp_fgets l1 得到ucrtbase!fgets,说明是Universal CRT(VS2015重构后的crt结构有所变化,具体可参考ucrt的源码),这里的FILE对应crt_stdio_stream_data结构,其_file成员即是ioinfo的索引值。输入x ucrtbase!pioinfo,查看ioinfo的地址,进而找到__crt_lowio_handle_data,根据偏移和索引值,可得到osfhnd,即文件句柄。然而!handle命令没法在用户态得到句柄对应的文件路径,可以写插件实现,也可以用procexp找到句柄对应的文件路径。
头文件:
ucrtinccorecrt_internal_stdio.h
struct __crt_stdio_stream_data
{
union
{
FILE _public_file;
char* _ptr;
};
char* _base;
int _cnt;
long _flags;
long _file;
int _charbuf;
int _bufsiz;
char* _tmpfname;
CRITICAL_SECTION _lock;
};
头文件:ucrtinccorecrt_internal_lowio.h
//x64结构大小:0x40,osfhnd偏移:0x28,这些可通过sizeof打印,对于简单的结构体,偏移遵照对齐方式自行估算。
struct __crt_lowio_handle_data
{
CRITICAL_SECTION lock;
intptr_t osfhnd; // underlying OS file HANDLE
__int64 startpos; // File position that matches buffer start
unsigned char osfile; // Attributes of file (e.g., open in text mode?)
__crt_lowio_text_mode textmode;
__crt_lowio_pipe_lookahead _pipe_lookahead;
uint8_t unicode : 1; // Was the file opened as unicode?
uint8_t utf8translations : 1; // Buffer contains translations other than CRLF
uint8_t dbcsBufferUsed : 1; // Is the dbcsBuffer in use?
char dbcsBuffer; // Buffer for the lead byte of DBCS when converting from DBCS to Unicode
};
typedef __crt_lowio_handle_data* __crt_lowio_handle_data_array[IOINFO_ARRAYS];
extern __crt_lowio_handle_data_array __pioinfo;
最后发现文件是”D:ChromeSSL”,大小有100多M,看到chrome才回过神来,因为很早之前分析某网站的Https协议时,把chrome的ssl key通过环境变量SSLKEYLOGFILE存储到文件,再设置到了Pre-Master-Secret中。经过上述分析,卡死问题总算得解。。。
刚解决完卡死问题,又遇到一次Wireshark崩溃。
既然准备工作都完成了,那就接着分析吧。首先保存现场,存一份完整dump(minidump内存信息太少不易分析)。
说到如何转储dump,通常有几种方式:
procexp – Create Full/Mini Dump。 任务管理器 – 创建转储文件,完整dump。 Windbg – Attach到进程,.dump /ma c:crash.dmp。这里有一点需要注意:如果是Attach上去再存储dump,那么当前的异常信息是Break instruction exception(用!analyze命令也分析不出真正的异常,后面我们会讲如何分析这类dump),因为此时程序正在等待WER服务响应,调试器并没有收到异常事件。正确做法是继续运行程序,在Werfault的错误框点击调试程序,若弹出选择VS JIT调试对话框,则选择否。Windbg会再次中断到异常状态,此时存储dump可获取到异常信息。 procdump – 命令行程序,跟procexp是一个系列。 自己写程序,调用MiniDumpWriteDump函数。一般软件都自带BugReport收集dump,而且dump里还加入了异常信息,具体可参考google breakpad实现。
笔者这次用Windbg保存了dump,将dump拖入windbg(x64/x86即可),输入.excr查看当前异常信息,发现是DbgBreakPoint(DbgUiRemoteBreakin),也就是上面所提到的Attach int 3异常,异常信息虽有,但不正确。如果你用procexp、任务管理器存储的dump则会提示Unable to get exception context, HRESULT 0x80004002,因为这些程序在调用MiniDumpWriteDump函数生成dump时,没获取异常信息。
对于这类dump,可以用!analyze -v自动分析,或许能搜到异常信息。但笔者习惯先查看所有线程的栈回溯,输入:~* k,找到异常线程,如下:
00 00000000`00248c98 000007fe`fdae1430 ntdll!NtWaitForMultipleObjects+0xa
01 00000000`00248ca0 00000000`77991723 KERNELBASE!WaitForMultipleObjectsEx+0xe8
02 00000000`00248da0 00000000`77a0b5e5 kernel32!WaitForMultipleObjectsExImplementation+0xb3
03 00000000`00248e30 00000000`77a0b767 kernel32!WerpReportFaultInternal+0x215
04 00000000`00248ed0 00000000`77a0b7bf kernel32!WerpReportFault+0x77
05 00000000`00248f00 00000000`77a0b9dc kernel32!BasepReportFault+0x1f
06 00000000`00248f30 00000000`77b23398 kernel32!UnhandledExceptionFilter+0x1fc
07 00000000`00249010 00000000`77aa85c8 ntdll! ?? ::FNODOBFM::`string'+0x2365
08 00000000`00249040 00000000`77ab9d2d ntdll!_C_specific_handler+0x8c
09 00000000`002490b0 00000000`77aa91cf ntdll!RtlpExecuteHandlerForException+0xd
0a 00000000`002490e0 00000000`77ae1248 ntdll!RtlDispatchException+0x45a
0b 00000000`002497c0 00000001`3f9be2b1 ntdll!KiUserExceptionDispatch+0x2e
0c 00000000`00249ee0 00000001`3f9b97a4 Wireshark!rescan_packets+0x351 [c:buildbotwiresharkwireshark-2.4-64windows-2016-x64buildfile.c @ 1773]
识别异常线程很简单,找关键函数KiUserExceptionDispatch,这是用户态SEH异常分发的源头,异常如果没人处理就到了UnhandledExceptionFilter,开始调用WerpReportFault函数,通过ALPC(WindowsErrorReportingServicePort端口)发消息给WER服务弹错误提示,如果和WER服务通信失败,则创建Werfault.exe弹错误提示。创建Werfault再失败,就只有NtRaiseHardError,交给csrss弹框了(进程创建时,CsrCreateProcess时默认会设置EPROCESS的ExceptionPort,之间也交互是ALPC(ApiPort端口)通信。
//Werfault进程的命令行参数-p是后面是异常进程ID。
//Werfault进程也会保存异常线程ID,对于多个同名进程,可以用procexp直接找到崩溃进程,查看异常线程的栈,粗略分析。
//下面是分析WerpReportFault得到的验证代码。
#define PAGE_SIZE 0x1000
HANDLE Section = (HANDLE)0x308; //异常进程共享内存句柄,用procexp查看,一般是Werfault进程最后一个Section(procexp记得勾选显示uname handle)。
DWORD WerPid = 12304; //Werfault进程ID
HANDLE WerProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, WerPid);
if (WerProcess != NULL) {
BOOL DupOk;
HANDLE DupHandle;
//复制一份Section句柄
DupOk = DuplicateHandle(WerProcess,Section,
GetCurrentProcess(), &DupHandle,
SECTION_MAP_READ, FALSE, 0);
if (DupOk) {
CHAR* MapBuffer;
//访问共享内存,获取异常进程ID和线程ID
MapBuffer = (CHAR*)MapViewOfFile(DupHandle, FILE_MAP_READ, 0 , 0, PAGE_SIZE);
if (MapBuffer) {
printf("Exception PID:%dn", *(DWORD*)(MapBuffer+0x4));
printf("Exception TID:%dn", *(DWORD*)(MapBuffer+0x8));
UnmapViewOfFile(MapBuffer);
}
CloseHandle(DupHandle);
}
CloseHandle(WerProcess);
}
通过Windbg找到异常线程后,如果dump中没保存异常信息,.excr不能用,怎么分析异常?很简单,从RtlDispatchException参数入手,函数原型如下:
BOOLEAN
RtlDispatchException (
IN PEXCEPTION_RECORD ExceptionRecord,
IN PCONTEXT ContextRecord
)
前面提及过如何寻找x64的前4个参数,ub反汇编RtlDispatchException上层调用代码如下:
00000000`77ae1236 488bcc mov rcx,rsp
00000000`77ae1239 4881c1f0040000 add rcx,4F0h
00000000`77ae1240 488bd4 mov rdx,rsp
00000000`77ae1243 e8f87bfcff call ntdll!RtlDispatchException (00000000`77aa8e40)
说明ExceptionRecord=rcx=rsp+0x4F0,ContextFrame=rdx=rsp,继续查看0B栈帧,(.frame /r 0B或者~0kn查看栈帧SP)得到rsp的值,接着获取EXCEPTION_RECORD(.exr rsp+0x4F0),CONTEXT(.cxr rsp),使用dt命令亦可。
显示的异常指令:and dword ptr [rsi+24h],0FFFFFFFDh,rsi(96239a8)地址无效 。内存违规c0000005 (Access violation)是个很常见的错误,例如空指针、DoubleFree、UAF等。
根据这条异常信息猜测是一个结构成员的位标记,但结构体地址却无效,查看源码rescan_packets函数发现是fdata->flags.dependent_of_displayed = 0,而fdata地址无效。
capture_file cfile是个全局变量,用来管理数据包及状态,用dt命令结合符号查看。
0:000> dt wireshark!capture_file @@masm(wireshark!cfile)
+0x000 epan : (null)
+0x008 state : 0 ( FILE_CLOSED ) //file已经关闭,即调用了cf_close,相应的内存都被释放。
为什么关闭? 难道cfile有同步问题?带着这一系列问题阅读源码吧。
关于源码分析的一点说明:
首先选择一款好的源码阅读工具,比如SourceInsight、VisualStudioCode,VisualAssist,具备交叉引用,符号索引等实用。从开发入手,先熟悉软件架构,弄清代码结构及逻辑,追踪实现细节,通过代码交叉引用观察函数调用、参数传递等。从逆向入手,根据符号查看各种变量和内存结构,若函数没有符号(例如优化后的inline代码)那就需要多锻炼逆向分析能力了,熟悉调用约定,了解编译器常用优化。
必要时也可通过调试分析,依靠内存访问断点,结合栈回溯观察函数调用和参数传递。例如这里有个技巧通过Windbg调试新Wireshark实例,对state设置硬件写入断点:ba w4 @@c++(&wireshark!cfile.state),可以观察到状态变化,通过栈回溯符号看到函数调用流程,能减轻不少工作量。
后续源码分析较为琐碎,考虑篇幅,因此我不再赘述,读者可自行阅读,最后结论是:
先看看cfile的文件状态
typedef enum {
FILE_CLOSED, /* No file open */关闭状态
FILE_READ_IN_PROGRESS, /* Reading a file we've opened */抓包中,从管道读取数据
FILE_READ_ABORTED, /* Read aborted by user */用户异常终止
FILE_READ_DONE /* Read completed */停止抓包,读取完成
} file_state;
rescan_packets会定时调用update_progress_dlg更新进度条,更新进度条必然会刷新Qt UI,因此会调用WiresharkApplication::processEvents()准备处理UI事件,如果在此时触发了FILE_CLOSE事件就严重了。例如点了开始按钮,会调用on_actionCaptureStart_triggered函数,接着调用cf_close,释放了frames和fdata的内存,然后调用sync_pipe_start和dumpcap.exe通过管道接收捕获的数据包,然而接收数据是基于Qt定时器事件(周期为200ms),只有当收到第一份数据包时才会调用capture_input_new_file,进而调用cf_open更新state为FILE_READ_IN_PROCESS,才会为frames和fdata分配内存,因此这是导致崩溃的直接原因。
当然还有不少地方有这种问题,比如多次reload,再start,构造崩溃的极端方法如下:
1、先抓一部分数据包,然后点击停止。
2、多次按下Ctrl+R(reload),此时再按下E(start),即开始抓包。
3、这时程序会崩溃,或者一直按住Ctrl+R,再按E能稳定复现。
尝试了最新版的Wireshark 2.4.4也有此问题,整个分析过程就告一段落。
本文主要讲述了如何利用Windbg解决Wireshark卡死和崩溃此类实际问题,如果你也遇到过类似问题,不妨拿起Windbg去探一探究竟。
文章整体可能显得有些松散,有些是思维过程,有些是技巧,有些看似不相关。但我觉得对于学习和研究,可以试着走最长的路,看最远的风景,当你经历过看似繁琐的过程后,或许有不一样的思路。如果本文涉及的知识点我未详细说明的,各位可自行搜索研究,或者通过邮箱与我交流。
最后,感谢各位阅读,期待下次再见。
MSDN
Qt Assist
Windbg Help
WRK/NT/Windows 2000 source code
联系作者:[email protected]
作者Github:https://github.com/BlackINT3