news 2026/9/24 22:52:33

深入解析Mach-O中的__objc_protorefs:Objective-C协议引用的运行时机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析Mach-O中的__objc_protorefs:Objective-C协议引用的运行时机制

前阵子分析一个老版本App的二进制,顺手把__DATA段里的节一个个过了一遍。扫到__objc_protorefs的时候,我愣了一下——这个节我以前见过不少次,但从来没细究过它到底在忙什么。MachOView里它看起来就是一行行指针,数量还不多,和隔壁的__objc_classlist、__objc_selrefs一比,存在感非常低。可当我真正去追这些指针的去向时,才发现它连接着dyld、ObjC runtime和iOS逆向三个方向的很多细节。

这篇内容就专门聊__objc_protorefs:它放在Mach-O的哪个位置、里面装的到底是什么、编译器怎么生成它、runtime加载时怎么处理它,以及我们做逆向和崩溃排查时能怎么利用它。不管你是刚接触Mach-O格式,还是已经能熟练在IDA里翻找类信息,我都建议花几分钟把这节弄明白,因为很多ObjC相关的问题,最终都会绕回到“定义”和“引用”这两个朴素概念上,而__objc_protorefs就是ObjC协议引用关系的最好标本。

1. 在Mach-O全景图里定位__objc_protorefs

1.1 一个常被跳过但职责明确的小节

Mach-O文件的基本组织方式,是“段(segment)+ 节(section)”。段负责划定内存区域的读写属性和对齐方式,节则把同一类用途的数据归拢到一起。__TEXT段放代码和只读常量,__DATA段放可读写的运行时数据,后来苹果又拆出__DATA_CONST来放那些“原则上只读、但runtime启动时需要微调”的数据。

__objc_protorefs的官方定位,就是这样一个专门用来收集协议引用的节。传统构建下它出现在__DATA段,新版Xcode工具链也可能把它放到__DATA_CONST段。名字里的“protorefs”是“protocol references”的缩写,不要和“protocol definitions”搞混。

这个节存在的唯一目的,是把当前Mach-O镜像里所有对Objective-C协议对象的引用集中起来,交给runtime统一修正。你可以把它理解成一个协议引用的登记表:代码里凡是用了@protocol(SomeProtocol)、类声明遵循了某个协议、分类遵循了某个协议,这些地方对协议对象的指针引用,最终都会被收集到这个节里。

它为什么会存在?直接原因很朴素:协议符号不像普通函数符号那样,链接时绑一次就完事。协议对象在runtime里要保证“同名协议只有一个合法身份”,同时还要处理弱链接、外部动态库、动态创建等复杂场景,所以需要对所有协议引用做一轮统一修补。与其在整个__DATA段里大海捞针,不如编译器把这些引用集中摆好,让runtime一次性处理。

1.2 和__objc_protolist、__objc_classrefs的分工

很多初学者会把__objc_protorefs和另一个名字很像的节弄混:__objc_protolist。这两个词就差一个“refs”,含义却是天壤之别。

Section名称典型Segment存储内容一句话职责
__objc_protolist__DATAprotocol_t 对象本身协议定义清单,协议对象的“出生地”
__objc_protorefs__DATA / __DATA_CONSTprotocol_t 指针协议引用清单,指向协议对象的指针集合
__objc_classlist__DATAclass_t 对象本身类定义清单
__objc_classrefs__DATA / __DATA_CONSTclass_t 指针类引用清单,指向类对象的指针集合
__objc_superrefs__DATA / __DATA_CONST消息接收者指针super调用时的目标引用
__objc_selrefs__DATASEL 指针selector引用清单
__objc_catlist__DATAcategory_t 对象本身分类定义清单
__objc_imageinfo__DATA标志位结构体镜像级别元信息

用生活化的方式理解:__objc_protolist是出生登记处,__objc_protorefs是寻人启事栏。一个协议先要在protolist里“落户”,成为runtime公认的协议对象,它的引用才会被romorefs收编。类也有类似的对应关系:__objc_classlist存类定义,__objc_classrefs存类引用。所以只要你理解了classlist和classrefs的区别,protolist和protorefs的区别就顺理成章了。

这个“引用型”节的设计思路,在整个Mach-O里其实反复出现。C++里有类似的重定位段,Swift里也有一堆间接指针表。本质原因都一样:间接层是运行时灵活性的基础。没有这层间接,编译器就得在编译期把协议地址写死,后面任何动态处理都无从谈起。

2. 数据结构拆解:一趟指针引用到底装着什么

2.1 每一条Entry是什么

在64位Mach-O下,__objc_protorefs每一项占8字节,也就是一个指针宽度。这个指针指向的内容,是_OBJC_PROTOCOL_$_协议名这个符号所在的位置。

这个_OBJC_PROTOCOL_$_前缀是编译器约定的命名规则。无论协议定义在哪个镜像里,最终都会有一个对应的_OBJC_PROTOCOL_$_符号。如果协议定义在同一个镜像内部,这个指针就是镜像内的一个相对地址,加载后通过rebase修正成绝对地址;如果协议定义在系统库或其他动态库里,这个指针就是绑定符号,加载时通过dyld bind机制解析成远方地址。

节的总大小除以8,就是这个镜像里协议引用的条数。举个例子,如果你用otool -l看到__objc_protorefs的size是0x48,那就是72字节,也就是9个协议引用条目。

有一个容易绕晕的点,我当年也卡了很久:这个条目究竟是“协议对象的地址”,还是“一个需要被改写成协议对象地址的坑位”?从文件形态看,它就是普通的指针数据,初始值就是协议对象的地址。从runtime语义看,它又是会被统一检查、可能被替换的“引用占位”。正确理解是:它首先是数据,数据里存的是指针值,而runtime会在加载时遍历这些数据,确保每个指针值都指向当前进程里规范的那个协议对象,否则就把值覆盖成规范对象的地址。所以它既是地址,也是被修正的对象,一体两面。

2.2 protocol_t结构体长什么样

protorefs里的指针,最终指向的对象是protocol_t。这个结构体在objc4源码的objc-runtime-new.h里可以找到,我挑最核心的字段简化说明:

struct protocol_t : objc_object { // isa等objc_object通用头部 const char *mangledName; // 协议名称 struct protocol_list_t *protocols; // 继承的父协议列表 method_list_t *instanceMethods; // 必需实例方法 method_list_t *classMethods; // 必需类方法 method_list_t *optionalInstanceMethods; // 可选实例方法 method_list_t *optionalClassMethods; // 可选类方法 // properties、size、flags等 };

注意,protocol_t本身也是一个objc_object,也就是说它有一个isa指针。这意味着协议对象和类对象一样,能在运行时被持有、查询、引用计数管理。这也是为什么协议能作为参数传给objc_respondsToSelector相关的API,能在conformsToProtocol:里被比较。

当你顺着protorefs里的指针跳到目标地址,看到的是这样一个协议对象,而不是一串普普通通的数据。在IDA或Hopper里跟过去,你能清晰看到协议名、方法列表、父协议列表,这些都是逆向分析时的重要线索。

2.3 为什么不能少掉这张引用表

有人可能会问:既然__objc_protocol_list里已经有协议定义了,其他结构直接引用它不就行了?为什么还要专门搞一个protorefs?

答案是:没有protorefs,runtime没法高效且正确地完成“协议引用归一化”。

先聊效率问题。一个Mach-O镜像的__DATA段可能有几百KB甚至几MB,里面混着类结构、字符串、方法缓存、各种指针。如果runtime想修正协议引用,没有节表指示的话,它只能遍历整个可写段,逐个指针去判断“这个地址看起来像是协议对象吗”。这种扫描既慢又容易误判。

再聊一致性问题。同一个名字的正式协议,在多个镜像里可能出现多次。比如你链接了一个第三方库,里面自带一份和系统同名的NSXXXProtocol;或者Swift和ObjC混编时,某些协议在多个编译单元里都有定义。runtime在做协议注册时,会尽量保证“同名协议只保留一个规范对象”。如果各个使用方手里的协议指针各自指向自己的那份定义,那么==比较会失败,conformsToProtocol:也可能产生诡异行为。protorefs就是给runtime提供一个“需要统一重定向的名单”,没有它,这个归一化无从下手。

3. 从源码到二进制:编译器如何生成__objc_protorefs

3.1 触发条件:哪些源码会制造引用

不是任何代码都会往protorefs里塞条目,只有真正涉及协议引用的地方才会。以Clang/LLVM的CodeGen逻辑为例,下面几种情况会在编译单元里生成对_OBJC_PROTOCOL_$_XXX的引用:

  • 类声明遵循协议:@interface MyClass : NSObject <MyProtocol>
  • 分类声明遵循协议:@interface MyClass (CategoryName) <MyProtocol>
  • 直接用@protocol(MyProtocol)取协议对象并赋给变量或传给函数
  • 通过Protocol *p = @protocol(XXX);这种形式把协议当作值传递
  • Swift类声明@objc并遵循了ObjC协议(Swift侧编译也会生成对这个协议符号的引用)

这里有一个小知识点:编译器在生成类或分类的protocol列表时,会构造一个protocol_list_t结构,这两个结构里放的是指向_OBJC_PROTOCOL_$_的指针。而同时,这些协议指针引用会被登记进__objc_protorefs。可以理解为:使用点负责“具体使用”,protorefs负责“统一登记”,两者协同完成协议引用的修正。

3.2 链接器侧的处理与合并

进入链接阶段后,ld会把所有编译单元生成的__objc_protorefs节合并到一起。链接器不会真正理解这些指针的语义,它只把它们当作普通指针数据来处理:该rebased的rebased,该bind的bind,该对应的chained fixup就进fixup链。你在最终可执行文件里看到的__objc_protorefs,是各个编译单元条目的汇总结果。

这里有一个实际经验:链接器开启-dead_strip后,会检查这些协议引用是否还被其他存活结构引用。如果一个protocol_list_t本身因为类被剥离而不存在了,那么绑定在它身上的protorefs条目也可能被一起剥掉。反过来,只要有一个存活对象还引用协议,protorefs里的对应条目就会保留。所以protorefs的大小,其实也反映了这个镜像“实际存活的协议引用数量”。

3.3 弱链接与重复定义的特殊局面

协议是可以被标记为弱链接的,用Objective-C的__attribute__((weak_import))。弱链接的含义是:如果提供这个协议的库在运行时不存在,这个协议引用可以被静默置空,而不会导致dyld bind失败。

这种场景下,protorefs的价值就凸显出来了。runtime拿到弱链接协议的引用时,会发现指针为NULL或指向一个占位对象,于是会跳过修正,后续使用方通过conformsToProtocol:判断时自然得到失败结果,App不会因此崩溃。

重复定义则是另一个层面。两个镜像都定义了_OBJC_PROTOCOL_$_MyProtocol,runtime怎么处理?我读过一定量的objc4源码,用一句话概括:对协议的登记,runtime遵循“同名协议尽可能只有一个”。新加载的镜像如果发现表里已有同名协议,一般会保留先前的,或根据版本信息做替换。无论哪种策略,protorefs都是最终统一所有“散落引用”的关键入口——这些引用原本可能指向各自镜像内的不同协议对象,经过runtime修正后才被掰到同一个对象上。

4. Runtime加载协议引用的完整流程

4.1 dyld做了第一轮修正

Mach-O被加载进内存后,dyld的工作首先是映射,然后是修正。传统流程分rebase和bind两步:rebase负责把镜像内部基于偏移的指针修正成真实加载地址;bind负责把指向外部动态库的符号引用解析成真实地址。新版系统逐步启用chained fixups后,信息被压缩到了每页Fixup链里,但做的事情没有本质变化。

对于__objc_protorefs里的条目,dyld只做了“第一轮修正”,也就是把指针值从占位状态变成可用的地址。但第一轮修正过后的协议对象,不一定就是runtime最终认定的那个规范协议对象。真正的“归一化重定向”发生在后面。

需要提醒一点:如果构建时有chained fixups,你直接从文件里dump protorefs可能看到的是带fixup标志的编码数据,而不是可读地址。这种情况我会在第五章专门讲。

4.2 _read_images里的三步走

dyld修正完基本指针后,检测到这个镜像是ObjC镜像,会调用runtime的初始化入口,最终进入_read_images流程。这个流程对协议的处理顺序非常经典,我按步骤拆开:

第一步,读取__objc_protolist。runtime会遍历这个节,把里面的每个protocol_t对象注册到全局协议表(Protocol Table)中,并按协议名建立索引。这一步完成了“协议定义”的登记。

第二步,读取__objc_protorefs。runtime遍历这个节里的每一个指针,对每个协议引用做“重映射”:检查该指针指向的协议对象,是否与协议表中同名协议的规范对象一致;如果不一致,就把protorefs里的指针值改成规范对象的地址。这个过程就是我前面反复提到的“归一化”。

第三步,才去处理__objc_classlist、__objc_catlist等结构。因为类和分类里都包含了协议列表,如果先处理这些结构再处理协议引用,那么类里的协议指针可能还没来得及被归一化,导致后续行为不一致。

这个先后顺序,我从objc4源码里反复确认过,是刻意安排的。理解这个顺序对排查某些诡异的“协议明明存在但conformsToProtocol返回NO”问题很有帮助——有时候问题就出在协议引用的重定向没有生效。

4.3 protocol唯一化与引用修正的时机

proto唯一化(Protocol Uniquing)是runtime设计里一个重要原则。同一个协议名的正式协议,在一个进程里应该只有一个规范对象。这带来了几个连锁设计:

  • 协议定义注册时,如果发现同名冲突,runtime要有取舍策略。不同版本实现略有差异,但整体方向是保留一个“当前认为正确”的对象。
  • 所有外部引用(protorefs)必须在协议定义注册完成后、类结构处理开始前,统一修正到规范对象上。
  • 协议继承关系也在这个阶段理顺。protocol_t里的protocols指针会指向父协¬议的列表,runtime要保证父协议对象的指针同样归一化。

实际调试时,你会在崩溃堆栈里偶尔看到_read_imagesfixupProtocolReferences这类符号,这就是在修正protorefs的过程中出错了。常见的原因包括:协议所在动态库加载顺序异常、两个镜像同时声明同名协议导致状态不一致、或者镜像文件被篡改导致节内容损坏。

5. 动手解剖:从Mach-O里把协议引用倒出来

5.1 otool和llvm-objdump命令行定位

如果你手上刚好有个可执行文件或动态库,最快的办法是用otool直接看节信息:

otool -l YourBinary | grep -A5 __objc_protorefs

输出里能看到segname、sectname、addr、size、offset这些关键字段。注意size要换成十进制除以8,就是协议引用条数。

然后直接dump节内容:

otool -s __DATA __objc_protorefs YourBinary

如果这个节在__DATA_CONST段,要把上面命令里的__DATA换成__DATA_CONST。不要死记它在哪个段,因为不同Xcode版本、不同优化开关下结果会不一样,必须以otool -l输出为准。

otool输出的是裸地址,可读性一般。想看得更直观,可以上llvm-objdump:

llvm-objdump --macho --section=__DATA,__objc_protorefs YourBinary

较新版本的LLVM会对节内指针做符号化,直接显示出指向_OBJC_PROTOCOL_$_XXX的符号名称,排查效率高很多。

5.2 用Python脚本批量提取协议引用

命令行工具适合单次查看,想成批量分析时,我习惯用Python配合lief库。这个库能解析Mach-O格式,一行就能拿到节内容。这里给一个可用的演示脚本:

import lief def dump_protorefs(path): fat = lief.parse(path) binaries = [fat] if not hasattr(fat, 'binaries') else fat.binaries for b in binaries: for section in b.sections: if section.name == '__objc_protorefs': print(f'[{b.name}] {section.name}: {section.size} bytes') data = bytes(section.content) for i in range(0, len(data), 8): val = int.from_bytes(data[i:i+8], 'little') print(f' +0x{i:04x} -> 0x{val:016x}') dump_protorefs('YourBinary')

脚本输出的是裸地址,要转成协议名,可以把地址和_OBJC_PROTOCOL_$_符号做匹配。如果你只是想快速知道一个镜像“引用了哪些协议”,用nm -m YourBinary | grep "_OBJC_PROTOCOL_$_"会更直接,但注意这会混入定义和引用两类符号,需要自己区分。protorefs里只会有引用,语义更纯粹。

5.3 在IDA/Hopper里高效浏览这个节

逆向工具里看protorefs最考验手感。我自己的习惯是三步走:

第一步,在segments或sections视图里找到__objc_protorefs,按X查看交叉引用,看哪些结构在引用这个节。

第二步,跳转到节内某个条目,指针指向哪里就跟着走,通常会看到一个_OBJC_PROTOCOL_$_XXX标签。再往下偏移,能看到protocol_t的字段:协议名、父协议列表、方法列表。协议名通常直接以C字符串存在,搜_OBJC_PROTOCOL_$_字符串能快速建立“地址到协议名”的映射。

第三步,反过来用。很多协议方法名是全局唯一的字符串,你在__objc_methname里搜到某个方法名后,找到谁在protorefs里引用了对应协议,就能判断某段逻辑是否依赖某个特定协议。这是一种从“行为证据”反推“模块能力”的逆向思路,非常实用。

举个例子,我在分析一个第三方SDK时,发现它的protorefs里只有几个条目,其中就有_OBJC_PROTOCOL_$_MFMessageComposeViewControllerDelegate。这一个协议引用基本就能断定,这个SDK在申请以短信方式分享内容的能力。在整个逆向过程中,我可能还没找到具体调用点,就已经锁定了它的核心功能方向。

6. 实战中的坑:__objc_protorefs相关的排查经验

6.1 别和__objc_protocol_list搞混

这是最容易踩的坑。很多刚开始接触Mach-O的同事,看到protorefs就以为是协议定义,结果在统计一个镜像“定义了多少个协议”时,把引用的数量也算了进去,数据翻了好几倍。

我的建议是:先看一下节的大小。定义有完整结构体,通常较大;引用只是8字节一个指针,通常较小。再对照名字:protolist是list,protorefs是references。遇到otool -l输出时,两个节前后挨着,第一眼确认segname和sectname,再动手统计。

6.2 找不到符号?先想动态协议

有几次我编译完一个App,用nm_OBJC_PROTOCOL_$_相关符号,发现某个协议根本搜不到,但代码里明明用了@protocol(MyProtocol)。排查后发现,这个协议是用objc_allocateProtocolobjc_registerProtocol动态创建的,压根不存在于Mach-O的protorefs里。动态创建的协议对象,只存在于运行时,任何静态分析工具都看不到它的定义,也看不到protorefs条目。

所以逆向时,如果某处conformsToProtocol:respondsToSelector:的行为在静态二进制里找不到依据,脑子里要立刻闪过“这是动态协议”这个可能性,别在同一份静态数据里死磕。

6.3 strip、App Store与这个节的生死

另一个常见认知误区是“strip会删掉协议”。实际上,strip主要影响符号表,而__objc_protorefs是节数据,里面有实际的内存内容,并不是符号表条目。常规的strip -x或App Store的裁剪,不会删除节本身,协议引用依然存在。真正会删掉它是dead_strip,而dead_strip移除的是“未被引用的数据”,不是简单按符号来了结。

有一点要提醒:开启chained fixups的新构建里,直接从文件里读到的protorefs内容可能不是最终地址。因为文件里存储的是fixup链上的编码值,要经过解码才能还原成“应该指向哪个协议”。遇到这种情况,别急着怀疑数据错了,先在lldb里跑起来,用memory read看运行时的节内容,那才是修正后的真实内存。也可以用dyld_info工具输出fixup信息辅助分析。

最后再分享一个我个人的懒人技巧:逆向新目标的时候,我会先顺手把__objc_protorefs导出来,列一个“协议引用清单”,再和已知的类名、方法名交叉比对。很多时候,一个小小协议引用指向的系统能力路径,比读懂一整个类还要快得多。读Mach-O也好,做逆向也罢,最大的乐趣往往不在于那些显眼的类定义,而在于这种藏在间接层里的小结构——它看起来只有几行指针,背后却是一整套链接、加载、运行时协作的逻辑。这篇把__objc_protorefs从段落到运行时到实战都过了一遍,希望能帮你下次看到它时,不再只是扫一眼就跳过。

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

深度度量学习实战:Python实现蛋白质二级结构预测

简介&#xff1a;这份源码包面向生物信息学与深度学习方向的毕业设计学生及软件工程实践者&#xff0c;提供用Python实现深度度量学习预测蛋白质二级结构的完整方案&#xff0c;解决氨基酸序列到α螺旋、β折叠、β转角等局部构象的建模与评估问题。包内共39个文件&#xff0c;…

作者头像 李华
网站建设 2026/9/24 22:50:48

遥感图像目标检测实战:旋转框、小目标与DOTA数据集

简介&#xff1a;本资源为国际算法算例大赛中遥感图像物体目标检测赛题的完整实现方案&#xff0c;面向计算机、人工智能、遥感信息科学等专业学生及初阶算法工程师&#xff0c;解决遥感影像中小尺度目标&#xff08;如车辆、建筑、船舶&#xff09;的精准定位与识别问题。压缩…

作者头像 李华
网站建设 2026/9/24 22:49:32

Windows生产力操作系统:四层工具链构建AI就绪工作流

1. 这不是一份“软件清单”&#xff0c;而是一套 Windows 生产力操作系统方案你有没有过这种体验&#xff1a;重装一次系统&#xff0c;光是找齐自己顺手的工具就花掉大半天&#xff1f;下载、安装、配置、授权、更新……最后发现某个小工具其实早被替代了&#xff0c;或者根本…

作者头像 李华
网站建设 2026/9/24 22:48:47

电脑格式化清除所有数据:从原理到实操的完整指南

1. 格式化到底在做什么&#xff1a;从需求到方案的全景拆解很多人第一次接触“格式化”这个词&#xff0c;都是在电脑变卡、准备转手、或者系统彻底崩溃的时候。表面上看&#xff0c;格式化就是“把东西删干净”&#xff0c;但实际操作里&#xff0c;它牵扯到分区结构、文件系统…

作者头像 李华
网站建设 2026/9/24 22:47:57

Python TCP入侵检测系统实战:从Scapy抓包到iptables自动封禁

简介&#xff1a;基于Python实现的TCP入侵检测系统&#xff0c;面向网络安全方向的毕业设计、课程设计与项目开发者。系统重点解决端口扫描与Dos攻击的实时检测问题&#xff0c;能够联动iptables完成自动防御&#xff1b;评判逻辑综合TCP请求频率、SYN/FIN/NULL标志位比例、未开…

作者头像 李华
网站建设 2026/9/24 22:47:53

Ghost扇区级备份原理与C盘D盘全搬实战指南

1. 项目概述&#xff1a;为什么今天还要谈Ghost——一个被低估的“系统快照”老将“ghost备份还原系统&#xff08;C盘D盘全搬&#xff09;”&#xff0c;这行字看起来像从2008年的网吧机箱贴纸上撕下来的。但如果你刚重装完Win11&#xff0c;发现VS Code缓存占了12GB、PyCharm…

作者头像 李华