从原始码了解Pokémon Go
从原始码了解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)
如果你是 Android 开发者的话,可以会觉得奇怪:为什么有两个 JSON parser?一个做 reactive programming(译注:作者Ray Shih对reactive 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 比较有趣,它们包括了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(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 和 Charles。Charles 要付费,但有使用接口可以导引我们做设定。下图是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 (绿色)
到目前为止,我们会读取网络交换的数据、串行化的格式,还会分辨一些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 是很平常的,而且很容易。但就像模糊程序代码,它并不能抵挡偏激的攻击者(因为凭证也可以被逆向工程),但可以拖延他们一些时间。
最后,本文是相当基本的分析,我们没有揭露任何游戏的秘密,公开作弊的方法让游戏产生不公平。但对开发者来说,你必须谨慎防范专业级的黑客。
这里条列一下我们的发现:
· 程序代码没有模糊化,这会很容易进行逆向工程分析。
· 我们可以重建可执行的项目
· 库的依赖管理可以更好
· 未来没有 VR 或 Cardboard 版本的迹象
· 可能可以降低 Android 版本需求
· 我们可以读取跟位置/网络/传感器和Pokémon Go Plus相关的程序代码
· 容易撷取网络请求,因为缺少certificate pinning
· 网络请求是透过 protobuffers-RPC 完成
你可以找到我们的逆向工程版程序代码:Github
与我们联络: Twitter