1. 为什么说LC_SEGMENT是Mach-O文件里的骨架命令如果你已经看过前面几篇Mach-O相关的拆解应该对Load Commands这个夹在Mach-O Header和文件数据之间的区域不陌生。Load Commands本质上是给内核和dyld下的一组指令告诉它们这个文件应该怎么被加载、怎么被映射进内存、运行时需要依赖哪些库。在这么多命令里LC_SEGMENT64位下是LC_SEGMENT_64是当我拿到一个陌生二进制文件时第一个会去看的东西——因为几乎所有的Mach-O文件都包含至少一个segment命令它直接决定了整个进程地址空间的布局。打个比方Mach-O文件就像一套房Load Commands是装修图纸LC_SEGMENT是墙上那些承重结构的分界线。承重墙怎么划分房间就怎么隔segment怎么划分进程的内存空间就怎么排。__TEXT段放代码和只读常量__DATA段放可读写的全局变量__LINKEDIT段放符号表和字符串表……这些在进程启动时都会被dyld逐一映射到虚拟内存的对应地址。为什么说这段信息重要因为做逆向分析、崩溃日志符号化、二进制加固、代码签名校验几乎每一步都需要知道某个数据从文件哪个偏移被加载到内存哪个地址segment命令就是这条映射关系的唯一坐标来源。分析Mach-O时我一般会先把Load Commands从头到尾扫一遍数一下有多少个LC_SEGMENT_64再对照每个segment的命名和权限位。一个干净的可执行文件通常包含__PAGEZERO、__TEXT、__DATA、__LINKEDIT这几个标准段如果你看到一个二进制里多了奇怪的段名比如__LLVM、__RODATA、__AUTH那说明编译链接时可能启用了某些特殊的工具链特性比如地址无关代码、指针认证、链接器优化等。这些都是后面深入分析时需要留意的线索。这篇文章我打算从LC_SEGMENT的结构定义讲起顺着字段拆到segment和section的关系然后直接上代码实现一个最小可用的解析器最后聊几个我在实际调试中反复踩过的坑。无论你是想做Mach-O解析工具、搞逆向、还是单纯想搞懂dyld加载过程这篇都适合当一份可以对照着查的参考。2. 结构体逐字段拆解32位与64位到底差在哪里2.1 LC_SEGMENT和LC_SEGMENT_64是两条命令不是大小写区别很多人第一次接触Mach-O时容易把LC_SEGMENT和LC_SEGMENT_64当成同一条命令的64位叫法这其实不准确。它们是两条独立的load commandcmd的枚举值不同LC_SEGMENT是0x1LC_SEGMENT_64是0x19。Mach-O文件会根据自身所属架构决定使用哪一条32位架构i386、armv7用LC_SEGMENT64位架构x86_64、arm64用LC_SEGMENT_64。为什么不能统一成一条命令因为32位和64位下的地址偏移量范围完全不同。segment_command结构体里的vmaddr和fileoff在32位下是4字节只能表示到4GB的地址空间而64位进程的虚拟地址和文件偏移量都远超过这个范围必须用8字节的无符号整数承载。Apple为了兼容旧格式索性把结构体一分为二加载器根据magic number判断架构后再按对应的结构体去解析。2.2 segment_command_64字段逐项说明先贴出64位版本的结构体定义这是从Xcode自带的loader.h里摘出来的struct segment_command_64 { uint32_t cmd; // 命令类型固定为LC_SEGMENT_64 (0x19) uint32_t cmdsize; // 命令总字节数包含后面跟着的所有section char segname[16]; // 段名最长16字节可能不带结尾\0 uint64_t vmaddr; // 段的起始虚拟内存地址 uint64_t vmsize; // 段占用的虚拟内存大小 uint64_t fileoff; // 段数据在文件中的起始偏移 uint64_t filesize; // 段数据在文件中的字节数 vm_prot_t maxprot; // 段页面允许的最大内存保护读/写/执行 vm_prot_t initprot; // 段页面加载时的初始内存保护 uint32_t nsects; // 该段包含的section数量 uint32_t flags; // 段属性标志位 };逐个看每个字段的实际作用cmd和cmdsize是所有load command的公共头。动态加载器拿到cmdsize后会以它为步长跳过当前命令接着解析下一跳命令。cmdsize的数值是segment_command_64结构体本身大小 所有section_header_64的大小之和因为它后面紧跟着的就是这个段所管辖的section列表。segname是段名例如__TEXT、__DATA、__LINKEDIT。注意这个字符数组没有保证以\0结尾如果段名恰好是16个字符虽然目前标准段名都不满16直接当C字符串打印就会越界。写解析工具时建议手动限宽拷贝。vmaddr和vmsize描述的是段在虚拟内存中的位置和大小。进程启动后dyld会以页为单位把段的数据从文件映射到以vmaddr为起始地址的虚拟内存区域。注意vmsize往往大于filesize因为段所占的内存页是向上取整对齐的比如__TEXT段文件里有4097字节但映射时至少要占用两页即8192字节多出来的那段空间是清零填充的。fileoff和filesize描述的是段数据在文件里的位置和大小。这里有一个关键点一个段可以只有虚拟内存大小而没有文件大小吗可以典型例子是__DATA段里的__bss节。__bss存放未初始化的全局变量程序加载时这块空间必须存在但它不需要从文件里搬运内容所以文件里根本没为它留空间filesize因此小于vmsize。后面3.3节会细说。maxprot和initprot是两个内存保护标志取值为VM_PROT_READ1、VM_PROT_WRITE2、VM_PROT_EXECUTE4的组合。initprot是进程刚启动时该段内存的权限maxprot是允许修改到的最大权限。正常情况下两者是一致的但存在一些特殊情况比如某些受保护的内核二进制里initprot的写权限被去掉只在运行时通过特定机制临时加上。检查一个段是否可写可执行直接看这两个字段最直观。nsects表示这个段后面跟着多少个section header。需要特别留意nsects只是直接归属于该段的section数量。__LINKEDIT段通常nsects为0它只存原始数据不参与section划分。flags是段级属性常用的有SG_HIGHVM0x1表示该段位于高地址、SG_FVMLIB0x2段属于固定虚拟内存库、SG_NORELOC0x4段的地址不可重定位等日常分析时关注得不算多但做加载器时需要处理。2.3 32位segment_command的结构差异32位版本的区别主要在字段宽度struct segment_command { uint32_t cmd; uint32_t cmdsize; char segname[16]; uint32_t vmaddr; uint32_t vmsize; uint32_t fileoff; uint32_t filesize; vm_prot_t maxprot; vm_prot_t initprot; uint32_t nsects; uint32_t flags; };除了地址相关的4个字段从uint64_t降为uint32_t其余字段完全一致。对于32位二进制__TEXT段的vmaddr通常是0x1000加上某个固定基址而64位二进制在x86_64上通常从0x1000000004GB开始。这个差异在解析时不需要特殊处理按文件头的magic判断位数后选择正确的结构体即可。3. Segment与Section的主从关系内存布局的真相3.1 Section是Segment的子单元如果你用过otool -l查看一个Mach-O文件的结构会看到输出先是各个segment的信息然后缩进一段打印它下面的section信息这就是segment和section的层级关系。Mac系统上segment和section的命名习惯是双下划线前缀段名和节名组合起来就像__TEXT,__text这样的路径。% otool -l /bin/ls Load command 1 cmd LC_SEGMENT_64 cmdsize 72 segname __TEXT vmaddr 0x100000000 vmsize 0x10000 fileoff 0 filesize 0x10000 maxprot 0x7 initprot 0x5 nsects 6 flags 0x0 Section sectname __text segname __TEXT addr 0x1000034a0 size 0x99d4 offset 0x34a0 align 2^4 (16) ...在结构上LC_SEGMENT_64命令的cmdsize已经包含了紧跟其后的所有section header所占的空间。也就是说加载器从Load Commands起始地址开始解析完segment_command_64这个结构体后紧接着读到的那一段连续内存就是nsects个section_header_64结构体。这种命令后面挂子结构的设计让所有segments和sections可以通过一次线性遍历完整读取。3.2 section_header_64长什么样从struct section_64可以看出每个section描述的信息粒度更细struct section_64 { char sectname[16]; // 节名 char segname[16]; // 所属段名 uint64_t addr; // 节在内存中的起始地址是segment vmaddr加上节在段内的相对偏移 uint64_t size; // 节的大小 uint32_t offset; // 节数据在文件中的偏移 uint32_t align; // 2^align字节对齐 uint32_t reloff; // 重定位信息在文件中的偏移 uint32_t nreloc; // 重定位项数量 uint32_t flags; // 节的类型和属性 uint32_t reserved1; // 按段用途不同含义不同例如符号指针表的下标 uint32_t reserved2; // 按段用途不同含义不同例如间接符号表计数 uint32_t reserved3; // 64位下保留 };section的作用在于更精细地描述文件的逻辑内容。例如__TEXT段里__text节放的是编译后的机器指令__cstring节放的是C字符串字面量__stubs节放的是动态符号桩代码。搞清楚一个数据在哪个section里对逆向时的静态分析非常关键。比如你想在二进制里搜一个字符串顺着__cstring节的offset和size去扫描文件会比全文件暴力搜索快得多。3.3 __bss为什么没有文件数据__bss节是segment与section映射关系最容易理解错的地方。它的flags类型是S_ZEROFILL0x1表示这个节在文件中不占空间但加载后会在内存中分配一块被清零的空间。体现在数值上它的offset字段指向文件末尾附近有时等于filesizesize字段却是一个很大的数。这样一来segment的filesize可能小于vmsize因为segment的filesize只是所有非零填充section文件数据之和而vmsize则包含了__bss那部分虚拟内存大小。做Mach-O解析时很容易在这里栽跟头如果假设文件里完整的文件数据等于vmsize然后顺着vmaddr去读文件内容读到的可能是文件末尾甚至越界。正确做法是永远用fileoff和filesize去文件里读数据用vmaddr和vmsize去规划内存布局两者不要混用。4. 动手实现一个LC_SEGMENT_64解析器4.1 准备工作读Mach-O Header解析任何Mach-O文件第一步都是读头部。头部里的magic number决定文件是32位还是64位MH_MAGIC_64、MH_CIGAM_64对应64位MH_MAGIC、MH_CIGAM对应32位ncmds字段告诉我们总共有多少条load commandsizeofcmds告诉我们这些命令总共占多大空间。#include stdio.h #include stdlib.h #include stdint.h #include string.h #include mach-o/loader.h #include mach-o/fat.h static uint32_t read_u32(FILE *fp, int swap) { uint32_t v; if (fread(v, sizeof(v), 1, fp) ! 1) return 0; if (swap) return __builtin_bswap32(v); return v; } int main(int argc, char **argv) { if (argc ! 2) { fprintf(stderr, usage: %s mach-o file\n, argv[0]); return 1; } const char *path argv[1]; FILE *fp fopen(path, rb); if (!fp) { perror(fopen); return 1; } // 读magic判断是否fat binary / 是否需要字节交换 uint32_t magic read_u32(fp, 0); int swap 0; uint64_t base_offset 0; if (magic FAT_MAGIC || magic FAT_CIGAM) { swap (magic FAT_CIGAM); uint32_t nfat_arch read_u32(fp, swap); // 这里简化处理只取第一个64位架构 long header_pos ftell(fp); for (uint32_t i 0; i nfat_arch; i) { uint32_t cputype read_u32(fp, swap); uint32_t cpusubtype read_u32(fp, swap); uint32_t offset read_u32(fp, swap); uint32_t size read_u32(fp, swap); uint32_t align read_u32(fp, swap); // 跳过两个保留字段 fseek(fp, 8, SEEK_CUR); if (cputype CPU_TYPE_X86_64 || cputype CPU_TYPE_ARM64) { base_offset offset; break; } } fseek(fp, (long)base_offset, SEEK_SET); magic read_u32(fp, 0); } if (magic MH_MAGIC_64 || magic MH_CIGAM_64) { swap (magic MH_CIGAM_64); } else if (magic MH_MAGIC || magic MH_CIGAM) { fclose(fp); fprintf(stderr, 32-bit Mach-O not handled in this example\n); return 1; } else { fprintf(stderr, not a mach-o file\n); return 1; } // 读头部其余字段 struct mach_header_64 header; if (fread(header, sizeof(header), 1, fp) ! 1) return 1; if (swap) { // 字节序反转处理 } printf(magic: 0x%08x\n, header.magic); printf(cputype: 0x%08x, filetype: %u, ncmds: %u, sizeofcmds: %u\n, header.cputype, header.filetype, header.ncmds, header.sizeofcmds); fclose(fp); return 0; }上面这段代码之所以特意处理fat binary是因为现在大部分真机app和通用二进制工具都打包了arm64和x86_64两个slice直接读文件头会得到FAT_MAGIC而不是MH_MAGIC_64很容易一脸懵。判断出fat后遍历fat_arch列表找到目标架构对应的offset再从这个偏移处开始按Mach-O格式解析。4.2 遍历Load Commands并解析LC_SEGMENT_64解析segment命令的核心逻辑是把文件指针定位到Load Commands起始处然后一个命令一个命令地跳着读遇到LC_SEGMENT_64就深挖。// 接上一步header已经从文件开头偏移base_offset处读好了 // 这里继续从Load Commands起始处开始遍历 fseek(fp, (long)(base_offset sizeof(struct mach_header_64)), SEEK_SET); uint8_t *cmds malloc(header.sizeofcmds); if (!cmds) return 1; if (fread(cmds, header.sizeofcmds, 1, fp) ! 1) return 1; uint8_t *p cmds; uint8_t *end cmds header.sizeofcmds; while (p sizeof(struct load_command) end) { struct load_command *lc (struct load_command *)p; if (swap) { // 对lc-cmd和lc-cmdsize做交换 } if (lc-cmd LC_SEGMENT_64) { struct segment_command_64 *seg (struct segment_command_64 *)p; printf( LC_SEGMENT_64\n); printf( segname: %.16s\n, seg-segname); printf( vmaddr: 0x%016llx\n, seg-vmaddr); printf( vmsize: 0x%016llx (%llu)\n, seg-vmsize, seg-vmsize); printf( fileoff: 0x%016llx\n, seg-fileoff); printf( filesize:0x%016llx\n, seg-filesize); printf( maxprot: 0x%x initprot: 0x%x\n, seg-maxprot, seg-initprot); printf( nsects: %u\n, seg-nsects); // 解析该segment下的sections struct section_64 *sect (struct section_64 *)(p sizeof(struct segment_command_64)); for (uint32_t i 0; i seg-nsects; i) { printf( sectname: %.16s\n, sect-sectname); printf( segname: %.16s\n, sect-segname); printf( addr: 0x%016llx\n, sect-addr); printf( size: 0x%016llx\n, sect-size); printf( offset: 0x%08x\n, sect-offset); printf( align: 2^%u\n, sect-align); printf( flags: 0x%08x\n, sect-flags); sect; } } p lc-cmdsize; } free(cmds); fclose(fp);有个典型的重量级问题字节序是个大坑。Intel和ARM的Mac二进制都是小端序但fat文件本身可能包含大端序的PowerPC slice老版本Xcode编译的。如果你只针对x86_64和arm64做解析默认小端是安全的但解析fat头时FAT_MAGIC和FAT_CIGAM的判断早晚要面对否则在universal二进制面前直接翻车。早期不少解析工具就是死在字节序判断上建议在正式代码里加一个swap标志位并全面套用。4.3 验证解析结果写完后拿一个系统里的实际二进制文件验证一下比如/usr/bin/env或者/System/Applications/Calculator.app里的主程序。运行上面这段代码预期输出和otool -l的结果是一致的。实际发生不一致时优先检查是不是section结构体大小算错了或者偏移计算时没有加上fat slice的base_offset。5. 偏移量、对齐与常见误判实战中反复确认的问题5.1 页对齐规则决定了segment的实际大小在x86_64和arm64上虚拟内存页大小是4KBarm64的某些大页场景除外。dyld做映射时文件偏移与虚拟地址的差值在段长度内保持恒定每个segment的vmaddr和fileoff之间必须满足页对齐关系vmaddr % 0x1000 fileoff % 0x1000否则mmap无法按页映射。举一个实际的例子__TEXT段的vmaddr是0x100000000fileoff是0这是标准对齐。到了__DATA段vmaddr可能是0x100008000fileoff可能是0x4000两者都对齐到页。如果你自己DIY一个Mach-O文件不小心把fileoff设成0x4001加载时就会报错。为什么我总是强调这一点因为写链接器或做Mach-O编辑工具时文件里增删一个字节很容易导致所有段的fileoff集体错位。修复方法不是手动挨个改而是重新计算对齐并更新各命令里的偏移字段。手工修改二进制时最容易出的问题就是改完offset忘了更新vmaddr最后映射出来的数据全是乱的。5.2 虚拟地址到文件偏移的换算公式做逆向时最常遇到的场景是你有一个内存地址比如崩溃日志里的栈地址想让它对应到文件里的某个位置需要先减去__TEXT段的vmaddr再加上__TEXT段的fileofffile_offset (mem_addr - seg.vmaddr) seg.fileoff这个公式只在该内存地址确实落在某个segment的范围内时成立。所以严谨的做法是先遍历所有segment找到满足mem_addr seg.vmaddr mem_addr seg.vmaddr seg.vmsize的那个段再做换算。如果找不到说明这个地址不在Mach-O的映射区内可能是dyld本身、共享缓存或堆栈上的地址。5.3 __PAGEZERO段看起来没用实际是空气墙几乎每个arm64和x86_64的可执行文件都有__PAGEZERO段。它通常位于地址0vmaddr为0x0vmsize为0x100000000在x86_64上是4GB在arm64上通常是4GB或者取决于编译选项或0x100000000而fileoff和filesize都是0。也就是说这一段在文件里完全不存在但进程会为它保留巨大的虚拟地址空间。它的用途是空指针保护因为虚拟地址0附近的空间被标记为不可读不可写不可执行任何对空指针或极小地址的非法访问都会立刻触发段错误而不是碰巧读写到某些有效内存。这个段虽然不占文件体积但它在Load Commands里占了一条命令的位置做解析时别把它当异常跳过。5.4 遇到奇异的段名和flags时要注意什么标准二进制里常见的段名就那几个但我在实际逆向中遇到过__AUTH、__OBJC、__RODATA等扩展段。比如arm64e架构开启了POINTER_AUTH后__AUTH段存放带指针认证签名的指针它的maxprot和initprot可能不同于普通段__RODATA段是最近工具链专门为只读数据划分的段。遇到这些段时不要想当然地拿旧经验判断最好结合dysz共享缓存工具或dyld源码确认它的用途。flags字段里section的S_REGULAR0x0、S_ZEROFILL0x1、S_CSTRING_LITERALS0x2、S_LAZY_SYMBOL_POINTERS0x6、S_SYMBOL_STUBS0x8这些类型各有各的用途。尤其是S_LAZY_SYMBOL_POINTERS和S_NON_LAZY_SYMBOL_POINTERS它们代表延迟绑定的符号指针表和非延迟绑定符号指针表符号解析时到底走哪条路径就是靠section flags区分的。5.5 一个实例崩溃地址如何对应到文件里的代码位置假设你拿到一个crash log崩溃地址是0x1052e4350先从dyld共享缓存里或者用image list找到主程序的__TEXT段起始地址是0x1052e0000把两者相减得到0x4350再去找__TEXT段的fileoff通常是0就可以把崩溃指令定位到文件偏移0x4350处进而去反汇编那个位置。这套换算逻辑我在分析和定位线上bug时用了很多次每次都直接有效。关键就是先找到目标段再做一次代入运算。6. 排查问题时的工具链与调试心得6.1 otool是最快的确认手段如果要快速确认一个文件里的segment/section布局otool -l永远是最省事的otool -l /bin/ls输出里的Load command 1、Load command 2就是一条条load command的编号。你可能会看到LC_SEGMENT_64下面直接打印段信息和随后的section信息清晰明了。唯一的问题是输出非常冗长一个大型二进制可能会有几十上百条命令建议先看前面几条再grep特定段名otool -l /bin/ls | grep -A 5 __TEXT6.2 MachOView和llvm-objdump各有擅长MachOView是一款经典的GUI可视化工具适合看层级结构鼠标点一点就能看到每个segment下挂了哪些section每一条load command的原始字节都对得上。而llvm-objdump更适合在命令行环境里批量做自动化分析比如llvm-objdump --macho --private-headers /bin/ls llvm-objdump --macho --section-headers /bin/ls前者打印所有load command的详细信息后者只打印section列表。我的习惯是快速了解用otool深挖字段细节用MachOView写脚本批量处理用llvm-objdump输出文本。6.3 我踩过的一个真实误区segname是段名而非命令名在一开始读struct segment_command时我习惯性地把segname理解成这条命令的名字导致一度以为LC_SEGMENT_64命令本身的标识就是__TEXT。后来对照源码和实际二进制才发现code signature、symtab等许多load command根本不带segname只有segment命令带。真正用来识别命令类型的是cmd字段而不是segname。cmd决定这条命令是什么segname决定这段数据叫什么两者是不同维度。另外一个处理细节上文提到过segname不保证以\0结尾所以printf直接% s打印会越界。写工具时要么用%.16s限制宽度要么memcpy到本地缓冲区后手动补\0。这个小问题看似不起眼但在批量扫描大量文件时越界读取可能读到未知内存直接导致崩溃或误判。6.4 解析顺序与诊断技巧当我自己写的解析器输出和系统工具对不上时我会按三步排查检查magic和cputype是否识别正确。如果文件是fat binary没有定位到正确的slice偏移后面所有解析都无从谈起。检查ncmds和sizeofcmds是否与文件实际内容一致。有些编辑工具改了Load Commands后忘了同步头部这两个字段导致遍历时走出了命令区。逐步打印每条load command的cmdsize。如果某条cmdsize特别大导致下一跳直接越界那一定是某个命令的结构解析错了。做过几轮这类对比后我对Mach-O的信心就从听说过变成了真的懂了——因为LC_SEGMENT_64每个字段的意义都是在一次次对照、报错、修bug的过程里沉淀下来的。最后聊聊我的体会Mach-O格式本身不算复杂真正难的是你需要在文件格式描述和运行时行为之间建立映射感。LC_SEGMENT_64这条命令是这种映射感的核心枢纽——你不光要知道segname、vmaddr、fileoff这些字段各自是什么还要能想象出dyld拿着这些值去调用mmap时进程地址空间里一个个区域被标注好权限、填充好数据的样子。把这条命令吃透了再去理解dyld的加载过程、代码签名的验证范围、App Store的加密位置都会轻松很多。下次拿到一个陌生二进制不妨按文里的思路先看一遍它的segment布局说不定就能发现一些不寻常的东西。