当前位置: 首页 > 科技观察

Linux系统中编译、链接的基石-ELF文件:扒开它的层层外衣,从字节码的粒度来探索

时间:2023-03-11 22:16:03 科技观察

Linux系统中编译链接的基石——ELF文件:剥去它的层层外衣,从字节码的粒度去探究经常在Linux系统中开发的朋友一定对我很熟悉,尤其是需要了解的朋友编译和链接,我想他们已经把我研究透了。为了认识更多的朋友,今天是我的开放日。像洋葱一样,我会一层一层地打开自己的心扉,让更多的朋友认识我。欢迎大家前来观看。以前看到有的朋友在研究我的时候,先看看header里的summary信息,再看看Section的布局,好像和我很熟似的。从科学的角度来看,这还远远不够,还没有走到尽头。当你面对编译链接的详细过程时,你还是会一头雾水。今天,我将从字节码的粒度来分析自己,毫无保留,坦诚相待,知无不言,滔滔不绝,忠贞不渝,忠心敬业,死而后已。视觉盛宴。了解了这些知识之后,再继续学习编译链接的底层过程,以及从硬盘加载一个可执行程序到内存,直到执行main函数,你的思路就会很开阔.也就是说,掌握ELF文件的结构和内容,是理解编译、链接和程序执行的基础。你不是有句老话吗:磨刀不误砍柴工!好吧,让我们现在开始吧!文件很简单,但复杂的是,人就是一种文件,所以必须有一定的格式必须遵守,我也不例外。从宏观上看,我可以拆解成四个部分:图中的概念看不懂也没关系,下面我会一一解释。在Linux系统中,一个ELF文件主要用来表示3种文件:既然可以用它来表示3种文件,那么在文件中就必须有一个地方来区分这3种情况。可能你已经猜到了,在我的header内容中,有一个字段表示:当前的ELF文件,是可执行文件吗?它是目标文件吗?还是共享库文件?另外,由于I可以用来表示3种类型的文件,所以肯定是用在3种不同的场合,或者由不同的人操作:执行记忆;目标文件:由链接器读取生成可执行文件或共享库文件;共享库文件:在动态链接期间由ld-linux.so读取。以链接器和加载器为例。这两个人性格不同,对我的看法也不一样。链接器看我的时候,它的眼里只有3个部分:即链接器只关心ELF头,Sections和Section头表这3个部分。当loader看我的时候,它的眼里还有另外三个部分:loader只关心ELFheader、Programheadertable和Segment这三个部分。对了,从loader的角度来说,对于中间的Sections,它已经更名为Segments了。换汤不换药,本质上是一样的。可以理解为:一个Segment可能包含一个或多个Section,如下:这好比超市货架上的商品:矿泉水、可乐、啤酒、巧克力、牛肉干、薯片。从理货员的角度来看:属于6种不同的商品;但在超市经理看来,它们只属于2类商品:饮料和零食。如何?现在你对我有一个大概的印象了吧?其实你只需要掌握2点:一个ELF文件由4部分组成;linker和loader,他们在使用我的时候,只需要会用到他们感兴趣的部分。还有一件事我差点忘了提醒你:在linux系统中,会有不同的数据结构来描述每个部分上面提到的内容。我知道有些朋友不耐烦,我就先把这些结构告诉大家。初次见面,先熟悉就好,不要陷得太深。描述ELF头的结构:描述Program头表的结构:描述Section头表的结构:ELF头(ELF头)的内容相当于一个管理器,它决定了完整的所有内容ELF文件信息,例如:这是一个ELF文件;一些基本信息:版本、文件类型、机器类型;程序头表(programheadertable)起始地址,在整个文件中的位置;节头表(sectionheadertable)整个文件中的起始地址。是不是有点疑惑,ELF文件中好像没有Sections(从linker的角度)或者Segments(从loader的角度)的位置。为了描述方便,我将Sections和Segments统称为Sections!事实上,一个ELF文件中有很多Section。这些Section的具体信息在Programheadertable或Sectionheadtable中。描述。以Sectionheadtable为例:如果一个ELF文件中有4个Section:.text,.rodata,.data,.bss,那么在Sectionheadtable中,会有4个Entry(条目)来分隔描述这4个Section的具体信息(严格来说不止4个Entry,因为还有一些其他的辅助Section),如下:开头说了,我想用字节码的粒度,拿来打开它你看看!为了不耍流氓,我就用具体的代码例子来描述一下。只有这样才能看到真正的字节码。程序的功能比较简单://mymath.cintmy_add(inta,intb){returna+b;}//main.c#includeexternintmy_add(inta,intb);intmain(){inti=1;intj=2;intk=my_add(i,j);printf("k=%d\n",k);}从刚才的描述我们可以知道:动态库文件libmymath.so,目标文件main.o和executableFilemain,都是ELF文件,只是类型不同。这里我们使用main这个可执行文件进行反汇编!我们先用命令readelf-hmain查看main文件中ELF头的信息。readelf这个工具是个好东西!一定要用好它。这张图显示的信息就是ELF头中描述的所有内容。该内容与结构体Elf32_Ehdr中的成员变量一一对应!有没有发现图中第15行显示的内容:Sizeofthisheader:52(bytes)。也就是说:ELF头部分的内容一共是52字节。然后我将向您展示前52个字节码。本次使用命令od-Ax-tx1-N52main读取main中的字节码,并对部分选项进行简单说明:-Ax:显示地址时,使用十六进制表示。如果使用-Ad,表示以十进制显示地址;-t-x1:显示字节码内容时,使用十六进制(x)一次显示一个字节(1);-N52:只需要读取52个字节;这52个字节的内容可以参照上述结构中的各个字段来解释。先看前16个字节。结构中的第一个成员是unsignedchare_ident[EI_NIDENT];,EI_NIDENT的长度为16,代表EL头中的前16个字节。具体含义如下:0-15字节怎么样?我就这样暴露自己,向你表白,就足以表明我的诚意了?!感动的话,别忘了点击文章底部的观看和收藏,也非常感谢转发给身边的小伙伴。赠人玫瑰,手留余香!为了权威起见,我也把官方文档这部分的解释贴出来给大家看看:关于big-endian和little-endian格式,主文件显示1,代表little-endian格式。什么意思,看下图就明白了:然后再看big-endian格式:OK,我们继续写剩下的36个字节(52-16=32),同样用这个字节码画出意思:16-31字节:32-47字节:48-51字节:具体内容不用解释。酒里~~哦不,重点都在图里!字符串表项Entry在一个ELF文件中,里面有很多字符串,比如:变量名,Section名,链接器添加的符号等等,这些字符串的长度是不固定的,所以肯定是用固定结构表示这些字符串是不现实的。于是,聪明的人类想到了:把这些字符串集合起来,放在一起,作为一个独立的科室来管理。在文件的其他地方,如果要表示一个字符串,就在这个地方写一个数字索引:表示该字符串位于该字符串统一存放的某个偏移位置。经过这样的搜索,你可以找到这个特定的字符串。例如,所有的字符串都存放在如下空间:在程序的其他地方,如果要引用字符串“hello,world!”,那么只需要在该处标记数字13,即:this字符串从偏移量13个字节开始。那么现在,让我们回到主文件中的字符串表。ELF头中最后2个字节是0x1C0x00,对应结构体中的成员e_shstrndx,表示在这个ELF文件中,字符串表是一个OrdinarySection,在这个Section中,ELF文件中使用的所有字符串被存储。既然是Section,那么Section头表中肯定有一个entry来描述它,那么是哪个entry呢?这是条目0x1C0x00,这是第28个条目。这里,我们还可以使用命令readelf-Smain查看这个ELF文件中所有的Section信息:里面的第28个Section描述了字符串表Section:可以看出这个Section在ELF文件中的偏移地址是0x0016ed,长度是0x00010a字节。接下来,我们从ELF标头中的二进制数据中推断出这些信息。阅读stringtablesection的内容,接下来我将演示:如何通过ELFheader中提供的信息找出stringtablesection,然后打印出它的字节码给大家看。如果要打印字符串表Section的内容,就必须知道这个Section在ELF文件中的偏移地址。如果想知道偏移地址,只能从Sectionhead表中第28个表项的描述信息中得到。要知道第28个表项的地址,就必须知道ELF文件中Section头表的起始地址和每个表项的大小。正好最后两个需求信息都在ELF头中告诉了,所以我们逆向计算就能成功。ELF头中第32到35字节的内容为:F8170000(这里注意字节顺序,低位在前),表示ELF文件中Sectionheadtable的起始地址(e_shoff)。0x000017F8=6136,也就是说Sectionheadtable的起始地址位于ELF文件的第6136字节。知道了起始地址,我们来计算第28个条目Entry的地址。ELF头中第46、47字节的内容为:2800,表示每个表项的长度为0x0028=40字节。注意,这里的计算是从0开始的,所以第28个表项的起始地址为:6136+28*40=7256,也就是说,用于描述字符串表Section的表项位于ELF的7256文件字节位置。知道了Entry入口的地址,我们来看看它的二进制内容:执行命令:od-Ad-tx1-j7256-N40main。-j7256选项表示跳过前面的7256字节,也就是我们从ELF主文件的7256字节开始读取,一共读取了40字节。这40个字节的内容分别对应了Elf32_Shdr结构体中的各个成员变量:这里我们主要关注上图中标出的4个字段:sh_name:暂时不告诉你,待会儿再解释;sh_type:表示这个Section的类型,3表示这是一个字符串表;sh_offset:表示该Section在ELF文件中的偏移量。0x000016ed=5869,表示这个Section在字符串表中的内容,从ELF文件的5869字节开始;sh_size:表示该Section的长度。0x0000010a=266bytes,表示字符串表Section的内容,共266bytes。还记得我们刚才用readelf工具读取到ELF文件中字符串表Section的偏移地址是0x0016ed,长度是0x00010a字节吗?完全符合我们这里的推断!既然我们知道了字符串表中这个Section在ELF文件中的偏移量和长度,那么就可以读出它的字节码内容了。执行命令:od-Ad-tc-j5869-N266main,这些参数应该都不需要解释吧?!看看,看看,是不是这个Section里面存的都是字符串?刚才没有解释sh_name字段表示的是Section本身在字符串表中的名称。既然是名字,那肯定是字符串。但是这里并没有直接存储这个字符串,而是存储了一个索引,索引值为0x00000011,也就是十进制值17。现在我们统计一下字符串表的内容中第17个字节开头存储的是什么部分?别偷懒,数一数,你见过:“.shstrtab”这个字符串(\0是字符串分隔符)?!好吧,如果你能看懂这一切,那么你就已经完全理解了字符串表的内容,给你一百个赞!!!从下图读取代码段内容(命令:readelf-Smain):可以看到代码段位于第14个入口,加载(虚拟)地址为0x08048470,位于ELF文件中偏移量为0x000470,长度为0x0001b2字节。因此,让我们尝试阅读它。先计算出这个entry的地址Entry:6136+14*40=6696。然后读取这个entry,读取命令是od-Ad-tx1-j6696-N40main:同样的,我们只关心下面五个字段:sh_name:这下应该清楚了,表示代码段名在字符串表Section中的偏移位置。0x9B=155字节,即在字符串表Section的第155字节处,存放的是代码段的名称。回头看看是不是字符串“.text”;sh_type:表示该Section的类型,1(SHT_PROGBITS)表示这是代码;sh_addr:表示该Section加载的虚拟地址为0x08048470,这个值与ELF头中e_entry字段的值相同;sh_offset:表示该Section在ELF文件中的偏移量。0x00000470=1136,表示这个Section的内容,从ELF文件的1136字节开始;sh_size:表示该Section的长度。0x000001b2=434bytes,表示代码段一共434字节。以上分析结构与命令readelf-Smain读取的完全一样!PS:看字符串表Section中的字符串时,别告诉我你真的是从0数到155!可以计算一下:字符串表的起始地址是5869(十进制),加上155,结果是6024,所以从6024开始的地方就是代码段的名字,就是“.text”。知道了以上信息,我们就可以读取代码段的字节码了。使用命令:od-Ad-tx1-j1136-N434main。内容都是暗码,就不贴了。Programheader一文开头介绍:我是一个通用的文件结构,链接器和加载器对我的看法不同。为了对Programheader有一个更感性的认识,我先用readelf工具把主文件中的所有段信息作为一个整体来看。执行命令:readelf-lmain,得到如下图:显示的信息很清楚:这是一个可执行程序;入口地址为0x8048470;Programheaders一共有9个,从ELF文件的52个偏移地址开始;布局如下图所示:一开始就跟大家讲过:Section和Segment本质上是一样的,可以理解为:一个Segment由一个或多个Section组成。从上图可以看出,第二个程序头段是由这么多的Section组成的。现在更清楚了?!从图中也可以看出,一共有2个LOAD类型的段:我们要读取第一个LOAD类型的段,当然二进制字节码还是被剥离了。第一步是计算这个段表项的地址信息。从ELF头中可知以下信息:字段e_phoff:程序头表位于ELF文件的偏移量52字节处。Fielde_phentsize:每个表项的长度为32字节;fielde_phnum:共9个entry条目;计算得到可读可执行的LOAD段,位于偏移量116字节处。执行读取命令:od-Ad-tx1-j116-N32main:根据上面的做法,我还是将一些需要注意的字段与数据结构中的成员变量关联起来:p_type:segmentType,1:表示该段需要加载到内存中;p_offset:段在ELF文件中的偏移地址,这里的值为0,表示该段从ELF文件的头部开始;p_vaddr:加载到内存Address0x08048000的段的虚拟地址;p_paddr:加载的section的物理地址,与虚拟地址相同;p_filesz:该段在ELF文件中占用的字节数,0x0744=1860字节;p_memsz:加载到内存中的section,需要占用Bytes,0x0744=1860bytes。注意:有些段不需要加载到内存中;经过上面的分析,我们知道:ELF文件的第1到第1860字节,都属于这个LOAD段的内容。执行时,需要将该段加载到虚拟地址0x08048000处的内存中。从这里开始,这是一个全新的故事。回头看这里,我已经像剥洋葱一样剥掉了所有的外套,这样你就可以看到最细的颗粒了。现在,你对我足够了解了吗?其实只要把握好以下两个关键点即可:ELF头描述了文件的整体信息和两个表的相关信息(偏移地址、表项数、表项长度);每个表包含很多entry条目,每个entry描述了一个Section/Segment的具体信息。链接器和加载器也是按照这个原则来解析ELF文件的。了解了这些原理,后面学习具体链接和加载过程就不会迷路了!