☰
逆向工程基础:ELF与PE文件格式逐层解析与实战工具清单
2026/10/6 10:29:17 网站建设 项目流程

干逆向这一行,手边最常打交道的就是 ELF 和 PE 这两种二进制容器。不管你要分析的是一个 Linux 下的可执行程序,还是一个 Windows 上的 exe/dll,文件头里那几十个字节基本决定了后面所有行为的解析方式。我刚入行的时候,被各种工具的输出搞得很懵,后来发现只要把 ELF 和 PE 的结构骨架搞清楚,很多所谓高级玩法——恶意样本分析、崩溃定位、游戏逆向、自研调试工具——都只是在这个骨架上做文章。这篇文章我把这两类文件格式从头到尾拆一遍,也会带上实际用到的命令和踩坑记录,适合刚接触逆向的读者,也适合已经会点工具但想补足底层细节的人。

1. 先把它讲清楚:ELF 与 PE 到底是谁

1.1 一句话理解两种格式

ELF 的全称是 Executable and Linkable Format,中文常叫“可执行与可链接格式”。Linux、UNIX、Android 上的可执行文件、共享库、内核模块、目标文件(.o)基本都是 ELF。

PE 的全称是 Portable Executable,也就是“可移植可执行文件”,Windows 上的 exe、dll、sys、ocx、scr 都属于 PE 家族。从 Visual C++ 生成的程序,到系统自带的 notepad.exe,格式基础都是一套,只是子系统、依赖库、导出函数不同。

这两者在逆向工程里之所以绕不开,不是因为它们有多玄,而是因为所有静态分析都从“文件结构解析”开始。你总得知道代码放在哪个节区、入口地址在哪、导入哪些外部函数、符号表是否被剥掉,才能谈反汇编、谈逻辑还原、谈动态调试。不懂这两类文件的组织方式,就等于拿着地图不看图例,工具能显示一整页输出,你却不知道哪些字段有用。

1.2 想学逆向的你,为什么绕不开这两个词

逆向工程的实际场景里,ELF 和 PE 几乎覆盖了 90% 以上待分析对象。安全分析师拿到一个可疑样本,第一件事就是看它是 PE 还是 ELF,是 32 位还是 64 位,有没有加壳;做漏洞研究的人要理解段权限、栈保护、ASLR,也需要回到文件头里的特性字段;游戏逆向和插件开发,往往要处理导入导出表、重定位表,找到关键函数在内存里的真实地址。

我之前遇到过一种很常见的现场:某个 App 启动后日志里直接报“找不到 xweb/ELF 库”,或者干脆说 native 库加载失败。这时候的分析路径基本就是先确认 so 文件是否真的被打包进应用、架构是否匹配、依赖符号是否齐全,这些判断全都要用到 ELF 的节区视图和动态符号表。所以说,这些知识不是只在破解和恶意分析里有用,日常做兼容性排查、性能剖析、崩溃分析也一样离不开。

下面我从文件结构、工具使用、实际分析流程、问题排查四个方面展开,尽量把每个关键点都落到能直接操作的程度。

2. 文件结构逐层拆解

2.1 入口处的“魔数”与头结构

逆向工程师看文件,第一眼永远是最开始的几个字节,也就是“魔数”。

ELF 的魔数在文件偏移 0 的位置,一共 4 个字节:7f 45 4c 46,对应的 ASCII 字符是\x7F E L F。7F是为了和文本文件区分,后面三个字符是明确标识。用xxd随便看一个 ELF 程序:

$ xxd -l 32 /bin/ls | head -n 2 00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............ 00000010: 0200 3e00 0100 0000 507b a757 0000 0000 ..>.....P{.W....

PE 文件的魔数则是4d 5a,也就是MZ,这是 DOS 头的一部分。当年 DOS 可执行文件的标记被沿用下来,Windows 为了兼容性一直保留。你打开任意 exe 最先看到的永远是MZ两个字母:

$ xxd -l 2 notepad.exe 00000000: 4d5a MZ

PE 真正的主角不在文件开头,而是在 DOS 头偏后面的位置。DOS 头里有个字段叫e_lfanew,在文件偏移0x3C处,它记录的是 NT 头的文件偏移。Windows 加载器会读这个偏移,跳过去找PE\0\0签名。所以看 PE 文件时,我的习惯是直接看一下0x3C处的 4 字节,再跳到对应位置看签名是否有效。

2.2 ELF 的骨架:文件头、程序头、节头

ELF 的核心结构可以分成三层:文件头(ELF Header)、程序头(Program Header)、节头(Section Header)。

文件头在最前面,定义了很多关键字段。初学阶段最该关注的几个:

字段作用
e_ident包含魔数、ELF 位数、字节序、版本、OS/ABI
e_type文件类型:ET_REL(.o)、ET_EXEC(可执行)、ET_DYN(共享库或 PIE)、ET_CORE
e_machine目标架构,比如EM_X86_64、EM_ARM
e_entry程序入口的虚拟地址
e_phoff程序头表在文件中的偏移
e_shoff节头表在文件中的偏移
e_phentsize/e_phnum程序头表项大小和项数
e_shentsize/e_shnum节头表项大小和项数

这里有一个容易混淆的点:节区(section)和段(segment)不是一回事。节区是链接视图,静态分析时看它;段是加载视图,程序要映射进内存时依赖它。用readelf -S看节,用readelf -l看段。比如.text节存放代码,.rodata存放只读常量,.data和.bss放可读写数据;段里则会有PT_LOAD,表示哪些区域需要映射到内存,PT_INTERP表示需要哪个动态链接器,PT_DYNAMIC存放动态链接信息。

在 ELF 里还有一个绕不过的概念:重定位。你在逆向一个新版程序时,经常会看到relocations in generic ELF这类说法,或者用readelf -r查看重定位表。重定位的本质是,链接器编译时不知道某个外部符号最终放在哪个地址,于是留下一个个“待修正位置”,等链接或加载时换算成真实地址。在逆向分析时,重定位表能帮你快速看出哪些符号是外部的、哪些位置需要动态绑定。比如.rela.dyn和.rela.plt,一个对应全局变量引用,一个对应函数引用。

2.3 PE 的骨架:DOS头、NT头、可选头与节区

PE 文件的结构比 ELF 在外观上多一层“兼容外壳”。整体链是这样的:

DOS 头(MZ) -> DOS Stub -> NT 头(PE\0\0签名 + COFF 文件头 + 可选头) -> 节区表 -> 各节区数据

NT 头里的 COFF 文件头有几个字段很重要:Machine标识架构,常见的是0x8664(x64)和0x14c(x86);NumberOfSections表示节区数量;Characteristics是一组标志位,决定文件是 exe 还是 dll、是否可执行、是否支持大地址等。

可选头才是逆向时看得最多的部分:

字段作用
ImageBase加载到内存时的首选基址
AddressOfEntryPoint入口点 RVA,也就是相对基址的偏移
SectionAlignment内存中的节对齐粒度,常见 0x1000
FileAlignment文件中的节对齐粒度,常见 0x200
Subsystem是控制台程序还是图形界面程序
DllCharacteristics是否启用 ASLR、DEP、CFG 等安全机制
NumberOfRvaAndSizes数据目录项的数量
DataDirectory数据目录数组,指向导入表、导出表、重定位表等

PE 里地址经常用 RVA(Relative Virtual Address)表示,也就是“相对 ImageBase 的偏移”。真正运行时的虚拟地址等于ImageBase + RVA。如果有 ASLR,实际加载基址会变化,但 RVA 不变,这也是逆向中“地址先减基址再看”的原因。

PE 常见节区名字和作用:

  • .text:编译后的代码
  • .rdata:只读全局数据和导入导出表的一部分
  • .data:可读写的全局变量
  • .idata:导入表(IAT 和导入描述结构)
  • .edata:导出表
  • .rsrc:资源,比如图标、菜单、版本信息
  • .tls:线程局部存储

导入表尤其关键,因为程序总得调用系统 API。通过导入表能直接知道这个程序做了什么层面的操作:调用了网络、文件、注册表还是进程注入相关函数,很多时候仅凭导入函数列表就能判断样本的大致行为。

2.4 ELF vs PE 对照表

对比维度ELFPE
开头魔数\x7F ELFMZ
主头位置文件起始处DOS 头中0x3C指向的 NT 头
架构标识e_machineCOFF 文件头Machine
入口地址e_entryAddressOfEntryPoint加上ImageBase
静态视角节头表(Section Header)节区头数组
加载视角程序头表(Program Header)可选头 + 数据目录
符号表.symtab/.dynsymCOFF 符号表 / PDB 调试信息
导入机制.dynsym+.rela.plt+.plt/.got导入表(IAT / IDT)
导出机制.dynsym+.gnu.version导出表(.edata)
重定位.rela.dyn/.rela.plt有显式加数.reloc数据目录,记录基址变化后需要修补的位置
常见后缀无固定后缀,so、o、可执行文件exe、dll、sys、ocx

对比看下来会发现,两种格式虽然来源不同,但解决的问题几乎一样:描述代码和数据的位置,描述依赖关系,描述加载执行的偏移。理解了这一点,切换平台分析时就不会觉得是两套完全陌生的东西。

3. 动起手来:常用分析工具集合

3.1 Linux 下从“看看头”到“跑起来”

在 Linux 上分析 ELF,我习惯按这个顺序走:

file target readelf -h target readelf -l target readelf -S target readelf -s target readelf -d target readelf -r target objdump -d target

file是最轻量的判断,能快速告诉我这是 ELF 还是脚本,是 32 位还是 64 位,是不是动态链接,有没有 strip。真正看细节用readelf:

$ 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 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1a70

这里最容易被忽略的是Type: DYN。现代 Linux 发行版默认编译的 PIE 程序在文件头里显示为 DYN,而不是 EXEC。如果误以为是共享库,很正常,但逆向时要注意 PIE 程序的入口是一个地址,加 ASLR 之后实际装载基址会变。分析这类文件时,最好把断点下在符号名上,而不是硬编码地址。

objdump -d用来反汇编,nm和readelf -s看符号表,ldd看动态库依赖,strace看系统调用,gdb做动态调试。还有一个容易漏掉的工具是readelf -d,看动态节的条目,比如NEEDED、RPATH、BIND_NOW,这些对理解库依赖路径和加载行为帮助极大。

3.2 Windows 下几条不同的路子

Windows 上分析 PE,不同阶段用不同工具。

dumpbin是 Visual Studio 自带的命令行工具,功能类似 readelf。常用命令:

dumpbin /headers target.exe dumpbin /imports target.exe dumpbin /exports target.dll dumpbin /relocations target.exe dumpbin /disasm target.exe

如果不想装 VS,也可以用llvm-objdump或者objdump的 Windows 版本。图形化工具方面,CFF Explorer和PE-bear是查看 PE 结构的利器,能直接看到每个数据目录指向哪里、节区偏移和大小、导入导出函数列表。Detect It Easy(DIE)更快,能看到编译器、加壳工具和简单的保护特征。

动态分析时我用x64dbg,搭配Process Monitor和Process Explorer看进程行为和句柄操作。资源类分析可以用Resource Hacker。如果只是临时看一眼导入表,有时候我甚至会直接用 Python:

import pefile pe = pefile.PE("sample.exe", fast_load=False) print(hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)) print(hex(pe.OPTIONAL_HEADER.ImageBase)) print(pe.FILE_HEADER.Machine, pe.FILE_HEADER.NumberOfSections)

pefile这个库非常适合快速脚本化处理批量样本,省去一个个点图形界面的时间。

4. 逆向分析实战:从小样本到打通静态动态

4.1 亲手拆一个 ELF:hello 的完整过程

与其只看定义,不如拿一个最小程序走一遍。先创建一个hello.c:

#include <stdio.h> int main(void) { puts("hello, elf"); return 0; }

用 gcc 编译,关掉 PIE 方便固定地址:

gcc -no-pie -o hello hello.c

先用file看类型,确认是 ELF64。再看入口:

$ readelf -h hello Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x401040

注意这里 Type 是 EXEC,入口是0x401040,但 main 函数不在那里。入口地址指向的是_start,真正的main要由 libc 的启动代码调用。反汇编看一下:

$ objdump -d hello | grep -A 20 '<main>' 0000000000401136 <main>: 401136: 55 push %rbp 401137: 48 89 e5 mov %rsp,%rbp 40113a: 48 8d 3d c3 0e 00 00 lea 0xec3(%rip),%rdi 401141: e8 ba fe ff ff call 401000 <puts@plt> 401146: b8 00 00 00 00 mov $0x0,%eax 40114b: c9 leave 40114c: c3 ret

这里的call 401000 <puts@plt>是动态符号解析的关键点。puts不在这个文件里,而是通过 PLT(过程链接表)跳转到 GOT(全局偏移表),由动态链接器解析真实地址。逆向时如果看到大量@plt调用,基本可以确定程序的边界,哪些逻辑在自己代码里,哪些行为委托给外部库。

接着用readelf -r看重定位:

$ readelf -r hello | head -n 20

输出里会看到类似R_X86_64_PLT32的重定位类型,指向puts。这就是前面提到的 relocations in generic ELF 的表现形态。链接器编译时并不知道 puts 在内存哪里,只能预留位置,运行时再由动态链接器填充。

如果直接把hello拖进 Ghidra 或 Cutter,也能在入口处看到_start、__libc_start_main、main的调用链。建议多看几遍这个链条,它能帮你理解为什么很多程序分析要从main开始,而不是从入口开始。

4.2 再看 PE:用 pefile 快速拿到关键信息

Windows 上的流程类似,区别主要在工具。假设有一个很小的sample.exe,用 Python 一次性把关键信息打出来:

import pefile pe = pefile.PE("sample.exe", fast_load=False) print("Entry:", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)) print("ImageBase:", hex(pe.OPTIONAL_HEADER.ImageBase)) print("Subsystem:", pe.OPTIONAL_HEADER.Subsystem) for sec in pe.sections: name = sec.Name.decode().strip("\x00") print(f"{name}: VA={hex(sec.VirtualAddress)} vsize={hex(sec.Misc_VirtualSize)} " f"raw_size={hex(sec.SizeOfRawData)}") if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"): for entry in pe.DIRECTORY_ENTRY_IMPORT: print("[import]", entry.dll.decode()) for imp in entry.imports[:5]: print(" ", imp.name if imp.name else hex(imp.address))

看输出时先关注三点:

第一,入口点是否和.text节的起始地址一致。不一致不一定是坏事,比如 UPX 加壳后入口点会指向UPX0/UPX1区域,也就是壳代码所在处。这时导入表会非常单薄,通常只有LoadLibrary、GetProcAddress之类,因为真正的导入要等壳解压后才恢复。

第二,节区名称是否常见。如果出现UPX0、UPX1、ASPack、vmp0之类的名字,可以直接推测有壳。当然现在很多壳会伪装节区名,不能只看名字,要看入口点、节区权限、熵值一起判断。

第三,导入函数是否符合程序应有的逻辑。一个小工具却导入了一堆网络和进程操作函数,就值得多看几眼。

4.3 静态到动态的闭环

只看静态很容易被混淆代码误导,所以我的习惯是静态做路线图,动态做确认。Linux 上用strace看系统调用:

strace ./hello

能看到write或puts最终触发的系统调用。Windows 上用Process Monitor看文件、注册表、网络行为。两者配合就能把文件头里的“声明”变成实际运行时的“事实”。

动态调试时还要注意一个细节:如果 PE 开启了 ASLR,WinDbg 或 x64dbg 里看到的模块基址会和文件头里的 ImageBase 不一致。算偏移时要把实际基址减掉,再和静态分析时的 RVA 对上。ELF 的 PIE 程序同理,运行前不要依赖固定地址。

5. 常见问题与排查技巧实录

5.1 文件头对不上,工具不认

最常遇到的一种情况是,分析工具说什么都不认这个文件。第一反应不是怀疑工具,而是去看最开头的字节。

ELF 文件如果开头不是7f 45 4c 46,系统或工具会直接拒认。有时候是文件被传输过程损坏,有时候是被有意改过魔数来规避检测。把开头两个字节改坏之后,file会报告 data 或 unknown,但代码和数据区域其实都在,只是解析入口失效了。修复思路很简单:找到原始字节或推断正确魔数,用十六进制编辑器改回去。前提是确认文件类型,别盲目把可执行文件改成文本。

PE 文件常见的是只保留了一部分,比如从某处截断的文件。判断方法有几个:0x3C处的 e_lfanew 是否合理,跳到 NT 头后签名是否为PE\0\0,节表数量和节区数据是否越界。如果文件本身损坏,工具再强也救不回来,这时候需要找完整的样本。

改一个字节的魔数这件事,我自己试过很多次。把 ELF 的第一个字节从7F改成00,程序立刻无法执行,但用readelf --file-header还能读。这说明解析工具通常也会校验魔数,只是有些工具允许强制解析。遇到这种场景,不要慌,先确认魔数,再看文件长度和头部字段是否一致。

5.2 一运行就报“ELF 装载错误”之类

运行时报加载错误,常见原因就那么几类,按概率排:

第一,架构不匹配。一个 ARM 架构的 so 被拉到 x86 设备上跑,系统会直接报无法加载。这个用readelf -h看 Machine 字段,或者用file一眼就能确认。

第二,依赖缺失。动态链接器提示找不到某个库,或者某个符号未定义。排查用ldd,它会把缺失的库标成 not found。如果库在但符号对不上,多半是版本不匹配,需要对比.dynsym里的符号版本。

第三,ABI 不兼容。这个比较隐蔽,常见于某个 App 找不到某个内置 xweb/ELF 库的场景。明明文件存在,但加载失败,可能是 so 编译时的目标 API 级别太高,也可能是该 so 依赖另一个未打包的 so。在这类问题上,我一般的检查顺序是:先看日志里有没有具体错误段,再用readelf -d查 NEEDED 列表,再单独尝试加载这个 so,最后用dlopen手动拉一次,看返回错误。

PE 程序则更容易碰到缺 DLL 的问题。比如程序依赖某个 VC++ 运行库,但目标机器上没有。运行报错直接提示缺少VCRUNTIME140.dll之类,这就比 ELF 的情况直观很多。但有些程序会做延迟加载,缺依赖不提示,直到调用相关函数才崩溃,这时用dumpbin /imports和Process Monitor同时排查效果最好。

5.3 区分“PE 文件格式”与“Windows PE 预安装环境”

搜索的时候必然有很多结果会带你跑到另一个“PE”去。Windows 的“预安装环境”也缩写成 PE,像微PE工具箱、PE 启动盘、PE 装机这些词,指的都是一个可以独立启动的轻量 Windows 环境,用来重装系统、修复系统、做磁盘操作,和逆向分析里的 PE 文件格式完全不是一回事。

我见过不少刚入门的读者在这上面栽跟头:想找 PE 文件结构资料,结果下了一堆 PE 工具箱;想用 Windows PE 环境装机,又搜到 PE 导入导出表。区分方法其实很简单——如果语境是“文件格式、可执行文件、导入表”,那就是 Portable Executable;如果语境是“启动盘、重装系统、U盘、装机”,那就是预安装环境。

在某些场景下两者会有交集。比如你手上有一块崩溃的 Windows 系统盘,想把里面的样本拖出来分析,这时候用 PE 预安装环境启动电脑,把可疑 exe 复制到移动硬盘,再回到正常系统里用 PE 格式分析工具做检测。这个流程本身没问题,但前提是脑子里先把两个概念分开,别混在一起查,否则很浪费时间。

6. 我在实操中觉得最值得留意的几点

6.1 拿到陌生二进制,我先做三件事

不管目标是 ELF 还是 PE,我拿到陌生文件后基本只做三件事。

第一,看文件头和魔数,确认类型和架构。这一步用file、xxd、dumpbin都行,核心是把大方向定下来:这是个什么格式的文件,多少位,有没有可能是脚本伪装。

第二,看节区或段列表。重点看名称、大小、权限。正常程序.text段大、.rdata次之、UPX0这类段出现就是加壳信号。PE 看节区名和 entropy,ELF 看节区名和程序头里 PT_LOAD 的权限组合,如果出现同时可写可执行段,就要格外小心。

第三,看入口点和导入表/动态符号。入口点落在哪个节区,导入或外部符号揭示了程序依赖哪些高层的系统能力。这一轮下来,我能对样本大概是什么东西有个判断,再决定是静态细看还是直接上动态调试。

6.2 建议用“会读字节”替代“只依赖工具”

工具会失效,图例会误解,公司研发的私有加载器更可能走完全不同的解析路径。所以我特别建议在入门阶段练一个基本功:直接读文件字节,手算关键偏移。

几个最基础的偏移可以背下来:

  • ELF 的e_entry在文件偏移0x18处占 8 字节(ELF64)或0x18占 4 字节(ELF32)。
  • PE 的e_lfanew在文件偏移0x3C处占 4 字节。
  • PE 的 NT 头签名在e_lfanew指向的位置,紧接着的 COFF 文件头从+4开始。
  • PE 可选头的AddressOfEntryPoint通常在 NT 头之后偏移0x10左右。

我自己做过一个最简单的解析练习:用 Python 打开一个 exe,只读0x3C处的 4 字节,跳到 NT 头,打印 Signature,再读 Machine 和 AddressOfEntryPoint。这个练习做一次,比看十篇理论文章都有用,因为你会意识到所谓“加载器”不过是在做同样的字节偏移计算。写不了几十行代码的人,很容易被工具的神秘感唬住,其实底层就是结构体字段的边界问题。

6.3 踩坑后的体悟

真正让我长记性的不是哪个工具多厉害,而是几次因为忽视文件格式边界翻车的经历。一次是给一个样本做动态分析时,把入口点当成了 main,结果一路跟进了 libc 启动逻辑,到了最后还是通过断puts才找到真实逻辑。一次是手算 RVA 时忘了文件对齐和内存对齐不一样,结果从文件里读取的字节和内存里实际内容对不上,白白排查了半天。还有一次是看到一个图标资源特别大的 exe,顺手分析完才意识到自己错过了 .rsrc 里藏的另一段 payload。

这些教训总结起来就一句话:文件格式的每个字段都有边界,读错边界读到的就不是同一份数据。ELF 和 PE 学起来不复杂,但它们定义了一个二进制文件的完整存续周期——从磁盘布局到装载执行,从符号绑定到重定位修正。把这两个容器吃透,逆向分析的地基就扎实了。之后不管面对的是 Ghidra 的伪代码、x64dbg 的反汇编,还是某 App 日志里一句“找不到 ELF 库”的报错,你都知道该从哪里下手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询