從原始碼了解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 開發者的話,可以會覺得奇怪:為什麼有兩個 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 比較有趣,它們包括了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 的設置,像 mitmproxyCharles。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÷‰
<j8y”Êvâ–9~Ă/§¾ñ¶,s^å†ïúÞ*$Äß.¸ñŒD©nz»fM¢¢

整理成:

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 {
}

這是請求 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^

變成:

nr5Z617754\'3X60_}06~7170\'@55t13-C71\r<j8y42v269~42/7616,s\f^

從結果推測,這像是出現在附近的神奇寶貝的物件列表,每個物件有自己的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 classesOkHttp 是很平常的,而且很容易。但就像模糊程式碼,它並不能抵擋偏激的攻擊者(因為憑證也可以被逆向工程),但可以拖延他們一些時間。

最後,本文是相當基本的分析,我們沒有揭露任何遊戲的秘密,公開作弊的方法讓遊戲產生不公平。但對開發者來說,你必須謹慎防範專業級的駭客。

這裡條列一下我們的發現:

  • 程式碼沒有模糊化,這會很容易進行逆向工程分析。
  • 我們可以重建可執行的專案
  • 庫的依賴管理可以更好
  • 未來沒有 VR 或 Cardboard 版本的跡象
  • 可能可以降低 Android 版本需求
  • 我們可以讀取跟位置/網路/感測器和Pokémon Go Plus相關的程式碼
  • 容易擷取網路請求,因為缺少certificate pinning
  • 網路請求是透過 protobuffers-RPC 完成

你可以找到我們的逆向工程版程式碼:Github
與我們聯絡: Twitter