Mach-O中的__RODATA段:只读数据与ObjC类信息逆向解析

发布时间:2026/9/26 18:47:25
Mach-O中的__RODATA段:只读数据与ObjC类信息逆向解析 每次遇到Mach-O里到底哪个段放什么东西这种问题我都建议别死记直接打开终端看一遍otool -l最靠谱。但你如果问的是__RODATA这个段那我得说这几年它变得越来越重要了。以前很多二进制里压根没有这个segment现在随手翻一个新SDK编译出来的arm64程序十有八九能看到segname __RODATA里面装着一堆只读数据尤其是Objective-C类信息的只读半边天。这是Mach-O系列第四十三篇。前面我们把segment基本概念、__TEXT段的组成、__LINKEDIT里的符号表都聊过了这一篇单独把__RODATA拎出来拆干净。读完你至少能解决三个问题这个段到底哪来的、里面常住哪些数据、逆向时怎么把它的内容快速提取并解读。我还会把class_ro_t相关的结构、以及我实际踩过的几个坑一并讲掉尽量让你少走弯路。1. 先搞清楚__RODATA段从哪来为什么单独拆出来1.1 一次从__TEXT段里的搬家在比较早期的Mach-O布局里只读数据并没有一个独立的段。const变量、字符串常量、虚函数表、Objective-C的类信息这些乱七八糟的东西大多数被塞进__TEXT段跟代码共用一个段。当时这么做有一定道理因为__TEXT段的页被映射成只读可执行天然的写保护不需要再额外开发一套机制。但问题也很明显。__TEXT是r-x权限需要加载到可执行内存里而里面其实混着大量只是纯数据的东西。这些数据不需要执行权限却被迫跟着代码一起占用可执行内存页。在内存分页、代码签名、dyld加载路径上这既浪费又不灵活。所以苹果在Mach-O的段定义里很早就预留了SEG_RODATA这个常量只是一直没大规模启用。这几年随着SDK和链接器升级编译出来的二进制越来越多地把只读数据从__TEXT搬到独立的__RODATA段。你完全可以把它理解成一次搬家凡是编译期就确定、运行期不应该被改写的数据都迁到这边来住。1.2 权限设计r-x、r--、rw-怎么分配看Mach-O的segment信息时最该关注的就是段权限。典型的arm64程序里大概是这样的段名内存权限典型内容__PAGEZERO不可读写不可执行空指针保护区__TEXTr-x机器指令、部分字符串__RODATAr--只读常量、类只读结构__DATA_CONSTrw-运行期改只读绑定后的指针、GOT__DATArw-可写对象、运行时数据__LINKEDITr--符号表、重定位信息等__RODATA定位非常明确只读不可执行。相比__TEXT它把纯数据和可执行代码在页级别分开了。这样做的好处是dyld和内核在映射内存时可以更精细地选择页属性数据页不需要被标记为可执行攻击面更小内存调优也更灵活。1.3 dyld shared cache带来的连锁影响系统库的情况更有意思。iOS和macOS里大部分系统动态库被合入dyld shared cache共享缓存里的段划分直接影响启动速度和共享页命中率。把只读数据单独聚合到__RODATA后同一个共享缓存区域内所有库的只读数据可以更集中地布局页面去重和内存压缩都更好做。这件事对逆向工程的影响是你在分析系统库时__RODATA会很显眼。以前你在__DATA.__objc_const里看到的class_ro_t结构现在往往出现在__RODATA.__objc_classro。链接器和dyld版本不同实际布局会有差异但大方向是一致的——只读的东西尽量往只读段放。2. __RODATA段里都住了谁常见子段一网打尽2.1 __const编译期常量与C虚表的归宿__RODATA.__const是这段里最常见的子段之一。凡是编译期就能确定的全局常量对象比如static const int kBufferMax 1024;、const char *kVersion 1.2.0;这种数据本体一般都会落在这里。注意这个说的是存字符串内容的地址不是字符串指针变量本身。指针变量如果是全局的那它在__DATA或__DATA_CONST里而它指向的常量内容在__RODATA.__const。C的虚表也是这里的常客。虚表在编译期就定型了运行期不会改所以放进只读区很合理。分析C写的库时快速定位__RODATA.__const里的虚表能帮你顺藤摸瓜找到虚函数实现。我有一次分析一个第三方C静态库就是用虚表基址作为锚点把继承关系捋清楚进而找到了核心逻辑的入口函数。2.2 __cstring字符串常量与逆向入口__RODATA.__cstring存放C字符串字面量。你写NSLog(login success)里的login success如果编译器决定放进__RODATA那这一小段文本就住在__cstring里。字符串常量对逆向分析来说就是路标。恶意软件里的C2地址、加密密钥、命令行参数很多都以明文字符串形式躺在这里。我常用的一个笨办法先用strings把二进制里的字符串全捞出来再对照otool看哪些字符串属于__RODATA.__cstring。对于定位代码逻辑比漫无目的反汇编快得多。strings -a -t x test | grep -i login-t x会显示字符串在文件中的十六进制偏移跟otool -l里的offset字段对照就能确认它属于哪个段哪个section。2.3 __objc_classroObjective-C类结构的只读半壁江山这是做iOS逆向时最值得关注的一个section。Objective-C的类对象在运行时有一个class_rw_t和一个class_ro_t。class_ro_t全称是read-only名字就已经告诉你了它就是编译期固定下来的那部分类信息包括类名、实例变量布局、base method list、base protocols和base properties。在新一些的链接器输出里class_ro_t经常被放在__RODATA.__objc_classro。你如果在otool -l里看到这个section基本可以断定这个二进制的Objective-C类元数据是走的新布局。后面我会单独开一节讲class_ro_t的字段解析这里先记住它的位置。2.4 __swift_proto / __swift_typesSwift元数据的只读区如果你的二进制混了Swift代码__RODATA里还会出现和Swift运行时相关的section比如__swift_proto、__swift_types。这些是用来存放Swift协议描述、类型元数据中只读部分的。Swift的元数据比ObjC复杂里面有各种描述符、访问函数、泛型参数信息解析门槛高一些。做Swift逆向时这些数据可以作为类型结构的路标。比如你想知道一个Swift类有哪些字段光看反汇编会比较痛苦但从__swift_types里的类型描述符出发能定位到字段的偏移和访问方式。不过Swift的ABI稳定性在不同版本之间差异不小解析时要小心版本匹配。3. 动手实践用otool与二进制分析工具定位__RODATA数据3.1 准备一个最小实验程序纸上谈兵没意思我们实际编译一个最小的Objective-C程序然后把它拆开看。用Clang直接编译不经过Xcode工程// test.m #import Foundation/Foundation.h interface MyClass : NSObject property (nonatomic, copy) NSString *name; - (void)hello; end implementation MyClass - (void)hello { NSLog(hello %, self.name); } end int main() { autoreleasepool { MyClass *obj [[MyClass alloc] init]; obj.name world; [obj hello]; } return 0; }编译命令clang -fobjc-arc -framework Foundation test.m -o test3.2 用otool -l解析段和节的布局编译完先看整体段布局otool -l test | grep -E (segname|sectname|addr|offset|initprot) | head -80你会看到类似这样的输出片段Section sectname __objc_classro segname __RODATA addr 0x100008000 size 0x00000090 offset 16384这就是__RODATA.__objc_classro它的VM地址从0x100008000开始大小0x90字节在文件里的偏移是16384。记住这些值后面手动提取要靠它们。3.3 手工提取__objc_classro内容的完整流程拿到offset和size之后用dd把整段数据抠出来dd iftest bs1 skip16384 count144 | xxd这里144是0x90的十进制。运行后你会看到一段十六进制数据里面包含class_ro_t的各个字段。光看二进制不太直观我建议先确认一下字段排列。在64位arm64下class_ro_t大致长这样注意不同runtime版本字段有调整struct class_ro_t { uint32_t flags; uint32_t instanceStart; uint32_t instanceSize; #ifdef __LP64__ uint32_t reserved; #endif const uint8_t *ivarLayout; const char *name; method_list_t *baseMethodList; protocol_list_t *baseProtocols; const ivar_list_t *ivars; const uint8_t *weakIvarLayout; property_list_t *baseProperties; };所以前面几个4字节是flags、instanceStart、instanceSize、reserved后面就是各种指针。把指针提取出来后可以再用image list、lldb里的memory read配合验证或者写一个小的Mach-O解析脚本去解析。手动抠数据虽然繁琐但能让你对段和节的位置关系产生肌肉记忆。3.4 MachOView、Hopper、jtool等图形化/脚本化工具怎么用命令行练完手实用工具会让你速度快很多MachOView免费图形化查看Mach-O段和节点击__RODATA.__objc_classro能直接看到结构体字段的解析适合新手建立直觉。Hopper / IDA反汇编时会把__RODATA里的数据识别成只读变量。Hopper里跳转到VM地址0x100008000附近可以看到class_ro_t字段的引用关系。jtool2Jonathan Levin的命令行神器。比如看__RODATA下所有sectionjtool2 -l test还可以直接dump一个sectionjtool2 -d __RODATA.__const testPython macholib想批量处理用macholib加载Mach-O文件遍历segments和sections。我之前写过一个脚本遍历一个app目录下所有二进制把__RODATA里所有__objc_classro的地址范围统计出来几秒钟就能扫一遍。这类自动化脚本在检查大规模逆向样本时非常省力。4. 深度剖析class_ro_t才是__RODATA的灵魂4.1 class_ro_t结构体逐字段拆解如果你是做ObjC逆向或者运行时机制研究class_ro_t是绕不开的。它以只读方式保存在__RODATA表达的是编译时就已经确定、运行期不会变化的类信息。逐字段看flags类的一些标志位比如RO_META表示元类RO_ROOT表示根类。调试时可以跟runtime源码里的枚举对照。instanceStart和instanceSize实例对象的内存布局参数。前者表示实例数据开始位置后者表示实例总大小。垃圾回收、object_getClass_size这类逻辑会依赖它。ivarLayout和weakIvarLayout描述强引用/弱引用ivar的内存布局供运行时在做内存管理时扫描对象内部的引用。name指向类名字符串的指针。逆向时最常看这个字段确认当前解析的是哪个类。baseMethodList指向该类在编译期不含分类定义的方法列表。方法列表里面有一堆method_t结构包含方法名、方法类型编码、函数实现地址。baseProtocols类遵循的正式协议。ivars成员变量列表。包含ivar的名字、类型编码和偏移。baseProperties属性列表。每个属性有名字、属性特性编码比如TNSString,C,N,V_name和对应的ivar、getter/setter。4.2 从class_ro_t到ObjC运行时baseMethodList、ivarLayout怎么串联class_ro_t并不是孤立存在的。运行时真正的类对象中class_rw_t会指向这个只读结构运行时在必要时还会把分类里的方法合并进method list。于是你就看到一条链路进程启动后dyld把__RODATA里的class_ro_t映射到只读内存运行时通过readonly指针引用它当有分类加载或动态添加方法时运行时在可写区域重建method list。所以分析一个ObjC类时你不能只盯着__RODATA里的baseMethodList还要同时看__DATA或__DATA_CONST里的class_rw_t相关结构。但如果你是静态分析只看class_ro_t已经能拿到这个类出生时的绝大部分信息。我个人做类接口导出时会把name字段打印出来把baseMethodList里的SEL和IMP对应关系拉出来再把baseProperties里的类型编码全部展开。这样产出的类接口清单比class-dump在某些场景下更可控因为你能完全掌控选择哪些字段。4.3 逆向场景怎样从__objc_classro恢复类接口信息假设你在Hopper里看到一个__RODATA.__objc_classro地址0x100008000想快速知道这个类有哪些方法。可以这样做读取name字段确认类名。找到baseMethodList指针跳到对应地址。方法列表头部有个entsize和count之后每个method_t大概三部分nameSEL字符串指针、types类型编码、imp实现函数地址。把imp地址记录下来跳到该地址就能反汇编方法实现。如果你用Hopper直接在class_ro_t的baseMethodList指针上回车跳转数据区里能看到方法名指针。再跳一次就到了方法名所在的只读字符串区整个过程非常直观。我记得有一次分析一个闭源SDKSDK核心类的方法名被刻意混淆了。照理说字符串常量也能看到但对方把方法名也搞成了动态拼接。最后还是靠__RODATA里class_ro_t的baseMethodList结构把SEL指针指到的那块内存内容按字节抓出来才还原出真实方法名。所以我一直强调class_ro_t是你绕过字符串混淆的好帮手因为SEL本身就是带指针引用的。5. 踩坑实录和__RODATA打交道时最容易翻车的几个地方5.1 地址(addr)和文件偏移(offset)混用导致解析错乱这个坑几乎所有看过Mach-O的人都踩过。otool -l里section会给两个关键值addr和offset。addr是加载到内存后的虚拟地址offset是数据在文件中的物理偏移。用dd抠数据时要用offset用调试器和Hopper时要用addr两者不能混用。之前有个同事写脚本提取__RODATA.__const拿addr当成文件偏移去读结果读出来的是一堆莫名其妙的数据还跟我争论说段数据被加密了。其实文件里那个位置根本不是他要的内容因为文件加载到内存时有段对齐和页对齐偏移和地址是两套坐标系统。5.2 写RODATA引起的经典崩溃只读页不是开玩笑__RODATA页在内存里是r--权限这就意味着任何写入都会引发page fault。如果你在调试时发现程序崩在某一个地址而这个地址正好落在__RODATA范围内请优先怀疑代码往只读区域写数据了。我遇到过一种典型情况代码里定义了一个全局数组本来是const但某个模块通过强制类型转换把const去掉然后在启动时往里写缓存数据。以前数组在__TEXT.devtools这种宽松环境可能不崩但链接器把数据挪到__RODATA之后一写就SIGSEGV。排查方法很简单崩溃时用image lookup --address看地址属于哪个段如果是__RODATA马上就知道是写保护问题而不是普通的内存越界。5.3 系统库里的__RODATA为什么找不到分析系统动态库时你可能会遇到明明类名找到了但跳转到class_ro_t地址时内容不对的情况。原因多半是dyld shared cache把系统库做了合并重定位单文件里的__RODATA地址在缓存中已经被改写过了。这时候别拿原始的系统库文件死磕应该用dyld_shared_cache_util或dyldinfo配合真机/模拟器上的运行时布局来分析。还有一种办法在lldb里直接对运行进程做image lookup -n MyClass让运行时告诉你这个类的真实地址。拿运行时结果反推静态数据往往比纯静态解析省力得多。5.4 arm64e指针签名的干扰arm64e设备的二进制里__RODATA中的很多指针会用指针签名PTRAuth保护。静态解析时你看到的指针并不一定是原始指针可能带有签名位或经过编码。用Hopper或者自写脚本直接解引用这些指针可能会得到错误地址。如果你碰到指针签名有几个思路一是用支持签名解析的工具链新版本IDA/Hopper已经开始支持二是在真机调试时直接让运行时帮你解引用三是分析时区分哪些字段是纯数据、哪些字段是签名指针不要一刀切。这块内容单独写能写一整篇这里就不展开了。5.5 常见问题速查表问题原因处理办法二进制里没有__RODATA段链接器版本较旧或目标架构没有启用新布局不用强求老布局的只读数据在__TEXT里class_ro_t解析字段对不上runtime版本不同字段顺序有调整对照对应版本的objc4源码不要硬套旧结构抠出来的__cstring乱码用了addr而不是offset检查文件偏移和内存地址的换算写const数组崩溃数据被放到了只读页修改代码去掉强制写或改用可写段存储dyld shared cache里地址跳转不对缓存重定位导致静态地址失效用运行时image lookup或解包shared cachearm64e指针解引用失败指针签名用支持PTRAuth工具或运行时辅助6. 我的实操心得与后续扩展思路跟__RODATA打交道这么久我最大的体会是别把它当成一个新东西害怕它本质上只是__TEXT里只读数据的新家。你以前在__TEXT.__const、__DATA.__objc_const里分析的那套方法搬到__RODATA之后依然适用只是地址变了、段名变了。新手最需要做的是养成习惯拿到二进制第一眼先看otool -l的段分布而不是立刻跳进反汇编。还有一件事我觉得挺值得做写一个自动化脚本定期扫描自己产出的App二进制看看__RODATA和__DATA_CONST的占比变化。数据段布局一旦异常变大往往意味着庞大量级的常量数据被编译进包里这在做包体优化时是一个敏锐的预警信号。我自己的项目里就有一个这样的巡检脚本每次CI出包后自动跑一遍效果很直接。如果你想在这块继续深入可以从三个方向扩展一是研究dyld shared cache里__RODATA的聚合策略这能帮你更懂启动性能二是深入学习class_ro_t与class_rw_t之间的合并机制这对ObjC运行时机制理解帮助极大三是用llvm-objdump或自己写解析脚本把__RODATA的section完整导出成JSON方便做批量分析和差异对比。Mach-O这东西越往底层看越有意思。