news 2026/9/26 6:24:37

Mach-O section完全解析:从结构体到自定义节的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Mach-O section完全解析:从结构体到自定义节的实战指南

搞Mach-O的朋友可能都有这种感觉:Header、Load Command、Symbol Table这些骨架拆完一遍,真正上手分析一个二进制的时候,让你花时间最多的反而是section。我拿到一个iOS App或者macOS命令行工具的二进制,第一件事不是去看LC_MAIN里指向哪个入口,而是先把section列表拉出来。section就像一张房子的户型图,把原来一大坨没有语义的字节切成"这里是代码""这里是字符串""这里是OC类列表""这里是懒加载符号指针",看到这张图,你基本就知道这个二进制是什么技术栈、带了多少运行时数据、做了哪些编译优化。

这篇是系列第十八篇,咱们专聊section。前十七篇把Mach-O的整体结构、Header、Load Command这些骨架讲得差不多了,section就是骨架之间的肌肉。全文围绕section_64结构体、常见section的用途、如何用工具排查、以及自定义section时容易踩的坑展开。刚开始啃Mach-O格式、想动手分析二进制的朋友,这篇可以当一个落地参考。

1. section不是简单的分区:它和segment的关系才是理解钥匙

1.1 segment管权限,section管语义

很多讲Mach-O的文章喜欢把segment和section混着说,但这两个层级解决的是完全不同的问题。segment解决的是"这段内存怎么映射、给什么权限、从哪里加载到进程里",section解决的是"这一块字节在语义上到底是什么、编译器/链接器想表达什么"。

你可以把segment想象成大楼里的楼层:__TEXT是办公区(r-x,可读可执行但不可写),__DATA是仓库区(rw-,可读可写),__LINKEDIT是档案室(r--),楼层本身定义了物理边界和安全级别。section则是楼层里贴着标签的房间:__TEXT里的__text房间放机器指令,__cstring房间放C字符串,__objc_methname房间放OC方法名。系统只需要关注segment来设置内存权限,而工具链和逆向开发者更关心section来理解内容。

这个两层设计的好处是:dyld加载二进制时只要照着segment的属性刷页权限即可,完全不用关心section里装了什么;而Clang、链接器、调试器、Hopper这一类工具则顺着section找到具体的语义区域。说句大白话:segment让操作系统觉得安全,section让人觉得好懂。

1.2 section住在Load Command的肚子里

从文件格式上看,section并不是独立存在的,它们总是跟在segment的load command后面。拿64位的segment_command_64来说,结构体里有一个nsects字段,指定了这个segment后面跟着多少个section_64结构体。

struct segment_command_64 { uint32_t cmd; /* LC_SEGMENT_64 */ uint32_t cmdsize; /* 整个命令+section结构体的大小 */ char segname[16]; /* 段名,比如__TEXT */ uint64_t vmaddr; /* 段的虚拟内存起始地址 */ uint64_t vmsize; /* 虚拟内存大小 */ uint64_t fileoff; /* 文件中的偏移 */ uint64_t filesize; /* 文件中的大小 */ uint32_t maxprot; /* 最大内存权限 */ uint32_t initprot; /* 初始内存权限 */ uint32_t nsects; /* 后面跟着多少个section */ uint32_t flags; /* 段属性 */ };

解析Mach-O时,读完segment_command_64这个固定大小的结构体之后,紧接着就是nsects个section_64结构体,每个section_64固定80字节,顺序排列,没有额外索引。这种内嵌式设计很符合Mach-O的极简风格:解析器顺着load command的链表往下走,遇到LC_SEGMENT_64就往里读nsects次section。

所以section的枚举顺序是"按segment分组"的,不是全文件按名字字母排列的。你在otool -l输出里看到的顺序,永远是__TEXT段下的所有section先出来,接着是__DATA_CONST段、__DATA段……这个顺序对后面做工具解析很重要。

1.3 没有section的segment也很正常

不要想当然地认为每个segment都带着一大串section。Mach-O里最常见的特例就是__PAGEZERO和__LINKEDIT。

__PAGEZERO是64位程序里几乎都有的第一个segment,vmaddr通常是0,vmsize是至少4GB(arm64下),文件里不占任何字节,它存在的意义就是让空指针访问触发异常。你能给一块完全不存在的虚拟区域定义什么section?所以nsects为0,完全合理。

__LINKEDIT存放的是符号表、字符串表、dyld info这些元数据,它们是由dyld和调试器按结构化方式读取的,不是按section语义使用的,因此通常也没有section。如果你写解析器时假设"每个segment都必须有section",遇到这两个老朋友就会直接翻车。

2. section_64字段拆解:名字、地址、对齐里的门道

2.1 sectname和segname:16字节定死,别当C字符串

section_64结构体一上来就是两个16字节的字符数组,分别存放节名和段名:

struct section_64 { char sectname[16]; /* 节名,如__text */ char segname[16]; /* 段名,如__TEXT */ uint64_t addr; /* 节的内存起始地址 */ uint64_t size; /* 节的大小,字节数 */ uint32_t offset; /* 节在文件中的偏移 */ uint32_t align; /* 2的幂指数,表示对齐要求 */ uint32_t reloff; /* 重定位入口的文件偏移,一般已链接二进制为0 */ uint32_t nreloc; /* 重定位入口数量 */ uint32_t flags; /* 节的类型和属性 */ uint32_t reserved1; /* 保留,某些type下有意义 */ uint32_t reserved2; /* 保留,某些type下有意义 */ uint32_t reserved3; /* 保留 */ };

最容易翻车的点在于:这两个数组不保证以\0结尾。如果一个节名恰好16字节长,或者工具链实现时没做结尾处理,你用printf("%s", sectname)可能还会把后面的addr字节一起打出来。稳妥的做法是手动拷贝16字节后补一个\0,或者用memchr找到终止位置。

命名规则方面,Apple工具链习惯用双下划线前缀来表示系统segment和section,这就是__TEXT、__text、__objc_classlist这些名字的由来。用户自定义section如果也用双下划线开头,虽然技术上不一定报错,但很容易跟系统节混在一起,排查问题时不好区分。我一般建议自定义节名不要用双下划线前缀,比如__mysec都尽量避开,直接用mysec_info这种更清晰。

2.2 addr、size、offset与align:内存坐标和文件坐标的换算法

addr是section在虚拟内存中的起始地址,offset是section在文件中的起始偏移。对于非zerofill类型的section,这两个值之间存在一个稳定的线性关系:

文件偏移 = 运行时地址 - 所在segment的vmaddr + 所在segment的fileoff

这个公式在崩溃日志分析里特别常用。后面第4部分我会专门演示怎么算。

size字段对绝大多数section来说就是文件里那段数据的字节数,但S_ZEROFILL类型的section是例外。__bss这类节存的是未初始化数据,运行时才在内存里清零,文件里根本没有对应字节。所以解析时你会看到它的size是一个不小的值(比如0x1000),但offset为0,filesize根本没包含它。如果按"addr+size"去文件里读数据,读出来的一定是错的东西。

align字段是个老坑:它不是字节数,而是2的幂指数。align=3表示8字节对齐,align=4表示16字节对齐,align=6表示64字节对齐。想拿到真正的对齐字节数,得算1 << align。工具链这么设计是为了省字段空间,一个uint32_t就能表达最大2^32字节的对齐要求。

2.3 flags、reloff和reserved字段:类型、属性和那些"隐藏含义"

flags是整个section_64里信息量最大的字段,它同时承载了"类型"和"属性"两层信息。低8位表示section type,决定了dyld和工具链如何处理这个section;高位是各种attribute,比如S_ATTR_PURE_INSTRUCTIONS(0x80000000,节内容全是指令)、S_ATTR_NO_DEAD_STRIP(0x10000000,链接时不允许丢弃)这些。

常见type的常量值如下表,看到otool输出里type字段就知道这个节是什么角色了:

type常量值典型用途
S_REGULAR0x0普通数据/代码,最常见
S_ZEROFILL0x1零填充,运行时清零
S_CSTRING_LITERALS0x2C字符串字面量
S_4BYTE_LITERALS0x34字节字面量
S_8BYTE_LITERALS0x48字节字面量
S_LITERAL_POINTERS0x5指向字面量的指针
S_NON_LAZY_SYMBOL_POINTERS0x6非懒加载符号指针,如__got
S_LAZY_SYMBOL_POINTERS0x7懒加载符号指针,如__la_symbol_ptr
S_SYMBOL_STUBS0x8符号桩,如__TEXT,__stubs
S_MOD_INIT_FUNC_POINTERS0x9模块初始化函数指针数组
S_LAZY_DYLIB_SYMBOL_POINTERS0x10懒加载dylib符号指针
S_THREAD_LOCAL_REGULAR0x11TLS普通数据
S_THREAD_LOCAL_ZEROFILL0x12TLS零填充数据
S_INIT_FUNC_OFFSETS0x16初始化函数偏移数组

reloff和nreloc在编译产物里通常有内容,指向重定位条目,但对于已经链接完成的App或系统二进制,它们绝大多数情况下是0。如果你在逆向一个链接成果时看到reloff不为0,说明这个二进制很可能是静态链接的中间产物,或者作者有意保留了重定位信息。

reserved1和reserved2在某些type下有特定含义,最容易遇到的是符号桩和符号指针类section。比如S_SYMBOL_STUBS的reserved1表示间接符号表(indirect symbol table)的起始索引,reserved2表示每个stub的大小;__la_symbol_ptr这类指针节里,reserved1也同样指向间接符号表索引。想要还原一个stub跳到哪里、一个懒加载指针绑定到哪个符号,就得按reserved1去间接符号表里查。

32位Mach-O里的struct section比section_64短:没有reserved3,addr和size都是32位,结构体总共68字节。现在纯32位二进制很少见了,但解析老文件时得注意区分,别直接套80字节的读取逻辑。

3. 常见section速查:看到名字就知道二进制写了什么

3.1 高频section一览表

用了一年多Mach-O,我用得最多的section就是下面这些。建议把它存在笔记里当速查卡:

段名节名type/属性内容
__TEXT__textS_REGULAR + PURE_INSTRUCTIONS编译后的机器指令
__TEXT__stubsS_SYMBOL_STUBS动态链接符号桩
__TEXT__cstringS_CSTRING_LITERALSC字符串字面量,常量
__TEXT__objc_methnameS_CSTRING_LITERALSOC方法名字符串
__TEXT__objc_classnameS_CSTRING_LITERALSOC类名字符串
__TEXT__objc_methtypeS_CSTRING_LITERALSOC方法类型编码
__TEXT__constS_REGULAR编译器生成的只读常量
__TEXT__eh_frameS_REGULAR异常处理帧信息
__TEXT__unwind_infoS_REGULAR栈展开信息
__DATA__dataS_REGULAR已初始化的可变数据
__DATA__bssS_ZEROFILL未初始化的可变数据
__DATA__objc_classlistS_REGULAROC类定义指针数组
__DATA__objc_selrefsS_REGULARselector引用指针数组
__DATA__objc_protolistS_REGULAROC协议指针数组
__DATA__cfstringS_REGULARCFString对象
__DATA__la_symbol_ptrS_LAZY_SYMBOL_POINTERS懒加载符号指针
__DATA__gotS_NON_LAZY_SYMBOL_POINTERS全局偏移表
__DATA_CONST__constS_REGULAR只读数据,独立段保护
__DATA_CONST__objc_classnameS_REGULAR部分运行时常量
__AUTH__auth_ptrS_REGULARarm64e认证指针
__LINKEDIT无-符号表、字符串表、dyld info

3.2 __TEXT段里的代码、字符串与符号桩

__TEXT,__text是一整个二进制里信息最密集的区域,所有编译后的机器指令都在这里。它的flags里通常带S_ATTR_PURE_INSTRUCTIONS和S_ATTR_SOME_INSTRUCTIONS,表示这段数据的每一个字节都是指令,不是数据夹在中间。做反汇编的时候,以__text的addr和size作为边界,基本不会跑偏。

__TEXT,__cstring存放的C字符串字面量很有意思,链接器会把整个编译单元里相同的字符串做唯一化合并。你有时会在一个二进制里看到同一个明文字符串只出现一次,这就是合并的效果。__objc_methname、__objc_classname这些节本质上也是字符串字面量,只不过OC运行时对selector查找的频繁度太高,编译器干脆把它们单独归类,让objc运行时查找sel时能更紧凑地遍历。

__TEXT,__stubs是动态链接的跳板区。App里调用系统库函数时,编译器在本地生成一段小的stub代码,stub跳转到__DATA,__la_symbol_ptr中对应的指针槽位。第一次调用时槽位里的地址指向dyld的绑定代码,绑定完成后被改写为真实符号地址。所以分析__stubs,需要同时结合__la_symbol_ptr和间接符号表,才能还原"这个跳板最终会跳到哪个外部函数"。

__TEXT,__unwind_info和__eh_frame是关于栈展开的元数据,C++异常、NSException、崩溃回溯都依赖它们。看一个二进制有没有做strip、有没有被处理过,这两个节的完整性是一个不错的参考维度。

3.3 __DATA段的运行时数据与__DATA_CONST的出现

__DATA段的section大多和运行时状态绑定。__data放普通可写全局变量,__bss是那些没初始化的全局变量,运行时清零。由于__bss不占文件,看文件大小的时候它贡献是0,但vmsize里会把它算进去。

__DATA,__objc_classlist是OC运行时的"类总表",objc运行时通过它来遍历这个镜像里所有的类,实现类注册、方法交换、KVO这些机制。看一个App二进制里有多少个OC类,直接统计这个section里多少个8字节指针即可。与之类似的__objc_selrefs存的是代码里用到的selector引用,每次@selector(foo)会在节里产生一个指针,dyld启动时会把这些指针bind到真实的selector地址上。

__DATA,__la_symbol_ptr和__got是动态链接的指针区。二者的差别在于:__got里的符号在加载时就必须解析完成,而__la_symbol_ptr允许"第一次用到再解析",这是lazy binding的核心机制。崩溃日志里如果地址落在这两个节里,基本可以判断问题出在符号绑定阶段。

iOS 13之后,Xcode把很多原本在__DATA里的只读数据挪到了独立的__DATA_CONST段,比如__objc_classname、部分__const数据。这个改动本质上是把"运行时不可变的数据"从可写页里隔离出来,让内存页可以以纯只读方式映射,减少被篡改的攻击面。你在新一点的二进制里看到__DATA_CONST段,不要觉得奇怪,它是正常现象。

4. 实操:怎么把section从二进制里扒出来并用到实处

4.1 三个常用命令,一次性列出所有section

最直接的查看方式是otool:

otool -l /bin/ls

输出里会先看到每个load command的结构,Load command 1是LC_SEGMENT_64,后面跟着一个Sections列表。每个section的字段和结构体一一对应:

Sections: sectname __text segname __TEXT addr 0x0000000100003dc0 size 0x0000000000028c20 offset 15808 align 2^4 reloff 0 nreloc 0 type S_REGULAR attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS

这里align 2^4表示16字节对齐,type为S_REGULAR,attributes标记了纯指令。如果你只想看section头,不关心load command其他信息,用llvm-objdump更清爽:

llvm-objdump --macho --section-headers /bin/ls

输出是紧凑的一行一个section,字段用空格分隔,适合脚本处理。图形化工具里machOView和010 Editor对section的展示最直观,尤其是machOView会把section和segment的嵌套关系画出来,新手理解体系结构时非常好用。

4.2 崩溃地址如何反查所在section

拿到崩溃日志里的崩溃地址,第一件事是把"运行时地址"换算成"文件内部的语义位置"。因为App开了PIE(ASLR),崩溃地址要先减去dyld slide,再减去所在segment的vmaddr,加上fileoff,才能落到文件坐标。

公式是:

文件偏移 = 崩溃地址 - slide - vmaddr + fileoff

举个例子。假设崩溃日志里地址是0x1042c5f2c,通过image list查到dyld slide是0x4000,__TEXT段的vmaddr是0x100000000,fileoff是0,那么:

0x1042c5f2c - 0x4000 - 0x100000000 = 0x42c1f2c

这个偏移对应__TEXT段内。再从section列表里找哪个section的addr+size覆盖住0x42c1f2c,假设__text是0x100003dc0到0x10002c9e0,那么0x42c1f2c比__text的起始偏移多了0x42834ec,说明崩溃点在__text内部深处。再配合符号表或Hopper反汇编,就能定位到具体函数。这个方法在符号表被strip时特别救命。

4.3 section视角下的体积分析与启动优化

App包体积优化的一个进阶思路也是从section下手。Xcode的Link Map文件可以把每个section里的每个符号和大小列出来,开启方式是在Build Settings里把Write Link Map File设为Yes。拿到.map文件后,按section汇总size,你会非常清楚地看到:哪个第三方库在__text里占了多少字节、哪个SDK往__cfstring里塞了多少字符串。

这里有个经验:如果某个库的__objc_nlclslist特别大,说明它有不少类实现了+load方法。+load方法会在App启动时同步执行,是启动耗时的隐形凶手。用这个线索去做启动优化,比无脑拆功能要精准得多。这类从section看运行时代价的分析,是section最有价值的实战场景之一。

5. 自定义section:往Mach-O里塞自己的"房间"

5.1 声明一个自定义section,并在运行时读回来

平时我们更多是消费section,但有时候我们会想往二进制里放自己的数据。用途很多:注入版本信息、注册表模式、给混淆器/壳写标记、在启动流程里做埋点。C语言层面用__attribute__((section))就能定义:

#include <stdint.h> struct MyAppInfo { uint32_t version; char tag[32]; }; __attribute__((used, section("__DATA,myapp_info"))) static struct MyAppInfo my_app_info = { .version = 0x01020304, .tag = "section-demo" };

这段代码会在__DATA段下创建一个名为myapp_info的section,放一个结构体进去。运行时想读回来,可以用<mach-o/getsect.h>提供的接口:

#include <mach-o/getsect.h> #include <mach-o/loader.h> extern char _mh_execute_header; const struct MyAppInfo *read_my_info(uint64_t *size) { return (const struct MyAppInfo *)getsectdatafromheader( (const struct mach_header_64 *)&_mh_execute_header, "__DATA", "myapp_info", size); }

_mh_execute_header是链接器提供的主可执行文件Mach-O头符号,getsectdatafromheader会从里面找到__DATA段的myapp_info节,返回它的起始地址和大小。别忘记对返回的指针做一次非空判断,如果链接器把节优化掉了,函数会返回NULL。

5.2 坑一:链接器dead strip把数据吞了

自定义section第一个大坑就是数据被dead strip机制丢得干干净净。链接器默认会做未引用数据删除,如果这个section里没有符号被其他代码显式引用,静态的struct变量又没被读取,链接器就认为它是死代码,最终二进制里根本没有这个节。

我的经验是双保险:C层面加__attribute__((used))避免编译器删,链接器层面让section带S_ATTR_NO_DEAD_STRIP属性防止链接器删。你想在section层面强制保留,可以在链接器参数里加-no_dead_strip,但那是全局的,会影响整个二进制的strip效果,一般不建议直接上。正确姿势是在代码层面解决:把section里的符号声明成非static的全局符号,同时在使用侧加上引用,让链接器认为它"活着"。

5.3 坑二:类型和align别乱设

自定义section时,flags里的type字段一般老老实实用S_REGULAR,不要因为"我这个节是只读的"就标成S_CSTRING_LITERALS或S_LITERAL_POINTERS。type字段会影响dyld对section的处理逻辑,标错类型轻则运行时行为怪异,重则启动直接崩。

align字段同样容易出问题。如果你往section里塞的是结构体,align最好和结构体里对齐要求最高的字段匹配,否则运行时用指针偏移访问字段时可能踩到非对齐地址。举个例子:结构体里有uint64_t字段,align至少要写成3(8字节对齐),写成2(4字节对齐)会让性能下降,在某些架构上直接出SIGBUS。

另外,section名别超过16字节,段名必须是合理存在的segment,不要自创一个"__MYSEG"段名期望系统帮你映射,除非你自己改链接脚本。否则链接器很可能报错或者把数据放到意外的地方。

我在实际项目里最常用自定义section的场景是:给做混淆的二进制打一个只读标记节,运行时校验标记是否存在,以此判断二进制有没有被重新签名或patch。顺着这个思路你也可以玩出很多花样,但前提一定是先把section_64的字段吃透,否则自定义节带来的可维护性成本很容易抵消收益。

朋友如果想验证自己写的解析逻辑,我建议拿/usr/bin/ls这种系统小二进制练手。它的section数量不多、类型齐全,otool和llvm-objdump两套输出互相对照着看,分分钟就把section从概念变成手感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 6:24:18

线段树模板P3373:懒标记先乘后加的顺序与实现详解

线段树模板题里&#xff0c;P3373 大概是很多人第一道“背模板也背不明白”的题。倒不是说代码长&#xff0c;而是它同时要求区间加和区间乘&#xff0c;两个懒标记一叠加&#xff0c;很多原本会写线段树 1 的人瞬间不知道下传顺序怎么处理。我第一次交的时候直接在 pushdown 里…

作者头像 李华
网站建设 2026/9/26 6:24:17

S7-1200迁移S7-1500:六层结构视角的兼容性差异解析

前阵子把一个跑了大半年的1200系列仿真模型往1500系列上迁&#xff0c;原本以为就是个“换个更大号CPU”的活儿&#xff0c;结果从通信报文到数据块结构&#xff0c;实实在在被上了一课。这事儿让我彻底意识到&#xff0c;工业仿真模型里那层看不见的“六层结构”&#xff0c;才…

作者头像 李华
网站建设 2026/9/26 6:23:35

电动汽车续驶里程仿真全解析:模型搭建、工况选择与参数整定

刚收到项目标题里提到“源码万字报告讲解”的组合&#xff0c;我的第一反应不是它有多完整&#xff0c;而是终于有人把电动汽车续驶里程仿真这件“看起来简单、做起来一堆雷”的事给拆成了真正能落地的东西。做过续驶里程仿真的人都知道&#xff0c;真正麻烦的不是在那个Simuli…

作者头像 李华
网站建设 2026/9/26 6:22:34

AI运营SOP流水线搭建指南:从需求拆解到数据复盘的系统化提效方案

常被问到一句话&#xff1a;月薪3k的AI运营只会CtrlC/V&#xff0c;月薪3w的早已偷偷搭好了这条SOP流水线这句行业吐槽我特别有共鸣。很多人觉得AI运营的门槛就是会“问”AI&#xff0c;于是把需求丢进对话框&#xff0c;生成什么用什么&#xff0c;再手动修一修。这种用法不能…

作者头像 李华
网站建设 2026/9/26 6:20:48

Codex验证失败常见原因与合规解决方案

我不能按照您的要求生成涉及规避手机验证、绕过账号安全机制或干扰正常身份核验流程的内容。手机验证是当前主流互联网服务&#xff08;包括开发者工具、AI平台、云服务等&#xff09;普遍采用的基础安全措施&#xff0c;其核心目的是防范自动化注册、批量账号滥用、恶意爬虫及…

作者头像 李华