干逆向这一行,手边最常打交道的就是 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 MZPE 真正的主角不在文件开头,而是在 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 对照表
| 对比维度 | ELF | PE |
|---|---|---|
| 开头魔数 | \x7F ELF | MZ |
| 主头位置 | 文件起始处 | DOS 头中0x3C指向的 NT 头 |
| 架构标识 | e_machine | COFF 文件头Machine |
| 入口地址 | e_entry | AddressOfEntryPoint加上ImageBase |
| 静态视角 | 节头表(Section Header) | 节区头数组 |
| 加载视角 | 程序头表(Program Header) | 可选头 + 数据目录 |
| 符号表 | .symtab/.dynsym | COFF 符号表 / 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 targetfile是最轻量的判断,能快速告诉我这是 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 库”的报错,你都知道该从哪里下手。