腾讯VATeam

MachO符号分析:如何查找一个符号的调用函数

导语:近期项目遇到一个启动crash,只在系统为iOS12及以下的iOS遇到,而iOS12以上的版本没问题,深入分析后结合Mach-O文件,汇编等对符号在Mach-O文件的存储有了新的认识,这里对分析过程和原理进行概述,如有错误欢迎指正。

1、 从一个crash说起

最近在项目内遇到一个启动crash,只有iOS12及以下的设备才会出现,由于启动就出问题,没有办法获取其他日志,从手机系统crash日志里得到crash内容如下:

Dyld Error Message:
Dyld Message: Symbol not found: _OBJC_CLASS_$_NSConstantIntegerNumber
  Referenced from: /private/var/containers/Bundle/Application/65AB3C6C-60F1-422F-AC50-E1A1AF260300/XXX.app/Frameworks/XXXMainProject.framework/XXXMainProject
  Expected in: dyld shared cache
 in /private/var/containers/Bundle/Application/65AB3C6C-60F1-422F-AC50-E1A1AF260300/XXX.app/Frameworks/XXXMainProject.framework/XXXMainProject
  Dyld Version: 390.7

从上面的报错来看,可以发现出问题的是动态加载XXXMainProject这个Framework在动态共享缓存里没有找到对应的符号_OBJC_CLASS_$_NSConstantIntegerNumber,笔者开始在代码里查找相关符号,不过在代码里发现并没有显示引用该类型的情况,无法显示地定位到问题原因。为了找到符号出现的位置,笔者开始针对XXXMainProject这个二进制文件去寻找一些端倪。

2、 符号分析

笔者下载了出问题对应的ipa包,通过ipa包文件里找到crash报错的XXXMainProject.framework/XXXMainProject文件,本质上是一个Macho-O(Mach Object file format)文件,即一种用于可执行文件、目标代码、共享库、动态加载代码和核心转储的文件格式。在后续的篇幅,笔者将通过依赖这个文件,借助nm、otool、010editor、MachOView等工具以及Mach-O文件的相关原理,为读者讲述如何找到使用符号_OBJC_CLASS_$_NSConstantIntegerNumber的函数。

2.1、 符号表分析

由于Mach-O文件本质上是个二进制数据,为了更好地借助Mach-O的信息查找符号的出现位置,首先,笔者开始搜寻符号 _OBJC_CLASS_$_NSConstantIntegerNumber 的在Macho-O文件内的地址,笔者借助nm工具,查看该Mach-O的符号,单独过滤该符号,发现确实存在 _OBJC_CLASS_$_NSConstantIntegerNumber ,且flag为U,标识该符号在当前文件中是未定义的,即该符号定义在别的文件中:

nm XXXMainProject | grep _OBJC_CLASS_$_NSConstantIntegerNumber
                 U _OBJC_CLASS_$_NSConstantIntegerNumber

通过查询nm的源码((感兴趣的可以研读参考资料[2]里nm.c文件中的select_symbols函数),笔者发现nm输出的符号都是在Mach-O文件里Symbol Table里的符号。

Symbol Table里的符号可以通过解析Mach-O文件里cmd=LC_SYMTAB(定义在参考资料[4]里的loader.h里的定义的Load Command)来解析,解析过程如图2.1所示:

(图2.1 symbol table解析示例)

解析后可以得到一个描述符号信息的symtab_command结构体。

struct symtab_command {
 uint32_t cmd;  /* LC_SYMTAB */
 uint32_t cmdsize; /* sizeof(struct symtab_command) */
 uint32_t symoff;   /* symbol table offset */
 uint32_t nsyms;  /* number of symbol table entries */
 uint32_t stroff;   /* string table offset */
 uint32_t strsize; /* string table size in bytes */
};

这里可以关注symtab_command的几个字段:

  • symoff :存储符号表的nlist列表的内存偏移
  • nsyms :一共存储了多少个符号信息。
  • stroff :存储符号表的字符串区域的内存偏移。
  • strsize :存储符号表的字符串的总内存大小(byte)。

使用以上的字段可以分别得到nlist列表和string table的存储在Mach-O的内存区域。根据nlist.h(参考资料[1])定义的nlist结构,以64位结构为例,使用nlist_64的n_strx字段在string table里可以查找对应的符号定义:

struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};

nlist_64的n_value字段可以表示该符号的地址,刚才说到nm工具已经在symbol table找到了该符号的定义,但是nm工具是没有打印符号地址n_value,从nm的输出信息的flag U来看,实际上这个符号的n_value值为0。

笔者这里借助MachoView对该符号的地址为0进一步确认,如图2.2所示,这个符号的n_value的值是0,和nm工具标识的一致,这个符号是属于外部符号,并没有对应的地址。

(图2.2 MachOView针对Symbol Table的解析搜索结果)

对于Symbol Table里可以找到符号的定义,但是没有办法获得对应地址的情况,笔者通过查询资料发现可以从以下两个地方去查询线索:

  • 间接符号表(Indirect Symbol Table) :存储了保存有指向Symbol Table的Index信息,每个Indirect Symbol也有一个对应的地址,Symbol Table没有定义的符号可能在这里可以出现。
  • 动态加载信息(Dynamic Load Info) :存储了一系列绑定信息(包括bind info/lazy bind info/weak bind info),一个符号可能对应有多个绑定信息(同一种类型不同的值),每一个绑定信息都有一个内存地址映射。

2.2、 间接符号表分析

间接符号表可以通过解析Mach-O文件的cmd=LC_DYSYMTAB的Load Command得到,如图2.3所示:

(图2.3 Indirect Symbol Table的解析示意)

解析可以得到一个描述动态符号的dysymtab_command结构体,这里笔者只截取部分本文使用到的字段:

struct dysymtab_command {
    uint32_t cmd;  /* LC_DYSYMTAB */
    uint32_t cmdsize;  /* sizeof(struct dysymtab_command) */
    ...
    uint32_t indirectsymoff; /* file offset to the indirect symbol table */
    uint32_t nindirectsyms;  /* number of indirect symbol table entries */
    ...
}

dysymtab_command的字段比较多,和间接符号表相关的是以下两个字段:

  • indirectsymoff :存储间接符号表的index数组的内存偏移。
  • nindirectsyms :存储间接符号表的index数组数量。

使用以上字段可以解析出nindirectsyms个存储了Symbol Table的index数组,通过该index可以在2.1的Symbol Table里找到对应的符号信息。

同时根据特定section信息可以解析出每个indirect symbol table对应的地址信息,这里具体过程不在本文重点就不再赘述了,感兴趣的读者可以参考loader.h(参考资料[3])关于indirectsymoff的注释和cctools(参考资料[4])的代码逻辑。

笔者借助otool工具对该Mach-O文件的间接符号输出到文本里,发现如图2.4的结果里也并未出现_OBJC_CLASS_$_NSConstantIntegerNumber的符号地址:

otool -I -v XXXMainProject > indirectXXXMainProject.txt

(图2.4 Indirect Symbol Table解析搜索结果) 从上面的结果来看, OBJC_CLASS $_NSConstantIntegerNumber这个符号出现在符号表里,从该文件的dysymtab_command结构来看,发现存在5584个Undefined Symbol,而只有indirectsymoff和nindirectsyms是能够解析出信息的。

(图2.5 LC_DYSYMTAB的Load Command信息)

既然间接符号表里没有找到,结合该符号使用nm打印的标识为U,笔者把目光放在动态加载信息(dynamic load info)上。

2.3、 动态加载信息分析

动态加载信息(dynamic load info)是在Mach-O文件里存储包含一系列绑定信息(包括bind info/lazy bind info/weak bind info)的结构。

可以通过cmd=LC_DYLD_INFO/LC_DYLD_INFO_ONLY的Load Command来解析出来dyld_info_command结构:

struct dyld_info_command {
  uint32_t   cmd;  /* LC_DYLD_INFO or LC_DYLD_INFO_ONLY */
  uint32_t   cmdsize;  /* sizeof(struct dyld_info_command) */
  
  uint32_t   rebase_off; /* file offset to rebase info  */
  uint32_t   rebase_size; /* size of rebase info   */

  uint32_t   bind_off; /* file offset to binding info   */
  uint32_t   bind_size; /* size of binding info  */
  
  uint32_t   weak_bind_off; /* file offset to weak binding info   */
  uint32_t   weak_bind_size;  /* size of weak binding info  */

  uint32_t   lazy_bind_off; /* file offset to lazy binding info */
  uint32_t   lazy_bind_size;  /* size of lazy binding infs */

  uint32_t   export_off; /* file offset to lazy binding info */
  uint32_t   export_size; /* size of lazy binding infs */
};

绑定信息存储在dyld_info_command结构里,主要是以下字段:

  • bind_off/weak_bind_off/lazy_bind_off :存储不同类型的绑定信息的内存偏移。
  • bind_size/weak_bind_size/lazy_bind_size :存储不同类型绑定信息的内存区域大小。

其内容都是以操作数(Opcodes)、立即数(immediate)以及采用uleb128/sleb128编码的偏移值组成,具体解析过程不在本文的介绍范围内,感兴趣的读者可以参考BinaryParser.tcc(参考资料[5])关于bind info的相关解析过程。

借助MachOView,如图2.6所示我们可以在dynamic load info的binding info信息里搜索_OBJC_CLASS_$_NSConstantIntegerNumber发现确实可以找到其符号的地址为 0xC7C2BF8 。

(图2.6 dynamic load info的搜索结果) 在有了这个地址 0xC7C2BF8 之后,笔者的目的是为了找出谁使用了这个符号,可以看到上面的符号是个16进制只有机器能理解的地址,为了使用机器理解的地址,笔者打算将这个Mach-O文件翻译成汇编代码,在汇编代码里寻找相关线索。

3、 符号查找

3.1、 汇编分析

这里将Mach-O文件翻译成汇编的工具有很多,由于这个Mach-O文件较大,翻译后的结果较多,笔者借助otool运行如下指令,将汇编结果输出到文件里方便查看:

otool -t XXXMainProject -v > disassemblyXXXMainproject.txt

最后得到一个具有37241574行的汇编文件,具有接近2个G,如果直接把每一行汇编都看完肯定是不可能完成的任务,为了有效利用信息,盲目查找肯定是不可取的。

通过上面符号分析的步骤,得到了_OBJC_CLASS_$_NSConstantIntegerNumber的地址0xC7C2BF8,笔者先尝试一下在汇编代码里搜索C7C2BF8。

结果发现这里并没有直接使用该符号地址的情况,那除了这种情况以外,是否有其他方式在使用该地址呢?

(图3.1 汇编代码针对C7C2BF8的搜索结果)

笔者注意到该Mach-O文件的Mach header可以得出其是ARM64架构的,其中对于使用的符号,是有可能使用ADRP指令先获取到页基础地址再做加法的方式获取对应符号地址的,ADRP得到的结果是低12位都为0的4k对齐后的内存基地址,而 0xC7C2BF8 的基地址则是 0xC7C2000 。

再次在汇编结果里搜索 C7C2000 ,我们发现 C7C2000 在汇编结果里共有4个搜索结果,结合上下文,我们可以得到以下2处有效的使用:

0000000000a075bc adrp x2, 48571 ; 0xc7c2000
0000000000a075c0 add x2, x2, #0xbf8
...
0000000000a0783c adrp x2, 48571 ; 0xc7c2000
0000000000a07840 add x2, x2, #0xbf8
...

可以发现整个Mach-O的汇编代码里共有2处位置使用了这个符号_OBJC_CLASS_$_NSConstantIntegerNumber的地址 0xC7C2BF8 ,假设笔者能够找到该段地址属于哪个函数,就能找到这个符号是在哪里使用的了。

3.2、 Function Starts分析

笔者把目光重新聚焦在Mach-O文件里,通过查阅相关资料以及loader.h(参考资料[3]),发现在Mach-O文件里,存在cmd=LC_FUNCTION_STARTS的Load Command,而Function Starts存储在结构体linkedit_data_command里:

struct linkedit_data_command {
    uint32_t cmd;  /* LC_CODE_SIGNATURE, LC_SEGMENT_SPLIT_INFO,
       LC_FUNCTION_STARTS, LC_DATA_IN_CODE,
       LC_DYLIB_CODE_SIGN_DRS,
       LC_LINKER_OPTIMIZATION_HINT,
       LC_DYLD_EXPORTS_TRIE, or
       LC_DYLD_CHAINED_FIXUPS. */

    uint32_t cmdsize; /* sizeof(struct linkedit_data_command) */
    uint32_t dataoff; /* file offset of data in __LINKEDIT segment */
    uint32_t datasize;  /* file size of data in __LINKEDIT segment  */
};

linkedit_data_command结构体是不仅仅是cmd=LC_FUNCTION_STARTS的Load Command的解析结果,也是如上面代码所示的cmd=LC_CODE_SIGNATURE/LC_SEGMENT_SPLIT_INFO等的Load Command的解析结果。

通过cmd=LC_FUNCTION_STARTS解析出来的linkedit_data_command结构体,主要在以下两个字段存储相关信息:

  • dataoff :存储函数地址数组的内存偏移。
  • datasize :存储函数地址数组的内存大小。

通过以上信息可以解析出一个逐渐递增的存储有函数起始地址的数组,数组内两个相邻的函数起始地址之间的区域,可以认为就是一个函数的地址范围。

结合3.1所获的的汇编代码,我们如果能找到 0xa075bc 和 0xa0783c 所在的函数区间,就可以知道调用他们对应的函数是什么。

借助MachOView,查看Function Starts的解析结果,如图3.2所示,笔者发现汇编代码 0xa075bc 在起始地址为 0xa07450 的函数内, 0xa0783c 在起始地址为 0xa07720 的函数内。

(图3.2 Function Starts函数区间特定结果)

综上我们已经能够找到_OBJC_CLASS_$_NSConstantIntegerNumber这个符号的调用函数的地址是0xa07450和0xa07720,但是MachOView并没有办法能够对这个Mach-O文件的函数起始地址翻译成对应的符号。仅仅只有这个地址信息肯定是不够的,如果能够将这个函数地址翻译成可读的具体符号,那这个crash问题就明确了。

3.3、 函数地址查找

回到章节2提到的符号表和间接符号表,笔者开始在Symbol Table和Indirect Symbol Table里查找是否有可能出现相关的地址,然而很遗憾的是这几个地址在这两个表里都没有找到。

笔者开始把考虑从二进制数据里去寻找线索,理论上如果要找到 0xa07450 和 0xa07720 这两个地址的符号,首先肯定是需要有人引用该地址,那二进制数据里肯定会出现相关的地址。

以 0xa07450 为例,通过借助软件010editor打开这个文件,在搜索栏输入 a0 74 50 ,虽然有几个搜索结果,但是结合上下文并不是单纯的 a0 74 50 ,而是某些地址的子串中包含 a0 74 50 。笔者开始怀疑自己,难道这个二进制里不存在该符号的引用?

(图3.3 二进制数据对a0 74 50的搜索结果)

笔者发现之前的分析忽略了一个很重要的信息,虽然MachOView解析出来的是可读的地址数据,但是二进制数据里是以小端模式存储的,所以刚才我们搜索的 a0 74 50 实际上是 5074a0 ,所以我们应该在二进制上搜索的是 50 74 a0 ,以这个思路再次在二进制数据里进行搜索,发现确实存在在地址 0x98C0DE8 中存储了 0xa07450 的信息。

(图3.4 二进制数据对50 74 a0的搜索结果)

再结合MachOView的工具,如图3.5所示,笔者发现发现0x98C0DE8所处的内存区域位于__DATA的__objc_const,也就是存放类的元数据,包括:method list、variable list、property list、class info的信息,考虑到项目是Objective-C的项目,假设某个Objective-C方法对应的Functions start就是0xa07450,而且这个Objective-C方法和0x98C0DE8这个地址是有关联的?如果这个猜想是对的话,那就可以找到使用_OBJC_CLASS_$_NSConstantIntegerNumber这个符号的函数了。

(图3.5 MachOView存储0x98C0DE8的位置)

3.4、 Objective-C符号查找

在Mach-O文件里,Objective-C的存储结构是按macho-obj.h(参考资料[6])里定义的结构存储的,对类和方法的描述和runtime实现类似,这里挑选后面会用到的部分,以64位为例:

// The class object in a 64-bit Mach-O file.
struct class64_t {
  uint64_t isa;        // class64_t * (64-bit pointer)
  uint64_t superclass; // class64_t * (64-bit pointer)
  uint64_t cache;      // Cache (64-bit pointer)
  uint64_t vtable;     // IMP * (64-bit pointer)
  uint64_t data;       // class_ro64_t * (64-bit pointer)
};

struct class_ro64_t {
  uint32_t flags;
  uint32_t instanceStart;
  uint32_t instanceSize;
  uint32_t reserved;
  uint64_t ivarLayout;     // const uint8_t * (64-bit pointer)
  uint64_t name;           // const char * (64-bit pointer)
  uint64_t baseMethods;    // const method_list_t * (64-bit pointer)
  uint64_t baseProtocols;  // const protocol_list_t * (64-bit pointer)
  uint64_t ivars;          // const ivar_list_t * (64-bit pointer)
  uint64_t weakIvarLayout; // const uint8_t * (64-bit pointer)
  uint64_t baseProperties; // const struct objc_property_list (64-bit pointer)
};


struct method_list64_t {
  uint32_t entsize;
  uint32_t count;
  /* struct method64_t first;  These structures follow inline */
};

struct method64_t {
  uint64_t name;  /* SEL (64-bit pointer) */
  uint64_t types; /* const char * (64-bit pointer) */
  uint64_t imp;   /* IMP (64-bit pointer) */
};

对于含有Objective-C符号的Mach-O文件,一般情况下会有存储__objc_classlist的section,该section可以解析得到一个存储有Objective-C的类的地址列表,如图3.6所示:

(图3.6 Objective-C Class在Mach-O文件的映射过程) 每一个地址可以映射到一个 class64_t 的内存区域,每个类的类名以及实例方法和类方法的映射方式简要概括如下。
  • 对于类名:直接使用 class64_t 的 name 字段,其存储指向 __objc_classname 内的字符串的地址,即可找到对应类的定义。
  • 对于实例方法:
    • 首先可以通过 class64_t 的 data 字段可以映射到一个类的readonly信息 class_ro64_t 结构体。
    • 再通过 class_ro64_t 结构题里的 baseMethods 字段可以映射到表示方法列表的 method_list64_t 的结构体。 method_list64_t 结构体的 entsize 表示每个方法的内存大小, count 表示该类的实例方法数量。
    • 紧挨着存储有 method_list64_t 的结构体的内存区域后面的,就是存储有该类所有的实例方法的内存区域,单个实例方法使用 method64_t 的结构体存储, method64_t 结构体的name存储指向 __objc_methodname 内的字符串的地址,而 imp 存储函数的实现地址,会映射到上文提到的Function Starts内的地址。
  • 对于类方法,通过 class64_t 的 isa 字段会指向一个 class64_t 结构的其自身的 meta class ,紧接着再类似实例方法的映射规则,能够解析出类方法的符号和函数地址。

由3.3得知在二进制里存在地址 0x98C0DE8 存储了调用函数 0xa07450 ,假设有一个 method64_t 的 imp 地址为 0x98C0DE8 ,我们再找到该 method64_t 的 name 地址指向的字符串,就能找到这个_OBJC_CLASS_$_NSConstantIntegerNumber的调用函数了。

基于此笔者借助MachOView的解析结果在方法列表里查询可以发现符号函数起始地址是 0xa07450 的函数是"pixelImage:withMaskImage:isSave:",且和之前的猜想一致,存储在0x98C0DE8内。同理对于 0xa07720 函数也可以采用类似的方式查找到其符号为"pixelImage:withMaskImage"。

(图3.7 0xa07450在MachO文件的函数符号) 通过查看该函数的类结构,如图,可以得出该函数的类为TBFilter,而且由于都是在 meta class 里解析出来的method,证明这两个方法都是类方法,也即在该二进制文件里,调用_OBJC_CLASS_$_NSConstantIntegerNumber的函数为:
+[TBFilter pixelImage:withMaskImage:isSave:]
+[TBFilter pixelImage:withMaskImage]

(图3.8 pixelImage:withMaskImage:isSave:所对应的类名)

为了了解为什么这两个函数会引用到该符号,笔者查找了这两个函数的代码,发现其都有一段这个代码是和符号描述的Constant Integer Number有关系的:

[pixelFilter setValue:@(32) forKey:@"inputScale"];

不过为什么这一行代码会导致这个crash呢?如果是@(32)出现了这个问题,那应该类似的Constant Integer Number在项目内不止这两个函数使用才对。

4、 crash分析:

笔者开始搜索关于_OBJC_CLASS_$_NSConstantIntegerNumber符号的相关资料,得到以下的结论:

  • 在iOS13以上的系统里,确实是有NSConstantIntegerNumber的定义,会把常量的integer number使用该类型存储,定义可以参考NSConstantIntegerNumber.h([参考资料[7]]):
#import <Foundation/Foundation-Structs.h>
#import <Foundation/NSNumber.h>

@interface NSConstantIntegerNumber : NSNumber {

const char* _encoding;
long long _value;
}
...
  • 而如果切换到iOS12的版本,是不存在NSConstantIntegerNumber的定义的,可以参考:https://developer.limneos.net/index.php?ios=12.1&framework=Foundation.framework&header=NSConstantIntegerNumber.h
(图4.1 iOS12对NSConstantIntegerNumber.h文件的查找结果)

但是本项目的APP支持的最低iOS版本是iOS9.0,理论上是不会遇到这个问题的,不过为了业务拆分,实际上存在了多个子工程,如果出现了这个符号,是不是证明子工程的Build Setting配置不是基于deployment target为9.0的编译的。

笔者将找到出现问题的函数所对应的子工程,通过查看该工程的Build Setting,发现其Deployment Target是14.5:

(图4.2 submodule工程的Deployment Target截图)

该子工程在iOS14.5的编译目标下,会将@(32)的常量转换为 _OBJC_CLASS_$_NSConstantIntegerNumber 这一iOS13以上系统才存在的符号进行存储,而该工程的其他代码也没有类似的引用常量整形的方式,所以整个二进制文件里使用了 _OBJC_CLASS_$_NSConstantIntegerNumber 只有上面两个函数。

所以出现这个crash的原因就是可以概括为类似如图4.1的情况,有一个叫Submodule E的子工程Deployment Target是非9.0(例如使用iOS14.5)的情况进行编译,然后被只支持iOS9.0以上的主工程依赖引起的:

(图4.3 submodule deployment target 异常的图)

5、 结论

本文笔者从一个crash作为引子,为大家介绍了存在Mach-O文件的情况下,如果想要找到某个符号(例如不一定在代码直接引用,但是会存储在Mach-O文件符号表的符号)的使用位置,要如何基于这个Mach-O文件的信息来查找到可能使用到该符号的函数。也希望本篇文章能为读者带来一些帮助,后续遇到类似问题提供一个分析思路。



参考资料

[1]:https://opensource.apple.com/source/xnu/xnu-4570.71.2/EXTERNAL_HEADERS/mach-o/nlist.h.auto.html

[2]:https://github.com/opensource-apple/cctools/blob/master/misc/nm.c

[3]:https://opensource.apple.com/source/xnu/xnu-4570.71.2/EXTERNAL_HEADERS/mach-o/loader.h.auto.html

[4]:https://github.com/opensource-apple/cctools

[5]:https://github.com/lief-project/LIEF/blob/e04645d3c738d6fbd92ade9582f31be54384c06c/src/MachO/BinaryParser.tcc

[6]:https://opensource.apple.com/source/clang/clang-800.0.38/src/lib/ObjCMetadata/macho-obj.h.auto.html

[7]:https://developer.limneos.net/?ios=13.1.3&framework=Foundation.framework&header=NSConstantIntegerNumber.h

[8]:https://github.com/aidansteele/osx-abi-macho-file-format-reference

[9]:https://www.desgard.com/iOS-Source-Probe/C/mach-o/Mach-O%20%E6%96%87%E4%BB%B6%E6%A0%BC%E5%BC%8F%E6%8E%A2%E7%B4%A2.html

[10]:https://www.jianshu.com/p/08c0078c512b