破解某教学类APP核心功能

1.内容简介

本文会详解如何利用自己编写的APP来调用某教学类APP中的so库以达到机器刷题的目的,其中会分享自己在调试过程中的一些经验,包括使用ida调试安卓so库的一些技巧以及对程序流程进行分析的思路。

注:下文中使用Target表示该APP。

2.拍题请求/响应分析

TargetAPP的拍题功能为识别用户上传图片中的题目,随后返回对应题目和题解。使用burpsuite抓包分析该请求响应过程:

1

返回结果中的answers即为题解的密文,请求中的token参数是Target本地生成的签名,如果签名不正确则服务端不返回题解。

通过全局搜索特征字符串的方式去寻找拍题相关代码,使用grep –rn ‘beginUploadTime’ ./wenba_xbj_v5.0.3_online_server命令搜索结果如下:

2

 

使用jadx反编译工具,定位到相关代码,发现了token生成的函数如下:

3

由上图可知,token由beginUploadTime、uid、photoType、imgsign、dna等参数生成,跟进getWenbaToken函数如下:

4

该函数把传入的参数键值使用“&”连接,随后和时间戳一起被传入getNativeToken函数,继续跟进:

5 6

发现最后调用了一个native函数,至此找到了token的生成过程,继续查看处理返回结果的过程。可以使用jeb反编译工具的ctrl+x查看有哪些地方调用了生成token的函数,发现两处:

7

在分析的时候发现,jadx和jeb这两个反编译工具可以结合着用,有些smali代码jadx不能反编译成java代码,但jeb可以用goto这种语法识别成可读的代码。随便举个例子,比如下面这个isArmV7函数,分别贴上jadx和jeb的反编译结果:

8 9

接着分析程序逻辑,分析一下上面的两处调用之后发现下图这一处是上传图片的过程:

10

如何寻找处理返回结果的函数呢?处理返回结果函数所在类的对象通常会作为参数被传到发起http请求的函数中,比如上图中startHttpLoader函数中的WenbaRequest类,查看其代码发现decrypt函数正是处理返回结果的函数:

11

分析可知其中的SoUtil.decryptServerBytes函数为核心解密函数,跟进一下发现也是调用的native函数:

12 13

以上就是拍题请求、响应过程有关的java层代码。

4.尝试编写APP调用相关Native函数

首先需要找到这两个函数在哪个so库中,查看java代码发现该类通过System.loadLibrary的方式载入了两个so文件,分别是libbase.so和libWenbaCrashCatcher.so,如图:

14

静态查看so文件不方便的话,这里可以通过两次分别载入其中一个so文件,通过eclipse中的报错信息不同就可以知道所调用的函数在哪个so库中。通过尝试得知目标函数在libbase.so中。

调用的时候发现报错了,由于当时没有截图,这里只描述一下遇到的问题和解决的办法。遇到的第一个报错是so库中的JNI_Onload函数调用了getApplicationContext这样的方法(分析so代码得知),该方法是根据APP的包名来获得上下文信息,一开始我的APP包名是随意命名的,所以获取到一个null。去看一下TargetAPP的AndroidManifest文件,看到它注册了一个名为com.wenba.bangbang.BangbangApplication的应用:

15

于是修改我自己的APP包名和相关代码,并在AndroidManifest.xml中进行注册:

16

再次尝试调用,发现还是出错,只能去仔细分析libbase.so中的相关代码了。

5.相关Native层代码分析

把java函数和C函数关联起来有静态和动态两种方式,静态的方式是C函数在命名时根据所对应java函数的包名及函数名来命名,动态的方式是在so库中的JNI_Onload函数中调用RegisterNatives函数进行关联,其中gMethod结构的参数指明了对应关系,如下图所示,so库被加载后会在一个比较早的时间执行JNI_Onload函数:

17

通过分析可知libbase.so采用了动态关联的方式,于是使用ida看一下它的JNI_Onload函数:

18 19 20 21

跟下去可以看到JNI_Onload函数的主体,分析安卓so库时可以把jni.h这个头文件导入到ida中,然后修改参数的类型为jni.h中的类型,ida就会识别出一些jni.h中的方法名,便于我们进行分析,如下所示:

22

导入之后,因为我们知道JNI_Onload函数的第一个参数为JNIEnv *类型,按快捷键Y修改函数sub_24938的第一个参数的类型:

23

修改之后ida识别出了FindClass函数,如图:

24

由此可知JNI_Onload函数的主要功能由sub_265D4函数实现,所以先看一下这个函数,同样修改它第一个参数的类型,在该函数尾部发现了RegisterNatives函数:

25

上图中的off_973B4即gMethod结构,如图:

26

随便看一个表示java函数名的字符串发现是乱码,如下图,这是因为在注册时还需要调用相应的jni函数来将其表示成C语言中的字符串形式:

27

既然静态无法确定对应关系,这里有个办法,就是动态调试的时候在这9个native函数的开头都下断点,到时候看他断到哪个函数就知道我们调用的java函数对应哪个C函数了。

接下来使用ida去动态分析我们自己编写的APP,看一下在函数sub_265D4中都发生了什么。我用Windows虚拟机里启动eclipse的安卓4.4虚拟机的方法进行分析,这样可以利用虚拟机快照,方便在程序崩溃后迅速还原。需要注意的是虚拟机中一些程序的执行结果可能与在真机上执行不同,也有可能出一些莫名其妙的错误,如果遇到在安卓虚拟机上分析遇到了比较奇怪的问题,可以拿到真机上试一试,说不定就解决了,这里只是用虚拟机来动态理一下JNI_Onload函数的逻辑。

第一步,在安卓上运行android_server监听23946端口,android_server在ida安装目录的dbgsrv目录下:

28 29

第二步,使用adb forward tcp:23946 tcp:23946命令把安卓手机的23946端口转发到本地:

30

第三步,使用adb shell am start -D -n com.wenba.bangbang/com.wenba.bangbang.MainActivity命令以调试模式运行APP:

31

其中com.wenba.bangbang是APP的包名,com.wenba.bangbang.MainActivity是APP主Activity的名字,在AndroidManifest.xml文件中查看:

32

以调试等待模式运行APP后,在eclipse的DDMS可调式进程窗口就可以看到,其中8616为该程序的专用调试端口,8700时通用调试端口,建议使用专用的调试端口以防止冲突:

33

第四步,启动ida并附加该进程:

34 35 36

附加之后勾选如下调试选项:

37 38

其中有个选项是在加载so库的时候让程序断下来,一开始的时候libbase.so是没有加载到内存中的,如下图所示,按ctrl+s显示内存映射,搜索libbase.so:

39

当libbase.so加载时让程序断下来,便于我们在JNI_Onload函数开头,或者sub_265D4函数开头下断点。

第五步,使用jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8616命令以调试等待模式运行程序,其中8616即DDMS中显示的程序的专用调试端口:

40

之后在ida中按f9运行程序,发现程序随后加载了libbase.so并断下,如下图,点击取消即可:

41

再次在ctrl+s内存映射中搜索,发现已经加载了libbase.so,我们关注的是有X属性的libbase.so的加载地址,如下图所示,为A8A2D000:

42

这时候我们可以双开ida,使用动静结合的方式进行分析,函数sub_265D4的地址为libbase.so基值A8A2D000加上函数偏移265D4即A8A535D4,在指令窗口按快捷键G,输入该地址进行查看,如图:

43

在该指令处按快捷键C进行代码识别即可识别出该函数的汇编代码,如图:

111

 

在该处按f2下断点,然后按f9运行程序,在该处断下,PC寄存器的值即要执行的下一条指令的地址:

45

如何分析程序的逻辑呢?我们主要关注一些关键函数的执行结果即可。在arm架构、fastcall函数调用约定下,函数在调用时通过寄存器R0~R3传递前四个参数,如果函数参数超过4个,剩下的参数使用栈进行传递,函数的返回值同样保存在R0寄存器中,函数的返回地址通常保存在LR寄存器。

举个例子,来分析一下sub_265D4函数开头的一段逻辑:

46

我们看一下这个memcpy函数向dest指针所指向的地址存了什么东西,函数调用通常使用BL指令,在该位置下断点,并按f9执行,如图:

47

在数据窗口按G,然后输入此时R0寄存器,即dest指针所指向的地址BECEE37C,如图:

48

按f8单步步过memcpy函数,观察该地址中数据的变化,发现函数向这里复制了一个乱码字符串:

49

接着,函数sub_2581C函数同样使用了dest作为参数,我们同样单步步过该函数,发现这里的乱码字符串被解密为com/au/util/c:

50

解密结果被传到函数sub_25854中:

51 52

函数sub_25854中调用了一个libdvm.so这个系统so库中的函数,我们还是通过导入jni.h,然后把它第一个参数的类型改为JNIEnv *的方式来看一下,结果如下:

53

原来这个函数就是执行libdvm.so中的FindClass函数,这段代码的作用就是获取com/au/util/c类的指针(类似的东西)。

使用上述方法,我们就可以逐步分析出整个JNI_Onload函数的逻辑,整理如下:

在函数sub_265D4中前后两次调用了函数sub_2A368,如图:

54 55

在函数sub_2A368中进行了APP签名的校验,之前的崩溃即因在该函数中签名校验不通过,函数sub_2A368中进行签名校验的代码如下:

56

于是我们可以通过patch掉相关判断条件的方式来过掉它的签名校验,patch的方法举例如下,看下图中的代码:

57

如果v56等于40才正确,而我们的执行结果不等于40,我们就可以把相应的汇编代码BEQ改为BNE(或BNE改为BEQ),对应到机器码则是D0改为D1(或D1改为D0),如下图,本来是BNE,我们就改成BEQ,建议使用16进制编辑器根据偏移位置进行修改:

58 59

根据我们动态调试的结果,看程序在哪个分支崩溃我们就patch掉哪个跳转条件。在patch完第一个sub_2A368函数以及之后的一系列判断后,发现在第二个sub_2A368中又出错了,分析了一下,发现程序在第二个sub_2A368之前调用了sub_2AD2C函数:

60

在该函数中程序启动了一个线程,线程的主体函数为sub_2AAB8:

61

简单看一下发现这个函数似乎是用来反调试的:

62

于是patch掉该线程的产生过程,即patch掉调用pthread_create的代码:

63 64

将46 F0 C5 FA改为F8 8D 00 00,即MOV R0, R0  POP {R3-R7,PC},即执行一条无用指令之后马上返回。

完成上述patch之后,我们的APP就可以成功调用libbase.so中的函数了,成功地生成了token:

65

然而调用解密函数进行解密的时候,发现程序又崩溃了,于是去分析它的解密函数sub_26448(在所有native函数的开头下断点找到的,一开始我patch掉反调试线程的时候不小心偏了一个字节,于是我惊奇的发现ida直接跳到这个函数的中间执行了…),发现崩溃在下图if里面的代码:

66

于是粗暴地patch掉跳转条件,让程序不执行if里面的代码,发现成功执行完了解密函数,但是解密的结果依然是乱码,所以我又不得不去仔细分析这个解密函数。

6.题解加解密逻辑分析及调试TargetAPP

首先静态分析一下sub_26448函数,从java代码里我们可以知道该函数有两个参数,而在ida里sub_26448函数显示有5个参数:

67

其中前两个参数是JNIEnv *一类的固定类型的参数,从第三个参数开始才是我们从java代码传过来的,那这里为什么有三个呢?分析一下就可以知道,参数a3应该是我们传进来的时间戳有关的参数,参数a5是我们传进来的用于解密的题解密文,而a4参数在ida的整个sub_26448函数中都没有用到过,可知是ida反汇编出现了小错误,这是经常出现的,不影响我们分析函数的逻辑。

通过静态分析可知,程序先是在我们传入的字符串中寻找小写字母j,然后把小写字母j之前的字符串的长度传入函数sub_2A018中:

68

之后经过一系列变化,又把该长度和与时间戳有关的参数一起被传入函数sub_260E8中:

69

其中参数s作为函数输出,随后和要解密的字符串一起被传入函数sub_2A068中:

70

这个函数里应该就是解密的过程了,进去看一下:

71

果然,函数先是利用我们传入的s变量生成了AES解密所用的key,然后利用key去解密字符串。由此可知,解密所用的key的生成只与s有关,而s的生成在函数sub_260E8中,再进去看看:

72

看到了上图所示代码,v4即我们传入的和时间戳有关的参数,程序先用它除以86400,然后再乘86400,就会把相差在86400以内的时间戳变为相同的值,而86400秒即1天,故猜测服务端用于加密题解的密钥一天一换,所以客户端才做如此处理。而我用于解密的答案是几天前就获取的,一直用它来做实验,所以肯定不能解密成功。于是我又获取了一个最新的答案去解密,果然就成功了:

73

其实在分析的时候想的东西比较多,因为我对libbase.so做了一系列的patch,每当结果出错的时候,比如token生成的不正确或者题解解密不正确,我不能确定是因为我参数传的不对还是因为我做了patch,所以我就特别想看看TargetAPP生成的一些关键的中间值,比如s的值,和我的是不是一样。但是TargetAPP做了反调试和重打包校验,并且没有在AndroidManifest.xml中开启debug选项,那该如何去动态调试呢?

首先说一下没有debug选项的解决办法。安卓系统在判断一个程序是否可调试时,会首先看自己系统的总调试开关是否开启,如果开启的话则该系统上所有进程都是可调试的,如果系统调试开关关闭则去程序是否可调式才由AndroidManifest.xml中的debug选项决定。可以在安卓手机上用getprop re.debuggable命令获取系统调试开关的状态,在安卓虚拟机上执行如图:

74

发现安卓虚拟机的系统是可调式的,但TargetAPP在虚拟机中不能正常运行,而真机上的系统调试开关都是关闭的,所以我们必须开启真机的系统调试开关。方法有两种,一种是重新编译安卓内核,把调试开关打开,比较麻烦且不易成功(网上可能有别人编译好的镜像);第二种是使用网上的mprop程序在系统内存中修改该调试开关状态的值,只要系统不重新启动就没问题,这种方法比较实用。

运行mprop之前:

75

运行mprop:

76

结果如下,发现系统调试开关打开:

77

在adb中执行stop;start命令重启adb服务,即可在DDMS中看到所有进程都变成可调式状态:

78

之后我们首先在真机上运行TargetAPP,然后直接使用ida附加Target进程。在调试时发现我只能按一次f9,断下来之后再运行就会触发TargetAPP的反调试而崩溃,不过这足以使我们看到一些关键的中间值了。比如我们想看s字符串的值,就可以找出对应指令的地址,即下图中指令在内存中的地址(so库基址+指令偏移):

79

即在sub_260E8刚刚运行完时下断点,查看s在内存中的位置,为sp+3C:

80

SP即函数sub_26448运行时的栈顶指针,动态调试到该位置SP的值如下所示:

81

在数据区找BECEE3D8+3C=BECEE414,如图:

82

 

这样就能看到s的值。其实调试到最后,我发现在26158处下断点可以完美地避开TargetAPP的反调试,一直单步到解密函数执行结束,这对于我确认分析结果是否正确起了很大作用。