从ELF文件头到.text段:手把手教你用objdump拆解Linux可执行文件
在计算机的世界里,每个可执行程序都像一本精心编排的书,而ELF(Executable and Linkable Format)就是这本书的标准格式。对于逆向工程初学者来说,理解ELF文件结构就像获得了一把打开程序内部世界的钥匙。今天,我们就用Linux下的objdump工具,像侦探一样解剖一个简单的"hello world"程序,看看编译后的代码在磁盘上究竟是如何组织的。
1. 准备工作:认识我们的工具和目标
在开始之前,我们需要准备两样东西:一个简单的可执行文件和一个强大的分析工具。让我们先创建一个经典的"hello world"程序:
// hello.c #include <stdio.h> int main() { printf("Hello, World!\n"); return 0; }用gcc编译这个程序:
gcc -g -o hello hello.c现在,我们有了可执行文件hello,接下来就可以使用objdump这个瑞士军刀般的工具来解剖它了。objdump是GNU binutils工具集的一部分,能够显示目标文件的各种信息,包括:
- 文件头信息(-f选项)
- 段头表(-h选项)
- 段内容(-s选项)
- 反汇编代码(-d选项)
2. 初探ELF文件头:程序的身份证
ELF文件头是整个文件的起点,包含了描述文件基本属性的元数据。让我们先用-f选项查看文件头信息:
objdump -f hello你会看到类似这样的输出:
hello: 文件格式 elf64-x86-64 体系结构:i386:x86-64,标志 0x00000150: HAS_SYMS, DYNAMIC, D_PAGED 起始地址 0x0000000000401040这个输出告诉我们几个关键信息:
- 文件格式:elf64-x86-64,表示这是一个64位的ELF文件,针对x86-64架构
- 标志位:
- HAS_SYMS:表示文件包含符号表
- DYNAMIC:表示这是一个动态链接的可执行文件
- D_PAGED:表示文件是分页的
- 入口点地址:0x0000000000401040,这是程序开始执行的第一条指令的地址
ELF文件头实际上包含更多细节,我们可以用readelf工具查看更完整的信息(这不是本文重点,但值得了解):
readelf -h hello3. 解析段头表:程序的组织架构
ELF文件由多个段(segment)和节(section)组成。段是程序加载和执行时使用的单位,而节是链接和重定位时使用的单位。用-h选项可以查看段头表:
objdump -h hello典型输出会列出所有段的信息,包括:
节: Idx Name Size VMA LMA File off Algn 0 .interp 0000001c 0000000000400238 0000000000400238 00000238 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 1 .note.ABI-tag 00000020 0000000000400254 0000000000400254 00000254 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .note.gnu.build-id 00000024 0000000000400274 0000000000400274 00000274 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 3 .gnu.hash 00000024 0000000000400298 0000000000400298 00000298 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .dynsym 00000060 00000000004002b8 00000000004002b8 000002b8 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 5 .dynstr 0000003a 0000000000400318 0000000000400318 00000318 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 6 .gnu.version 00000008 0000000000400352 0000000000400352 00000352 2**1 CONTENTS, ALLOC, LOAD, READONLY, DATA 7 .gnu.version_r 00000020 0000000000400360 0000000000400360 00000360 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 8 .rela.dyn 00000018 0000000000400380 0000000000400380 00000380 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 9 .rela.plt 00000030 0000000000400398 0000000000400398 00000398 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 10 .init 00000017 00000000004003c8 00000000004003c8 000003c8 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 11 .plt 00000030 00000000004003e0 00000000004003e0 000003e0 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 12 .text 00000192 0000000000400410 0000000000400410 00000410 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 13 .fini 00000009 00000000004005a4 00000000004005a4 000005a4 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 14 .rodata 00000011 00000000004005b0 00000000004005b0 000005b0 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 15 .eh_frame_hdr 00000034 00000000004005c4 00000000004005c4 000005c4 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 16 .eh_frame 000000f4 00000000004005f8 00000000004005f8 000005f8 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 17 .init_array 00000008 0000000000600e10 0000000000600e10 00000e10 2**3 CONTENTS, ALLOC, LOAD, DATA 18 .fini_array 00000008 0000000000600e18 0000000000600e18 00000e18 2**3 CONTENTS, ALLOC, LOAD, DATA 19 .dynamic 000001d0 0000000000600e20 0000000000600e20 00000e20 2**3 CONTENTS, ALLOC, LOAD, DATA 20 .got 00000008 0000000000600ff0 0000000000600ff0 00000ff0 2**3 CONTENTS, ALLOC, LOAD, DATA 21 .got.plt 00000020 0000000000600ff8 0000000000600ff8 00000ff8 2**3 CONTENTS, ALLOC, LOAD, DATA 22 .data 00000010 0000000000601018 0000000000601018 00001018 2**3 CONTENTS, ALLOC, LOAD, DATA 23 .bss 00000008 0000000000601028 0000000000601028 00001028 2**0 ALLOC 24 .comment 0000002a 0000000000000000 0000000000000000 00001028 2**0 CONTENTS, READONLY每个段都有几个关键属性:
| 属性名 | 描述 |
|---|---|
| Size | 段在内存中的大小 |
| VMA | 虚拟内存地址(Virtual Memory Address),段在内存中的加载地址 |
| LMA | 加载内存地址(Load Memory Address),通常与VMA相同 |
| File off | 段在文件中的偏移量 |
| Algn | 段的对齐要求 |
| Flags | 段的属性(CONTENTS, ALLOC, LOAD, READONLY, CODE, DATA等) |
对于逆向分析来说,最重要的几个段是:
- .text:包含程序的可执行代码
- .data:包含已初始化的全局变量和静态变量
- .bss:包含未初始化的全局变量和静态变量
- .rodata:包含只读数据(如字符串常量)
- .dynamic:包含动态链接信息
4. 深入.text段:查看机器指令
.text段是程序的核心,包含了所有可执行代码。我们可以用-d选项来反汇编.text段:
objdump -d hello输出会显示所有可执行段的汇编代码,包括.init、.plt、.text和.fini等。我们最关心的是main函数的代码:
0000000000400526 <main>: 400526: 55 push %rbp 400527: 48 89 e5 mov %rsp,%rbp 40052a: bf c4 05 40 00 mov $0x4005c4,%edi 40052f: e8 cc fe ff ff callq 400400 <puts@plt> 400534: b8 00 00 00 00 mov $0x0,%eax 400539: 5d pop %rbp 40053a: c3 retq这段汇编代码对应我们的C代码printf("Hello, World!\n");。有趣的是,编译器优化后使用了puts而不是printf,因为我们的字符串以换行符结尾且没有格式说明符。
注意:实际输出可能会因编译器版本和优化选项不同而有所差异。使用
-O0关闭优化可以更接近源代码结构。
如果想只看.text段的内容,可以使用:
objdump -j .text -d hello5. 查看段内容:十六进制与ASCII表示
有时候我们需要查看段的原始内容,这时可以使用-s选项。例如,查看.rodata段(通常包含字符串常量):
objdump -j .rodata -s hello输出类似:
Contents of section .rodata: 4005c0 01000200 48656c6c 6f2c2057 6f726c64 ....Hello, World 4005d0 2100 !.这里我们可以看到字符串"Hello, World!"的ASCII表示和十六进制编码。
6. 高级技巧:结合源代码查看反汇编
如果程序是用-g选项编译的(包含调试信息),我们可以使用-S选项将源代码与汇编代码混合显示:
objdump -S hello输出会像这样:
0000000000400526 <main>: #include <stdio.h> int main() { 400526: 55 push %rbp 400527: 48 89 e5 mov %rsp,%rbp printf("Hello, World!\n"); 40052a: bf c4 05 40 00 mov $0x4005c4,%edi 40052f: e8 cc fe ff ff callq 400400 <puts@plt> return 0; 400534: b8 00 00 00 00 mov $0x0,%eax } 400539: 5d pop %rbp 40053a: c3 retq这种显示方式对于理解汇编代码与源代码的对应关系非常有帮助。
7. 动态符号表:查看外部依赖
现代Linux程序通常依赖动态链接库。我们可以用-T选项查看动态符号表:
objdump -T hello输出会列出所有动态符号,包括导入和导出的函数。例如:
DYNAMIC SYMBOL TABLE: 0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 puts 0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 __libc_start_main 0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 __gmon_start__这显示我们的程序依赖于GLIBC的puts、__libc_start_main等函数。
8. 实战分析:从ELF结构理解程序加载过程
通过前面的分析,我们现在可以理解Linux如何加载和执行一个程序:
- 读取ELF头:确定文件类型、架构和入口点
- 加载段:根据程序头表将各个段映射到内存
- 动态链接:解析依赖的共享库并重定位符号
- 执行:跳转到入口点开始执行
我们可以用objdump查看程序头表(与段头表不同):
objdump -p hello输出包含LOAD类型的段,这些是实际会被加载到内存的段:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 R E 8 INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c R 1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x000000000000070c 0x000000000000070c R E 200000 LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000218 0x0000000000000220 RW 200000 DYNAMIC 0x0000000000000e20 0x0000000000600e20 0x0000000000600e20 0x00000000000001d0 0x00000000000001d0 RW 8 NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254 0x0000000000000044 0x0000000000000044 R 4 GNU_EH_FRAME 0x00000000000005c4 0x00000000004005c4 0x00000000004005c4 0x0000000000000034 0x0000000000000034 R 4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10 GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10 0x00000000000001f0 0x00000000000001f0 R 19. 扩展应用:objdump在安全分析中的使用
objdump不仅用于学习,在实际安全分析中也很有价值:
- 漏洞分析:查看存在漏洞的函数汇编代码
- 恶意软件分析:分析可疑二进制文件的行为
- 补丁分析:比较补丁前后二进制文件的变化
例如,我们可以用以下命令查找程序中所有的函数调用:
objdump -d hello | grep callq或者查找所有对特定地址的引用:
objdump -d hello | grep '0x4005c4'10. 常见问题与技巧
在使用objdump时,可能会遇到一些问题和需要掌握的技巧:
问题1:为什么我的反汇编输出看起来不对?
可能原因:
- 文件不是有效的ELF格式
- 使用了错误的架构选项(尝试
-m选项指定架构) - 文件被加壳或混淆
问题2:如何查看特定函数的汇编代码?
可以使用grep过滤:
objdump -d hello | grep -A20 '<main>:'实用技巧:
彩色输出:通过管道将objdump输出传递给coderay或pygmentize可以获得语法高亮
objdump -d hello | coderay -asm交叉引用:使用
-r选项显示重定位信息objdump -dr hello比较差异:比较两个二进制文件的差异
diff <(objdump -d file1) <(objdump -d file2)查看特定地址范围:使用
--start-address和--stop-address选项objdump -d --start-address=0x400526 --stop-address=0x40053b hello
11. 结合其他工具进行更深入分析
虽然objdump功能强大,但结合其他工具能获得更全面的视角:
| 工具 | 用途 |
|---|---|
| readelf | 专门解析ELF文件,提供比objdump更详细的ELF结构信息 |
| nm | 列出符号表,快速查看程序中的函数和全局变量 |
| strings | 提取文件中的可打印字符串,常用于查找硬编码的敏感信息 |
| gdb | 动态调试工具,可以单步执行并观察程序状态 |
| ltrace/strace | 跟踪库函数调用和系统调用,了解程序运行时行为 |
例如,先用readelf查看ELF头:
readelf -h hello然后用nm查看符号表:
nm hello最后用gdb进行动态调试:
gdb ./hello12. 从实践中学:自己编写简单的ELF解析器
要真正理解ELF结构,最好的方法是自己编写一个简单的解析器。以下是一个用Python解析ELF头的基本示例:
import struct def parse_elf_header(filename): with open(filename, 'rb') as f: # 读取ELF魔数(16字节) magic = f.read(16) if magic[:4] != b'\x7fELF': print("不是有效的ELF文件") return # 解析ELF头基本结构(64位) e_ident = magic (e_type, e_machine, e_version, e_entry, e_phoff, e_shoff, e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx) = struct.unpack('HHIIIIIHHHHHH', f.read(36)) print(f"类型: {e_type} (1=可执行, 2=共享库, 3=目标文件)") print(f"架构: {e_machine} (0x3E=x86-64)") print(f"入口点: 0x{e_entry:x}") print(f"程序头表偏移: {e_phoff}") print(f"节头表偏移: {e_shoff}") print(f"程序头数量: {e_phnum}") print(f"节头数量: {e_shnum}") parse_elf_header('hello')这个简单的脚本可以显示ELF文件的基本信息。通过扩展它,你可以逐步实现更完整的ELF解析功能。