二进制基础:ELF/PE文件格式、指令集架构(x86/ARM)、内存布局与调用约定

二进制漏洞挖掘的本质,是在可执行文件的静态结构、CPU指令执行语义、进程内存运行时状态以及函数调用时的栈帧与寄存器约定之间寻找不一致性。无论是Fuzzing中构造的畸形输入,还是逆向分析时对反汇编代码的解读,最终都要落到“文件格式如何映射到内存、指令如何操作数据、调用约定如何传递参数”这三个基本问题上。本章将系统梳理ELF与PE两种主流可执行文件格式的装载视图,对比x86与ARM指令集在漏洞利用中的关键差异,并深入剖析进程内存布局与调用约定对栈溢出、堆溢出利用的影响。

二进制漏洞分析基础框架 可执行文件格式 ELF (Linux/Android) 节头表 + 程序头表 PE (Windows) DOS头 + NT头 + 节表 节区映射 → 虚拟地址 (VA/RVA) 导入表/导出表 → 动态链接解析 重定位表 → ASLR 基址修正 指令集架构 (ISA) x86/x64 (CISC) 变长指令,寄存器少 ARM (RISC) 定长指令,寄存器多 x86: push/pop/ret 栈操作 ARM: LDR/STR/BLX 加载存储 ARM: 条件执行 + IT块 (Thumb-2) 进程内存布局 栈 (高地址向下增长) 堆 (低地址向上增长) BSS / .data / .rodata .text (代码段) + 保留区 调用约定 (Calling Convention) cdecl / stdcall 栈传递参数 (x86) SysV AMD64 / AAPCS 寄存器传参 (x64/ARM) 参数顺序: RDI, RSI, RDX, RCX... 栈帧: 返回地址 + 保存的RBP 漏洞利用: 覆盖返回地址 → ROP

上图展示了二进制漏洞分析中四个核心知识域的关联关系:文件格式决定了程序加载后的内存布局,指令集架构决定了CPU如何执行代码,而调用约定则规定了函数间如何协作。漏洞挖掘者需要在这四个维度中寻找可被输入数据影响的“异常点”——例如栈溢出覆盖返回地址、堆溢出破坏chunk元数据、格式化字符串泄露内存等。下面逐一展开。

2.1 ELF与PE文件格式:从磁盘到内存的映射

2.1.1 核心原理:节区与段区的双重视图

ELF(Executable and Linkable Format)和PE(Portable Executable)都采用“节区(Section)”与“段区(Segment)”的双重视图来描述文件。节区面向链接器,用于符号解析和重定位;段区面向加载器,描述如何将文件内容映射到进程虚拟地址空间。理解这一双重性是逆向分析的第一步——IDA/Ghidra显示的是节区视图,而调试器(gdb/winDbg)显示的是段区映射后的内存视图。

特性ELF (Linux)PE (Windows)
文件头ELF Header (e_ident, e_entry, e_phoff)DOS Header + NT Headers (Signature, File Header, Optional Header)
节区表Section Header Table (.text, .data, .bss, .rodata)Section Table (.text, .rdata, .data, .pdata)
段区表Program Header Table (PT_LOAD, PT_DYNAMIC, PT_INTERP)Section Headers 直接映射 (无独立段表)
入口点e_entry 字段 (常指向 _start)AddressOfEntryPoint (常指向 CRT启动代码)
动态链接.dynamic + .dynsym + .rela.plt.idata (导入表) + .edata (导出表)
ASLR支持ET_DYN (PIE) + 内核随机化DllCharacteristics > DYNAMIC_BASE

2.1.2 判断方法:使用readelf与dumpbin进行结构分析

在Linux下,使用 readelf -h 查看ELF头,readelf -l 查看程序头(段区),readelf -S 查看节区表。在Windows下,使用 dumpbin /headers 查看PE头,dumpbin /imports 查看导入表。关键判断点:

  • 入口点地址:ELF的e_entry通常指向 _start,而PE的AddressOfEntryPoint指向CRT初始化代码。若入口点被修改(如加壳),则可能是恶意行为。
  • 节区权限:检查是否存在“可写可执行”的节区(如 RWX),这是漏洞利用的常见目标。
  • 重定位表:PIE/ASLR启用时,重定位表(.rela.dyn / .reloc)记录了需要修正的地址,Fuzzing时若输入触发重定位错误,可能导致崩溃。
经验提醒:在Fuzzing中,我们通常关注输入数据能否影响“文件头中的节区偏移”或“节区大小”。例如,一个解析ELF的库若未校验 e_shoff + e_shnum * e_shentsize 是否越界,攻击者可通过构造畸形节区表触发越界读写。

2.1.3 案例拆解:一个ELF节区越界读取漏洞

假设某固件解析器读取ELF节区名称时,使用 sh_name 作为字符串表偏移,但未检查该偏移是否超出 .shstrtab 节区大小。攻击者构造一个 sh_name = 0xFFFFFFFF 的节区头,解析器尝试读取 shstrtab + 0xFFFFFFFF,导致段错误或信息泄露。分析步骤:

  1. 使用 readelf -S 确认 .shstrtab 的偏移和大小。
  2. 构造恶意ELF,修改某个节区的 sh_name 为超大值。
  3. 运行解析器,观察崩溃地址是否在 shstrtab 附近。
  4. 利用崩溃时的寄存器值(如RDI指向越界地址)判断可泄露的内存范围。

修复方案:在访问 shstrtab + sh_name 前,校验 sh_name < shstrtab_size。此类漏洞在IOT固件、游戏客户端中常见。

2.1.4 常见误区与总结

  • 误区1:混淆VA与RVA。PE中RVA(Relative Virtual Address)需要加上ImageBase才是VA,但ASLR启用后ImageBase会变化,因此漏洞利用时需动态计算。
  • 误区2:忽略节区对齐。ELF的 p_align 和PE的 SectionAlignment 决定了文件偏移与虚拟地址的换算关系,错误换算会导致定位错误。
  • 总结:文件格式分析是静态逆向的基础,建议在Fuzzing前先对目标格式做一次完整的结构枚举,标记所有长度字段和偏移字段,作为后续变异测试的重点。

2.2 指令集架构:x86与ARM的漏洞利用差异

2.2.1 核心原理:CISC与RISC的设计哲学

x86采用变长指令(1-15字节),寄存器数量有限(8个通用寄存器),支持复杂的寻址模式(如 mov eax, [ebx+ecx*4+0x10])。ARM采用定长指令(ARM模式4字节,Thumb模式2字节),拥有16-31个通用寄存器,几乎所有指令都支持条件执行。这些差异直接影响漏洞利用的payload构造:

  • x86栈溢出:覆盖返回地址后,可直接跳转到栈上的shellcode(若栈可执行),或跳转到 jmp esp 指令地址。
  • ARM栈溢出:由于指令定长且支持条件执行,ROP gadget的寻找方式不同。ARM的 pop {pc} 指令相当于x86的 ret,但需要关注Thumb模式下的地址最低位(bit0=1表示Thumb)。

2.2.2 判断方法:识别指令集与利用原语

在逆向时,通过以下特征快速识别架构:

特征x86/x64ARM (32位)
指令长度变长 (1-15字节)定长 (4字节/2字节Thumb)
通用寄存器EAX/EBX/ECX/EDX/ESI/EDI/EBP/ESPR0-R12, SP, LR, PC
返回指令ret (pop eip)pop {pc} 或 bx lr
函数调用call 指令压入返回地址bl 指令将返回地址存入LR
条件执行仅分支指令有条件几乎所有指令可条件化 (EQ/NE/GT等)

在Fuzzing中,若目标为ARM架构(如Android Native库),需注意:

  • ARM的 ldr pc, [sp, #offset] 可直接从栈加载PC,比x86的 ret 更灵活。
  • Thumb-2指令集混合2字节和4字节指令,反汇编时需正确切换模式,否则会解析错误。

2.2.3 案例拆解:ARM ROP链构造与x86的对比

假设在ARM Linux设备上发现一个栈溢出漏洞,目标是执行 system("/bin/sh")。在x86上,ROP链通常以 pop rdi; ret 开头,然后传入 /bin/sh 地址,最后跳转到 system。在ARM上,需要找到 pop {r0, pc} 这样的gadget,将 /bin/sh 地址放入R0,同时将 system 地址放入PC。关键区别:

  1. 寻找gadget:ARM的gadget通常以 pop {r0-r3, pc} 或 ldr r0, [sp, #offset]; pop {pc} 形式存在。
  2. Thumb模式:若gadget位于Thumb代码段,跳转地址需设置bit0为1,否则触发硬件异常。
  3. 栈对齐:ARM AAPCS要求栈8字节对齐,若ROP链破坏了对齐,可能导致 system 内部崩溃。
风险提示:在ARM上使用 system 时,需确保栈指针SP在调用前指向合法内存,且LR寄存器被正确保存。若ROP链覆盖了LR,可能导致 system 返回时跳转到非法地址。

2.2.4 常见误区与总结

  • 误区1:认为ARM没有ROP。实际上ARM的ROP非常丰富,只是gadget形式不同。
  • 误区2:忽略指令集字节序。ARM默认小端,但ARM64(AArch64)指令固定小端,数据可配置。x86始终小端。
  • 总结:在Fuzzing中,建议同时开启架构相关的sanitizer(如QEMU的 -d in_asm),观察崩溃时的指令流,快速定位是数据问题还是控制流问题。

2.3 内存布局与调用约定:栈帧、堆块与参数传递

2.3.1 核心原理:进程地址空间与函数调用栈

现代操作系统为每个进程提供独立的虚拟地址空间,典型布局从高地址到低地址依次为:栈(向下增长)、共享库映射区、堆(向上增长)、BSS、数据段、代码段。栈帧(Stack Frame)是函数调用的核心结构,包含局部变量、保存的寄存器、返回地址和参数。调用约定定义了参数如何传递、由谁清理栈:

  • x86 (32位) cdecl:参数从右向左压栈,调用者清理栈。返回值在EAX。
  • x64 SysV:前6个整数参数依次放入RDI, RSI, RDX, RCX, R8, R9,多余参数压栈。返回值在RAX。
  • ARM AAPCS:前4个参数放入R0-R3,多余参数压栈。返回值在R0。

2.3.2 判断方法:从崩溃转储还原栈帧

当Fuzzing触发崩溃时,通过调试器查看栈回溯(backtrace)和寄存器值,可以快速判断漏洞类型:

  1. 检查RSP/SP指向的栈内容:若返回地址被非预期数据覆盖,说明存在栈溢出。
  2. 检查RBP/FP链:若RBP被破坏,栈回溯会失败,但可通过 frame 命令手动分析。
  3. 检查堆指针:若崩溃发生在 malloc/free 内部,可能是堆溢出或UAF。
# gdb 示例:分析崩溃点
(gdb) info registers rsp rbp rip
(gdb) x/20gx $rsp   # 查看栈顶内容
(gdb) bt            # 尝试栈回溯
(gdb) x/10i $rip    # 反汇编崩溃指令

2.3.3 案例拆解:一个x64栈溢出导致RIP劫持的完整分析

目标程序是一个网络服务,接收用户输入并拷贝到固定大小的栈缓冲区。Fuzzing发送超长字符串后崩溃,分析过程如下:

步骤操作关键发现
1. 复现崩溃使用 gdb ./server 运行,发送500字节输入程序在 ret 指令处崩溃,RIP=0x41414141
2. 定位偏移使用 pattern create 500 生成特征串偏移量为264字节时覆盖返回地址
3. 检查保护checksec 显示NX启用、ASLR启用、PIE启用无法直接执行栈shellcode,需ROP
4. 寻找gadget使用ROPgadget查找 pop rdi; ret在libc中找到gadget地址
5. 计算libc基址通过泄露GOT表项(如 puts@got)利用 puts(puts@got) 泄露libc地址
6. 构造ROP链调用 system("/bin/sh")成功获取shell

此案例展示了调用约定(SysV)在ROP链构造中的核心作用:pop rdi; ret 将第一个参数放入RDI,然后跳转到 system。若不了解SysV约定,无法正确构造参数传递。

2.3.4 常见误区与总结

  • 误区1:忽略栈对齐。x64 SysV要求调用 call 前栈16字节对齐,若ROP链中 system 内部使用SSE指令,未对齐会导致崩溃。
  • 误区2:混淆堆与栈。堆溢出通常需要利用 unlink 或 fastbin dup 等技巧,与栈溢出的利用方式完全不同。
  • 总结:内存布局和调用约定是漏洞利用的“语法规则”。建议在Fuzzing前,先对目标程序做一次完整的 checksec 和 readelf -l,明确保护机制和内存布局,再决定Fuzzing策略(如是否需绕过ASLR)。
本章要点回顾:① ELF/PE格式决定了程序加载后的内存视图,节区权限和重定位表是漏洞分析的关键;② x86与ARM在指令长度、寄存器数量和调用约定上差异显著,ROP链构造需针对架构定制;③ 内存布局(栈/堆/代码段)和调用约定(参数传递、栈清理)是漏洞利用的底层规则,Fuzzing中发现的崩溃必须结合这些知识才能转化为可利用漏洞。