兩道牆和一個 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 的角度看,這種自給自足就變成了兩道牆:
- 我們的 Wi-Fi proxy 是靠設定手機層級的設定,然後假設 app 會去讀它。Flutter 自己帶了網路功能。
- 我們的 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 的生態系才是擋路的那個:
- Static analysis(靜態分析) —
libapp.so裡有 Firebase plugin 的 channel 名稱,其中flutter.io/firebase_auth負責手機號碼/OTP 登入。這個 app 的登入是接 Firebase 的。 - Dynamic analysis(動態分析) — gost 的 log 顯示 OTP 登入時對同一個 IP 重試了好幾次,那個 IP 是
34.160.81.0。對這個 IP 做 reverse lookup,會解析到googleusercontent.com。 - HTTP headers — 在 iOS(TestFlight)版本上,打到
/verifyCustomToken的 HTTP POST Request 帶著User-Agent: FirebaseAuth.iOS/...跟一個X-Firebase-AppChecktoken,確認這個 app 用的是 Firebase Auth 的 Identity Toolkit endpoint。
我們的解法是把 Google 的流量直接導去 Google,完全不讓它碰到 Burp,OTP 就開始正常運作了。
學到的事:真實的專案裡,懂得在邊界內做事,跟懂得打破邊界一樣重要。
下面是攔截流程的示意圖。

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