1. ELF与PE:同一道门的两把钥匙
接触逆向工程的人迟早都会遇到这个问题:手里拿到一个二进制文件,想读懂它,却发现里面全是一些看起来毫无规律的字节流。不管是做恶意软件分析、漏洞研究、软件破解还是游戏逆向,第一步永远不是急着上调试器,而是先搞清楚这个文件到底是怎么组织的。这里的组织方式,就是文件格式。在这个话题里,有两个格式占了绝对的主流:Unix/Linux世界的ELF,和Windows世界的PE。
如果你是个逆向新手,我的建议是:不要绕开文件格式直接去学汇编和调试器。原因很简单,调试器给你看到的地址、节区、符号、导入表,本质上都是从文件格式里解析出来的。你不理解PE的导入表长什么样,就不知道为什么x64dbg里能看到某个函数来自ntdll.dll;不理解ELF的Section Header,就不知道readelf输出的那一大堆表格是什么意思。文件格式不只是"文件的排版规则",它决定了操作系统怎么加载这个程序、链接器怎么解析符号、逆向工具怎么展示信息。换句话说,它是整个逆向工程的地基。
这篇文章我会把ELF和PE放在一起讲。为什么是"一起"而不是分开写?因为这两个格式虽然在细节上千差万别,但设计思路有很多共通之处——都分头部、节区、符号表、重定位表,都有"程序运行时视图"和"文件存储视图"两套体系。对照着看,很多概念会串起来,比单独啃一份规范轻松得多。内容上我会覆盖它们的核心结构、关键字段、加载逻辑,以及用readelf、objdump、dumpbin这些工具实际分析时的操作思路。最后分享一些我在逆向实战里总结的判断流程和踩坑经验。
2. 解剖ELF:从文件头到节区的完整路径
2.1 ELF Header:只需死磕这几个字段
ELF(Executable and Linkable Format)是Unix/Linux世界的可执行文件格式,也被很多嵌入式系统、Android的so库采用。先用命令readelf -h随便看一个ELF文件,输出通常会是这样:
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1050 Start of program headers: 64 (bytes into file) Start of section headers: 15032 (bytes into file) Number of program headers: 13 Number of section headers: 30Magic的前四个字节7f 45 4c 46就是ELF的签名,其中45 4c 46是"ELF"三个字母的ASCII码。看到这个开头,基本可以确定是个ELF文件。Class字段表示是32位(ELF32)还是64位(ELF64),这直接决定后面所有结构体的大小。Data字段表示字节序,x86/ARM等常见平台都是小端,但MIPS等平台可能用大端。
对逆向来说,我建议至少记住这几个字段:
- Type:告诉你是可执行文件(EXEC)、可重定位文件(REL)、共享目标文件(DYN)还是core dump。注意现代Linux的可执行程序基本都是DYN,因为默认开了PIE。这个区分很重要,你会经常在分析so库或.o文件时看到Type不是EXEC。
- Entry point address:程序入口的虚拟地址。虽然实际调试时更多用
_start符号或者断在main上,但入口点是最底层的起点,脱壳、反调试时经常要回到这里看。 - Start of program headers / section headers:这两个偏移指向两张不同的表,下文细说。
- Machine:目标指令集架构。
读这些字段的土办法是直接看二进制。ELF Header在文件最开头,ELF64的Header固定64字节,前16字节是Magic、Class、Data、Version、OS/ABI这些,第18-19字节是Type,第20-21字节是Machine,第24-31字节是Entry point。用010 Editor或者xxd按偏移量去对,能加深记忆。工具能帮你解析,但理解字节布局才能在做样本分析时不至于被工具误导。
提示:做恶意样本分析时,恶意样本经常故意篡改Magic或头部字段来干扰识别。比如把
7f 45 4c 46改成别的值,有些弱检测就会漏报。这时候用file命令识别不出来,但你自己知道头部结构就能手动校验。
2.2 Program Header与Section Header:一对"运行"与"存储"的双轨
这是ELF最核心也最容易混淆的地方。很多初学者搞不清楚这两个Header的区别,其实一句话就能说明白:
- **Program Header(程序头)**描述的是"运行时视图"。加载器按它把文件映射进内存、设置权限、决定入口跳转到哪。它是操作系统真正会去读的东西。
- **Section Header(节区头)**描述的是"链接/存储视图"。编译器、链接器、调试器和逆向工具按它找到代码、数据、符号表、调试信息。它不是运行时必需的,所以strip过的文件会丢掉很多section信息。
用命令readelf -l看Program Header,会看到类似这样的输出:
Elf file type is DYN (Position-Independent Executable file) Entry point 0x1050 There are 13 program headers, starting 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 0x00000000000005c8 0x00000000000005c8 R 0x1000 LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000 0x0000000000000d31 0x0000000000000d31 R E 0x1000 LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000 0x0000000000000528 0x0000000000000528 R 0x1000 DYNAMIC 0x0000000000002db8 0x0000000000002db8 0x0000000000002db8 0x0000000000000190 0x0000000000000190 RW 0x8 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10重点关注Type为LOAD的段。这是真正要映射进内存的部分,每个LOAD段有四个关键属性:文件偏移、虚拟地址、文件大小、内存大小。其中FileSiz和MemSiz的差值很有讲究——比如数据段里含有.bss(未初始化全局变量),文件里不占用空间,但加载到内存后需要把那段区域清零,所以MemSiz会大于FileSiz。权限方面,通常第一个LOAD段是R,代码段是R E,数据段是RW,按页对齐(Align 0x1000)。
再来readelf -S看Section Header。输出会像一个长长的表格,列出.text、.data、.bss、.symtab、.strtab、.dynamic、.dynsym、.rela.dyn等等。这里我常用的是这几个:
.text:代码段主体,反汇编主要看它。.data/.bss:已初始化和未初始化的全局数据。.plt/.got:动态链接的跳板表和全局偏移表。分析调用外部函数时必看。.dynsym/.symtab:动态符号表和符号表。前者在strip之后通常还在,因为动态链接需要;后者是调试/链接用的,strip之后可能消失。.rodata:只读数据,字符串常量一般在这。.rela.dyn/.rela.plt:重定位表,做so文件hook或脱壳时非常关键。
Section Header的每个条目里,Type(PROGBITS、NOBITS、DYNAMIC、SYMTAB等)、Address、Offset、Size、Flags(W/A/X)是核心。NOBITS类型对应的就是.bss这种在文件里不占空间的节区。
2.3 实战:readelf、objdump、file三板斧
实际分析ELF时,我通常按固定顺序执行三条命令,分别是:
file target_binary readelf -h target_binary readelf -S target_binaryfile先看类型,确认是ELF、架构、是否strip。然后readelf -h看头部基本信息,接着readelf -S看有哪些section,心里有个大概。如果需要看导入导出函数,就执行:
readelf -s target_binary # 查看符号表 readelf -d target_binary # 查看动态段信息,包括依赖的共享库 objdump -d target_binary # 反汇编需要提醒的是,objdump -d默认反汇编.text,对PIE程序它输出的地址是从某个偏移开始的。如果你设置了断点或者用IDA分析,注意地址基准的差异。一个常见需求是找main函数入口。对C程序来说,入口其实不是main而是_start,它调用__libc_start_main,后者最终调用main。用readelf看符号时,可以同时看到_start和main的地址。遇到strip过的文件,符号没了,就得靠特征定位,这个后面专门说。
3. 解剖PE:Windows世界的"可移植"迷思
3.1 从DOS Header到NT Header:每个字节都是历史
PE(Portable Executable)是Windows家族的可执行文件格式。名字里的"Portable"其实是历史遗留——当年微软想设计一个跨硬件平台的可移植格式,后来并没有真正实现,但名字留了下来。PE的布局沿用了COFF的很多概念,同时又在头部保留了一个DOS程序头。
一个PE文件的开头是DOS Header,结构体叫IMAGE_DOS_HEADER,关键字段是:
e_magic:固定0x5A4D,也就是"MZ"两个字母。e_lfanew:偏移0x3C处的一个4字节值,指向真正的PE头。
为什么要留这个东西?因为DOS时代,如果一个程序被拿到DOS下运行,DOS会执行这个DOS头里的"DOS stub"小程序,通常会输出一句"This program cannot be run in DOS mode"。这个设计一直保留到现在。逆向时用十六进制编辑器打开任何PE文件,第一眼看到MZ,然后在0x3C位置读4字节偏移,跳到PE头,已经是基操。
顺着e_lfanew过去,你会看到PE\0\0四个字节,这是PE签名。再往后是IMAGE_FILE_HEADER(也叫COFF Header),里面有几个我经常用到的字段:
Machine:0x8664表示x64,0x14C表示x86。NumberOfSections:节区数量,加壳PE往往节区数量异常。TimeDateStamp:编译时间戳,有时被用来做模糊判断。Characteristics:文件属性,比如0x0002表示EXE,0x0022表示DLL。
接着是IMAGE_OPTIONAL_HEADER。名字叫Optional其实完全不是可选项,x64下固定112字节。这个结构里有几个逆向必看的字段:
AddressOfEntryPoint:程序入口RVA(Relative Virtual Address,相对虚拟地址)。脱壳、找OEP(Original Entry Point)全靠它。ImageBase:首选加载基址。DLL的ImageBase通常是0x180000000(x64)或0x10000000(x86),EXE常见0x140000000(x64)或0x400000(x86)。虽然现代Windows有ASLR,但ImageBase依然是分析地址换算的基准。SectionAlignment/FileAlignment:内存和文件中的对齐粒度。默认分别0x1000和0x200。Subsystem:0x3是控制台程序,0x2是GUI程序,做样本分析时这个字段能快速判断程序类型。DataDirectory:数据目录数组,其中索引0是导出表,1是导入表,2是资源表。逆向的核心入口就在这。
3.2 导入表与导出表:逆向时的两大主战场
PE用一张"数据目录"来索引所有关键结构。对逆向工程师来说,最有价值的三个目录是导入表(Import Table)、导出表(Export Table)和资源表(Resource Table)。
导入表的正式名字叫IMAGE_IMPORT_DESCRIPTOR数组。每个被导入的DLL对应一个条目,指向一个INT(Import Name Table)和一个IAT(Import Address Table)。简单理解:IAT是一张地址表,程序调用外部函数时实际上是通过IAT里的指针跳转的。程序加载时,加载器会修正IAT,让每个条目指向对应函数在内存中的真实地址。这个表在逆向里重要到什么程度呢?
- 你可以在x64dbg的"Symbols"视图里把DLL导入的函数名直接映射出来,快速判断程序调用哪些API。
- 恶意软件经常用动态解析API来规避静态检测,导入表会显得很干净,这时候就得结合运行时行为分析。
- 加壳程序为了保护IAT,会把导入表加密或压缩,运行到OEP时才逐步还原。分析壳的第一个核心工作往往就是"修复IAT"。
导出表则用于DLL对外暴露函数。它的结构包行了三个关键数组:函数地址表(AddressOfFunctions)、函数名表(AddressOfNames)、序号表(AddressOfNameOrdinals)。查一个导出函数的过程是:按名字去函数名表里找,拿到序号,再用序号去地址表里取地址。理解这个过程,你就明白为什么分析DLL导出函数时,工具里能看到名字和序号的映射。
资源表同样重要,但经常被新手忽略。图标、版本信息、字符串、对话框、Manifest都在资源段里。恶意样本的图标有时能提供线索,而版本信息里的"ProductName"、"CompanyName"可以辅助判断样本来源。用010 Editor的模板或者ResourceHacker能直接查看。
3.3 实战:dumpbin和010 Editor的组合用法
Visual Studio自带的dumpbin是个被很多人低估的工具。在"Developer Command Prompt"里运行:
dumpbin /headers target.exe # 看所有头部 dumpbin /imports target.exe # 看导入表 dumpbin /exports target.dll # 看导出表 dumpbin /disasm target.exe # 反汇编 dumpbin /relocations target.exe # 看重定位表/headers会一次列出DOS头、COFF头、Optional头和节区表,信息非常全。/imports的效果最直观——直接把每个DLL和导入函数列出来,做静态分析第一眼就够用了。
如果要用十六进制编辑器啃字节,我个人推荐010 Editor。它自带PE模板,能解析出结构化的字段视图,左边是文件偏移,右边是字段含义。更关键的是,你可以自己写脚本扫描异常。比如批量检测节区名是否可疑、AddressOfEntryPoint是否落在奇怪的位置、节区的RawSize和VirtualSize差异是否过大。
比如一个常见的加壳信号:程序有大量节区,名字还是UPX0、UPX1这种,或者节区名称是一串无意义字符。用010 Editor的模板一眼就能看出来。再比如正常程序的入口一般指向.text节区范围内,如果入口指向的不是第一个代码节区,就要警惕壳或混淆。
注意:dumpbin是微软工具,只能解析PE。分析ELF要用readelf/objdump。别指望一个工具通吃两个世界,这是很多刚接触跨平台逆向的人容易犯的错。
4. ELF与PE的关键差异:从加载器视角看
4.1 地址、基址与重定位的思维方式
把ELF和PE放在一起对比,最核心的差异在于它们怎么处理"地址"这件事。
PE在文件里大量使用一种叫RVA(Relative Virtual Address)的概念。RVA是相对ImageBase的偏移,真实虚拟地址 = ImageBase + RVA。有了ASLR之后,加载器可以给每个模块分配不同的基址,但内部的RVA关系不变,重定位表记录了哪些位置需要按基址差值修正。分析PE时,你时刻要记住"文件偏移、RVA、VA"三者的换算关系。工具显示VA,IDA显示RVA的情况很常见,地址算错一步,断点就下错位置。
ELF则更倾向用"与位置无关"的方式。现代Linux可执行文件基本都是PIE(Position-Independent Executable),编译时用-fPIC,代码里不写绝对地址,全局数据和函数调用通过GOT(Global Offset Table)和PLT(Procedure Linkage Table)间接访问。加载到任何地址都能跑,不需要像PE那样频繁做重定位。等到运行时,动态链接器再填充GOT条目。
两者的直接后果是:调试PE时,你面对的是一个"固定基址+重定位修正"的世界,修改文件时经常要处理重定位表;调试ELF时,你面对的是一个"相对地址+间接跳转"的世界,更多时候要跟着GOT/PLT走。我的习惯是,拿到ELF先看.dynamic段和.rela.dyn,拿到PE先看Import Table和Relocation Directory。
4.2 动态链接机制:PLT/GOT与IAT的对照
PE有IAT,ELF有PLT和GOT,两者回答的是同一个问题:程序怎么调用外部函数?
在ELF里,外部函数调用的典型路径是:.text里的call指令跳到.plt中的某个桩,桩再跳转GOT中对应的地址。如果这个函数是第一次调用,GOT条目指向PLT桩内部的解析代码,动态链接器会去查找函数真实地址并回填GOT;如果已经解析过,GOT条目直接就是函数地址。这个"懒绑定"机制在做脱壳和hook分析时经常被利用——你可以修改GOT条目来劫持函数调用,这比改代码段的字节更隐蔽。
PE的IAT思路更直白:加载器在程序运行前就把所有导入函数地址写进IAT,程序调用时直接jmp [IAT]。没有懒绑定那套流程(理论上延迟导入可以有类似机制,但用得不普遍)。这解释了为什么PE脱壳时修复IAT是个重头戏——一旦IAT被壳加密,程序运行前就必须由壳来重建这张表。
对照理解后你会发现:无论ELF还是PE,所谓"动态链接的关键数据结构",本质上都是一张"外部函数地址表" + 一张"名称解析表"。你在Ghidra或IDA里看反汇编,看到call printf@plt或者call qword ptr [__imp_printf],底层都是这套东西。而且符号命名风格极度相似。
4.3 两种格式的"壳"信号差异
逆向免不了遇见加壳程序。ELF和PE的壳信号有相似处,也有各自的特色。
PE加壳的典型信号:
- 节区数量异常增多或减少,节区名奇怪(UPX0、UPX1、.aspack、.themida等)。
- 节的
VirtualSize远大于RawSize,说明运行时需要动态解压数据。 AddressOfEntryPoint指向的节区不是第一个代码节。- 导入表非常小,甚至只有一个
LoadLibrary和GetProcAddress。
ELF加壳的典型信号:
- 唯一可执行的LOAD段覆盖范围异常大,或者权限异常(比如把数据段标成RWX)。
.text节区不在第一个LOAD段里,入口点指向.init之外的奇怪位置。- 符号表完整度异常,
.symtab缺失但.dynsym被大量修改。 - 文件里有明显的压缩特征,比如UPX的decompress桩代码。
我见过不少新手拿到加了壳的ELF,非要用objdump反汇编,结果看到一堆看不出逻辑的代码,然后怀疑自己汇编不过关。其实第一步就该用readelf -S看看section特征。工具检查顺序走一遍,壳不壳的心里基本有数。
5. 逆向中下一个断点前必做的三件事
5.1 先识别"性别",再谈"性格"
很多人打开一个二进制就急着在IDA里按F5,我建议先花两分钟做一个"三查"流程。
第一查:文件类型。用file命令或Detect It Easy(DIE)识别是ELF还是PE、32位还是64位、是否strip、是否加壳。DIE比file更直观,能识别上百种常见壳和编译器特征。第二查:入口点。用readelf或dumpbin找到AddressOfEntryPoint,在反汇编视图里跳到那里看一眼,通常能看到壳的入口代码或编译器生成的启动代码。第三查:导入表/动态符号。PE看/imports,ELF看.dynsym,确认程序调用了哪些关键API。
这三查做完,你至少知道了"这个程序是什么、壳还是无壳、大致干了哪几类事"。比如一个PE文件导入了CreateRemoteThread和VirtualAllocEx,那大概率涉及进程注入;一个ELF的.dynsym里有ptrace,那可能有反调试。
5.2 定位main的五种方法
看起来很简单的问题——"找到main函数",在实际样本里其实有五条路可以走:
- 符号表直接读:没strip的话,readelf或dumpbin直接告诉你main的地址。
- 入口点顺藤摸瓜:从入口
_start跟踪__libc_start_main的调用,第二个参数就是main指针。在IDA/Ghidra里,入口反汇编很快就能看到这种调用模式。ELF和PE在这一点上几乎一样。 - 字符串交叉引用:程序如果有明显的提示字符串(比如Usage提示),在IDA里对字符串交叉引用,通常就能定位main或相关函数。
- 栈回溯特征:调试器跑起来,在
__libc_start_main或mainCRTStartup下断点,等调用main的瞬间。 - 运行时确定:如果前面都不行,我在调试器里下断点在
WriteFile、printf等输出函数上,往回回溯调用栈找main。
实战中最好用的还是第二和第五种。尤其是分析恶意样本时,符号经常被剥掉,从入口跟踪启动流程几乎是必修课。
5.3 读导入表/符号时的常见误判
经验不足时,很容易在导入表上犯三类错误。
第一类是"看到导入就下结论"。导入表只说明程序可能调用某个API,不说明它一定干了坏事。一个正常软件导入LoadLibrary很常见,但如果把一个DLL Load进来之后再动态GetProcAddress找一堆敏感函数,那才是重点关注的地方。
第二类是"忽略动态解析"。现在很多样本会把API调用藏到动态解析里,导入表一片干净,运行时才通过哈希匹配函数名。这种情况下静态看导入表会得出"它什么都没干"的错误结论。我的习惯是,如果导入表干净得不像话,反而要提高警惕。
第三类是"不区分dynsym和symtab"。分析ELF时,readelf-s默认显示两个符号表,.dynsym是动态链接需要的,.symtab是调试和静态链接用的。恶意样本经常保留.dynsym而删掉.symtab。如果你只看-s输出,可能觉得符号很全,其实很多关键符号已经没了。
6. 一些从实战里总结的判断技巧
6.1 用"异常"反推意图
逆向分析不是背书,更多时候是"找异常"。所谓异常,就是这个文件和正常编译产物不一致的地方。
以ELF为例,PIE程序通常有多个LOAD段,权限设计是R、R E、RW这样分开的。如果哪个程序把RWX权限放在同一个段里,那两种可能:一是加了壳,二是故意给自己留运行时改写代码的能力。再比如PE的节区对齐,正常用SectionAlignment=0x1000,如果某个文件的节区对齐是0x200甚至0x1,通常是为了减小文件体积,常见于某些下载器或配置型木马。
有一个我常用的快速检查项:把程序编译时的默认节区记忆对照表背下来,见到不在这张表里的节区名,就要多留个心。比如UPX壳的UPX0、UPX1,Themida壳的.themida,或者一些自研壳干脆用随机节区名。记不住也没关系,DIE这类工具会帮你识别。
6.2 静态分析能定方向,动态分析才能定结论
文件格式知识更多是服务于静态分析,但它真正的价值在于为你规划动态分析服务。我在拿到样本后的大致流程是:
- 先用文件格式知识确定类型、架构、壳、链接方式。
- 再用readelf/dumpbin读入口点、导入表和节区布局,形成"这个程序大概干了什么"的假设。
- 把这些假设带到调试器里验证——比如在可疑API下断点,观察参数,比对调用来源。
- 最后回到文件,用十六进制编辑器验证内存里看到的修改对应到文件哪个偏移。
第4步常常被忽略。很多人习惯在调试器里改内存、改寄存器,但真正的持久化修改(比如patch文件)需要你把内存地址换算回文件偏移。不懂文件格式,这步就做不了。
6.3 给新手的一条循序渐进路径
如果你刚入逆向,我的建议是不要一上来就啃完整规范。ELF和PE的官方文档都几百页,硬啃很容易放弃。更可行的顺序是:
- 先用基础工具跑一遍,
file、readelf -h/-S、objdump -d、dumpbin的/headers、/imports,把输出和文件二进制对上。 - 然后挑几个简单程序(比如自己写的hello world,分别编成ELF和PE),自己用十六进制编辑器找入口点、节区表、导入表。这个过程会让你真正记住结构。
- 再往后,尝试手工解析一个加壳程序的节区变化,结合DIE和调试器观察壳的入口行为。
- 最后才去细啃规范里那些偏门部分,比如异常处理表、调试目录、延迟导入等。
我在带新人时经常说一句话:文件格式不是背出来的,是"对"出来的。每个工具的输出,你都能在二进制里找到对应位置,这个"对上"的练习做多了,结构自然就长在脑子里了。