二进制分析基础:ELF/PE/BIN文件格式深度解析

做固件逆向这些年,我有个很深的体会:不懂文件格式,逆向就是盲人摸象。你想想看,连目标程序怎么组织的都不知道,怎么下手分析?今天我们就来啃下这块硬骨头。

二进制文件格式,说白了就是操作系统和可执行程序之间的"契约"。ELF是Linux的,PE是Windows的,BIN是裸机固件的。它们各有各的脾气,但核心逻辑是相通的。

一、ELF文件格式:Linux世界的基石

ELF(Executable and Linkable Format)是我接触最多的格式。当年我第一次逆向一个嵌入式Linux设备时,就是靠读ELF头找到入口点的。

ELF文件结构其实不复杂,就三部分:

  • 文件头(ELF Header):描述文件基本信息
  • 程序头(Program Header):告诉系统怎么加载
  • 节区头(Section Header):描述各个节区的信息

来看一个典型的ELF头结构:

typedef struct {
    unsigned char e_ident[16];  // 魔数、类别、字节序等
    uint16_t      e_type;       // 文件类型(可重定位/可执行/共享库)
    uint16_t      e_machine;    // 架构(x86/ARM/MIPS等)
    uint32_t      e_version;    // 版本
    uint64_t      e_entry;      // 入口地址
    uint64_t      e_phoff;      // 程序头偏移
    uint64_t      e_shoff;      // 节区头偏移
    uint32_t      e_flags;      // 标志位
    uint16_t      e_ehsize;     // ELF头大小
    uint16_t      e_phentsize;  // 程序头表项大小
    uint16_t      e_phnum;      // 程序头表项数量
    uint16_t      e_shentsize;  // 节区头表项大小
    uint16_t      e_shnum;      // 节区头表项数量
    uint16_t      e_shstrndx;   // 节区名称字符串表索引
} Elf64_Ehdr;

关键点:e_ident[0]~e_ident[3]必须是0x7f 'E' 'L' 'F',这是识别ELF文件的魔数。我见过有人把BIN文件硬改成ELF头,结果加载器直接报错——魔数不对,一切免谈。

二、PE文件格式:Windows的"老江湖"

PE(Portable Executable)格式,说白了就是Windows版的ELF。但它的结构更复杂一些,因为要兼容DOS时代的遗产。

PE文件的结构层次:

  1. DOS头:以"MZ"开头,包含DOS存根
  2. PE头:以"PE\0\0"签名开始
  3. 节区表:描述各个节区的位置和属性
  4. 节区数据:实际的代码和数据

我记得有一次分析一个恶意软件样本,它把真正的PE头藏在文件中间,前面全是垃圾数据。当时我手动解析DOS头里的e_lfanew字段,才找到真正的PE签名位置。

// PE可选头中的关键字段
typedef struct {
    uint16_t magic;           // 0x10b (PE32) 或 0x20b (PE32+)
    uint8_t  major_linker_version;
    uint8_t  minor_linker_version;
    uint32_t size_of_code;
    uint32_t size_of_initialized_data;
    uint32_t size_of_uninitialized_data;
    uint32_t address_of_entry_point;  // 入口点RVA
    uint32_t base_of_code;
    uint64_t image_base;      // 首选加载基址
    uint32_t section_alignment;
    uint32_t file_alignment;
    // ... 还有很多字段
} IMAGE_OPTIONAL_HEADER;

实战技巧:解析PE文件时,我习惯先检查e_magic是否为0x5A4D("MZ"),再检查e_lfanew指向的位置是否有"PE\0\0"签名。两步验证,基本不会出错。

三、BIN文件格式:裸机世界的"原始人"

BIN文件,说白了就是内存的"快照"。没有头,没有节区表,就是纯粹的二进制数据。嵌入式固件大多用这种格式。

BIN文件的特点:

  • 无结构:没有文件头,直接就是代码和数据
  • 固定加载地址:通常烧录到特定地址(如0x08000000)
  • 大小即内容:文件大小就是固件大小

我曾经遇到一个坑:某款MCU的BIN文件里混入了0xFF填充字节,我以为是正常数据,结果反汇编出来全是非法指令。后来才发现,那是Flash的擦除状态,不是有效代码。

注意:BIN文件没有入口点信息,你需要从芯片手册或链接脚本中获取入口地址。我一般先用binwalk扫描,看看有没有已知的固件特征。

四、节区与段:程序的组织方式

节区(Section)和段(Segment)是两个容易混淆的概念。我简单解释一下:

  • 节区:链接视角,用于组织代码和数据
  • :加载视角,用于描述内存映射

常见的节区类型:

节区名 内容 属性
.text 可执行代码 读+执行
.data 已初始化全局变量 读+写
.bss 未初始化全局变量 读+写(不占文件空间)
.rodata 只读数据(字符串常量等)
.plt 过程链接表(动态链接用) 读+执行

嗯,这里要注意:.bss节区在文件中不占空间,但加载时会分配内存。我见过有人分析固件时,发现文件大小和加载后内存大小对不上,其实就是忽略了.bss。

五、符号表与重定位表:链接的"桥梁"

符号表(Symbol Table)记录了程序中所有的符号信息——函数名、全局变量名等。重定位表(Relocation Table)则记录了需要"修补"的地址。

为什么需要重定位?举个例子:

// 源代码
extern void foo(void);
void bar() {
    foo();  // 编译时不知道foo的地址
}

编译时,编译器不知道foo函数的地址,只能生成一个占位符。链接器通过重定位表,找到这些占位符,填入实际的地址。

符号表的结构:

typedef struct {
    uint32_t st_name;   // 符号名在字符串表中的索引
    uint8_t  st_info;   // 符号类型和绑定信息
    uint8_t  st_other;  // 可见性
    uint16_t st_shndx;  // 所在节区索引
    uint64_t st_value;  // 符号值(地址或偏移)
    uint64_t st_size;   // 符号大小
} Elf64_Sym;

实战经验:我在逆向一个IoT设备固件时,发现符号表被strip掉了。但通过分析重定位表,我仍然能推断出哪些地址是函数调用点,进而还原出控制流图。重定位表是逆向的"金矿"。

六、文件头解析实战:手撕ELF头

光说不练假把式。我们来写一个简单的ELF头解析器:

#include <stdio.h>
#include <stdint.h>
#include <elf.h>

void parse_elf_header(const char *filename) {
    FILE *fp = fopen(filename, "rb");
    if (!fp) {
        perror("fopen");
        return;
    }

    // 读取ELF头
    Elf64_Ehdr ehdr;
    fread(&ehdr, sizeof(ehdr), 1, fp);

    // 验证魔数
    if (ehdr.e_ident[0] != 0x7f || 
        ehdr.e_ident[1] != 'E' ||
        ehdr.e_ident[2] != 'L' ||
        ehdr.e_ident[3] != 'F') {
        printf("不是有效的ELF文件\n");
        fclose(fp);
        return;
    }

    printf("文件类型: %d\n", ehdr.e_type);
    printf("架构: %d\n", ehdr.e_machine);
    printf("入口地址: 0x%lx\n", ehdr.e_entry);
    printf("程序头数量: %d\n", ehdr.e_phnum);
    printf("节区头数量: %d\n", ehdr.e_shnum);

    // 读取节区头表
    fseek(fp, ehdr.e_shoff, SEEK_SET);
    for (int i = 0; i < ehdr.e_shnum; i++) {
        Elf64_Shdr shdr;
        fread(&shdr, sizeof(shdr), 1, fp);
        printf("节区%d: 偏移=0x%lx, 大小=0x%lx\n", 
               i, shdr.sh_offset, shdr.sh_size);
    }

    fclose(fp);
}

这段代码虽然简单,但涵盖了ELF解析的核心逻辑。我建议你动手跑一遍,看看实际文件的结构。

调试技巧:用readelf -a命令可以查看完整的ELF信息。我经常用它来验证自己的解析结果。如果readelf的输出和你的解析一致,那基本就对了。

七、知识体系总览

下面这张图总结了本章的核心内容:

二进制分析知识体系 ELF文件格式 PE文件格式 BIN文件格式 文件头 → 程序头 → 节区头 .text / .data / .bss / .rodata DOS头 → PE头 → 节区表 .text / .rdata / .data / .rsrc 无头结构,纯二进制数据 需从芯片手册获取入口地址 核心概念:节区 vs 段 节区(Section) 链接视角,组织代码和数据 段(Segment) 加载视角,描述内存映射 符号表 & 重定位表 符号表:记录函数/变量名 逆向分析的"地图" 重定位表:记录地址修补点 还原控制流的关键

这张图把本章的知识点串起来了。你可以看到,ELF、PE、BIN三种格式虽然不同,但都围绕着"如何组织代码和数据"这个核心问题。节区与段是理解程序结构的钥匙,符号表与重定位表则是逆向分析的利器。

好了,二进制分析基础就讲到这里。记住:文件格式是逆向的"语法",不懂语法,就看不懂程序。多动手解析几个文件,你会有更深的体会。

专注资料整理