我们有时在编写一些实用工具时可能想要显示一个类似与Windows中的标准“运行”对话框。虽然我们可以自己手工实现这个对话框,但是标准的“运行“对话框中却可以列出你曾经键入的命令。并且,最后一次输入的命令,如果它能够正常运行,那么它总是出现在组合框的最上端。在Windows中很多数据都是保存在系统注册表中的,我们当然有理由认为像这样的数据就被保存在注册表中。通过键入一些可用命令并在注册表中搜索,我们可以找到这个存储位置为HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU这个子键(SubKey)。这个子键中的每个值(Value)中的数据(Data)就是你曾经键入的命令。其中有个值MRUList保存的是你键入的命令的顺序。不过它保存的是键入的相反顺序,也就是说,最后一次键入并成功运行的命令总是出现在这个值的数据中的第一个位置。当然,我们可以枚举RunMRU这个子键然后将曾经运行过的各个命令添加到自己手工实现的运行对话框中的组合框中,并分析MRUList这个值中的数据来对枚举出的各个命令字符串进行排序。这样就能完全模仿系统的“运行”对话框。这不失为一种方法。但是,一定有许多人像我一样,确实想知道到底系统是如何显示这个对话框的。那就让我们一步一步来分析吧!
在调用这个对话框的系统程序中,我们首先想到就是系统Shell(即explorer.exe)。本想拿它来做实验,但是它太大,也太复杂,并不合适。接下来能想到的程序可能就是任务管理器了(即taskmgr.exe)。当你启动任务管理器后,单击“文件”菜单,就会看到“新建任务”命令,单击它就会出现“运行“对话框。另外一个会显示“运行”对话框的程序就是下面要提到的Process Explorer,并且它的“运行”对话框有标准的,也有定制的。那任务管理器是如何显示这个对话框的呢?这里,我要提到一个工具,那就是Process Explorer,它是由著名的Windows NT黑客Mark E. Russinovich编写的。现在Mark已经进入Microsoft,成为核心开发成员之一。它的网站www.sysinternals.com(现在已经合并到Microsoft网站中)中免费公开许多实用工具(甚至它们的源代码),像著名的FileMon和RegMon等等。在他与David A. Solomon合著的《Inside Windows 2000, Third Edition》中,Solomon曾经提到Mark不看Windows源代码,仅用ntoskrnl.exe(Windows执行内核)的反汇编代码并配合SoftICE就能很快解决遇到的问题。就连Windows NT之父David N. Cutler也佩服他竟然能在正在运行的Windows上运行内核调试器(Microsoft虽然支持内核调试,但是一定要在启动选项加入/DEBUG并重新启动之后才能有效地使用内核调试器),以及能让Windows 9x支持NTFS分区。这些都是十分棘手的问题。否则,Microsoft没有必要不自己实现。Mark的水平由此可见一斑。Process Explorer本来就是被设计用来替代任务管理器的,它实现了任务管理器的一切功能,并且又加入了许多有用的功能。今天我们要用到的就是其中的一个功能。
寻找切入点
当任务管理器显示“运行”对话框后,我们启动Process Explorer,双击其中的taskmgr.exe进程,在弹出的属性对话框中单击“Thread”选项卡,如果你正确配置了系统文件的调试符号,你会看到taskmgr.exe的各个线程。在“Start Address”一栏中,双击最上面的taskmgr.exe!ModuleEntry,会弹出一个关于这个线程的堆栈的对话框,查找此对话框,我们会看到这样两行:
SHELL32.dll!RunFileDlg+0xc4
taskmgr.exe!RunDlg+0x99
只所以会注意到这两行,是因为只有它们与运行(Run)对话框(Dialog,在Windows编程中常简称为Dlg)有关。这表明taskmgr.exe中的RunDlg函数调用了Shell32.dll中的RunFileDlg函数。由于各种程序(explorer.exe、taskmgr.exe,以及Process Explorer)所显示的“运行”对话框基本一样,所以我们有理由猜测它是由Windows自身实现的标准对话框。由上面堆栈中的两行内容我们可以初步猜测系统中提供的标准“运行”对话框可能是由Shell32.dll中的RunFileDlg函数实现的。如何验证我们的想法呢?首先,我们可以查看Shell32.dll的导出函数,看其中是否有RunFileDlg函数。令人遗憾的是,Shell32.dll并没有导出以这个名字命名的函数,但是它却导出了许多没有名字的函数,也就是说,只能以序号来调用这些函数。根据Microsoft向来好隐藏一些API函数这一点,我们可以猜测这个函数可能是仅由序号导出的。为了找出到底是如何调用这个函数的,读者当然可以调试这些程序,或者使用一些监视软件,但是我更喜欢使用SoftICE来设置断点。我们用Loader32载入Shell32.dll的调试符号,然后用“bpx RunFileDlg”来设置断点。但是在函数名还没有打完时,按TAB键让SoftICE将命令补全,它却提示“找不到匹配的符号”。由于Win32 API都是stdcall调用约定,而stdcall调用约定会改写函数名(在前面加上下划线,后面加上@符号以及此函数所需参数的总字节数。要详细了解函数调用习惯及其对函数名称与参数的影响,可以参考John Robbins的《Debugging Applications》的Power Debugging with x86 Assembly Language and the Visual C++ Debugger Disassembly Window一章),所以猜测这个函数的名称已经被改写,改用“bpx _RunFileDlg”来设置断点。在输入_RunF后按TAB键,SoftICE就自动将命令补全了。然后,单击“开始”菜单中的“运行”,SoftICE弹出,中断在函数_RunFileDlg的入口处。此时观察堆栈,发现最上面一行为shell32!_RunFileDlg。单击Process Explorer的“File”菜单下的“Run”,结果与上述情况完全一样。单击“任务管理器”中的“新建任务”,情况依然如此。这就充分说明了Shell32.dll中确实存在这个产生标准“运行”对话框的函数_RunFileDlg。由任务管理器中弹出的“运行”对话框的标题与图标与其它两个程序不同,这是不是表示这个对话框可以被定制呢?接下来的任务就是找出它的导出序号和各个参数及其功能了。
识别第二个参数
现在再来看其它五个参数。在taskmgr.exe的反汇编代码中,倒数第二个被压入堆栈中的参数是ebx,分析前面ebx寄存器中值的变化,发现ebx中的值是调用LoadImageW函数的返回值。而LoadImageW函数的第三个参数为1,也就是IMAGE_ICON,实际上这个函数就是加载一个图标,ebx中就是此图标的句柄。此时再看其第二个参数。根据MSDN中的描述,很明显这是一个资源的序号。现在看第一个参数,IDA Pro已经认出它是一个全局的实例句柄。显然,这里指的是任务管理器的实例句柄。我们用资源查看工具(例如Visual Studio 2005)来看一下,很容易发现它就是任务管理器的图标ID。当你查看由任务管理器产生的“运行”对话框时,你会发现对话框左边的图标确实是任务管理器的图标。这说明,第二个参数是用来控制这个图标的,并且它是一个图标句柄(即HICON类型的变量)。再看RunFileDlg函数后面对DestroyIcon函数的调用,以ebx为参数,更证明了我们的推断。根据Windows命名习惯,可以将此参数命名为hIcon。
识别第三、四、五个参数
再来看对LoadString函数的第一次调用。此函数的第一个参数同样为任务管理器的实例句柄。第二个参数为一个字符串的ID。同样用资源查看工具,可以发现这个字符串为“Create New Task”,第三个参数eax用来保存这个字符串的地址。而eax中实际是一个局部变量的偏移地址(用ebp寻址这个变量,编译器一般为未使用栈帧指针省略—FPO优化的函数生成这样的代码)。也就是说,这个变量指向了这个字符串。从后面可以看出,这个局部变量作为RunFileDlg函数的第四个参数被压入堆栈。观察由任务管理器产生的“运行”对话框,你会发现此对话框的标题正是这个字符串。根据Windows命名习惯,我们可以将这个参数命名为lpszCaption。但是现在还不知道这个字符串可不可以是ANSI字符串(由taskmgr.exe中的用法知道它可以是UNICODE字符串)。
接下来看对LoadString函数的第二次调用。使用与上面相同的方法分析,可以知道RunFileDlg函数的第五个参数也是一个指向字符串的指针。在任务管理器它指向“Type the name of a program, folder, document, or Internet resource, and Windows will open it for you.”,这个字符串正是运行对话框中显示的说明文字。根据Windows命名习惯,可以将其命名为lpszText。与上面一样,我们现在还不知道它指向的字符串可不可以是ANSI类型的。
接着看对GetCurrentDirectory函数的调用。同样,它将获得的当前目录字符串放入一个局部变量中,然后将此局部变量的地址作为第三个参数传递给RunFileDlg函数。显然,这个参数是一个指向目录字符串的指针,但是现在还不知道它必须指向当前目录,还是可以指向其它目录。根据Windows命名习惯,将其命名为lpszDestDirectory。现在,同样不知道这个参数指向的字符串可不可以是ANSI类型的。
以上三个参数的不确定性可以通过实验来解决。
读者可能已经看到了,我在所有的示例程序中都使用了
#pragma comment(linker, "/entry:main")
这条链接器指令。因为Visual C++默认生成的程序要在调用你的入口函数前进行许多初始化操作,包括初始化一些全局变量、堆等,你可以直接在C语言中使用的argc和argv变量都是由它处理生成的。(详细信息可以参考Jeffrey Richter的《Programming Applications for Microsoft Windows,Fourth Edition》一书第四章Processes以及Microsoft Systems Journal—现在已合并到MSDN Magazine中Under the Hood专栏的部分文章)它将这些代码都静态链接到你的程序中(这也是你用C语言写一个Hello World程序也会很大的一个主要原因)。而在我们的程序中,我们只调用Win32 API,并不调用任何C/C++运行时库函数,也不使用任何它的全局变量,当然不需要它进行任何的初始化操作。这样,便于我们调试自己的发行版程序,而不是在由它生成的指令堆中挣扎了半天,还没有到我们自己写的代码中。