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头部——文件的身份证
头部在最前面,固定大小。对于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)。系统怎么找到它们?
流程是这样的:
- 内核加载Mach-O头部和加载命令
- 根据LC_LOAD_DYLIB命令,找到所有依赖的动态库路径
- 动态链接器(dyld)接管,按顺序加载每个动态库
- 每个动态库自己也有LC_LOAD_DYLIB,递归加载
- 所有库加载完后,进行符号绑定(lazy或non-lazy)
- 最后跳转到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