從原始碼了解 Pokémon Go

原文為《Unbundling Pokémon Go》。譯者簡志偉,為軟體開發者,譯文刊載於 Medium ,INSIDE 獲授權轉載。
最近 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 檔案,而取得 Pokémon Go 的 APK 檔案並不困難,這裡就不詳述。請注意安裝來源不明的 APK 會有很大的安全風險,但其實 Google Play 會對 App 做一些分析以降低風險,因此一般人最好還是透過 Google Play 下載安裝 App。但對於逆向工程來說,最喜歡這些惡意的 APK,因為很有趣。在這裡,我們是針對 7/7 釋出的 Pokémon Go 0.29.0 版本進行分析。
先講一下,做了逆向工程後,我們仍然會看不到一些東西:
我們來看一下 APK 的內部構造。事實上,APK 只是一個 zip 壓縮擋,其包含:
這裏描述一下每個檔案 (綠色) 和檔案夾 (紅色) 的功用:
以上就是當你解壓縮 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 Shih 對 reactive programming 的見解),一個做 event bus?這其實是 transitive dependencies:函式庫會有相依性才能運作,但寫程式有時候只會呼叫到其中幾個函式庫,你可以到 這裡 了解我們如何分析 transitive dependencies。
清理掉一些沒有呼叫的函式庫後,得到一份更簡潔的清單:
另外有種相依性則是由外到內,一層層包裹起來,像是 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 原生的功能,例如:
第一眼看到最有趣的是 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 啟動時所截取到的網路請求:
從這裡面可以學到很多東西,來看看頭幾個請求:
我們可以看到 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 classes 或 OkHttp 是很平常的,而且很容易。但就像模糊程式碼,它並不能抵擋偏激的攻擊者 (因為憑證也可以被逆向工程),但可以拖延他們一些時間。
最後,本文是相當基本的分析,我們沒有揭露任何遊戲的秘密,公開作弊的方法讓遊戲產生不公平。但對開發者來說,你必須謹慎防範專業級的駭客。
這裡條列一下我們的發現: