从原始码了解PokémonGo

浏览量:593 2020-06-17 点赞:232

从原始码了解PokémonGo

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

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

从原始码了解PokémonGo

最近不知从哪儿冒出来,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 版本进行分析。

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

APK 的内容

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

从原始码了解PokémonGo

这裏描述一下每个档案和档案夹的功用:

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

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

反编译程式码

dex Dalvik Executable 的缩写 。这是 Android 系统专用的档案格式,而且不容易读取其内容。有两个方式可以做到:第一种使用 smali 反组译工具将 dex 档案内容转成可易于阅读的 bytecode,第二种使用 dex2jar 将内容转成传统的 Java 档案。

我们打算使用第二种方法将 dex 转成 jar 档 。接下来我们需要反编译工具再将 .class 档案转换成 Java 程式码。有很多现成的反编译工具,有各自个优缺点,我们使用  Jadx,你可以使用你惯用的,甚至可以找到 线上版的反编译器 。

从原始码了解PokémonGo

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

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

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

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

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

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

+--- 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 比较有趣,它们包括了 icons、layouts 和 wording。Resources 的内容会在 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,但在函式库的分析中,Google VR SDK 最高需求也只有到 API level 16,我们不清楚为什幺在 Google Play 的声明要高于实际 API 需求 3 个版本。这幺做一开始就排除了 20% 的 Android 使用者 ,也许是故意的,也或许是失误。

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

安装成功后,你会发现你卡在登入画面。第一个登入选项是用 Google Sign-In。但是当你点击它时,它会进行验证 App 签署的凭证,显然的是我们并没有凭证,所以跳出错误讯息:

为了避开这个限制,我们得花很大的力气才有办法,所以最简单的做法是直接到 Google Developer Console 申请一个新的 App,这样逆向工程版 App 就可以有自己的凭证了,登入成功后取得 token,但还是不能跟后端做资料交换。

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

分析程式码

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

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

第一眼看到最有趣的是 location/network/sensors 程式码

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

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

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

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

从原始码了解PokémonGo
截取网路连线

做逆向工程不代表就要大费周章地去拆解程式码,你可以从 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 和 Charles。Charles 要付费,但有使用介面可以导引我们做 设定 。下图是 App 启动时所截取到的网路请求:

从原始码了解PokémonGo

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

我们可以看到 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做序列化后的格式。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

这是请求 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\224\312v\342\2269~\304\202/\036\247\276\361\266,\033s\027\006\f^

变成:

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

从结果推测,这像是出现在附近的神奇宝贝的物件列表,每个物件有自己的 UUID 和属性 ,其他可能是座标、战斗力和统计数据。我们可以从请求 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。这对十六进制的值转成数字是:

从原始码了解PokémonGo

是我们办公室的座标!我们将可以拿到的所有座标,猜想它的意义,都标记在地图上:the position of the user , points of interests / PokéStopsand possible spawn points

 

到目前为止,我们会读取网路交换的资料、序列化的格式,还会分辨一些 id、timestamps 和 GPS 座标,其他的留给有兴趣的人研究。

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

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

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

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

我们能截取网路请求是因为 App 没有使用 Certificate pinning。使用 basic Android classes 或 OkHttp 是很平常的,而且很容易。但就像模糊程式码,它并不能抵挡偏激的攻击者 ,但可以拖延他们一些时间。

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

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

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

图文推荐