作者简介:
张银奎 ,《软件调试》和《格蠹汇编》作者,从事软件开发和研究十余年,对IA-32架构、操作系统内核、虚拟技术,尤其对软件调试有较深入的研究。微博ID:@dbgger
本文为《程序员》原创文章,未经允许不得转载,更多精彩文章请 订阅2016年《程序员》
因为太了解软件,我很慎重在自己的电脑上安装新软件。大约半年前,有朋友通过百度云盘向我传递dump文件。点击链接下载时失败,提示超过了普通方式允许的上限,必须安装百度云盘客户端软件。于是我的电脑新增了一个软件,名曰“百度云管家”。第一次看到这个名字,就觉得很奇怪,云的家在服务器上,为什么一个终端应用程序叫云管家呢?
过了一段时间,我慢慢意识到,原来这位云管家管的不是云的家,而是我(用户电脑)的家。名字的含义或曰:“我是百度云,来管你的家”。
为什么这么认为呢?最初的原因是我发现这个管家特别忙碌,即使当我根本没有使用百度云。更让我跌破眼镜的是,即使我把网线拔掉、关闭无线,它依然忙碌。这些反常的表现让我不得不留意它了。多少次,我打开任务管理器,看它忙碌的身影。多少次,我想大声对它说:“管家大哥,你歇歇,告诉我你在忙啥?”
怎一个忙字了得
起初是在任务管理器中发现百度云管家(以下简称其“管家程序”)很忙。图1是我某次看见它忙时做的截图。在这个截图中,系统中一共运行了175个进程,任务列表是按缺页异常总数(Page Faults)排名,管家程序排名第一位,而且遥遥领先,把一向排名靠前的McAfee安全软件(第二名和第三名)远远抛在后头(相差一个数量级)。顺便说下,排在第4位的BaiduProtect是管家程序的同门兄弟,以后台服务方式运行,权限更高。
在Page Faults右侧的那一列是PF Delta,代表最近一秒钟新增的缺页异常个数,管家程序新增4千多个,但这并不是我看到的最高值,有时是7000多。再往右的一列是CPU净时间,即CPU执行管家程序的累计时间,14分37秒。这个数值也算较高了,因为系统中CPU频率高达2.6 GHz,速度很快,排在后面的很多程序(图1中未显示出)的累计时间还不到1秒。排在第二名的安全软件CPU累计时间是1小时55分42秒,比管家程序还高很多。如果把缺页异常总数除以CPU净时间,便得到缺页异常与CPU净时间的比率。这个比率反应了CPU执行程序时触发缺页异常的频繁程度,不妨将其称为缺页异常净频率。为排名前两位的两个程序计算这个指标,其结果如下:
代码-01
可以看出管家程序触发缺页异常的净频率高得惊人,达到24万多次。这意味着CPU平均执行这个程序1秒钟就触发24万多个缺页异常。这也意味着,CPU花在这个程序上的时间有很多都用在了处理缺页异常上。
图2是使用Mark Russinovich先生的Process Explorer来观察管家程序的截图,显示的是管家程序的线程信息。
可以看到,管家程序有四个很活跃的线程,它们的CPU占用率都超过了0.1%。图2中第1列是线程ID,第2列是CPU占用率,第3列是Cycles Delta,即最近一秒钟CPU执行这个线程所用的时钟个数。从Windows Vista开始,NT内核会读取现代处理器的性能计数器来统计CPU花在每个线程上的时钟个数。根据图2,最近1秒里,管家程序的前4个线程使用的CPU时钟数分别为1千4百万、1千5百万、3千2百万和1亿零5百万。
图2下方是排名第一的8864号线程的更多数据,其中的Kernel和User分别是内核态净时间(23秒多)和用户态净时间(1分23秒多)。Context Switches是用户态和内核态之间切换的次数,高达3千1百多万次。左下角的Cycles是CPU执行该线程时所用的总时钟个数,7万多亿个。今天的x86处理器使用的超标量架构有4个发射端口,每次可以发出四条指令乱序执行,这意味着每个时钟周期可能执行多达四条指令。对于比较差的情况,平均每条指令所用的时钟周期(即所谓的CPI指标,Cycles Per Instruction)可能为3。按CPI为3来折算一下,CPU在这个线程上执行的指令数多达2万多亿条。2万多亿条指令是什么概念呢?曾经轰动信息产业的著名CIH病毒,总指令数只有几百条。即使按1千条来说,那么2万多亿条指令相当于把CIH病毒执行了20多亿次。
图3是使用Intel的著名调优工具VTune分析管家程序时得到的线程信息。每行代表一个线程。需要说明的是,因为图2与图3是针对管家程序的不同运行实例,所以无法用线程ID把两个线程对应起来。但观察到的结论是一致的,从VTune视图来看,也是有四个线程很繁忙,而且有很频繁的线程上下文切换。VTune视图给我们的另一个信息是,有多个线程的执行过程都很有规律,尤其是第四个,每隔大约1秒(横轴为时间,单位为秒)有个尖峰,这说明该线程很可能是受定时器触发来工作的。
上调试器
上面使用多个工具观察管家程序得到的结论都是它很忙碌。但不是很清楚到底是在忙什么?熟悉我的朋友一定想到了要上调试器。诚然,要想深刻认识软件,没有比调试器更有力的工具了。唤出WinDBG,附加到管家进程,一切顺利,先执行lm浏览模块信息(图4)。
图4中,第一行是EXE主模块,接下来的kernelbasis、kernel和kernelpromote三个模块的名字中都含有kernel字样,第一次看到这些名字让我一惊,以为与系统的kernel32和kernelbase模块有关,后来确认这是云管家自己的模块,我不禁好奇,这么高大上的名字,不知道出自哪位同行的妙想。
顺便说一下,图4中以Yun开头的YunDb和YunLogic模块也是管家程序的重要模块,后文会提到。
观察EXE模块的详细信息(图5),可以看到目前使用的是2016年3月的版本,这比我最初分析过的版本要新很多。
在已经卸载的模块列表(图6)中,可以看到一个名为AutoUpdateUtil.dll的模块多次出现,它应该是用来做自动更新的。
大致了解模块信息后,执行~*观察线程信息。哇,一共40多个线程。执行~*e .echo * ; ? @$tid;.ttime观察线程的执行时间信息,可以看到有几个线程的CPU累计时间都超过了秒级。这与前面使用Process Explorer看到的结果一致。
反调试与反反调试
做了以上观察后,执行g命令,希望让管家程序走走看。但是意外出现了,WinDBG很快收到了进程退出事件。第一次看到这一幕时,不禁愕然。凭借多年经验,我意识到这次的对手不一般,也是懂调试的,检测到调试器后,主动退出了。“你上调试器,我不跑了,死给你看。”
管家程序的这招反调试让我刷新了对百度同行的认识。但我并没有被这招吓到,反而兴趣更高了。不禁让我想起曾经在国内某公司的一次交流,在我演讲之后,一位同行提问,“看过了你写的《软件调试》,是否有计划写一本如何反调试的?”
调试是软件世界里的逃生通道,我真的不愿意写反调试的书。
但被逼到这里,只好出几招了。首先需要知道管家程序检测调试器的方法。先退出调试器,触发管家程序重新执行,并再附加WinDBG,然后执行x kernelbase! debug 列出Windows系统的调试API。其中的IsDebuggerPresent是用来检测是否在被调试的最简单方法,对其设置断点,而后执行g恢复管家程序执行。
刹那之间,断点果然命中,k命令观察,真的是上文曾提到的Yun字辈模块之一YunLogic在调用这个检测调试器的API(图7)。
使用u命令观察IsDebuggerPresent函数,很短。其原理我在《软件调试》中有详细介绍,先通过TEB取得PEB,再访问PEB中的BeingDebugged字段。
代码2
单步跟踪到ret指令,看EAX寄存器果然为1,代表调试器存在。
代码3
如果把这个结果返回给管家程序,那么它就发现被调试了,继而就会开始退出。于是,执行r eax=0,“狸猫换太子”。
这样篡改IsDebuggerPresent的结果后,再g恢复执行,发现断点再次命中,看来是“骗过”管家一次,它又一次做检查。
每次修改返回值太麻烦了。执行a命令开始交互式汇编(图8)。
WinDBG的汇编环境虽然简陋,但也足够用了,输入以下两行x86汇编后,直接按回车键结束汇编。
代码4
再观察IsDebuggerPresent API,现在变成了下面这样:
代码5
也就是永远返回假。这样偷梁换柱之后,先bd * 禁止断点,然后再执行g命令恢复管家执行。这下它不退出了,因为它以为调试器不在。
原来管家程序的反调试设施如此单薄。看了它的模块架构,其实有一种更简单有效的反调试方法,不过老雷不想说,因为我一向不赞成反调试。
折腾堆
解除了管家程序的反调试保护之后,可以进一步寻找它忙碌的原因了。经过一番勘察,我发现管家程序忙碌的第一个原因是非常频繁地分配和释放内存。长话短说,在WinDBG中设置如下断点来监视从堆上的内存分配。
代码6
先解释一下上面的断点命令,地址部分加5是为了越过函数开头的序言部分,以保证后面获取到参数值是准确的。双引号中包含了多条命令,先是显示提示信息,然后使用一个准变量来统计断点命中次数并打印出来,之后的kv是显示栈回溯,而后判断第三个参数所代表的分配大小是否超过1MB,如果超过则中断,不然则gc继续执行。
设好以上断点,恢复目标执行,发现大量信息喷涌而出,如图9所示。
等待5分钟左右,没有自动中断,说明没有发生参数超过1MB的调用,手工中断下来,可以看到$t1的累计值高达6万多次。
代码7
如果再设置如下断点监视释放堆块的行为,那么即使过了十几分钟之后,t1的值仍然不大。
代码8
这说明很多内存块是分配了后,很快又释放掉了。有经验的程序员知道,从堆上分配内存是开销比较大的操作,好的程序应该尽可能减少从堆上分配内存的次数,分配好了的堆快如果将来还可能使用,那么最好重复使用,不要释放了又分配,分配了又释放。
枚举进程
管家程序的更大问题是频繁调用很重的系统API。执行如下命令对系统的CreateToolhelp32Snapshot API设置断点。
代码8-1
禁止其他断点后,恢复目标执行,会发现这个断点命中的也很频繁。一分钟调用了100多次,大约每秒钟调用两次,如图10所示。
熟悉Windows操作系统开发的朋友知道,CreateToolhelp32Snapshot的用途是对指定进程或者系统中的所有进程抓取快照。其函数原型为:
代码9
参考图10中的kv命令结果,可以看到dwFlags参数为2,代表TH32CS_SNAPPROCESS,意为包含系统中的所有进程。
把上述断点中的gc去掉,不要自动恢复执行,断点命中后,一边观察任务管理器窗口,一边执行gu命令,执行完这个API后中断,可以发现每调用CreateToolhelp32Snapshot一次大约触发60多个缺页异常。
CreateToolhelp32Snapshot返回的是一个句柄,通常拿到这个句柄后再反复调用Process32Next API来获取每个进程的信息。设置如下断点:
代码10
恢复管家程序执行,可以看到以上断点果然反复命中,如图11所示。
根据老雷的试验观察,每调用一次Process32NextW API,大约会触发8次缺页异常。管家程序每调用好一次CreateToolhelp32Snapshot后,会调用165次Process32NextW,那么这两项导致的缺页异常总数加起来便是1300多次,即:
代码11
管家程序每秒钟会做两轮以上循环,于是便是2千多次了。值得说明的是,这个很重的循环操作发生在一个线程中,即前文所说图3中很有规律的第4个线程。有读者可能会问,如果每秒循环两次,那么图3中的尖峰应该是间隔半秒啊?其实不然,因为这个线程是连续循环两次。也就是每次唤醒后,连续做两次拍照和枚举,然后休息不到1秒再做两轮循环,如此往复。执行.ttime观察这个线程的执行时间,可以看到它的执行时间很长。
代码12
执行~17n命令把这个线程临时挂起,恢复管家程序,再观察任务管理器,发现PF Delta(每秒钟新增的缺页异常)指标立刻降下来了,只有不到十次了。看来导致管家程序那么多的缺页异常的主要原因在于这个枚举系统进程的线程。它忙着给系统里的所有进程拍照,然后再一个个看过来。重要的是,这样的工作不是做一次,而是每秒来两轮,风雨无阻、孜孜不倦,时时刻刻关心着系统里运行着的其它进程,好辛劳的管家啊。软件的历史不长,但软件的孩提时代已经过去了,因为今天的软件已经丧失了曾经拥有的简单和纯真,变得复杂、贪婪和狡黠。
一年多之前,我曾写过一篇《在调试器里看阿里的软件兵团》,批评了支付宝客户端软件中的性能问题,文章发表后,很高兴看到阿里的同行不断改进,今天已经不再有当时的问题了(图1中还可以看到淘宝的TBSecSvc进程,排名已经比较靠后)。不知百度的同行看过此文有何感想?作为一款客户端软件,能帮助用户管家是好想法,但是管家毕竟是仆人,有事时应该尽心给主人办事,没事时应该安安静静休息,不要肆意挥霍主人家的东西。
订阅2016年程序员(含iOS、Android及印刷版)请访问 http://dingyue.programmer.com.cn
订阅咨询:
• 在线咨询(QQ):2251809102
• 电话咨询:010-64351436
• 更多消息,欢迎关注“程序员编辑部”