之前拆Mach-O二进制的时候,我一向有个习惯:看到一个字符串就想翻段找家。小字符串翻__TEXT,__cstring,方法名翻__objc_methname,全局变量名就找__DATA。但有一次在符号表里查一个报错文案的出处,otool的section name显示它不属于__cstring,而是落在__RODATA段里。那一下我才意识到,对__RODATA这个段我一直是眼熟,从来没认真拆过它。这篇就把我后来对__RODATA做的完整梳理写出来,包括它从哪里来、里面到底装了什么、和__TEXT、__DATA怎么划边界,以及实际逆向和性能分析里能直接用上的几个技巧。
1. __RODATA的基本盘:为什么会有这个段
先说结论:__RODATA是一个“可读但不可写、也不可执行”的数据段。它在Mach-O的可执行文件里出现的频率很高,macOS和iOS的系统库、常规App的二进制里基本都有它的身影,只是一般没什么人专门盯它。
Mach-O的布局不像ELF那样由一个一个section平铺,而是先分成几个segment,每个segment再挂一堆section。我们常见的有__TEXT、__DATA、__LINKEDIT,每个segment都有独立的页权限。__TEXT是代码段,可读可执行,但不可写;__DATA是数据段,可读可写,但不可执行;__LINKEDIT是链接器元数据,通常只读。而__RODATA夹在中间,权限是只读,不执行。
那问题来了:既然__TEXT已经可读可执行了,为什么要把一部分数据单独拿出来放在__RODATA里?这里有个历史演变。早期工具链里,很多只读常量直接放在__TEXT段内,比如__TEXT,__const。问题是__TEXT页面上既有指令又有数据,权限必须同时包含R和X,这类页既不利于安全性,也浪费内存。后来在较新的系统工具链版本里,链接器把大量的只读数据统一归拢到独立的__RODATA段,让代码页尽量只装指令,数据页尽量不带有执行权限,这对页表管理和Code Sign都更友好。
用一个生活化的类比来解释:__TEXT像厨房里的掌勺师傅,他需要看菜谱(指令),偶尔也要看食材(只读数据)。老厨房把菜谱和食材堆在同一张桌子上,桌面必须允许“触摸+阅读”;新厨房则给食材加了一个“只许看不许动”的冷藏抽屉,这就是__RODATA。
想确认一个二进制里有没有这个段,直接看Mach-O的Load Command即可:
otool -l /bin/ls | grep -A 5 "segname __RODATA"输出里你会看到类似这样的结构:
Segname __RODATA vmaddr 0x000000000001d000 vmsize 0x0000000000003000 fileoff 0x000000000001d000 filesize 0x0000000000003000 maxprot 0x00000005 initprot 0x00000001 flags 0x00000000initprot 0x1表示初始保护只有读权限,maxprot 0x5表示最高可以到读加执行,但初始状态只给了读。这说明dyld在加载时不打算把这块数据变成可写。
这里顺便提一个容易忽略的点:__RODATA在文件里的内容和内存里的内容基本是直接映射的。链接器在设计时会做“Preferred File Data”(PFD)布局,让文件内偏移和虚拟地址偏移保持一致,这样dyld加载时可以用文件页直接映射,不需要把数据重新拷贝到新页再做修正。这也是为什么我们分析__RODATA时,用otool看到的文件内容基本就等于运行时内存里的内容,很好跟踪。
2. 打开冰箱看抽屉:__RODATA里常见的section
__RODATA不是一个大仓库,里面有若干个各自独立的section,每个section装一类东西。这就好比冰箱里分了“冷藏抽屉”“标签架”“便签贴”。下面是我的实际观察里最常见的几个:
| Section名 | 典型内容 | 判断方法 |
|---|---|---|
__const | 编译器生成的只读常量、大数组、vtable指针、位图 | 数据量大,排列整齐 |
__cstring | 只读的C字符串字面量 | otool -v能直接读出字符串 |
__objc_methname | Objective-C运行时用到的全部方法名 | 全是方法选择器的UTF-8字符串 |
__swift5_types、__swift5_capture等 | Swift的类型描述符、闭包捕获信息 | 结构紧凑,通常是二进制序列化数据 |
2.1 __const:只读常量的大本营
__const是__RODATA里最常见也最容易被忽略的section。它不一定存你C代码里写的const int = 42那种单值,更多时候存的是编译器生成的大块数据:全局常量数组、跳转表、虚函数表指针、布尔掩码位图等。这份数据通常没有明显的字符串特征,用strings扫的时候经常被忽略,但恰恰是关键逻辑所在。
比如一个全局的权限配置表,写成:
static const struct rule rules[] = { { .type = 1, .allow = 0 }, { .type = 2, .allow = 1 }, };在Mach-O里大概率落在__RODATA,__const。分析时不能光看字符串,得按结构体长度切片去猜。想导出它的原始字节,可以用:
otool -s __RODATA __const /bin/ls输出是十六进制,一行一个地址加16个字节。如果嫌可读性差,可以用Python脚本把hex转成二进制流再解析:
import subprocess out = subprocess.check_output(['otool', '-s', '__RODATA', '__const', '/bin/ls']) lines = out.decode().splitlines() data = b'' for line in lines: if ':' not in line: continue addr, _, hexpart = line.partition('\t') try: data += bytes.fromhex(hexpart.strip()) except ValueError: continue print(len(data), 'bytes exported')这个脚本的坑是otool不同版本输出格式可能有细微差异,关键是抓住“地址: 十六进制”那一行。导出的数据可以继续交给radare2或Hopper做结构体重建。
2.2 __cstring:字符串字面量的家
__RODATA,__cstring是普通C字符串字面量存的地方。你用char *s = "hello"写的字符串,编译之后大概率会落到这里。这个section最好认,因为数据本身是可打印字符串,直接看就行:
otool -v -s __RODATA __cstring /usr/bin/env | head -20不加-v时是十六进制,加上-v后otool会把可打印字符直接显示出来,调试时非常直观。注意这和__TEXT,__cstring不是一回事。老版本二进制里字符串可能留在__TEXT,新版本的则归到__RODATA。分析时别只搜一个名字。
2.3 __objc_methname:藏在只读段里的“方法名字典”
如果你分析的是Objective-C或混编的App,__RODATA,__objc_methname大概率会占很大一块。它保存的是整个二进制里所有用到的Objective-C方法名,比如viewDidLoad、tableView:cellForRowAtIndexPath:,全部以UTF-8字符串形式集中放在这里。
为什么放只读段?因为Objective-C的SEL选择器本质上就是一个指向方法名字符串的指针。运行时比较两个方法是否相同,不是去逐个字符比较,而是直接比较SEL的指针地址。这就要求方法名的字符串地址必须是全局唯一且稳定的,所以编译器和链接器干脆把它们统一放到一个只读section里集中管理,既保证地址稳定,也方便去重。
我拆App时有个小技巧:如果想知道某个方法名在实际二进制里的引用情况,直接在__objc_methname里搜到字符串,然后在Hopper里看谁引用了这个地址,基本就能定位到方法调用点或实现入口。这个方法比在__TEXT里全文搜索指令要高效很多。
2.4 Swift描述符与其它家族成员
Swift编译出的二进制里,__RODATA还会挂一串__swift5_开头的section。这些不像__cstring那么直观,它们是给Swift运行时用的类型元数据、捕获描述、协议描述等,属于一套紧凑编码的二进制结构。普通逆向任务不用逐字段解析,但有一个用途很值得说:分析Swift闭包的捕获列表时,__swift5_capture能告诉你闭包捕获了哪些变量、这些变量的类型是什么,这对还原闭包内部逻辑帮助很大。
3. 编译与链接的搬迁工程:数据是怎么进到__RODATA的
我们习惯说“某个变量落在__RODATA”,其实这个落点不是单个编译动作决定的,而是编译和链接两个阶段共同决定的。
先看编译阶段。写一个简单的C文件:
const char *g_hello = "hello"; int global_counter = 42;用clang编出目标文件:
clang -c test.c -o test.o otool -l test.o | grep -B 2 -A 3 "sectname"你会在test.o里看到__cstring、__data等section,但可能看不到成型的__RODATA段。也就是说,目标文件阶段section还比较零散,真正的搬家和归类发生在最后链接时:链接器ld会把所有目标文件里匹配的只读section按照最终二进制的段权限规划合并到一起,形成完整的__RODATA。
所以有种常见情况是:你在.o里看到字符串在__TEXT,__cstring,但最终可执行文件里它跑到了__RODATA,__cstring。这不是bug,而是链接器做了重排。理解了这层,你分析大型Xcode工程时就不会被“目标文件里的段”和“最终二进制的段”不一致搞糊涂。
链接器在合并时还会做字符串池化。什么意思?不同源文件里如果写了相同的字符串字面量,比如两个地方都用"OK",链接器会把它们合并成同一个字符串,让后面的引用都指向同一个地址。这个优化在__RODATA,__cstring和__objc_methname里尤其明显。
这也是逆向时一个常见的坑:你以为两个引用点指向同一个字符串是因为业务逻辑相关,其实只是链接器做了去重。分析时必须结合引用上下文判断,不能看到两个x20寄存器都加载同一个地址就认为它们是一路货色。
4. 边界判定:__TEXT、__DATA、__RODATA之间的“户口”归属
新手最容易在__TEXT、__DATA和__RODATA之间迷路。其实判断逻辑就一句话:数据在运行时会不会被写?会写的进__DATA,不会写且不是代码的就进__RODATA,是代码或纯执行逻辑才归__TEXT。可以按下表去对号入座:
| 数据类型 | 归属 |
|---|---|
| 可执行指令 | __TEXT,__text |
| 只读常量、大块const数据 | __RODATA,__const |
| 只读字符串字面量 | __RODATA,__cstring |
| Objective-C方法名 | __RODATA,__objc_methname |
| 可写的全局变量(已初始化) | __DATA,__data |
| 可写的全局变量(零初始化) | __DATA,__bss |
| 绑定符号指针、全局偏移表 | __DATA相关section |
| 符号表、代码签名等链接元数据 | __LINKEDIT |
举个很典型的C例子来加深印象:
const char *p = "hello"; // p 在 __DATA,字符串在 __RODATA,__cstring const char * const q = "world"; // q 在 __RODATA,__const,字符串也在 __RODATA,__cstringp本身是一个指针变量,运行时可以被改成指向别的字符串,所以它必须待在可写的__DATA,__data里;它指向的"hello"是字符串字面量,没人会去改它,于是放只读的__RODATA,__cstring。而q多了const限定,指针本身也是只读的,就落到__RODATA,__const了。
这个区分在实际分析里非常有用。比如你在日志里看到一段可疑信息,先判断这段字符串在哪个段:
- 落在
__RODATA,__cstring,说明它是编译期常量,任何地方都可能引用。 - 落在
__DATA,说明它可能在运行时被修改过、拼接而成,逻辑线索要往代码运行时去找。 - 落在
__TEXT,__text,那大概率不是普通字符串数据,而是指令里的立即数编码,得按指令反汇编来看。
还有一个让人迷糊的地方是__LINKEDIT。它也只读,但它存放的是链结元数据,比如符号表、字符串表、Code Signature。它跟__RODATA的分工可以理解为:__RODATA装的是程序逻辑需要的正经数据,__LINKEDIT装的是给系统加载器和调试器看的“档案”。分析业务逻辑时不用翻__LINKEDIT,但做符号还原时就离不开它。
5. 实际操练:几条命令快速定位和导出__RODATA
工具层面,我把平时最常用的几条命令整理一下,都是macOS自带的otool和nm就能搞定。
首先看段结构。想确认整个__RODATA段的区间,用:
otool -l /path/to/binary | grep -A 4 "segname __RODATA"想在段里看某个section的名字和范围,把-A参数后的关键字换成sectname即可:
otool -l /path/to/binary | grep -A 4 "sectname __const"如果只想快速dump字符串,用:
otool -v -s __RODATA __cstring /path/to/binary如果想结合文件偏移做字符串定位,用strings带-t x参数:
strings -t x /path/to/binary | grep "需要查找的内容"得到文件偏移后,用前面的段区间表对比,能判断它到底在哪个段里。举例:你搜到一个方法名-startEngine,偏移是0x2c374,再查__RODATA,__objc_methname的section区间是0x2c000-0x2c900,那么你可以确定这个字符串属于方法名字段,接着就可以去反汇编视图里查看谁引用了这个地址。
想进一步看某个section里非字符串的数据,我推荐的流程是:
- 用
otool -s __RODATA __const binary > const.hex导出hex文本。 - 用上一节那个Python脚本转成二进制。
- 把二进制文件丢进
010 Editor或hexdump -C里查看。
有一个实操经验值得专门说:扫描__RODATA找硬编码机密信息时,别一上来就搜整个文件。正确做法是先确定__RODATA的文件偏移区间,再用dd把这一整段裁出来:
dd if=/path/to/binary of=/tmp/rodata.bin bs=1 skip=0x1d000 count=0x3000然后对着rodata.bin跑strings和entropy检测。只读段里的高熵数据块往往是加密密钥或压缩数据,低熵的可打印字符串往往是配置文案。用这种方式过滤,比全局搜索高效得多。
6. 优化、签名与分析时容易踩的坑
最后把我在实际工作中踩过的、或者见过别人踩的坑集中说一下,每条背后都有真实案例。
6.1 直接改__RODATA会触发代码签名失效
这是最经典的坑。Mach-O的Code Signature会覆盖大部分segment的文件内容,__RODATA在签名覆盖范围内。你用十六进制编辑器把__RODATA里的某个字符串改了,系统启动时校验签名就会失败,App直接闪退或拒绝启动。
所以逆向修改时,如果你要改的是__DATA里的可变指针,绕开签名校验的思路还相对可行;但你要是直接改__RODATA里的只读字符串,基本等于把签名撕了。常规做法是改完字节后对整个二进制重新签名,需要相应的证书或签名工具,这件事成本不低。我的建议是:分析阶段先只读不改,等确定改哪儿了再一次性做完整修改加重签。
6.2 __RODATA的只读不是静态的,有一个“可写窗口”
__RODATA的只读是运行时的结果,不是天生的。dyld加载Mach-O时,会先完成rebase和bind,这两步需要修改某些内存区域,然后再把不该写的段统一mprotect成只读。也就是说,在加载早期,__RODATA所在的内存页可能短暂处于可写状态。
这个知识点在安全研究和调试hook实现时很重要:如果你做了很早期的注入或钩子,看到的__RODATA状态可能与最终快照完全不同。分析时要对齐“数据捕获时机”,否则你dump出来的常量可能是绑定前的原始链结信息,和运行时实际值对不上。
6.3 自定义section的偏移陷阱
链接器支持用-sectcreate __RODATA __foo config.bin把任意文件嵌进__RODATA段。很多团队拿这个做版本号注入或资源打包。这个功能的坑在于:__RODATA段本身要求页对齐,而config.bin的长度往往达不到一页。结果就是这个section的实际size会比文件自身大,填充了额外的0x00字节。解析时如果直接用section的size去文件里读,容易越界读到其它内容。正确做法是先用otool -l看这个section的offset和size,再根据这两个字段裁剪,不要依赖源文件的长度。
6.4 字符串池化会让“同一地址”骗到你
前面提过链接器去重。实际操作中,你搜一个版权字符串、一个错误提示文案,可能发现好几个完全不相关的函数都引用同一个地址。这时别急着推导调用关系,先确认是不是池化合并后的假象。一个稳妥的办法是,把引用的指令上下文都反汇编出来对比,只看数据地址很容易跑偏。
6.5 用dladdr快速判断代码属于哪个段
调试和崩溃日志分析时,我经常用dladdr来归因,它能根据一个函数指针返回所在镜像和符号信息。配合段区间做判断,可以快速知道一个地址落在__TEXT还是__RODATA:
#include <dlfcn.h> Dl_info info; if (dladdr((void *)some_ptr, &info)) { // info.dli_fbase 是镜像基址 // 通过 otool -l 获得 __RODATA 相对基址的偏移后 // 即可判断 some_ptr 是否落在 __RODATA 区间 }这个技巧在处理崩溃日志里的可疑指针时特别有用:如果指针落在__RODATA,说明是纯只读常量被误当函数指针使用;如果落在堆区,那就是运行时对象被提前释放,问题性质完全不一样。
我现在分析一个新的Mach-O文件时,会把字符串按段分组列成索引,先看它落在__RODATA还是__DATA,再决定往编译期逻辑方向查还是往运行时逻辑方向查。这个习惯帮我省了大量时间。__RODATA这个段看着不起眼,但它是理解一个二进制“哪些东西是生下来就定死的、哪些是运行时才变的”的最佳入口。下次你用otool翻到一个陌生二进制时,记得先打开这个冰箱看看里面的抽屉。