← 回到 nvnda.dev EN
閱讀時間 5 分鐘

兩道牆和一個 Google:Flutter App 滲透測試

Flutter 不走你的 proxy,也不信任你的憑證。這篇講我們最後還是怎麼把流量攔下來,以及為什麼最難的那道牆,偏偏是我們不被允許打破的。

「就讓 Google 做它的事吧。」我這樣跟團隊說。當時我們正在追一個 OTP 的問題:只要我們攔截 app 的流量,OTP 就怎樣都過不了。

目標是一個 Flutter app,它的登入流程裡某個環節依賴一個 Google 服務,而這個服務不在我的測試範圍內,我本來就打不破,也拿不掉。我的攔截在我沒察覺的情況下踩到了它。一旦發現它在那裡,修法反而是最無聊的部分:別碰 Google 的流量,讓它原封不動地直達 Google,然後繼續監聽它周圍的所有東西。

Flutter 自己處理網路連線,Dart 從來不看手機的 proxy 設定。所以傳統做法,也就是設定 Wi-Fi proxy、在手機上安裝 Burp 的 CA,在 Flutter 上行不通;那些 request 會直接打到伺服器,根本不會經過 Burp。這就是為什麼 Chrome 跟 Safari 的流量照預期出現在 Burp 裡,Flutter app 卻一點聲音都沒有,而且從頭到尾都沒有 crash。

這個目標是不一樣的戰場,所以需要不一樣的工具。

這篇講的是每個工具「為什麼」需要。如果你只想要一步一步的指令 playbook,在這裡:Flutter 攔截 playbook →

認識 Flutter 跟 Dart

如果你熟悉 web application framework,我會說 Flutter 跟 Dart 就像是 web 世界裡的 WordPress 跟 PHP。Flutter 是讓開發者更容易做出 app 的 framework,Dart 則是每個 Flutter app 底層使用的程式語言。

你可以不用 Flutter 寫 Dart,但不能不用 Dart 寫 Flutter。

開發者喜歡 Flutter 的原因是它的跨平台能力:寫一次,iOS 跟 Android 都能跑。如果 iOS 的 Swift 跟 Android 的 Kotlin 有一個兩種語言都精通的翻譯員,那就是 Dart。

因為 Flutter 要在兩個平台上跑,它沒辦法像 Swift 跟 Kotlin 那樣依賴各平台的原生 UI。它自給自足到自己帶了 rendering engine 跟 runtime,還把自己的網路跟 TLS stack 打包在 app 裡面。

換成 pentester 的角度看,這種自給自足就變成了兩道牆:

  1. 我們的 Wi-Fi proxy 是靠設定手機層級的設定,然後假設 app 會去讀它。Flutter 自己帶了網路功能。
  2. 我們的 CA 信任是靠把憑證加進手機的 trust store(信任儲存區),然後假設 app 會去那裡檢查。Flutter 自己帶了 trust store。

要用一般方式攔截流量,兩件事必須成立:app 要走你的 proxy,而且要信任你的憑證。這就是 Flutter 築起的兩道牆,每一道單獨都足以打壞 pentester 的工作流程。

我們來把兩道牆一一拆掉,讓流量乖乖走我們的 proxy。

第一道牆:自己的網路功能

因為 Flutter 自己帶了網路功能,它會無視手機的 Wi-Fi proxy。Dart 發出的 request 雖然還是一般的 L7 HTTP,卻會直接打到 origin server,不會繞道經過 Burp。

Bypass:往下到 L3 去抓,不管 app 決定理會哪個 proxy,每個封包都會在那裡出現。我們用 PCAPdroid 跟 gost,把目標的流量導到 Burp,範圍外的流量則完全不動。

第二道牆:自己的 Trust Store

Flutter app 的 HTTPS 走的是打包在 libflutter.so 裡的 BoringSSL。它有自己的憑證檢查 routine,經過 Burp 的流量會在這一關失敗。

Bypass:我們用 reFlutter patch 掉 libflutter.so 裡的驗證 routine,讓它永遠回傳 valid。

reFlutter 只對沒加密的 APK 有效;packed(加殼)的 binary 讓 patch 沒有地方可以下手。

沒有第三道牆:不在範圍內,卻擋在路上

就算 app 的流量已經進到 Burp,我還是撞到另一個障礙。測試 OTP 功能時,伺服器一直拒絕我的驗證碼,即使 HTTP Response 有回傳一個唯一的 access token。把 proxy 拿掉,app 就一切正常。

我們最早還在 iOS 上用 mitmproxy 的時候,第一個猜測是 mitmproxy 可能重複送了 request。但如果 request 被重複送出,我的信箱應該會收到兩組 OTP 碼,結果只有一組,所以這個可能被排除了。

有三條線索都指向 Google 的生態系才是擋路的那個:

  1. Static analysis(靜態分析) — libapp.so 裡有 Firebase plugin 的 channel 名稱,其中 flutter.io/firebase_auth 負責手機號碼/OTP 登入。這個 app 的登入是接 Firebase 的。
  2. Dynamic analysis(動態分析) — gost 的 log 顯示 OTP 登入時對同一個 IP 重試了好幾次,那個 IP 是 34.160.81.0。對這個 IP 做 reverse lookup,會解析到 googleusercontent.com。
  3. HTTP headers — 在 iOS(TestFlight)版本上,打到 /verifyCustomToken 的 HTTP POST Request 帶著 User-Agent: FirebaseAuth.iOS/... 跟一個 X-Firebase-AppCheck token,確認這個 app 用的是 Firebase Auth 的 Identity Toolkit endpoint。

我們的解法是把 Google 的流量直接導去 Google,完全不讓它碰到 Burp,OTP 就開始正常運作了。

學到的事:真實的專案裡,懂得在邊界內做事,跟懂得打破邊界一樣重要。

下面是攔截流程的示意圖。

完整的 Flutter 攔截鏈:兩道牆都拆了,Google/Firebase 的流量直接放行,不進 Burp

我們怎麼繞過這個 app 的完整技術細節在這裡。謝謝 Zet,在 brainstorm root cause 的時候幫了大忙,也推薦了我們用到的一些工具。