http://hi.baidu.com/zengzhaonong/item/32b06adfecdb774edcf9be78【来源】
现代Linux采用ELF(Executable and Linking Format)做为其可连接和可执行文件的格式,因此ELF格式也向我们透出了一点Linux核内的情景,就像戏台维幕留下的一条未拉严的缝。 PC世界32仍是主流,但64位的脚步却已如此的逼近。如果你对Windows比较熟悉,本文还将时时把你带回到PE中,在它们的相似之处稍做比较。ELF文件以“ELF头”开始,后面可选择的跟随着程序头和节头。地理学用等高线与等温线分别展示同一地区的地势和气候,程序头和节头则分别从加载与连接角度来描述EFL文件的组织方式。
ELF头
------------------------------------------------
ELF头也叫ELF文件头,它位于文件中最开始的地方。
/usr/src/linux/include/linux/elf.h
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
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;
#define EI_NIDENT 16
ELF头中每个字段的含意如下:
Elf32_Ehdr->e_ident[] (Magic)
这个字段是ELF头结构中的第一个字段,在elf.h中EI_NIDENT被定义为16,因此它占用16个字节。e_ident的前四个字节顺次应该是0x7f、 0x45、 0x4c、 0x46,也就是"\177ELF"。这是ELF文件的标志,任何一个ELF文件这四个字节都完全相同。
16进制 8进制 字母
0x7f 0177
0x45 E
0x4c L
0x46 F
第5个字节标志了ELF格式是32位还是64位,32位是1,64位是2。
第6个字节,在0x86系统上是1,表明数据存储方式为低字节优先。
第10个字节,指明了在e_ident中从第几个字节开始后面的字节未使用。
Elf32_Ehdr->e_type (Type)
ELF文件的类型,1表示此文件是重定位文件,2表示可执行文件,3表示此文件是一个动态连接库。
Elf32_Ehdr->e_machine (Machine)
CPU类型,它指出了此文件使用何种指令集。如果是Intel 0x386 CPU此值为3,如果是AMD 64 CPU此值为62也就是16进制的0x3E。
Elf32_Ehdr->e_version (Version)
ELF文件版本,为1。
Elf32_Ehdr->e_entry (Entry point address)
可执行文件的入口虚拟地址。此字段指出了该文件中第一条可执行机器指令在进程被正确加载后的内存地址! (注: 入口地址并不是可执行文件的第一个函数 -- main函数的地址)。
Elf32_Ehdr->e_phoff (Start of program headers)
程序头在ELF文件中的偏移量。如果程序头不存在此值为0。
Elf32_Ehdr->e_shoff (Start of section headers)
节头在ELF文件中的偏移量。如果节头不存在此值为0。
Elf32_Ehdr->e_ehsize (Size of -ELF header)
它描述了“ELF头”自身占用的字节数。
Elf32_Ehdr->e_phentsize (Size of program headers)
程序头中的每一个结构占用的字节数。程序头也叫程序头表,可以被看做一个在文件中连续存储的结构数组,数组中每一项是一个结构,此字段给出了这个结构占用的字节大小。e_phoff指出程序头在ELF文件中的起始偏移。
Elf32_Ehdr->e_phnum (Number of program headers)
此字段给出了程序头中保存了多少个结构。如果程序头中有3个结构则程序头(程序头表)在文件中占用了(3×e_phentsize)个字节的大小。
Elf32_Ehdr->e_shentsize (Size of section headers)
节头中每个结构占用的字节大小。节头与程序头类似也是一个结构数组,关于这两个结构的定义将分别在讲述程序头和节头的时候给出。
Elf32_Ehdr->e_shnum (Number of section headers)
节头中保存了多少个结构。
Elf32_Ehdr->e_shstrndx (Section header string table index)
这是一个整数索引值。节头可以看作是一个结构数组,用这个索引值做为此数组的下标,它在节头中指定的一个结构进一步给出了一个“字符串表”的信息,而这个字符串表保存着节头中描述的每一个节的名称,包括字符串表自己也是其中的一个节。
至此为止我们已经讲述了“ELF头”,在此过程中提前提到的一些将来才用的概念,不必急于了解。现在读者可自己编写一个小程序来验证刚学到的知识,这有助于进一步的学习。elf.h文件一般会存在于/usr/include目录下,直接include它就可以。但我们能够验证的知识有限,当更多知识联系在一起的时候我们的理解正误才可以得到更好的验证。接下来我们再学习程序头。
# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x80482f0
Start of program headers: 52 (bytes into file)
Start of section headers: 3228 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 36
Section header string table index: 33
程序头(程序头表) -- Program Header
------------------------------------------------
程序头有时也叫程序头表,它保存了一个结构数组(结构Elf32_Phdr的数组)。程序头是从加载执行的角度看待ELF文件的结果,从它的角度ELF文件被分成许多个段。每个段保存着用于不同目的的数据,有的段保存着机器指令,有的段保存着已经初始化的变量;有的段会做为进程映像的一部分被操作系统读入内存,有的段则只存在于文件中。
后面还会讲到ELF的节头,节头把ELF文件分成了许多节。ELF文件的一部分常常是既在某一段中又在某一节中。Linux和Windows的进程空间都采用的是平坦模式,没有x86的段概念,这里ELF中提到的段仅是文件的分段与x86的段没有任何联系。
/usr/src/linux/include/linux/elf.h
typedef struct elf32_phdr{
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;
Elf32_Phdr->p_type
段的类型,它能告诉我们这个段里存放着什么用途的数据。此字段的值是在elf.h中定义了一些常量。例如1(PT_LOAD)表示是可加载的段,这样的段将被读入程序的进程空间成为内存映像的一部分。段的种类再不断增加,例如7(PT_TLS)在以前就没有定义,它表示用于线程局部存储。
Elf32_Phdr->p_flags
段的属性。它用每一个二进制位表示一种属,相应位为1表示含有相应的属性,为0表示不含那种属性。其中最低位是可执行位,次低位是可写位,第三低位是可读位。如果这个字段的最低三位同时为1那就表示这个段中的数据加载以后既可读也可写而且可执行的。同样在elf.h文件中也定义了一此常量(PF_X、 PF_W、PF_R)来测试这个字段的属性,做为一个好习惯应该尽量使用这此常量。
Elf32_Phdr->p_offset
该段在文件中的偏移。这个偏移是相对于整个文件的。
Elf32_Phdr->p_vaddr
该段加载后在进程空间中占用的内存起始地址。
Elf32_Phdr->p_paddr
该段的物理地地址。这个字段被忽略,因为在多数现代操作系统下物理地址是进程无法触及的。
Elf32_Phdr->p_filesz
该段在文件中占用的字节大小。有些段可能在文件中不存在但却占用一定的内存空间,此时这个字段为0。
Elf32_Phdr->p_memsz
该段在内存中占用的字节大小。有些段可能仅存在于文件中而不被加载到内存,此时这个字段为0。
Elf32_Phdr->p_align
对齐。现代操作系统都使用虚拟内存为进程序提供更大的空间,分页技术功不可没,页就成了最小的内存分配单位,不足一页的按一页算。所以加载程序数据一般也从一页的起始地址开始,这就属于对齐。
尽管我给出了描述每个段信息的程序头结构,但我并不打算介绍任何一个具体类型的段所存储的内容,大多数情况下它们和节中保存的内容是一致的。我们只关心可以加载的段,但上面给出的信息应该足够了。好啦,你现在就是操作系统,你已经知道了组成程序的指令和数据都存放在文件的各个段中,通过程序头你知道它们在文件中的偏移和它们在文件中的大小,你就可以把这个段读到它的进程空间中以p_vaddr开始的地址处。水平所限,我所能表达的必然不是精确的,为了更好理解程序头与进程加载,我设计了一个小实验并给出C语言代码 -- 代码可以精确的说明一切!
覆盖ELF可执行文件入口指令的实验
------------------------------------------------
现在掌握了ELF头和程序头,从加载执行程序的角度可以说已对ELF文件有了初步的了解。为更好理解它,做个试验吧!
回忆一下程序头表把ELF文件分成了许多段,并告诉操作系统怎样把这些段读到内存里去。当操作系统已按程序头表的指示把ELF文件各个段的数据读入到内存中相应的地方以后,就可以说操作系统已建立了完整且正确的进程映像(如果不考虑依赖),下一步就是要执行程序了。ELF头的e_entry给出了第一条机器指令在内存中的地址,操作系统只要在某个时候将指令流引向那里就可以了。
这个猜测对不对呢,下面的这个实验将从某种角度来证明它。首先准备好一段代码 -- exit_print(),把这段代码写到ELF文件 -- hello中,代码写入的位置恰恰是ELF文件的第一条机器指令在文件中的位置。这样当系统把这个修改过的可执行程序 -- hello加载到内存时,它原来入口处的指令已经换成了我们准备的这段代码,程序的行为被完全改变。可是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 zxl ”之后就结束整个程序。
鉴于上面的两点说明,我们不能使用printf和malloc输出字符串和为它分配内存,也没有把完整的字符串做为变量存储,而是用了堆栈中的局部变量,这将导致栈中内存分配。把字符串放到strHello中用了四条C语句。注意,前三条中每条语句放入的四个字符的顺序是颠倒的,这是x86低字节优先存储造成的。最后一条C语句放入一个回车符‘\n’,字符串没有以0结尾。
void exit_print()
{
char strHello[20];
*((unsigned long*)&strHello[0])='lleH';
*((unsigned long*)&strHello[4])=' o';
*((unsigned long*)&strHello[8])=' zxl';
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说明完毕!
------------------------------------------------
#include <stdio.h>
int main()
{
printf("hello\n");
}
# gcc hello.c -o hello
# ./hello
hello
下面给出这个试验程序的完整代码,它被存为mod_entry.c文件,exit_print函数也在其中。下面代码将替换ELF文件hello的入口地址(将hello文件放置在和mod_entry.c相同的目录下)。
//文件名 :mod_entry.c
//功能 : 覆盖ELF可执行文件指令入口
#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])=' o';
*((unsigned long*)&strHello[8])=' lxz';
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;
}
//将文件hFile,从pos处开始读取count个字节到缓冲区buf中
int ReadAt(int hFile, int pos, void *buf, int count)
{
if(pos == lseek(hFile, pos, SEEK_SET))
return read(hFile, buf, count);
return -1;
}
//将缓冲区buf中的内容(count个字节),写入文件hFile中(从pos处开始写入)
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指向的结构中
//参数entry为第一条可执行机器指令在进程被正确加载后的内存地址(Elf32_Ehdr->e_entry)
int FileEntryIndex(int hFile, Elf32_Ehdr* pEhdr, Elf32_Phdr *pPhdr, unsigned long entry)
{
int i;
for(i = 0; i < pEhdr->e_phnum; i++) {
if(sizeof(*pPhdr) !=
ReadAt(hFile,
pEhdr->e_phoff + i*pEhdr->e_phentsize,
pPhdr,
sizeof(*pPhdr)))
return 0;
if(entry >= pPhdr->p_vaddr && entry < (pPhdr->p_vaddr + pPhdr->p_memsz))
return 1;
}
return 0;
}
int main()
{
int hFile;
int offset, size;
Elf32_Ehdr ehdr;
Elf32_Phdr phdr;
//以读写方式打开文件
hFile = open("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_vaddr;
offset += phdr.p_offset;
//计算exit_print函数体的字节数
size=(int)(&IsElf) - (int)(&exit_print);
//修改ELF文件第一条可执行机器指令在进程被正确加载后的内存地址
if(size != WriteAt(hFile, offset, exit_print, size))
goto error;
printf("write Elf file success!\n");
error:
close(hFile);
return 0;
}
编译的时候gcc会有如下警告提示!
# gcc mod_entry.c -o mod_entry
mod_entry.c:20:37: warning: multi-character character constant
mod_entry.c:21:37: warning: multi-character character constant
mod_entry.c:22:37: warning: multi-character character constant
不用在意这个警告,它毫无防碍。这个程序非常简单,因为它忽略了许多本该注意的问题,比如被修改的ELF文件的那个段是否足够大可以容下我们的exit_print函数体? 实事上我们的函数很小,它几乎总能使你的试验成功。
# ./mod_entry
write Elf file success!
# ./hello (此时hello文件的入口地址已经被修改了)
Hello zxl

节头(节头表) -- Section Header
------------------------------------------------
节头也叫节头表。ELF头的e_shoff字段给出了节头在整个文件中的偏移(如果节头存在的话),节头可看做一个在文件中连续存储的结构数组(Elf32_Shdr结构的数组),数组的长度由ELF头的e_shnum字段给出,数组中每个结构的字节大小由ELF头的e_shentsize字段给出。把文件指针移到在ELF头中e_shoff给出的位置,然后读出的内容就是节头了。节头表是从连接角度看待ELF文件的结果,所以从节头的角度ELF文件分成了许多的节,每个节保存着用于不同目的的数据,这些数据可能被前面提到的程序头重复引用。关于节的内容非常琐碎,完成一次任务所的需的信息往往被分散到不同的节里。
相对而言,PE中的资源表、引入表、导出表都集中给出了所有相关的信息,理解起来真是方便多了。由于节中数据的用途不同,节被分为不同的类型,每种类型的节都有自己组织数据的方式。
有的节存储着一些字符串,例如前面提过的字符串表就是一种类型的节;
有的节保存一张符号表,程序从动态连接库中引入的函数和变量都会出现在一个叫做”动态符号表“的节中;
重定位表则包含在重定位节中。
不管这些节是何种类型,在节头中都用相同的结构保存着与这些节有关的信息。先来看一看节头中用来保存这些信息的结构吧:
/usr/src/linux/include/linux/elf.h
typedef struct {
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
Elf32_Shdr->sh_name
这个整数占用两个字节,它能告诉你这个节的名字。讲字符串表的时候会再来研究这个字段。
Elf32_Shdr->sh_type
节的类型。或者说它能告诉你这个节里存放的是什么样的数据。随着ELF的发展,用于不同目的的节会不断增多,节的类型值是在elf.h中定义的一些常量。例如字符串表是SHT_STRTAB,符号表是SHT_SYMTAB等。
Elf32_Shdr->sh_flags
节的属性。这个字段在32位下占4个字节,64位下占8个字节。与程序头中的p_flags字段一样,它用每一个二进制位表示一种属。其中最低位如果为1表示此节在进程执行过程中可写,次低位为1表示此节的内容加载时要读到内存中去,第三低位为1表示这个节中的数据是可执行的机器指令。一些常量,如 SHF_INFO_LINK,帮助用来测试节的属性。
Elf32_Shdr->sh_addr
如果此节的内容将出现在进程空间里,这个字段给出了该节在内存中起始地址。
Elf32_Shdr->sh_offset
如果此节在文件中占用一定的字节,这个字段给出了该节在整个文件中的起始偏移量。
Elf32_Shdr->sh_size
如果此节在文件中占用一定的字节,这个字段给出了该节在文件中的字节大小,如果此节在文件中不存在但却存在于内存中那么此字段给出了此节在内存中的字节大小。
Elf32_Shdr->sh_link
如果另一个节与这个节相关联,这个字段给出了相关的节在节头中的索引。
Elf32_Shdr->sh_info
这个字段如果用到再说。
Elf32_Shdr->sh_addralign
地址对齐。这个数是2的整数次幂,对齐只能是2字节对齐、4字节对齐、8字节对齐等。如果这个数是0或1表示这个节不用对齐。
Elf32_Shdr->sh_entsize
这个字段是一个代表字节大小的数,对某些节才有意义。例如对动态符号节来说这个字段就给出动态符号表中每个符号结构的字节大小。
节头的结构讲完了,很多一时用不到的知识被暂时抛弃。对于节的知识我们掌握了很少,但我希望至少能够知道每个节的名字。所以我们必须开始接触我们将要学习的几种类型节的第一类 -- 字符串表!