当在Android应用里碰到native方法时,在Java层能看到的也就只是一个光秃秃的方法声明,真正干活的逻辑基本上都藏在so库里面。要用JEB Decompiler把JNI调用的落脚点给找出来,再把Java层的方法和so库里的实现函数之间的映射关系给追踪清楚,关键就在于DEX这一层和Native那一层要放在一起看,不能各看各的。JEB这个工具本身既能分析Android应用,也能对ELF这类Native二进制文件做反汇编、反编译和调试,正好适合拿来把Java native方法和so里的实现函数串在一条线上去追。
一、JEB Decompiler怎么定位JNI调用
在开始定位JNI调用之前,先把APK文件打开,确认一下里面的lib目录是不是真的存在,而且下面有没有对应着目标设备架构的so文件,比如arm64-v8a或者armeabi-v7a。如果翻遍了也没找到so文件,那这些native方法就可能是来自于系统库,或者是由应用在运行的时候动态加载进来的。
1、先把Java层里那些native声明揪出来
把APK文件拖进JEB以后,第一步可以到DEX代码里直接去搜native这个关键字,或者是搜System.loadLibrary和System.load这类字符串,通常native方法本身会写成类似native String check这个样子,而loadLibrary则能直接告诉我们它要载入的so库叫什么名字。因为Android里的这套JNI机制,本来就是为了让Java或者Kotlin写成的字节码能够调用到底层用C或C++编好的native代码。
2、翻一翻native方法的交叉引用
在找到的那条native方法名字上,按一下X键,去查看它的交叉引用,按照JEB文档里的说明,交叉引用可以帮我们看到有哪些地方用到了当前这个对象,按下X之后会弹出一个引用窗口,在里面双击就能直接跳到对应的那个调用位置,这样一来,在Java层里到底是从哪里去调用这个native方法的就先搞清楚了。
3、把对应的so库打开
在项目的文件树里找到lib目录并把它展开,点开目标设备架构下面的那个so文件就可以了。在实际分析的时候,最好先挑跟测试用的真机架构一致的那个版本,比如真机是arm64的,那就先去看arm64-v8a下面的so,不同架构下面函数名虽然是一样的,但是汇编层面的细节会有差别,不要把它们混在一起去看。
4、去搜索JNI的导出符号
到了Native的符号表里面,可以搜索Java_开头的那些函数,因为如果用的是静态注册的方式,JNI通常会按照Java_包名_类名_方法名这种格式生成一个导出符号。找到它以后,就可以进一步去看反编译出来的C代码,或者直接观察反汇编的结果,再结合Java层里那个native方法声明的参数类型,接着往下分析。
二、JEB Decompiler JNI函数映射关系怎么追踪
JNI在做接口绑定的时候,常用的有两种路子,一种是上面说的那种静态命名导出,另一种就是靠RegisterNatives在运行的时候动态注册,所以我们要是只去查那些Java_开头的函数,就经常会把用动态注册方式绑定的native方法给漏过去。
1、先顺着静态注册往下追
如果打开so库之后,直接在符号列表里就看到了像Java_包名_类名_方法名这种格式的函数,那映射关系就比较直白了,只要把Java这边的包名、类名、方法名,跟native那边的导出函数名字一一对上,再在JEB里顺手给函数重新命个看得懂的名字,后面再往下分析就会清楚很多。
2、再去检查JNI_OnLoad
要是把符号表翻遍了也找不到一个Java_打头的函数,这时候就要重点去搜JNI_OnLoad这个函数了,因为JEB的官方博客里也提到过,JNI的绑定除了按照Java方法签名派生出固定的导出名之外,还可以通过RegisterNatives来完成,而这些动态注册的代码,一般都会放在JNI_OnLoad里面去执行。
3、去搜RegisterNatives在哪些地方被调用
可以在so文件里直接搜索RegisterNatives这个标识符,找到它的引用位置以后,再顺着这个调用的地方往周围看,通常能找到一个叫JNINativeMethod的数组,这个数组里其实就装着三样东西:Java层的方法名字、方法对应的签名信息,还有真正的native函数指针,只要把这三项抄成一张表,Java方法和native函数之间的对应关系也就建起来了。
4、把字符串和函数指针结合到一起看
在动态注册的时候,Java层的方法名和它的签名一般都是以字符串的形式存在的,比如check、sign、encrypt这一类名字,我们可以先到字符串窗口里去搜这些方法名,看到它们以后再去看交叉引用,一般就能顺着引用关系回到注册函数的那片代码里,然后再根据数组里记录的函数指针,跳到真正干活的native实现那边去。
三、JEB追踪JNI时容易漏掉什么
如果在追JNI调用链的时候总觉得拼不完整,最常见的原因就是眼睛一直只盯着Java这一侧的代码,却没有同步地去检查so库是怎么被加载起来的、动态注册发生在哪里,还有application里有没有用了反射来调用native方法。
1、注意库名字的转换
Java代码里的System.loadLibrary("abc"),实际对应的so文件名是libabc.so,中间多了lib这个前缀和.so这个后缀,所以在文件系统里去找的时候不能按原样字符串去搜,要把名字转换以后再去对号。
2、注意重载方法的签名
同名的native方法如果传进去的参数不一样,那在JNI里面的签名信息也是不一样的,动态注册那张表里的签名字符串就是个很关键的角色,比如参数里到底是字符串、字节数组,还是返回一个int值,都会直接影响映射的结果,签名对不上就找不到正确的函数。
3、注意混淆以后变了样的类名
Java层的代码一旦被混淆过以后,包名和类名往往会变得很短,比如变成了a、b这种,那静态JNI导出名里跟着使用的也会是混淆后的那套类路径,所以不要按开发源码里的原始类名去猜导出函数的名字,要以APK里面实际存的类名作准。
4、实在追不下去就上动态调试
要是靠静态分析怎么也拼不出一条完整的路径,就可以把JEB的Android调试能力给用起来,在Java那边调用native方法的位置,或者在native函数的入口附近打上几个断点,JEB本身就支持对Android应用做静态和动态分析,到了native调试这一步,正好可以帮我们确认在真正跑起来的时候,到底实际走进了so库里的哪一个函数。
总结
用JEB Decompiler定位JNI调用,大致的路数是先在DEX层把native方法的声明和loadLibrary的位置搜出来,再通过交叉引用找到Java代码里的调用点,接着去打开和当前架构对应的so文件查找Java_打头的导出函数。而在追踪JNI函数映射关系的时候,不光要把静态注册的那些导出符号理出来,还要专门去查JNI_OnLoad里面有没有通过RegisterNatives动态绑定的那张注册表,最后把Java这边的方法名和签名,跟so库里的native函数地址整理成一套清晰的对应关系,后面再去分析native层的代码逻辑就会顺畅得多。
