当前位置: 首页 > 科技观察

汽车之家APP基于Mach-O的探索与实践_1

时间:2023-03-18 14:15:10 科技观察

Mach-O背景介绍:Mach-O文件全称是MachObject,是MacOS、iOS、iPadOS上的可执行文件,类似于PEWindows上的文件。支持的CPU架构类型主要有x86_64、armv7、arm64。Mach-O文件生成流程:源代码-->预处理-->词法分析-->语法分析-->语义分析-->中间代码-->生成目标代码-->汇编-->机器码-->静态链接-->Mach-O文件Mach-O能做什么?了解Mach-O格式的结构和加载过程,可以帮助我们更容易理解APP启动过程的原理,C函数的hook,以及动态库的懒加载。常见的应用场景包括:① Crash符号化②Bitcode分析 APP启动速度优化④ app包大小优化⑤ 方法调用链分析接下来,本文针对第五个应用场景介绍两个工作中使用的实际项目。1、基于Mach-O文件的动态库和静态库的归属方案。2、基于Mach-O的API扫描方案。由于APPStore上已经基本放弃了对armv7的支持,所以接下来介绍的方案都是基于arm64架构的Mach-O分析。实践项目一:基于Mach-O文件的动态库和静态库归属方案背景:大多数APP都会包含多个动态库和静态库,家庭APP也是一样,随着业务的增长,集成的越来越多功能越来越多,静态库和动态库的数量也越来越多。为提升用户体验,家APP采集了多个维度的数据,如:网络、崩溃、卡顿、秒开、画面表现等,以及如何将采集到的数据准确、快速分发给研发人员用于解决方案,一直是我们面临的难题。基于Mach-O结构和Runtime的原理,我们通过不断的实践,实现了一套自定义归因方案。该方案可以将性能数据分库,然后通过库归属地找到开发者,从而解决分发问题。下面给出该方案的详细描述。首先,归属划分主要涉及动态库和静态库两种场景:(1)运行时创建的对象属于哪个库(按类找库)。(2)公共库的某个方法调用了哪个库(通过栈找库)。因为动态库本身是代码隔离的,非常方便查找,而静态库最终会被编译成主程序的二进制文件,或者多个静态库打包成一个动态库,所以不可能直接进行类和栈的归属划分。接下来本段先介绍动态库的归属方式,然后重点介绍静态库的归属方式。动态库:类定位:先用对象找isa指针获取类,再用类找到所在的可执行文件;NSBundle*bundle=[NSBundlebundleForClass:objClass];栈定位:获取的栈可以直接区分所属动态库,如图:NSArray*callAddresses=[NSThreadcallStackReturnAddresses];longlongcallStackAddress=[callAddresses[i]longLongValue];Dl_infoinfo={0};dladdr((void*)callStackAddress,&info);//获取栈地址对应的可执行文件信息NSString*dliFname=[NSStringstringWithFormat:@"%s",info.dli_fname];//取出库名静态库:名词解释:Mac服务器:用于编译APP和dSYM符号解析,以下简称Mac服务器。日志服务器:记录在线APP上报的性能数据,以下简称日志服务器。ASLR:全称Addressspcelayoutrandomization,地址空间布局随机化,通过对堆、栈、共享库等关键数据区的地址空间进行随机化处理,防止攻击者直接定位代码位置来篡改程序。这种技术会让APP或者动态库在每次运行时加载到内存中的基地址是随机的。静态库归属的常见解决方案是基于dSYM的符号解析,主要过程:1、打包时将所有库的dSYM文件存储在Mac服务器上。2、在线APP上报执行文件名、发布版本、偏移量等信息。3、日志服务器收到APP上报的信息,通过版本号和可执行文件名查找缓存的dSYM,最后使用偏移量解析Mac服务器上的dSYM符号,返回静态库名.该方案需要在Mac服务器上缓存所有动态库的所有版本的dSYM,增加了服务器的存储成本,并且由于需要三端交互才能完成静态库的归属,稳定性较差,而且同时运行在APP上无法直接定位,可读性不高。针对解析dSYM符号的问题,我们通过分析Mach-O的结构和原理,探索出一种基于Mach-O的静态库归属方案,具体如下:通过解析Mach-O和LinkMap文件生成静态库编译类的地址范围和汇编代码段的地址范围在运行时根据isa指针(指向Mach-O中的类声明地址)和代码偏移地址来解析静态库名。首先分析Mach-O文件的结构,从Mach-O的头部开始,Header包含二进制文件的大小,支持的CPU类型,LoadCommands的个数和大小,Segment包括sections和符号表等。偏移位置和大小。Mach-O的结构比较复杂。已知的Segment类型有50多种,不同的类型有不同的职责。该方案只使用了代码段和类列表,所以只解析了__text和__objc_classlist。解析Mach-O头部,关键代码如下:mach_header_64mhHeader;//读取头部信息[fileDatagetBytes:&mhHeaderrange:NSMakeRange(0,sizeof(mach_header_64))];for(inti=0;icmd==LC_SEGMENT_64){segment_command_64segmentCommand;[fileDatagetBytes:&segmentCommandrange:NSMakeRange(currentLcLocation,sizeof(segment_command_64))];NSString*segName=[NSStringstringWithFormat:@"%s",segmentCommand.segname];//提取汇编代码__TEXTif([segNameisEqualToString:SEGMENT_TEXT]||[segNameisEqualToString:SEGMENT_BD_TEXT]){section_64sectionHeader;[文件数据getBytes:§ionHeader范围:NSMakeRange(currentSecLocation,sizeof(section_64))];NSString*secName=[[NSStringalloc]initWithUTF8String:sectionHeader.sectname];}elseif([segNameisEqualToString:SEGMENT_DATA]){//提取指定DATAsectionHeader信息unsignedlonglongcurrentSecLocation=currentLcLocation+sizeof(segment_command_64);}//符号表}elseif(cmd->cmd==LC_SYMTAB){symtab_commandtsymtabcommand;[fileDatagetBytes:&tsymtabcommandrange:NSMakeRange(currentLcLocation,sizeof(segment_command_64))];symtab命令=tsymtab命令;}elseif(cmd->cmd==LC_FUNCTION_STARTS){[fileDatagetBytes:&funcStartHeaderrange:NSMakeRange(currentLcLocation,sizeof(linkedit_data_command))];}}如果为每个类都标注库名,生成的ClassMap文件会过大,对包大小影响很大。通过分析APP编译过程,发现静态库是顺序编译的。每个section下的静态库也是分段的,所以静态库的位置标记是通过计算每个静态库的偏移地址和大小来最终生成的。静态库类的标记:解析Mach-O的ClassList部分,借助LinkMap文件反向解析各个静态库的类声明位置,找到第一个类的声明地址作为起始地址,找到声明最后一个类的地址+类声明的字节大小就是类声明的结束地址。静态库代码段标记:原理类似于查找类声明。结合__TEXT、SymbolTable和FunctionStarts,找到静态库第一个类的第一个方法的起始地址作为该库代码段的起始地址,找到静态库最后一个方法的结束地址库的最后一个类,作为静态库代码段的结束地址。通过以上两步生成ClassMap和TextMap并导入到ipa中,文件不到1kb,对APP大小几乎没有影响。生成脚本的位置应该在LinkBinaryWithLibraries之后和CopybundleResources之前。前面介绍了编译时的工作,下面介绍运行时的定位原理。1.运行时获取对象对应的isa指针,找到类,然后类指针的地址减去加载到内存中的动态库的起始地址,计算出对应的偏移量(上面提到的ASLR会让APP每次运行)库的内存基址发生变化,所以需要计算偏移地址),然后根据偏移量在ClassMap中找到对应的静态库名。NSString*imageName=[[NSStringalloc]initWithUTF8String:_dyld_get_image_name(i)];//查找APPmainbinary我);break;}uintptr_tos=(uintptr_t)objClass;//计算类的偏移地址=classMap[键];if(addressDic&&[addressDic[@"end"]longLongValue]>classInBundleAddress&&classInBundleAddress>=[addressDic[@"start"]longLongValue]){返回键;}}2.Stack-based定位静态库。先获取无符号栈数组,然后找到上次调用的回调地址,获取栈地址所在的动态库起始地址,使用栈地址-动态库起始地址得到偏移量,并然后使用偏移量到TextMap中找到对应的静态库名。NSBundle*mainBundle=[NSBundlemainBundle];dl_infoinfo={0};//获取栈地址对应的插件信息dladdr((void*)callStackAddress,&info);//获取二进制名NSString*dliFname=[NSStringstringWithFormat:@"%s",info.dli_fname];//获取偏移量uintptr_tcallStackBundleAddress=callStackAddress-[selfmainStartAddress];NSDictionary*textMap=[selfpluginAllAddressWithText];for(NSString*keyintextMap.allKeys){NSDictionary*addressDic=textMap[key];//查找所属的静态库if([addressDic[@"end"]longLongValue]>callStackBundleAddress&&callStackBundleAddress>=[addressDic[@"start"]longLongValue]){返回键;}}这样一个静态库的归属可以在运行时完成,耗时不到1ms,可以广泛应用于各种APP场景。总结:介绍了基于Mach-O文件的动态库和静态库归属方案。在静态库归属方面,相比之前的dSYM方案,复杂度更低,易用性更强,运行时可以实时解析,更容易迁移到其他APP使用。实践项目二:基于Mach-O的API扫描背景:在实际工作中,由于经常需要对APP中调用的API进行定位,为了减少重复性工作,我们实现了一套自动扫描工具。API扫描的常见解决方案是基于语法树的扫描。代码编译时会生成语法树,通过遍历语法树可以实现API扫描。由于语法树扫描存在以下不足,不能满足使用要求。1.不支持黑盒扫描,只能在编译时生成语法树,无法扫描第三方SDK。2、扫描速度太慢。语法树扫描功能强大,但扫描性能较低。在优化条件下扫描20,000行代码树大约需要1分钟。为了解决以上两个问题,我们实现了基于Mach-O的API扫描方案。实践项目1中介绍的Mach-O结构,本段将继续使用__objc_classrefs:类引用列表。__objc_selrefs:方法引用列表。Mach-O分析的主要步骤:1.首先分析__objc_classrefs,读取所有调用的外部API,将扫描到的API的类地址取出来记录为class_addr。2.解析__objc_selrefs,获取扫描到的API的方法地址为method_addr。3.将二进制机器码解析为汇编代码,找到所有的bl指令,计算离bl指令最近的x1寄存器的值,比较x1寄存器是否等于method_addr,如果是则记录bl指令的位置平等的。4、在bl指令的位置向上查找,看是否有你要找的类地址class_addr,一直查找到最近的函数入口,如果找到,输出结果,如果没有找到,转步骤5.5.查找调用类的起始地址和结束地址,在扫描的API类中查找起始地址和结束地址的所有汇编指令是否存在,如果存在则输出结果。API扫描流程图反汇编代码:vm:(unsignedlonglong)vm{mach_header_64mhHeader;//解析header[fileDatagetBytes:&mhHeaderrange:NSMakeRange(0,sizeof(mach_header_64))];//获取汇编代码移位地址和大小的偏置char*ot_sect=(char*)[fileDatabytes]+begin;uint64_tot_addr=vm+开始;cshcs_handle=0;cs_insn*cs_insn=NULL;cs_errcserr;if((cserr=cs_open(CS_ARCH_ARM64,CS_MODE_ARM,&cs_handle))!=CS_ERR_OK){NSLog(@"初始化失败:%d,%s.",cserr,cs_strerror(cs_errno(cs_handle)));返回空值;}//设置解析模式cs_option(cs_handle,CS_OPT_DETAIL,CS_OPT_ON);cs_option(cs_handle,CS_OPT_SKIPDATA,CS_OPT_ON);//反汇编size_tdisasm_count=cs_disasm(cs_handle,(constuint8_t*)ot_sect,size,ot_addr,0,&cs_insn);if(disasm_count<1){NSLog("汇编指令解析不符合预期!");返回空值;}returncs_insn;}检索方法范围://检索方法偏移范围do{@autoreleasepool{unsignedlonglongindex=(end-textList.addr)/4;char*dataStr=s_cs_insn[index].mnemonic;//查找是否为函数跳转指令,记录方法的起止地址if(strcmp(dataStr,"b")==0||strcmp(dataStr,"ret")==0){unsignedlong长nextSymoble=结束+4;MethodHelper*nextMethodHelper=[objectSymbolMapobjectForKey:[NSNumbernumberWithUnsignedLong:nextSymoble]];if(nextMethodHelper&&![nextMethodHelper.classNameisEqualToString:className]{foundclassName])}}end+=4;}}while(end<=textList.addr+textList.size);找到objc_msgSend调用位置,检索调用外部方法名是根据runtime的原则。调用objc_msgSend时,x1寄存器是存储的方法地址,所以主要是查找bl指令之前的x1寄存器信息。扫描过程中发现调用objc_msgSend时的寄存器有时会被ldr指令计算出来,这涉及到汇编中查找高低地址。汇编指令会将地址拆分为高位和低位,所以查找时要注意x1寄存器值的计算过程。如上图,需要计算x8寄存器+低位地址0x908,然后比较检测到的API地址是否等于x1寄存器,最后查找检测到的API的类,和如果匹配则输出结果。总结:至此,基于Mach-O的API扫描介绍就结束了。与传统的语法树扫描相比,Mach-O的扫描方式本质上是与代码分离的。可扫描任意动态库和静态库,扫描过程采用多线程分段扫描提速,2秒内完成3万行代码编译产品扫描,速度提升20倍以上比语法树扫描。语言方面,除了支持OC方法的扫描外,还支持Swift和C方法的扫描。原理和OC扫描类似,这里不再详细介绍。总结与展望:以上是基于Mach-O结构的动态库和静态库归属以及API扫描方案,已经应用于生产环境,帮助团队降低成本,提高效率。未来,我们将在以下几个方面继续探索和实践:1.扩大归属检索的范围,如类别、块、常量、C方法等2.扫描API使用规范,生成简单调用基于Mach-O的关系图查看API的规范,扫描冲突的分类方式,提前发现隐藏的bug。3.安全:防止反编译和动态注入。参考文档:Mach-O可执行格式概述(apple.com)简介(apple.com)