摘要
随着用户需求的复杂度越来越高,软件开发的难度也在不断地上升,快速高效的软件开发已成为项目成败的关键之一。为了提高程序员的产品率,开发工具的选择尤为重要,因为开发工具的自动化程度可以大大减少程序员繁琐重复的工作,使其集中关注他所面临的特定领域的问题。
为此,当前的IDE不可避地要向用户隐藏着大量的操作细节,而这些细节包含了大量的有价值的技术。本文将着重研究从源程序到机器码的详细过程,而不注重程序本身的功能。另一方面也为了便于分析,因此选取了一个简易的记事本源程序(约600行)。本文将根据它来了解源程序是怎么一步步变成机器码的,又是怎么在计算机上运行起来的。
1. 工具简介
1. PE Explorer
PE Explorer是功能超强的可视化Delphi、C++、VB程序解析器,能快速对32位可执行程序进行反编译,并修改其中资源。它也是一款功能极为强大的可视化汉化集成工具,可直接浏览、修改软件资源,包括菜单、对话框、字符串表等; 另外,还具备有 W32DASM 软件的反编译能力和PEditor 软件的 PE 文件头编辑功能,可以更容易的分析源代码,修复损坏了的资源,可以处理 PE 格式的文件如:EXE、DLL、DRV、BPL、DPL、SYS、CPL、OCX、SCR 等 32 位可执行程序。该软件支持插件,你可以通过增加插件加强该软件的功能, 原公司在该工具中捆绑了 UPX 的脱壳插件、扫描器和反汇编器。
2. dumpbin
Dumpbin是VC自带的二进制转储工具,可以将PE/COFF文件以文字可读的方式显示出来。
2. 实验过程
1) 平台说明
操作系统:Windows XP Professional with SP3
开发工具:Visual Studio 2005 Professional Edition
开发语言:VC++
源文件:notepad.cpp 约600行
2) 程序的编译与链接
目标程序运行效果如图表1。整个程序包含9个函数,如图表2所示。
图 1 目标程序运行效果
style="text-align: left;">
图 2 目标函数
源程序被编译成机器码,在这个过程中除了词法分析、语法分析、语义分析、机器码生成外,最需要程序员关注的是程序的链接过程。每个C/C++源文件是一个独立的编译模块,也就是说每个文件会首先被编译成目标文件,如这里的*.obj文件,这个过程是编译器的工作。在目标文件中源程序的函数已被翻译成了机器码。此外目标文件还包含最重要的一个信息就是重定位信息,这里的重定位信息一般是指静态重定位信息。静态重定位信息包含了怎样修改引用数据和子程序的指令以及数据的重定位信息。为什么要包含重定位信息呢?前面已提到,每个源文件是一个独立的编译模块,那么如果在这个源文件中的函数调用了另外一个源文件中的函数或引用了它的变量时,那么在编译本源文件时是无法知道那个函数的地址的。因些在生成这些指令时,只能放占位符这样的信息。
当进入链接过程的时假,链接器除了要进行空间分配外,就是要进行符号的解析和符号的重定位。在汇编级或机器指令级,实质上已经没有了函数的概念了。因为函数本身是作为高级语言的一种抽象,现在目标文件中只是一堆机器码。为了表示一个指令序列或数据空间,用使用了符号这一术语。链接器的空间分配是指根据PE文件格式规范生成可执行文件,在这个过程中如果安排指令和数据以及动态重定位等等的过程。简单地讲,符号解析是指将找到各模块间相互引用的函数符号,符号重定位就是将前面提到的指令占位符号修改成正常的指令。当然还包括数据的重定位,相象一下程序引用了一个动态链接库里的变量。象这些同样要生成重定位信息。
为了减少干扰,将源程序进行Release编译。在工程的Release目录可以看到notepad.obj文件。在"开始">"Microsoft Visual Studio 2005"->"Visual Studio Tools"->"Visual Studio 2005 Command Prompt",启动命令提示符。然后执行dumpbin命令,导出符号信息。如图表3所示。
图 3 转储符号信息
由于导出信息很多,只列出如下几个符号:
0D5 00000000 SECT30 notype () External | _WinMain@16
01D 00000000 SECT5 notype () External | ?GetFileName@@YAXXZ (void __cdecl GetFileName(void))
07D 00000000 SECT21 notype () External | ?WndProc@@YGJPAUHWND__@@IIJ@Z (long __stdcall WndProc(struct HWND__ *,unsigned int,unsigned int,long))
025 00000000 SECT8 notype () External | ?FileToEditBox@@YA_NPAUHWND__@@PAD@Z (bool __cdecl FileToEditBox(struct HWND__ *,char *))
这里源程序中自定义的函数,它的名称已经是面目全非了。这象处理的原因在于,C++的函数重载导致的。函数重载使得,相同函数名称却有不同的函数签明。所以不经过处理,在下层就无法知道确切的函数。因此,为了使每个函数的标识唯一,就要对函数名称进行易容处理(mangle),相反的过程叫作复容处理(demangle)。
1) PE/COFF格式
VC/C++链接器,生成的可执行文件是PE格式,PE格式一类文件的规范,这个规范明确指定了在Windows平台可执行程序文件的内部结构,主机针对x86保护模式的程序。COFF就是目标文件*.obj的格式规范。PE文件另一个名称就是映象文件,说它是映象文件是因为操作系统的加载器把它加载到内存后,会形成一个它的映象。但内存映象与文件并不一定一致辞。如调试符号信息一般不会被加载到内存,它主要由调试器使用。常见文件就是*.dll和*.exe类型的文件。而*.com并不是PE文件,但它也是可执行文件,它运行的环境是虚拟8086模式,并非保护模式。
图4是PE格式的布局图。PE文件使用的是一个平面地址空间,所有代码和数据都合并在一起,组成一个很大的结构。主要有:.text 是在编译或汇编结束时产生的一种块,它的内容全是指令代码;.rdata 是运行期只读数据;.data 是初始化的数据块;.bss是未初始化的数据节;.idata 包含其它外来DLL的函数及数据信息,即输入表;.rsrc 包含模块的全部资源:如图标、菜单、位图等。
图 4 PE文件格式
现在使用PE Explorer对编译的notepad.exe程序进行逆向。如图5所示。由图可以知道程序入口点是0x000028DFh。当程序被加载到内存执行时,第一条指令将从这里取得。注意,这个地址是相对虚拟地址(RVM),程序的入口点地址还要道基地址才能得出。
图表 5 PE Explorer逆向notepad.exe文件
图 6数据目录
图 7节区头数据
图6和图7分别是读到了数据目录和区段头信息。
1) 调用协定
调用协定规定了函数调用的参数传递方式及返回值的传递方式。它是应用程序二进制兼容的必要面规范。常见的调用协定有如下方式:
① __stdcall
用于调用Win32 API函数。采用__stdcall约定时,函数参数按照从右到左的顺序入栈,被调用的函数在返回前清理传送参数的栈,函数参数个数固定。由于函数体本身知道传进来的参数个数,因此被调用的函数可以在返回前用一条ret n指令直接清理传递参数的堆栈。
② _cdecl:
是C调用约定, 按从右至左的顺序压参数入栈,由调用者把参数弹出栈。对于传送参数的内存栈是由调用者来维护的(正因为如此,实现可变参数的函数只能使用该调用约定)。另外,在函数名修饰约定方面也有所不同。
③ fastcall
快速调用方式。它的主要特点就是快,因为它是通过寄存器来传送参数的。实际上,它用ECX和EDX传送前两个双字(DWORD)或更小的参数,剩下的参数仍旧自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈。
④ 参数传递分析
在目标程序中有这样一个函数声明如下:
BOOL ShowFileInfo( HWND hwnd,HDC hDC,HDROP hDropInfo )
发生调用地方为:ShowFileInfo(hwnd,hDC,hDropInfo);
可以看到最后一条指令是堆栈平衡用的,传递了三个参数,每个参数的大小都为4个字节,所以大小刚好是0x0Ch。还可以看到第一个压栈的参数是hDropInfo,另外两参数都是用ebp来做基址寻址取到的,说明前两个参数不是局部变量。参数传递方向从右到左依次压栈。
1) 堆栈平衡
参数传递后由调用者或被调用者负责平衡堆栈,但函数使用了局部变量,那堆栈又是如何保持平衡的呢?这里引入了一个叫栈帧(Stack Frame)的概念。栈帧实质就一个函数栈所用的堆栈空间。每个函数都平衡了,那么整个程序栈也就平衡了。如图8所示,函数体的第一条指令就是保存ebp寄存器,它存的就是上一个函数的栈帧边界。第二条指令就是制定当前函数的栈帧的起始位置。第三条指令就是为函数分配局部变量的堆栈空间了。
图 8 函数栈的平衡
根椐VC/C++的调用协定,寄存器EAX、ECX、EDX是易变寄存器,也就是说调用函数不能假定被调用函数不改变它们的值。因此,调用函数想保留它们的值,在调用一个函数之前应自已先把它们保存起来了。另外的5个通用寄存器(EBX、ESP、EBP、ESI、EDI),则是非易变的。被调用函数在使用它们之前必须先保存。
所以上图的汇编指令就不难理解了。函数执行完毕后,只需把先前保存在栈中的EBP弹到ESP就保持了栈的平衡了。情况确实如此。如图9所示,最后一条指令是pop ebp,然后返回。根据返回指令,还可行知此函数使用的是cdecl调用协定。因为它没有参数的堆栈平衡。