ARTICLE DETAIL

资讯详情

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

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

深入解析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_protorefs1.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答案是没有protorefsruntime没法高效且正确地完成“协议引用归一化”。先聊效率问题。一个Mach-O镜像的__DATA段可能有几百KB甚至几MB里面混着类结构、字符串、方法缓存、各种指针。如果runtime想修正协议引用没有节表指示的话它只能遍历整个可写段逐个指针去判断“这个地址看起来像是协议对象吗”。这种扫描既慢又容易误判。再聊一致性问题。同一个名字的正式协议在多个镜像里可能出现多次。比如你链接了一个第三方库里面自带一份和系统同名的NSXXXProtocol或者Swift和ObjC混编时某些协议在多个编译单元里都有定义。runtime在做协议注册时会尽量保证“同名协议只保留一个规范对象”。如果各个使用方手里的协议指针各自指向自己的那份定义那么比较会失败conformsToProtocol:也可能产生诡异行为。protorefs就是给runtime提供一个“需要统一重定向的名单”没有它这个归一化无从下手。3. 从源码到二进制编译器如何生成__objc_protorefs3.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_$_MyProtocolruntime怎么处理我读过一定量的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_images、fixupProtocolReferences这类符号这就是在修正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-objdumpllvm-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:i8], 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是listprotorefs是references。遇到otool -l输出时两个节前后挨着第一眼确认segname和sectname再动手统计。6.2 找不到符号先想动态协议有几次我编译完一个App用nm查_OBJC_PROTOCOL_$_相关符号发现某个协议根本搜不到但代码里明明用了protocol(MyProtocol)。排查后发现这个协议是用objc_allocateProtocol和objc_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从段落到运行时到实战都过了一遍希望能帮你下次看到它时不再只是扫一眼就跳过。
返回列表