老雷看Win7(4)——发疯内幕
【本文曾发表在《程序员》杂志的调试之剑专栏中】
现代人生活在重重压力中,有时压力太大无法忍受时,便以疯狂的方式来释放压力,有的大喊大叫,有的打人骂人,也有动刀动枪的……其实软件世界也有类似的问题,随着软件被应用到越来越多的重要岗位,人们对软件的期望也越来越高:要长的漂亮,有华丽的外表;要跑的快,反应神速;要功能强大,啥都能干;要灵活通用,能在所有带CPU的盒子上跑;要坚韧鲁棒,能适应千差万别的使用环境;要开放,能以有线无线、局域网、互联网等各种方式通信;而且还要安全,铜墙铁壁,百毒不侵……看看人们对软件的这些要求,您说软件的压力大不大?在如此重负下,软件也难免会有“发疯”的时候。
疯狂重启
一位朋友说,他的“Win7疯特了”,没完没了的重启,我答应帮忙看一下。几个小时后,出故障的笔记本电脑摆在了我面前。的确,Win7进入桌面后便会弹出一个如图1所示的对话框,然后大约5秒钟后,系统中的所有程序都会被杀掉,系统开始重启。重启后,可以看到启动的画面,闪烁的Win7徽标,然后看到桌面,但是很快就又出现了要强制重启的“通牒”,这时如果速度足够快,有可能把任务管理器打开,或者做一些其他动作,但是可以动作的时间只有几秒钟,几秒钟过后,所有东西都会被杀掉,然后显示登出(log off)和关机画面,系统复位重启。重启后的现象还是一样,如此启动、关机,再启动,再关机,周而复始,持续不断,Win7看起来真的像“疯”了一样!
图1 强制重启通告
安全模式无济于事
抱着试试看的心理,在启动前按F8选择,调出高级启动选项菜单(图2),然后选择以安全模式(Safe Mode)启动。
图2 尝试安全模式
但是问题没这么简单,故障依然存在,那个已经熟悉的对话框仍然态度坚决的蹦出来(图3),然后很准时的开始杀掉所有程序,开始重启,与普通模式没什么变化。
图3 安全模式下的强制重启通告
细心的读者可能注意到图1和图3中的错误信息略有不同,图1中提到的是电源服务(Power service),图3中提到的是PnP服务(Plug and Play service)。但是事实上,在正常模式下,两种提示也都出现过。
也尝试过在恢复到已知的良好配置(Last Known Good Configuration),但也于事无补,问题照旧。
是谁杀了关键服务?
简单的方法没能奏效后,我开始思考如何对付这个问题。从图1和图3中的提示信息来看,系统重启的直接原因是关键的系统服务意外终止了(service terminated unexpectedly)。因为有些系统服务(service)承担着重要的职责,它们的“健康”关系到整个系统是否能正常运行,所以系统会监视这些服务,如果发现它们意外退出(终止)了,那么便像有国家政要被谋杀了一样,进入紧急状态,强制“戒严”—— 关闭登录会话,退出窗口系统,强制重启系统…….因为有很多个重要服务,比如日志服务、PnP服务、电源服务、DCOM等,都居住(host)在SvcHost进程中,在那里办公,所以一旦这个进程意外终止,那么很多个关键服务都会受影响。从上面描述的现象来看,很可能是SvcHost进程意外终止了,导致运行在这个进程中的系统服务全完了,可谓“城门失火,殃及池鱼”。那么是谁杀了这个进程呢?
选择方法
接下来应该选用什么方法来调试呢?我开始评估各种方法。
很多进程意外终止是因为未处理异常导致进程被强行终止,即通常说的应用程序崩溃(Application Crash)。对付应用程序崩溃的常用方法是JIT调试(《软件调试》12.5节),也就是当程序在被终止前,自动启动JIT调试器。但对于本例,出问题的进程运行在不可见的Session 0中,因此,当JIT调试器被启动后,我们也看不见它,只能将通过另外一个调试器间接的方式来控制它,要么通过内核调试会话来控制,要么通过命名管道等方式来远程调试,前者需要两台机器;后者理论上可以在同一台机器实现,例如,在Session 1中运行一个WinDBG,然后连接到Session 0中的NTSD,但因为本例中很多关键的系统服务都受到影响,所以这很可能导致两个调试器无法建立连接,所以使用远程调试,也应该使用串行口这种依赖系统服务较少的硬件方式,这意味着也需要两台机器。
第二种方法是使用双机内核调试,也就是通过串行口、1394或者USB 2.0电缆来调试出问题的系统。尽管本例中问题发生在用户态,但是仍可以通过内核方式设置断点,或者等待发生未处理异常时中断到内核调试器,然后进行调试。
第三种方式是使用转储文件(dump file),也就是在SvcHost进程崩溃时产生转储文件,然后分析这个转储文件。通常系统的WER机制(《软件调试》第14章)会自动产生转储文件,因此只需要找到并复制出来。
比较以上三种方法,第三种相对来说简单一些,但是只能看到崩溃时的“瞬间快照”,前两种方法都需要两台机器,相对较麻烦,但是可以进行交互式调试。
不妨先尝试第三种方法,不行再用其它两种方法。于是面临的问题便是如何找到转储文件并复制出来。眼下系统反复的重启,每次只能使用几秒钟,要在那几秒钟时间内找到转储文件,然后复制出来难度太大了。怎么办呢?Win7的一个新功能刚好可以完美的解决这个问题。
WinRE派用场
很多普通用户可能根本不注意,一个典型的Win7系统中,其实有两个Windows,一个是用户通常使用的,另一个是正常系统出故障时用来紧急恢复用的,后者通常被称为WinRE(Windows Recovery Environment)。简单来说,WRE是个简化了的Windows,它很小,占用大约200MB的磁盘空间。
如何进入WinRE呢?与进入安全模式的方法是类似的,也就是在图2所示的高级启动菜单中选择Repair Your Computer。
进入WinRE后,启动一个命令行窗口,然后切换到Win7的系统盘。值得注意的是,WinRE映射的盘符与正常系统中看到盘符很可能是不一样的,C盘一般是所谓的系统保留分区,D盘一般是Win7的系统盘,可以通过文件来确定。在本例中,D盘是Win7的系统盘,于是切换到D盘后,执行dir *.mdmp /s以下命令来寻找WER机制产生的转储文件,如图4所示。
图4 寻找转储文件
哦,真的存在,因为WinRE对USB磁盘支持的非常好,因此只要插入一个U盘就可以把找到文件复制出来了。
分析转储文件
在正常的系统中,启动WinDBG打开复制过来的转储文件。加载过程中,WinDBG显示的如下信息值得注意:
(280.2a4): Stack buffer overflow - code c0000409 (first/second chance not available)
上面这句话是说在280号进程的2a4号线程中发生了缓冲区溢出,这个缓冲区是分配在栈上的。用~*命令列出所有线程,可以看到当前线程就是这个发生溢出的线程,执行kn命令观察栈回溯,其结果如图5所示。
图5 溢出线程的栈回溯
从栈回溯中可以看到几个Wer开头的函数,这说明这个进程在终止前调用了WER设施,这正是我们能得到这个转储文件的原因。#08栈帧中的函数是UnhandledExceptionFilter,这是位于kernel32.dll中的用于处置未处理异常核心函数,它也是系统在终止掉一个进程前做最后处理的地方,应用程序错误对话框和JIT调试都是从这个函数发起的,《软件调试》的第12章曾深入讨论过这个函数,并给出了伪代码。通常这个函数下面就是导致异常的函数了。看一下#09栈帧,函数名叫__report_gsfailure,模块名被我们故意隐掉了,我们不妨就称它为模块U,下一个栈帧的函数也是位于模块U中,我们称那个函数为函数U。
再看一眼栈帧#0b,我基本明白了故障的原因,简单来说,是模块U中的函数U发生了缓冲区溢出,当这个函数要返回时,编译在函数中溢出检查代码检测出了溢出,于是调用__report_gsfailure函数报告错误。这种检测溢出的方式通常称为基于Cookie的溢出检查(《软件调试》22.12),简称GS机制。
谁动了我的甜饼?
简单来说,GS机制就是在可能发生溢出的函数所使用的栈帧起始处(EBP-4的位置),存放一个称为Cookie的整数,在函数返回时检查这个Cookie是否完好,如果被破坏了,就说明函数中发生了溢出。部署和检查Cookie的代码都是编译器在编译时加入到函数中的。反汇编模块U的函数U,我们可以看到存入Cookie的代码:
751a15fb a190a31b75 mov eax,dword ptr [XXXX!__security_cookie (751ba390)]
751a1600 33c5 xor eax,ebp
751a1602 8945fc mov dword ptr [ebp-4],eax
和函数返回前检查Cookie的代码:
74ed166f e82efdffff call XXXX!__security_check_cookie (74ed13a2)
从图5可以知道函数U的栈帧基地址(EBP)是009afb30,于是使用dd命令可以观察目前栈上的Cookie值、父函数EBP值和函数返回地址值:
0:007> dd 009afb30-4 l4
009afb2c 00640064 00640064 006a002e 00670070
从上面的结果看,Cookie值为00640064,父函数的EBP值为00640064,函数返回地址为006a002e,这些值看起来都不大像合适的内存地址,倒都像ASCII代码。看来,因为缓冲区溢出,这些重要的信息都被冲掉了,变成其它数值,也正因为这个原因,图5中,#0b号栈帧的函数名字段只能显示006a002e,因为WinDBG无法找到这个地址所对应的有效符号。
那么,在函数U中到底发生了什么呢?反汇编整个函数,便可以得到它的伪代码:
ULONG FuncU(PVOID p, int nLength)
{
XXX_MSG msg;
memset(&msg, 0, 0x200);
memcpy(&msg, p, nLength);
return FuncV(…);
}
这段代码有问题么?很多程序员都会发现并且大声说有问题,但是在实际编写代码的时候,他们还是会写出这样的代码。上面的代码在大多数时候应该都是可以工作的,但是当第二个参数的取值大于512(0x200)时便有问题了,这时会有超出局部变量m长度的数据写向这个缓冲区,超出的部分会把Cookie覆盖掉。因为放在栈上的参数原始值也被覆盖掉了,我们无法直接看到它们的值,但是因为函数中会把参数指定的内存区复制到栈帧中,因为我们可以通过观察整个栈帧来了解传进来的内容:
变量区的长度是0x204,因此我们从EBP-204开始显示,显示的长度为0x210字节。依稀可以看到,从第4行开始一直到结束是一个很长的文件名:c:\users\public\...\...dd.jpg
仔细核对一下,上面我们观察的放Cookie、父函数EBP和返回地址的地方(009afb2c~009afb38)正好是被这个长文件名给覆盖掉了。
为了安全
现在可以知道,因为模块U的函数U接受到一个比“预想长度”还长的参数时发生了缓冲区溢出,触发了GS机制,让当前线程开始执行__report_gsfailure函数。那么这个函数中都会做什么呢?在Visual Studio的源文件中可以找到它的源代码,源文件名为gs_report.c,典型的完整路径为:
c:\Program Files\Microsoft Visual Studio 8\VC\crt\src\gs_report.c
看一下这个函数的源代码,并不复杂,它主要做两件事,第一件是模拟一个异常现场,然后调用UnhandledExceptionFilter,这一步的主要目的是支持JIT调试和WER;第二件事便是终止当前进程:
TerminateProcess(GetCurrentProcess(), STATUS_STACK_BUFFER_OVERRUN);
看来SvcHost进程是因为发生缓冲区溢出而“自杀”的。为什么要如此做呢?假设没有GS机制,因为函数U的返回地址已经被破坏了,所以函数U就会返回到一个由参数内容所决定的未知地方,如果参数内容是精心设计的,那么函数U便可能返回到一个精心设计的地方,执行一段精心设计的代码,于是这个进程便成了恶意代码进入系统的“登陆地”,后果可能比“发疯”还糟。
恢复正常
原因找到了,要排除故障只要找到那个意外的参数是哪里来的。在WinRE中打开注册表,搜索那个超长的文件名,果真找到了,将其改为一个典型长度的文件名,然后关闭注册表编辑器,重启,系统恢复正常了。为了防止本文描述的问题在正式修复前被人用来做坏事,我们就不介绍可以重现这个问题的注册表表键了,前面故意隐掉有问题的模块名和函数名也是这个原因。