做过Android开发的应该或多或少都知道“混淆”这个技术点,它不仅可以帮助我们增加三方逆向的难度,还可以有效减少包体积,瘦身APK。
其实这些能力都来自于Proguard这个程序,Proguard能利用字典文件,在编译时将我们的类名,方法名,字段名都替换掉,最后生成一份非常反人类的编译产物。Proguard在每次运行时都会创建一个mapping.txt文件,其中列出了经过混淆处理的类、方法和字段名称与原始名称的映射关系。此映射文件还包含用于将行号映射回原始源文件行号的信息。
以下就是Mapping文件的生成过程
这个Mapping文件是由Proguard程序自动生成的,会存放在output目录下,与release包放在一起。需要谨记的是,Mapping文件可能在每次Proguard运行后都会不同,所以发布给用户的包一定要留存好Mapping文件,方便后续跟踪解决问题
了解Mapping文件最直观的好处在于我们跟踪线上的经过混淆之后的Crash信息时,可以从Mapping文件逆向推出原始的堆栈信息,更快更方便的定位问题,但不只这些,我们还可以通过Mapping文件处理内存快照文件Hprof的反混淆,处理Systrace的文件的反混淆,还有Nanoscope文件的反混淆等
注意:Android在新版中启用了R8编译器,没有使用Proguard工具,虽然兼容Proguard的配置和字典等,但是编译出来的Mapping文件格式还是有一点不同。我们会在最后一个小节讲一下其中的不同
下面我们详细来看Mapping文件的格式
classlinefieldline*methodline*Mapping文件的正式部分由多个Class块组成,每个Class块中包含混淆前后的类信息,字段信息,方法信息。每个Class块由顶格的类信息开头,后边跟着开头带有4个空格的字段信息与方法信息每个Class块中详细格式如下:
类信息:
originalclassname->obfuscatedclassname:混淆之前的全限定类名与混淆后的全限定类名通过->分隔符分割,以:标识当前类信息的结束,标识类内字段,方法信息的开始
备注:全限定类名,是指带有包名限定的类名,可以完全定位一个类
字段信息:
originalfieldtypeoriginalfieldname->obfuscatedfieldname混淆之前的字段信息与混淆之后的字段信息同样通过->分隔符分割,值得注意的是,混淆前的字段包含了字段类型和字段名称,而混淆之后只有字段名称
方法信息:
[startline:endline:]originalreturntype[originalclassname.]originalmethodname(originalargumenttype,...)[:originalstartline[:originalendline]]->obfuscatedmethodname备注:标识着*的行,意味着可能出现任意多次;[]表示内容是可选的;…表示可能会出现任意多个前边指定的item;:与,与->都是分隔符
方法信息同样通过->分隔符分割,但是方法信息比类信息和字段信息更复杂一点,因为方法还额外包含了行号表,参数,返回类型等信息
剩下的行号信息,稍微复杂一点要根据方法有没有做内联优化分成两种情况:
有一些要注意的点:
如果多个方法或者字段的签名(或者说描述符)不同,那么混淆之后的名称可能是相同的
方法nextLine和lineLocationDescription都有自己的源码行号范围,但是返回类型和参数列表是相同的,如果在混淆的配置文件中配置保留了LineNumberTable,那么在报错堆栈中就可以看到行号,也就可以通过行号定位到具体的方法,而如果没有在混淆的配置文件中配置保留LineNumberTable,那么报错堆栈中也就不会打印出行号,仅仅通过返回类型和参数列表是无法区分二者的,所以这就是为什么这两个方法的混淆之后的名称是不同的
以上的示例比较简单,我们来看一下复杂的示例
剩下的方法都比较奇怪,开头的行号都是特别大的数字,且有几个方法行号是相同的,明显不是正常的行号,这是因为经过了方法内联处理,在混淆处理的过程中,可能会内联方法到其他方法中,甚至进行递归的内联
简单来说,就是将互相调用的多个方法合并为一个方法,这样减少程序方法调用的次数,从而减少程序调用过程中的栈帧的创建销毁等额外的消耗,提升性能例如
classA:defa():print("a")B.b()classB:defb():print("fromB")print("b")做方法内联优化之后:
classA:defa():print("a")print("fromB")print("b")//innerlinefromB()了解了方法内联之后,我们再来看方法内联对混淆的影响,方法内联之后,堆栈中原来B.b()方法已经被内联到A.a()方法中,混淆之后的方法信息也自然指向了A.a(),那么堆栈中出现的错误信息也是指向A.a(),但是我们源码中的调用是来自方法B.b()的,所以内联前后的优化信息我们是需要知道的,方便在后续堆栈信息追踪时反推源码信息。
下边我们就看一下具体的解析方法
2039:2056:voidcom.example.application.GPL.check():39:56->a2039:2056:voidexecute():76->a方法最前边的行号范围如果相同,就代表一个内联链中的方法调用链,比如以上两句表示,方法check被内联到了execute方法中,内联的位置是原execute方法的第76行,如果末尾是行号范围,那么对应的就是最终的内联方法体
开头的行号是内联函数调用链最底层的行号范围和编译器给予的一定的偏移量加和的结果,偏移量是1000的倍数,偏移量的目的是避免与其他的正常的代码范围产生冲突,所以2039:2056是来自check方法的源码行号范围39:56与2000的偏移量相加得出的结果
另外,因为check方法因为不属于类com.example.application.Main,所以使用了类全限定符标识,标明check所处的类
2236:2252:voidprintConfiguration():236:252->a2236:2252:voidexecute():80->a3040:3042:java.io.PrintWritercom.example.application.util.PrintWriterUtil.createPrintWriterOut(java.io.File):40:42->a3040:3042:voidprintConfiguration():243->a3040:3042:voidexecute():80->a以上Mapping文件的分析方法和之前的一致,唯一需要说明的是这其中的关联execute方法在80行内联了方法printConfiguration,后者的行号范围是236:252,其中,printConfiguration又在243行内联了方法createPrintWriterOut,后者的行号范围是40:42。
至此,我们分析完了Mapping文件的所有情况的格式,最后的两行交由读者自己尝试分析一下。
当使用AndroidGradle插件3.4.0或更高版本构建项目时,该插件不再使用ProGuard来执行编译时代码优化,而是与R8编译器协同工作来处理编译时任务,所以可以通过Gradle插件版本来查看具体使用了Proguard还是R8编译器。
R8编译器一定程度上兼容Proguard规则,但是还是略有不同。
Mapping文件以#开头的行作为注释,标识R8程序的格式,日期等信息,但是在Proguard中还未发现这样的规范
例如以下:
和Proguard规范有所不同的是:
例如:
1:2:voidattachBaseContext(android.content.Context):97:98->attachBaseContext1:4:voidcloseOptionsMenu():609:612->closeOptionsMenu虚拟行号范围重叠了,但实际的行号范围是不一样的,而且混淆后的名称也是不同的
基于以上的Mapping文件的解析规则,我们可以做很多事情,比如反混淆Trace文件,反混淆Nanoscope文件,反混淆Hprof文件等等,我基于这个规则,开发了一个ReProguard的程序,可以供大家参考,欢迎交流提意见