从原始码了解Pokémon Go

发表于2017-07-14
评论2 2.8k浏览

从原始码了解Pokémon Go

最近 Pokémon Go 实在太红了,加上自己是技术控,看到这篇文Unbundling Pokémon Go:在讲如何用逆向工程得到 App 的原始码,并分析其运作机制,在此翻译分享给大家。


本翻译文已取得 Adrien Couque 的同意,全文如下:

最近不知从哪儿冒出来,Pokémon Go 在一个礼拜内席卷了全世界,我们从里面发现一些有趣的东西。

虽然,这个 App 目前只在三个国家公开下载(美国、澳洲和新西兰),但它仍然让 Twitter Facebook 相形失色。它打败了 Candy Crush 成为美国最成功的手机游戏,不仅证明了对开发者带来收益,在地商家也注意Pokémon Go会为他们带来客源,任天堂公司的市值因而增加90%

这个游戏在这么短的时间就成为家喻户晓的话题,激励着我们想去看看它内部的构造。这篇文以 Pokémon Go App 为例子,说明如何透过逆向工程取得 Android App 的程序代码,同时分析其网络联机请求来得知更多的信息。


准备APK

要做逆向工程之前你必须先有 APK 档案,而取得 Pokémon Go APK 档案并不困难,这里就不详述。请注意安装来源不明的 APK 会有很大的安全风险,但其实 Google Play 会对 App 做一些分析以降低风险,因此一般人最好还是透过 Google Play 下载安装 App。但对于逆向工程来说,最喜欢这些恶意的 APK,因为很有趣。在这里,我们是针对7/7释出的 Pokémon Go 0.29.0 版本进行分析。

先讲一下,做了逆向工程后,我们仍然会看不到一些东西:

·         任何跟build code有关的东西

·         任何跟测试和持续整合(continuous integration)的东西

·         其他特殊版本(例如debug版本):在开发当中,你可能会有某些特殊的功能但不会放在最终的产品里,因为我们是要分析Pokémon Go正式释出的版本,所以特殊功能应该不会被放在这里面

·         后端服务的程序代码。很多人可能想知道算法是如何决定神奇宝贝出现的地点。但这个算法是放在后端,我们只能知道如何跟后端传送数据,无法知道内部算法的逻辑。


APK的内容

我们来看一下 APK 的内部构造。事实上,APK 只是一个 zip 压缩挡,其包含:

这里描述一下每个档案(绿色)和档案夹(红色)的功用:

·         Manifest 就是 Android Manifest,它就像 App 的身分证,里面提供名字、图示、版本、权限、硬件限制和其他组件等信息。当系统在安装或升级 App 时也会需要它。

·         程序被编译后都放在 classes.dex,你可以有一个以上的 classes.dex

·         lib 档案夹装着的是函式库

·         res assets 档案夹装着的是静态资源文件

·         resources.arsc Android 的特殊档案,由编译 R.java 产生的,它是用来链接程序和静态资源。

·         META-INF 档案夹装着的是中介数据(metadata)但我们在这里不需要。

以上就是当你解压缩 APK 后会看到的东西。

我们开始来看第一个档案:classes.dex


反编译程序代码

dex  Dalvik Executable 的缩写(Dalvik  Android 系统里的旧版虚拟器,现在新的叫 ART,全名是 Android Runtime,但档案的扩展名仍用 dex)。这是Android 系统专用的文件格式,而且不容易读取其内容。有两个方式可以做到:第一种使用 smali 反汇编程序将 dex 档案内容转成可易于阅读的bytecode,第二种使用 dex2jar 将内容转成传统的 Java 档案。

我们打算使用第二种方法将 dex 转成 jar (jar 是一种压缩文件,其包含所有的 .class 档案)。接下来我们需要反编译工具再将 .class 档案转换成 Java 程序代码。有很多现成的反编译工具,有各自个优缺点,我们使用 Jadx,你可以使用你惯用的,甚至可以找到在线版的反编译程序

我们现在有的大部份易于阅读的Java程序代码,受限于反编译程序的限制,仍然有一部分的程序代码无法被看见。事实上,还有一个反编译程序 Procyon,可能可以有更好的输出结果。

有一点很重要:我们得到的程序代码并不是当初开发者所写的原始码,就像使用 Google 翻译将英文翻成法文后,再翻回英文,你会得到另一串新的英文。原因是当要翻成法文时,根据英文的内容会针对单字或词组决定最佳的对应词或句,再次翻回英文时,根据法文的内容会再做一次决定最佳的对应词或句的运算,这来回的过程各自独立,结果就会产生差异。这和程序代码的逆向工程的结果很像:我们反编译出来的程序代码,其运作的行为会跟原始码一样,但程序代码内容不会完全跟原始码一样,差异可能有函数名称、变量名称和批注。

幸运的是,我们可以清楚得知app里所用到的函式库:

·         Android support libraries : support-v4, appcompat and support-annotations

·         Various parts of the Play Services

·         Jackson (JSON parser) : core, annotations and databind

·         Gson (JSON parser)

·         Otto (event bus)

·         Dagger (dependency injection)

·         RxJava / RxAndroid (reactive programming)

·         Apache Commons IO (utilities for I/O)

·         AdMob, now declared as firebase-ads (ads, analytics)

·         Upsight (analytics)

·         Crittercism, now known as Apteligent (monitoring and crash reporting)

·         Unity classes.jar (interaction between the Android framework and Unity)

·         Lunar Mobile Console (Unity logger for Android)

·         Voxelbusters’s Cross Platform Native Plugins (mainly used for sharing from Unity)

·         Google VR SDK

如果你是 Android 开发者的话,可以会觉得奇怪:为什么有两个 JSON parser?一个做 reactive programming(译注:作者Ray Shihreactive programming的见解),一个做 event bus?这其实是 transitive dependencies:函式库会有相依性才能运作,但写程序有时候只会呼叫到其中几个函式库,你可以到这里了解我们如何分析 transitive dependencies

清理掉一些没有呼叫的函式库后,得到一份更简洁的清单:

·         Gson

·         Crittercism

·         Upsight

·         Admob/firebase-ads

·         Google VR SDK, Unity and associated

另外有种相依性则是由外到内,一层层包裹起来,像是 Upsight 里头包了大量的函式库,列出清单和函式数目:RxAndroid (4k), Dagger (~200), Commons IO (1k), Jackson (10k), Otto (~50), various Play Services (12k), 自己开发的函式 (3k)

 --- com.upsight.android:all:4.1.3
|     --- io.reactivex:rxandroid:1.0.1
|    |    \--- io.reactivex:rxjava:1.0.13
|     --- com.upsight.android:analytics:4.1.3
|    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |     --- com.google.dagger:dagger:2.0.2
|    |    |    \--- javax.inject:javax.inject:1
|    |     --- com.upsight.android:core:4.1.3
|    |    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |    |     --- com.google.dagger:dagger:2.0.2 (*)
|    |    |     --- commons-io:commons-io:2.4
|    |    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3
|    |    |    |     --- com.fasterxml.jackson.core:jackson-annotations:2.6.0
|    |    |    |    \--- com.fasterxml.jackson.core:jackson-core:2.6.3
|    |    |    \--- com.squareup:otto:1.3.8
|    |     --- commons-io:commons-io:2.4
|    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    |    \--- com.squareup:otto:1.3.8
|     --- com.google.dagger:dagger:2.0.2 (*)
|     --- com.upsight.android:google-advertising-id:4.1.3
|    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |     --- com.upsight.android:analytics:4.1.3 (*)
|    |     --- com.google.dagger:dagger:2.0.2 (*)
|    |     --- com.android.support:support-v4:23.2.1 (*)
|    |     --- com.google.android.gms:play-services-ads:8.4.0 -> 9.2.0 (*)
|    |     --- com.upsight.android:core:4.1.3 (*)
|    |     --- com.upsight.android:marketing:4.1.3
|    |    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |    |     --- com.upsight.android:analytics:4.1.3 (*)
|    |    |     --- com.google.dagger:dagger:2.0.2 (*)
|    |    |     --- com.upsight.android:core:4.1.3 (*)
|    |    |     --- commons-io:commons-io:2.4
|    |    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    |    |    \--- com.squareup:otto:1.3.8
|    |     --- commons-io:commons-io:2.4
|    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    |    \--- com.squareup:otto:1.3.8
|     --- com.upsight.android:google-push-services:4.1.3
|    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |     --- com.upsight.android:analytics:4.1.3 (*)
|    |     --- com.google.dagger:dagger:2.0.2 (*)
|    |     --- com.android.support:support-v4:23.2.1 (*)
|    |     --- com.google.android.gms:play-services-gcm:8.4.0 -> 9.2.0 (*)
|    |     --- com.upsight.android:core:4.1.3 (*)
|    |     --- com.upsight.android:marketing:4.1.3 (*)
|    |     --- commons-io:commons-io:2.4
|    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    |    \--- com.squareup:otto:1.3.8
|     --- com.upsight.android:managed-variables:4.1.3
|    |     --- io.reactivex:rxandroid:1.0.1 (*)
|    |     --- com.upsight.android:analytics:4.1.3 (*)
|    |     --- com.google.dagger:dagger:2.0.2 (*)
|    |     --- com.upsight.android:core:4.1.3 (*)
|    |     --- commons-io:commons-io:2.4
|    |     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    |    \--- com.squareup:otto:1.3.8
|     --- com.upsight.android:marketing:4.1.3 (*)
|     --- com.upsight.android:core:4.1.3 (*)
|     --- commons-io:commons-io:2.4
|     --- com.fasterxml.jackson.core:jackson-databind:2.6.3 (*)
|    \--- com.squareup:otto:1.3.8

这表示你有数以千计的函式要分析。

虽然函式库很多,但去掉了分析用工具、监测工具、当机回报和广告,最主要的剩下 Pokémon Go 用的游戏引擎 Unity。这就是为什么你打开app会有一个 Niantic 的标志,为的是让用户稍待片刻让 Unity 引擎启动,然后再出现一个进度条,显示引擎读取静态文件的状态。你所有的互动操作都是在 Unity 的执行环境里,所以不会看到任何 Android 原生的界面。

另一个受到注意的是:VR SDK。在 Pokémon Go Beta 的阶段,有人用跟我们一样的方法发现 Cardboard/VR 等字眼在程序代码里,在正式版的 app使用声明里也提到Cardboard。但从我的分析来看,未来并不会有 VR Cardboard 的相应功能。从我们的专业来看,VR SDK 这个函式库只是用来串接 Android framework Unity,但如果真的要和 Cardboard 整合,就必须让 Android framework Unity 可以交互沟通,因此必须引用大量的开源程序才能做到。但我们从现在的程序代码中并没有看到。

到这里,我们花了很多时间在清理程序,但还没有一个真正能执行项目,因为还需要 resources assets,让我们继续往下看。


静态资源文件

要得到 resources assets 比原始码还简单。事实上,assets会原封不动地被打包进 App,几乎所有的 assets 都用在 Unity,所以我们暂且先不管它们。Resources 比较有趣,它们包括了iconslayouts wordingResources 的内容会在 build 后变得不易于阅读或编辑,例如 xml layouts 档案会转为二进制格式,9-patches 档案则失去判读缩放的依据。

好消息是有个工具叫 apktool,它可以帮助我们将 Manifest resources 档案转会成易于阅读的格式内容,并且产生一个可执行的 Android 项目。一开始我们没有用是因为 apktool 会将 classes.dex 转成 smali 档案,而不是我们要的 Java 程序代码。

现在有了反编译的 resources Manifest,另外也有 assets,再加上早些将程序代码先清理干净,我们可以开始建立和执行一个完整的 Android 项目了。


编译和执行

为了产生 APK,我们要编译的Java程序代码前,需要建立一个 Android 项目和build 的指令。如果你还记得的话,因为这些东西并不在 APK 里,所以我们得自己来,靠的是:Gradle

其中有一件有趣的事情就是最低 Android 版本需求App Google Play 上的最低需求是 Android KitKat(Android 4.4, API level 19),但在函式库的分析中,Google VR SDK 最高需求也只有到 API level 16(JellyBeans, or Android 4.1),我们不清楚为什么在 Google Play 的声明要高于实际 API 需求3个版本。这么做一开始就排除了20% Android 使用者(根据 Google’s latest numbers ),也许是故意的,也或许是失误。

不过目前最重要的是,我们已经有一个可以执行在手机上的项目了。如果你想要安装这个逆向工程版的 App,建议在你的 build.gradle Manifest components/permissions 里面先改掉 application id,避免和官方版的发生冲突,以确保官方版随时可以更新。

安装成功后,你会发现你卡在登入画面。第一个登入选项是用 Google Sign-In。但是当你点击它时,它会进行验证 App 签署的凭证,显然的是我们并没有凭证,所以跳出错误讯息:GoogleAuthException: INVALID_AUDIENCE。为了避开这个限制,我们得花很大的力气才有办法,所以最简单的做法是直接到 Google Developer Console 申请一个新的 App,这样逆向工程版 App 就可以有自己的凭证了,登入成功后取得 token,但还是不能跟后端做数据交换。

第二个登入选项是透过 Pokémon Trainer Club 申请账号。但因为太多人申请,服务器似乎已经关闭,等它恢复后,我们会再试看看逆向工程版 App 是否可以登入。


分析程序代码

这里开始我们会简短看一下程序代码。虽说这篇文是在讲述逆向工程的概论,但这部分我们会着重在 Pokémon Go App,而且每支 App 的分析可能都不太一样。

我们稍早看到大部份的程序代码都执行在 Unity 引擎中,因为 Unity 是跨平台的,所以这些程序代码可以执行在 iOS Android 上。但有些则是基于Android 原生的功能,例如:

·         Sign-in / Registration (inside the package com.nianticlabs.nia.account)

·         In-App purchases (inside com.nianticlabs.nia.iap)

·         Interaction with Location, Network and Sensors (inside com.nianticlabs.nia.location/ network/sensors)

·         Communication via Bluetooth with the Pokémon Go Plus (inside com.nianticproject.holoholo.sfida)

第一眼看到最有趣的是 location/network/sensors 程序代码(如果你假造你的位置或速度,第一时间知道出现的位置和种类,然后可以抓到更多神奇宝贝的话…)

Pokémon Go Plus 沟通,应该就是当你的手机放在背包或口袋的时候,能通知你附近出现神奇宝贝。这部分程序代码可以和网络请求的分析做结合,让App 只通知你所感兴趣的神奇宝贝,例如你还没搜集到的那只。

稍微看一下与 Pokémon Go Plus 沟通的程序代码:

boolean notifyCancelDowser();
boolean notifyError();
boolean notifyFoundDowser();
boolean notifyNoPokeball();
boolean notifyPokeballShakeAndBroken(String str);
boolean notifyPokemonCaught();
boolean notifyProximityDowser(String str);
boolean notifyReachedPokestop(String str);
boolean notifyReadyForThrowPokeball(String str);
boolean notifyRewardItems(String str);
boolean notifySpawnedLegendaryPokemon(String str);
boolean notifySpawnedPokemon(String str);
boolean notifySpawnedUncaughtPokemon(String str);
boolean notifyStartDowser();

这是非常有价值的数据!你可以打造你自己的装置:


截取网络联机

做逆向工程不代表就要大费周章地去拆解程序代码,你可以从 App 如何和外界事物互动,这个方法适用于任何软件。

App 基本上都会与屏幕连动,来做显示或触控的互动,另外还有:文件系统、传感器、网络等。

这里我们最感兴趣的是网络请求。如我们稍早提到的,游戏最重要的逻辑运算都在服务器上头,App 需要与服务器做数据交换才可以运作,如果能撷取这些传输的资料,我们也许可以不用再透过 App 就可以和服务器沟通。

实际上,Pokémon Go在处理网络请求时,用了一个叫 Optimistic Models 的方法。Optimistic Models 让使用者在app上做一个动作后,不需要等待服务器的响应,就直接往下一动作继续操作,让使用者感觉很流畅。如果后来服务器报错,它才会跳出警示。所以你可以看到当你在传送神奇宝贝的时候,并没有显示任何等待提示。目前 App 在这个机制上还没有运作得很流畅,主要是因为服务器满载,相信接下来几个礼拜会改善。

所以,我们如何撷取网络请求?最简单的方式是在 App 和服务器中间架一个 proxy。可是如果数据被 HTTPS 加密,你只能看到无关紧要的 metadata

有一种方式叫 Man-in-the-Middle 攻击。这种方式是你用 proxy 来骗 App 你是 Server,然后骗 Server 你是 App。当你收到 App 的请求,用你的 app-side key 先解密,再用 server-side key 加密送到 Server 取得回应,再用 server-side key 解密,再用 app-side key 加密送回 App。这样你就可以取得完整的资料,而且 App Server 并不会知道你的存在。

显然,如果故事就这样结束,那所有在网络上的数据都会被看光光。事实上,这些加解密用的 key 是需要被第三方验证过的,就是 Certificate Authorities。你的手机或浏览器只会信任验证过的 key,否则回跳出警告讯息。因为手机是我们自己的,我们可以把 key 先装在手机上,来撷取资料。

有现成的工具可以帮我们完成 proxy 的设置,像 mitmproxy  CharlesCharles 要付费,但有使用接口可以导引我们做设定。下图是App 启动时所截取到的网络请求:

从这里面可以学到很多东西,来看看头几个请求:

·         https://android.clients.google.com/c2dm/register3 : 注册 push notifications

·         https://stats.unity3d.com/HWStatsUpdate.cgi : 可能是一个跟 Unity 有关的分析事件

·         https://bootstrap.upsight-api.com/config/v1/a9cc12f87adc420baf964f187672ecb4/ : Upsight 的第一个分析事件

·         https://appload.ingest.crittercism.com/v0/appload : Crittercism 的第一个分析事件

·         https://pgorelease.nianticlabs.com/plfe/rpc : 底下会详述这项

·         https://play.googleapis.com/log : Play Services 后端沟通

·         http://lh4.ggpht.com/LakctgAXpXwe-3PMCWws8rCoVn1_TmyfAiWjWXm6VtsRjRl5v53n1JrWBumWmldzsBFxIUdRLXgsMewLjuyN: 这是一个对 Picasa 的请求,就是 PokéStop 的图片

·         https://e.crashlytics.com : Crashlytics 沟通,但看起来是失败

·         https://www.google.com/loc/m/api : GPS 位置

我们可以看到 App 很频繁地跟 https://pgorelease.nianticlabs.com/plfe/ 做沟通,而且一个226的数字接在URL后面,我猜这是为了做 Load balancing:也就是第一个请求会被指定到某台服务器去,接下来在同个 session 的所有请求都会导向一样的服务器。

最后,“rpc”这个接在 URL 最后的东西代表 App 是透过 Remote Procedure Call  Server 做沟通,因此所有的请求才都发到同一个 URL,这跟用 REST方式不一样。

看看请求的内容,既不像 JSON,也不是 XML,而且也没有压缩或加密过:所以我们可以清楚看到 UUIDs “pm0015”等字符串,这可能是使用 protocol buffers (或是 flat buffers)做串行化后的格式。Charles 会帮忙整理干净,也可以使用 protocol buffers command line,所以从:

5‚€€€€ÉßÛS#pgorelease.nianticlabs.com/plfe/226:[
@nrÝZ†¡Ï¯½”'ëXÖÐ_}Î~—ñ÷0'@…Ít‘›-C÷‰

整理成:

1: 53
2: 6032429073588813826
3: "pgorelease.nianticlabs.com/plfe/226"
7 {
  1: "nr\026\335Z\206\241\317\257\275\224\'\353X\326\320_}\220
  \316~\227\361\3670\'@\205\315t\221\233-C\367\211\r<j8y\024
  \224\312v\342\2269~\304\202/\036\247\276\361\266,\033s\027\006\f^"
  2: 1468599616357
  3: "$\002\304\337.\034\270\361\214D\251nz\273fM"
}
100 {
}
100 {
}</j8y\024

这是请求 pgorelease.nianticlabs.com/plfe/rpc 返回的内容,其中有一个新的请求端点:pgorelease.nianticlabs.com/plfe/226,是给之后的所有请求使用。

还可以看到很多“\xxx”,这是“octal escaping”。使用译码器,内容从:

nr\026\335Z\206\241\317\257\275\224\'\353X\326\320_}\220
\316~\227\361\3670\'@\205\315t\221\233-C\367\211\r<j8y\024
\224\312v\342\2269~\304\202/\036\247\276\361\266,\033s\027\006\f^</j8y\024

变成:

nr5Z617754\'3X60_}06~7170\'@55t13-C71\r

从结果推测,这像是出现在附近的神奇宝贝的对象列表,每个对象有自己的UUID 和属性(例如pm0015代表pokémon 015号: Beedrill),其他可能是坐标、战斗力和统计数据。我们可以从请求 https://storage.googleapis.com/cloud_assets_pgorelease/bundles/android/pm0126 来证明这个假设,因为这个请求可以得到 pm0126 相关的 assets

继续看其他的请求的返回内容。例如,底下这应该是玩家的相关信息:

100 {
  1: 1
  2 {
    1: 1467925951134
    2: "REDACTED: player name"
    7: "\000\001\003\004\a"
    8 {
      8: 1
    }
    9: 250
    10: 350
    11 {
    }
    12 {
    }
    13 {
    }
    14 {
      1: "POKECOIN"
    }
    14 {
      1: "STARDUST"
      2: 500
    }
  }
}

数字 1467925951134 Unix timestamp,指的是 07/07/2016 21:12,这应该是玩家的注册时间。在请求和返回的内容中,到处都可以看到 timestamp,有的精确度到 millisecond,有的到 nanosecond

再深入些,我们可以看到很多成对的数字,像:0x40486ddc40000000, 0x4002d99520000000。这应该是坐标,但不是被编码成十六进制,而是IEEE 754 doubles。这对十六进制的值转成数字是:

是我们办公室的坐标!我们将可以拿到的所有坐标,猜想它的意义,都标记在地图上:the position of the user (黄色), points of interests / PokéStops (红色) and possible spawn points (绿色)

到目前为止,我们会读取网络交换的数据、串行化的格式,还会分辨一些idtimestampsGPS坐标,其他的留给有兴趣的人研究。


结论:如何避免被逆向工程

看到这里,身为开发者也许会觉得没办法防止被别人做逆向工程分析,其实是有的。

模糊你的 Java 程序代码是第一步:使用 Proguard。它会把所有的 packagefields methods 的名字以随机数取代,让分析更困难。如果你想要对这种 App 做分析,从 framework classes 开始。Proguard 不只用在模糊程序代码,也可以移除没用到的 resources methodsProguard 很好用,我想 Pokémon Go 未来应该会用。

还有一种方式是减少 Java 程序代码,将部分功能改写成 native libraries,这会增加分析的难度,但对开发很不方便,而且有太多的 Java native 串接,会导致效能下降。

我们能截取网络请求是因为 App 没有使用 Certificate pinning。使用 basic Android classes  OkHttp 是很平常的,而且很容易。但就像模糊程序代码,它并不能抵挡偏激的攻击者(因为凭证也可以被逆向工程),但可以拖延他们一些时间。

最后,本文是相当基本的分析,我们没有揭露任何游戏的秘密,公开作弊的方法让游戏产生不公平。但对开发者来说,你必须谨慎防范专业级的黑客。

这里条列一下我们的发现:

·         程序代码没有模糊化,这会很容易进行逆向工程分析。

·         我们可以重建可执行的项目

·         库的依赖管理可以更好

·         未来没有 VR Cardboard 版本的迹象

·         可能可以降低 Android 版本需求

·         我们可以读取跟位置/网络/传感器和Pokémon Go Plus相关的程序代码

·         容易撷取网络请求,因为缺少certificate pinning

·         网络请求是透过 protobuffers-RPC 完成

你可以找到我们的逆向工程版程序代码:Github
与我们联络: Twitter

 

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

0个评论