ELF文件格式.docx
《ELF文件格式.docx》由会员分享,可在线阅读,更多相关《ELF文件格式.docx(42页珍藏版)》请在冰豆网上搜索。
ELF文件格式
关于ELF文件格式的实验
现代Linux采用ELF做为其可连接和可执行文件的格式,因此ELF格式也向我们透出了一点Linux核内的情景,就像戏台维幕留下的一条未拉严的缝。
本文着重讲述32位ELF的同时附带了64位的信息,这两种格式如此雷同,以致于初次接触ELF的读者不必兼顾左右。
如果你对Windows比较熟悉,本文还将时时把你带回到PE中,在它们的相似之处稍做比较。
ELF文件以“ELF头”开始,后面可选择的跟随着程序头和节头。
地理学用等高线与等温线分别展示同一地区的地势和气候,程序头和节头则分别从加载与连接角度来描述EFL文件的组织方式。
下面我们进入正文。
1、ELF头
ELF头也叫ELF文件头,它位于文件中最开始的地方。
我用系统的是Fedora Core 2,它在elf.h文件中同时给出了ELF头在32位系统和64位系统下的结构,我们先来看一下:
typedef struct
{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
typedef struct
{
unsigned char e_ident[EI_NIDENT];
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff;
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
elf.h中关于ELF格式所有结构给出的定义,其成员字段的类型声名都是C语言基本类型的别名,不会再嵌套结构。
可以看出32位系统和64位系统下ELF头的结构基本相同,不同的是两种结构中的某个成员字段占用字节个数有所变化。
比如e_entry由32位下占4个字节的Elf32_Addr变为64位下占8个字节的Elf64_Addr,这是因为两种系统下CPU寻址能力不同造成的。
同理文件偏移也从4字节的Elf32_Off变为8字节的Elf64_Off。
有些成员字段虽然类型声名从Elf32_XXXX变成了Elf64_XXXX,该域所占的字节个数并未改变。
如Elf32_Half和Elf64_Half都占两个字节,Elf32_Word、Elf32_Sword、Elf64_Word、Elf64_Sword全都是4个字节。
尽量使用elf.h中的现有定义将使我们写的程序具有很强的可移植性。
另外ELF格式在两种系统下的这种雷同也使得我们可放心的抛弃它们的差别,专心研究其中的一种,然后再轻松的掌握另一种。
ELF头中每个字段的含意如下:
◆e_ident:
这个字段是ELF头结构中的第一个字段,在elf.h中EI_NIDENT被定义为16,因此它占用16个字节。
e_ident的前四个字节顺次应该是0x7f、0x45、0x4c、0x46,也就是"\177ELF"。
这是ELF文件的标志,任何一个ELF文件这四个字节都完全相同。
它让熟悉Windows的人想起'MZ'和'PE\O\O'。
第5个字节标志了ELF格式是32位还是64位,32位是1,64位是2。
第6个字节,在0x86系统上是1,表明数据存储方式为低字节优先。
第10个字节,指明了在e_ident中从第几个字节开始后面的字节未使用。
◆e_type:
ELF文件的类型,1表示此文件是重定位文件,2表示可执行文件,3表示此文件是一个动态连接库。
◆e_machine:
CPU类型,它指出了此文件使用何种指令集。
如果是Intel 0x386 CPU此值为3,如果是AMD 64 CPU此值为62也就是16进制的0x3E。
◆e_version:
ELF文件版本。
为1。
◆e_entry:
可执行文件的入口虚拟地址。
此字段指出了该文件中第一条可执行机器指令在进程被正确加载后的内存地址!
(PE可执行文件指出的是入口的相对虚拟地址RVA,它是相对于文件加载起始地址的一个偏移值,因此理论上PE文件可被加载到进程序空间任何位置,而ELF可执行文件只能被加载到固定位置)。
◆e_phoff:
程序头在ELF文件中的偏移量。
如果程序头不存在此值为0。
◆e_shoff:
节头在ELF文件中的偏移量。
如果节头不存在此值为0。
◆e_ehsize:
它描述了“ELF头”自身占用的字节数。
◆e_phentsize:
程序头中的每一个结构占用的字节数。
程序头也叫程序头表,可以被看做一个在文件中连续存储的结构数组,数组中每一项是一个结构,此字段给出了这个结构占用的字节大小。
e_phoff指出程序头在ELF文件中的起始偏移。
◆e_phnum:
此字段给出了程序头中保存了多少个结构。
如果程序头中有3个结构则程序头在文件中占用了3×e_phentsize个字节的大小。
◆e_shentsize:
节头中每个结构占用的字节大小。
节头与程序头类似也是一个结构数组,关于这两个结构的定义将分别在讲述程序头和节头的时候给出。
◆e_shnum:
节头中保存了多少个结构。
◆e_shstrndx:
这是一个整数索引值。
节头可以看作是一个结构数组,用这个索引值做为此数组的下标,它在节头中指定的一个结构进一步给出了一个“字符串表”的信息,而这个字符串表保存着节头中描述的每一个节的名称,包括字符串表自己也是其中的一个节。
至此为止我们已经讲述了“ELF头”,在此过程中提前提到的一些将来才用的概念,不必急于了解。
现在读者可自己编写一个小程序来验证刚学到的知识,这有助于进一步的学习。
ELF.elf.h文件一般会存在于/usr/include目录下,直接include它就可以。
但我们能够验证的知识有限,当更多知识联系在一起的时候我们的理解正误才可以得到更好的验证。
接下来我们再学习程序头。
2、程序头
程序头有时也叫程序头表,它保存了一个结构数组。
程序头是从加载执行的角度看待ELF文件的结果,从它的角度ELF文件被分成许多个段。
每个段保存着用于不同目的的数据,有的段保存着机器指令,有的段保存着已经初始化的变量;有的段会做为进程映像的一部分被操作系统读入内存,有的段则只存在于文件中。
熟悉Windows的读者很容易理解,因为从这个角度来讲程序头的作用有点像PE文件中的节表。
后面还会讲到ELF的节头,节头把ELF文件分成了许多节。
ELF文件的一部分常常是既在某一段中又在某一节中。
Linux和Windows的进程空间都采用的是平坦模式,没有x86的段概念,这里ELF中提到的段仅是文件的分段与x86的段没有任何联系。
我们仍然先看一下程序头中结构的定义,它们在32位系统与64系统下是多么雷同!
typedef struct
{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
typedef struct
{
Elf64_Word p_type;
Elf64_Word p_flags;
Elf64_Off p_offset;
Elf64_Addr p_vaddr;
Elf64_Addr p_paddr;
Elf64_Xword p_filesz;
Elf64_Xword p_memsz;
Elf64_Xword p_align;
} Elf64_Phdr;
注意有几三个字段改为Elf64_Xword类型它们都占64个二进制位。
如果手头有一个ELF文件(当然有),把文件指针移到在ELF头中e_phoff字段给出的位置,然后读出的内容就是程序头了。
程序头中保存着e_phnum(ELF头的成员)个Elf32_Phdr或Elf64_Phdr结构,每一个这样的结构描述了一个段,下面通过了解结构中每个字段来了解程序头和这些段吧!
◆p_type:
段的类型,它能告诉我们这个段里存放着什么用途的数据。
此字段的值是在elf.h中定义了一些常量。
例如1(PT_LOAD)表示是可加载的段,这样的段将被读入程序的进程空间成为内存映像的一部分。
段的种类再不断增加,例如7(PT_TLS)在以前就没有定义,它表示用于线程局部存储。
◆p_flags:
段的属性。
它用每一个二进制位表示一种属,相应位为1表示含有相应的属性,为0表示不含那种属性。
其中最低位是可执行位,次低位是可写位,第三低位是可读位。
如果这个字段的最低三位同时为1那就表示这个段中的数据加载以后既可读也可写而且可执行的。
同样在elf.h文件中也定义了一此常量(PF_X、PF_W、PF_R)来测试这个字段的属性,做为一个好习惯应该尽量使用这此常量。
◆p_offset:
该段在文件中的偏移。
这个偏移是相对于整个文件的。
◆p_vaddr:
该段加载后在进程空间中占用的内存起始地址。
◆p_paddr:
该段的物理地地址。
这个字段被忽略,因为在多数现代操作系统下物理地址是进程无法触及的。
◆p_filesz:
该段在文件中占用的字节大小。
有些段可能在文件中不存在但却占用一定的内存空间,此时这个字段为0。
◆p_memsz:
该段在内存中占用的字节大小。
有些段可能仅存在于文件中而不被加载到内存,此时这个字段为0。
◆p_align:
对齐。
现代操作系统都使用虚拟内存为进程序提供更大的空间,分页技术功不可没,页就成了最小的内存分配单位,不足一页的按一页算。
所以加载程序数据一般也从一页的起始地址开始,这就属于对齐。
尽管我给出了描述每个段信息的程序头结构,但我并不打算介绍任何一个具体类型的段所存储的内容,大多数情况下它们和节中保存的内容是一致的。
我们只关心可以加载的段,但上面给出的信息应该足够了。
好啦,你现在就是操作系统,你已经知道了组成程序的指令和数据都存放在文件的各个段中,通过程序头你知道它们在文件中的偏移和它们在文件中的大小,你就可以把这个段读到它的进程空间中以p_vaddr开始的地址处。
水平所限,我所能表达的必然不是精确的,为了更好理解程序头与进程加载,我设计了一个小实验并给出C语言代码——代码可以精确的说明一切!
3、覆盖ELF可执行文件入口指令的实验
现在掌握了ELF头和程序头,从加载执行程序的角度可以说已对ELF文件有了初步的了解。
为更好理解它,做个试验吧!
回忆一下程序头表把ELF文件分成了许多段,并告诉操作系统怎样把这些段读到内存里去。
当操作系统已按程序头表的指示把ELF文件各个段的数据读入到内存中相应的地方以后,就可以说操作系统已建立了完整且正确的进程映像(如果不考虑依赖),下一步就是要执行程序了。
ELF头的e_entry给出了第一条机器指令在内存中的地址,操作系统只要在某个时候将指令流引向那里就可以了。
这个猜测对不对呢,下面的这个实验将从某种角度来证明它。
首先准备好一段代码,把这段代码写到ELF文件中,代码写入的位置恰恰是ELF文件的第一条机器指令在文件中的位置。
这样当系统把这个修改过的可执行程序加载到内存时,它原来入口处的指令已经换成了我们准备的这段代码,程序的行为被完全改变。
可是ELF头的e_entry给出的是内存地址而不文件偏移,所以这需要我们自己找到这个文件偏移。
怎么找?
运用刚刚掌握的知识。
程序头不是给出了文件中每一段对应的内存起始地址吗,还有每一段在内存中占了多少字节。
只要遍历程序头中的每一个结构,看看哪个段的起始内存地址小于等于e_entry并且该地址加上该段内存大小又大于e_entry,那么这个段就是程序第一条指令所在的段。
第一条指令在段中偏移就是e_entry减去该段的p_vaddr所得的值,第一条指令在整个文件中偏移 =该段的p_offset +(e_entry -该段的p_vaddr)。
下面就是我准备的那段代码,它是一个没有参数的C函数exit_print。
对于这段代码有三点需要说明。
1)这个函数中不能调用常用的库函数,因为若从so中取函数我们现在无法解决动态引入;如果采用静态连接,被调用函数有可能再调用其它函数,而被调用函在内存映像的地址、大小都不易掌握。
2)这个段代码最好是位置无关代码,这样能减少这个实验的代码量,而使用全局或静态变量将使我们花更大代价来实现位置无关,所以这个函数不使用它们。
3)这个函数只能在IA32机器上运行,若想在其它环境下做此实验必须修改它的一段汇编代码。
另外我们没有判断ELF文件是否为可执行文件。
为了确信这段代码被运行,它将在控制台输出“hello!
Hangj!
”之后就结束整个程序。
鉴于上面的两点说明,我们不能使用printf和malloc输出字符串和为它分配内存,也没有把完整的字符串做为变量存储,而是用了堆栈中的局部变量,这将导致栈中内存分配。
把字符串放到strHello中用了四条C语句。
注意,前三条中每条语句放入的四个字符的顺序是颠倒的,这是x86低字节优先存储造成的。
最后一条C语句放入一个回车符‘\n’,字符串没有以0结尾。
void exit_print()
{
char strHello[20];
*((unsigned long*)&strHello[0])='lleH';
*((unsigned long*)&strHello[4])='aH!
o';
*((unsigned long*)&strHello[8])='!
jgn';
strHello[12]='\n';
__asm__ volatile ("int $0x80; movl $0,%%ebx; movl $1,%%eax; int $0x80"\
:
\
:
"a"((long) 4),\
"b" ((long)1),\
"c" ((long)(&strHello[0])),\
"d" ((long)13));
}
exit_print用到了一些汇编语法,不防在这里先复习下汇编,如果你不喜欢看汇编,可以直接阅读后面给出的完整C代码,我可以保证它实现上面想要的功能。
gcc内部汇编以“__asm__”开始,关键字volatile告诉gcc不要优化。
汇编体以一对小括号包围并以分号结束:
输入部分把寄存器EAX置为4,这是write系统调用的功能号;EBX置为1,这write系统调用使用的文件句柄,1代表标准输出设备;寄存器ECX置为字符串的起始地址;寄存器EDX置为13,这代表字符串的长度是13个字节;我们不关心系统返回值因此输出部分没有内容;接下来int $0x80把刚才的设置到寄存器的参数传给内核完成打印功能!
后面在把寄存器EBX置0、EAX置1后又是一次系统调用,它将结束当前进程并把EBX中的0返回给父进程。
函数exit_print说明完毕!
下面给出这个试验程序的完整代码,它被存为mod_entry.c文件,exit_print函数也在其中:
//////////////////////////////////////////////////////////////////////
//文件名 :
mod_entry.c
//功能 :
覆盖ELF可执行文件指令入口
//创建 :
2004.11.28
//修改日期 :
2004.11.28
//作者 :
//
////////////////////////////////////////////////////////////////////////
#include "stdio.h"
#include "unistd.h"
#include "fcntl.h"
#include "elf.h"
void exit_print()
{
char strHello[20];
*((unsigned long*)&strHello[0])='lleH';
*((unsigned long*)&strHello[4])='aH!
o';
*((unsigned long*)&strHello[8])='!
jgn';
strHello[12]='\n';
__asm__ volatile ("int $0x80; movl $0,%%ebx; movl $1,%%eax; int $0x80"\
:
\
:
"a"((long) 4),\
"b" ((long)1),\
"c" ((long)(&strHello[0])),\
"d" ((long)13));
}
/*
AMD 64下的调用write系统调用可能有如下形式,其中__syscall是中断调用指令,__NR_write是系统功能号:
__asm__ volatile (__syscall \
:
\
:
"a" (__NR_write),"D" ((long)
(1)),"S" ((long)(&strHello[0])),"d" ((long)(13)) :
"r11","rcx","memory" );
*/
//简单判断是否是ELF文件
int IsElf(Elf32_Ehdr *pEhdr)
{
if(pEhdr->e_ident[EI_MAG0]!
=0x7f
||pEhdr->e_ident[EI_MAG1]!
='E'
||pEhdr->e_ident[EI_MAG2]!
='L'
||pEhdr->e_ident[EI_MAG3]!
='F'
||pEhdr->e_machine!
=EM_386)//是否在x86上运行
return 0;
return 1;
}
//从指定的位置读文件
int ReadAt(int hFile,int pos,void *buf,int count)
{
if(pos==lseek(hFile,pos,SEEK_SET))
{
return read(hFile,buf,count);
}
return -1;
}
//从指定的位置写文件
int WriteAt(int hFile,int pos,void* buf,int count)
{
if(pos==lseek(hFile,pos,SEEK_SET))
{
return write(hFile,buf,count);
}
return -1;
}
//找到程序第一条指令所在的段,并把该段的程序头结构读到pPhdr指向的结构中
int FileEntryIndex(int hFile,Elf32_Ehdr*pEhdr,Elf32_Phdr *pPhdr,unsigned long entry)
{
int i;
for(i=0;ie_phnum;i++)
{
if(sizeof(*pPhdr)!
=ReadAt(hFile,
pEhdr->e_phoff+i*pEhdr->e_phentsize,
pPhdr,
sizeof(*pPhdr)))
return 0;
if(entry >= pPhdr->p_paddr &&
entry < (pPhdr->p_paddr + pPhdr->p_memsz))
{
return 1;
}
}
return 0;
}
int main()
{
int hFile;
int offset,size;
Elf32_Ehdr ehdr;
Elf32_Phdr phdr;
//以读写方式打开文件
hFile=open("/home/hangj/hello",O_RDWR,0);
if(hFile<0)
return -1;
//读取ELF头
if(sizeof(ehdr)!
=ReadAt(hFile,0,&ehdr,sizeof(ehdr)))
goto error;
//判断是否是ELF文件
if(!
IsElf(&ehdr))
goto error;
//找到该文件第一条指令所在的段并读出这个段的程序头结构信息
if(!
FileEntryIndex(hFile,&ehdr,&phdr,ehdr.e_entry))
goto error;
//计算第一条指令在整个文件中的位置
offset=ehdr.e_entry-phdr.p_paddr;
offset+=phdr.p_offset;
//计算exit_print函数体的字节数
size=(int)(&IsElf)-(int)(&exit