5、Mach-O文件结构解析:Mach-O头、加载命令、段与节、动态库加载机制

说到逆向工程,Mach-O格式是绕不开的坎。无论是iOS应用还是macOS程序,底层都是这个格式。我最早接触它时,是在分析一个越狱检测逻辑——那时候我连Mach-O头在哪看都不知道,硬是啃了好几天。今天我把这些经验掰开揉碎,跟你聊聊。

Mach-O文件长什么样?

一个Mach-O文件,说白了就三部分:

  • 头部(Header)——告诉系统这是个啥文件
  • 加载命令(Load Commands)——描述内存布局和依赖
  • 数据区(Data)——真正的代码和数据,按段(Segment)和节(Section)组织

你可以把它想象成一本书:封面是头部,目录是加载命令,正文就是数据区。嗯,这个比喻我用了好多年,挺贴切的。

核心要点:Mach-O是苹果生态的二进制格式,理解它是逆向iOS/macOS应用的第一步。

Mach-O 文件结构总览 Mach-O Header(头部) magic | cputype | cpusubtype | filetype | ncmds | sizeofcmds | flags Load Commands(加载命令) LC_SEGMENT_64 | LC_LOAD_DYLIB | LC_MAIN | LC_CODE_SIGNATURE ... 描述段布局、依赖库、入口点、签名信息等 Data(数据区) __TEXT(代码段) __DATA(数据段) __LINKEDIT(链接信息)

Mach-O头部——文件的身份证

头部在最前面,固定大小。对于64位程序,是mach_header_64结构体。我习惯用otool -h或者xxd直接看原始字节。

// 64位Mach-O头部结构
struct mach_header_64 {
    uint32_t    magic;        // 魔数,固定为0xFEEDFACF
    cpu_type_t  cputype;      // CPU类型,比如ARM64
    cpu_subtype_t cpusubtype; // CPU子类型
    uint32_t    filetype;     // 文件类型:MH_EXECUTE(可执行)、MH_DYLIB(动态库)
    uint32_t    ncmds;        // 加载命令数量
    uint32_t    sizeofcmds;   // 所有加载命令的总大小
    uint32_t    flags;        // 标志位
    uint32_t    reserved;     // 保留字段
};

你看,就这么几个字段。但每个字段都有讲究。比如filetype,我遇到过有人把动态库伪装成可执行文件,结果一运行就崩——因为系统加载逻辑完全不同。

小技巧:otool -f可以快速查看文件类型和架构。我每次拿到一个陌生二进制,第一件事就是跑这个命令。

加载命令——系统的操作手册

头部后面紧跟着加载命令。每个命令以load_command开头,后面跟具体数据。

struct load_command {
    uint32_t cmd;      // 命令类型,比如LC_SEGMENT_64
    uint32_t cmdsize;  // 命令总大小(包含自身)
};

常见的加载命令有哪些?我给你列个表:

命令 作用 我遇到的坑
LC_SEGMENT_64 定义段(Segment)在内存中的布局 段偏移算错,导致内存映射失败
LC_LOAD_DYLIB 声明依赖的动态库 动态库路径写死,换环境就崩
LC_MAIN 指定程序入口地址 入口被篡改,程序启动异常
LC_CODE_SIGNATURE 代码签名信息 签名校验失败,应用闪退
LC_SYMTAB 符号表位置 符号表被剥离,逆向难度加大
LC_DYLD_INFO_ONLY 动态链接器所需信息 这个命令缺失,动态库加载会出问题

我曾经调试过一个App,启动就闪退。用otool -l一看,发现LC_MAIN指向的地址根本不在代码段范围内——明显是被篡改过。这种问题,光看崩溃日志根本看不出来。

段与节——代码和数据的老家

数据区按段(Segment)组织,每个段又分成若干节(Section)。64位下最常用的是LC_SEGMENT_64命令。

struct segment_command_64 {
    uint32_t    cmd;          // LC_SEGMENT_64
    uint32_t    cmdsize;      // 命令大小
    char        segname[16];  // 段名,如__TEXT
    uint64_t    vmaddr;       // 虚拟内存起始地址
    uint64_t    vmsize;       // 虚拟内存大小
    uint64_t    fileoff;      // 文件偏移
    uint64_t    filesize;     // 文件大小
    vm_prot_t   maxprot;      // 最大内存保护
    vm_prot_t   initprot;     // 初始内存保护
    uint32_t    nsects;       // 节的数量
    uint32_t    flags;        // 标志位
};

三个核心段:

  • __TEXT——代码段。只读可执行。包含__text(机器码)、__cstring(C字符串)、__const(常量)。
  • __DATA——数据段。读写。包含__data(全局变量)、__bss(未初始化数据)、__objc_*(OC运行时数据)。
  • __LINKEDIT——链接信息。只读。包含符号表、字符串表、重定位信息。

注意:__LINKEDIT段在文件中的偏移和大小,跟虚拟内存中的地址和大小可能不一样。我见过有人直接按文件偏移去读符号表,结果读出来全是乱码——因为没考虑对齐和填充。

节的头部结构:

struct section_64 {
    char        sectname[16]; // 节名,如__text
    char        segname[16];  // 所属段名,如__TEXT
    uint64_t    addr;         // 节在内存中的起始地址
    uint64_t    size;         // 节的大小
    uint32_t    offset;       // 节在文件中的偏移
    uint32_t    align;        // 字节对齐
    uint32_t    reloff;       // 重定位信息偏移
    uint32_t    nreloc;       // 重定位条目数
    uint32_t    flags;        // 标志位
    uint32_t    reserved1;    // 保留
    uint32_t    reserved2;    // 保留
};

举个例子,你想找OC的类信息,就去__DATA.__objc_classlist节。想找方法实现,去__TEXT.__text节。我经常用otool -s __TEXT __text来dump代码段,配合Hopper或者IDA做静态分析。

动态库加载机制——App怎么找到它的依赖

这部分是Mach-O的精髓。一个可执行文件通常依赖多个动态库(.dylib)。系统怎么找到它们?

流程是这样的:

  1. 内核加载Mach-O头部和加载命令
  2. 根据LC_LOAD_DYLIB命令,找到所有依赖的动态库路径
  3. 动态链接器(dyld)接管,按顺序加载每个动态库
  4. 每个动态库自己也有LC_LOAD_DYLIB,递归加载
  5. 所有库加载完后,进行符号绑定(lazy或non-lazy)
  6. 最后跳转到LC_MAIN指定的入口

你想想看,这个过程其实挺复杂的。我遇到过一个问题:App在iOS 14上跑得好好的,升级到iOS 15就崩了。查了半天,发现是某个动态库的路径变了,但LC_LOAD_DYLIB里还是旧路径。这就是典型的动态库加载失败。

关键命令:LC_LOAD_DYLIB的结构包含库路径、兼容版本和当前版本。用otool -L可以列出所有依赖库。

struct dylib_command {
    uint32_t    cmd;        // LC_LOAD_DYLIB
    uint32_t    cmdsize;    // 命令大小
    struct dylib dylib;     // 库信息
};

struct dylib {
    uint32_t    name_offset; // 库路径在命令中的偏移
    uint32_t    timestamp;   // 时间戳
    uint32_t    current_version; // 当前版本
    uint32_t    compatibility_version; // 兼容版本
};

动态库加载还有一个重要概念——懒加载(Lazy Binding)非懒加载(Non-Lazy Binding)。说白了,懒加载是函数第一次调用时才去解析地址,非懒加载是启动时就全部解析。我建议你在逆向时,重点关注__DATA.__la_symbol_ptr__DATA.__nl_symbol_ptr这两个节——它们存的就是函数指针,改掉它们就能hook函数调用。

实战技巧:想修改某个系统函数的调用?找到__DATA.__la_symbol_ptr里对应的指针,改成你的函数地址。我经常用这个手法做函数劫持,比fishhook还直接。

动手实践——用工具看Mach-O

光说不练假把式。我建议你拿一个iOS App的二进制文件,试试这些命令:

# 查看头部
otool -h 你的App

# 查看所有加载命令
otool -l 你的App

# 查看依赖的动态库
otool -L 你的App

# 查看指定段的内容
otool -s __TEXT __text 你的App

# 查看符号表
nm 你的App

# 查看Objective-C类信息
otool -o 你的App

我个人习惯先用otool -l把整个加载命令dump出来,然后重点关注LC_SEGMENT_64和LC_LOAD_DYLIB。这样能快速了解这个二进制文件的整体结构。

嗯,Mach-O的内容就聊到这儿。记住一句话:理解Mach-O,就是理解苹果生态的底层语言。不管是做逆向、安全研究,还是性能优化,这些知识都是基本功。

总结:Mach-O头告诉系统文件类型和架构;加载命令描述内存布局和依赖;段与节组织代码和数据;动态库加载机制让App能复用系统库。把这四块吃透,你就能在二进制层面看懂一个App的骨架。


公众号:蓝海资料掘金营,微信deep3321