缘起
最近,接连在项目中遇到了两个界面无响应的问题。都只发生在客户特定机器上,不方便直接调试,只能抓取 dump
进行事后分析了。
抓取 dump
远程连上可以重现问题的机器,使用 process explorer
初步观察卡死的进程,发现 CPU
占用率很低,经过一段时间的观察,基本确定是一个死锁问题。 在卡死的进程上右键,保存完整转储,压缩,发回本地进行分析。
使用 windbg 进行分析
双击抓取的 dump
文件,因为之前已经执行过 windbg.exe -IA
,所以默认会通过 windbg
打开 dump
文件。先使用 ~*kvn
粗略浏览一下每个线程的调用栈(因为比较长,这里就不截图了)。经过观察,很快锁定了两个值得进一步查看的线程:一个是主线程(界面线程),因为是界面无响应,肯定要关注界面线程。另外一个是 7
号工作线程。分别看一下这两个线程的调用栈。
主线程的调用栈如下图所示:
注意上图红色高亮部分,主线程通过 SleepConditionVariableCS()
进入等待。
看完主线程,再看 7
号工作线程的调用栈,如下图所示:
7
号线程对 SendMessage()
的调用非常值得怀疑。
猜测整个流程是这样的:主线程不知由于什么原因进入等待状态,而工作线程由于各种各样的原因也进入了等待状态。其中 7
号线程最明显,因为它正在发消息,而主线程此时是无论如何也不会响应这个消息的。
于是,典型的死锁再一次发生了。
加载
加载 AssemblyDesign_Tools.dll
的符号后,查看对应的源码,消除对 SendMessage()
的调用,问题解决!so easy!
说明:工作线程中并没有直接调用
SendMessage()
,而是调用了操作界面的相关API
,间接调用了SendMessage()
给界面线程发消息。
死锁的问题解决了,但是为什么向主线程发个消息就死锁了呢?秉着打破砂锅问到底的原则,我又开始折腾了。下面的内容适合喜欢调试逆向的极客阅读。
深入调查
最开始的思路是:查看主线程在等待的条件变量,然后再调查哪个工作线程会唤醒这个条件变量。奈何 64
位下,前四个参数通过寄存器 RCX, RDX, r8, r9
进行传递,如果这些寄存器没有在栈上存储一份的话,很难查看具体的值。折腾一番后,确实没找到有用的信息,而且就算找到了,也很难找出是哪个线程会执行唤醒操作。
这个死锁问题不像关键段死锁解决起来那么直接。不能直接通过命令(!cs -l
),或者查看调用栈就能直接理出头绪。看来只能硬着头皮逆向分析相关代码了。
0
号线程和 7
号线程最值得怀疑,其它线程基本可以排除。先看看主线程为什么会等待吧。
主线程逻辑
找到调用 BentleyG!Bentley::BeConditionVariable::WaitOnCondition()
的地方,也就是 5
号栈帧。
在 IDA
中打开 MobileDgn.dll
,并找到这个函数,然后按下神奇的 F5
:
可以看到,主线程在陷入等待之前,向工作线程发送了一个任务,也就是 sub_7FEDAC749A0
,传递的参数是 v5
。v5
偏移 88
的位置保存了 BeConditonVariable
类型的变量,也就是 WaitOnCondition()
所等待的变量。猜测:sub_7FEDAC749A0
内部会唤醒这个BeConditionVariable
, 如果 sub_7FEDAC749A0
被顺利执行,那么主线程的等待自然就结束了。
先看看 sub_7FEDAC749A0
的反汇编代码,当然是直接看 F5
后的伪代码了。
可以很明显的看到 sub_7FEDAC749A0
内部调用了 Bentley::BeConditionVariable::Wake((CMFCRibbonInfo::XElementButtonUndo *)((char *)v1 + 88), 1);
。
从函数名就可以猜到是用来唤醒 BeConditionVariable
的。
如此看来,sub_7FEDAC749A0
很有可能还没有被执行,工作线程就挂起了。一起来看看 SendToWorkThread
是怎么把任务发送到工作线程的。
SendToWorkThread()
会先判断是否在工作线程运行,如果是则直接执行对应的函数,否则就根据参数生成一个 RpcMessage
,然后发送这个新生成的 RpcMessage
到工作线程的任务队列中。
再来看一下生成 RpcMessage
的函数,我把这个函数命名为 MakeMobileDgnRPCMessage()
。
一定要记住这里的关键信息,后面会根据这里的关键信息验证。 HandlePaint()
传过来的函数地址是 0x7FEDAC749A0
,保存在了 rpcMsg
偏移 128
的位置。MobileDgnRPCGenericMessage
类的虚表地址是 000007FEDAD19658
。
继续追踪 SendAsynToWorkThread()
,如下图:
继续追踪 HandleRpcMessage()
(这个名字是我命名的,不是 IDA
识别的),传给它的第一个参数是一个全局变量(姑且命名为 g_taskQueueManager
),第二个参数是要发送的 rpcMsg
,第三个参数 v3
的值是 2
,第四个参数是 1
。
这个函数比较长,我只逆了个大概,大体思路是先检查是否存在,不存在则插入。如果队列已经满了,需要等待工作线程从队列中取走一些任务才返回。
至此,基本理清了主线程相关的逻辑。大体是这样的:主线程在处理 HandlePaint()
的时候,先发送一个任务给工作线程,(通过 SendToWorkThread()
发送到工作线程的任务队列中),然后通过 BeConditionVariable::WaitOnCondition()
等待这个任务结束。
看完界面线程,再来再看 7
号工作线程的相关逻辑。
工作线程逻辑
7
号工作线程,只需要关心 9
号栈帧对应的函数。
注意,_RunThread+0x1a3c
,这个偏移有点大。由于缺少符号,这里很有可能只是以 _RunThread
作为参照得到的一个偏移,实际对应的是另外一个函数的代码。使用 ub
向前查看反汇编,很快定位到正确的函数首地址。
从上图可知,9
号栈帧对应的函数起始地址是 000007fe dac5fee0
。怎么在 IDA
中找到这个函数呢?如果知道这个函数相对于其所在模块的偏移,就可以算出在 IDA
中的地址了。该怎么获取这个地址对应的模块基址呢?在 windbg
中执行 lma address
就可以知道一个地址对应的模块信息了。
得到模块基址(0x000007fe dac10000
)后,可以很简单的计算出偏移量为 0x4fee0
。有了这些信息就可以在 IDA
中找到这个函数了。
小贴士:也可以在
IDA
中调整模块基址,使其与windbg
保持一致。这样就不用根据偏移在IDA
中手动计算地址了。
得到要查看的函数地址后(我在 IDA
中执行了 Rebase program
,所以是 000007fe dac10000
),在 IDA
中直接按快捷键 g
,输入地址后即可跳转到输入的地址处。
注意:如果在
IDA
中以16
进制输入地址,请加上0x
前缀,而且不要带重音连接符。
再次按下神奇的 F5
:
至此,工作线程的逻辑也理清了。简单总结如下:工作线程是一个循环,不断从任务队列取任务执行,如果设置了唤醒标记位,那么需要在执行完任务函数后,唤醒等待的线程。
验证猜想
好了,花费了这么多精力终于理清了主线程和工作线程的交互逻辑。目的只有一个,就是为了更好的验证之前的猜想:工作线程还没有来得及执行主线程过来的任务就挂起了。
如果猜测是正确的,那么工作线程的任务队列中应该还保留着这个未执行的任务。接下来的任务就是来找到这个未执行的任务。
通过上面对主线程和工作线程的分析,工作线程的任务队列中应该有类型为 MobileDgnRPCGenericMessage
的对象,并且该对象偏移 128
的位置的值为 0x7FEDAC749A0
。根据这两条关键信息在内存中搜寻一下符合条件的记录。
在 windbg
中输入命令 s -q 0 L?fffffffffffffff 000007FEDAD19658
,根据虚表地址搜寻 MobileDgnRPCGenericMessage
类型的对象。找到了两条符合条件的记录。
再输入 s -q 0 L?fffffffffffffff 7FEDAC749A0
根据 sub_7FEDAC749A0
的地址搜寻包含这个地址的对象。找到四条符合条件的记录。
我们关心的是这两次搜寻结果相差 128
的记录(因为根据之前的分析,workProc
存储在偏移为 128
的位置)。经过肉眼观察及在 windbg
中计算(? 00000000300b4ea0 - 00000000300b4f20
),得到了一个符合条件的对象的地址 00000000 300b4ea0
。整个过程如下图:
找到了满足上面条件的对象地址,还需要确认这个对象是否在工作线程的任务队列中。工作线程的任务队列由全局变量 g_taskQueueManager
管理,该变量是一个指针,指针所在的地址是 000007FEDADAA380
,指针的值是 00000000024d9fa0
。
根据之前的分析猜测,偏移为 8
的位置记录了任务队列的开始位置,偏移 16
的位置记录了任务队列的结束位置,偏移 24
的位置记录了任务队列缓冲区结尾的位置,这个任务队列很有可能是通过 vector
管理的。在 windbg
中查看 g_taskQueueManager
的内容。
查看任务队列起始位置保存的记录信息,输入 dq 0000000037e33ce0
,然后与 s -q 0 L?fffffffffffffff 00000000300b4ea0
得到的搜索记录对比,发现有一条是吻合的。
看来,主线程发送给工作线程的任务确实还没有被执行,工作线程就挂起了。
总结
这个偶发的挂起 bug
终于算是解决了。整个过程多亏了强大的 IDA
的强力支持。使整个分析过程简单了 N
倍。最后,对整个分析过程中用到的技术点做一个简单的总结:
IDA
的F5
真香。- 可以在
IDA
中通过Rebase program
调整模块基址。 - 在
IDA
中按g
可以跳转到输入的地址。 - 在
IDA
中的地址需要有0x
前缀,不要包含windbg
中64
位地址的地址连接符。 - 可以在
windbg
中通过lm a address
得到一个地址对应的模块信息。 - 在
windbg
中可以通过ub
进行反向反汇编。 - 在
windbg
中可以根据虚表地址搜寻对应类型的变量在内存中的位置。 - 在工作线程调用界面相关的
API
时,是通过给界面线程发消息的方式实现的。