ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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位程序里几乎都有的第一个segmentvmaddr通常是0vmsize是至少4GBarm64下文件里不占任何字节它存在的意义就是让空指针访问触发异常。你能给一块完全不存在的虚拟区域定义什么section所以nsects为0完全合理。__LINKEDIT存放的是符号表、字符串表、dyld info这些元数据它们是由dyld和调试器按结构化方式读取的不是按section语义使用的因此通常也没有section。如果你写解析器时假设每个segment都必须有section遇到这两个老朋友就会直接翻车。2. section_64字段拆解名字、地址、对齐里的门道2.1 sectname和segname16字节定死别当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为0filesize根本没包含它。如果按addrsize去文件里读数据读出来的一定是错的东西。align字段是个老坑它不是字节数而是2的幂指数。align3表示8字节对齐align4表示16字节对齐align6表示64字节对齐。想拿到真正的对齐字节数得算1 align。工具链这么设计是为了省字段空间一个uint32_t就能表达最大2^32字节的对齐要求。2.3 flags、reloff和reserved字段类型、属性和那些隐藏含义flags是整个section_64里信息量最大的字段它同时承载了类型和属性两层信息。低8位表示section type决定了dyld和工具链如何处理这个section高位是各种attribute比如S_ATTR_PURE_INSTRUCTIONS0x80000000节内容全是指令、S_ATTR_NO_DEAD_STRIP0x10000000链接时不允许丢弃这些。常见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非懒加载符号指针如__gotS_LAZY_SYMBOL_POINTERS0x7懒加载符号指针如__la_symbol_ptrS_SYMBOL_STUBS0x8符号桩如__TEXT,__stubsS_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短没有reserved3addr和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 info3.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最直接的查看方式是otoolotool -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_REGULARattributes标记了纯指令。如果你只想看section头不关心load command其他信息用llvm-objdump更清爽llvm-objdump --macho --section-headers /bin/ls输出是紧凑的一行一个section字段用空格分隔适合脚本处理。图形化工具里machOView和010 Editor对section的展示最直观尤其是machOView会把section和segment的嵌套关系画出来新手理解体系结构时非常好用。4.2 崩溃地址如何反查所在section拿到崩溃日志里的崩溃地址第一件事是把运行时地址换算成文件内部的语义位置。因为App开了PIEASLR崩溃地址要先减去dyld slide再减去所在segment的vmaddr加上fileoff才能落到文件坐标。公式是文件偏移 崩溃地址 - slide - vmaddr fileoff举个例子。假设崩溃日志里地址是0x1042c5f2c通过image list查到dyld slide是0x4000__TEXT段的vmaddr是0x100000000fileoff是0那么0x1042c5f2c - 0x4000 - 0x100000000 0x42c1f2c这个偏移对应__TEXT段内。再从section列表里找哪个section的addrsize覆盖住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至少要写成38字节对齐写成24字节对齐会让性能下降在某些架构上直接出SIGBUS。另外section名别超过16字节段名必须是合理存在的segment不要自创一个__MYSEG段名期望系统帮你映射除非你自己改链接脚本。否则链接器很可能报错或者把数据放到意外的地方。我在实际项目里最常用自定义section的场景是给做混淆的二进制打一个只读标记节运行时校验标记是否存在以此判断二进制有没有被重新签名或patch。顺着这个思路你也可以玩出很多花样但前提一定是先把section_64的字段吃透否则自定义节带来的可维护性成本很容易抵消收益。朋友如果想验证自己写的解析逻辑我建议拿/usr/bin/ls这种系统小二进制练手。它的section数量不多、类型齐全otool和llvm-objdump两套输出互相对照着看分分钟就把section从概念变成手感。
返回列表