有关神武3手游协议分析的一些简单思路

市面上大部分的手游,都是基于cocos2dx。神武3同样如此,不过有一点却很奇怪,该游戏没有采用Lua脚本引擎,而是采用了python脚本。众所周知,cocos2dx有对Lua的支持,没有对python的支持,这就意味着,多益已经将cocos2dx提供的C++api用python进行了封装,并且已经非常成熟稳定了。

当然,这对于我们来说并不重要。我们首先应该要做的是找到构造登录包的地方,最直观的就是从登录按钮那里入手,登录按钮必然包含了点击事件,那么问题就来了,这个登录按钮是继承于安卓原生Button,还是自己独立创建的新控件?

如果是原生的,只需要找到它的监听事件,用Xposed Hook所有的OnClick()函数,并打印出函数所在的类,即可找到点击事件。

如果不是原生的,那么这个监听器就是自己设计的,原理应该是当按下屏幕的时候记录下来坐标,同时记录下来抬起动作的坐标,如果两个坐标都在控件范围内,则产生一个点击事件。

接下来开始反编译apk,先静态分析一波……

反编译的apk中搜索不到和登录相关的关键词,猜测要么做了混淆,要么放在了so里面。

于是用ida查看几个可疑的so,在libdygame.so中找到了关键字符串,并且还发现了大量的python字符串。意味着有部分代码是写在python脚本中。

根据以上分析,我们已经知道,登录的事件函数有可能存在三个地方。

  1. Java层,onclick点击事件中。
  2. Native层,自己实现了Button,并且将事件函数放入了native中。
  3. Python脚本,通过cocos2dx的api获取点击事件,然后调用事件函数。

在apk中搜索不到onclick函数,也就是说在Java层中,没有点击事件,所以假设一不成立。

自定义button中肯定有一个按下和抬起的动作,还有一个检测触控点移动范围的函数。Java层提供控件的UI,所有的事件都回调Native层的函数,那么native层中有暴露接口给Java层调用,结合之前分析的libdygame.so,可以看到,有几个可疑的接口,touchonend()、touchonstart()、onkeyup()、onkeydown()等等。

于是我们使用frida,Hook这几个相关函数,touchonstart(),touchonend()连个函数有被调用,而这两个函数都传入了坐标参数。然后接着看上层的引用,进入GLSurfaceView中,它继承于SurfaceView,说明是一个view控件。到这里就差不多弄明白了,登录Button是通过GLSurfaceView实现的,其点击事件处理过程交给了Native层。

通过以上分析,我们可以得知假设二成立。

因为python脚本实际上是调用cocos2dx接口,而cocos2dx在libdygame.so中,也就是说python是在native层的,所以我们无法确定点击事件有没有传递给python。

这个假设可以暂时待定,继续分析假设二中的native层。

既然已经知道了登录的逻辑代码在Native层,那么我们就开始动态调试,先在所有可疑的函数前下断点。因为登录过程是需要网络发包的,登录包必然要经过send函数,所以也要对其下断点。我们的目的是寻找登录相关的代码,包括了登录数据包的生成部分。下面开始进行调试。

只有send函数断下来了,那么之前的猜想可能存在误差。只能确定调用了send发包函数,而send函数是libc库函数,完全不知道问题出在那里啊,基本上算是没有思路了。根据之前的三种假设,也只剩下假设三存在一定的可能性,前路一片黑暗啊,革命尚未成功,同志仍需努力…

经过一天的休整,继续围绕send函数做突破,我们需要的send最上层的函数,packet的组包函数,那我能不能把send调用的堆栈打印出来呢?有了堆栈就能知道packet buff经过了哪些函数,最上层的有可能就是packet的组包函数。

这里使用Frida打印函数的堆栈send(Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join("\n"));。从输出结果可以看到,出现了一些列的PyEval_CallObjectWithKeywords函数调用,最上层为系统函数,下面就是python的一系列调用。现在基本可以肯定了,登录函数和组包函数全部都在python代码中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
0xd7b6d6b4 libdygame.so!0x4b86b4
0xd7f9c610 libdygame.so!0x8e7610
0xd7f945b4 libdygame.so!0x8df5b4
0xd8287d54 libdygame.so!0xbd2d54
0xd8232d4c libdygame.so!PyObject_Call+0x64 python调用C扩展里面的函数
0xd7f98870 libdygame.so!0x8e3870
0xd8232d4c libdygame.so!PyObject_Call+0x64
0xd82c3328 libdygame.so!PyEval_CallObjectWithKeywords+0x54 调用python的回调函数
0xd8285b40 libdygame.so!PyTuple_GetSlice+0x70 # 将元组从低到高调换并返回新的元组
0xd8385cfc libdygame.so!0xcd0cfc
0xd8232d4c libdygame.so!PyObject_Call+0x64
0xd82c82ac libdygame.so!PyEval_EvalFrameEx+0x4918
0xd9f1637e base.odex!oatexec+0x6537e
0xd8267820 libdygame.so!PyDict_GetItem+0xc0
0xd8267820 libdygame.so!PyDict_GetItem+0xc0
0xd838a7e4 libdygame.so!PyFrame_New+0x4c

adb看一下apk的包目录,找到一个data.fls可疑文件,而这个文件在反编译的apk中是没有的,说明是从服务器下载过来,有可能是资源文件,但更大的可能性是加密后的pyc文件集合。

我们知道python是一门解释型的语言,将py文件编译成pyc字节码文件,然后将pyc字节码文件加载到python虚拟机中。换一种思想理解,在python中所有的一切都是对象,pyc文件也是一个对象,所以,加载到内存中,以一个对象的形式存在着。python又是动态执行的,通过import导入模块,那么加载pyc的机制一定在import机制里面,结合Google搜索查找import的相关资料,在python源码中可以很容易的定位到最底层的pyc加载函数,分析该函数发现有两个方法将pyc转换成object对象,从字符串加载j_PyMarshal_ReadObjectFromString和从文件加载j_PyMarshal_ReadObjectFromFile。那么,在这里解密data.fls文件可以从两个方向入手,第一个方向是找到加载data.fls文件的函数,分析该函数是如何解密data.fls文件的,解密出来的肯定是n多个不同路径的pyc文件组合;第二个方向就是从import导入机制入手,由于data.fls解密后的内容在内存中,python虚拟机必然是从字符串加载成object,只需要拦截j_PyMarshal_ReadObjectFromString函数传入的参数字符串(pyc),就能还原出完整的data.fls数据。

  1. 方向1,分析加载data.fls的函数

已完成,以后再发表。

  1. 方向2,分析j_PyMarshal_ReadObjectFromString函数

每次执行import操作,都会判断模块是否已经导入,如果没有导入,则执行该函数,所以对该函数进行hook操作,第二个参数String则是我们需要的pyc。请看以下源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# coding:utf-8
import hashlib
import os
import sys
import frida


def adb_forward():
    os.system("adb forward tcp:27042 tcp:27042")
    os.system("adb forward tcp:27043 tcp:27043")


def on_message(message, data):
    if message['type'] == "send":
        if not data: return
        md = hashlib.md5()
        md.update(data)
        ouput_name = os.path.join(".\\pyc", str(md.hexdigest()))
        if os.path.isfile(ouput_name):
            print("File already exist.")
            return
        with open(ouput_name, "wb") as f:
            f.write(0xA0DF303.to_bytes(4, "little"))
            f.write(0x00.to_bytes(4, "little"))
            f.write(data)
            f.flush()
        print("Success write to file..." + message.get('payload'))
    else:
        print(message)


adb_forward()
rdev = frida.get_remote_device()
session = rdev.attach("com.duoyi.shenwu3")

fp = open("hook_dump_pyc.js", "r", encoding="utf-8")
scr = fp.read(8192)
fp.close()

script = session.create_script(scr)

script.on("message", on_message)
script.load()
sys.stdin.read()

js代码hook指定函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Java.perform(function () {
    var func_name = "j_PyMarshal_ReadObjectFromString"; // 将pyc内容转换成pyobject,通过偏移计算, 偏移:0x001799F8


    //----------------------------------------------------------------------------
    // 通过导出函数获取地址
    // var exports = Module.enumerateExportsSync("libdygame.so");
    // for (var i = 0; i < exports.length; i++) {
    //     if (exports[i].name === func_name) {
    //         func_address = exports[i].address;
    //         send(func_name + " address is at " + func_address);
    //         break;
    //     }
    // }
    //----------------------------------------------------------------------------


    //----------------------------------------------------------------------------
    // 通过模块基地址+偏移 计算函数地址
    var baseAddress = Module.getBaseAddress("libdygame.so");
    send("[*] Found Base address:" + baseAddress.toString());
    // 计算函数的绝对地址:基地址 + 函数偏移 + 1
    var func_address = parseInt(baseAddress) + parseInt(0x0017617C);
    //----------------------------------------------------------------------------


    var nativePointer = new NativePointer(func_address);
    send("[*] " + func_name + " native pointers:" + nativePointer);

    // 开始Hook操作
    Interceptor.attach(nativePointer, {
        onEnter: function (args) {
            var size = parseInt(args[1]);
            var buf = Memory.readByteArray(args[0], size);
            send("data size:" + size + " data address:" + args[0], buf);
        },
        onLeave: function (retval) {
        }
    });
});

通过以上步骤成功的导出了pyc文件,接下来我们需要将pyc文件转换成py文件,用到开源的程序uncompyle2,具体细节不再赘述。

从始至终我们的目的都是为了分析登录协议。一步步分析下来,猜测到登录相关函数在python代码中,所以接下来我们开始分析dump下来的源码,在这里使用sublime进行分析,用sublime打开文件夹,在文件夹中全局搜索login、username、password等待关键字。很容易的定位到了login函数。

接下来就是按部就班的分析协议了,具体细节请参考项目demo,其中有详细的注释。

最难的部分已经解决了,分析协议格式已经不存在难度了。所以具体的细节就不写出来了。在这次逆向中,对手游的框架有一个全面的认识。