ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ELF与PE文件格式深度解析:逆向工程的地基

ELF与PE文件格式深度解析:逆向工程的地基 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只需死磕这几个字段ELFExecutable 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: 2s 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的每个条目里TypePROGBITS、NOBITS、DYNAMIC、SYMTAB等、Address、Offset、Size、FlagsW/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. 解剖PEWindows世界的可移植迷思3.1 从DOS Header到NT Header每个字节都是历史PEPortable 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里面有几个我经常用到的字段Machine0x8664表示x640x14C表示x86。NumberOfSections节区数量加壳PE往往节区数量异常。TimeDateStamp编译时间戳有时被用来做模糊判断。Characteristics文件属性比如0x0002表示EXE0x0022表示DLL。接着是IMAGE_OPTIONAL_HEADER。名字叫Optional其实完全不是可选项x64下固定112字节。这个结构里有几个逆向必看的字段AddressOfEntryPoint程序入口RVARelative Virtual Address相对虚拟地址。脱壳、找OEPOriginal Entry Point全靠它。ImageBase首选加载基址。DLL的ImageBase通常是0x180000000x64或0x10000000x86EXE常见0x140000000x64或0x400000x86。虽然现代Windows有ASLR但ImageBase依然是分析地址换算的基准。SectionAlignment/FileAlignment内存和文件中的对齐粒度。默认分别0x1000和0x200。Subsystem0x3是控制台程序0x2是GUI程序做样本分析时这个字段能快速判断程序类型。DataDirectory数据目录数组其中索引0是导出表1是导入表2是资源表。逆向的核心入口就在这。3.2 导入表与导出表逆向时的两大主战场PE用一张数据目录来索引所有关键结构。对逆向工程师来说最有价值的三个目录是导入表Import Table、导出表Export Table和资源表Resource Table。导入表的正式名字叫IMAGE_IMPORT_DESCRIPTOR数组。每个被导入的DLL对应一个条目指向一个INTImport Name Table和一个IATImport 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在文件里大量使用一种叫RVARelative Virtual Address的概念。RVA是相对ImageBase的偏移真实虚拟地址 ImageBase RVA。有了ASLR之后加载器可以给每个模块分配不同的基址但内部的RVA关系不变重定位表记录了哪些位置需要按基址差值修正。分析PE时你时刻要记住文件偏移、RVA、VA三者的换算关系。工具显示VAIDA显示RVA的情况很常见地址算错一步断点就下错位置。ELF则更倾向用与位置无关的方式。现代Linux可执行文件基本都是PIEPosition-Independent Executable编译时用-fPIC代码里不写绝对地址全局数据和函数调用通过GOTGlobal Offset Table和PLTProcedure Linkage Table间接访问。加载到任何地址都能跑不需要像PE那样频繁做重定位。等到运行时动态链接器再填充GOT条目。两者的直接后果是调试PE时你面对的是一个固定基址重定位修正的世界修改文件时经常要处理重定位表调试ELF时你面对的是一个相对地址间接跳转的世界更多时候要跟着GOT/PLT走。我的习惯是拿到ELF先看.dynamic段和.rela.dyn拿到PE先看Import Table和Relocation Directory。4.2 动态链接机制PLT/GOT与IAT的对照PE有IATELF有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 printfplt或者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 EasyDIE识别是ELF还是PE、32位还是64位、是否strip、是否加壳。DIE比file更直观能识别上百种常见壳和编译器特征。第二查入口点。用readelf或dumpbin找到AddressOfEntryPoint在反汇编视图里跳到那里看一眼通常能看到壳的入口代码或编译器生成的启动代码。第三查导入表/动态符号。PE看/importsELF看.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的节区对齐正常用SectionAlignment0x1000如果某个文件的节区对齐是0x200甚至0x1通常是为了减小文件体积常见于某些下载器或配置型木马。有一个我常用的快速检查项把程序编译时的默认节区记忆对照表背下来见到不在这张表里的节区名就要多留个心。比如UPX壳的UPX0、UPX1Themida壳的.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和调试器观察壳的入口行为。最后才去细啃规范里那些偏门部分比如异常处理表、调试目录、延迟导入等。我在带新人时经常说一句话文件格式不是背出来的是对出来的。每个工具的输出你都能在二进制里找到对应位置这个对上的练习做多了结构自然就长在脑子里了。
返回列表