ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux ELF 文件

Linux ELF 文件 敲下./program命令的那一刻计算机是怎么把这个文件变成一个活蹦乱跳的进程的这背后的黑魔法到底是什么答案就是ELFExecutable and Linkable Format可执行与可链接格式。可以把它理解为 Linux 世界里程序的灵魂容器一、什么是 ELF 文件简单来说ELF 是 Linux 下的可执行文件格式就像 Windows 下的.exe一样。但别被这个简单的解释骗了ELF 可比.exe复杂得多也强大得多ELF 文件可以是可执行文件比如你的./program目标文件编译后但还没链接的.o文件共享库文件就是.so文件类似 Windows 下的.dll核心转储文件程序崩溃时的那个 core dump本质上ELF 就是一个容器里面装着代码、数据以及程序运行所需的各种信息按照特定的格式组织起来。二、初见 ELF第一印象很重要想知道一个文件是不是 ELF 格式的超简单$file./program program: ELF64-bit LSB executable, x86-64, version1(SYSV), dynamically linked,\interpreter /lib64/ld-linux-x86-64.so.2,forGNU/Linux3.2.0, not stripped只要文件输出信息的开头是 “ELF”那它就是 ELF 格式的再来点儿硬核的直接看一下 ELF 文件的前几个字节$ xxd-l16./program 00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............这里最开始的7f 45 4c 46就是 ELF 文件的魔数Magic Number。其中45 4c 46是 ASCII 码中的 “ELF” 三个字母前面的7f是一个特殊字符。这四个字节就是 ELF 文件的身份证操作系统首先会检查这四个字节确认它是不是一个 ELF 文件。三、ELF 文件的内部结构化繁为简很多教程一上来就画个复杂的结构图看得人头晕眼花。这里用一个简单的类比来辅助理解把 ELF 文件想象成一本程序说明书这本书有三部分组成文件头ELF Header相当于书的封面和目录告诉你这本书有什么内容怎么看程序头表Program Header Table相当于给阅读器操作系统看的指南告诉它怎么把这本书变成一个活的程序节区头表Section Header Table相当于给编辑器链接器、调试器看的指南告诉它这本书的内部结构然后书的主体内容就是各种节区Sections或段Segments里面装着代码、数据等实际内容。直观一点用图来表示就是ELF Header ├── Program Header Table ← 加载器视角 │ ├── Segment (LOAD, RX) ┐ │ ├── Segment (LOAD, RW) ├─ 每个段由若干节区拼成 │ └── Segment (DYNAMIC) ┘ └── Section Header Table ← 链接器视角 ├── .text ├── .rodata ├── .data ├── .bss ├── .symtab ├── .strtab ...哎你可能会问什么是节区Section什么又是段Segment它们有什么区别简单来说节区Section是 ELF 文件存储的基本单位针对链接器段Segment是运行时内存的基本单位针对加载器一个段通常包含多个功能相似的节区。比如包含代码的所有节区会被归入到一个叫做 “TEXT” 的段中。四、深入解剖 ELF 文件逐层剥开1. ELF 头ELF HeaderELF 头是整个文件的门面包含了文件的基本信息和指向其他部分的指针。用readelf -h命令可以查看$ readelf-h./program ELF Header: Magic: 7f454c4602 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Type: EXEC(Executablefile)Machine: Advanced Micro Devices X86-64 Entry point address: 0x401000 Start of program headers:64(bytes intofile)Start of section headers:13872(bytes intofile)...这里面最重要的信息是Entry point address程序执行的起点地址Start of program headers程序头表的位置Start of section headers节区头表的位置2. 程序头表Program Header Table程序头表告诉操作系统如何创建进程映像用readelf -l命令查看$ readelf-l./program Type Offset VirtAddr FileSiz MemSiz Flg Align INTERP 0x000318 0x0000000000400318 0x00001c 0x00001c R1LOAD 0x000000 0x0000000000400000 0x000818 0x000818 R E1000LOAD 0x000e10 0x0000000000401e10 0x000240 0x000248 RW1000DYNAMIC 0x000e28 0x0000000000401e28 0x0001d0 0x0001d0 RW8最重要的是那些类型为 LOAD 的段它们会被加载到内存中。注意看 FlagsR表示可读ReadW表示可写WriteE表示可执行Execute这就是为什么有的内存区域可执行有的只能读不能写这些权限在 ELF 文件里就定义好了3. 节区头表Section Header Table节区头表描述了文件中各个节区的信息用readelf -S查看readelf -S 输出节选$ readelf-S./program There are29section headers, starting at offset 0x3630:[Nr]Name Type Address Off Size ES Flg[1].interp PROGBITS 0000000000400318 000318 00001c 00 A[2].dynsym DYNSYM 0000000000400340 000340 0000c018A[7].text PROGBITS 0000000000401060 001060 000115 00 AX[8].rodata PROGBITS 0000000000402000 002000 000004 00 A[11].data PROGBITS 0000000000404000 003000 000010 00 WA[12].bss NOBITS 0000000000404018 003010 000008 00 WA[14].comment PROGBITS 0000000000000000 003018 00002a 01[27].symtab SYMTAB 0000000000000000 003180 00016818[28].strtab STRTAB 0000000000000000 0032e8 0000f9 00常见的重要节区包括.text存放程序的机器代码.data已初始化的全局变量和静态变量.bss未初始化的全局变量和静态变量不占用文件空间.rodata只读数据如字符串常量.symtab符号表存储程序中定义和引用的函数、变量.strtab字符串表通常存储符号名.dynamic动态链接信息五、ELF 文件的生命周期从编译到执行为了彻底搞懂 ELF 文件需要了解它的整个生命周期。1. 编译阶段生成目标文件.o当你写完 C 代码运行gcc -c hello.c时会得到一个hello.o的目标文件。这个文件已经是 ELF 格式的了但它还不能直接执行因为里面有很多坑等着被填上。这些坑在 ELF 文件中表现为重定位表用readelf -r可以看到$ readelf-rhello.o Relocation section.rela.textat offset 0x... contains2entries: Offset Info Type Symbols Value Symbols Name Addend 000000000000000b 000000050002 R_X86_64_PLT32 0000000000000000printf-4000000000000001a 000000060002 R_X86_64_PLT32 0000000000000000exit-4这表示代码中调用了printf和exit函数但编译器不知道它们在哪儿所以留了个坑等着链接器来填。2. 符号表程序的通讯录说到这些函数printf、exit不得不提 ELF 文件中的符号表。简单来说符号表就像是程序的通讯录记录了程序中所有函数和变量的名字和位置。来看看符号表长啥样$ readelf-shello.o Symbol table.symtabcontains13entries: Num: Value Size Type Bind Vis Ndx Name0: 00000000000000000NOTYPE LOCAL DEFAULT UND1: 00000000000000000FILE LOCAL DEFAULT ABS hello.c7: 000000000000000036FUNC GLOBAL DEFAULT1main11: 00000000000000000NOTYPE GLOBAL DEFAULT UNDprintf12: 00000000000000000NOTYPE GLOBAL DEFAULT UNDexit这里面有main函数我们自己定义的还有printf和exit外部函数。注意它们的 Ndx索引列main是 1表示在第 1 个节区而printf和exit是 UND表示未定义这就是前面说的坑。这个目标文件的符号表就像一张半成品通讯录只记录了自己有什么函数以及自己需要哪些外部函数但还不知道那些外部函数在哪里。所以它还不能独立工作需要链接器来帮忙找到这些外部函数。3. 动态链接程序的即插即用说到外部函数就不得不提 ELF 的一个超强功能动态链接。还记得 Windows 上安装软件时经常冒出的 “DLL 缺失” 错误吗Linux 上也有类似概念不过实现得更优雅这就是动态链接库.so文件。动态链接的好处简直不要太多节省内存多个程序共享同一个库节省磁盘不用把所有代码都打包进可执行文件方便升级库更新后程序自动用上新版本不用重新编译那么问题来了程序怎么知道自己需要哪些库又是如何找到这些库的呢ELF 文件中有一个特殊的.dynamic节区专门记录这些信息$ readelf-d/bin/ls Dynamic section at offset 0x... contains27entries: Tag Type Name/Value 0x00000001(NEEDED)Shared library:[libselinux.so.1]0x00000001(NEEDED)Shared library:[libc.so.6]0x0000000c(INIT)0x3000这告诉我们ls命令依赖于这两个共享库具体依赖哪几个跟发行版有关需验证。如果想更直观地看到所有依赖及它们的实际位置可以用ldd命令$ ldd /bin/ls linux-vdso.so.1(0x00007ffd1c3f9000)libselinux.so.1/lib/x86_64-linux-gnu/libselinux.so.1(0x00007f1c4e2b0000)libc.so.6/lib/x86_64-linux-gnu/libc.so.6(0x00007f1c4e0c0000)/lib64/ld-linux-x86-64.so.2(0x00007f1c4e310000)看到没ldd不仅告诉你需要哪些库还告诉你它们的实际位置和加载地址。那程序又是怎么找到这些库的呢它会按照以下顺序查找环境变量LD_LIBRARY_PATH指定的目录可执行文件的 RPATH 属性指定的目录/etc/ld.so.cache缓存中记录的位置默认目录如/lib、/usr/lib等RPATH、RUNPATH 与LD_LIBRARY_PATH的先后关系在不同 glibc 版本下有差异精确顺序以文末 ld.so 手册页为准需验证。动态链接器ld.so会在程序启动时自动处理这些依赖关系把所有需要的库都加载进来就像乐高积木一样把程序拼装完整非常巧妙4. 链接阶段生成可执行文件链接器会把多个目标文件和库文件链接在一起解决那些坑重定位最终生成可执行文件。那么链接器具体是怎么解决这些坑的呢简单来说就是做个牵线搭桥的活收集所有目标文件中的符号表建立一个全局符号表找到所有标记为未定义UND的符号在全局符号表或者库文件中寻找这些符号的定义把找到的地址填回原来的坑中比如当链接器找到printf函数在libc.so中的实际地址后就会修改原来调用printf的指令让它指向正确的地址。链接完成后再看同一个程序的符号表会发现那些 UND 的符号要么有了实际地址静态链接要么指向了动态链接的跳转表动态链接。在动态链接的情况下还会在 ELF 文件中记录运行时需要哪些共享库前面已经说过了。5. 加载阶段从文件到进程当你执行./program时操作系统确切地说是加载器ld.so会做这些事检查 ELF 头的合法性根据程序头表将需要的段加载到内存如果是动态链接的还会找到并加载所需的共享库跳转到 Entry Point 开始执行这个过程可以用strace命令观察strace ./program 输出节选$strace./program execve(./program,[./program], 0x7ffd... /*34vars */)0brk(NULL)0x55e931a2c000 access(/etc/ld.so.preload, R_OK)-1ENOENT(No suchfileor directory)openat(AT_FDCWD,/etc/ld.so.cache, O_RDONLY|O_CLOEXEC)3openat(AT_FDCWD,/lib/x86_64-linux-gnu/libc.so.6, O_RDONLY|O_CLOEXEC)3mmap(NULL,2005120, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE,3,0)0x7f1c4e0c0000 mmap(0x55e930e2f000,4096, PROT_READ|PROT_EXEC, MAP_PRIVATE|...,3,0)...... exit_group(0)? exited with0execve就是创建新进程的系统调用后面一系列操作就是在加载和准备程序运行环境。六、ELF 实用工具箱玩转 ELF 文件了解了 ELF 的原理后来看看有哪些工具可以操作 ELF 文件工具用途典型用法file判断文件类型file ./programreadelf查看 ELF 文件的所有信息readelf -a ./programobjdump反汇编 ELF 文件objdump -d ./programnm列出符号表nm -u hello.oldd查看动态依赖ldd /bin/lsstrings提取文件中的字符串strings ./program | headstrip移除符号表和调试信息strip --strip-all ./programpatchelf修改 ELF 文件的属性patchelf --set-rpath ./lib ./program七、实际应用ELF 文件的那些神奇玩法ELF 文件的知识不仅仅是理论来看看一些实际的例子。1. 程序加固与混淆开发了一个软件不想被轻易破解$ls-lhprogram -rwxr-xr-x1user user 25K program $ strip --strip-all ./program $ls-lhprogram -rwxr-xr-x1user user 14K program文件体积一下减少了几十 k因为符号信息都被删掉了2. 程序补丁与热修复假设想修改程序使用的解释器路径$ patchelf --set-interpreter /lib/ld-linux.so.2 ./program这样程序就会使用你自定义的动态链接器而不需要重新编译更酷的是Linux 还提供了一种不用重启程序就能热修复的黑科技——LD_PRELOAD环境变量它可以让你悄悄地替换程序中的函数实现。来看一个简单实用的例子 —— 监控程序的内存分配创建一个简单的内存跟踪库memtrace.c#define_GNU_SOURCE#includestdio.h#includedlfcn.h// 原始 malloc 函数指针staticvoid*(*real_malloc)(size_t)NULL;// 拦截 malloc 函数void*malloc(size_tsize){// 延迟初始化原始函数if(real_mallocNULL){real_mallocdlsym(RTLD_NEXT,malloc);}// 调用原始 mallocvoid*ptrreal_malloc(size);// 打印跟踪信息fprintf(stderr,malloc(%zu) %p\n,size,ptr);returnptr;}编译成共享库$ gcc-shared-fPICmemtrace.c-olibmemtrace.so-ldl接着使用库监控任何程序的内存分配$LD_PRELOAD./libmemtrace.so ./my_program输出malloc(100) 0x55e930e2f6b0 malloc(200) 0x55e930e2f720 malloc(300) 0x55e930e2f7f0只用了十几行代码就实现了一个能够监控任何程序内存分配的工具这个例子的工作原理很简单定义一个与系统函数同名的malloc用dlsym(RTLD_NEXT, malloc)找到真正的 malloc 函数在调用真正的 malloc 前后添加我们的代码这里是打印日志通过LD_PRELOAD让系统优先加载我们的库这种技术经常用于调试内存问题给程序添加日志修改程序行为而不用改源码临时修复运行中的服务当然这项技术也常被黑客利用来劫持程序函数所以理解它不仅能提升编程能力也对安全防护很重要八、总结ELF 文件的精髓来总结一下 ELF 文件的核心要点ELF 是容器装载了代码、数据和各种元数据分层结构ELF 头、程序头表、节区、节区头表两种视角执行视角段Segments— 加载器关心链接视角节Sections— 链接器关心生命周期从源代码到目标文件再到可执行文件最后变成进程当你理解了 ELF 文件的本质Linux 下的很多问题就迎刃而解了为什么有些程序不能在不同版本的 Linux 上运行为什么动态库版本不匹配会导致程序崩溃为什么有些恶意软件难以检测——这些问题的答案都藏在 ELF 文件的结构中记住ELF 文件不仅仅是一个格式它是 Linux 世界中程序的灵魂容器承载着程序从编译到执行的整个生命周期。延伸阅读elf(5) — ELF 文件格式官方手册页ld.so(8) — 动态链接器与库查找顺序GNU binutils 文档readelf / objdump / nm / strip
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进