loading...
Featured image of post 逆向基础3:ELF文件

逆向基础3:ELF文件

linux的可执行文件格式

终于讲到了,久闻ELF大名,真的很有意思

在几乎所有的现代Unix-like操作系统(比如Linux)中,从可执行程序、共享库到目标文件,背后都有一个共同的标准——那就是ELF (Executable and Linkable Format,可执行和可链接格式)

ELF格式的文件也常简称对象文件,对象文件参与程序的链接和执行

参考文章:

静态链接与动态链接

ELF文件结构描述

延迟绑定过程分析

三种形态

根据在程序生命周期中所处的阶段,ELF文件主要表现为三种形式:

可重定位文件(.o)

由编译器和汇编器生成,包含了代码和数据

它可以与其他目标文件链接,以创建可执行文件或共享库

# 从 test.c 生成可重定位文件 test.o
gcc -c test.c -o test.o

可执行文件

包含了执行一个程序所需的全部信息,它指定了如何创建一个进程映像

# 从 test.o 链接生成可执行文件 test
gcc test.o -o test

共享对象文件 (.so)

这就是我们常说的动态链接库,它有两个用法:

  • 链接器能将它与其他.o.so文件链接,生成新的对象文件

  • 动态链接器在程序运行时能将多个它与可执行文件结合,共同创建进程映像

# 生成位置无关代码的目标文件
gcc -c -fPIC shared.c -o shared.o
# 链接生成共享库
gcc -shared -o libshared.so shared.o

链接与执行

ELF格式的一个核心设计思想是为同一个文件提供两种不同的解析视图,以满足不同工具的需求

链接视图

供链接器使用

文件被看作是一系列**节(Section)**的集合,链接器通过解析节头表来处理和合并这些节

image-20251010153927970

执行视图

供加载器(操作系统内核的一部分)使用

文件被看作是一系列**段(Segment)**的集合,加载器通过解析程序头表来将段加载到内存并创建进程

image-20251010153944253

下面我们会分别解释它们是什么

ELF文件核心结构

ELF头

位于文件的最开始(即偏移量是0),是整个文件的索引名片,包含了最基本的信息:

  • 文件魔数,用于识别ELF格式

  • 文件位数,32位还是64位

  • 数据编码存储方式,大端序还是小端序

  • 指令集体系结构,如 x86-64、ARM

  • 程序执行的入口点地址

  • 程序头表和节头表在文件中的偏移量、条目数量和大小

还由其他的一些信息

可以使用readelf命令查看ELF头:

readelf -h <filename>

image-20251010205639241

节头表与重要节区

这是链接视图的核心

它是一个数组,描述了文件中所有的节,每个节都是一块具有相似属性的数据或代码的集合

一些重要的节:

节名称 含义
.text 程序的可执行指令(代码)
.data 已初始化的全局变量和静态变量
.bss 未初始化的全局变量和静态变量(在文件中不占空间,加载时才分配)
.rodata 只读数据,如字符串常量
.interp 存放动态链接器的路径名
.plt 过程链接表,用于动态链接中的函数调用跳转
.got 全局偏移量表,存储动态链接符号的地址
.rel.<x> <x>的重定位信息,比如.rel.text就是.text节的重定位信息
.dynamic 动态链接所需的信息

可以使用readelf查看详细的节头表信息:

readelf -S <filename>

image-20251010210448607

程序头表与段

这是执行视图的核心

在加载器眼中,文件被划分为若干个段,而它告诉系统如何将文件内容加载到内存中以创建一个进程

重要的段类型:

段类型 含义
LOAD 可加载段,加载器需要将此类型的段从文件映射到内存
通常,一个LOAD段对应代码(可读可执行),另一个对应数据(可读可写)
INTERP 指向动态链接器的路径(对应.interp节)
DYNAMIC 指向动态链接信息(对应.dynamic节)
PHDR 描述程序头表本身的位置和大小

可以使用readelf -l <filename>查看程序头表:

image-20251010210617934

节与段到底是什么啊

这俩东西的确有些难以理解,我们展开说一下

节是ELF文件中基本的逻辑单位,用来分类不同类型的数据,编译器在生成目标文件(.o)时,会把不同性质的内容放到不同的节里

还是拿之前打印输出hello的那个c语言程序举例:

#include <stdio.h>

int main() {
    printf("hello");
    return 0;
}

我们把它编译链接生成可执行文件后,这个可执行文件内部就有了许多的节

比如.text存放的是函数的机器指令,也就是main()函数的汇编代码,而.rodata存放只读常量,比如字符串 "hello",也就是给文件里面的东西分类摆放了

当多个.o文件被合并时,链接器会把同名的节拼在一起,比如把所有.text合并成一个大.text

注意,这些节只对编译器和链接器有意义,别的东西来看是没有什么用的

当链接器把.o文件合并成最终的可执行文件后,它还要告诉操作系统运行这个程序时要加载哪些部分,这时它就会生成一组段信息

段描述的是内存映射的区域,比如哪一部分是可执行的(代码段),哪一部分又是可读写的(数据段)等等,一个段往往包含多个节

在上面代码编译生成最终的可执行文件会有两个主要的加载段:

第一个是可执行段,包含.text.rodata,程序加载时,这部分会映射成只读、可执行的内存区域

第二个是可读写段,包含.data.bss,程序加载时,这部分会映射成可读可写的区域,用于存储全局变量

除了这两个段还会有一些特殊段,就不多说了

我们上面说的都是“程序加载时”,也就是说段是给加载器看的,在加载的时候才起作用

如此羁绊

总之啊可以这样理解:

  • 节是编译器和链接器关心的划分,它描述文件中的逻辑内容

    段是操作系统加载器关心的划分,它描述程序加载到内存后的布局

  • 节是编译时的单位

    段是运行时的单位

  • 节存在于文件逻辑结构

    段存在于文件加载映射

在链接阶段完成之后,节和段完全就是两个东西,不会互相干扰

使用readelf -l看文件的时候,其实还会有下面这样的输出:

image-20251010210659755

左边就是段表各个段的索引号,右边就是对应段里面存储了那些节

记得之前的strip命令吗,当执行strip时,程序会删除节头表,于是里面的信息,也就是元数据,都没有了

那为什么删除它不会影响执行呢?现在我们就应该知道了——

**因为操作系统加载程序时只看段信息!**节没了就没了呗

程序的链接与装载

ok,我们已经了解了ELF文件的内部结构,但一个静态的文件是怎么变成一个在内存中运行的进程的呢?

这个过程的核心就是链接和装载

而链接则负责解决不同代码模块之间的依赖关系,而装载负责将文件内容搬入内存

代码的两种形态

在介绍链接细节之前,我们先看看代码生成的两种不同模式,它们直接决定了文件应如何被加载

注意,下面的概念都是基于虚拟地址的

绝对代码(Absolute Code)

这是在编译时就假定自己会被加载到内存中一个固定地址的代码

这个预设的地址被记录在程序头表的p_vaddr字段中,操作系统加载器必须将代码段存放到这个指定的虚拟地址,否则程序内部的地址引用就会全部出错

对于一个进程的主程序来说,这是可行的,因为它是第一个被加载的,其首选地址通常是空闲的

位置无关代码(PIC, Position-Independent Code)

这是为共享库(.so文件)量身定做的代码喔,在之前介绍.so文件的时候已经提到过,使用GCC编译时加上-fPIC选项即可生成

共享库会被许多不同的进程加载和共享,我们无法为一个库预设一个固定的加载地址,因为这个地址在A进程中可能是空闲的,但在B进程中可能已经被主程序或其他库占用了

PIC通过生成不依赖于任何绝对地址的代码,完美地解决了这个问题

当加载一个PIC库时,系统会为它在当前进程中选择一个空闲的虚拟基地址,这意味着同一个.so文件,在不同的进程中其段的起始虚拟地址是不同的

PIC代码之所以能正常工作,关键在于它内部所有的地址引用(如调用库内另一个函数、访问全局变量)都不是硬编码的绝对地址,而是通过相对寻址的方式实现的

系统为PIC代码维护了段间的相对位置,无论库被加载到多高的基地址,其代码段和数据段之间的虚拟地址差值始终保持不变,这个差值与它们在原始.so文件中的虚拟地址差值是相等的

叽里呱啦说那么多,总之位置无关代码很灵活,在不同进程的虚拟地址空间里位置可变,但内部各段之间的相对位置保持不变

静态链接

静态链接发生在程序编译的最后阶段,它是一个将分散的模块组合成一个完整、独立的可执行文件的过程

链接器读取开发者编写的多个可重定位文件(.o文件)和可能用到的静态库(.a文件),将它们的所有内容“打包”到一个单独的可执行文件中

链接的核心操作之一是将所有输入文件中的同类型节合并,在之前介绍节的时候提到过的:

img

而在合并的过程中,链接器最关键的工作就是进行重定位

它负责解析各个模块间的符号引用(比如函数调用),并修正代码和数据中的地址,确保它们指向正确的位置

重定位

重定位的作用

重定位本质上是一种对二进制文件进行打补丁的机制,它将代码中对符号的引用连接到该符号的定义上,解决地址未知问题

还是拿之前的hello.c程序举例吧,我们调用了printf函数,但是程序不知道这个函数在哪,所以编译器只能在机器码里留下一个临时的占位符,再用一个重定位入口来告诉链接器“这里有个地址需要你填一下”

或许你还记得之前使用objdump查看汇编后的文件的那张图片:

image-20251011153952525

里面call(调用函数)这一行怎么理解呢?

右边是汇编,我们不管,只看左边:

17: e8 00 00 00 00

冒号前面的17意思是当前指令地址为0x17

冒号后面的e8 00 00 00 00五个字节是指令,其中第一个字节e8是对应call的操作码,后面四个字节是偏移量,都是“00”,这就是“编译器在机器码里留下的临时占位符”!

之前我们没有看链接后的可执行文件结构,现在我们来看看,还是使用objdump命令:

image-20251011154918553

哦!链接之后,这里的偏移量被换成了确定的数据了!

这就是重定位的作用

重定位入口

链接器之所以知道要去哪里打补丁,是因为它读取了文件中的重定位节,也就是.rel.<X>

每个入口都用以下字段描述了一个重定位操作:

字段 说明
OFFSET 需要修正的位置在节内的偏移量,以字节为单位
TYPE 重定位类型,决定怎么计算地址
VALUE 目标符号,也就是被引用的对象的名称

使用objdump能查看重定位表:

image-20251011160328709

里面的第二条记录:

0000000000000018 R_X86_64_PLT32    printf-0x4

说明在.text段偏移0x18处有一个通过PLT(过程链接表)调用的printf的指令

重定位地址计算

链接器根据重定位类型使用特定公式来计算最终要填入的值

上文的记录的类型是R_X86_64_PLT32,用在x86-64架构,即64位架构

我们这里介绍一个类似的用在32位的R_386_PC32类型,其公式为:

目标值 = S + A - P
  • S:是符号的最终值,即被调用函数在内存中的绝对地址
  • A:是存储在要修改位置的原始值,称为隐式加数
  • P:是要修改位置本身的地址

动态链接

动态链接将链接过程的一部分推迟到程序运行时进行。当程序依赖于共享库(如libc.so)时,操作系统会在程序启动时找到这些库,并将它们动态地绑定到进程上。这种方式极大地减少了可执行文件的体积和系统内存的占用。

启动过程

系统装载可执行文件本身后,如果在解析程序头表时发现有一个INTERP段,它就知道这个程序需要动态链接

此时会加载并启动INTERP段中指定的动态链接器(在Linux上通常是/lib/ld-linux.so.2),并将控制权移交给动态链接器

动态连接器会分析可执行文件需要依赖哪些共享库,然后查找、加载这些库,并将它们也映射到进程的内存空间中

延迟绑定

为了加快程序的启动速度,动态链接器默认采用延迟绑定策略:一个外部库函数的地址只有在它第一次被调用时才会被解析

这个机制由过程链接表 (PLT)全局偏移量表 (GOT) 协同完成

过程链接表 (PLT)

给外部函数提供一个统一的跳板入口,负责第一次调用的动态解析

它在运行之前就已经确定并且不会被修改

全局偏移量表 (GOT)

保存外部函数或全局变量的真实地址,程序通过它来间接访问外部符号

它可以在程序运行中被修改(这就导致它很容易作为漏洞利用)

关于PLT和GOT的详细的结构,主要是pwn的内容,我们学逆向就点到为止

绑定过程

下面我们用printf为例看看什么叫延迟绑定:

  • 首次调用函数:
    1. 代码中的call指令实际上跳转到printf在PLT中的一个专属条目,我们称之为printf@plt
    2. printf@plt的第一条指令是跳转到GOT中为printf函数预留的地址槽printf_in_got
    3. GOT槽里存放的并不是printf的真实地址,而是printf@plt中下一条指令的地址!所以这个jmp实际上又跳了回来
    4. 程序继续执行PLT条目中的后续指令,它会将printf函数的一个标识信息压栈,然后跳转到动态链接器中一个公共的解析函数
    5. 解析函数根据传入的标识信息,查找printf函数的真实地址,然后用这个真实地址覆盖GOT中printf_in_got原来的值
    6. 最后,解析函数直接跳转到printf的真实地址去执行
  • 再次调用函数:
    1. 代码再次执行call printf@plt
    2. PLT条目再次执行jmp *[printf_in_got]
    3. 但这一次,printf_in_got这个槽里已经存放了printf函数的真实地址!所以,程序会直接跳转到printf函数,完全不再需要动态链接器介入,从而实现了高效调用

img

总之,plt就像指示牌,指向got,而got里面存了真正的地址,第一次使用plt会更新got值,后面就随便用了

程序的装载

将一个静态的ELF可执行文件转换为一个动态运行的进程映像,操作系统的加载器有着严谨的步骤

Shell调用

当用户在shell输入./hello时,shell的执行流程如下:

  1. 调用fork()创建一个子进程
  2. 子进程调用execve("./a.out", argv, envp)执行指定的ELF文件
  3. 父进程(shell)继续挂起,等待子进程结束后继续接收用户输入

也就是说,此时shell本身并不直接执行ELF文件,而是通过execve()请求内核将子进程的地址空间替换为ELF可执行文件

execve()的原型:

int execve(const char *filename, char *const argv[], char *const envp[]);

参数分别为:程序文件名、命令行参数、环境变量

execve系统调用

系统调用入口为:

int sys_execve(const char *filename, char *const argv[], char *const envp[]);

在内核中,sys_execve()会调用do_execve(),把用户传入的文件名、argvenvp(环境变量数组)封装为内核可以使用的格式,然后进入do_execve_common(),处理实际加载过程:

  1. 检查进程限制

    例如每用户进程数是否超过限制

  2. 调度准备

    sched_exec()选择负载最小的CPU

  3. 准备执行参数结构

    创建结构体struct linux_binprm bprm

    struct linux_binfmt bprm{
        struct list_head lh;        // 单链表表头
        struct module *module;      // 模块
        int (*load_binary)(struct linux_binprm *); // 装载函数
    };
    

    保存文件指针、命令行参数和环境变量、文件开头的缓冲区(前128字节)

  4. 拷贝参数与环境变量到内核

    调用copy_strings()将argv和envp拷贝到内核空间

  5. 调用exec_binprm()

    搜索可执行文件格式,并调用对应加载函数

格式识别

exec_binprm()会调用search_binary_handler(),遍历由bprm组成的链表,调用对应格式的load_binary()

接着,对于可能的ELF文件,调用load_elf_binary(),并执行以下操作:

  1. 读取ELF头

    读取文件开头128字节,检查魔数 {0x7f, 'E', 'L', 'F'},验证ELF类型(ET_EXECET_DYN

    你可能奇怪为什么已经知道是ELF了,还要再识别一次,那是因为之前只是初步匹配,这一步才是严格检查

  2. 读取程序头表

    获取段的数量、偏移位置等信息,确定每个段在文件中的偏移、大小、内存加载地址和权限。

  3. 检查动态链接器

    如果存在.interp段,说明ELF需要动态链接,内核会打开动态链接器ELF文件,并递归调用 load_elf_binary()

规划内存布局

在读取程序头表的时候,加载器会特别关注所有类型为LOAD的段,因为这些段是需要被完整加载到内存中的

程序头表中的每个LOAD条目都精确地指明了下面的内容:

  • 该段在文件中的偏移量和大小(p_offset, p_filesz
  • 该段应该被加载到的目标虚拟内存地址(p_vaddr
  • 该段在内存中应该占据的空间大小(p_memsz
  • 该段的内存访问权限

之后,跟据这些信息确定内存布局:

  • .text.rodata → 可执行、只读段
  • .data.bss → 可写段

准备工作

调用flush_old_exec(bprm)释放旧程序的代码段、数据段、栈等资源

使用setup_new_exec(bprm)初始化mm_struct(进程地址空间描述符)、地址空间

通过setup_arg_pages(),为进程分配用户态栈,将argvenvp写入栈顶

映射段到内存

根据规划好的内存布局,加载器开始为每一个LOAD段执行内存映射操作:

首先,在进程的虚拟地址空间中,于p_vaddr处开辟一块内存区域,大小为p_memsz

接着,将段的数据从ELF文件中(从p_offset偏移量开始)复制到刚刚开辟的虚拟内存中,复制的数据量为p_filesz

📝 Note

值得注意的是,p_memsz(内存大小)常常会大于p_filesz(文件大小)

这种差异通常是为了方便.bss节,这里存放的是未初始化的全局和静态变量,它们在程序运行前没有具体值,因此在文件中无需存储,不占用磁盘空间

加载器会在内存中将p_memszp_filesz之间的差额部分全部用0填充,从而完成这些变量的默认初始化

最后,加载器根据程序头表中为该段指定的权限位,设置这块内存区域的访问权限

设置入口点

对于静态链接程序,直接使用ELF头中的e_entry作为入口

对于动态链接程序,入口点由动态链接器提供

返回用户态

调用start_thread()初始化用户态寄存器,设置EIP为程序入口,设置ESP为栈顶,设置CPU标志位

启动程序执行

控制权交给ELF程序,程序从入口点_start或动态链接器入口开始运行

最后更新于 2025-10-13
距离小站第一行代码被置下已经过去
使用 Hugo 构建
主题 StackJimmy 设计
...当然还有kakahuote🤓👆