说简单点,ELF 对应于UNIX 下的文件,而PE 则是Windows 的可执行文件,分析ELF 和 PE 的文件结构,是逆向工程,或者是做调试,甚至是开发所应具备的基本能力。在进行逆向工程的开端,我们拿到ELF 文件,或者是PE 文件,首先要做的就是分析文件头,了解信息,进而逆向文件。不说废话,开始分析:
ELF和PE 文件都是基于Unix 的 COFF(Common Object File Format) 改造而来,更加具体的来说,他是来源于当时著名的 DEC(Digital Equipment Corporation) 的VAX/VMS 上的COFF文件格式。我们从ELF 说起。
ELF 文件标准里把系统中采用ELF 格式的文件归类为四种:
ELF 文件的总体结构大概是这样的:
ELF Header | |
---|---|
.text | |
.data | |
.bss | |
… other section | |
Section header table | |
String Tables, Symbol Tables,.. |
ELF 文件信息的查看利器在Linux 下是是objdump, readelf, 相关命令较多,可查。下面我们从ELF 文件头说起。
文件头包含的内容很多,我们在Ubuntu 系统下使用 readelf 命令来查看ELF 文件头:
我们以bash 这个可执行文件为例,我们可以看到ELF 文件头定义了ELF 魔数,文件机器字节长度,数据存储方式,版本,运行平台,ABI版本,ELF 重定位类型,硬件平台,硬件平台版本,入口地址,程序头入口和长度,段表的位置和长度,段的数量。
ELF 文件头的结构和相关常数一般定义在了 /usr/include/elf.h 中,我们可以进去查看一下:
除了第一个,其他都是一一对应的,第一个是一个对应了Magic number, Class, Data, Version, OS/ABI, ABI version.
出现在最开始的ELF Magic number, 16字节是用来标识ELF 文件的平台属性,比如字长,字节序,ELF 文件版本。在加载的时候,首先会确认魔数的正确性,不正确的话就拒绝加载。
另一个重要的东西是段表(Section Header Table) ,保存了各种各样段的基本属性,比如段名,段长度,文件中的偏移,读写权限,段的其他属性。而段表自己在ELF 文件中的位置是在ELF 头文件 e_shoff 决定的。
我们可以使用 objdump -h 的指令来查看ELF 文件中包含哪些段,以bash 这个可执行为例,其实除了我们之前说的哪些基本结构,他包含很多其他的结构:
同样的,我们使用readelf -S 的指令也可以进行查看。
下面我们来看一下结构,还是到elf.h 中去查看,他的结构体名字叫 Elf32_Shdr,64位对应Elf64_Shdr,结构如下:
以上结构中,分别对应于:
这些项目,在使用readelf -S 指令时一一对应。
另外还有一个重要的表,叫重定位表,一般段名叫.rel.text, 在上边没有出现,链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,就是代码段和数据段中那些对绝对地址引用的位置,这个时候就需要使用重定位表了。
为什么会有字符串表呢?其实这个也是在不断发展改进中找到的解决办法,在ELF 文件中,会用到很多的字符串,段名,变量名等等,但是字符串其本身又长度不固定,如果使用固定结构来表示,就会带来空间上的麻烦。所以,构造一个字符串表,将使用的字符串统一放在那里,然后通过偏移量来引用字符串,岂不美哉。
需要使用的时候,只需要给一个偏移量,然后就到字符串该位置找字符串,遇到\0 就停止。
字符串在ELF 文件中,也是以段的形式保存的,常见的段名 .strtab, .shstrtab 两个字符串分别为字符串表和段表字符串,前者用来保存普通的字符串,后者保存段名。
在我们使用readelf -h 的时候,我们看到最后一个成员,section header string table index ,实际上他指的就是字符串表的下标,bash 对应的字符串表下标为27,在使用objdump 的时候,实际上忽略了字符串表,我们使用readelf ,就可以看到第27位即字符串表:
下面我们回顾一下,这个ELF 构造的精妙之处,当一个ELF 文件到来的时候,系统自然的找到他的开头,拿到文件头,首先看魔数,识别基本信息,看是不是正确的,或者是可识别的文件,然后加载他的基本信息,包括CPU 平台,版本号,段表的位置在哪,还可以拿到字符串表在哪,以及整个程序的入口地址。这一系列初始化信息拿到之后,程序可以通过字符串表定位,找到段名的字符串,通过段表的初始位置,确认每个段的位置,段名,长度等等信息,进而到达入口地址,准备执行。
当然,这只是最初始的内容,其后还要考虑链接,Import,Export 等等内容,留待以后完善。
下面我们去看看更为常见的PE 文件格式,实际上PE 与 ELF 文件基本相同,也是采用了基于段的格式,同时PE 也允许程序员将变量或者函数放在自定义的段中, GCC 中attribute(section(‘name’)) 扩展属性。
PE 文件的前身是COFF,所以分析PE 文件,先来看看COFF 的文件格式,他保存在WinNT.h 文件中。
COFF 的文件格式和ELF 几乎一毛一样:
Image Header | |
---|---|
SectionTable Image_SECTION_HEADER | |
.text | |
data | |
.drectve | |
.debug$S | |
… other sections | |
Symbol Table |
文件头定义在WinNT.h 中,我们打开来看一下:
我们可以看到,它这个文件头和ELF 实际上是一样的,也在文件头中定义了段数,符号表的位置,Optional Header 的大小,这个Optional Header 后边就看到了,他就是PE 可执行文件的文件头的部分,以及段的属性等。
跟在文件头后边的是COFF 文件的段表,结构体名叫 IMAGE_SECTION_HEADER :
属性包括这些,和ELF 没差:
在我们分析PE 的之前,还有另外一个头要了解一下,DOS 头,不得不说,微软事儿还是挺多的。
微软在创建PE 文件格式时,人们正在广泛使用DOS 文件,所以微软为了考虑兼容性的问题,所以在PE 头的最前边还添加了一个 IMAGE_DOS_HEADER 结构体,用来扩展已有的DOS EXE。在WinNTFS.h 里可以看到他的身影。
DOS 头结构体的大小是40字节,这里边有两个重要的成员,需要知道,一个是e_magic 又见魔数,一个是e_lfanew,它只是了NT 头的偏移。
对于PE 文件来说,这个e_magic,也就是DOS 签名都是MZ,据说是一个叫 Mark Zbikowski 的开发人员在微软设计了这种ODS 可执行文件,所以…
我们以Windows 下的notepad++ 的可执行文件为例,在二进制编辑软件中打开,此类软件比较多,Heditor 打开:
开始的两个字节是4D5A,e_lfanew 为00000108 注意存储顺序,小端。
你以为开头加上了DOS 头就完事了么,就可以跟着接PE 头了么。为了兼容DOS 当然不是这么简单了,紧接着DOS 头,跟的是DOS 存根,DOS stub。这一块就是为DOS 而准备的,对于PE 文件,即使没有它也可以正常运行。
旁边的ASCII 是读不懂的,因为他是机器码,是汇编,为了在DOS 下执行,对于notepad++ 来说,这里是执行了一句,this program cannot be run in DOS mode 然后退出。逗我= =,有新的人,可以在DOS 中创造一个程序,做一些小动作。
下面进入正题,在HEditor 上也看到了PE,这一块就是正式的步入PE 的范畴。
这是32位的PE 文件头定义,64位对应改。第一个成员就是签名,如我们所说,就是我们看到的「PE」,对应为50450000h。
这里边有两个东西,第一个就是我们之前看到的COFF 文件头,这里直接放进来了,我们不再分析。
看第二个,IMAGE_OPTIONAL_HEADER 不是说这个头可选,而是里边有些变量是可选的,而且有一些变量是必须的,否则会导致文件无法运行:
有这么几个需要重点关注的成员,这些都是文件运行所必需的:
PE 的段头直接沿用的COFF 的段头结构,上边也说过了,我们查看notepad++ 的段头,可以获得各个段名,以及其信息,这里,我们可以使用一些软件查看,更加方便:
理解PE 最重要的一个部分就是理解文件从磁盘到内存地址的映射过程,做逆向的人员,只有熟练地掌握才能跟踪到程序的调用过程和位置,才能分析和寻找漏洞。
对于文件和内存的映射关系,其实很简单,他们通过一个简单的公式计算而来:
换算公式是这样的:
RAW -PointToRawData = RVA - VirtualAddress
寻找过程就是先找到RVA 所在的段,然后根据公式计算出文件偏移。因为我们通过逆向工具,可以在内存中查找到所在的RVA,进而我们就可以计算出在文件中所在的位置,这样,就可以手动进行修改。
看回我们刚才载入的nodepad++ ,其中的V Addr, 实际上就是VirtualAddress,R offset 就是PointerToRawData。
假如我们的RVA 地址是5000,那么计算方法就是,查看区段,发现在.text 中,5000-1000+400 = 4400,这就是RAW 00004400,而实际上,因为我们的ImageBase 是00400000,所以,我们在反编译时候内存中的地址是00405000.
接下来,使我们的PE头中的核心内容,IAT 和 EAT,也就是 Import address table, export address table.
导入地址表的内容与Windows 操作系统的核心进程,内存,DLL 结构有关。他是一种表格,记录了程序使用哪些库中的哪些函数。
下面,让我们把目光转到DLL 上,Dynamic Linked Library 支撑了整个 OS。DLL 的好处在于,不需要把库包含在程序中,单独组成DLL 文件,需要时调用即可,内存映射技术使加载后的DLL 代码,资源在多个进程中实现共享,更新库时候只要替换相关DLL 文件即可。
加载DLL 的方式有两种,一种是显式链接,使用DLL 时候加载,使用完释放内存。另一种是隐式链接,程序开始就一同加载DLL,程序终止的时候才释放掉内存。而IAT 提供的机制与隐式链接相关,最典型的Kernel32.dll。
我们来看看notepad++ 调用kernel32.dll 中的CreateFileW, 使用PE 调试工具Ollydbg
我们看到填入参数之后,call 了35d7ffff 地址的内容,然后我们去dump 窗口,找一下kernel.CreateFileW:
我们双击汇编窗口,启动编辑,发现确实是call 的这个数值:
可是问题来了,上边是E8 35D7FFFF,下边地址却是 00C62178。其实这是Win Visita, Win 7的ASLR 技术,主要就是针对缓冲溢出攻击的一种保护技术,通过随机化布局,让逆向跟踪者,难以查找地址,就难以简单的进行溢出攻击。不过还是可以通过跳板的方式,找到溢出的办法,这就是后话了。
现在可以确定的是,35D7FFFF 可以认为保存的数值就是 CreateFileW 的地址。而为什么不直接使用CALL 7509168B 这种方式直接调用呢? Kernel32.dll 版本各不相同,对应的CreateFileW 函数也各不相同,为了兼容各种环境,编译器准备了CreateFileW 函数的实际地址,然后记下DWORD PTR DS:[xxxxxx] 这样的指令,执行文件时候,PE 装载器将CreateFileW 函数地址写到这个位置。
同时,由于重定位的原因存在,所以也不能直接使用CALL 7509168B 的方式,比如两个DLL 文件有相同的 ImageBase,装载的时候,一个装载到该位置之后,另一个就不能装载该位置了,需要换位置。所以我们不能对实际地址进行硬编码。
IMAGE_IMPORT_DESCRIPTOR
对于一个普通程序来说,需要导入多少个库,就会存在多少个这样的结构体,这些结构体组成数组,然后数组最后是以NULL 结构体结束。其中有几个重要的成员:
那么PE 是如何导入函数输出到IAT 的:
这里就产生了一个疑惑,OriginalFirstThunk 和 First Thunk 都指向的是函数,为什么多此一举呢?
首先,从直观上说,两个都指向了库中引入函数的数组,鱼C 画的这张图挺直观:
OriginalFirstThunk 和 FirstThunk 他们都是两个类型为IMAGE_THUNK_DATA 的数组,它是一个指针大小的联合(union)类型。
每一个IMAGE_THUNK_DATA 结构定义一个导入函数信息(即指向结构为IMAGE_IMPORT_BY_NAME 的家伙,这家伙稍后再议)。
然后数组最后以一个内容为0 的 IMAGE_THUNK_DATA 结构作为结束标志。
IMAGE_THUNK_DATA32 结构体如下:
因为是Union 结构,IMAGE_THUNK_DATA 事实上是一个双字大小。
规定如下:
当 IMAGE_THUNK_DATA 值的最高位为 1时,表示函数以序号方式输入,这时候低 31位被看作一个函数序号。
当 IMAGE_THUNK_DATA 值的最高位为 0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个 RVA,指向一个 IMAGE_IMPORT_BY_NAME 结构。
我们再看IMAGE_IMPORT_BY_NAME 结构:
结构中的 Hint 字段也表示函数的序号,不过这个字段是可选的,有些编译器总是将它设置为 0。
Name 字段定义了导入函数的名称字符串,这是一个以 0 为结尾的字符串。
现在重点来了:
第一个数组(由 OriginalFirstThunk 所指向)是单独的一项,而且不能被改写,我们前边称为 INT。第二个数组(由 FirstThunk 所指向)事实上是由 PE 装载器重写的。
PE 装载器装载顺序正如上边所讲的那样,我们再将它讲详细一点:
PE 装载器首先搜索 OriginalFirstThunk ,找到之后加载程序迭代搜索数组中的每个指针,找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口,因此我们称为输入地址表(IAT).
继续套用鱼C 的图,就能直观的感受到了:
所以,在读取一次OriginalFirstThunk 之后,程序就是依靠IAT 提供的函数地址来运行了。
搞清楚了IAT 的原理,EAT 就好理解了,目前这篇总结的有点长了,我长话短说。IAT 是导入的库和函数的表,那么EAT 就对应于导出,它使不同的应用程序可以调用库文件中提供的函数,为了方便导出函数,就需要保存这些导出信息。
回头看PE 文件中的PE头我们可以看到IMAGE_EXPORT_DIRECTORY 结构体以的位置,他在IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress 的值就是 IMAGE_EXPORT_DIREDCTORY 的起始位置。
IMAGE_EXPORT_DIRECTORY结构体如下:
这里边同样是这么几个重要的成员:
从上边这些成员,我们实际上可以看出,是有两种方式提供给那些想调用该库中函数的,一种是直接从序号查找函数入口地址导入,一种是通过函数名来查找函数入口地址导入。
先上一个鱼C 的图,方便理解:
上边图,注意一点,因为AddressOfNameOrdinals 的序号应当是从0开始的,不过图中映射的是第二个函数指向的序号1。
我们分别说一下两种方式:
当已知导出序号的时候
当已知函数名称查找入口地址时
一般来说,做逆向或者是写代码都是第二种方法,我们以kernel32.dll 中的GetProcAddress 函数为例,其操作原理如下:
写了这么多,实际上算是对文件结构有了一个入门的认识,至少知道在程序运行过程中,系统是如何进行操作和链接的,而更加详细的内容注入运行时压缩,DLL 注入,API 钩取等技术,就需要在这个基础之上继续挖掘,所以PE ,ELF 文件结构的分析是相当重要的。
PS. 参考:
鱼C 讲解PE 文件格式之INT
《Windows PE 权威指南》
《逆向工程核心原理》
《程序员的自我修养-链接,装载与库》