您的位置:首页 > 运维架构 > Linux

浅谈Linux的可执行文件格式ELF(转帖)

2013-06-24 18:42 316 查看

代Linux采用ELF做为其可连接和可执行文件的格式,因此ELF格式也向我们透出了一点Linux核内的情景,就像戏台维幕留下的一条未拉严的缝。
PC世界32仍是主流,但64位的脚步却已如此的逼近。本文着重讲述32位ELF的同时附带了64位的信息,这两种格式如此雷同,以致于初次接触ELF的
读者不必兼顾左右。如果你对Windows比较熟悉,本文还将时时把你带回到PE中,在它们的相似之处稍做比较。ELF文件以“ELF头”开始,后面可选
择的跟随着程序头和节头。地理学用等高线与等温线分别展示同一地区的地势和气候,程序头和节头则分别从加载与连接角度来描述EFL文件的组织方式。下面我
们进入正文。
一、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它就可以。但我们能够验证的知识有限,当更多
知识联系在一起的时候我们的理解正误才可以得到更好的验证。接下来我们再学习程序头。
二、程序头
  程序头有时也叫程序
头表,它保存了一个结构数组。程序头是从加载执行的角度看待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语言代码――――代码可以精确的说明一切!
三、覆盖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 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(hFilepEhdr->e_shnum)
return
NULL;
//相应的节头结构在文件中的偏移=节头偏移+每个节头结构的字节大小×节索引
offset=pEhdr->e_shoff+pEhdr->e_shentsize*index;
//从文件中读取一个节头结构
if(sizeof(shdr)!=ReadAt(hFile,offset,&shdr,sizeof(shdr)))
return
NULL;
//分配与节字节大小相同的内存块
pbuf=(char
*)malloc(shdr.sh_size);
if(pbuf!=NULL)
{
//把该节全部读入内存,成功就返回内存地址,否则释放内存返回NULL
if(shdr.sh_size==ReadAt(hFile,shdr.sh_offset,pbuf,shdr.sh_size))
return
pbuf;
free(pbuf);
}
return NULL;
}
void printSection(char
*fileNameOfElf)
{
int hFile;
int offset;
Elf32_Ehdr
ehdr;
Elf32_Shdr shdr;
char
*strTable;
strTable=NULL;
//以只读方式打开ELF文件
hFile=open(fileNameOfElf,O_RDONLY,0);
if(hFilest_info&0xf)
{
TEST_NAME(STT_NOTYPE);
TEST_NAME(STT_OBJECT);
TEST_NAME(STT_FUNC);
TEST_NAME(STT_SECTION);
TEST_NAME(STT_FILE);
default:
strcpy(typeName,"unkown
type");
}
//打印符号类型和属性
printf("type=%d(%s)\tattrib=%d\n",
ELF32_ST_TYPE(pSym->st_info),//符号的类型
typeName,
ELF32_ST_BIND(pSym->st_info));//绑定类型
//打印其它符号信息
printf("\tst_shndx=%d,\tst_value=0x%x(%d),\tst_size=%d\n",
pSym->st_shndx,//与符号相关的节
pSym->st_value,//符号的值
pSym->st_value,
pSym->st_size);//符号大小
#undef
TEST_NAME
}
void printAllSymbol(char *fileName)
{
int hFile;
int
offset,size;
Elf32_Ehdr ehdr;
Elf32_Shdr shdr;
char
*strTable;
strTable=NULL;
hFile=open(fileName,O_RDWR,0);
if(hFiler_info))
{
TEST_NAME(R_386_NONE);
TEST_NAME(R_386_32);
TEST_NAME(R_386_PC32);
TEST_NAME(R_386_GOT32);
TEST_NAME(R_386_PLT32);
TEST_NAME(R_386_COPY);
TEST_NAME(R_386_GLOB_DAT);
TEST_NAME(R_386_JMP_SLOT);
TEST_NAME(R_386_RELATIVE);
TEST_NAME(R_386_GOTOFF);
TEST_NAME(R_386_GOTPC);
default:
strcpy(typeName,"unkown
type");
}
//打印重定位类型、在符号表中的索引、重位内容的地址。
printf("type=%d(%s)\tSYM=%d\t
offset=0x%x\n",
ELF32_R_TYPE(pRel->r_info),//重定向类型
typeName,
ELF32_R_SYM(pRel->r_info),//重定向用到的符号
pRel->r_offset);//需要系统修改的内容地址
#undef
TEST_NAME
}
void printAllRel(char *fileName)
{
int hFile;
int
offset,size;
Elf32_Ehdr ehdr;
Elf32_Shdr shdr;
char
*strTable;
strTable=NULL;
hFile=open(fileName,O_RDWR,0);
if(hFile: push %ebp
0x08048371
: mov %esp,%ebp
0x08048373 : sub
$0x8,%esp
0x08048376 : and $0xfffffff0,%esp
0x08048379
: mov $0x0,%eax
0x0804837e : sub
%eax,%esp
0x08048380 : sub $0xc,%esp
0x08048383
: push $0x804846c
0x08048388 : call 0x80482b0
0x0804838d : add $0x10,%esp
0x08048390
: mov $0x0,%eax
0x08048395 :
leave
0x08048396 : ret
0x08048397 :
nop
End of assembler dump.
显然唯一的一条函数调用“call 0x80482b0
”应该就是printf,看看0x80482b0处有什么?
(gdb) x/10i
0x80482b0
0x80482b0 : jmp *0x8049570
0x80482b6
: push $0x8
0x80482bb : jmp 0x8048290
0x80482c0 : xor %ebp,%ebp
0x80482c2
: pop %esi
0x80482c3 : mov
%esp,%ecx
0x80482c5 : and $0xfffffff0,%esp
0x80482c8
: push %eax
0x80482c9 : push
%esp
0x80482ca : push
%edx

来是一条跳转指令,要跳到的地址被保存在0x8049570处,按照我们对PE文件的经验0x8049570处一定存放着printf的真正地址。系统加
载hello时从重定位表和动态表中知道我们引用了libc.so.6中的printf,然后它就加载libc.so.6,然后它就从libc.so.6
的符号表中找到了printf,然后它就把hello的0x8049570处的四个字节改成printf的地址!一定是这样!那么这一条跳转就是去执行
printf了。看看再说。
(gdb) x/4xw 0x8049570
0x8049570
: 0x080482b6 0x00000000 0x00000000
0x0804948c
“0x080482b6”
这不是紧挨着跳转指令的下一条指令地址吗?怎么不是printf!Linux与Windows是不同的,《ELF规范》把0x80482b0处的这段东西
叫做过程连接表(PLT)又把0x8049570处的东西称为全局偏移表(GOT),这两个概念我们不过多引入。请大家注意0x080482b6开始的那
条push指令,它把0x8压入堆栈。0x8就我们的关键!我们退出GDB使用我们自己前面写下的工具。先打印符号表,由于内容太多我只把动态符号表中的
情况摘抄在下面,动态符号表是类型为SHT_DYNSYM节。
.dynsym(type=SHT_DYNSYM)
offset=0x174,
vaddr=0x8048174, size=0x60
Sym:
type=0(STT_NOTYPE)
attrib=0
st_shndx=0, st_value=0x0(0),
st_size=0
Sym:__libc_start_main
type=2(STT_FUNC) attrib=1
st_shndx=0,
st_value=0x0(0), st_size=239
Sym:printf
type=2(STT_FUNC)
attrib=1
st_shndx=0, st_value=0x0(0),
st_size=57
Sym:_IO_stdin_used
type=1(STT_OBJECT) attrib=1
st_shndx=14,
st_value=0x8048468(134513768),
st_size=4
Sym:_Jv_RegisterClasses
type=0(STT_NOTYPE)
attrib=2
st_shndx=0, st_value=0x0(0),
st_size=0
Sym:__gmon_start__
type=0(STT_NOTYPE) attrib=2
st_shndx=0,
st_value=0x0(0),
st_size=0
可以看到动态符号表共有六项,除第1项保留不用,共引入5个外部符号,其中两个是函数,printf位于第3项。再打印重定位表:
.rel.dyn(type=SHT_REL)
offset=0x260,
vaddr=0x8048260, size=0x8
type=6(R_386_GLOB_DAT) SYM=5
offset=0x804955c
.rel.plt(type=SHT_REL)
offset=0x268, vaddr=0x8048268,
size=0x10
type=7(R_386_JMP_SLOT) SYM=1
offset=0x804956c
type=7(R_386_JMP_SLOT) SYM=2
offset=0x8049570

重定位表中请注意名为“.rel.plt”的重定位节,这个重定位表中只有两项,它们的重定位类型都是R_386_JMP_SLOT,它们与动态符号表中
仅有的两个函数一一对应。第一个的符号表索引是1,对应着__libc_start_main,我们不关心;第二个符号表索引是2,恰与printf对
应,我们看到它的r_offset字段的值是0x8049570,这个地址正好保存着printf要跳转到的地址。重定位信息告诉操作系统要修改这个地方
可是系统并没修改,修改任务于是就必须由0x80482b6处的指令来完成。0x8――这个压入栈中的数值就我们的关键。重定位表中每个结构的大小恰是8
个字节,于是你大胆猜测这个0x8就是外部函数的重定位信息在重定位表中的偏移量。这个猜测可以通过引入多个不同的函数加以验证。push指令后的跳转可
认为是去调用一个函数,而push本身仅是向那个函数传递这个参数罢了,而那个函数一定会找到printf并调用它。那么为什么要如此大费周折呢,我想有
些人一定猜到了结果:那个用重定位表偏移做参数的函数一定在得到printf地址后随后修改了0x8049570处保存的值,下一次再调用printf时
就会直接由0x80482b0处跳到真正的函数体内了――怎么有点像Win9x中“VXD
CALL”。通过引入多个外部函数我们又发现:多个外部函数在“.rel.plt”中的排列顺序与它们对应内容在GOT中的排列顺序完全一致,不同的是它
们不是从GOT的偏移0开始的。
继续我们的实验,重新用GDB打开hello,在0x804838d处下一个断点,它将使程序在调用printf的语句之后停止不动。然后用r命令执行hello。
(gdb)
b *0x804838d
Breakpoint 1 at 0x804838d
(gdb)
r
经过几行输出,GDB最后打印了一条信息“Breakpoint 1, 0x0804838d in main
()”等待我们的命令。我们再来看一下0x8049570处的内容,它果然变了,由0x080482b6变成了0x005d62a0。这恰是printf
的真正地址,我们还能看到printf还调用了vfprintf。
(gdb) x/4x 0x8049570
0x8049570
: 0x005d62a0 0x00000000 0x00000000
0x0804948c
(gdb) disass printf
Dump of assembler code for function
printf:
0x005d62a0 : push %ebp
0x005d62a1
: mov %esp,%ebp
0x005d62a3 : sub
$0x10,%esp
0x005d62a6 : mov
%ebx,0xfffffffc(%ebp)
0x005d62a9 : mov
0x8(%ebp),%edx
0x005d62ac : lea 0xc(%ebp),%ecx
0x005d62af
: call 0x5a990d
0x005d62b4
: add $0xd5d48,%ebx
0x005d62ba : mov
%ecx,0x8(%esp)
0x005d62be : mov
0xfffffe7c(%ebx),%ecx
0x005d62c4 : mov
%edx,0x4(%esp)
0x005d62c8 : mov (%ecx),%edx
0x005d62ca
: mov %edx,(%esp)
0x005d62cd : call
0x5cd620
0x005d62d2 : mov
0xfffffffc(%ebp),%ebx
0x005d62d5 : mov
%ebp,%esp
0x005d62d7 : pop %ebp
0x005d62d8
: ret
0x005d62d9 : nop
0x005d62da
: nop
0x005d62db : nop
0x005d62dc
: nop
0x005d62dd : nop
0x005d62de
: nop
0x005d62df : nop
End of assembler
dump.
(gdb)
实验内容就这么多,更进一步的细节我宁愿当它是一个黑盒,根据参数实现功能。

一点我需要再三重复。在ELF文件中没有信息把printf与libc.so.6联系在一起,也就是说加载程序不知道printf的函数体在
libc.so.6中。所以加载程序只能根据DT_NEEDED类型的动态结构加载所有程序需要的so模块,然后在所有so模块中寻找printf。这也
是符号结构要带有“绑定类型”信息的原因。这样的连接方式有些好处。例如PE中没有使用的“懒模式”,不管程序执行过程中是否会用到,PE文件执行之前它
引入的所有外部函数都必须被系统解析出来,而Linux下正如刚才看到的用到时才会解析。“懒模式”有它的危害,如果用到时才加载so模块,必然使程序的
运行不够流畅,所以Linux一次性加载所有模块而用时解析函数应该是对此问题的解决方案。没有把模块与函数绑定在一起也使Linux的驱动开发者受益,
可加载模块能够引用内核符号并能导出符号供其它模块使用。
希望大家看完文章后会下载我写的小程序,然后帮我发现BUG(它需要大量的测试而我又不能满足它):hdasm64
本身是一个Win32程序(PE32格式)在下载的包里有64位的PE和ELF文件各一个,32位ELF文件一个,供大家研究!
支持实模式、保护模式、64位模式三种模式指令集的动态反汇编.
支持DOS系统COM和EXE可执行文件格式,支持32位和64位PE文件格式(windows可执行文件),支持32位和64位ELF文件格式(Linux可执行文件),共计六种文件格式。支持部分指令的虚拟执行调试。
下载页面:
http://pay500.com/s/s56504.htm
作者邮箱:hangj@zhongsou.com
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: