可执行文件 ELF 学习总结

491 阅读8分钟

ELF 文件格式极其重要,在很多操作系统都有应用,比如在window 中的 exe 、bin 都是ELF 格式 ,但本文不讨论这些,因为我是android 开发,所以主要讨论 android 中的ELF格式so文件(dex以后再讲) ,以及这个文件有什么实际运用,且听我胡编乱造。

要想了解清楚ELF文件格式该从什么地方下手呢?直接查ELF格式的文档?ELF格式的文档有几万行解释,并且和cpu架构相关,估计看完我们也记不住什么,我们可以先从ELF文件是怎么生成的,生成好之后怎么被我们系统加载进内存的,以及一些开源库比如xCrash ,xHook对ELF文件的处理来学习。

了解ELF文件之前先来点计算机基础知识。我们知道操作系统有三大法宝,存储程序计算机、函数调用堆栈、中断。这与ELF 有什么关系?现在的计算机和手机其实也是基于冯诺依曼体系结构演变而来,cpu从寄存器取指执行,为什么从寄存器而不直接从内存取指?因为cpu频率几个GHz ,内存只有几千Hz,如果直接从内存取指会造成cpu性能的严重浪费。最关键的一个寄存器就是程序计数器 ip/eip/rip/pc(不同架构有不同的命名但作用大抵相同),cpu从程序计数器取指,译码,执行,然后+1(其实cpu取完指,程序计数器就自动加1了) ,这样一直重复。下面来一段 X86汇编感受下这个过程

X86汇编

int g(int x){
    return x + 3;
}
int f(int x) {
    return g(x) ;
}
int main(void){
    return f(8) + 1;
}

在C/C++中栈是向下递减的,ebp表示栈底 ,esp表示栈顶 。 假设ebp栈底地址初始值为2000 ,cpu 从程序计数器取指 pushl %ebp(程序计数器eip立即加1,指向下一条move1 %esp ,%ebp 指令并且等待cpu取指) ,执行完 pushl %ebp 指令后 ,ebp 还是指向2000的地址,但esp 指向地址1996,并且2000 -1996这个地址存的值为ebp 0 (为上个栈恢复做准备),执行完move1 %esp ,%ebp 后 , esp 和 ebp都是指向地址1996了,pushl movl 这两句指令的作用就是开辟一个属于main的子栈,接着然后下面两句 sub1 movl ,就是把立即数8压入栈此时栈顶esp 地址= 1992 。执行call f指令,call 会执行两条指令,第一条指令把程序计数器 eip 压入栈,此时esp 地址=1988 , 1992 -1988这个地址存的值为 eip 23(存这个值用来恢复程序计数器),第二条指令修改eip为 f 函数的地址。

f 函数里面前两个指令没什么好说的,就是开辟一个属于f 函数的子栈 。后面三条指令subl , movl ,movl 就是先把立即数8 放入函数返回值相关的寄存器eax , 然后把立即数8压栈 (这里就解释了为什么x86 32位函数传递是基于栈的),接着执行call g指令调用 g函数,把eip 15 入栈并且修改eip 为g函数地址。

g 函数中前两条指令用于建立属于g函数的子栈,后面两条指令就是把eax 寄存器值修改为11 没什么好说的,我们主要说下怎么拆毁这个子栈的,执行到第5行时栈如下

popl %ebp 就是把 ebp指向上一个函数的栈底 ,即ebp =地址1984 , 因为popl 所以esp 会 +4 ,此时栈顶esp =地址1976 ,然后接着执行ret 指令,这条指令实际指令为 popl %eip ,把eip 指向 f 函数15行的地址,这样就结束g函数继续执行f函数了。leave 指令 包含 movl ebp esp 和 popl ebp 两条指令,把栈顶指向栈底,然后将栈底指向上个函数main的栈底 ,很好理解不多解释了。

好了打住,再说下去就有点不干正事了,这些与可执行文件so有什么关联?其实这些汇编指令就存在ELF 文件的某个 .text节\段里,把这些通过mmap 映射到内存中cpu就能去解释执行了。

说了这么多就用一句废话来概括。每次调用一个新函数都会先把上个函数栈底保存,然后将ebp esp指向同一个地址,随着函数执行过程会不断压栈即栈顶esp 指向的地址一直递减,栈底ebp保持不变,当这个新函数执行完需要销毁这个子栈时会把 esp 指向栈底ebp,然后弹两次栈恢复ebp eip的值,cpu从eip取指恢复上一个函数执行 。

ELF 文件生成

安卓中一般使用cmake来编译生成可执行文件ELF , cmake要编译生成ELF自然是需要一个配置文件来编译的,这个文件就是CMakeLists.txt 。一个普通的项目要变成NDK项目就必须要在gradle指定CMakeLists文件的路径和cmake 相关配置。

我们知道cmake会根据CMakeLists 来生成一个或多个ELF , CMakeLists 里面有什么呢? 比较重要的方法有add_library(xx xx 要编译c源代码)和add_executable(xx 要编译的c源代码),前者用来添加生成共享.so/静态.a库,后者用来添加生成可执行文件。这两个都是ELF格式,但可执行文件必须要包含入口main函数,并且elf 头有入口函数的地址,不然执行系统调用execve等系列方法时会因找不到入口地址而报错。

如果我们要在自己写的C/C++ 代码中去用系统库或者别人写好的So ,CMakeLists该怎么配置呢?通过target_link_libraries(xx log)这样我们就能链接并使用系统的log库了。但是使用外部So就比较麻烦了,先要add_library(name SHARED IMPORTED) 把外部so添加进来,然后set_target_properties(name PROPERTIES IMPORTED_LOCATION 外部so路径) 设置外部so的路径, 最后在链接target_link_libraries(xx ${name})就行了,最终使用的话还要用include_directories 把头文件包含进来才可以使用,更多关于CMakeLists就不多讲了,可以翻阅 官方文档

当我们要使用外部共享库So的时候,这些外部 so保存在ELF文件哪里呢?这些外部 so会保存 .dynamic Section/Segment 中 , 链接器去动态链接so的时候,会先去解析这个 .dynamic ,把需要的so先加载,你不先加载这些so怎么知道用到的So库的函数在内存中地址呢?不知道地址就无法进行重定位了。

ELF 加载运行

在java 中我们可以使用 System.load \ System.loadLibrary()把我们的动态库加载进内存,最终也是在native层调用dlopen。dlopen会先检查已加载的 ELF 列表,查看我们要加载的So是否已加载,如果已加载就把引用计数加一然后返回文件句柄,如果没有加载会从 .dynamic section 中读取外部依赖,先加载外部依赖的 ELF 列表。加载完成后最终会得到一个ELF 完整列表(包括自身的),接着会遍历这个完整列表,读程序头表PHT,用 mmap 把所有类型为type为 PT_LOAD 的 segment 依次映射到内存中,接着从 .dynamic segment读取各信息项执行重定位操作,把地址填入.got 中,PLT HOOK 的原理就是修改.got表中要HOOK 函数的地址。

读了这么多是不是有点懵了? .dynamic .got .data section segment 这些在ELF 里是干什么的?PT_LOAD 类型的segment是什么? 其实我们用readelf 来查看ELF ,结合offset 在ELF 文件的偏移来解读就很容易理解了 。从ELF整体结构再来说下ELF文件,一个ELF 文件包含一个ELF文件头, 程序头表(PHT),若干个 Section/Segment,节头表(SHT),读ELF文件头可以获取到PHT、SHT的相对ELF的偏移和 Section/Segment数量,可执行文件的入口函数地址等,也就说读ELF 头就可以读到PHT、SHT,再遍历PHT\SHT表就可以获取全部Section/Segment的信息了。Section/Segment有什么区别呢? .dynamic .got .data 都是Section ,但是 .dynamic在Section/Segment 相对ELF的偏移和所占大小,虚拟地址都一样。

通过mmap将ELF映射到内存的时候,.data Section和.data Section之前的Section都被映射到内存了,这个之前的就包括给cpu执行的汇编指令集 .text Section了 ,怎么验证呢?因为 PT_LOAD 类型的Segment 的 offset 是从 0 到.data offset的末尾,也就是说只有SHT 保存各个Section信息那张表没被映射,其余都被映射了,因为这张表在执行的时候根本用不上了。

xHook 源码中也是从内存中读取ELF的 ,先读取文件头 ,然后读取程序头表,然后通过程序头表读取 .dynamic segment ,通过 .dynamic segment 确定 .dynamic Section虚拟地址,通过 .dynamic Section 再定位.got 位置 , 因为.got在 .dynamic Section后面,通过 .dynamic 虚拟地址+ .dynamic的大小就可以得到.got 表在内存中的位置,进而修改函数地址完成Hook。xCrash 中进行栈回溯也借助了ELF里面的很多东西,比如 .ARM.exidx .eh_frame,.debug_frame等。以及Inline Hook 修改汇编指令,ELF都无处不在。