1. 为什么首先要理解ELF格式我在日常排查问题和做安全分析时手里拿到的第一个对象往往就是一个ELF文件。不管它是刚编译出来的可执行程序还是某个安了恶意软件特征的样本只要先用file命令确认了是ELF 64-bit LSB executable后面所有逆向、调试、性能分析的动作都需要围绕这套格式展开。可以说ELF就是Linux世界里最底层的通行语言不懂它很多深层的工具调用就只是表面功夫。ELF全称是Executable and Linkable Format直译就是“可执行与可链接格式”。它最初由系统V的设计者在1980年代推出后来被Linux、各种BSD、以及绝大多数类Unix系统继承并沿用至今。Windows下的PE格式、macOS下的Mach-O格式它们解决的是同一类问题只是布局和细节不同。所以一旦吃透了ELF再去看PE或者Mach-O很多概念都是相通的只是字段名和偏移位置需要重新记忆。理解ELF能解决什么问题呢往大里说它能让你明白一个C程序从源码变成内存里运行的进程中间到底经历了什么往小里说它能直接帮你定位“为什么我的程序一运行就段错误”“为什么静态链接的程序体积这么大”“为什么某个函数被编译器优化掉了”。对开发者、运维、安全研究人员来说这都是躲不开的基础。这篇内容适合刚接触Linux底层机制的开发者也适合做了几年应用开发但一直没系统理清文件格式的工程师。我尽量用实际例子带着大家走一遍保证你能拿着命令亲手验证。2. ELF文件在磁盘上的整体布局2.1 从文件头开始认识四大组成部分一个标准的ELF文件解剖开之后大致由四个部分组成文件头ELF Header、程序头表Program Header Table、节头表Section Header Table和它们各自指向的数据区。文件头位于文件的最开始固定占52字节32位ELF是52字节64位是64字节。它是整个文件的“主心骨”告诉你这个文件是什么类型、在哪种架构上运行、程序入口在哪、两张表程序头表和节头表分别在第几个字节、各有多少项。没有文件头后面的一切都无从谈起。程序头表描述的是运行视角。内核和动态链接器加载文件时只看这张表它告诉你哪些数据需要映射到内存、映射到哪个虚拟地址、权限是只读还是可读写可执行、对齐是多少。这张表只在可执行文件和共享库中存在纯目标文件.o里是没有的因为目标文件还不参与加载。节头表描述的是链接视角。编译器、链接器、调试器主要看这张表。每个节Section都有自己的名字和用途比如.text是代码、.data是已初始化数据、.symtab是符号表。节头表是链接和调试的索引目录。数据区就是实际内容了代码、全局变量、字符串、调试信息全都堆在这里分别被上面的两张表“间接引用”。2.2 文件头里的关键字段逐一拆解我习惯用readelf -h查看文件头这是最直观的方式。看一个典型64位ELF的输出$ readelf -h /bin/ls ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x5db0 Start of program headers: 64 (bytes into file) Start of section headers: 133416 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 30最有价值的几个字段我逐个说一下。Magic的前四个字节是固定的7f 45 4c 46也就是\x7fELF。任何解析器识别ELF第一件事就是检查这四个字节不匹配直接报错。第五个字节是02表示64位如果是01就是32位。第六个字节是01表示小端字节序。TCP/IP协议是网络字节序ELF可执行文件在自己本地跑所以通常沿用CPU本身的字节序x86和ARM基本都是小端。Type字段标识文件类型。常见的有REL目标文件、EXEC可执行文件、DYN共享库或者启用PIE的可执行文件、CORE核心转储文件。这里/bin/ls显示是DYN而不是EXEC因为现代Linux发行版都默认开启PIE位置无关可执行它的基本地址不固定加载时由内核随机分配。这个机制是缓解地址随机化ASLR攻击的重要手段。Entry point address是程序第一条指令的虚拟地址。对于PIE程序这个值表示为相对于加载基址的偏移对于普通的EXEC文件这就是绝对地址。Start of program headers和Start of section headers告诉你两张索引表的起始偏移。Number of program headers和Number of section headers分别是表项数量乘以表项大小就能算出表的结束位置。2.3 用命令亲手验证文件头光看输出还不够我会建议你用xxd直接看原始字节把文件头的几个关键数字亲手对一下。拿一个简单程序举例$ cat hello.c #include stdio.h int main() { puts(hello); } $ gcc -o hello hello.c $ xxd -l 64 hello 00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............ 00000010: 0300 3e00 0100 0000 4012 0000 0000 0000 .............. 00000020: 4000 0000 0000 0000 3802 0000 0000 0000 .......8....... 00000030: 1c00 1900 0800 0000 0000 0000 0000 0000 ................从第16字节开始是03 00 3e 00低位在前即Type3DYNMachine0x3e即62x86-64。第24字节是入口地址0x1240这个地址相对基址的偏移很小因为PIE把启动代码放在很靠前的位置。第32字节40 00 00 00是程序头表偏移64字节正好紧跟在文件头后面。第40字节38 02 00 00是节头表偏移0x238也就是568字节处。养成“先看readelf输出再用xxd验证原始字节”的习惯你会对格式的理解扎实很多至少以后再遇到任何异常文件能自己动手排查。这一步没有捷径。提示readelf的版本和你系统上binutils的版本有关。新版本会多显示一些字段比如GNU_PROPERTY相关的属性节但核心结构始终不变。3. 节Section与段Segment两个视角的映射关系3.1 节编译器眼中的文件区块节是ELF文件内部按功能划分的数据块。编译器每生成一个目标文件就会产生一系列节.text放机器码.data放已初始化的全局变量.bss放未初始化的全局变量.rodata放只读常量.symtab放符号表.strtab放符号名称字符串.shstrtab放节名称字符串。用readelf -S看节表你能直观体会到这种划分$ readelf -S hello There are 30 section headers, starting at offset 0x238: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .interp PROGBITS 0000000000000318 00000318 000000000000001c 0000000000000000 A 0 0 1 [ 2] .note.gnu.property NOTE 0000000000000338 00000338 0000000000000020 0000000000000000 A 0 0 8 ...每一节都有名称、类型、地址、文件偏移、大小、标志等属性。Flags里的字母含义很重要A表示Alloc加载时需要分配内存W表示WritableX表示ExecutableI表示Info系统保留。编译器在生成目标文件时会根据代码属性的不同把内容分类放进不同节这样链接器才有得编排。其中.bss节比较特殊它在文件里不占用任何实际字节Size显示有值但Offset往往紧跟在数据区末尾实际文件里没存内容。之所以可以这样是因为未初始化的全局变量在加载时全填零没必要在磁盘上存一堆零字节。这是ELF的经典空间优化。3.2 段内核加载视角下的内存布局程序头表描述的段Segment则是内核真正关心的。每个段都是一块连续的、有相同权限属性的数据。内核加载ELF时并不是把每个节单独映射进内存那样太浪费页表项了而是把相邻的、权限相同的节合并到一起作为一段一次性mmap进内存。节到段的合并规则由链接器的脚本linker script控制。默认情况下链接器会把.text、.rodata、.note等只读节合并成一个只读段对应readelf -l里的LOAD类型一般标注R E权限把.data、.bss等可写节合并成一个可写段R W权限。这样映射时一个段映射一次权限统一设置既省页表项又符合安全原则——不该写的代码块永远不给你写权限。用readelf -l查看程序头表$ readelf -l /bin/ls Elf file type is DYN (Position-Independent Executable file) Entry point 0x5db0 There are 13 program headers, beginning at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040 0x00000000000002d8 0x00000000000002d8 R 0x8 INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000003f70 0x0000000000003f70 R E 0x1000 LOAD 0x0000000000003f70 0x0000000000003f70 0x0000000000003f70 0x0000000000001130 0x0000000000002c80 R W 0x1000 DYNAMIC 0x0000000000003f98 0x0000000000003f98 0x0000000000003f98 0x00000000000001b0 0x00000000000001b0 R W 0x8 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10 ...注意看两个LOAD类型的段。第一个从文件偏移0开始大小0x3f70Flags是R E这是包含代码和只读数据的那一段。第二个从偏移0x3f70开始大小0x2c80Flags是R W这是包含.data和.bss的那一段。MemSiz比FileSiz大的部分就是.bss在内存里后续铺开的零区域。为什么会这样设计因为操作系统加载程序的时候做的是mmap映射而mmap的最小单位是页通常4096字节。为了不浪费页链接器必须把所有节的偏移和大小对齐到页边界把权限相同的节前后拼接在一起。这就是为什么你写了一个1KB的代码文件可能被填充到3KB左右而不是精确的1KB。3.3 从节到段的映射链接脚本在背后拉线如果你好奇具体哪些节归属于哪个段可以用readelf -S和readelf -l对照着看。通常链接器使用默认脚本但嵌入式开发或者有特殊内存布局需求时会提供自定义链接脚本。查看默认链接脚本可以用ld --verbose输出里能看到类似这样的片段. SIZEOF_HEADERS; .text : { *(.text) *(.text.*) } .rodata : { *(.rodata) *(.rodata.*) } .data : { *(.data) *(.data.*) } .bss : { *(.bss) *(.bss.*) }每个输出节按顺序排放所有同名输入节都被聚合到对应的输出节中。靠这个机制编译器只管生成内容链接器集中控制布局。在实际项目里我要调整某个特殊全局变量放在特定地址就是在链接脚本里给它专门开辟一个节然后定义起始符号和结束符号。4. 符号表与重定位链接过程的核心4.1 符号表名字到底怎么和地址绑定符号表是ELF里最像“字典”的数据结构。每个符号记录一个名字和它对应的值通常是地址或偏移量还有一些属性位描述这个符号是函数还是变量、是全局的还是局部的、是否已定义。用readelf -s查看符号表$ readelf -s hello Symbol table .symtab contains 65 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c ... 23: 0000000000004010 0 OBJECT LOCAL DEFAULT 23 completed.0 ... 59: 0000000000001139 16 FUNC GLOBAL DEFAULT 13 main 60: 0000000000000000 0 FUNC GLOBAL DEFAULT UND putsGLIBC_2.2.5这里最需要区分的就是LOCAL和GLOBAL。LOCAL符号如静态函数、静态变量只在目标文件内部可见不会参与到跨文件链接。GLOBAL符号则可以被其他目标文件引用比如这里的main。UND表示该符号在当前文件中未定义需要从别的目标文件或共享库里找比如puts。符号值对于不同的符号含义不同。在目标文件里它通常是对某个节的偏移在可执行文件里它往往是虚拟地址。所以解析符号表前必须根据文件类型来理解Value字段的含义。4.2 重定位链接器怎么把“引用”改成“具体地址”写C代码时我们用函数名调用函数但CPU最终执行的指令里没有函数名只有地址。把函数名“翻译”成地址的过程分为两步编译时编译器生成目标文件遇到外部函数或全局变量先留一个空位并产生一条重定位记录链接时链接器根据符号表找到目标地址回填到这个空位里。重定位记录用readelf -r查看。看一个简单目标文件$ cat foo.c extern int global_val; void func() { global_val 42; } $ gcc -c -o foo.o foo.c $ readelf -r foo.o Relocation section .rela.text at offset 0x238 contains 1 entry: Offset Info Type Sym. Value Sym. Name Addend 0000000000000004 0000000200000002 R_X86_64_PC32 0000000000000000 global_val - 4这条记录的意思是在.text节的偏移4处需要放一个32位的PC相对跳转值目标是global_val符号附加修正值是-4。链接器做计算时公式大致是S A - P其中S是符号实际地址A是AddendP是正在重定位的位置。PC相对寻址要求计算目标与当前位置的差值所以要减去P。这些公式虽然枯燥但你在排查一些“调用外部函数崩溃”的问题时脑子里有这条公式就能判断是不是链接时没解析到符号或者重定位类型选错了。重定位的类型非常多R_X86_64_PC32、R_X86_64_PLT32、R_X86_64_GOTPCREL只是其中一小部分。你不需要全部背下来但至少要知道每种类型决定了“要不要加基址”“是绝对寻址还是相对寻址”这是理解动态链接跳转结构的基础。4.3 静态链接的过程一步步看着地址被填上我建议你自己动手做一次实验编译两个小源文件然后把.o文件的符号表、重定位表都打出来再用gcc -static做一次完整静态链接对比链接前后符号表的变化。你会看到链接前puts在符号表里是UND链接后它被解析到libc.a里某个具体地址重定位记录全部清零因为地址已经回填完成。这个过程对理解“链接器就是地址整理器”更有帮助。5. 动态链接与加载过程拆解5.1 动态段与.dynamic节存了什么现代Linux上的可执行文件几乎都是动态链接的。动态链接的骨架在PT_DYNAMIC段里对应.dynamic节。用readelf -d查看$ readelf -d /bin/ls | head -30 Dynamic section at offset 0x3f98 contains 29 entries: Tag Type Name/Value 0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 0x000000000000000c (SYMBOLIC) 0 0x000000000000000d (INIT) 0x4d30 0x0000000000000019 (INIT_ARRAY) 0xbf40 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) 0x0000000000000005 (STRTAB) 0x4960 0x0000000000000006 (SYMTAB) 0x43e0 0x000000000000000a (SYMENT) 24 (bytes) 0x0000000000000011 (RELRO) 0xde20 0x0000000000000012 (RELA) 0x3700 0x0000000000000013 (RELASZ) 1008 (bytes) 0x0000000000000014 (RELAENT) 24 (bytes) 0x0000000000000017 (JMPREL) 0x3af0 0x0000000000000018 (BIND_NOW) 0 0x0000000000000006 (SYMTAB) 0x43e0NEEDED条目列的是这个程序依赖的共享库清单加载器会依次加载这些库并解析出其中定义的符号供主程序使用。STRTAB、SYMTAB分别是动态符号表和它对应的字符串表它们的结构和静态符号表类似但只保留动态链接需要的入口。INIT_ARRAY是构造函数数组C全局对象构造、.init_array里注册的初始化函数都会在这些地址上被依次调用。5.2 GOT与PLT两个表撑起延迟绑定动态链接有一个关键问题如果程序调用了100个库函数而其中某些函数在运行过程中根本没被调用到那在启动阶段全都解析一遍就很浪费。为此ELF设计了延迟绑定机制核心就是GOT全局偏移表和PLT过程链接表。大致的流程是调用一个外部函数时代码里先跳到对应的PLT条目PLT条目再跳到GOT里存放的函数地址。第一次调用时GOT里存的不是真实函数地址而是回到动态链接器的一段解析代码解析器查完符号表把真实地址写回GOT然后跳转到目标函数。之后再调用这个函数跳转到GOT时拿到的就是真实地址不再需要解析。这个“先占位、后填充、只填一次”的机制就是延迟绑定。你可以用objdump -d看一个调用了puts的反汇编片段0000000000001139 main: 1139: f3 0f 1e fa endbr64 113d: 55 push %rbp 113e: 48 89 e5 mov %rsp,%rbp 1141: 48 8d 05 fc 0e 00 00 lea 0xefc(%rip),%rax 1148: 48 89 c7 mov %rax,%rdi 114b: e8 f0 fe ff ff call 1040 putsplt这里的call 1040 putsplt就是跳到PLT表项。在1040处你会看到一条间接跳转跳到GOT里存的地址。第一次执行后这个GOT项就被修改为真实地址。5.3 加载器的工作流程整个加载过程可以参照内核和动态链接器的配合来理解。内核首先把可执行文件的代码段和数据段映射到虚拟地址空间里然后检查PT_INTERP段找到动态链接器的路径通常就是/lib64/ld-linux-x86-64.so.2把这个链接器本身也映射进内存并将控制权交给它。动态链接器随后读取.dynamic里的NEEDED条目递归加载所有依赖库解析重定位项调用INIT_ARRAY里的构造函数最后才跳转到程序入口。我把这个顺序记住排查“为什么程序启动时崩溃”就有一个清晰的排查线索二进制本身是不是合法、依赖库有没有找到、构造函数段有没有问题、入口点是否合理一条条顺着查下去。6. 实战工具与调试技巧6.1 readelf解剖文件的第一工具readelf是我用得最多的命令它不依赖系统中的动态链接符号表直接解析文件本身因此在遭受攻击、链接器异常等情况下依然可以输出真实内容。常用选项我整理一下readelf -h file # 文件头 readelf -l file # 程序头表段 readelf -S file # 节头表 readelf -s file # 符号表 readelf -r file # 重定位信息 readelf -d file # 动态段信息 readelf -n file # 注记段note segment排查“为什么程序跑不起来”时先用readelf -l确认LOAD段权限是否正确再用readelf -d确认依赖库路径是否能找到。大部分动态库缺失问题都能靠这两步定位。6.2 objdump和nm反汇编与符号浏览objdump既能把二进制翻译成汇编语言也能查看各种头信息。常用的是objdump -d file # 反汇编可执行节 objdump -t file # 符号表 objdump -R file # 动态重定位 objdump -p file # 文件头与段信息配合nm可以快速扫描目标文件里的符号。nm的功能和readelf -s类似但输出更简洁适合快速过滤全局函数和变量。我在分析二进制体积时会用nm --size-sort把所有符号按占用空间排序一眼就能看出哪些函数或数据结构异常膨胀。6.3 hexdump在字节层面观察ELF数据有些时候文件头被篡改、魔数错误、字段被破坏readelf会直接报错。这时候就要回到最原始的十六进制字节。我之前处理过一个被加密混淆的样例readelf根本认不出来但用hexdump -C看前64字节发现魔数被替换成了其他值。把魔数改回7f 45 4c 46文件才被工具链重新识别。虽然这种手工修文件的方式不常用但遇到“反调试”或“反分析”手段时非常有效。hexdump -C file | head -20对于大文件hexdump输出太多可以根据readelf给出的偏移直接定位到具体区域。比如想查看节名表先看readelf -S输出中.shstrtab的Offset字段再照这个偏移去hexdump里查看不会浪费时间一屏一屏地翻看。7. 常见问题排查与避坑记录7.1 几类常见错误速查我把自己这些年遇到过的典型问题整理成了一张速查表可以直接照着排查。现象常见原因排查命令cannot open shared object file依赖的共享库不在系统搜索路径ldd file查看缺失依赖file format not recognized魔数被破坏或文件被截断xxd程序一运行就段错误入口地址错误、栈不可执行/堆栈被破坏readelf -h看Entry pointreadelf -l看GNU_STACK权限跳转表错乱、代码跑飞PLT/GOT重定位未正确完成objdump -R file查看动态重定位条目静态链接体积异常大可能引入了不必要的依赖或调试节nm --size-sort file查看大符号7.2 我踩过的两个坑第一个坑是误以为Size of section headers占了文件很大比例。事实上节头表本身只是索引并不会因为节的数量增加而让文件暴涨。真正让二进制变大的是.debug系列调试节和符号表。想查看每一节实际占用排名可以用readelf -S配合awk暴力排个序。发布前如果想去掉调试信息strip命令要慎用它对可执行文件效果很好但对有些场景需要保留.dynamic和.dynsym否则动态链接无法工作。我自己用的是strip --strip-unneeded它只去掉模块静态符号和调试信息保留动态链接必需的符号。第二个坑是修改LD_LIBRARY_PATH带来的连锁问题。设置了错误的环境变量后动态链接器会优先搜索指定路径结果系统自带的libc.so.6被一个不兼容版本拦截几乎所有命令都启动不了。排查时连ls都用不了最后只能靠绝对路径调用/lib64/ld-linux-x86-64.so.2手动加载程序才把环境变量清理干净。从那以后我调试动态库时都尽量用LD_DEBUGlibs输出详细加载路径而不是直接改环境变量全局生效这样既能定位问题又不污染系统环境。7.3 调试动态链接的隐藏利器LD_DEBUG环境变量是排查动态链接问题的隐藏利器平时文档里不常讲。设置LD_DEBUGlibs会打印每个库的搜索路径和加载结果设置LD_DEBUGsymbols会打印每次符号查找的结果设置LD_DEBUGreloc会打印每次重定位的详细过程。$ LD_DEBUGlibs /bin/ls 21 | grep trying file | head -20这个命令几乎可以回答“为什么这个动态库没被找到”的全部疑问。不过要注意它会输出海量内容最好搭配grep将结果收窄。我在做兼容性分析时经常用它来对比不同机器上同一动态库的搜索路径差异排查结果往往一眼见分晓。7.4 学会使用工具链附带的小工具除了标准的问题排查还有几个命令对理解ELF也很有用。size命令能直接显示各节占用情况ldd能列出动态库依赖树objdump --private-headers能查看某些私有节。我最常推荐给新手的组合是file先确认基本格式readelf -h看头和入口readelf -l看段权限readelf -d看依赖和动态信息。四个命令依次下来一个可执行文件的基本健康状况就完全在掌握之中了。我个人在实际项目里最大的体会是对ELF格式的熟悉不能只停留在概念层面一定要配合命令和原始字节去反复验证。读十遍文档不如亲手修改一个节的名字、观察readelf输出的变化来得直观。当然改动文件一定要在副本上操作否则一个不经意的字节覆盖就可能让一个原本完好的二进制彻底报废。理解了这些机制平时遇到任何文件加载、链接、符号相关的疑难杂症排查效率都会比只靠搜索答案高出一大截。