CGSS 核心反向过程实录 | 四、Java 代码反向

文章目录
  1. 1. 一、声明
  2. 2. 二、APK 解包
  3. 3. 三、类代码查看
  4. 4. 四、寻找密钥吧
  5. 5. 五、Unity3D 和 JNI

索引

唉!从17日开始写了几句话到现在,拖了13天,这病得治。

28日在博客园的本周回顾中找到了这样的一篇个人足迹。如果真的遇到这样的对手,我很荣幸。

2017年5月追记:

我探索这些的目的,是以一个玩家的身份,让 CGSS 变得更好玩。私下里我类比过普罗米修斯,将天火——谱面的制作和玩的能力从“天上”分一点出来。其实,修改 CGSS 去作弊是很简单的,无论是客户端还是 MITM。但是我不会去作弊,也不希望这系列文章的读者们将文章内容用在歪门邪道上,不希望去作弊、破坏平衡性。玩过 LLSIFSB69GF(note)Arcaea 之后,我仍然认为 CGSS 的综合指标(游戏性、操作体验、曲目等等)是行业内翘楚,Cy这次比较用心。那么作弊还有什么意思嘛!对了,我不氪金,不冲分,只是闲暇时间来两局,玩得舒心。

当然,まゆさまが見ています。

(3月3日8时-12时)


一、声明

The Idolmaster Cinderella Girls Starlight Stage 软件和相关媒体的著作权为 Bandai Namco Entertainment Inc. 所有。本系列文章仅从兴趣出发,研究数据反向过程。在此不会给出完整的反向过程、反向结果和源代码,只给出思路和部分非关键数据。

我相信大牛即使是没有看过我写的这些玩意儿也能上的。

二、APK 解包

首先拿到 CGSS 的 APK 安装包。大家都知道,APK 源自 JAR,而 JAR 采用 PKZIP。如果要浏览、编辑,最简单最直接的工具就是 WinRAR。

APK 根目录文件结构

打开一看,在根路径就出现了一个 classes.dex。什么是 DEX 呢?这需要对 Android 有一点了解。一般的 Java 应用程序都会编译为 .class 后封入 JAR 包,运行时由 Java 虚拟机读取并执行其中的字节码。而 DEX 则是为了 Android Dalvik 虚拟机设计的执行文件(可以想象成 CLR PE,是带元数据的),紧凑存储,更适合移动设备。这一步就需要将 DEX 还原为更常见的 JAR,然后再反向。选用的工具,自然就是 dex2jar

同时注意另外的几个目录。assets 稍微浏览一下就能看出带有浓浓的 Unity3D 气息,lib 是针对不同平台编译的 JNI 库(.so 是 Linux 下的共享库,也就是动态链接库,的后缀名,然而 Android 下大量的 Java 代码为何要使用原生库呢?性能高嘛;那互操作方式就很可能是 JNI 了),META-INF 是清单文件存储的位置(和 JAR 类似),res 是界面布局和资源。AndroidManifest.xml 是这个 app 的描述和需求(如特殊权限),resources.arsc 暂时不知道是什么,不过这里没用到。

由于整个结构资源文件实在分析不出什么,就拿代码开刀。解压出 classes.dex 并用 dex2jar 还原为 JAR 等待分析。

三、类代码查看

这一步比较蛋疼。在我试的时候,JavaClassViewer 报错,jd2 无法反编译 Unity3D 的混淆代码而且是输出“发生了错误”。幸好我还有第三件神器,基于 Procyond4j。这货居然顺利反编译了 Unity3D 的部分混淆代码,这在我发现调用本质的路上起了关键的作用。有些代码反编译不了不用管,我看了一下字节码,应该是人工构造了一些栈不平衡的指令(实际上不会执行),用来迷惑反编译器的,这手法在另外的地方见过,此处略去。

现在用 d4j 来看看这个 JAR 里的类们。

首先根据运行时的外部存储目录名字 jp.co.bandainamcoent.BNEI0242,顺藤摸瓜找到对应的包 jp.co.bandainamcoent.BNEI0242。不过很不幸,这里看上去只有一些字符串常量,而且不是我们需要的。就是,还有谁像 BiliBili WinPhone App 的团队那样把机密用一个一眼就知道是干什么用的字符串常量存着啊!

接着我们可以看到 jp.cygames 包。Cygames 身为 CGSS 的发行商(运营商?根据后文,应该是运营商吧,我猜),往里面加入了自己的广告 SDK。另一个广告 SDK 是 App-AdForce(现在这个网站似乎转移了)的。很有意思的是,我用着的手机是华为的,内置 Google Services Framework 和 Google Play(虽然实际上都不能用),这就免去了找 GSF 和 Google Play 并 root 安装的麻烦——我并不喜欢随便 root 手机。在真机上玩的时候,只有第一次弹窗(弹出浏览器广告)了,弹出浏览器转向倒是在数据同步(引き継ぎ)时有一次,之后再也没烦过我。然而在一个安装了带 Google API 系统镜像的模拟器上,每次启动都会弹出广告。也许是因为我没有真正开始玩吧,不过我目前相信是一个“阉割”了的 GSF 有着“抗干扰”的副作用。——顺带,功夫网的一个积极作用就是保证我访问不了 Google Play,不剁手。

回到正题。还可以看到,这里面有一个 Unity3D 插件,提供游戏内的 WebView。其实抓一下就知道,每日更新的信息就是承载在一个 WebView 中的。bitter.jnibridge 放了一个 JNI 调用的辅助类,不过这个类在 jd2 中没有找到其他引用,或许是在混淆了的代码之中,所以就算了。com.prime31 则是 prime[31] 的 SDK,这是氪金的收费接口。FMOD 作为一个独立模块存在在内,是不是 Unity3D 打包的时候这么放的我不清楚。

大略浏览一下,可以发现这里面存在不少用 Java 写的 Unity3D 插件:Cygames 的(广告推送)、App-AdForce 的(广告推送)、Gree 的(WebView)。

四、寻找密钥吧

上一篇中我们知道要找一个密钥,但是不知道它位于何处,也不知道什么时候会出现(静态字段还是动态生成)。解答这个谜题的感觉就跟探险一样,让人激动不已。

首先我们可以确定,最显眼的 jp.co.bandainamcoent.BNEI0242 中并不存在密钥。其余的很多东西,主要分为几类:

  1. Android 兼容所需;
  2. 第三方 SDK;
  3. Unity3D;
  4. FMOD。

密钥在前两者中出现的概率微乎其微。对于后两者,如果有了源代码,重新编译并将密钥作为一个字段嵌入并不是难事,所以是有可能的。我曾经怀疑过,但是经过仔细的搜查,里面还没发现密钥什么的代码。

五、Unity3D 和 JNI

在反向的时候,我想着一个问题:这么一个 Unity3D 的游戏,为什么需要原生的共享库呢?一个很自然的想法,就是将密钥藏在更不容易被反向的原生代码中。这些原生库有两种可能的使用方式:JNI,给 Java 程序调用;P/Invoke,给 CLR 调用(Unity 内)。而且一个大大的“jnibridge”摆在那里,这是一个商业软件,也就是说应该是经过了最优化的(清除无用符号和指令、速度/内存优化、混淆、压缩),留下的组件被用上的概率很大,不明摆着可能用到了 JNI 吗?

由于我不熟悉 JNI,我就找了一下。于是我找到了这篇文章(原文链接已经失效),讲到了 JNI_OnLoad 函数。一找,果然找到了。其实不用 IDA,用个文本编辑器什么的打开应该都能看到,导出表还是属于可读部分的。

JNI_OnLoad

但是这个分析也只能到此为止,拆开 JNI_OnLoad 去看初始化了什么,就是 C++ 级的反向了,代价很大。

但是问题来了,可以看到,除了 JNI_OnLoad,还导出了那么多函数,它们并没有在 Java 层被调用,应该就是 Unity3D 调用了。(而且名字 libcri_ware_unity.so 已经十分直白了。)搜索一下“jni_onload unity3d”,就能发现蛛丝马迹——它们,都是用 C/C++ 写的 Unity3D 的插件!由于插件数量未知,加载应该是使用依赖注入的方式进行,也就是类似枚举+反射的方式。但是,Unity3D 加载的应该是兼容 CLS 的程序集才对,Unity3D 的 Java 部分也没有写出要加载哪些库/程序集,入口点是什么。

那么我们换一个思路,既然都是插件,就看看 Unity3D 是如何加载插件不就好了?于是我们能找到简易的说明,并引导到官方文档。从这里能看到,Unity3D 的原生插件加载是通过 DllImport 特性(有 .NET 开发经验的应该不陌生)加上反射实现的。关于这个说法还有待验证,下一步进入到 C# 的反向时就能证明了。com.unity3d.player.NativeLoader 类和 com.unity3d.player.ReflectionHelper 类的存在,佐证了这种想法。

关于 NativeLoader,它的类文件是这样的:

package com.unity3d.player;

public class NativeLoader {
    static final native boolean load(final String p0);
    static final native boolean unload();
}

从两个方法名称就可以看出它们是干什么的。不过虽然 p0 这个参数我们还不知道是什么意思(大致能猜到),但是搜索一番,可以在 com.unity3d.player.UnityPlayer 类中找到这一段代码:

private static void a(final ApplicationInfo applicationInfo) {
    if (UnityPlayer.q && NativeLoader.load(applicationInfo.nativeLibraryDir)) {
        t.a();
    }
}

这证明有一个遍历载入的操作——JNI_OnLoad 有用武之地了。这就相当于 DllMainDLL_PROCESS_ATTACH,在这个库被载入进程的内存空间时进行全局(相对于库而言)的初始化。这样,虽然之后的 P/Invoke 需要产生加载调用,但是此时这个库已经存在于与调用者相同的内存空间中——同一个“实例”中,此时 RVA 是同一段,不用担心进程隔离带来的非法内存访问。

什么意思呢?假设我有这么一个库,导出如下的两个函数:

__declspec(dllexport) void *get_buffer() {
    return malloc(100);
}

__declspec(dllexport) void free_buffer(void *buffer) {
    free(buffer);
}

然后由一个应用程序动态加载(不静态链接)并执行:

int main(int argc, char *argv[]) {
    void *p = get_buffer();
    free_buffer(p);
}

中间有一个堆上的指针,这个指针在进程间是无效的,但是进程内是有效的。这个程序能否正常运行,就取决于应用程序和所加载的库是否在一个内存空间中。还可以证明,如果我放在库中并声明为静态的全局变量,在整个进程实例中是共享的——听上去就是一个放密钥的好地方,在库初始化时初始化密钥字段,然后其他 API 读取、操作什么的,岂不是能避开旁人耳目?

不过尽量还是不要去跟原生代码较劲,太费力气。想到既然是 DllImport,进入到 C# 部分的话,即使是 P/Invoke 也是十分简单的事情。更重要的是,这个插件的一个导出函数引起了我的注意,它说明密钥的写入很可能不是在内部进行的,而是一个由外而内(假设 libcri_ware_unity.so 为内的话)的过程。


欲知后事如何,请听下回分解。

分享到 评论